@tommy-ca/lazypi 0.6.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rob Zolkos
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,82 @@
1
+ # LazyPi
2
+
3
+ The [Pi](https://github.com/earendil-works/pi-mono) coding agent is minimal by design. LazyPi is opinionated by design. Run one command and get a complete, curated Pi setup — everything selected by default, nothing to research, nothing to configure. Remove what you don't want later.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx @tommy-ca/lazypi
9
+ ```
10
+
11
+ LazyPi will:
12
+
13
+ 1. Install `pi` for you if it isn't installed yet.
14
+ 2. Ask if you want to install all the packages or choose which to install.
15
+
16
+ That setup is the harness core — isolated sub-agents, a structured ask gate, skill visibility, $ skill mention, a long-objective gate, side chat, context budgeting, code simplification review, web research, and FFF search. Optional extras (skill arguments, memory, MCP, interactive shell overlays, research loops, themes) install on demand with `pi install`.
17
+
18
+ That's it. Once done - run `pi` and experience a feature rich coding agent experience.
19
+
20
+ Install is **idempotent** — LazyPi reads your Pi settings and skips any package that is already installed, so re-running is safe.
21
+
22
+ ## Commands
23
+
24
+ | Command | What it does |
25
+ | --- | --- |
26
+ | `npx @tommy-ca/lazypi` | Install all or selected catalog (interactive picker by default) |
27
+ | `npx @tommy-ca/lazypi remove <id>` | Remove a catalog package by id (or pass a raw pi source) |
28
+ | `npx @tommy-ca/lazypi status` | Show which catalog packages are installed, missing, or extra |
29
+ | `npx @tommy-ca/lazypi update` | Run `pi update` for installed Pi packages |
30
+ | `npx @tommy-ca/lazypi doctor` | Check your environment for common problems |
31
+
32
+ ## Updating
33
+
34
+ ```bash
35
+ npx @tommy-ca/lazypi update
36
+ ```
37
+
38
+ ## Removing packages
39
+
40
+ ```bash
41
+ npx @tommy-ca/lazypi remove
42
+ ```
43
+
44
+ Shows an interactive picker of installed packages. Or pass ids directly to skip the picker:
45
+
46
+ ```bash
47
+ npx @tommy-ca/lazypi remove subagents
48
+ npx @tommy-ca/lazypi remove npm:pi-subagents@0.13.3 # raw pi source also works
49
+ ```
50
+
51
+ There is nothing to "uninstall" for LazyPi itself — `npx` doesn't leave it around.
52
+
53
+ ## Troubleshooting
54
+
55
+ Run the built-in health check with `npx @tommy-ca/lazypi doctor`.
56
+
57
+ ## Site / docs
58
+
59
+ The site at [lazypi.org](https://lazypi.org) lives in `docs/` and is a Jekyll site compiled by GitHub Pages automatically on push to `master`.
60
+
61
+ To preview locally (requires Ruby + Bundler):
62
+
63
+ ```bash
64
+ cd docs && bundle install # first time only
65
+ npm run docs:serve # serves at http://localhost:4000 with livereload
66
+ ```
67
+
68
+ Shared nav and footer are in `docs/_includes/`. Layouts are in `docs/_layouts/`. CSS variables and nav styles are in `docs/assets/css/site.css`.
69
+
70
+ ## Releasing
71
+
72
+ LazyPi uses **Release Please** and **npm trusted publishing**.
73
+
74
+ To release a new version:
75
+
76
+ - Merge your normal PRs into `master`
77
+ - Merge the Release Please release PR when you are ready to publish
78
+ - GitHub creates the tag/release and publishes to npm automatically
79
+
80
+ ---
81
+
82
+ For the full list of included packages and optional extras, see [lazypi.org](https://lazypi.org).
package/bin/lazypi.mjs ADDED
@@ -0,0 +1,910 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { homedir, platform } from "node:os";
4
+ import { dirname, join, posix, resolve, win32 } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { argv, cwd, exit, stdout, stderr } from "node:process";
7
+ import { pathToFileURL } from "node:url";
8
+ import {
9
+ cancel as clackCancel,
10
+ confirm as clackConfirm,
11
+ groupMultiselect,
12
+ intro,
13
+ isCancel,
14
+ log,
15
+ note,
16
+ outro,
17
+ select,
18
+ } from "@clack/prompts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Catalog
22
+ // ---------------------------------------------------------------------------
23
+ // Categories. LazyPi installs every package by default — "lazy" means you get
24
+ // the whole thing without thinking. Use the interactive picker or --only /
25
+ // --except to narrow it.
26
+ const CATEGORIES = ["core"];
27
+
28
+ export const PACKAGES = [
29
+ { id: "subagents", category: "core", source: "npm:pi-subagents", essential: true, description: "Sub-agent execution", hint: "Run isolated sub-agents for parallel work." },
30
+ { id: "pi-ask-user", category: "core", source: "npm:pi-ask-user", essential: true, description: "Ask-user prompts", hint: "Interactive user questions for agent workflows." },
31
+ { id: "pi-skillful", category: "core", source: "npm:pi-skillful", essential: true, description: "Skill visibility", hint: "Discover skills above the git root, hide unused skills, and expand /skill:name inline." },
32
+ { id: "mention-skill", category: "core", source: "npm:@zigai/pi-mention-skill", essential: true, description: "$ skill mention", hint: "Fuzzy-search skills with $ and expand them into the prompt; hidden skills stay reachable." },
33
+ { id: "goal", category: "core", source: "npm:@narumitw/pi-goal", essential: true, description: "Long-objective gate", hint: "Stop on done, blocked, or external wait for long tasks." },
34
+ {
35
+ id: "btw",
36
+ category: "core",
37
+ source: "npm:@narumitw/pi-btw",
38
+ legacySources: ["npm:pi-btw"],
39
+ essential: true,
40
+ description: "Side-chat popover",
41
+ hint: "Ask quick questions without polluting your conversation history.",
42
+ },
43
+ { id: "context-usage", category: "core", source: "npm:pi-context-usage", essential: true, description: "Context budget", hint: "See what is burning the context window before it fills." },
44
+ { id: "simplify", category: "core", source: "npm:pi-simplify", essential: true, description: "Code simplify review", hint: "Reviews recently changed code for clarity, consistency, and maintainability." },
45
+ { id: "web-access", category: "core", source: "npm:pi-web-access", essential: true, description: "Web search and page fetch", hint: "Built-in web search and URL fetching." },
46
+ { id: "fff", category: "core", source: "npm:@ff-labs/pi-fff", essential: true, description: "FFF fuzzy search", hint: "Additive fffind / ffgrep / fff-multi-grep beside the built-in tools; /fff-health checks the index." },
47
+ ];
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Output helpers
51
+ // ---------------------------------------------------------------------------
52
+ const isTTY = Boolean(stdout.isTTY);
53
+ const c = (code) => (s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s));
54
+ const bold = c("1");
55
+ const dim = c("2");
56
+ const red = c("31");
57
+ const green = c("32");
58
+ const yellow = c("33");
59
+ const cyan = c("36");
60
+ const blue = c("94");
61
+ const white = c("1;97");
62
+
63
+ function printHeader(text) {
64
+ console.log(`\n${bold(text)}`);
65
+ }
66
+
67
+ // ASCII "Pi" logo: capital P + lowercase i, with a blue "zzz" cascade
68
+ // rising from where the dot of the "i" would be. Letters in bold white,
69
+ // sleep trail in blue.
70
+ function renderLogo() {
71
+ const Z = (s) => blue(s);
72
+ const P = (s) => white(s);
73
+ return [
74
+ "",
75
+ " " + Z("z Z z"),
76
+ " " + Z("z Z"),
77
+ " " + Z("z"),
78
+ " " + P("____ "),
79
+ " " + P("| _ \\(_)"),
80
+ " " + P("| |_) | |"),
81
+ " " + P("| __/| |"),
82
+ " " + P("|_| |_|"),
83
+ "",
84
+ ].join("\n");
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Arg parsing
89
+ // ---------------------------------------------------------------------------
90
+ const KNOWN_COMMANDS = new Set(["install", "status", "update", "doctor", "remove"]);
91
+
92
+ function parseArgs(args) {
93
+ const flags = {
94
+ command: "install",
95
+ local: false,
96
+ yes: false,
97
+ help: false,
98
+ only: null,
99
+ except: null,
100
+ targets: [],
101
+ };
102
+
103
+ let i = 0;
104
+ if (args[0] && KNOWN_COMMANDS.has(args[0])) {
105
+ flags.command = args[0];
106
+ i = 1;
107
+ }
108
+
109
+ for (; i < args.length; i++) {
110
+ const arg = args[i];
111
+ if (arg === "-l" || arg === "--local") flags.local = true;
112
+ else if (arg === "-y" || arg === "--yes") flags.yes = true;
113
+ else if (arg === "-h" || arg === "--help") flags.help = true;
114
+ else if (arg === "--only") flags.only = parseList(args[++i]);
115
+ else if (arg.startsWith("--only=")) flags.only = parseList(arg.slice("--only=".length));
116
+ else if (arg === "--except") flags.except = parseList(args[++i]);
117
+ else if (arg.startsWith("--except=")) flags.except = parseList(arg.slice("--except=".length));
118
+ else if (flags.command === "remove" && !arg.startsWith("-")) flags.targets.push(arg);
119
+ else {
120
+ console.error(red(`Unknown argument: ${arg}`));
121
+ flags.help = true;
122
+ break;
123
+ }
124
+ }
125
+
126
+ return flags;
127
+ }
128
+
129
+ export function parseList(value) {
130
+ if (!value) return [];
131
+ return value
132
+ .split(",")
133
+ .map((s) => s.trim())
134
+ .filter(Boolean);
135
+ }
136
+
137
+ function validateSelectors(list, label) {
138
+ const ids = new Set(PACKAGES.map((p) => p.id));
139
+ const bad = list.filter((name) => !CATEGORIES.includes(name) && !ids.has(name));
140
+ if (bad.length > 0) {
141
+ console.error(red(`Unknown ${label}: ${bad.join(", ")}`));
142
+ console.error(`Valid categories: ${CATEGORIES.join(", ")}`);
143
+ console.error(`Valid package ids: ${[...ids].join(", ")}`);
144
+ exit(2);
145
+ }
146
+ }
147
+
148
+ function matchesSelector(pkg, selectors) {
149
+ return selectors.some((name) => name === pkg.category || name === pkg.id);
150
+ }
151
+
152
+ function resolveSelection(flags) {
153
+ if (flags.only) {
154
+ validateSelectors(flags.only, "--only");
155
+ return new Set(PACKAGES.filter((p) => matchesSelector(p, flags.only)).map((p) => p.id));
156
+ }
157
+ if (flags.except) {
158
+ validateSelectors(flags.except, "--except");
159
+ return new Set(PACKAGES.filter((p) => !matchesSelector(p, flags.except)).map((p) => p.id));
160
+ }
161
+ return new Set(PACKAGES.map((p) => p.id));
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Help
166
+ // ---------------------------------------------------------------------------
167
+ function printHelp() {
168
+ console.log(`${bold("lazypi")} — opinionated installer for Pi extensions
169
+
170
+ ${bold("Usage:")}
171
+ npx @tommy-ca/lazypi [command] [options]
172
+
173
+ ${bold("Commands:")}
174
+ install Install the selected LazyPi catalog (default)
175
+ remove Remove a catalog package by id (or pass a raw pi source)
176
+ status Show which catalog packages are installed
177
+ update Run \`pi update\` for installed Pi packages
178
+ doctor Check your environment for common problems
179
+
180
+ ${bold("Install options:")}
181
+ --only <list> Install only the given categories or package ids
182
+ --except <list> Install everything except the given categories or ids
183
+ -l, --local Install into the current project (.pi/settings.json)
184
+ -y, --yes Skip the picker and any confirmation prompt
185
+ -h, --help Show this help
186
+
187
+ ${bold("Default behaviour:")}
188
+ - Every catalog package is installed (lazy on purpose).
189
+ - On a TTY, an interactive picker appears with everything pre-ticked;
190
+ untick categories or packages before confirming.
191
+ - With --yes, --only, or --except the picker is skipped.
192
+
193
+ ${bold("Categories:")}
194
+ core the harness control plane: sub-agents, ask gate, skill visibility, $ mention, goal, side chat, context budget, simplify, web research, fff search
195
+ Non-catalog extras (pi install npm:<source>): skill-args, memory, mcp,
196
+ interactive-shell, ralph-wiggum, curated-themes (65 dark themes)
197
+
198
+ ${bold("Examples:")}
199
+ npx @tommy-ca/lazypi # everything (interactive picker on a TTY)
200
+ npx @tommy-ca/lazypi --yes # everything, no prompt
201
+ npx @tommy-ca/lazypi --only core # just the core category
202
+ npx @tommy-ca/lazypi --only subagents,fff # individual package ids also work
203
+ npx @tommy-ca/lazypi --only core --local # core into the current project
204
+ npx @tommy-ca/lazypi status
205
+ npx @tommy-ca/lazypi doctor`);
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Pi / settings plumbing
210
+ // ---------------------------------------------------------------------------
211
+ // On Windows, package-manager CLIs and global Node bins are usually `.cmd`
212
+ // shims. Node's child_process docs note that those need to be launched via a
213
+ // shell, so we route spawned commands through the platform shell there while
214
+ // keeping direct execution on Unix.
215
+ export function buildSpawnOptions(options = {}, platformName = platform()) {
216
+ const resolved = { ...options };
217
+ if (platformName === "win32" && resolved.shell == null) resolved.shell = true;
218
+ return resolved;
219
+ }
220
+
221
+ function spawnCommand(command, args = [], options = {}) {
222
+ return spawnSync(command, args, buildSpawnOptions(options));
223
+ }
224
+
225
+ function hasCmd(name) {
226
+ return commandPath(name) !== null;
227
+ }
228
+
229
+ export function resolveAgentConfigDir(configured, home = homedir(), platformName = platform()) {
230
+ const joinPath = platformName === "win32" ? win32.join : posix.join;
231
+ if (!configured) return joinPath(home, ".pi", "agent");
232
+ if (configured === "~") return home;
233
+ if (configured.startsWith("~/") || (platformName === "win32" && configured.startsWith("~\\"))) {
234
+ return joinPath(home, configured.slice(2));
235
+ }
236
+ return configured;
237
+ }
238
+
239
+ function agentConfigDir() {
240
+ return resolveAgentConfigDir(process.env.PI_CODING_AGENT_DIR);
241
+ }
242
+
243
+ function settingsPath(local) {
244
+ return local ? join(cwd(), ".pi", "settings.json") : join(agentConfigDir(), "settings.json");
245
+ }
246
+
247
+ // Builtin pi-subagents agents that hardcode a specific model — blank them out
248
+ // so they fall back to the user's active session model instead.
249
+ const SUBAGENT_BUILTIN_MODELS = ["context-builder", "planner", "researcher", "reviewer", "scout", "worker"];
250
+
251
+
252
+ function readSettings(local) {
253
+ const path = settingsPath(local);
254
+ if (!existsSync(path)) return { path, exists: false, parsed: null, error: null };
255
+ try {
256
+ return { path, exists: true, parsed: JSON.parse(readFileSync(path, "utf8")), error: null };
257
+ } catch (err) {
258
+ return { path, exists: true, parsed: null, error: err instanceof Error ? err.message : String(err) };
259
+ }
260
+ }
261
+
262
+ function backupPath(path) {
263
+ const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
264
+ return `${path}.lazypi.${timestamp}.bak`;
265
+ }
266
+
267
+ function writeSettings(local, mutate) {
268
+ const current = readSettings(local);
269
+ if (current.error) return { ok: false, path: current.path, error: current.error };
270
+ const settings = current.parsed ?? {};
271
+ const changed = mutate(settings);
272
+ if (!changed) return { ok: true, path: current.path, backup: null, changed: false };
273
+ mkdirSync(dirname(current.path), { recursive: true });
274
+ let backup = null;
275
+ if (current.exists) {
276
+ backup = backupPath(current.path);
277
+ copyFileSync(current.path, backup);
278
+ }
279
+ writeFileSync(current.path, JSON.stringify(settings, null, 2) + "\n", "utf8");
280
+ return { ok: true, path: current.path, backup, changed: true };
281
+ }
282
+
283
+ function writeSubagentOverrides(local) {
284
+ return writeSettings(local, (settings) => {
285
+ const overrides = {};
286
+ for (const name of SUBAGENT_BUILTIN_MODELS) overrides[name] = { model: "" };
287
+ settings.subagents = { ...(settings.subagents ?? {}), agentOverrides: { ...(settings.subagents?.agentOverrides ?? {}), ...overrides } };
288
+ return true;
289
+ });
290
+ }
291
+
292
+ function packageEntrySource(entry) {
293
+ if (typeof entry === "string") return entry;
294
+ if (entry && typeof entry === "object" && typeof entry.source === "string") return entry.source;
295
+ return null;
296
+ }
297
+
298
+ function readInstalledSources(local) {
299
+ const current = readSettings(local);
300
+ if (!current.exists) return { sources: new Set(), path: current.path, exists: false };
301
+ if (current.error) return { sources: new Set(), path: current.path, exists: true, error: current.error };
302
+ const sources = new Set();
303
+ for (const entry of current.parsed?.packages ?? []) {
304
+ const source = packageEntrySource(entry);
305
+ if (source) sources.add(source);
306
+ }
307
+ return { sources, path: current.path, exists: true };
308
+ }
309
+
310
+ function runPi(args) {
311
+ const result = spawnCommand("pi", args, { stdio: "inherit" });
312
+ return result.status ?? 1;
313
+ }
314
+
315
+ function commandPath(name) {
316
+ const command = platform() === "win32" ? "where" : "which";
317
+ const probe = spawnCommand(command, [name], { encoding: "utf8" });
318
+ if (probe.status !== 0) return null;
319
+ const first = String(probe.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).find(Boolean);
320
+ return first || null;
321
+ }
322
+
323
+ function legacySourcesForPackage(pkg) {
324
+ return Array.isArray(pkg.legacySources) ? pkg.legacySources : [];
325
+ }
326
+
327
+ function isLegacySourceForPackage(pkg, source) {
328
+ return legacySourcesForPackage(pkg).includes(source);
329
+ }
330
+
331
+ function findLegacyInstalledSources(pkg, installedPiSources) {
332
+ return [...installedPiSources].filter((source) => isLegacySourceForPackage(pkg, source));
333
+ }
334
+
335
+ function removeLegacy(pkg, installedPiSources, local, interactive) {
336
+ let status = 0;
337
+ for (const legacySource of findLegacyInstalledSources(pkg, installedPiSources)) {
338
+ const action = `pi remove ${legacySource}`;
339
+ if (interactive) log.step(action);
340
+ else console.log(`\n→ ${action}`);
341
+ status = spawnCommand("pi", local ? ["remove", "-l", legacySource] : ["remove", legacySource], { stdio: "inherit" }).status ?? 1;
342
+ if (status !== 0) break;
343
+ }
344
+ return status;
345
+ }
346
+
347
+ function packageInstallStatus(pkg, installedPiSources) {
348
+ const legacySources = findLegacyInstalledSources(pkg, installedPiSources);
349
+ return {
350
+ installed: installedPiSources.has(pkg.source),
351
+ legacy: legacySources.length > 0,
352
+ present: installedPiSources.has(pkg.source) || legacySources.length > 0,
353
+ };
354
+ }
355
+
356
+ function isPackageInstalled(pkg, installedPiSources) {
357
+ return packageInstallStatus(pkg, installedPiSources).installed;
358
+ }
359
+
360
+ function isPackagePresent(pkg, installedPiSources) {
361
+ return packageInstallStatus(pkg, installedPiSources).present;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Pi / settings plumbing (shared helpers)
366
+ // ---------------------------------------------------------------------------
367
+ function readJsonSafe(path) {
368
+ try {
369
+ if (!existsSync(path)) return null;
370
+ return JSON.parse(readFileSync(path, "utf8"));
371
+ } catch {
372
+ return null;
373
+ }
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Auth detection (read-only)
378
+ // ---------------------------------------------------------------------------
379
+ // Pi reads credentials from auth.json in its agent config directory and also
380
+ // honors provider env vars. LazyPi reports the available credentials so users
381
+ // know whether to run `pi /login` first.
382
+ const AUTH_ENV_VARS = [
383
+ ["ANTHROPIC_API_KEY", "anthropic"],
384
+ ["OPENAI_API_KEY", "openai"],
385
+ ["GOOGLE_API_KEY", "google"],
386
+ ["GEMINI_API_KEY", "google"],
387
+ ["OPENROUTER_API_KEY", "openrouter"],
388
+ ["TOGETHER_API_KEY", "together"],
389
+ ["GROQ_API_KEY", "groq"],
390
+ ["MISTRAL_API_KEY", "mistral"],
391
+ ];
392
+
393
+ function authJsonPath() {
394
+ return join(agentConfigDir(), "auth.json");
395
+ }
396
+
397
+ function detectAuth() {
398
+ const envProviders = new Map(); // provider -> env var name
399
+ for (const [name, provider] of AUTH_ENV_VARS) {
400
+ if (process.env[name] && !envProviders.has(provider)) envProviders.set(provider, name);
401
+ }
402
+ const auth = readJsonSafe(authJsonPath()) ?? {};
403
+ const fileProviders = Object.keys(auth);
404
+ return {
405
+ envProviders: [...envProviders.entries()].map(([provider, envVar]) => ({ provider, envVar })),
406
+ fileProviders,
407
+ path: authJsonPath(),
408
+ authed: envProviders.size > 0 || fileProviders.length > 0,
409
+ };
410
+ }
411
+
412
+ function formatAuthSummary(state) {
413
+ const bits = [];
414
+ for (const { provider, envVar } of state.envProviders) bits.push(`${provider} (${envVar})`);
415
+ for (const provider of state.fileProviders) bits.push(`${provider} (auth.json)`);
416
+ return bits.length > 0 ? bits.join(", ") : "none detected";
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Interactive prompts (powered by @clack/prompts)
421
+ // ---------------------------------------------------------------------------
422
+ function isInteractive() {
423
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
424
+ }
425
+
426
+ function abortIfCancelled(value) {
427
+ if (isCancel(value)) {
428
+ clackCancel("Aborted.");
429
+ exit(0);
430
+ }
431
+ return value;
432
+ }
433
+
434
+ async function confirm(message, initial = false) {
435
+ const answer = await clackConfirm({ message, initialValue: initial });
436
+ return abortIfCancelled(answer);
437
+ }
438
+
439
+ async function askLazyOrPick(totalCount) {
440
+ const options = [
441
+ { value: "lazy", label: `Install everything`, hint: `all ${totalCount} packages` },
442
+ { value: "pick", label: "Pick packages", hint: "open a checklist" },
443
+ ];
444
+
445
+ const choice = await select({
446
+ message: `Install all ${totalCount} Pi packages the lazy way, or pick them yourself?`,
447
+ options,
448
+ initialValue: "lazy",
449
+ });
450
+ return abortIfCancelled(choice);
451
+ }
452
+
453
+ async function runPicker(initialSelected) {
454
+ const idWidth = Math.max(...PACKAGES.map((p) => p.id.length));
455
+ const options = {};
456
+ for (const cat of CATEGORIES) {
457
+ const pkgs = PACKAGES.filter((p) => p.category === cat);
458
+ if (pkgs.length === 0) continue;
459
+ options[cat] = pkgs.map((pkg) => ({
460
+ value: pkg.id,
461
+ label: `${pkg.id.padEnd(idWidth + 2)}${pkg.description}`,
462
+ }));
463
+ }
464
+
465
+ const picked = await groupMultiselect({
466
+ message: "Pick packages to install",
467
+ options,
468
+ initialValues: [...initialSelected],
469
+ required: false,
470
+ selectableGroups: true,
471
+ });
472
+ abortIfCancelled(picked);
473
+ return new Set(picked);
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Ensure Pi is present (offer to install)
478
+ // ---------------------------------------------------------------------------
479
+ async function ensurePi(flags) {
480
+ if (hasCmd("pi")) return true;
481
+
482
+ log.warn("Could not find the `pi` command on PATH.");
483
+ const ok = flags.yes || (await confirm("Install Pi now with `npm install -g @earendil-works/pi-coding-agent`?", true));
484
+ if (!ok) {
485
+ log.error("Install Pi first, then re-run `npx @tommy-ca/lazypi`.");
486
+ return false;
487
+ }
488
+
489
+ log.step("Installing Pi via `npm install -g @earendil-works/pi-coding-agent`");
490
+ const code = spawnCommand("npm", ["install", "-g", "@earendil-works/pi-coding-agent"], { stdio: "inherit" }).status;
491
+ if (code !== 0) {
492
+ log.error("Failed to install Pi. On some systems a global npm install needs sudo:\n sudo npm install -g @earendil-works/pi-coding-agent");
493
+ return false;
494
+ }
495
+
496
+ if (!hasCmd("pi")) {
497
+ log.error("Installed Pi, but `pi` is still not on PATH. Open a new shell and re-run `npx @tommy-ca/lazypi`.");
498
+ return false;
499
+ }
500
+ return true;
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // install
505
+ // ---------------------------------------------------------------------------
506
+ async function cmdInstall(flags) {
507
+ let selectedIds = resolveSelection(flags);
508
+
509
+ const usedSelectionFlag = Boolean(flags.only || flags.except);
510
+ const interactive = !flags.yes && !usedSelectionFlag && isInteractive();
511
+
512
+ if (interactive) {
513
+ console.log(renderLogo());
514
+ intro(bold("LazyPi"));
515
+ }
516
+ if (!(await ensurePi(flags))) return 127;
517
+
518
+ if (interactive) {
519
+ const choice = await askLazyOrPick(PACKAGES.length);
520
+ if (choice === "pick") {
521
+ selectedIds = await runPicker(selectedIds);
522
+ }
523
+ }
524
+
525
+ const selected = PACKAGES.filter((p) => selectedIds.has(p.id));
526
+ if (selected.length === 0) {
527
+ if (interactive) outro("Nothing selected — nothing to install.");
528
+ else console.log(yellow("Nothing selected — nothing to install."));
529
+ return 0;
530
+ }
531
+
532
+ const { sources: installedSources, error: settingsError } = readInstalledSources(flags.local);
533
+ if (settingsError) log.warn(`Could not parse ${settingsPath(flags.local)} — ${settingsError}`);
534
+
535
+ const toInstall = selected.filter((pkg) => {
536
+ return !isPackageInstalled(pkg, installedSources);
537
+ });
538
+ const alreadyInstalled = selected.filter((pkg) => {
539
+ return isPackageInstalled(pkg, installedSources);
540
+ });
541
+ const legacyInstalled = selected.filter((pkg) => {
542
+ return !isPackageInstalled(pkg, installedSources) && isPackagePresent(pkg, installedSources);
543
+ });
544
+ const migrateOnly = alreadyInstalled.filter((pkg) => findLegacyInstalledSources(pkg, installedSources).length > 0);
545
+ const migrations = legacyInstalled.length + migrateOnly.length;
546
+ const installLabel = migrations > 0 ? `${toInstall.length} (${migrations} migration${migrations === 1 ? "" : "s"})` : String(toInstall.length);
547
+ const scope = flags.local ? "project (.pi/settings.json)" : `global (${settingsPath(false)})`;
548
+
549
+ const preInstallAuth = detectAuth();
550
+ const summary = [
551
+ `Target: ${scope}`,
552
+ `Selected: ${selected.length}/${PACKAGES.length}`,
553
+ `Already installed: ${alreadyInstalled.length}`,
554
+ `Will install: ${installLabel}`,
555
+ `Pi credentials: ${formatAuthSummary(preInstallAuth)}`,
556
+ ].join("\n");
557
+ if (interactive) note(summary, "Plan");
558
+ else console.log(summary);
559
+
560
+ if (selected.some((p) => p.id === "subagents")) {
561
+ const overrideResult = writeSubagentOverrides(flags.local);
562
+ if (!overrideResult.ok) {
563
+ const message = `Refusing to update ${overrideResult.path} because it is not valid JSON (${overrideResult.error}). Fix the file first, then rerun lazypi.`;
564
+ if (interactive) {
565
+ log.error(message);
566
+ outro(red("Aborted."));
567
+ } else {
568
+ console.error(red(message));
569
+ }
570
+ return 2;
571
+ }
572
+ }
573
+
574
+ const failed = [];
575
+
576
+ // Remove stale legacy sources even when the replacement is already installed.
577
+ for (const pkg of migrateOnly) {
578
+ const status = removeLegacy(pkg, installedSources, flags.local, interactive);
579
+ if (status !== 0) {
580
+ failed.push(pkg);
581
+ if (interactive) log.error(`failed to migrate ${pkg.id}`);
582
+ else console.error(red(` ✗ failed to migrate ${pkg.id}`));
583
+ }
584
+ }
585
+
586
+ if (toInstall.length === 0 && failed.length === 0) {
587
+ printCheatsheet(selected, interactive);
588
+ const done = migrations === 0
589
+ ? "Nothing to do — every selected package is already installed."
590
+ : "Nothing new to install — stale legacy sources removed.";
591
+ if (interactive) log.success(green(done));
592
+ else console.log(green(done));
593
+ const authState = detectAuth();
594
+ printNextSteps(authState, 0, interactive);
595
+ return 0;
596
+ }
597
+
598
+ const piArgs = flags.local ? ["install", "-l"] : ["install"];
599
+
600
+ for (const pkg of toInstall) {
601
+ const migrationStatus = removeLegacy(pkg, installedSources, flags.local, interactive);
602
+ if (migrationStatus !== 0) {
603
+ failed.push(pkg);
604
+ if (interactive) log.error(`failed to migrate ${pkg.id}`);
605
+ else console.error(red(` ✗ failed to migrate ${pkg.id}`));
606
+ continue;
607
+ }
608
+
609
+ const action = `pi install ${pkg.source}`;
610
+ if (interactive) log.step(action);
611
+ else console.log(`\n→ ${action}`);
612
+ const status = spawnCommand("pi", [...piArgs, pkg.source], { stdio: "inherit" }).status;
613
+ if (status !== 0) {
614
+ failed.push(pkg);
615
+ if (interactive) log.error(`failed to install ${pkg.id}`);
616
+ else console.error(red(` ✗ failed to install ${pkg.id}`));
617
+ }
618
+ }
619
+
620
+ const installedCount = toInstall.length - failed.length;
621
+ if (failed.length === 0) {
622
+ printCheatsheet(selected, interactive);
623
+ const authState = detectAuth();
624
+ printNextSteps(authState, installedCount, interactive);
625
+ return 0;
626
+ }
627
+
628
+ const failureList = failed.map((p) => `- ${p.id} (${p.source})`).join("\n");
629
+ if (interactive) {
630
+ note(failureList, "Failures");
631
+ outro(red(`Finished with ${failed.length} failure(s).`));
632
+ } else {
633
+ console.error(red(`\nLazyPi finished with ${failed.length} failure(s):`));
634
+ console.error(failureList);
635
+ }
636
+ return 1;
637
+ }
638
+
639
+ function printNextSteps(state, installedCount, interactive) {
640
+ const lines = [];
641
+ if (state.authed) {
642
+ lines.push(`Pi credentials: ${formatAuthSummary(state)}`);
643
+ lines.push("");
644
+ lines.push("You're all set. Run `pi` to get started.");
645
+ } else {
646
+ lines.push("Pi credentials: none detected.");
647
+ lines.push("");
648
+ lines.push("Run `pi`, then type `/login` inside Pi to sign in with a");
649
+ lines.push("subscription (Claude Pro/Max, ChatGPT Plus/Pro, Copilot, Gemini)");
650
+ lines.push("or set a provider env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …)");
651
+ lines.push("before launching pi.");
652
+ }
653
+
654
+ const title = installedCount > 0
655
+ ? `Installed ${installedCount} package(s) — next steps`
656
+ : "Next steps";
657
+ const body = lines.join("\n");
658
+ if (interactive) {
659
+ note(body, title);
660
+ outro(green("Done."));
661
+ } else {
662
+ printHeader(title + ":");
663
+ console.log(body);
664
+ }
665
+ }
666
+
667
+ function printCheatsheet(selected, interactive) {
668
+ if (selected.length === 0) return;
669
+ const lines = selected.map((p) => `${p.id.padEnd(20)} ${p.hint}`);
670
+ if (interactive) note(lines.join("\n"), "What you've got");
671
+ else {
672
+ printHeader("What you've got:");
673
+ for (const line of lines) console.log(` ${line}`);
674
+ console.log(dim("\nRemove pi packages with `pi remove <source>`."));
675
+ }
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // status
680
+ // ---------------------------------------------------------------------------
681
+ function cmdStatus(flags) {
682
+ const { sources, path, exists, error } = readInstalledSources(flags.local);
683
+ console.log(`Settings file: ${bold(path)}`);
684
+ if (!exists) {
685
+ console.log(yellow(" (not found — Pi has not written settings yet)"));
686
+ } else if (error) {
687
+ console.error(red(` could not parse: ${error}`));
688
+ return 1;
689
+ }
690
+
691
+ const piCatalogSources = new Set(PACKAGES.flatMap((p) => [p.source, ...legacySourcesForPackage(p)]));
692
+ const installed = PACKAGES.filter((pkg) => packageInstallStatus(pkg, sources).installed);
693
+ const legacy = PACKAGES.filter((pkg) => packageInstallStatus(pkg, sources).legacy);
694
+ const missing = PACKAGES.filter((pkg) => !packageInstallStatus(pkg, sources).present);
695
+ const others = [...sources].filter((src) => !piCatalogSources.has(src));
696
+
697
+ printHeader(`Installed from LazyPi catalog (${installed.length}/${PACKAGES.length}):`);
698
+ if (installed.length === 0) console.log(dim(" none"));
699
+ for (const pkg of installed) {
700
+ console.log(` ${green("✓")} [${pkg.category}] ${pkg.id.padEnd(20)} ${dim(pkg.source)}`);
701
+ }
702
+
703
+ printHeader(`Installed with legacy catalog sources (${legacy.length}):`);
704
+ if (legacy.length === 0) console.log(dim(" none"));
705
+ for (const pkg of legacy) {
706
+ const detail = findLegacyInstalledSources(pkg, sources).map((src) => dim(src)).join(", ");
707
+ console.log(` ${yellow("!")} [${pkg.category}] ${pkg.id.padEnd(20)} ${detail}`);
708
+ }
709
+
710
+ printHeader(`Missing from LazyPi catalog (${missing.length}):`);
711
+ if (missing.length === 0) console.log(dim(" none — full catalog is installed"));
712
+ for (const pkg of missing) {
713
+ console.log(` ${dim("·")} [${pkg.category}] ${pkg.id.padEnd(20)} ${dim(pkg.source)}`);
714
+ }
715
+
716
+ printHeader(`Other Pi packages outside the LazyPi catalog (${others.length}):`);
717
+ if (others.length === 0) console.log(dim(" none"));
718
+ for (const src of others) console.log(` ${cyan("·")} ${src}`);
719
+
720
+ return 0;
721
+ }
722
+
723
+ // ---------------------------------------------------------------------------
724
+ // update
725
+ // ---------------------------------------------------------------------------
726
+ async function cmdUpdate(flags) {
727
+ if (!(await ensurePi(flags))) return 127;
728
+
729
+ const settings = readSettings(flags.local);
730
+ if (settings.error) {
731
+ console.error(red(`Could not parse ${settingsPath(flags.local)} — ${settings.error}`));
732
+ return 1;
733
+ }
734
+
735
+ console.log(bold("pi update"));
736
+
737
+ return runPi(flags.local ? ["update", "--extensions"] : ["update"]);
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // doctor
742
+ // ---------------------------------------------------------------------------
743
+ function cmdDoctor(flags) {
744
+ let problems = 0;
745
+ let warnings = 0;
746
+ const pass = (msg) => console.log(` ${green("✓")} ${msg}`);
747
+ const warn = (msg, { fatal = true } = {}) => {
748
+ console.log(` ${yellow("!")} ${msg}`);
749
+ if (fatal) problems++;
750
+ else warnings++;
751
+ };
752
+ const fail = (msg) => {
753
+ console.log(` ${red("✗")} ${msg}`);
754
+ problems++;
755
+ };
756
+
757
+ printHeader("Environment");
758
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
759
+ if (Number.isFinite(nodeMajor) && nodeMajor >= 20) pass(`Node ${process.versions.node}`);
760
+ else fail(`Node ${process.versions.node} — LazyPi requires Node >= 20`);
761
+
762
+ if (hasCmd("npm")) pass("npm is on PATH");
763
+ else fail("npm is not on PATH — LazyPi can't install Pi for you");
764
+
765
+ if (hasCmd("git")) pass("git is on PATH");
766
+ else warn("git is not on PATH — required by git-based catalog packages");
767
+
768
+ printHeader("Pi");
769
+ if (hasCmd("pi")) {
770
+ pass("`pi` is on PATH");
771
+ const v = spawnCommand("pi", ["--version"], { encoding: "utf8" });
772
+ const vout = (v.stdout ?? "").trim() || (v.stderr ?? "").trim();
773
+ if (vout) pass(`pi --version: ${vout}`);
774
+ else warn("Could not read `pi --version` output");
775
+ } else {
776
+ fail("`pi` is not on PATH — run `npx @tommy-ca/lazypi` to install it");
777
+ }
778
+
779
+ printHeader("Settings");
780
+ const { sources, path, exists, error } = readInstalledSources(flags.local);
781
+ if (!exists) warn(`${path} does not exist yet (Pi has not been run)`);
782
+ else if (error) fail(`${path} is not valid JSON — ${error}`);
783
+ else {
784
+ pass(`${path} is readable`);
785
+ const unpinnedGit = [...sources].filter((src) => /^git:github\.com\/[^/@]+\/[^@\s]+$/.test(src));
786
+ for (const src of unpinnedGit) warn(`${src} is an unpinned git head — pin it to a commit for reproducible installs`, { fatal: false });
787
+ }
788
+
789
+ printHeader("Auth");
790
+ const auth = detectAuth();
791
+ for (const { provider, envVar } of auth.envProviders) pass(`env var ${envVar} → ${provider}`);
792
+ if (auth.fileProviders.length > 0) pass(`${auth.path} → ${auth.fileProviders.join(", ")}`);
793
+ if (!auth.authed) warn("No credentials detected — run `pi` then `/login`, or export a provider API key", { fatal: false });
794
+
795
+ console.log("");
796
+ if (problems === 0 && warnings === 0) {
797
+ console.log(green("All checks passed."));
798
+ return 0;
799
+ }
800
+ if (problems === 0) {
801
+ console.log(yellow(`${warnings} warning(s) found.`));
802
+ return 0;
803
+ }
804
+ console.log(yellow(`${problems} problem(s) found${warnings ? `, ${warnings} warning(s)` : ""}.`));
805
+ return 1;
806
+ }
807
+
808
+ // ---------------------------------------------------------------------------
809
+ // remove
810
+ // ---------------------------------------------------------------------------
811
+ async function cmdRemove(flags, targets) {
812
+ if (targets.length === 0) {
813
+ if (!isInteractive()) {
814
+ console.error(red("Usage: npx @tommy-ca/lazypi remove <id|source> [...]"));
815
+ return 2;
816
+ }
817
+ const { sources } = readInstalledSources(flags.local);
818
+ const installedPkgs = PACKAGES.filter((p) => isPackagePresent(p, sources));
819
+ if (installedPkgs.length === 0) {
820
+ console.log(yellow("No catalog packages are installed."));
821
+ return 0;
822
+ }
823
+ const idWidth = Math.max(...installedPkgs.map((p) => p.id.length));
824
+ const { multiselect } = await import("@clack/prompts");
825
+ const picked = await multiselect({
826
+ message: "Select packages to remove",
827
+ options: installedPkgs.map((p) => ({
828
+ value: p.id,
829
+ label: `${p.id.padEnd(idWidth + 2)}${p.description}`,
830
+ })),
831
+ required: false,
832
+ });
833
+ abortIfCancelled(picked);
834
+ if (!picked.length) {
835
+ console.log(yellow("Nothing selected."));
836
+ return 0;
837
+ }
838
+ targets = picked;
839
+ }
840
+
841
+ const { sources: installedSources } = readInstalledSources(flags.local);
842
+ let exitCode = 0;
843
+ for (const target of targets) {
844
+ // Resolve a catalog id to its source string, or pass through raw sources
845
+ const pkg = PACKAGES.find((p) => p.id === target);
846
+ const source = pkg ? pkg.source : target;
847
+
848
+ const sourcesToRemove = pkg
849
+ ? [
850
+ ...(installedSources.has(pkg.source) ? [pkg.source] : []),
851
+ ...findLegacyInstalledSources(pkg, installedSources),
852
+ ]
853
+ : [source];
854
+ const uniqueSources = [...new Set(sourcesToRemove.length > 0 ? sourcesToRemove : [source])];
855
+ for (const resolvedSource of uniqueSources) {
856
+ const piArgs = flags.local ? ["remove", "-l", resolvedSource] : ["remove", resolvedSource];
857
+ const result = spawnCommand("pi", piArgs, { stdio: "inherit" });
858
+ if (result.status !== 0) {
859
+ console.error(red(`Failed to remove ${target}`));
860
+ exitCode = 1;
861
+ break;
862
+ }
863
+ }
864
+ }
865
+ return exitCode;
866
+ }
867
+
868
+ // ---------------------------------------------------------------------------
869
+ // Main
870
+ // ---------------------------------------------------------------------------
871
+ async function main() {
872
+ const flags = parseArgs(argv.slice(2));
873
+ if (flags.help) {
874
+ printHelp();
875
+ return 0;
876
+ }
877
+ switch (flags.command) {
878
+ case "install":
879
+ return cmdInstall(flags);
880
+ case "status":
881
+ return cmdStatus(flags);
882
+ case "update":
883
+ return cmdUpdate(flags);
884
+ case "doctor":
885
+ return cmdDoctor(flags);
886
+ case "remove":
887
+ return cmdRemove(flags, flags.targets);
888
+ default:
889
+ printHelp();
890
+ return 2;
891
+ }
892
+ }
893
+
894
+ export function resolveEntrypointUrl(scriptPath) {
895
+ if (!scriptPath) return null;
896
+ try {
897
+ return pathToFileURL(realpathSync(scriptPath)).href;
898
+ } catch {
899
+ return pathToFileURL(resolve(scriptPath)).href;
900
+ }
901
+ }
902
+
903
+ const entrypoint = resolveEntrypointUrl(argv[1]);
904
+
905
+ if (entrypoint === import.meta.url) {
906
+ main().then((code) => exit(code ?? 0)).catch((err) => {
907
+ stderr.write(`${err?.stack || err}\n`);
908
+ exit(1);
909
+ });
910
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@tommy-ca/lazypi",
3
+ "version": "0.6.4",
4
+ "description": "Opinionated one-shot installer for a full-featured Pi coding agent setup.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tommy-ca/LazyPi.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/tommy-ca/LazyPi/issues"
12
+ },
13
+ "homepage": "https://github.com/tommy-ca/LazyPi",
14
+ "type": "module",
15
+ "bin": {
16
+ "lazypi": "bin/lazypi.mjs"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "scripts": {
27
+ "docs:serve": "cd docs && bundle exec jekyll serve --livereload",
28
+ "test": "node --test",
29
+ "spec:validate": "openspec validate --all && openspec validate --changes --archived"
30
+ },
31
+ "dependencies": {
32
+ "@clack/prompts": "^1.2.0"
33
+ },
34
+ "keywords": [
35
+ "pi",
36
+ "pi-mono",
37
+ "pi-coding-agent",
38
+ "installer",
39
+ "npx",
40
+ "extensions"
41
+ ],
42
+ "devDependencies": {
43
+ "@fission-ai/openspec": "^1.11.0"
44
+ }
45
+ }