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