@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 +21 -1
- package/README.md +23 -12
- package/docs/authoring.md +2 -2
- package/extensions/catalogue.ts +42 -33
- package/extensions/deploy.ts +5 -4
- package/extensions/github.ts +124 -0
- package/extensions/info.ts +30 -12
- package/extensions/pick.ts +5 -4
- package/extensions/resolve.ts +33 -6
- package/extensions/save.ts +4 -3
- package/extensions/show.ts +22 -5
- package/extensions/update.ts +1 -1
- package/package.json +1 -2
- package/skills/pi-distro/SKILL.md +8 -7
- package/harnesses/minimal/README.md +0 -21
- package/harnesses/minimal/files/AGENTS.md +0 -20
- package/harnesses/minimal/files/settings.json +0 -4
- package/harnesses/minimal/harness.md +0 -24
- package/harnesses/pi-distro-one/README.md +0 -50
- package/harnesses/pi-distro-one/files/.pi/extensions/claude-statusline.ts +0 -220
- package/harnesses/pi-distro-one/files/AGENTS.md +0 -166
- package/harnesses/pi-distro-one/files/settings.json +0 -9
- package/harnesses/pi-distro-one/harness.md +0 -79
- package/harnesses/web-fullstack/README.md +0 -25
- package/harnesses/web-fullstack/files/.pi/prompts/review.md +0 -12
- package/harnesses/web-fullstack/files/AGENTS.md +0 -37
- package/harnesses/web-fullstack/files/settings.json +0 -11
- package/harnesses/web-fullstack/harness.md +0 -40
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**:
|
|
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 (
|
|
30
|
-
/pi-distro deploy
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
243
|
-
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
###
|
|
264
|
+
### cc-knockoff
|
|
256
265
|
|
|
257
|
-
`
|
|
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
|
|
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|
|
|
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`: `
|
|
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`
|
package/extensions/catalogue.ts
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Catalogue reading logic for pi-distro.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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; // "
|
|
20
|
-
dir
|
|
21
|
-
harnessMdPath
|
|
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
|
-
|
|
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:
|
|
96
|
+
* Read the effective catalogue: official distros (GitHub, dynamic) ∪ user harnesses.
|
|
105
97
|
* On name collision, user takes precedence.
|
|
106
|
-
* Returns sorted:
|
|
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
|
|
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
|
|
108
|
+
const filteredOfficial = official.filter((o) => !userNames.has(o.name));
|
|
115
109
|
|
|
116
|
-
|
|
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 [...
|
|
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: "
|
|
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
|
+
}
|
package/extensions/deploy.ts
CHANGED
|
@@ -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
|
|
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
|
|
123
|
-
|
|
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.`;
|
package/extensions/github.ts
CHANGED
|
@@ -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
|
+
}
|
package/extensions/info.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
ExtensionAPI,
|
|
8
8
|
ExtensionCommandContext,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { readFileSync, existsSync,
|
|
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
|
|
32
|
-
const
|
|
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
|
-
|
|
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
|
|
56
|
-
if (
|
|
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
|
|
120
|
-
ctx.ui.notify(`'${name}' is a
|
|
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
|
|
131
|
-
rmSync(harness.dir
|
|
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
|
}
|
package/extensions/pick.ts
CHANGED
|
@@ -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
|
|
31
|
-
|
|
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
|
|
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) : [];
|
package/extensions/resolve.ts
CHANGED
|
@@ -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
|
|
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 ?
|
|
65
|
+
return harness ? resolveEntry(harness, ctx) : undefined;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
/**
|
|
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 =
|
|
95
|
+
const catalogue = readLocalCatalogue();
|
|
69
96
|
return findHarness(name, catalogue);
|
|
70
97
|
}
|
package/extensions/save.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
144
|
-
|
|
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
|