@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-07-13
4
+
5
+ - **Install scope: local vs global.** Every component of a distro can now be installed
6
+ **project-locally** (default, `./.pi/`) or **globally** (`~/.pi/agent/`, opt-in). At
7
+ deploy/pick time the agent builds a deployment plan and offers three presets
8
+ (accept-defaults / all-global-where-safe / customize), with safety guards for dangerous
9
+ types (settings.json, SYSTEM.md/APPEND_SYSTEM.md, AGENTS.md). Authors can suggest a
10
+ global default per package with a `(global)` marker. `status`/`undeploy` detect where
11
+ each component actually landed by checking both scopes; `save` captures global config
12
+ too (marked `(global)`).
13
+ - **Offline/rate-limit awareness**: when the official catalogue can't be fetched from
14
+ GitHub, `list`, `status`, and the selectors now say so explicitly instead of implying
15
+ distros don't exist or were removed.
16
+ - **Official distro names in tab-completion**: the last successful catalogue listing is
17
+ cached to `~/.pi/harnesses/.official-cache.json`, so `deploy`/`show`/`pick` completion
18
+ offers official names, not just local ones.
19
+ - **Robustness fixes**: provenance header parsing is now field-order-independent;
20
+ `compareVersions` handles `v` prefixes and prerelease suffixes (`1.0.0-beta < 1.0.0`);
21
+ the `(global)` scope marker is only honored between the package ref and the description
22
+ dash (prose mentions no longer count); an h1 heading now also terminates the
23
+ `## pi packages` section; `status` renders object-form package entries correctly.
24
+ - **Save snapshot hygiene**: skips `.pi/loops/` runtime state, detects binary files
25
+ instead of inlining garbage, and caps the total inlined snapshot at 256 KB (per-file
26
+ cap unchanged; omitted files are listed by path).
27
+ - **GitHub temp clones cleaned up**: deploy/pick kickoffs now instruct the agent to
28
+ remove the temporary clone after copying bundled files.
29
+ - **CLI polish**: extra arguments are warned about instead of silently ignored; the
30
+ command description lists all subcommands.
31
+ - **Distro fixes**: `web-fullstack` 0.2.0 — removed the non-functional `tools` key from
32
+ bundled settings (pi restricts tools via the `--tools` launch flag, now documented in
33
+ the directives); `minimal` 0.1.1 — removed the empty `packages` array from bundled
34
+ settings (merge hazard); `trip-planner` 0.3.0 — statusline now provided by
35
+ `npm:pi-cc-status` (bundled extension removed), supi post-install aligned with
36
+ cc-knockoff's config-file flow, description shortened to the ≤300-char limit;
37
+ `cc-knockoff` 1.2.1 — README global-settings path corrected to
38
+ `~/.pi/agent/settings.json`.
39
+ - **New test suite for shipped distros** (`tests/harnesses.test.ts`): validates
40
+ frontmatter rules (name/dir match, plain semver, description length), README presence,
41
+ and that the bundled-files manifest matches `files/` on disk in both directions.
42
+
43
+ ## 0.3.0 - 2026-07-10
44
+
45
+ - **Dynamic official distros from GitHub**: official distros (`minimal`, `web-fullstack`,
46
+ `cc-knockoff`) are no longer bundled inside the npm package. The catalogue fetches them
47
+ dynamically from the [`msdavid/pi-distro`](https://github.com/msdavid/pi-distro) repo's
48
+ `harnesses/` directory via the GitHub Contents API (listed cheaply, cloned on demand
49
+ when selected). Publishing a new official distro now only requires pushing to the repo —
50
+ no npm release needed. Selectors and `/pi-distro list` label each distro's source
51
+ clearly: **Official**, **Local**, or **GitHub (<owner>/<repo>)**. Official distros come
52
+ from the trusted package repo and skip the GitHub security confirmation that other-repo
53
+ distros require. Network failures degrade gracefully (local-only catalogue).
54
+ - **`harnesses/` removed from the npm tarball**: `package.json` `files` no longer includes
55
+ `harnesses/`. The directory remains in the repo as the GitHub source of truth.
56
+ - **`/pi-distro status` update check** now also checks official (GitHub) sources for
57
+ updates via the cached listing, instead of only local sources.
58
+ - **Renamed `pi-distro-one` → `cc-knockoff`**: the distro formerly known as `pi-distro-one`
59
+ is renamed `cc-knockoff` everywhere (directory, frontmatter, docs). Projects with
60
+ `appliedHarness: pi-distro-one` provenance will show "no longer in the catalogue" —
61
+ redeploy as `cc-knockoff` to migrate. No automatic migration is performed.
62
+
3
63
  ## 0.2.0 - 2026-07-08
4
64
 
5
65
  - **`/pi-distro undeploy`**: the reverse of `deploy` — removes an applied distro from the
@@ -35,7 +95,7 @@
35
95
  - **Semantic redundancy check**: the package-conflict check now evaluates semantic overlap
36
96
  (different tool names, similar function), not just exact name collisions. Offers
37
97
  skip / replace / keep both / cancel.
38
- - **Auto-expand tool outputs**: pi-distro-one's status-line extension now auto-expands
98
+ - **Auto-expand tool outputs**: cc-knockoff's status-line extension now auto-expands
39
99
  tool output on session start (the Ctrl+O effect), while keeping thinking blocks hidden.
40
100
  - **Per-distro README.md**: saved distros now include a `README.md` with an extended
41
101
  human-readable description. All seed distros ship one too.
package/README.md CHANGED
@@ -23,11 +23,13 @@ existing setup — nothing is silently overwritten.
23
23
  ## Get a fully-configured coder in 60 seconds
24
24
 
25
25
  ```bash
26
- # 1. Install the package
26
+ # 1. Install the package (in your shell)
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. Start pi, then deploy a distro from inside the session
30
+ # (cc-knockoff = a Claude Code-style multi-agent coder)
31
+ pi
32
+ > /pi-distro deploy cc-knockoff
31
33
 
32
34
  # 3. Restart pi — done.
33
35
  ```
@@ -46,7 +48,10 @@ explore-before-acting methodology — all configured and ready to go.
46
48
  substitutes, or chooses. Every state-changing decision (file merge, package install, tool
47
49
  conflict, upgrade) is surfaced with options, and the agent waits for your explicit choice
48
50
  - 📦 **Project-local by default** — each project gets its own isolated configuration, so
49
- different projects can use different harnesses (coding, research, automation, trading…)
51
+ different projects can use different harnesses (coding, research, automation, trading…).
52
+ Need a component everywhere? **Install globally** — at deploy time, choose
53
+ accept-defaults / all-global / customize per component (with safety guards for
54
+ machine-wide changes)
50
55
  - 🔄 **Round-trip** — snapshot your live config back into a reusable distro with `/pi-distro save`
51
56
  - 🐙 **GitHub distros** — deploy distros straight from any GitHub repo, so your personal
52
57
  config follows you to any machine (`/pi-distro deploy owner/repo`)
@@ -110,6 +115,57 @@ Other advantages:
110
115
  You can still use global packages and settings alongside project-level ones — pi merges
111
116
  them. But the distro itself lives at the project level.
112
117
 
118
+ ## Installing locally vs globally
119
+
120
+ Although pi-distro defaults to **project-local** installation (so each project gets its own
121
+ isolated harness), you can choose to install any component **globally** — shared across
122
+ every project and session on your machine. This is an opt-in choice made at deploy time; the
123
+ project-local default is never changed silently.
124
+
125
+ When you run `/pi-distro deploy` (or `/pi-distro pick`), the agent builds a **deployment
126
+ plan** listing every component with its default scope, then offers three presets:
127
+
128
+ - **Accept defaults** — keep each component at its default scope (recommended). Most things
129
+ go project-local; themes default to global (they're user-wide by nature).
130
+ - **All-global (where safe)** — install every safe component globally. Dangerous types
131
+ (settings, `SYSTEM.md`, `AGENTS.md`) stay project-local with a surfaced warning, because
132
+ their blast radius is machine-wide.
133
+ - **Customize** — walk components one at a time and pick `local` / `global` / `skip` for each.
134
+
135
+ ### What can go global
136
+
137
+ | Component | Default | Global? |
138
+ |---|---|---|
139
+ | Packages | local | ✅ |
140
+ | Extensions | local | ✅ |
141
+ | Skills | local | ✅ |
142
+ | Prompts | local | ✅ |
143
+ | Themes | **global** | ✅ |
144
+ | `settings.json` merge | local | ⚠️ guarded (explicit confirm) |
145
+ | `SYSTEM.md` / `APPEND_SYSTEM.md` | local | ⚠️ double-confirm |
146
+ | `AGENTS.md` | local | ⚠️ guarded (explicit confirm) |
147
+
148
+ Global placement writes to `~/.pi/agent/` (packages via `pi install` →
149
+ `~/.pi/agent/settings.json`; extensions/themes/skills/prompts into `~/.pi/agent/<type>/`).
150
+ Project-local writes to `./.pi/` (packages via `pi install -l`). pi merges the two;
151
+ project-local shadows global on conflict.
152
+
153
+ ### Tracking global installs
154
+
155
+ Provenance (`./.pi/harness.md`) records the distro's directives — it does **not** record
156
+ scope. So `/pi-distro status` and `/pi-distro undeploy` detect where each component
157
+ actually landed by checking **both** `./.pi/...` and `~/.pi/agent/...` (plus `pi list` for
158
+ packages). When you undeploy, the agent offers the right removal command per location
159
+ (`pi remove -l` for local, `pi remove` for global). `/pi-distro save` captures
160
+ globally-installed config too (marked `(global)` in the snapshot), so saved distros stay
161
+ reproducible.
162
+
163
+ ### Authoring a distro with a global hint
164
+
165
+ Distro authors can suggest a global default for a package with the `(global)` marker — see
166
+ [docs/authoring.md](docs/authoring.md). The hint is a suggested default; the user's
167
+ preset still governs at deploy time.
168
+
113
169
  ## Workflows
114
170
 
115
171
  ### Global setup (install once, use everywhere)
@@ -145,7 +201,7 @@ to apply — just the packages you want, or a single extension, or a subset of t
145
201
  This is how you combine pieces from different distros to build your own:
146
202
 
147
203
  ```bash
148
- /pi-distro pick pi-distro-one # pick, say, just pi-subagents + the statusline
204
+ /pi-distro pick cc-knockoff # pick, say, just pi-subagents + the statusline
149
205
  /pi-distro pick web-fullstack # then add web research from another distro
150
206
  /pi-distro save # snapshot the combination as your own distro
151
207
  ```
@@ -166,7 +222,7 @@ doesn't write provenance (it's a custom config, not an applied distro) — run
166
222
  ```bash
167
223
  /pi-distro status # shows the applied distro + live config
168
224
  /pi-distro list # lists all available distros
169
- /pi-distro show pi-distro-one # dry-run preview of what a distro would do
225
+ /pi-distro show cc-knockoff # dry-run preview of what a distro would do
170
226
  ```
171
227
 
172
228
  ### Bring your config to any machine
@@ -239,22 +295,35 @@ All interaction happens through a single slash command — there is no standalon
239
295
 
240
296
  The effective catalogue is the union of:
241
297
 
242
- - **Package seeds** — shipped with `@msdavid/pi-distro` under `harnesses/`. Read straight
243
- from the installed package at runtime. Currently:
298
+ - **Official distros** — fetched dynamically from the [`msdavid/pi-distro`](https://github.com/msdavid/pi-distro)
299
+ repo's `harnesses/` directory on GitHub (no longer bundled inside the npm package, so
300
+ new official distros ship by pushing to the repo — no npm release needed). The catalogue
301
+ is listed via the GitHub Contents API and each distro is cloned on demand when you
302
+ select it. Currently:
244
303
  - **minimal** — clean starting point: basic `AGENTS.md` + `.pi/settings.json`.
245
304
  - **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).
305
+ - **cc-knockoff** — a Claude Code–style multi-agent coder (see below).
247
306
  - **User distros** — saved by you to `~/.pi/harnesses/<name>/` via `/pi-distro save`.
248
- - **GitHub distros** — fetched on-demand from any GitHub repo.
307
+ - **GitHub distros** — fetched on-demand from any GitHub repo (`/pi-distro deploy owner/repo`).
249
308
  - **Partial/combined configs** — built from `/pi-distro pick` across multiple distros, then
250
309
  saved as a user distro.
251
310
 
311
+ Selectors and `/pi-distro list` show each distro's source clearly: **Official** (the
312
+ `msdavid/pi-distro` repo), **Local** (your `~/.pi/harnesses/`), or **GitHub (<owner>/<repo>)**
313
+ for distros from other repos.
314
+
315
+ If GitHub is unreachable (offline, or the unauthenticated API rate limit is hit), the
316
+ catalogue degrades to local-only and `/pi-distro list`, `/pi-distro status`, and the
317
+ selectors say so explicitly — official distros are temporarily hidden, not gone.
318
+
252
319
  On a name collision, the **user distro takes precedence** — save a distro with the same
253
- name as a seed to override it.
320
+ name as an official distro to override it. Official distros come from a trusted repo (the
321
+ package's own repo), so they skip the GitHub security confirmation that other-repo
322
+ distros require.
254
323
 
255
- ### pi-distro-one
324
+ ### cc-knockoff
256
325
 
257
- `pi-distro-one` is the author's draft shot at using the most popular pi coding packages to
326
+ `cc-knockoff` is the author's draft shot at using the most popular pi coding packages to
258
327
  closely resemble the capabilities of Claude Code. It's opinionated — spawning and
259
328
  coordinating autonomous sub-agents is the primary capability, with web research, browser
260
329
  automation, live shell, model routing, and task management integrated in support. It
@@ -300,7 +369,9 @@ it's saved to `~/.pi/harnesses/<name>/` with a `README.md` describing the distro
300
369
 
301
370
  ### Author a distro by hand
302
371
 
303
- Create a directory under `~/.pi/harnesses/<name>/` (or contribute a seed to the package)
372
+ Create a directory under `~/.pi/harnesses/<name>/` (or contribute an official distro via a
373
+ PR to the [`msdavid/pi-distro`](https://github.com/msdavid/pi-distro) repo's `harnesses/`
374
+ dir)
304
375
  with a `harness.md` and optional `files/`. See
305
376
  [docs/authoring.md](docs/authoring.md) for the complete format reference — frontmatter
306
377
  fields, bundled file conventions, directive sections, and merge-don't-clobber
package/docs/authoring.md CHANGED
@@ -125,25 +125,45 @@ manifest the agent uses during `deploy`.
125
125
  ### `## pi packages to install`
126
126
 
127
127
  List pi packages the distro depends on. The agent installs these with `pi install -l`
128
- (**project-local** — writes to `./.pi/settings.json`, not global) **only after confirming
129
- with the user** AND after a **redundancy/conflict evaluation**: the agent compares each
130
- package's stated purpose against the project's already-active tools (and `pi list`),
131
- checking for both exact tool-name collisions and semantic redundancy (different names,
132
- similar function). If overlap is detected, it offers the user **skip / replace / keep both /
133
- cancel** instead of installing blindly. (Exact name collisions are non-fatal in pi —
134
- project-local tools shadow global ones but redundancy leaves a confusing duplicate tool
135
- set, so the agent surfaces it for the user to decide.)
136
-
137
- **Do not list these packages in the bundled `settings.json` `packages` array.** `pi install -l`
138
- is the single mechanism that registers a package: it installs the package AND appends the
139
- source to `./.pi/settings.json` on success, and leaves settings untouched on failure. This
140
- way a failed install never leaves a dangling, unresolvable entry in settings.
128
+ (**project-local** — writes to `./.pi/settings.json`) **or** `pi install` (**global** writes
129
+ to `~/.pi/agent/settings.json`, shared across every project) per the user's deploy-time
130
+ scope choice, **only after confirming with the user** AND after a
131
+ **redundancy/conflict evaluation**: the agent compares each package's stated purpose against
132
+ the project's already-active tools and installed packages (both local and global, via
133
+ `pi list`), checking for both exact tool-name collisions and semantic redundancy (different
134
+ names, similar function). If overlap is detected, it offers the user **skip / replace /
135
+ keep both / cancel** instead of installing blindly. (Exact name collisions are non-fatal in
136
+ pi — project-local tools shadow global ones — but redundancy leaves a confusing duplicate
137
+ tool set, so the agent surfaces it for the user to decide.)
138
+
139
+ **Do not list these packages in the bundled `settings.json` `packages` array.**
140
+ `pi install -l` / `pi install` are the single mechanisms that register a package: each
141
+ installs the package AND appends the source to the corresponding settings file on success,
142
+ and leaves settings untouched on failure. This way a failed install never leaves a dangling,
143
+ unresolvable entry in settings.
144
+
145
+ #### Scope hint: `(global)`
146
+
147
+ By default, a package is suggested for **project-local** install. To suggest **global** as
148
+ the author-intended default, suffix the entry with `(global)`:
141
149
 
142
150
  ```markdown
143
151
  ## pi packages to install
144
152
  - `npm:pi-browse` — for web search and content extraction
153
+ - `npm:my-shared-tool` (global) — a tool the user wants in every project
145
154
  ```
146
155
 
156
+ The hint is a **suggested default** for the deployment plan — it does not force global
157
+ install. At deploy time the user picks a preset (accept-defaults / all-global-where-safe /
158
+ customize) which governs the final scope. Use `(global)` for packages that are genuinely
159
+ user-wide (a favorite status-line, a shared utility); leave it off for project-specific
160
+ dependencies.
161
+
162
+ > **Marker placement:** the `(global)` / `(local)` marker must appear **between the
163
+ > package reference and the description dash** (as in the example above). A `(global)`
164
+ > that appears inside the description prose after the `—` dash is intentionally ignored,
165
+ > so descriptions can mention the word without changing the scope.
166
+
147
167
  ### `## Hooks`
148
168
 
149
169
  Describe extensions/hooks to create under `./.pi/extensions/`. These are bundled as
@@ -161,6 +181,56 @@ Reference bundled skills (under `files/.pi/skills/`) and prompts (under
161
181
  `files/.pi/prompts/`) that should be deployed, or describe skills/prompts the agent
162
182
  should create.
163
183
 
184
+ ## Install scope: local vs global
185
+
186
+ Every component a distro installs can be placed either **project-local** (the default,
187
+ under `./.pi/` — scoped to this project) or **global** (under `~/.pi/agent/` — shared
188
+ across every project and session on the machine). pi merges the two; project-local
189
+ shadows global on conflict. pi-distro's default philosophy is **project-local**, because
190
+ different projects need different harnesses — global is an opt-in choice the user makes at
191
+ deploy time.
192
+
193
+ ### Per-type default scopes
194
+
195
+ | Component type | Default scope | Global allowed? |
196
+ |---|---|---|
197
+ | Packages | local | yes |
198
+ | Extensions | local | yes |
199
+ | Skills | local | yes |
200
+ | Prompts | local | yes |
201
+ | Themes | **global** | yes |
202
+ | settings.json merge | local | guarded (explicit confirm) |
203
+ | SYSTEM.md / APPEND_SYSTEM.md | local | double-confirm |
204
+ | AGENTS.md | local | guarded (explicit confirm) |
205
+
206
+ At deploy (and `/pi-distro pick`), the agent builds a **deployment plan** from the
207
+ directives — grouping every component with its default scope — and offers the user three
208
+ presets:
209
+
210
+ - **(a) Accept defaults** — keep each component at its default scope (recommended).
211
+ - **(b) All-global (where safe)** — flip every global-allowed component to global;
212
+ dangerous types (settings, SYSTEM.md, AGENTS.md) stay local with a surfaced warning.
213
+ - **(c) Customize** — walk items one at a time, offering `local` / `global` / `skip`.
214
+
215
+ When a dangerous type's final scope is global, the agent surfaces the blast radius
216
+ ("affects every project/session on this machine") and requires explicit confirmation
217
+ (double-confirm for SYSTEM.md/APPEND_SYSTEM.md).
218
+
219
+ ### `## Global deployment notes`
220
+
221
+ If your distro is intended to place some bundled files **globally** (e.g. an extension that
222
+ should live at `~/.pi/agent/extensions/` rather than `./.pi/extensions/`), add a `## Global
223
+ deployment notes` section listing which file targets should go global:
224
+
225
+ ```markdown
226
+ ## Global deployment notes
227
+ - `.pi/extensions/my-shared-ext.ts` → deploy globally to `~/.pi/agent/extensions/my-shared-ext.ts`
228
+ - `.pi/themes/shared-theme.json` → deploy globally to `~/.pi/agent/themes/shared-theme.json`
229
+ ```
230
+
231
+ The agent reads this note and places those files at the global path per the user's scope
232
+ choice. For packages, prefer the `(global)` marker in `## pi packages to install` instead.
233
+
164
234
  ## Merge-don't-clobber expectations
165
235
 
166
236
  When a distro is deployed via `/pi-distro deploy`, **existing files are never
@@ -187,14 +257,14 @@ frontmatter + directives) with a provenance header injected at the top of the bo
187
257
  <!-- pi-distro provenance
188
258
  appliedHarness: <name>
189
259
  appliedVersion: <version>
190
- sourceCatalogue: <user|seed|none>
260
+ sourceCatalogue: <user|github:owner/repo[/subpath]>
191
261
  lastUpdated: <ISO8601>
192
262
  -->
193
263
  ```
194
264
 
195
265
  - `appliedHarness`: the name of the distro that was deployed.
196
266
  - `appliedVersion`: the version from the distro frontmatter.
197
- - `sourceCatalogue`: `seed` (from the package) or `user` (from `~/.pi/harnesses/`).
267
+ - `sourceCatalogue`: `user` (from `~/.pi/harnesses/`) or `github:owner/repo[/subpath]` (official distros are `github:msdavid/pi-distro/harnesses/<name>`).
198
268
  - `lastUpdated`: ISO 8601 timestamp of the last apply/save.
199
269
 
200
270
  The provenance file is updated automatically by the `deploy`, `save`, and `undeploy`
@@ -234,5 +304,9 @@ Never keep the same version when updating a distro — the version should always
234
304
  that something changed. When authoring a distro by hand, pick a starting version (e.g.
235
305
  `0.1.0`) and bump it on each meaningful change.
236
306
 
307
+ Write versions as plain `MAJOR.MINOR.PATCH` — no `v` prefix. Prerelease suffixes
308
+ (`1.0.0-beta`) are tolerated and sort before their release, but plain releases are
309
+ recommended for distros.
310
+
237
311
  The saved distro is immediately available in `/pi-distro list` and can be deployed
238
312
  into other projects with `/pi-distro deploy`.
@@ -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 {
@@ -27,17 +32,35 @@ export interface BundledFile {
27
32
  target: string;
28
33
  }
29
34
 
35
+ export interface PackageEntry {
36
+ source: string;
37
+ scope: "local" | "global"; // author hint; the user's deploy-time preset still governs
38
+ }
39
+
30
40
  /** Parse pi package references (npm:...) from a directives body. */
31
41
  export function parsePackageList(body: string): string[] {
32
- const packages: string[] = [];
42
+ return parsePackageListWithScope(body).map((p) => p.source);
43
+ }
44
+
45
+ /** Parse pi package references with their author-specified scope hint.
46
+ * A package may be suffixed with `(global)` to suggest global install; otherwise
47
+ * the default scope is `local`. The hint is a *default* — the user's deploy-time
48
+ * preset (accept-defaults / all-global / customize) still governs the final scope. */
49
+ export function parsePackageListWithScope(body: string): PackageEntry[] {
50
+ const packages: PackageEntry[] = [];
33
51
  let inSection = false;
34
52
  for (const line of body.split("\n")) {
35
53
  const t = line.trim();
36
54
  if (/^##\s+pi\s*packages/i.test(t)) { inSection = true; continue; }
37
55
  if (inSection) {
38
- if (/^##\s/.test(t)) { inSection = false; continue; }
39
- const m = t.match(/^-\s+`(npm:[^`]+)`/);
40
- if (m) packages.push(m[1]);
56
+ if (/^#{1,2}\s/.test(t)) { inSection = false; continue; } // any h1/h2 ends the section
57
+ const m = t.match(/^-\s+`(npm:[^`]+)`([^—–]*)/);
58
+ if (m) {
59
+ // The scope marker must appear between the package ref and the
60
+ // description dash — "(global)" inside description prose is ignored.
61
+ const scopeM = m[2].match(/\((global|local)\)/);
62
+ packages.push({ source: m[1], scope: scopeM?.[1] === "global" ? "global" : "local" });
63
+ }
41
64
  }
42
65
  }
43
66
  return packages;
@@ -50,74 +73,86 @@ export interface Provenance {
50
73
  lastUpdated: string;
51
74
  }
52
75
 
53
- /** Parse the provenance header from a harness.md content. */
76
+ /** Parse the provenance header from a harness.md content. Fields are matched
77
+ * independently within the provenance comment block, so their order (and any
78
+ * extra fields an agent may have added) doesn't matter. */
54
79
  export function parseProvenance(content: string): Provenance | null {
55
- const m = content.match(
56
- /appliedHarness:\s*(.+?)\s*\n\s*appliedVersion:\s*(.+?)\s*\n\s*sourceCatalogue:\s*(.+?)\s*\n\s*lastUpdated:\s*(.+?)\s*\n/,
57
- );
58
- if (!m) return null;
59
- return {
60
- appliedHarness: m[1].trim(),
61
- appliedVersion: m[2].trim(),
62
- sourceCatalogue: m[3].trim(),
63
- lastUpdated: m[4].trim(),
64
- };
65
- }
66
-
67
- /** Synchronous catalogue name read for autocomplete (best-effort). */
80
+ const block = content.match(/<!--\s*pi-distro provenance([\s\S]*?)-->/);
81
+ if (!block) return null;
82
+ const field = (name: string): string | undefined =>
83
+ block[1].match(new RegExp(`${name}:[ \\t]*(.+)`))?.[1].trim();
84
+ const appliedHarness = field("appliedHarness");
85
+ const appliedVersion = field("appliedVersion");
86
+ const sourceCatalogue = field("sourceCatalogue");
87
+ const lastUpdated = field("lastUpdated");
88
+ if (!appliedHarness || !appliedVersion || !sourceCatalogue || !lastUpdated) return null;
89
+ return { appliedHarness, appliedVersion, sourceCatalogue, lastUpdated };
90
+ }
91
+
92
+ /** Synchronous catalogue name read for autocomplete (best-effort).
93
+ * User distros are read from disk (by frontmatter name, falling back to the
94
+ * directory name). Official distros live on GitHub and can't be fetched
95
+ * synchronously — but the last successful async listing is cached to
96
+ * `.official-cache.json` (see github.ts), so their names complete too once
97
+ * any command has read the catalogue. */
68
98
  export function getCatalogueNamesSync(): string[] {
99
+ const dir = getUserHarnessesDir();
69
100
  const names: string[] = [];
70
- for (const dir of [getSeedHarnessesDir(), getUserHarnessesDir()]) {
71
- if (!existsSync(dir)) continue;
101
+ if (existsSync(dir)) {
72
102
  try {
73
103
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
74
- if (entry.isDirectory() && entry.name !== ".trash" && existsSync(join(dir, entry.name, "harness.md"))) {
75
- names.push(entry.name);
104
+ if (entry.isDirectory() && entry.name !== ".trash") {
105
+ const harnessMdPath = join(dir, entry.name, "harness.md");
106
+ if (!existsSync(harnessMdPath)) continue;
107
+ try {
108
+ const fm = parseFrontmatter(readFileSync(harnessMdPath, "utf-8"));
109
+ names.push(fm?.name ?? entry.name);
110
+ } catch {
111
+ names.push(entry.name);
112
+ }
76
113
  }
77
114
  }
78
115
  } catch { /* ignore */ }
79
116
  }
117
+ try {
118
+ const cached = JSON.parse(readFileSync(join(dir, ".official-cache.json"), "utf-8"));
119
+ if (Array.isArray(cached)) {
120
+ for (const n of cached) if (typeof n === "string") names.push(n);
121
+ }
122
+ } catch { /* no cache yet — officials appear after the first catalogue read */ }
80
123
  return [...new Set(names)].sort();
81
124
  }
82
125
 
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
126
  /** Directory containing user-saved harnesses (~/.pi/harnesses/). */
99
127
  export function getUserHarnessesDir(): string {
100
128
  return join(homedir(), ".pi", "harnesses");
101
129
  }
102
130
 
103
131
  /**
104
- * Read the effective catalogue: seeds ∪ user harnesses.
132
+ * Read the effective catalogue: official distros (GitHub, dynamic) ∪ user harnesses.
105
133
  * On name collision, user takes precedence.
106
- * Returns sorted: seeds first (alphabetical), then user (alphabetical).
134
+ * Returns sorted: official first (alphabetical), then user (alphabetical).
135
+ * Never throws on network failure — official distros are simply omitted and the
136
+ * catalogue degrades to local-only.
107
137
  */
108
138
  export async function readCatalogue(): Promise<HarnessEntry[]> {
109
- const seeds = readHarnessesFromDir(getSeedHarnessesDir(), "seed");
139
+ const official = await listOfficialDistros();
110
140
  const users = readHarnessesFromDir(getUserHarnessesDir(), "user");
111
141
 
112
142
  // User takes precedence on collision
113
143
  const userNames = new Set(users.map((u) => u.name));
114
- const filteredSeeds = seeds.filter((s) => !userNames.has(s.name));
144
+ const filteredOfficial = official.filter((o) => !userNames.has(o.name));
115
145
 
116
- // Sort each group alphabetically
117
- filteredSeeds.sort((a, b) => a.name.localeCompare(b.name));
146
+ filteredOfficial.sort((a, b) => a.name.localeCompare(b.name));
118
147
  users.sort((a, b) => a.name.localeCompare(b.name));
119
148
 
120
- return [...filteredSeeds, ...users];
149
+ return [...filteredOfficial, ...users];
150
+ }
151
+
152
+ /** Read only local (user) harnesses — no network. Used by update/status local
153
+ * resolution and anywhere a network call is undesirable. */
154
+ export function readLocalCatalogue(): HarnessEntry[] {
155
+ return readHarnessesFromDir(getUserHarnessesDir(), "user");
121
156
  }
122
157
 
123
158
  /** Find a harness by name in the catalogue. */
@@ -156,7 +191,7 @@ export async function readFullHarnessMd(harnessMdPath: string): Promise<string>
156
191
 
157
192
  function readHarnessesFromDir(
158
193
  dir: string,
159
- source: "seed" | "user",
194
+ source: "user",
160
195
  ): HarnessEntry[] {
161
196
  if (!existsSync(dir)) return [];
162
197
 
@@ -219,3 +254,13 @@ function walkDir(
219
254
  }
220
255
  }
221
256
  }
257
+
258
+ // --- Source labelling (for selectors / list / status) ---
259
+
260
+ /** A short human label for a distro source, shown in selectors and lists. */
261
+ export function sourceLabel(source: string): string {
262
+ if (isOfficialSource(source)) return "Official";
263
+ if (source === "user") return "Local";
264
+ if (source.startsWith("github:")) return `GitHub (${source.slice("github:".length)})`;
265
+ return source;
266
+ }