@msdavid/pi-distro 0.2.0 → 0.4.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.
@@ -10,17 +10,19 @@ import type {
10
10
  import { readFileSync, existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
- import { listBundledFiles, parseProvenance } from "./catalogue.ts";
13
+ import { listBundledFiles, parseProvenance, parsePackageListWithScope } from "./catalogue.ts";
14
14
  import type { HarnessEntry } from "./catalogue.ts";
15
15
  import { extractBody } from "./frontmatter.ts";
16
- import { parseGithubRef } from "./github.ts";
16
+ import { parseGithubRef, isOfficialSource, tempCloneRoot } from "./github.ts";
17
17
  import { resolveDistro } from "./resolve.ts";
18
18
  import { buildShowPreview } from "./show.ts";
19
19
  import {
20
20
  display,
21
21
  compareVersions,
22
22
  readProjectPackages,
23
+ readGlobalPackages,
23
24
  MERGE_RULE,
25
+ SCOPE_RULE,
24
26
  USER_INVOLVEMENT_RULE,
25
27
  PACKAGE_CONFLICT_RULE,
26
28
  } from "./util.ts";
@@ -31,13 +33,17 @@ export async function sendDeployKickoff(
31
33
  ctx: ExtensionCommandContext,
32
34
  harness: HarnessEntry,
33
35
  ): Promise<void> {
34
- const body = readFileSync(harness.harnessMdPath, "utf-8");
36
+ const body = readFileSync(harness.harnessMdPath!, "utf-8");
35
37
  const directives = extractBody(body);
36
38
  const files = harness.filesDir ? await listBundledFiles(harness.filesDir) : [];
37
39
  const fileList = files.length > 0
38
40
  ? files.map((f) => `- \`${f.source}\` → \`./${f.target}\``).join("\n")
39
41
  : "_(no bundled files)_";
40
- const filesDirNote = harness.filesDir ? `\n\nBundled files are located at: \`${harness.filesDir}\`` : "";
42
+ const cloneRoot = harness.dir ? tempCloneRoot(harness.dir) : undefined;
43
+ const cloneCleanupNote = cloneRoot
44
+ ? `\n\nThe distro was fetched to a temporary GitHub clone. After all bundled files have been copied into the project, remove the clone: \`rm -rf ${cloneRoot}\``
45
+ : "";
46
+ const filesDirNote = (harness.filesDir ? `\n\nBundled files are located at: \`${harness.filesDir}\`` : "") + cloneCleanupNote;
41
47
 
42
48
  const snapshot = ctx.getSystemPromptOptions();
43
49
  const activeTools = snapshot.selectedTools?.length
@@ -45,6 +51,15 @@ export async function sendDeployKickoff(
45
51
  : "_(none / default set)_";
46
52
  const pkgs = readProjectPackages(snapshot.cwd);
47
53
  const projectPackages = pkgs.length > 0 ? pkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
54
+ const gpkgs = readGlobalPackages();
55
+ const globalPackages = gpkgs.length > 0 ? gpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
56
+
57
+ // Author-specified scope hints (a package may be marked `(global)` in the directives).
58
+ // These are *defaults* for the deployment plan — the user's preset still governs.
59
+ const scopedPackages = parsePackageListWithScope(directives);
60
+ const packagePlan = scopedPackages.length > 0
61
+ ? scopedPackages.map((p) => `- \`${p.source}\` [${p.scope}]`).join("\n")
62
+ : "_(none)_";
48
63
 
49
64
  // Version comparison against existing provenance (upgrade / downgrade / same / first deploy)
50
65
  const provenancePath = join(snapshot.cwd, ".pi", "harness.md");
@@ -77,20 +92,31 @@ ${directives}
77
92
  ### Bundled files manifest
78
93
  ${fileList}${filesDirNote}
79
94
 
95
+ ### Packages with author scope hints
96
+ ${packagePlan}
97
+
98
+ (\`[local]\` / \`[global]\` are the distro author's suggested default scope. The user's deploy-time preset — see the scope rule below — governs the final scope.)
99
+
80
100
  ### Current project state (for conflict detection)
81
101
  **Already-active tools in this session:**
82
102
  ${activeTools}
83
103
 
84
- **Packages already in this project's .pi/settings.json:**
104
+ **Packages already in this project's .pi/settings.json (project-local):**
85
105
  ${projectPackages}
86
106
 
87
- (Run \`pi list\` to also see globally-installed packages.)
107
+ **Packages already in ~/.pi/agent/settings.json (global, every project):**
108
+ ${globalPackages}
109
+
110
+ (Run \`pi list\` to see both local and global packages in one view.)
88
111
 
89
112
  ${versionNote}
90
113
 
91
114
  ### User involvement rule
92
115
  ${USER_INVOLVEMENT_RULE}
93
116
 
117
+ ### Scope rule (install locally or globally)
118
+ ${SCOPE_RULE}
119
+
94
120
  ### Merge rule
95
121
  ${MERGE_RULE}
96
122
 
@@ -119,8 +145,9 @@ export async function handleDeploy(pi: ExtensionAPI, ctx: ExtensionCommandContex
119
145
  if (!resolved) return;
120
146
  const { entry, cleanup } = resolved;
121
147
 
122
- // GitHub trust gate (local distros are trusted by being in the catalogue).
123
- if (cleanup) {
148
+ // GitHub trust gate official distros (msdavid/pi-distro) are trusted and skip
149
+ // the warning; other GitHub refs require explicit user confirmation.
150
+ if (cleanup && !isOfficialSource(entry.source)) {
124
151
  const ref = parseGithubRef(nameArg!)!;
125
152
  const preview = await buildShowPreview(entry);
126
153
  const warning = `\n\n---\n\n⚠️ **Security warning:** This distro was fetched from \`${ref.displayRef}\` on GitHub. Installing unknown distros is **dangerous** — they can install arbitrary packages, write extensions that execute code, and inject agent instructions. Review everything above carefully before proceeding. You are responsible for what you install.`;
@@ -137,5 +164,6 @@ export async function handleDeploy(pi: ExtensionAPI, ctx: ExtensionCommandContex
137
164
  }
138
165
 
139
166
  await sendDeployKickoff(pi, ctx, entry);
140
- // GitHub temp dir left in /tmp for the agent to read bundled files (ephemeral).
167
+ // GitHub temp dir is left in place so the agent can read the bundled files;
168
+ // the kickoff instructs the agent to remove it after copying them.
141
169
  }
@@ -11,9 +11,9 @@
11
11
  */
12
12
 
13
13
  import { execSync } from "node:child_process";
14
- import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs";
14
+ import { existsSync, mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { tmpdir } from "node:os";
16
+ import { tmpdir, homedir } from "node:os";
17
17
  import { parseFrontmatter } from "./frontmatter.ts";
18
18
  import type { HarnessEntry } from "./catalogue.ts";
19
19
 
@@ -24,6 +24,43 @@ export interface GithubRef {
24
24
  displayRef: string; // "owner/repo" or "owner/repo/subpath"
25
25
  }
26
26
 
27
+ /**
28
+ * The official distros repo: `msdavid/pi-distro`, with distros under `harnesses/`.
29
+ * Official distros are a special case of GitHub distros — they live here and are
30
+ * fetched on demand (the npm package no longer ships bundled seeds).
31
+ */
32
+ export const OFFICIAL_REPO = {
33
+ owner: "msdavid",
34
+ repo: "pi-distro",
35
+ path: "harnesses",
36
+ ref: "main",
37
+ } as const;
38
+
39
+ /** Official distro source prefix: `github:msdavid/pi-distro/harnesses/<name>`. */
40
+ export const OFFICIAL_SOURCE_PREFIX = `github:${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.path}/`;
41
+
42
+ /** Whether a HarnessEntry.source / provenance sourceCatalogue is the official repo. */
43
+ export function isOfficialSource(source: string): boolean {
44
+ return source.startsWith(OFFICIAL_SOURCE_PREFIX) ||
45
+ source === `github:${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.path}`;
46
+ }
47
+
48
+ /** Build the official source string for a distro name. */
49
+ export function officialSource(name: string): string {
50
+ return `${OFFICIAL_SOURCE_PREFIX}${name}`;
51
+ }
52
+
53
+ /** Parse an official source string back into the distro name, or undefined. */
54
+ export function officialNameFromSource(source: string): string | undefined {
55
+ if (source.startsWith(OFFICIAL_SOURCE_PREFIX)) {
56
+ return source.slice(OFFICIAL_SOURCE_PREFIX.length);
57
+ }
58
+ if (source === `github:${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.path}`) {
59
+ return ""; // the bare harnesses path (shouldn't normally happen)
60
+ }
61
+ return undefined;
62
+ }
63
+
27
64
  /**
28
65
  * Parse a GitHub reference: `owner/repo[/subpath]` or a full GitHub URL.
29
66
  * Returns undefined if the string doesn't look like a valid GitHub ref.
@@ -108,3 +145,127 @@ export function fetchGithubDistro(
108
145
 
109
146
  return { entry, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
110
147
  }
148
+
149
+ // --- Official distros (dynamic, from OFFICIAL_REPO on GitHub) ---
150
+
151
+ /** In-memory cache for listOfficialDistros() to avoid repeat API calls in a session. */
152
+ let officialListCache: { entries: HarnessEntry[]; fetchedAt: number } | null = null;
153
+ const OFFICIAL_LIST_TTL_MS = 5 * 60 * 1000; // 5 minutes
154
+
155
+ /** Whether the last official-catalogue fetch failed (network down, GitHub API
156
+ * rate limit, …). Lets callers tell "GitHub unreachable" apart from "distro
157
+ * genuinely doesn't exist" in their messaging. */
158
+ let officialUnavailable = false;
159
+
160
+ /** True if the most recent official-catalogue fetch failed. */
161
+ export function isOfficialCatalogueUnavailable(): boolean {
162
+ return officialUnavailable;
163
+ }
164
+
165
+ /** On-disk cache of official distro names, for synchronous autocomplete. */
166
+ function officialNamesCachePath(): string {
167
+ return join(homedir(), ".pi", "harnesses", ".official-cache.json");
168
+ }
169
+
170
+ /**
171
+ * List official distros from OFFICIAL_REPO's `harnesses/` directory via the GitHub
172
+ * Contents API (one call) + a raw frontmatter fetch per distro.
173
+ *
174
+ * Returns *listing* entries: `needsFetch: true`, no `dir`/`filesDir` (those are
175
+ * populated by `fetchOfficialDistro()` when the user actually selects one).
176
+ *
177
+ * Never throws — on any network/parse failure returns [] (caller degrades to
178
+ * local-only catalogue). Results are cached for OFFICIAL_LIST_TTL_MS.
179
+ */
180
+ export async function listOfficialDistros(): Promise<HarnessEntry[]> {
181
+ if (officialListCache && Date.now() - officialListCache.fetchedAt < OFFICIAL_LIST_TTL_MS) {
182
+ return officialListCache.entries;
183
+ }
184
+ const entries = await listOfficialDistrosUncached();
185
+ officialListCache = { entries, fetchedAt: Date.now() };
186
+ return entries;
187
+ }
188
+
189
+ async function listOfficialDistrosUncached(): Promise<HarnessEntry[]> {
190
+ // 1. List `harnesses/` subdirs via the Contents API.
191
+ const contentsUrl = `https://api.github.com/repos/${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/contents/${OFFICIAL_REPO.path}?ref=${OFFICIAL_REPO.ref}`;
192
+ let dirs: string[];
193
+ try {
194
+ const resp = await fetch(contentsUrl, {
195
+ headers: { "Accept": "application/vnd.github+json", "User-Agent": "pi-distro" },
196
+ });
197
+ if (!resp.ok) { officialUnavailable = true; return []; }
198
+ const listing = (await resp.json()) as Array<{ name: string; type: string }>;
199
+ dirs = listing.filter((e) => e.type === "dir").map((e) => e.name);
200
+ } catch {
201
+ officialUnavailable = true;
202
+ return [];
203
+ }
204
+
205
+ // 2. Fetch each distro's harness.md frontmatter (raw, unauthenticated).
206
+ const entries: HarnessEntry[] = [];
207
+ await Promise.all(dirs.map(async (name) => {
208
+ const rawUrl = `https://raw.githubusercontent.com/${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.ref}/${OFFICIAL_REPO.path}/${name}/harness.md`;
209
+ try {
210
+ const resp = await fetch(rawUrl, { headers: { "User-Agent": "pi-distro" } });
211
+ if (!resp.ok) return;
212
+ const content = await resp.text();
213
+ const fm = parseFrontmatter(content);
214
+ if (!fm?.name) return;
215
+ entries.push({
216
+ name: fm.name,
217
+ title: fm.title ?? fm.name,
218
+ description: fm.description ?? "",
219
+ version: fm.version ?? "0.0.0",
220
+ source: officialSource(fm.name),
221
+ needsFetch: true,
222
+ });
223
+ } catch {
224
+ // skip this distro
225
+ }
226
+ }));
227
+ entries.sort((a, b) => a.name.localeCompare(b.name));
228
+ // The raw fetches all failing (with a non-empty dir listing) is also "unavailable".
229
+ officialUnavailable = dirs.length > 0 && entries.length === 0;
230
+ if (!officialUnavailable) {
231
+ try {
232
+ mkdirSync(join(homedir(), ".pi", "harnesses"), { recursive: true });
233
+ writeFileSync(officialNamesCachePath(), JSON.stringify(entries.map((e) => e.name)));
234
+ } catch { /* cache is best-effort */ }
235
+ }
236
+ return entries;
237
+ }
238
+
239
+ /**
240
+ * For a directory inside one of our temp GitHub clones, return the clone root
241
+ * (the `pi-distro-gh-*` mkdtemp dir), or undefined if the dir is not a temp
242
+ * clone. Used to tell the deploying agent which directory to remove once the
243
+ * bundled files have been copied.
244
+ */
245
+ export function tempCloneRoot(dir: string): string | undefined {
246
+ const prefix = join(tmpdir(), "pi-distro-gh-");
247
+ if (!dir.startsWith(prefix)) return undefined;
248
+ const first = dir.slice(tmpdir().length + 1).split(/[\\/]/)[0];
249
+ return join(tmpdir(), first);
250
+ }
251
+
252
+ /** Invalidate the official-list cache (used by tests / explicit refresh). */
253
+ export function clearOfficialListCache(): void {
254
+ officialListCache = null;
255
+ }
256
+
257
+ /**
258
+ * Fetch an official distro by name: shallow-clones OFFICIAL_REPO and points at
259
+ * `harnesses/<name>/`. Returns { entry, cleanup } like fetchGithubDistro.
260
+ * Throws on clone failure or if the distro/harness.md is missing.
261
+ */
262
+ export function fetchOfficialDistro(
263
+ name: string,
264
+ ): { entry: HarnessEntry; cleanup: () => void } {
265
+ return fetchGithubDistro({
266
+ owner: OFFICIAL_REPO.owner,
267
+ repo: OFFICIAL_REPO.repo,
268
+ subPath: `${OFFICIAL_REPO.path}/${name}`,
269
+ displayRef: `${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.path}/${name}`,
270
+ });
271
+ }
@@ -35,11 +35,14 @@ const SUBCOMMANDS = [
35
35
 
36
36
  export default function (pi: ExtensionAPI): void {
37
37
  pi.registerCommand("pi-distro", {
38
- description: "Manage pi distros (deploy, save, list, show, status, remove)",
38
+ description: "Manage pi distros (deploy, undeploy, pick, update, save, list, show, status, remove)",
39
39
  handler: async (args: string, ctx: ExtensionCommandContext) => {
40
40
  const parts = args.trim().split(/\s+/).filter(Boolean);
41
41
  const sub = parts[0] ?? "";
42
42
  const nameArg = parts[1];
43
+ if (parts.length > 2) {
44
+ ctx.ui.notify(`Ignoring extra arguments: ${parts.slice(2).join(" ")}`, "warning");
45
+ }
43
46
  switch (sub) {
44
47
  case "": await handleHelp(pi); break;
45
48
  case "deploy": await handleDeploy(pi, ctx, nameArg); break;
@@ -7,7 +7,7 @@ import type {
7
7
  ExtensionAPI,
8
8
  ExtensionCommandContext,
9
9
  } from "@earendil-works/pi-coding-agent";
10
- import { readFileSync, existsSync, readdirSync, mkdirSync, cpSync, rmSync } from "node:fs";
10
+ import { readFileSync, existsSync, mkdirSync, cpSync, rmSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  import {
@@ -15,28 +15,42 @@ import {
15
15
  findHarness,
16
16
  parseProvenance,
17
17
  getUserHarnessesDir,
18
+ sourceLabel,
18
19
  } from "./catalogue.ts";
19
20
  import { resolveCatalogueEntry } from "./resolve.ts";
20
- import { display, compareVersions } from "./util.ts";
21
+ import {
22
+ isOfficialSource,
23
+ officialNameFromSource,
24
+ listOfficialDistros,
25
+ isOfficialCatalogueUnavailable,
26
+ } from "./github.ts";
27
+ import { display, compareVersions, readProjectPackages, readGlobalPackages } from "./util.ts";
21
28
 
22
29
  // --- list ---
23
30
 
24
31
  export async function handleList(pi: ExtensionAPI): Promise<void> {
25
32
  const catalogue = await readCatalogue();
26
- if (catalogue.length === 0) { display(pi, "No distros found in the catalogue."); return; }
33
+ const offlineNote = isOfficialCatalogueUnavailable()
34
+ ? "\n\n⚠️ Official distros could not be fetched from GitHub (offline or rate-limited?) — showing local distros only. Try again later."
35
+ : "";
36
+ if (catalogue.length === 0) {
37
+ display(pi, `No distros found in the catalogue.${offlineNote}`);
38
+ return;
39
+ }
27
40
  const rows = catalogue.map((h) => {
28
41
  const desc = h.description.length > 50 ? h.description.slice(0, 47) + "..." : h.description;
29
- return `| ${h.name} | ${h.title} | ${h.version} | ${h.source} | ${desc} |`;
42
+ return `| ${h.name} | ${h.title} | ${h.version} | ${sourceLabel(h.source)} | ${desc} |`;
30
43
  });
31
- const seedCount = catalogue.filter((h) => h.source === "seed").length;
32
- const userCount = catalogue.filter((h) => h.source === "user").length;
44
+ const officialCount = catalogue.filter((h) => isOfficialSource(h.source)).length;
45
+ const localCount = catalogue.filter((h) => h.source === "user").length;
46
+ const otherGhCount = catalogue.length - officialCount - localCount;
33
47
  display(pi, `## Distro catalogue
34
48
 
35
49
  | NAME | TITLE | VERSION | SOURCE | DESCRIPTION |
36
50
  |------|-------|---------|--------|-------------|
37
51
  ${rows.join("\n")}
38
52
 
39
- _Seeds: ${seedCount} · User: ${userCount} · Total: ${catalogue.length}_`);
53
+ _Official: ${officialCount} (GitHub) · Local: ${localCount}${otherGhCount > 0 ? ` · Other GitHub: ${otherGhCount}` : ""} · Total: ${catalogue.length}_${offlineNote}`);
40
54
  }
41
55
 
42
56
  // --- status ---
@@ -51,9 +65,26 @@ export async function handleStatus(pi: ExtensionAPI, ctx: ExtensionCommandContex
51
65
  if (existsSync(provenancePath)) {
52
66
  const prov = parseProvenance(readFileSync(provenancePath, "utf-8"));
53
67
  if (prov) {
54
- provenanceSection = `### Applied distro\n- **Name:** ${prov.appliedHarness}\n- **Version:** ${prov.appliedVersion}\n- **Source:** ${prov.sourceCatalogue}\n- **Last updated:** ${prov.lastUpdated}`;
55
- // Check for updates (local catalogue only; GitHub sources are not auto-fetched on status).
56
- if (!prov.sourceCatalogue.startsWith("github:")) {
68
+ provenanceSection = `### Applied distro\n- **Name:** ${prov.appliedHarness}\n- **Version:** ${prov.appliedVersion}\n- **Source:** ${sourceLabel(prov.sourceCatalogue)}\n- **Last updated:** ${prov.lastUpdated}`;
69
+ // Check for updates.
70
+ if (isOfficialSource(prov.sourceCatalogue)) {
71
+ // Official: cheap version check via the cached GitHub listing (no clone).
72
+ const officialName = officialNameFromSource(prov.sourceCatalogue);
73
+ const current = (await listOfficialDistros()).find((h) => h.name === officialName);
74
+ if (current) {
75
+ const cmp = compareVersions(current.version, prov.appliedVersion);
76
+ if (cmp > 0) {
77
+ updateSection = `### Update available\n**${prov.appliedHarness}** v${prov.appliedVersion} → v${current.version}. Run \`/pi-distro update\` to apply.`;
78
+ } else if (cmp < 0) {
79
+ updateSection = `### Version note\nApplied v${prov.appliedVersion} is newer than the official catalogue's v${current.version} (downgrade).`;
80
+ } // same version → no section
81
+ } else if (isOfficialCatalogueUnavailable()) {
82
+ updateSection = `### Update check\nCould not check for updates: the official catalogue is unreachable (offline or GitHub rate limit?). Try again later.`;
83
+ } else {
84
+ updateSection = `### Update check\nOfficial distro '${prov.appliedHarness}' is no longer in the catalogue (removed or renamed).`;
85
+ }
86
+ } else if (!prov.sourceCatalogue.startsWith("github:")) {
87
+ // Local (user) catalogue.
57
88
  const current = await resolveCatalogueEntry(prov.appliedHarness);
58
89
  if (current) {
59
90
  const cmp = compareVersions(current.version, prov.appliedVersion);
@@ -79,16 +110,10 @@ export async function handleStatus(pi: ExtensionAPI, ctx: ExtensionCommandContex
79
110
  const skillsList = snapshot.skills?.map((s) => `- ${s.name}`).join("\n") ?? "_(none)_";
80
111
  const contextList = snapshot.contextFiles?.map((c) => `- ${c.path}`).join("\n") ?? "_(none)_";
81
112
 
82
- let packages = "_(none)_";
83
- const settingsPath = join(cwd, ".pi", "settings.json");
84
- if (existsSync(settingsPath)) {
85
- try {
86
- const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
87
- if (Array.isArray(settings.packages) && settings.packages.length > 0) {
88
- packages = settings.packages.map((p: string) => `- \`${p}\``).join("\n");
89
- }
90
- } catch { /* ignore */ }
91
- }
113
+ const lpkgs = readProjectPackages(cwd);
114
+ const localPackages = lpkgs.length > 0 ? lpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
115
+ const gpkgs = readGlobalPackages();
116
+ const globalPackages = gpkgs.length > 0 ? gpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
92
117
 
93
118
  display(pi, `## Project distro status
94
119
 
@@ -101,8 +126,10 @@ ${updateSection}
101
126
  ${skillsList}
102
127
  - **Context files:**
103
128
  ${contextList}
104
- - **Installed packages:**
105
- ${packages}`);
129
+ - **Installed packages (project-local, ./.pi/settings.json):**
130
+ ${localPackages}
131
+ - **Installed packages (global, ~/.pi/agent/settings.json):**
132
+ ${globalPackages}`);
106
133
  }
107
134
 
108
135
  // --- remove ---
@@ -116,8 +143,8 @@ export async function handleRemove(pi: ExtensionAPI, ctx: ExtensionCommandContex
116
143
  ctx.ui.notify(`Distro '${name}' not found. Available: ${available}`, "error");
117
144
  return;
118
145
  }
119
- if (harness.source === "seed") {
120
- ctx.ui.notify(`'${name}' is a package seed and cannot be removed.`, "error");
146
+ if (harness.source.startsWith("github:")) {
147
+ ctx.ui.notify(`'${name}' is a GitHub distro (${sourceLabel(harness.source)}) and cannot be removed locally. Only user-saved distros in ~/.pi/harnesses/ can be removed.`, "error");
121
148
  return;
122
149
  }
123
150
  const confirmed = await ctx.ui.confirm("Remove harness?", `This deletes '~/.pi/harnesses/${name}/'. A backup will be saved to .trash/.`);
@@ -127,7 +154,7 @@ export async function handleRemove(pi: ExtensionAPI, ctx: ExtensionCommandContex
127
154
  const userDir = getUserHarnessesDir();
128
155
  const trashDir = join(userDir, ".trash", `${name}-${Date.now()}`);
129
156
  mkdirSync(trashDir, { recursive: true });
130
- cpSync(harness.dir, join(trashDir, name), { recursive: true });
131
- rmSync(harness.dir, { recursive: true, force: true });
157
+ cpSync(harness.dir!, join(trashDir, name), { recursive: true });
158
+ rmSync(harness.dir!, { recursive: true, force: true });
132
159
  ctx.ui.notify(`Removed distro '${name}'. (Backup in ~/.pi/harnesses/.trash/)`, "info");
133
160
  }
@@ -9,15 +9,17 @@ import type {
9
9
  import { readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
- import { listBundledFiles, parsePackageList } from "./catalogue.ts";
12
+ import { listBundledFiles, parsePackageListWithScope } from "./catalogue.ts";
13
13
  import { extractBody } from "./frontmatter.ts";
14
- import { parseGithubRef } from "./github.ts";
14
+ import { parseGithubRef, isOfficialSource, tempCloneRoot } from "./github.ts";
15
15
  import { resolveDistro } from "./resolve.ts";
16
16
  import { buildShowPreview } from "./show.ts";
17
17
  import {
18
18
  display,
19
19
  readProjectPackages,
20
+ readGlobalPackages,
20
21
  MERGE_RULE,
22
+ SCOPE_RULE,
21
23
  USER_INVOLVEMENT_RULE,
22
24
  PACKAGE_CONFLICT_RULE,
23
25
  } from "./util.ts";
@@ -27,8 +29,9 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
27
29
  if (!resolved) return;
28
30
  const { entry, cleanup } = resolved;
29
31
 
30
- // GitHub trust gate (same as deploy the user must confirm before anything is applied).
31
- if (cleanup) {
32
+ // GitHub trust gate — official distros are trusted and skip the warning;
33
+ // other GitHub refs require explicit user confirmation.
34
+ if (cleanup && !isOfficialSource(entry.source)) {
32
35
  const ref = parseGithubRef(nameArg!)!;
33
36
  const preview = await buildShowPreview(entry);
34
37
  const warning = `\n\n---\n\n⚠️ **Security warning:** This distro was fetched from \`${ref.displayRef}\` on GitHub. Picking components from an unknown distro is still **dangerous** — packages can execute code, extensions run at startup, and directives inject agent instructions. Review everything above carefully. You are responsible for what you install.`;
@@ -45,11 +48,15 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
45
48
  }
46
49
 
47
50
  // Parse the distro into selectable components.
48
- const fullMd = readFileSync(entry.harnessMdPath, "utf-8");
51
+ const fullMd = readFileSync(entry.harnessMdPath!, "utf-8");
49
52
  const directives = extractBody(fullMd);
50
- const packages = parsePackageList(directives);
53
+ const packages = parsePackageListWithScope(directives);
51
54
  const files = entry.filesDir ? await listBundledFiles(entry.filesDir) : [];
52
- const filesDirNote = entry.filesDir ? `\n\nBundled files are located at: \`${entry.filesDir}\`` : "";
55
+ const cloneRoot = entry.dir ? tempCloneRoot(entry.dir) : undefined;
56
+ const cloneCleanupNote = cloneRoot
57
+ ? `\n\nThe distro was fetched to a temporary GitHub clone. After the selected bundled files have been copied into the project, remove the clone: \`rm -rf ${cloneRoot}\``
58
+ : "";
59
+ const filesDirNote = (entry.filesDir ? `\n\nBundled files are located at: \`${entry.filesDir}\`` : "") + cloneCleanupNote;
53
60
 
54
61
  const snapshot = ctx.getSystemPromptOptions();
55
62
  const activeTools = snapshot.selectedTools?.length
@@ -57,9 +64,11 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
57
64
  : "_(none / default set)_";
58
65
  const pkgs = readProjectPackages(snapshot.cwd);
59
66
  const projectPackages = pkgs.length > 0 ? pkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
67
+ const gpkgs = readGlobalPackages();
68
+ const globalPackages = gpkgs.length > 0 ? gpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
60
69
 
61
70
  const packageList = packages.length > 0
62
- ? packages.map((p) => `- \`${p}\``).join("\n")
71
+ ? packages.map((p) => `- \`${p.source}\` [${p.scope}]`).join("\n")
63
72
  : "_(none)_";
64
73
  const fileList = files.length > 0
65
74
  ? files.map((f) => `- \`${f.source}\` → \`./${f.target}\``).join("\n")
@@ -88,19 +97,25 @@ extensions, themes, prompts, skills) — treat each as individually selectable t
88
97
  **Already-active tools in this session:**
89
98
  ${activeTools}
90
99
 
91
- **Packages already in this project's .pi/settings.json:**
100
+ **Packages already in this project's .pi/settings.json (project-local):**
92
101
  ${projectPackages}
93
102
 
94
- (Run \`pi list\` to also see globally-installed packages.)
103
+ **Packages already in ~/.pi/agent/settings.json (global, every project):**
104
+ ${globalPackages}
105
+
106
+ (Run \`pi list\` to see both local and global packages in one view.)
95
107
 
96
108
  ### Selection procedure (follow exactly, collaborating with the user)
97
109
 
98
110
  **0. User involvement rule** — ${USER_INVOLVEMENT_RULE}
99
111
 
112
+ **0a. Scope rule** — ${SCOPE_RULE} Apply the deployment-plan + preset flow to the items the user selects to apply (not the whole distro). Build the plan from only the selected components, offer the three presets (accept-defaults / all-global-where-safe / customize), apply the scope-safety guard for dangerous types, then install/place at the chosen scope.
113
+
100
114
  **1. Walk the user through each category, one at a time.** For each category (packages, then
101
115
  bundled files, then any other components described in the directives), use
102
116
  \`ctx.ui.select\`/\`ctx.ui.confirm\` to let the user pick which to apply. Present the items
103
- with their one-line purpose (from the directives). Let the user select any subset —
117
+ with their one-line purpose (from the directives) and their author scope hint
118
+ (\`[local]\`/\`[global]\`). Let the user select any subset —
104
119
  including none (skip the category entirely).
105
120
 
106
121
  **2. Surface dependencies.** As the user selects, **evaluate cross-component dependencies**
@@ -114,7 +129,7 @@ ${projectPackages}
114
129
  **3. Apply only the selected components**, with the same rules as a full deploy:
115
130
  - **Merge-don't-clobber** — ${MERGE_RULE}
116
131
  - **Package-redundancy check** — ${PACKAGE_CONFLICT_RULE}
117
- - For each selected package: \`pi install -l\` after confirming (do NOT pre-add to
132
+ - For each selected package: install at the chosen scope (\`pi install -l\` for local, \`pi install\` for global) after confirming (do NOT pre-add to
118
133
  settings.json by hand).
119
134
  - For each selected bundled file: copy/merge as the user chooses (overwrite / keep theirs /
120
135
  merge).
@@ -128,5 +143,6 @@ ${projectPackages}
128
143
 
129
144
  **5. Recommend a restart** if any packages or extensions were installed — they load at
130
145
  startup.`);
131
- // GitHub temp dir left in /tmp for the agent to read bundled files (ephemeral).
146
+ // GitHub temp dir is left in place so the agent can read the bundled files;
147
+ // the kickoff instructs the agent to remove it after copying them.
132
148
  }
@@ -9,10 +9,18 @@ import type {
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
11
  readCatalogue,
12
+ readLocalCatalogue,
12
13
  findHarness,
14
+ sourceLabel,
13
15
  } from "./catalogue.ts";
14
16
  import type { HarnessEntry } from "./catalogue.ts";
15
- import { looksLikeGithubRef, parseGithubRef, fetchGithubDistro } from "./github.ts";
17
+ import {
18
+ looksLikeGithubRef,
19
+ parseGithubRef,
20
+ fetchGithubDistro,
21
+ fetchOfficialDistro,
22
+ isOfficialCatalogueUnavailable,
23
+ } from "./github.ts";
16
24
 
17
25
  /**
18
26
  * Resolve a distro from a name argument (GitHub ref or local catalogue name).
@@ -41,30 +49,63 @@ export async function resolveDistro(
41
49
 
42
50
  // Local catalogue
43
51
  const catalogue = await readCatalogue();
52
+ const offlineNote = isOfficialCatalogueUnavailable()
53
+ ? " (Official distros could not be fetched from GitHub — offline or rate-limited? Try again later.)"
54
+ : "";
44
55
  if (catalogue.length === 0) {
45
- ctx.ui.notify("No distros found. Run /pi-distro save to create one.", "warning");
56
+ ctx.ui.notify(
57
+ isOfficialCatalogueUnavailable()
58
+ ? "Could not reach GitHub to list official distros (offline or rate-limited?), and no local distros exist. Retry later, or run /pi-distro save to create one."
59
+ : "No distros found. Run /pi-distro save to create one.",
60
+ "warning",
61
+ );
46
62
  return undefined;
47
63
  }
48
64
  if (nameArg) {
49
65
  const harness = findHarness(nameArg, catalogue);
50
66
  if (!harness) {
51
67
  const available = catalogue.map((h) => h.name).join(", ") || "(none)";
52
- ctx.ui.notify(`Distro '${nameArg}' not found. Available: ${available}`, "error");
68
+ ctx.ui.notify(`Distro '${nameArg}' not found. Available: ${available}${offlineNote}`, "error");
53
69
  return undefined;
54
70
  }
55
- return { entry: harness };
71
+ return resolveEntry(harness, ctx);
56
72
  }
57
73
  // No nameArg → interactive selector
58
74
  const theme = ctx.ui.theme;
59
- const labels = catalogue.map((h) => `${theme.bold(h.name)} — ${h.description}`);
75
+ const labels = catalogue.map((h) => `${theme.bold(h.name)} — ${h.description} [${sourceLabel(h.source)}]`);
60
76
  const selected = await ctx.ui.select(`Select a distro to ${verb}:`, labels);
61
77
  if (selected === undefined) return undefined;
62
78
  const harness = catalogue[labels.indexOf(selected)];
63
- return harness ? { entry: harness } : undefined;
79
+ return harness ? resolveEntry(harness, ctx) : undefined;
64
80
  }
65
81
 
66
- /** Resolve the current catalogue entry for a distro by name (local catalogue only). */
82
+ /**
83
+ * Resolve a catalogue entry to a usable { entry, cleanup? }.
84
+ * Official listing entries (needsFetch) are fetched from GitHub here — the actual
85
+ * clone happens at selection time, not at catalogue read. Local entries are
86
+ * returned as-is (no cleanup).
87
+ */
88
+ async function resolveEntry(
89
+ harness: HarnessEntry,
90
+ ctx: ExtensionCommandContext,
91
+ ): Promise<{ entry: HarnessEntry; cleanup?: () => void } | undefined> {
92
+ if (harness.needsFetch) {
93
+ ctx.ui.notify(`Fetching official distro '${harness.name}' from GitHub…`, "info");
94
+ try {
95
+ const fetched = fetchOfficialDistro(harness.name);
96
+ return { entry: fetched.entry, cleanup: fetched.cleanup };
97
+ } catch (err) {
98
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
99
+ return undefined;
100
+ }
101
+ }
102
+ return { entry: harness };
103
+ }
104
+
105
+ /** Resolve the current catalogue entry for a distro by name (local/user catalogue only —
106
+ * no network). Only called for non-GitHub provenance sources; GitHub sources
107
+ * are re-fetched directly by the update command. */
67
108
  export async function resolveCatalogueEntry(name: string): Promise<HarnessEntry | undefined> {
68
- const catalogue = await readCatalogue();
109
+ const catalogue = readLocalCatalogue();
69
110
  return findHarness(name, catalogue);
70
111
  }