pi-project-switcher 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/index.ts +82 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch be
|
|
|
10
10
|
- persists across reloads (session entry)
|
|
11
11
|
- sets the session display name
|
|
12
12
|
- injects the project path into every agent turn's system prompt, so file operations default to the active project
|
|
13
|
+
- **if the project doesn't exist yet**, offers to create the folder and switch to it (confirmation dialog on dialog-capable surfaces; use `/project <name>!` to skip the dialog — e.g. on headless/RPC surfaces). Unsafe names (path segments, `..`, hidden, absolute) are never created.
|
|
13
14
|
- **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
15
|
- **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
|
|
15
16
|
|
package/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
22
22
|
import { execSync } from "node:child_process";
|
|
23
23
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
25
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
26
26
|
|
|
27
27
|
const HOME = homedir();
|
|
28
28
|
const ENTRY_TYPE = "project-switcher-state";
|
|
@@ -189,6 +189,26 @@ function isValidProject(name: string): boolean {
|
|
|
189
189
|
return projects.includes(name);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/** True when a name is safe to materialize as a single directory under the base dir. */
|
|
193
|
+
function isSafeProjectName(name: string): boolean {
|
|
194
|
+
if (!name || name === "." || name === "..") {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (name.startsWith(".")) {
|
|
198
|
+
return false; // hidden
|
|
199
|
+
}
|
|
200
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
201
|
+
return false; // no path segments
|
|
202
|
+
}
|
|
203
|
+
if (name.includes("..")) {
|
|
204
|
+
return false; // no traversal
|
|
205
|
+
}
|
|
206
|
+
if (isAbsolute(name)) {
|
|
207
|
+
return false; // absolute paths
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
192
212
|
export default function (pi: ExtensionAPI) {
|
|
193
213
|
// ── Restore state on session start ──────────────────────────────────────
|
|
194
214
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -255,7 +275,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
255
275
|
},
|
|
256
276
|
|
|
257
277
|
handler: async (args, ctx) => {
|
|
258
|
-
|
|
278
|
+
let name = args.trim();
|
|
279
|
+
|
|
280
|
+
// ── Explicit create opt-in: trailing "!" on the project name ────────
|
|
281
|
+
const createOptIn = name.endsWith("!");
|
|
282
|
+
if (createOptIn) {
|
|
283
|
+
name = name.slice(0, -1).trim();
|
|
284
|
+
}
|
|
259
285
|
|
|
260
286
|
// ── No arg: show status ──────────────────────────────────────────────
|
|
261
287
|
if (!name) {
|
|
@@ -280,12 +306,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
280
306
|
|
|
281
307
|
// ── Switch project ────────────────────────────────────────────────────
|
|
282
308
|
if (!isValidProject(name)) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
309
|
+
// Offer to create the folder and switch to it (never silently)
|
|
310
|
+
if (isSafeProjectName(name)) {
|
|
311
|
+
let create = false;
|
|
312
|
+
if (createOptIn) {
|
|
313
|
+
create = true;
|
|
314
|
+
} else if (ctx.hasUI) {
|
|
315
|
+
try {
|
|
316
|
+
create = await ctx.ui.confirm(
|
|
317
|
+
"Create project?",
|
|
318
|
+
`Project "${name}" does not exist. Create ${join(getConfig().baseDir, name)} and switch to it?`
|
|
319
|
+
);
|
|
320
|
+
} catch {
|
|
321
|
+
create = false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (create) {
|
|
326
|
+
const newPath = projectPath(name);
|
|
327
|
+
try {
|
|
328
|
+
mkdirSync(newPath, { recursive: false });
|
|
329
|
+
ctx.ui.notify(`Created project folder: ${newPath}`, "info");
|
|
330
|
+
} catch (err: any) {
|
|
331
|
+
ctx.ui.notify(
|
|
332
|
+
`Failed to create project folder ${newPath}: ${err?.message ?? err}`,
|
|
333
|
+
"error"
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// fall through: the folder now exists and the switch proceeds below
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!isValidProject(name)) {
|
|
342
|
+
const available = discoverProjects(getConfig().baseDir).join(", ");
|
|
343
|
+
ctx.ui.notify(
|
|
344
|
+
`Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
|
|
345
|
+
"warning"
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
289
349
|
}
|
|
290
350
|
|
|
291
351
|
if (name === activeProject) {
|
|
@@ -327,7 +387,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
327
387
|
const branch = getGitBranch(path);
|
|
328
388
|
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
329
389
|
ctx.ui.notify(
|
|
330
|
-
`Switched to ${name}
|
|
390
|
+
`Switched to ${name} — session restored: ${basename(targetSession)}\n` +
|
|
391
|
+
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
|
|
331
392
|
"info"
|
|
332
393
|
);
|
|
333
394
|
return;
|
|
@@ -347,7 +408,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
347
408
|
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
348
409
|
const fromStr = previous ? ` (was: ${previous})` : "";
|
|
349
410
|
|
|
350
|
-
|
|
411
|
+
// Current session identity — still valid on this path (no session replacement)
|
|
412
|
+
const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
|
|
413
|
+
const sessionLine = currentFile
|
|
414
|
+
? `Continuing session: ${basename(currentFile)}`
|
|
415
|
+
: "Continuing current session";
|
|
416
|
+
|
|
417
|
+
ctx.ui.notify(
|
|
418
|
+
`Switched to ${name}${fromStr} — first session in this project\n` +
|
|
419
|
+
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}\n` +
|
|
420
|
+
sessionLine,
|
|
421
|
+
"info"
|
|
422
|
+
);
|
|
351
423
|
|
|
352
424
|
// Announce to the agent so it operates in the new context
|
|
353
425
|
await ctx.waitForIdle();
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name": "pi-project-switcher", "version": "0.
|
|
1
|
+
{"name": "pi-project-switcher", "version": "0.5.0", "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"}}
|