pi-project-switcher 0.4.0 → 0.6.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 +7 -2
- package/index.ts +319 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,12 +4,17 @@ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch be
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
- **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one
|
|
7
|
+
- **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one. The output adapts to the surface:
|
|
8
|
+
- **native TUI**: a selection dialog; picking a project switches to it (dismissing shows the plain list)
|
|
9
|
+
- **Telegram bridge**: the reply in the chat shows one tappable button per project; clicking a button is exactly like typing `/project <name>`
|
|
10
|
+
- **other surfaces** (rpc/json/print): plain text list
|
|
11
|
+
- Note: for the Telegram buttons to work, this extension must be loaded **before** `pi-telegram-command-bridge` (package order in `~/.pi/agent/settings.json`) — the switcher needs to see the raw `[telegram] /project` dispatch before the bridge re-dispatches it.
|
|
8
12
|
- **`/project <name>`** — switch the active project:
|
|
9
13
|
- restores the project's last session if one is stored (see below)
|
|
10
14
|
- persists across reloads (session entry)
|
|
11
15
|
- sets the session display name
|
|
12
16
|
- injects the project path into every agent turn's system prompt, so file operations default to the active project
|
|
17
|
+
- **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
18
|
- **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
19
|
- **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
|
|
15
20
|
|
|
@@ -50,7 +55,7 @@ Single-file TypeScript extension (`index.ts`), loaded directly by pi via jiti
|
|
|
50
55
|
|
|
51
56
|
```bash
|
|
52
57
|
npm install
|
|
53
|
-
npm test # vitest (
|
|
58
|
+
npm test # vitest (45 tests)
|
|
54
59
|
npm run typecheck
|
|
55
60
|
|
|
56
61
|
# Run once without installing
|
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";
|
|
@@ -173,6 +173,92 @@ function rememberSessionFile(project: string, absSessionFile: string): void {
|
|
|
173
173
|
let activeProject: string | null = null;
|
|
174
174
|
let config: Config | null = null;
|
|
175
175
|
|
|
176
|
+
// ── Telegram origin detection (input event + flag) ───────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Matches a first line dispatched from the pi-telegram bridge: a
|
|
180
|
+
* `[telegram]` tag (optionally with attributes like `[telegram|thread:x]`)
|
|
181
|
+
* followed by a bare `/project` with no arguments.
|
|
182
|
+
*/
|
|
183
|
+
const TELEGRAM_STATUS_RE = /^\[telegram(?:\|[^\]]*)?\]\s*\/project\s*$/;
|
|
184
|
+
|
|
185
|
+
/** How long an armed flag stays valid (ms). Guards against stale flags
|
|
186
|
+
* from dispatches that never reached the command handler. */
|
|
187
|
+
const TELEGRAM_FLAG_TTL_MS = 30_000;
|
|
188
|
+
|
|
189
|
+
/** Armed-at timestamp of a pending Telegram-originated status request, or null. */
|
|
190
|
+
let telegramStatusFlag: number | null = null;
|
|
191
|
+
|
|
192
|
+
function armTelegramStatusFlag(): void {
|
|
193
|
+
telegramStatusFlag = Date.now();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Consume the flag: returns true (and clears it) when armed and not expired. */
|
|
197
|
+
function consumeTelegramStatusFlag(): boolean {
|
|
198
|
+
if (telegramStatusFlag === null) return false;
|
|
199
|
+
const armed = telegramStatusFlag;
|
|
200
|
+
telegramStatusFlag = null;
|
|
201
|
+
return Date.now() - armed <= TELEGRAM_FLAG_TTL_MS;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clearTelegramStatusFlag(): void {
|
|
205
|
+
telegramStatusFlag = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A project name that could alter button markup or the button prompt is
|
|
210
|
+
* rendered as plain list text only (never gets a button cell).
|
|
211
|
+
*/
|
|
212
|
+
function isUnsafeButtonName(name: string): boolean {
|
|
213
|
+
return /[{}|`\\\n]/.test(name);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build the follow-up prompt for a Telegram-originated status request.
|
|
218
|
+
* Embeds the authoritative project list and a pre-rendered telegram_button
|
|
219
|
+
* block the agent copies verbatim into its reply.
|
|
220
|
+
*/
|
|
221
|
+
function buildTelegramStatusPrompt(projects: string[]): string {
|
|
222
|
+
const ordered = [...projects];
|
|
223
|
+
if (activeProject) {
|
|
224
|
+
const idx = ordered.indexOf(activeProject);
|
|
225
|
+
if (idx > 0) {
|
|
226
|
+
ordered.splice(idx, 1);
|
|
227
|
+
ordered.unshift(activeProject);
|
|
228
|
+
} else if (idx === -1) {
|
|
229
|
+
ordered.unshift(activeProject);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const listLines = ordered
|
|
234
|
+
.map((p) => ` ${p}${p === activeProject ? " ◀ active" : ""}`)
|
|
235
|
+
.join("\n");
|
|
236
|
+
|
|
237
|
+
const buttonCells = ordered
|
|
238
|
+
.filter((p) => !isUnsafeButtonName(p))
|
|
239
|
+
.map((p) =>
|
|
240
|
+
p === activeProject
|
|
241
|
+
? `{✅ ${p} (active)|/project ${p}}`
|
|
242
|
+
: `{📁 ${p}|/project ${p}}`,
|
|
243
|
+
)
|
|
244
|
+
.join("\n");
|
|
245
|
+
const buttonBlock =
|
|
246
|
+
buttonCells.length > 0
|
|
247
|
+
? "\n\n```telegram_button\n" + buttonCells + "\n```"
|
|
248
|
+
: "";
|
|
249
|
+
|
|
250
|
+
const current = activeProject ? `Active: ${activeProject}` : "No project active";
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
`[project-switcher] The user ran /project via Telegram and expects the project list in the chat.\n` +
|
|
254
|
+
`Authoritative list (do not re-derive, do not add or remove entries):\n` +
|
|
255
|
+
`${current}\n\n${listLines}\n\n` +
|
|
256
|
+
`Reply in the chat with exactly this list (keep the active marker) followed by the button block below, ` +
|
|
257
|
+
`copied verbatim. Do not run any command, do not switch projects yourself, and add nothing else.${buttonBlock}`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
176
262
|
function getConfig(): Config {
|
|
177
263
|
if (!config) {
|
|
178
264
|
config = loadConfig();
|
|
@@ -189,9 +275,50 @@ function isValidProject(name: string): boolean {
|
|
|
189
275
|
return projects.includes(name);
|
|
190
276
|
}
|
|
191
277
|
|
|
278
|
+
/** True when a name is safe to materialize as a single directory under the base dir. */
|
|
279
|
+
function isSafeProjectName(name: string): boolean {
|
|
280
|
+
if (!name || name === "." || name === "..") {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
if (name.startsWith(".")) {
|
|
284
|
+
return false; // hidden
|
|
285
|
+
}
|
|
286
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
287
|
+
return false; // no path segments
|
|
288
|
+
}
|
|
289
|
+
if (name.includes("..")) {
|
|
290
|
+
return false; // no traversal
|
|
291
|
+
}
|
|
292
|
+
if (isAbsolute(name)) {
|
|
293
|
+
return false; // absolute paths
|
|
294
|
+
}
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Plain text project list via the local UI notification channel —
|
|
300
|
+
* the unchanged default output for non-Telegram surfaces.
|
|
301
|
+
*/
|
|
302
|
+
function notifyPlainProjectList(ctx: any, projects: string[]): void {
|
|
303
|
+
const current = activeProject ? `Active: ${activeProject}` : "No project active";
|
|
304
|
+
const lines = projects.map((p) => {
|
|
305
|
+
const branch = getGitBranch(projectPath(p));
|
|
306
|
+
const branchStr = branch ? ` [${branch}]` : "";
|
|
307
|
+
const marker = p === activeProject ? " ◀ active" : "";
|
|
308
|
+
return ` ${p}${branchStr}${marker}`;
|
|
309
|
+
});
|
|
310
|
+
ctx.ui.notify(
|
|
311
|
+
`${current}\n\nProjects under ${getConfig().baseDir}:\n${lines.join("\n")}`,
|
|
312
|
+
"info"
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
192
316
|
export default function (pi: ExtensionAPI) {
|
|
193
317
|
// ── Restore state on session start ──────────────────────────────────────
|
|
194
318
|
pi.on("session_start", async (_event, ctx) => {
|
|
319
|
+
// Session switches reset any pending Telegram status flag
|
|
320
|
+
clearTelegramStatusFlag();
|
|
321
|
+
|
|
195
322
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
196
323
|
if (
|
|
197
324
|
entry.type === "custom" &&
|
|
@@ -224,6 +351,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
224
351
|
}
|
|
225
352
|
});
|
|
226
353
|
|
|
354
|
+
// ── Telegram origin detection (input event) ────────────────────────────────
|
|
355
|
+
//
|
|
356
|
+
// Prompts dispatched from the pi-telegram queue arrive with source
|
|
357
|
+
// "extension" and a `[telegram]`-tagged first line, BEFORE the command
|
|
358
|
+
// bridge re-dispatches the bare command line. Recognizing the raw
|
|
359
|
+
// dispatch here is the only reliable origin signal; the re-dispatched
|
|
360
|
+
// text is indistinguishable from a native TUI invocation.
|
|
361
|
+
//
|
|
362
|
+
// Requires this extension to be listed BEFORE the command bridge in the
|
|
363
|
+
// package order (the bridge's "handled" result would short-circuit the
|
|
364
|
+
// input chain before this handler runs otherwise). Wrong order degrades
|
|
365
|
+
// gracefully: the flag is never armed and the status falls back to the
|
|
366
|
+
// plain list.
|
|
367
|
+
pi.on("input", async (event) => {
|
|
368
|
+
if (event.source !== "extension") return;
|
|
369
|
+
const firstLine = event.text.split("\n", 1)[0];
|
|
370
|
+
if (TELEGRAM_STATUS_RE.test(firstLine.trim())) {
|
|
371
|
+
armTelegramStatusFlag();
|
|
372
|
+
}
|
|
373
|
+
// Never transform or handle: the command bridge owns the re-dispatch.
|
|
374
|
+
});
|
|
375
|
+
|
|
227
376
|
// ── Inject project context into every agent turn ─────────────────────────
|
|
228
377
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
229
378
|
if (!activeProject || !isValidProject(activeProject)) {
|
|
@@ -242,43 +391,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
242
391
|
};
|
|
243
392
|
});
|
|
244
393
|
|
|
245
|
-
// ──
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
394
|
+
// ── Shared switch flow ──────────────────────────────────────────────────
|
|
395
|
+
//
|
|
396
|
+
// Single implementation of /project <name> semantics, used by the typed
|
|
397
|
+
// argument path and the TUI selection dialog. `createOptIn` (trailing
|
|
398
|
+
// "!") only applies to the typed path; dialog users confirm interactively.
|
|
399
|
+
const switchToProject = async (rawName: string, ctx: any, createOptIn: boolean): Promise<void> => {
|
|
400
|
+
const name = rawName;
|
|
401
|
+
|
|
402
|
+
if (!isValidProject(name)) {
|
|
403
|
+
// Offer to create the folder and switch to it (never silently)
|
|
404
|
+
if (isSafeProjectName(name)) {
|
|
405
|
+
let create = false;
|
|
406
|
+
if (createOptIn) {
|
|
407
|
+
create = true;
|
|
408
|
+
} else if (ctx.hasUI) {
|
|
409
|
+
try {
|
|
410
|
+
create = await ctx.ui.confirm(
|
|
411
|
+
"Create project?",
|
|
412
|
+
`Project "${name}" does not exist. Create ${join(getConfig().baseDir, name)} and switch to it?`
|
|
413
|
+
);
|
|
414
|
+
} catch {
|
|
415
|
+
create = false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
259
418
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
419
|
+
if (create) {
|
|
420
|
+
const newPath = projectPath(name);
|
|
421
|
+
try {
|
|
422
|
+
mkdirSync(newPath, { recursive: false });
|
|
423
|
+
ctx.ui.notify(`Created project folder: ${newPath}`, "info");
|
|
424
|
+
} catch (err: any) {
|
|
425
|
+
ctx.ui.notify(
|
|
426
|
+
`Failed to create project folder ${newPath}: ${err?.message ?? err}`,
|
|
427
|
+
"error"
|
|
428
|
+
);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// fall through: the folder now exists and the switch proceeds below
|
|
269
432
|
}
|
|
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
433
|
}
|
|
280
434
|
|
|
281
|
-
// ── Switch project ────────────────────────────────────────────────────
|
|
282
435
|
if (!isValidProject(name)) {
|
|
283
436
|
const available = discoverProjects(getConfig().baseDir).join(", ");
|
|
284
437
|
ctx.ui.notify(
|
|
@@ -287,88 +440,157 @@ export default function (pi: ExtensionAPI) {
|
|
|
287
440
|
);
|
|
288
441
|
return;
|
|
289
442
|
}
|
|
443
|
+
}
|
|
290
444
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
}
|
|
445
|
+
if (name === activeProject) {
|
|
446
|
+
ctx.ui.notify(`Already on project: ${name}`, "info");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
303
449
|
|
|
304
|
-
|
|
305
|
-
const targetSession = getMappedSessionFile(name);
|
|
450
|
+
const previous = activeProject;
|
|
306
451
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
452
|
+
// Remember the current session under the PREVIOUS project before switching
|
|
453
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
454
|
+
if (previous && currentSessionFile) {
|
|
455
|
+
rememberSessionFile(previous, currentSessionFile);
|
|
456
|
+
}
|
|
310
457
|
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
}
|
|
458
|
+
// Try to restore the target project's last session
|
|
459
|
+
const targetSession = getMappedSessionFile(name);
|
|
318
460
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
activeProject = name;
|
|
323
|
-
pi.setSessionName(name);
|
|
324
|
-
rememberSessionFile(name, targetSession);
|
|
461
|
+
if (targetSession) {
|
|
462
|
+
// Persist the switch in the OLD session before replacing it
|
|
463
|
+
pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
|
|
325
464
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
|
|
332
|
-
"info"
|
|
333
|
-
);
|
|
465
|
+
const result = await ctx.switchSession(targetSession);
|
|
466
|
+
if (result.cancelled) {
|
|
467
|
+
// User cancelled; roll back in-memory state
|
|
468
|
+
activeProject = previous;
|
|
469
|
+
ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
|
|
334
470
|
return;
|
|
335
471
|
}
|
|
336
472
|
|
|
337
|
-
//
|
|
473
|
+
// switchSession fires a new session_start, which restores state from
|
|
474
|
+
// the target session's entries (or auto-detects). Set it explicitly as
|
|
475
|
+
// a safety net in case the session has no project entry yet.
|
|
338
476
|
activeProject = name;
|
|
339
|
-
|
|
340
|
-
// Persist to session
|
|
341
|
-
pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
|
|
342
|
-
|
|
343
|
-
// Update session name
|
|
344
477
|
pi.setSessionName(name);
|
|
478
|
+
rememberSessionFile(name, targetSession);
|
|
345
479
|
|
|
346
480
|
const path = projectPath(name);
|
|
347
481
|
const branch = getGitBranch(path);
|
|
348
482
|
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
349
|
-
const fromStr = previous ? ` (was: ${previous})` : "";
|
|
350
|
-
|
|
351
|
-
// Current session identity — still valid on this path (no session replacement)
|
|
352
|
-
const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
|
|
353
|
-
const sessionLine = currentFile
|
|
354
|
-
? `Continuing session: ${basename(currentFile)}`
|
|
355
|
-
: "Continuing current session";
|
|
356
|
-
|
|
357
483
|
ctx.ui.notify(
|
|
358
|
-
`Switched to ${name}
|
|
359
|
-
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}
|
|
360
|
-
sessionLine,
|
|
484
|
+
`Switched to ${name} — session restored: ${basename(targetSession)}\n` +
|
|
485
|
+
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}`,
|
|
361
486
|
"info"
|
|
362
487
|
);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
363
490
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
491
|
+
// ── Fallback: no stored session (or file gone) -> same-session switch ─
|
|
492
|
+
activeProject = name;
|
|
493
|
+
|
|
494
|
+
// Persist to session
|
|
495
|
+
pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
|
|
496
|
+
|
|
497
|
+
// Update session name
|
|
498
|
+
pi.setSessionName(name);
|
|
499
|
+
|
|
500
|
+
const path = projectPath(name);
|
|
501
|
+
const branch = getGitBranch(path);
|
|
502
|
+
const branchStr = branch ? ` on branch \`${branch}\`` : "";
|
|
503
|
+
const fromStr = previous ? ` (was: ${previous})` : "";
|
|
504
|
+
|
|
505
|
+
// Current session identity — still valid on this path (no session replacement)
|
|
506
|
+
const currentFile = currentSessionFile ?? ctx.sessionManager.getSessionFile();
|
|
507
|
+
const sessionLine = currentFile
|
|
508
|
+
? `Continuing session: ${basename(currentFile)}`
|
|
509
|
+
: "Continuing current session";
|
|
510
|
+
|
|
511
|
+
ctx.ui.notify(
|
|
512
|
+
`Switched to ${name}${fromStr} — first session in this project\n` +
|
|
513
|
+
`Workdir: ${path}${branchStr ? ` ${branchStr}` : ""}\n` +
|
|
514
|
+
sessionLine,
|
|
515
|
+
"info"
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
// Announce to the agent so it operates in the new context
|
|
519
|
+
await ctx.waitForIdle();
|
|
520
|
+
pi.sendUserMessage(
|
|
521
|
+
`[Project switched to **${name}**]\n` +
|
|
522
|
+
`Working directory: \`${path}\`${branchStr}\n` +
|
|
523
|
+
`Please keep all file operations within this project from now on.`,
|
|
524
|
+
{ deliverAs: "followUp" }
|
|
525
|
+
);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// ── /project command ─────────────────────────────────────────────────────
|
|
529
|
+
pi.registerCommand("project", {
|
|
530
|
+
description: "Show or switch active project (/project [name])",
|
|
531
|
+
|
|
532
|
+
getArgumentCompletions: (prefix: string) => {
|
|
533
|
+
const projects = discoverProjects(getConfig().baseDir);
|
|
534
|
+
const filtered = projects.filter((p) => p.startsWith(prefix));
|
|
535
|
+
return filtered.length > 0
|
|
536
|
+
? filtered.map((p) => ({ value: p, label: p }))
|
|
537
|
+
: null;
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
handler: async (args, ctx) => {
|
|
541
|
+
let name = args.trim();
|
|
542
|
+
|
|
543
|
+
// ── Explicit create opt-in: trailing "!" on the project name ────────
|
|
544
|
+
const createOptIn = name.endsWith("!");
|
|
545
|
+
if (createOptIn) {
|
|
546
|
+
name = name.slice(0, -1).trim();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── No arg: surface-adaptive status ──────────────────────────────────
|
|
550
|
+
if (!name) {
|
|
551
|
+
const projects = discoverProjects(getConfig().baseDir);
|
|
552
|
+
if (projects.length === 0) {
|
|
553
|
+
ctx.ui.notify(
|
|
554
|
+
`No projects found under ${getConfig().baseDir}. Set PI_PROJECT_SWITCHER_BASE or create the settings file.`,
|
|
555
|
+
"warning"
|
|
556
|
+
);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const isTelegramOrigin = consumeTelegramStatusFlag();
|
|
561
|
+
|
|
562
|
+
if (isTelegramOrigin) {
|
|
563
|
+
// Plain list for the local surface (parity with today) …
|
|
564
|
+
notifyPlainProjectList(ctx, projects);
|
|
565
|
+
// … then a follow-up turn whose reply reaches the Telegram chat,
|
|
566
|
+
// with one button per project. The turn also settles the command
|
|
567
|
+
// bridge's pending dispatch (its agent_start hook clears the
|
|
568
|
+
// pending settle entry first).
|
|
569
|
+
await ctx.waitForIdle();
|
|
570
|
+
pi.sendUserMessage(buildTelegramStatusPrompt(projects), {
|
|
571
|
+
deliverAs: "followUp",
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (ctx.mode === "tui") {
|
|
577
|
+
// Selection dialog; dismissing falls back to the plain list.
|
|
578
|
+
const choice = await ctx.ui.select("Switch project", projects);
|
|
579
|
+
if (choice !== undefined) {
|
|
580
|
+
await switchToProject(choice, ctx, false);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
notifyPlainProjectList(ctx, projects);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// rpc/json/print and any other surface: unchanged plain list
|
|
588
|
+
notifyPlainProjectList(ctx, projects);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── Switch project ────────────────────────────────────────────────────
|
|
593
|
+
await switchToProject(name, ctx, createOptIn);
|
|
372
594
|
},
|
|
373
595
|
});
|
|
374
596
|
}
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name": "pi-project-switcher", "version": "0.
|
|
1
|
+
{"name": "pi-project-switcher", "version": "0.6.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"}}
|