@msdavid/pi-distro 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-07-10
4
+
5
+ - **Dynamic official distros from GitHub**: official distros (`minimal`, `web-fullstack`,
6
+ `cc-knockoff`) are no longer bundled inside the npm package. The catalogue fetches them
7
+ dynamically from the [`msdavid/pi-distro`](https://github.com/msdavid/pi-distro) repo's
8
+ `harnesses/` directory via the GitHub Contents API (listed cheaply, cloned on demand
9
+ when selected). Publishing a new official distro now only requires pushing to the repo —
10
+ no npm release needed. Selectors and `/pi-distro list` label each distro's source
11
+ clearly: **Official**, **Local**, or **GitHub (<owner>/<repo>)**. Official distros come
12
+ from the trusted package repo and skip the GitHub security confirmation that other-repo
13
+ distros require. Network failures degrade gracefully (local-only catalogue).
14
+ - **`harnesses/` removed from the npm tarball**: `package.json` `files` no longer includes
15
+ `harnesses/`. The directory remains in the repo as the GitHub source of truth.
16
+ - **`/pi-distro status` update check** now also checks official (GitHub) sources for
17
+ updates via the cached listing, instead of only local sources.
18
+ - **Renamed `pi-distro-one` → `cc-knockoff`**: the distro formerly known as `pi-distro-one`
19
+ is renamed `cc-knockoff` everywhere (directory, frontmatter, docs). Projects with
20
+ `appliedHarness: pi-distro-one` provenance will show "no longer in the catalogue" —
21
+ redeploy as `cc-knockoff` to migrate. No automatic migration is performed.
22
+
3
23
  ## 0.2.0 - 2026-07-08
4
24
 
5
25
  - **`/pi-distro undeploy`**: the reverse of `deploy` — removes an applied distro from the
@@ -35,7 +55,7 @@
35
55
  - **Semantic redundancy check**: the package-conflict check now evaluates semantic overlap
36
56
  (different tool names, similar function), not just exact name collisions. Offers
37
57
  skip / replace / keep both / cancel.
38
- - **Auto-expand tool outputs**: pi-distro-one's status-line extension now auto-expands
58
+ - **Auto-expand tool outputs**: cc-knockoff's status-line extension now auto-expands
39
59
  tool output on session start (the Ctrl+O effect), while keeping thinking blocks hidden.
40
60
  - **Per-distro README.md**: saved distros now include a `README.md` with an extended
41
61
  human-readable description. All seed distros ship one too.
package/README.md CHANGED
@@ -26,8 +26,8 @@ existing setup — nothing is silently overwritten.
26
26
  # 1. Install the package
27
27
  pi install npm:@msdavid/pi-distro
28
28
 
29
- # 2. Deploy a distro (pi-distro-one = a Claude Code-style multi-agent coder)
30
- /pi-distro deploy pi-distro-one
29
+ # 2. Deploy a distro (cc-knockoff = a Claude Code-style multi-agent coder)
30
+ /pi-distro deploy cc-knockoff
31
31
 
32
32
  # 3. Restart pi — done.
33
33
  ```
@@ -145,7 +145,7 @@ to apply — just the packages you want, or a single extension, or a subset of t
145
145
  This is how you combine pieces from different distros to build your own:
146
146
 
147
147
  ```bash
148
- /pi-distro pick pi-distro-one # pick, say, just pi-subagents + the statusline
148
+ /pi-distro pick cc-knockoff # pick, say, just pi-subagents + the statusline
149
149
  /pi-distro pick web-fullstack # then add web research from another distro
150
150
  /pi-distro save # snapshot the combination as your own distro
151
151
  ```
@@ -166,7 +166,7 @@ doesn't write provenance (it's a custom config, not an applied distro) — run
166
166
  ```bash
167
167
  /pi-distro status # shows the applied distro + live config
168
168
  /pi-distro list # lists all available distros
169
- /pi-distro show pi-distro-one # dry-run preview of what a distro would do
169
+ /pi-distro show cc-knockoff # dry-run preview of what a distro would do
170
170
  ```
171
171
 
172
172
  ### Bring your config to any machine
@@ -239,22 +239,31 @@ All interaction happens through a single slash command — there is no standalon
239
239
 
240
240
  The effective catalogue is the union of:
241
241
 
242
- - **Package seeds** — shipped with `@msdavid/pi-distro` under `harnesses/`. Read straight
243
- from the installed package at runtime. Currently:
242
+ - **Official distros** — fetched dynamically from the [`msdavid/pi-distro`](https://github.com/msdavid/pi-distro)
243
+ repo's `harnesses/` directory on GitHub (no longer bundled inside the npm package, so
244
+ new official distros ship by pushing to the repo — no npm release needed). The catalogue
245
+ is listed via the GitHub Contents API and each distro is cloned on demand when you
246
+ select it. Currently:
244
247
  - **minimal** — clean starting point: basic `AGENTS.md` + `.pi/settings.json`.
245
248
  - **web-fullstack** — React/Node project with web research, review skills, restricted tools.
246
- - **pi-distro-one** — a Claude Code–style multi-agent coder (see below).
249
+ - **cc-knockoff** — a Claude Code–style multi-agent coder (see below).
247
250
  - **User distros** — saved by you to `~/.pi/harnesses/<name>/` via `/pi-distro save`.
248
- - **GitHub distros** — fetched on-demand from any GitHub repo.
251
+ - **GitHub distros** — fetched on-demand from any GitHub repo (`/pi-distro deploy owner/repo`).
249
252
  - **Partial/combined configs** — built from `/pi-distro pick` across multiple distros, then
250
253
  saved as a user distro.
251
254
 
255
+ Selectors and `/pi-distro list` show each distro's source clearly: **Official** (the
256
+ `msdavid/pi-distro` repo), **Local** (your `~/.pi/harnesses/`), or **GitHub (<owner>/<repo>)**
257
+ for distros from other repos.
258
+
252
259
  On a name collision, the **user distro takes precedence** — save a distro with the same
253
- name as a seed to override it.
260
+ name as an official distro to override it. Official distros come from a trusted repo (the
261
+ package's own repo), so they skip the GitHub security confirmation that other-repo
262
+ distros require.
254
263
 
255
- ### pi-distro-one
264
+ ### cc-knockoff
256
265
 
257
- `pi-distro-one` is the author's draft shot at using the most popular pi coding packages to
266
+ `cc-knockoff` is the author's draft shot at using the most popular pi coding packages to
258
267
  closely resemble the capabilities of Claude Code. It's opinionated — spawning and
259
268
  coordinating autonomous sub-agents is the primary capability, with web research, browser
260
269
  automation, live shell, model routing, and task management integrated in support. It
@@ -300,7 +309,9 @@ it's saved to `~/.pi/harnesses/<name>/` with a `README.md` describing the distro
300
309
 
301
310
  ### Author a distro by hand
302
311
 
303
- Create a directory under `~/.pi/harnesses/<name>/` (or contribute a seed to the package)
312
+ Create a directory under `~/.pi/harnesses/<name>/` (or contribute an official distro via a
313
+ PR to the [`msdavid/pi-distro`](https://github.com/msdavid/pi-distro) repo's `harnesses/`
314
+ dir)
304
315
  with a `harness.md` and optional `files/`. See
305
316
  [docs/authoring.md](docs/authoring.md) for the complete format reference — frontmatter
306
317
  fields, bundled file conventions, directive sections, and merge-don't-clobber
package/docs/authoring.md CHANGED
@@ -187,14 +187,14 @@ frontmatter + directives) with a provenance header injected at the top of the bo
187
187
  <!-- pi-distro provenance
188
188
  appliedHarness: <name>
189
189
  appliedVersion: <version>
190
- sourceCatalogue: <user|seed|none>
190
+ sourceCatalogue: <user|github:owner/repo[/subpath]>
191
191
  lastUpdated: <ISO8601>
192
192
  -->
193
193
  ```
194
194
 
195
195
  - `appliedHarness`: the name of the distro that was deployed.
196
196
  - `appliedVersion`: the version from the distro frontmatter.
197
- - `sourceCatalogue`: `seed` (from the package) or `user` (from `~/.pi/harnesses/`).
197
+ - `sourceCatalogue`: `user` (from `~/.pi/harnesses/`) or `github:owner/repo[/subpath]` (official distros are `github:msdavid/pi-distro/harnesses/<name>`).
198
198
  - `lastUpdated`: ISO 8601 timestamp of the last apply/save.
199
199
 
200
200
  The provenance file is updated automatically by the `deploy`, `save`, and `undeploy`
@@ -1,25 +1,30 @@
1
1
  /**
2
2
  * Catalogue reading logic for pi-distro.
3
3
  *
4
- * Reads seed harnesses from the installed package dir and user harnesses
5
- * from ~/.pi/harnesses/. On name collision, user takes precedence.
4
+ * The effective catalogue is the union of:
5
+ * - **Official distros** fetched dynamically from `msdavid/pi-distro`'s
6
+ * `harnesses/` directory on GitHub (no longer bundled in the npm package).
7
+ * - **User distros** — saved locally to `~/.pi/harnesses/`.
8
+ * On a name collision, the user distro takes precedence (so a user can override
9
+ * an official distro by saving one with the same name).
6
10
  */
7
11
 
8
12
  import { readdirSync, readFileSync, existsSync } from "node:fs";
9
- import { join, dirname, relative } from "node:path";
13
+ import { join, relative } from "node:path";
10
14
  import { homedir } from "node:os";
11
- import { fileURLToPath } from "node:url";
12
15
  import { parseFrontmatter, extractBody } from "./frontmatter.ts";
16
+ import { listOfficialDistros, isOfficialSource } from "./github.ts";
13
17
 
14
18
  export interface HarnessEntry {
15
19
  name: string;
16
20
  title: string;
17
21
  description: string;
18
22
  version: string;
19
- source: string; // "seed", "user", or "github:<owner>/<repo>[/subpath]"
20
- dir: string;
21
- harnessMdPath: string;
23
+ source: string; // "user", "github:owner/repo[/subpath]", or official "github:msdavid/pi-distro/harnesses/<name>"
24
+ dir?: string; // on-disk dir (user distros, or a fetched GitHub clone). Absent for official *listing* entries.
25
+ harnessMdPath?: string; // path to harness.md. Absent for official *listing* entries (fetched on selection).
22
26
  filesDir?: string;
27
+ needsFetch?: boolean; // true for official listing entries — must fetchGithubDistro before use
23
28
  }
24
29
 
25
30
  export interface BundledFile {
@@ -64,11 +69,13 @@ export function parseProvenance(content: string): Provenance | null {
64
69
  };
65
70
  }
66
71
 
67
- /** Synchronous catalogue name read for autocomplete (best-effort). */
72
+ /** Synchronous catalogue name read for autocomplete (best-effort, local only).
73
+ * Official distros live on GitHub and aren't available to synchronous
74
+ * tab-completion — they appear once a command runs and reads the catalogue. */
68
75
  export function getCatalogueNamesSync(): string[] {
76
+ const dir = getUserHarnessesDir();
69
77
  const names: string[] = [];
70
- for (const dir of [getSeedHarnessesDir(), getUserHarnessesDir()]) {
71
- if (!existsSync(dir)) continue;
78
+ if (existsSync(dir)) {
72
79
  try {
73
80
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
74
81
  if (entry.isDirectory() && entry.name !== ".trash" && existsSync(join(dir, entry.name, "harness.md"))) {
@@ -80,44 +87,36 @@ export function getCatalogueNamesSync(): string[] {
80
87
  return [...new Set(names)].sort();
81
88
  }
82
89
 
83
- /**
84
- * Resolve the package root directory (the dir containing package.json).
85
- * Uses import.meta.url — in extensions/index.ts this resolves to the
86
- * extensions/ dir, so the package dir is one level up.
87
- */
88
- export function getPackageDir(): string {
89
- const extensionsDir = dirname(fileURLToPath(import.meta.url));
90
- return dirname(extensionsDir);
91
- }
92
-
93
- /** Directory containing seed harnesses shipped with the package. */
94
- export function getSeedHarnessesDir(): string {
95
- return join(getPackageDir(), "harnesses");
96
- }
97
-
98
90
  /** Directory containing user-saved harnesses (~/.pi/harnesses/). */
99
91
  export function getUserHarnessesDir(): string {
100
92
  return join(homedir(), ".pi", "harnesses");
101
93
  }
102
94
 
103
95
  /**
104
- * Read the effective catalogue: seeds ∪ user harnesses.
96
+ * Read the effective catalogue: official distros (GitHub, dynamic) ∪ user harnesses.
105
97
  * On name collision, user takes precedence.
106
- * Returns sorted: seeds first (alphabetical), then user (alphabetical).
98
+ * Returns sorted: official first (alphabetical), then user (alphabetical).
99
+ * Never throws on network failure — official distros are simply omitted and the
100
+ * catalogue degrades to local-only.
107
101
  */
108
102
  export async function readCatalogue(): Promise<HarnessEntry[]> {
109
- const seeds = readHarnessesFromDir(getSeedHarnessesDir(), "seed");
103
+ const official = await listOfficialDistros();
110
104
  const users = readHarnessesFromDir(getUserHarnessesDir(), "user");
111
105
 
112
106
  // User takes precedence on collision
113
107
  const userNames = new Set(users.map((u) => u.name));
114
- const filteredSeeds = seeds.filter((s) => !userNames.has(s.name));
108
+ const filteredOfficial = official.filter((o) => !userNames.has(o.name));
115
109
 
116
- // Sort each group alphabetically
117
- filteredSeeds.sort((a, b) => a.name.localeCompare(b.name));
110
+ filteredOfficial.sort((a, b) => a.name.localeCompare(b.name));
118
111
  users.sort((a, b) => a.name.localeCompare(b.name));
119
112
 
120
- return [...filteredSeeds, ...users];
113
+ return [...filteredOfficial, ...users];
114
+ }
115
+
116
+ /** Read only local (user) harnesses — no network. Used by update/status local
117
+ * resolution and anywhere a network call is undesirable. */
118
+ export function readLocalCatalogue(): HarnessEntry[] {
119
+ return readHarnessesFromDir(getUserHarnessesDir(), "user");
121
120
  }
122
121
 
123
122
  /** Find a harness by name in the catalogue. */
@@ -156,7 +155,7 @@ export async function readFullHarnessMd(harnessMdPath: string): Promise<string>
156
155
 
157
156
  function readHarnessesFromDir(
158
157
  dir: string,
159
- source: "seed" | "user",
158
+ source: "user",
160
159
  ): HarnessEntry[] {
161
160
  if (!existsSync(dir)) return [];
162
161
 
@@ -219,3 +218,13 @@ function walkDir(
219
218
  }
220
219
  }
221
220
  }
221
+
222
+ // --- Source labelling (for selectors / list / status) ---
223
+
224
+ /** A short human label for a distro source, shown in selectors and lists. */
225
+ export function sourceLabel(source: string): string {
226
+ if (isOfficialSource(source)) return "Official";
227
+ if (source === "user") return "Local";
228
+ if (source.startsWith("github:")) return `GitHub (${source.slice("github:".length)})`;
229
+ return source;
230
+ }
@@ -13,7 +13,7 @@ import { join } from "node:path";
13
13
  import { listBundledFiles, parseProvenance } 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 } from "./github.ts";
17
17
  import { resolveDistro } from "./resolve.ts";
18
18
  import { buildShowPreview } from "./show.ts";
19
19
  import {
@@ -31,7 +31,7 @@ export async function sendDeployKickoff(
31
31
  ctx: ExtensionCommandContext,
32
32
  harness: HarnessEntry,
33
33
  ): Promise<void> {
34
- const body = readFileSync(harness.harnessMdPath, "utf-8");
34
+ const body = readFileSync(harness.harnessMdPath!, "utf-8");
35
35
  const directives = extractBody(body);
36
36
  const files = harness.filesDir ? await listBundledFiles(harness.filesDir) : [];
37
37
  const fileList = files.length > 0
@@ -119,8 +119,9 @@ export async function handleDeploy(pi: ExtensionAPI, ctx: ExtensionCommandContex
119
119
  if (!resolved) return;
120
120
  const { entry, cleanup } = resolved;
121
121
 
122
- // GitHub trust gate (local distros are trusted by being in the catalogue).
123
- if (cleanup) {
122
+ // GitHub trust gate official distros (msdavid/pi-distro) are trusted and skip
123
+ // the warning; other GitHub refs require explicit user confirmation.
124
+ if (cleanup && !isOfficialSource(entry.source)) {
124
125
  const ref = parseGithubRef(nameArg!)!;
125
126
  const preview = await buildShowPreview(entry);
126
127
  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.`;
@@ -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,90 @@ 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
+ /**
156
+ * List official distros from OFFICIAL_REPO's `harnesses/` directory via the GitHub
157
+ * Contents API (one call) + a raw frontmatter fetch per distro.
158
+ *
159
+ * Returns *listing* entries: `needsFetch: true`, no `dir`/`filesDir` (those are
160
+ * populated by `fetchOfficialDistro()` when the user actually selects one).
161
+ *
162
+ * Never throws — on any network/parse failure returns [] (caller degrades to
163
+ * local-only catalogue). Results are cached for OFFICIAL_LIST_TTL_MS.
164
+ */
165
+ export async function listOfficialDistros(): Promise<HarnessEntry[]> {
166
+ if (officialListCache && Date.now() - officialListCache.fetchedAt < OFFICIAL_LIST_TTL_MS) {
167
+ return officialListCache.entries;
168
+ }
169
+ const entries = await listOfficialDistrosUncached();
170
+ officialListCache = { entries, fetchedAt: Date.now() };
171
+ return entries;
172
+ }
173
+
174
+ async function listOfficialDistrosUncached(): Promise<HarnessEntry[]> {
175
+ // 1. List `harnesses/` subdirs via the Contents API.
176
+ const contentsUrl = `https://api.github.com/repos/${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/contents/${OFFICIAL_REPO.path}?ref=${OFFICIAL_REPO.ref}`;
177
+ let dirs: string[];
178
+ try {
179
+ const resp = await fetch(contentsUrl, {
180
+ headers: { "Accept": "application/vnd.github+json", "User-Agent": "pi-distro" },
181
+ });
182
+ if (!resp.ok) return [];
183
+ const listing = (await resp.json()) as Array<{ name: string; type: string }>;
184
+ dirs = listing.filter((e) => e.type === "dir").map((e) => e.name);
185
+ } catch {
186
+ return [];
187
+ }
188
+
189
+ // 2. Fetch each distro's harness.md frontmatter (raw, unauthenticated).
190
+ const entries: HarnessEntry[] = [];
191
+ await Promise.all(dirs.map(async (name) => {
192
+ const rawUrl = `https://raw.githubusercontent.com/${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.ref}/${OFFICIAL_REPO.path}/${name}/harness.md`;
193
+ try {
194
+ const resp = await fetch(rawUrl, { headers: { "User-Agent": "pi-distro" } });
195
+ if (!resp.ok) return;
196
+ const content = await resp.text();
197
+ const fm = parseFrontmatter(content);
198
+ if (!fm?.name) return;
199
+ entries.push({
200
+ name: fm.name,
201
+ title: fm.title ?? fm.name,
202
+ description: fm.description ?? "",
203
+ version: fm.version ?? "0.0.0",
204
+ source: officialSource(fm.name),
205
+ needsFetch: true,
206
+ });
207
+ } catch {
208
+ // skip this distro
209
+ }
210
+ }));
211
+ entries.sort((a, b) => a.name.localeCompare(b.name));
212
+ return entries;
213
+ }
214
+
215
+ /** Invalidate the official-list cache (used by tests / explicit refresh). */
216
+ export function clearOfficialListCache(): void {
217
+ officialListCache = null;
218
+ }
219
+
220
+ /**
221
+ * Fetch an official distro by name: shallow-clones OFFICIAL_REPO and points at
222
+ * `harnesses/<name>/`. Returns { entry, cleanup } like fetchGithubDistro.
223
+ * Throws on clone failure or if the distro/harness.md is missing.
224
+ */
225
+ export function fetchOfficialDistro(
226
+ name: string,
227
+ ): { entry: HarnessEntry; cleanup: () => void } {
228
+ return fetchGithubDistro({
229
+ owner: OFFICIAL_REPO.owner,
230
+ repo: OFFICIAL_REPO.repo,
231
+ subPath: `${OFFICIAL_REPO.path}/${name}`,
232
+ displayRef: `${OFFICIAL_REPO.owner}/${OFFICIAL_REPO.repo}/${OFFICIAL_REPO.path}/${name}`,
233
+ });
234
+ }
@@ -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,8 +15,10 @@ import {
15
15
  findHarness,
16
16
  parseProvenance,
17
17
  getUserHarnessesDir,
18
+ sourceLabel,
18
19
  } from "./catalogue.ts";
19
20
  import { resolveCatalogueEntry } from "./resolve.ts";
21
+ import { isOfficialSource, officialNameFromSource, listOfficialDistros } from "./github.ts";
20
22
  import { display, compareVersions } from "./util.ts";
21
23
 
22
24
  // --- list ---
@@ -26,17 +28,18 @@ export async function handleList(pi: ExtensionAPI): Promise<void> {
26
28
  if (catalogue.length === 0) { display(pi, "No distros found in the catalogue."); return; }
27
29
  const rows = catalogue.map((h) => {
28
30
  const desc = h.description.length > 50 ? h.description.slice(0, 47) + "..." : h.description;
29
- return `| ${h.name} | ${h.title} | ${h.version} | ${h.source} | ${desc} |`;
31
+ return `| ${h.name} | ${h.title} | ${h.version} | ${sourceLabel(h.source)} | ${desc} |`;
30
32
  });
31
- const seedCount = catalogue.filter((h) => h.source === "seed").length;
32
- const userCount = catalogue.filter((h) => h.source === "user").length;
33
+ const officialCount = catalogue.filter((h) => isOfficialSource(h.source)).length;
34
+ const localCount = catalogue.filter((h) => h.source === "user").length;
35
+ const otherGhCount = catalogue.length - officialCount - localCount;
33
36
  display(pi, `## Distro catalogue
34
37
 
35
38
  | NAME | TITLE | VERSION | SOURCE | DESCRIPTION |
36
39
  |------|-------|---------|--------|-------------|
37
40
  ${rows.join("\n")}
38
41
 
39
- _Seeds: ${seedCount} · User: ${userCount} · Total: ${catalogue.length}_`);
42
+ _Official: ${officialCount} (GitHub) · Local: ${localCount}${otherGhCount > 0 ? ` · Other GitHub: ${otherGhCount}` : ""} · Total: ${catalogue.length}_`);
40
43
  }
41
44
 
42
45
  // --- status ---
@@ -51,9 +54,24 @@ export async function handleStatus(pi: ExtensionAPI, ctx: ExtensionCommandContex
51
54
  if (existsSync(provenancePath)) {
52
55
  const prov = parseProvenance(readFileSync(provenancePath, "utf-8"));
53
56
  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:")) {
57
+ provenanceSection = `### Applied distro\n- **Name:** ${prov.appliedHarness}\n- **Version:** ${prov.appliedVersion}\n- **Source:** ${sourceLabel(prov.sourceCatalogue)}\n- **Last updated:** ${prov.lastUpdated}`;
58
+ // Check for updates.
59
+ if (isOfficialSource(prov.sourceCatalogue)) {
60
+ // Official: cheap version check via the cached GitHub listing (no clone).
61
+ const officialName = officialNameFromSource(prov.sourceCatalogue);
62
+ const current = (await listOfficialDistros()).find((h) => h.name === officialName);
63
+ if (current) {
64
+ const cmp = compareVersions(current.version, prov.appliedVersion);
65
+ if (cmp > 0) {
66
+ updateSection = `### Update available\n**${prov.appliedHarness}** v${prov.appliedVersion} → v${current.version}. Run \`/pi-distro update\` to apply.`;
67
+ } else if (cmp < 0) {
68
+ updateSection = `### Version note\nApplied v${prov.appliedVersion} is newer than the official catalogue's v${current.version} (downgrade).`;
69
+ } // same version → no section
70
+ } else {
71
+ updateSection = `### Update check\nOfficial distro '${prov.appliedHarness}' is no longer in the catalogue (removed or renamed).`;
72
+ }
73
+ } else if (!prov.sourceCatalogue.startsWith("github:")) {
74
+ // Local (user) catalogue.
57
75
  const current = await resolveCatalogueEntry(prov.appliedHarness);
58
76
  if (current) {
59
77
  const cmp = compareVersions(current.version, prov.appliedVersion);
@@ -116,8 +134,8 @@ export async function handleRemove(pi: ExtensionAPI, ctx: ExtensionCommandContex
116
134
  ctx.ui.notify(`Distro '${name}' not found. Available: ${available}`, "error");
117
135
  return;
118
136
  }
119
- if (harness.source === "seed") {
120
- ctx.ui.notify(`'${name}' is a package seed and cannot be removed.`, "error");
137
+ if (harness.source.startsWith("github:")) {
138
+ 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
139
  return;
122
140
  }
123
141
  const confirmed = await ctx.ui.confirm("Remove harness?", `This deletes '~/.pi/harnesses/${name}/'. A backup will be saved to .trash/.`);
@@ -127,7 +145,7 @@ export async function handleRemove(pi: ExtensionAPI, ctx: ExtensionCommandContex
127
145
  const userDir = getUserHarnessesDir();
128
146
  const trashDir = join(userDir, ".trash", `${name}-${Date.now()}`);
129
147
  mkdirSync(trashDir, { recursive: true });
130
- cpSync(harness.dir, join(trashDir, name), { recursive: true });
131
- rmSync(harness.dir, { recursive: true, force: true });
148
+ cpSync(harness.dir!, join(trashDir, name), { recursive: true });
149
+ rmSync(harness.dir!, { recursive: true, force: true });
132
150
  ctx.ui.notify(`Removed distro '${name}'. (Backup in ~/.pi/harnesses/.trash/)`, "info");
133
151
  }
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
 
12
12
  import { listBundledFiles, parsePackageList } from "./catalogue.ts";
13
13
  import { extractBody } from "./frontmatter.ts";
14
- import { parseGithubRef } from "./github.ts";
14
+ import { parseGithubRef, isOfficialSource } from "./github.ts";
15
15
  import { resolveDistro } from "./resolve.ts";
16
16
  import { buildShowPreview } from "./show.ts";
17
17
  import {
@@ -27,8 +27,9 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
27
27
  if (!resolved) return;
28
28
  const { entry, cleanup } = resolved;
29
29
 
30
- // GitHub trust gate (same as deploy the user must confirm before anything is applied).
31
- if (cleanup) {
30
+ // GitHub trust gate — official distros are trusted and skip the warning;
31
+ // other GitHub refs require explicit user confirmation.
32
+ if (cleanup && !isOfficialSource(entry.source)) {
32
33
  const ref = parseGithubRef(nameArg!)!;
33
34
  const preview = await buildShowPreview(entry);
34
35
  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,7 +46,7 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
45
46
  }
46
47
 
47
48
  // Parse the distro into selectable components.
48
- const fullMd = readFileSync(entry.harnessMdPath, "utf-8");
49
+ const fullMd = readFileSync(entry.harnessMdPath!, "utf-8");
49
50
  const directives = extractBody(fullMd);
50
51
  const packages = parsePackageList(directives);
51
52
  const files = entry.filesDir ? await listBundledFiles(entry.filesDir) : [];
@@ -9,10 +9,12 @@ 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 { looksLikeGithubRef, parseGithubRef, fetchGithubDistro, fetchOfficialDistro } from "./github.ts";
16
18
 
17
19
  /**
18
20
  * Resolve a distro from a name argument (GitHub ref or local catalogue name).
@@ -52,19 +54,44 @@ export async function resolveDistro(
52
54
  ctx.ui.notify(`Distro '${nameArg}' not found. Available: ${available}`, "error");
53
55
  return undefined;
54
56
  }
55
- return { entry: harness };
57
+ return resolveEntry(harness, ctx);
56
58
  }
57
59
  // No nameArg → interactive selector
58
60
  const theme = ctx.ui.theme;
59
- const labels = catalogue.map((h) => `${theme.bold(h.name)} — ${h.description}`);
61
+ const labels = catalogue.map((h) => `${theme.bold(h.name)} — ${h.description} [${sourceLabel(h.source)}]`);
60
62
  const selected = await ctx.ui.select(`Select a distro to ${verb}:`, labels);
61
63
  if (selected === undefined) return undefined;
62
64
  const harness = catalogue[labels.indexOf(selected)];
63
- return harness ? { entry: harness } : undefined;
65
+ return harness ? resolveEntry(harness, ctx) : undefined;
64
66
  }
65
67
 
66
- /** Resolve the current catalogue entry for a distro by name (local catalogue only). */
68
+ /**
69
+ * Resolve a catalogue entry to a usable { entry, cleanup? }.
70
+ * Official listing entries (needsFetch) are fetched from GitHub here — the actual
71
+ * clone happens at selection time, not at catalogue read. Local entries are
72
+ * returned as-is (no cleanup).
73
+ */
74
+ async function resolveEntry(
75
+ harness: HarnessEntry,
76
+ ctx: ExtensionCommandContext,
77
+ ): Promise<{ entry: HarnessEntry; cleanup?: () => void } | undefined> {
78
+ if (harness.needsFetch) {
79
+ ctx.ui.notify(`Fetching official distro '${harness.name}' from GitHub…`, "info");
80
+ try {
81
+ const fetched = fetchOfficialDistro(harness.name);
82
+ return { entry: fetched.entry, cleanup: fetched.cleanup };
83
+ } catch (err) {
84
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
85
+ return undefined;
86
+ }
87
+ }
88
+ return { entry: harness };
89
+ }
90
+
91
+ /** Resolve the current catalogue entry for a distro by name (local/user catalogue only —
92
+ * no network). Only called for non-GitHub provenance sources; GitHub sources
93
+ * are re-fetched directly by the update command. */
67
94
  export async function resolveCatalogueEntry(name: string): Promise<HarnessEntry | undefined> {
68
- const catalogue = await readCatalogue();
95
+ const catalogue = readLocalCatalogue();
69
96
  return findHarness(name, catalogue);
70
97
  }
@@ -137,11 +137,12 @@ and the full draft. Ask for confirmation or edits. Do NOT proceed until the user
137
137
 
138
138
  **3. Choose save target.** Ask the user whether to:
139
139
  - **(a) Save as a new distro** — ask for a name (validate the slug; if it collides with an
140
- existing user distro or a seed name, warn and ask whether to overwrite), then create
140
+ existing user distro or an official distro name (fetched from GitHub), warn and ask
141
+ whether to overwrite), then create
141
142
  \`~/.pi/harnesses/<name>/\`.
142
143
  - **(b) Update an existing distro** — let the user pick from the existing user distros
143
- above. Refuse to update a name that is NOT in the existing-user list (those are package
144
- seeds and are read-only). Before overwriting, back the old distro up: copy
144
+ above. Refuse to update a name that is NOT in the existing-user list (official distros
145
+ live on GitHub and are read-only locally). Before overwriting, back the old distro up: copy
145
146
  \`~/.pi/harnesses/<name>/\` to
146
147
  \`~/.pi/harnesses/.trash/<name>-<timestamp>/\` (create \`.trash/\` if needed).
147
148
  **Bump the version:** read the old distro's \`version\` field and increment it using