@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/save.ts
CHANGED
|
@@ -9,9 +9,10 @@ import type {
|
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { readFileSync, statSync, existsSync, readdirSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
12
13
|
|
|
13
14
|
import { getUserHarnessesDir } from "./catalogue.ts";
|
|
14
|
-
import { display } from "./util.ts";
|
|
15
|
+
import { display, readGlobalPackages } from "./util.ts";
|
|
15
16
|
|
|
16
17
|
export async function handleSave(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
17
18
|
const snapshot = ctx.getSystemPromptOptions();
|
|
@@ -26,20 +27,33 @@ export async function handleSave(pi: ExtensionAPI, ctx: ExtensionCommandContext)
|
|
|
26
27
|
// NOTE: .crew/ is handled separately below — it mixes authored config (agents/teams/workflows)
|
|
27
28
|
// with runtime state (state/artifacts/worktrees/imports/audit/cache/graphs), so only its three
|
|
28
29
|
// config subdirs are captured, not the whole tree.
|
|
29
|
-
const SKIP_NAMES = new Set(["npm", "git", "sessions", "state", "tmp", "node_modules"]);
|
|
30
|
+
const SKIP_NAMES = new Set(["npm", "git", "sessions", "state", "tmp", "node_modules", "loops"]);
|
|
30
31
|
const MAX_DUMP_BYTES = 16384;
|
|
31
|
-
const
|
|
32
|
-
|
|
32
|
+
const MAX_TOTAL_DUMP_BYTES = 262144; // cap the whole inlined snapshot, not just per-file
|
|
33
|
+
let totalInlined = 0;
|
|
34
|
+
const makePush = (sink: string[], label: string) => (fp: string) => {
|
|
33
35
|
let st;
|
|
34
36
|
try { st = statSync(fp); } catch { return; }
|
|
35
37
|
if (st.size > MAX_DUMP_BYTES) {
|
|
36
|
-
|
|
38
|
+
sink.push(`### ${fp}${label}\n_(${st.size} bytes — read with the \`read\` tool if needed)_`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (totalInlined + st.size > MAX_TOTAL_DUMP_BYTES) {
|
|
42
|
+
sink.push(`### ${fp}${label}\n_(omitted — snapshot size cap reached; read with the \`read\` tool if needed)_`);
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
const buf = readFileSync(fp);
|
|
47
|
+
if (buf.includes(0)) {
|
|
48
|
+
sink.push(`### ${fp}${label}\n_(binary file, ${st.size} bytes — not inlined; copy it as-is if bundling)_`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
totalInlined += st.size;
|
|
52
|
+
sink.push(`### ${fp}${label}\n\`\`\`\n${buf.toString("utf-8")}\n\`\`\``);
|
|
53
|
+
} catch { /* unreadable — skip */ }
|
|
42
54
|
};
|
|
55
|
+
const configFiles: string[] = [];
|
|
56
|
+
const pushFile = makePush(configFiles, "");
|
|
43
57
|
const walk = (dir: string, isPiRoot: boolean) => {
|
|
44
58
|
let entries;
|
|
45
59
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
@@ -81,6 +95,40 @@ export async function handleSave(pi: ExtensionAPI, ctx: ExtensionCommandContext)
|
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
// Global (user-level) config at ~/.pi/agent/ — capture alongside project-local so that
|
|
99
|
+
// components the user installed GLOBALLY (via `pi install` with no -l, or files placed in
|
|
100
|
+
// ~/.pi/agent/) are reproduced by the saved distro. These are marked as global in the
|
|
101
|
+
// snapshot (absolute path under ~/.pi/agent/). The agent must emit `(global)` markers for
|
|
102
|
+
// global packages and note global file placement in the directives so a re-deploy installs
|
|
103
|
+
// them at the global scope, not project-local. Skip runtime/data dirs (npm/, sessions/,
|
|
104
|
+
// state/, tmp/, bin/, supi/, cc-status/) and auth/models/trust (machine-specific). Only the
|
|
105
|
+
// authored config is captured: settings.json, AGENTS.md, extensions/, themes/, skills/,
|
|
106
|
+
// prompts/. An ancestor or unrelated global file the user added by hand is also captured.
|
|
107
|
+
const globalAgentDir = join(homedir(), ".pi", "agent");
|
|
108
|
+
const globalFiles: string[] = [];
|
|
109
|
+
const GLOBAL_SKIP_NAMES = new Set(["npm", "sessions", "state", "tmp", "bin", "supi", "cc-status", "loops"]);
|
|
110
|
+
const pushGlobalFile = makePush(globalFiles, " (global)");
|
|
111
|
+
const walkGlobal = (dir: string) => {
|
|
112
|
+
let entries;
|
|
113
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const fp = join(dir, entry.name);
|
|
116
|
+
if (entry.isDirectory()) {
|
|
117
|
+
if (GLOBAL_SKIP_NAMES.has(entry.name)) continue;
|
|
118
|
+
walkGlobal(fp);
|
|
119
|
+
} else if (entry.isFile()) {
|
|
120
|
+
// skip machine-specific runtime files
|
|
121
|
+
if (["auth.json", "models.json", "trust.json"].includes(entry.name)) continue;
|
|
122
|
+
pushGlobalFile(fp);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
if (existsSync(globalAgentDir)) walkGlobal(globalAgentDir);
|
|
127
|
+
const gpkgs = readGlobalPackages();
|
|
128
|
+
const globalPackagesList = gpkgs.length > 0
|
|
129
|
+
? gpkgs.map((p) => `- \`${p}\` (global)`).join("\n")
|
|
130
|
+
: "_(none)_";
|
|
131
|
+
|
|
84
132
|
// Existing user harnesses (so the agent can offer update-existing)
|
|
85
133
|
const userDir = getUserHarnessesDir();
|
|
86
134
|
const existing: string[] = [];
|
|
@@ -132,16 +180,39 @@ The body should have directive sections (Bundled files, pi packages to install,
|
|
|
132
180
|
prompt/SYSTEM.md if present, Themes, Context, Skills/prompts, and any per-extension/skill
|
|
133
181
|
config files) mirroring the live config.
|
|
134
182
|
|
|
183
|
+
**Scope handling (local vs global):** the snapshot above separates **project-local** files
|
|
184
|
+
(under \`./.pi/\` and \`./\`) from **global** files (under \`~/.pi/agent/\`, marked \`(global)\`),
|
|
185
|
+
and lists global packages with \`(global)\`. Reproduce each component at the scope it was
|
|
186
|
+
captured at:
|
|
187
|
+
- For a **global package**: list it in the \`## pi packages to install\` section with the
|
|
188
|
+
\`(global)\` marker (e.g. - npm:foo (global) — reason, written as a list item with
|
|
189
|
+
the marker suffix). At
|
|
190
|
+
deploy, the user's preset still governs, but the marker is the author-suggested default.
|
|
191
|
+
- For a **project-local package**: list it without the marker (default local).
|
|
192
|
+
- For a **global bundled file** (e.g. \`~/.pi/agent/extensions/foo.ts\`): copy it into the
|
|
193
|
+
distro's \`files/\` mirror preserving structure, AND add a note in a
|
|
194
|
+
\`## Global deployment notes\` directive section listing which file targets should be
|
|
195
|
+
placed globally (e.g. "\`.pi/extensions/foo.ts\` -> deploy globally to
|
|
196
|
+
\`~/.pi/agent/extensions/foo.ts\`"). The agent reads this note at deploy and places
|
|
197
|
+
those files at the global path per the user's scope choice.
|
|
198
|
+
- For a **global settings.json merge** or **global SYSTEM.md/AGENTS.md**: these are the
|
|
199
|
+
dangerous guarded types — note in \`## Global deployment notes\` that they target
|
|
200
|
+
\`~/.pi/agent/settings.json\` / \`~/.pi/agent/SYSTEM.md\` / \`~/.pi/agent/AGENTS.md\`, so the
|
|
201
|
+
deploy-time scope-safety guard will surface their blast radius.
|
|
202
|
+
If the user does not want a global component reproduced, they may ask to drop it or convert
|
|
203
|
+
it to local — confirm with them during the draft step.
|
|
204
|
+
|
|
135
205
|
**2. Propose & confirm.** Show the user the proposed \`name\`, \`title\`, \`description\`,
|
|
136
206
|
and the full draft. Ask for confirmation or edits. Do NOT proceed until the user confirms.
|
|
137
207
|
|
|
138
208
|
**3. Choose save target.** Ask the user whether to:
|
|
139
209
|
- **(a) Save as a new distro** — ask for a name (validate the slug; if it collides with an
|
|
140
|
-
existing user distro or
|
|
210
|
+
existing user distro or an official distro name (fetched from GitHub), warn and ask
|
|
211
|
+
whether to overwrite), then create
|
|
141
212
|
\`~/.pi/harnesses/<name>/\`.
|
|
142
213
|
- **(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
|
-
|
|
214
|
+
above. Refuse to update a name that is NOT in the existing-user list (official distros
|
|
215
|
+
live on GitHub and are read-only locally). Before overwriting, back the old distro up: copy
|
|
145
216
|
\`~/.pi/harnesses/<name>/\` to
|
|
146
217
|
\`~/.pi/harnesses/.trash/<name>-<timestamp>/\` (create \`.trash/\` if needed).
|
|
147
218
|
**Bump the version:** read the old distro's \`version\` field and increment it using
|
|
@@ -171,7 +242,7 @@ project root:
|
|
|
171
242
|
- any per-extension/skill config files (e.g. \`.pi/<name>.json\`) -> \`files/.pi/\` (same path)
|
|
172
243
|
Use \`cp -r\` for directory trees. Only copy files that actually exist. Do NOT copy:
|
|
173
244
|
\`./.pi/harness.md\` (provenance), \`./.pi/npm/\`, \`./.pi/git/\`, \`./.pi/sessions/\`,
|
|
174
|
-
\`./.pi/state/\`, \`./.pi/tmp/\`, \`./.pi/.crew/\`, or any \`node_modules/\`.
|
|
245
|
+
\`./.pi/state/\`, \`./.pi/tmp/\`, \`./.pi/loops/\`, \`./.pi/.crew/\`, or any \`node_modules/\`.
|
|
175
246
|
|
|
176
247
|
**Theme dedup:** before bundling a \`.pi/themes/<name>.json\`, check whether that theme
|
|
177
248
|
name is already provided by an installed package (run \`pi list\`; package themes surface
|
package/extensions/show.ts
CHANGED
|
@@ -7,18 +7,18 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
7
7
|
import { readFileSync, existsSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
|
-
import { readFullHarnessMd, listBundledFiles, readCatalogue, findHarness,
|
|
10
|
+
import { readFullHarnessMd, listBundledFiles, readCatalogue, findHarness, parsePackageListWithScope, sourceLabel } from "./catalogue.ts";
|
|
11
11
|
import type { HarnessEntry } from "./catalogue.ts";
|
|
12
12
|
import { parseFrontmatter, extractBody } from "./frontmatter.ts";
|
|
13
|
-
import { looksLikeGithubRef, parseGithubRef, fetchGithubDistro } from "./github.ts";
|
|
13
|
+
import { looksLikeGithubRef, parseGithubRef, fetchGithubDistro, fetchOfficialDistro } from "./github.ts";
|
|
14
14
|
import { display } from "./util.ts";
|
|
15
15
|
|
|
16
16
|
/** Build the dry-run preview for a harness (used by local and GitHub show). */
|
|
17
17
|
export async function buildShowPreview(harness: HarnessEntry): Promise<string> {
|
|
18
|
-
const fullMd = await readFullHarnessMd(harness.harnessMdPath);
|
|
18
|
+
const fullMd = await readFullHarnessMd(harness.harnessMdPath!);
|
|
19
19
|
const fm = parseFrontmatter(fullMd);
|
|
20
20
|
const body = extractBody(fullMd);
|
|
21
|
-
const packages =
|
|
21
|
+
const packages = parsePackageListWithScope(body);
|
|
22
22
|
|
|
23
23
|
let settingsPreview = "_(none)_";
|
|
24
24
|
if (harness.filesDir) {
|
|
@@ -41,10 +41,12 @@ export async function buildShowPreview(harness: HarnessEntry): Promise<string> {
|
|
|
41
41
|
- **Description:** ${fm?.description ?? harness.description}
|
|
42
42
|
- **Version:** ${fm?.version ?? harness.version}
|
|
43
43
|
- **Tags:** ${tags}
|
|
44
|
-
- **Source:** ${harness.source}
|
|
44
|
+
- **Source:** ${sourceLabel(harness.source)}
|
|
45
45
|
|
|
46
46
|
### pi packages to install
|
|
47
|
-
${packages.length > 0 ? packages.map((p) => `- \`${p}
|
|
47
|
+
${packages.length > 0 ? packages.map((p) => `- \`${p.source}\` [${p.scope}]`).join("\n") : "_(none)_"}
|
|
48
|
+
|
|
49
|
+
(\`[local]\`/\`[global]\` = author-suggested default scope; the user's deploy-time preset governs the final scope.)
|
|
48
50
|
|
|
49
51
|
### Settings (would be merged)
|
|
50
52
|
${settingsPreview}
|
|
@@ -52,6 +54,10 @@ ${settingsPreview}
|
|
|
52
54
|
### Bundled files (target paths)
|
|
53
55
|
${fileList}
|
|
54
56
|
|
|
57
|
+
> **Scope:** by default, bundled files deploy project-locally (\`./<target>\`), except
|
|
58
|
+
> themes which default to global (\`~/.pi/agent/themes/\`). At deploy time the user picks a
|
|
59
|
+
> preset (accept-defaults / all-global-where-safe / customize).
|
|
60
|
+
|
|
55
61
|
### Full directives
|
|
56
62
|
${body}
|
|
57
63
|
|
|
@@ -84,7 +90,8 @@ export async function handleShow(pi: ExtensionAPI, name?: string): Promise<void>
|
|
|
84
90
|
return;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
// Local catalogue
|
|
93
|
+
// Local catalogue (official distros appear here as needsFetch listing entries;
|
|
94
|
+
// user distros are read straight from disk).
|
|
88
95
|
const catalogue = await readCatalogue();
|
|
89
96
|
const harness = findHarness(name, catalogue);
|
|
90
97
|
if (!harness) {
|
|
@@ -92,5 +99,21 @@ export async function handleShow(pi: ExtensionAPI, name?: string): Promise<void>
|
|
|
92
99
|
display(pi, `Distro '${name}' not found. Available: ${available}`);
|
|
93
100
|
return;
|
|
94
101
|
}
|
|
102
|
+
// Official listing entry — fetch the actual distro from GitHub before previewing.
|
|
103
|
+
if (harness.needsFetch) {
|
|
104
|
+
let fetched;
|
|
105
|
+
try {
|
|
106
|
+
fetched = fetchOfficialDistro(harness.name);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
display(pi, `**Error:** ${err instanceof Error ? err.message : String(err)}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
display(pi, await buildShowPreview(fetched.entry));
|
|
113
|
+
} finally {
|
|
114
|
+
fetched.cleanup();
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
95
118
|
display(pi, await buildShowPreview(harness));
|
|
96
119
|
}
|
package/extensions/undeploy.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { parseProvenance, parsePackageList } from "./catalogue.ts";
|
|
14
14
|
import { extractBody } from "./frontmatter.ts";
|
|
15
|
-
import { readProjectPackages, USER_INVOLVEMENT_RULE } from "./util.ts";
|
|
15
|
+
import { readProjectPackages, readGlobalPackages, USER_INVOLVEMENT_RULE } from "./util.ts";
|
|
16
|
+
import { homedir } from "node:os";
|
|
16
17
|
|
|
17
18
|
export async function handleUndeploy(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
18
19
|
const snapshot = ctx.getSystemPromptOptions();
|
|
@@ -34,34 +35,57 @@ export async function handleUndeploy(pi: ExtensionAPI, ctx: ExtensionCommandCont
|
|
|
34
35
|
const directives = extractBody(provContent);
|
|
35
36
|
const packages = parsePackageList(directives);
|
|
36
37
|
|
|
37
|
-
// Read current project state to compare against the distro's directives.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
38
|
+
// Read current project state to compare against the distro's directives. A distro may
|
|
39
|
+
// have installed packages/files either project-locally or globally — provenance records
|
|
40
|
+
// the directives (intentions), not scope, so we detect placement at runtime by checking
|
|
41
|
+
// BOTH ./.pi/settings.json and ~/.pi/agent/settings.json (and both file trees).
|
|
42
|
+
const installedLocal = readProjectPackages(cwd);
|
|
43
|
+
const installedGlobal = readGlobalPackages();
|
|
44
|
+
|
|
45
|
+
// Classify each distro package by where it actually landed.
|
|
46
|
+
type Placement = "local" | "global" | "both" | "missing";
|
|
47
|
+
const placement = (src: string): Placement => {
|
|
48
|
+
const inLocal = installedLocal.includes(src);
|
|
49
|
+
const inGlobal = installedGlobal.includes(src);
|
|
50
|
+
if (inLocal && inGlobal) return "both";
|
|
51
|
+
if (inLocal) return "local";
|
|
52
|
+
if (inGlobal) return "global";
|
|
53
|
+
return "missing";
|
|
54
|
+
};
|
|
55
|
+
const removeHint = (pl: Placement): string =>
|
|
56
|
+
pl === "local" ? "remove with `pi remove -l <pkg>`"
|
|
57
|
+
: pl === "global" ? "remove with `pi remove <pkg>` (global — affects every project)"
|
|
58
|
+
: pl === "both" ? "remove from both: `pi remove -l <pkg>` AND `pi remove <pkg>`"
|
|
59
|
+
: "";
|
|
60
|
+
const distroPackages = packages.map((p) => ({ pkg: p, pl: placement(p) }));
|
|
61
|
+
const distroPackagesInstalled = distroPackages.filter((d) => d.pl !== "missing");
|
|
62
|
+
const distroPackagesMissing = distroPackages.filter((d) => d.pl === "missing");
|
|
63
|
+
|
|
64
|
+
// Check for the AGENTS.md delimited section at BOTH the project root and the global
|
|
65
|
+
// ~/.pi/agent/AGENTS.md (a distro may have deployed it at either scope).
|
|
66
|
+
const checkAgentsSection = (path: string): boolean => {
|
|
67
|
+
if (!existsSync(path)) return false;
|
|
68
|
+
return readFileSync(path, "utf-8").includes(`<!-- pi-distro: ${prov.appliedHarness} -->`);
|
|
69
|
+
};
|
|
70
|
+
const localAgentsMdPath = join(cwd, "AGENTS.md");
|
|
71
|
+
const globalAgentsMdPath = join(homedir(), ".pi", "agent", "AGENTS.md");
|
|
72
|
+
const localAgentsPresent = checkAgentsSection(localAgentsMdPath);
|
|
73
|
+
const globalAgentsPresent = checkAgentsSection(globalAgentsMdPath);
|
|
74
|
+
let agentsMdSection: string;
|
|
75
|
+
if (localAgentsPresent && globalAgentsPresent) agentsMdSection = "present in BOTH ./AGENTS.md and ~/.pi/agent/AGENTS.md";
|
|
76
|
+
else if (localAgentsPresent) agentsMdSection = "present in ./AGENTS.md (project-local)";
|
|
77
|
+
else if (globalAgentsPresent) agentsMdSection = "present in ~/.pi/agent/AGENTS.md (global — affects every session)";
|
|
78
|
+
else agentsMdSection = "not found (already removed or never added)";
|
|
55
79
|
|
|
56
80
|
const activeTools = snapshot.selectedTools?.length
|
|
57
81
|
? snapshot.selectedTools.map((t) => `- \`${t}\``).join("\n")
|
|
58
82
|
: "_(none / default set)_";
|
|
59
83
|
|
|
60
84
|
const packageList = distroPackagesInstalled.length > 0
|
|
61
|
-
? distroPackagesInstalled.map((
|
|
85
|
+
? distroPackagesInstalled.map((d) => `- \`${d.pkg}\` — [${d.pl}] ${removeHint(d.pl)}`).join("\n")
|
|
62
86
|
: "_(none of the distro's packages are currently installed)_";
|
|
63
87
|
const missingNote = distroPackagesMissing.length > 0
|
|
64
|
-
? `\n\n**Packages from this distro not currently installed (already removed):**\n${distroPackagesMissing.map((
|
|
88
|
+
? `\n\n**Packages from this distro not currently installed (already removed):**\n${distroPackagesMissing.map((d) => `- \`${d.pkg}\``).join("\n")}`
|
|
65
89
|
: "";
|
|
66
90
|
|
|
67
91
|
pi.sendUserMessage(`## Undeploying distro: ${prov.appliedHarness} (v${prov.appliedVersion})
|
|
@@ -76,7 +100,7 @@ the *current* project state and let the user decide what to remove.
|
|
|
76
100
|
${directives}
|
|
77
101
|
|
|
78
102
|
### Current project state
|
|
79
|
-
**Distro packages still installed
|
|
103
|
+
**Distro packages still installed (${distroPackagesInstalled.length} of ${packages.length}; [local] = ./.pi, [global] = ~/.pi/agent, [both] = both):**
|
|
80
104
|
${packageList}${missingNote}
|
|
81
105
|
|
|
82
106
|
**AGENTS.md delimited section:** ${agentsMdSection}
|
|
@@ -84,8 +108,11 @@ ${packageList}${missingNote}
|
|
|
84
108
|
**Active tools in this session:**
|
|
85
109
|
${activeTools}
|
|
86
110
|
|
|
87
|
-
**Installed packages in .pi/settings.json:**
|
|
88
|
-
${
|
|
111
|
+
**Installed packages in .pi/settings.json (project-local):**
|
|
112
|
+
${installedLocal.length > 0 ? installedLocal.map((p) => `- \`${p}\``).join("\n") : "_(none)_"}
|
|
113
|
+
|
|
114
|
+
**Installed packages in ~/.pi/agent/settings.json (global):**
|
|
115
|
+
${installedGlobal.length > 0 ? installedGlobal.map((p) => `- \`${p}\``).join("\n") : "_(none)_"}
|
|
89
116
|
|
|
90
117
|
### Removal procedure (follow exactly, collaborating with the user)
|
|
91
118
|
|
|
@@ -95,25 +122,35 @@ key. The user may have customized files after deploy, or may want to keep some c
|
|
|
95
122
|
|
|
96
123
|
**1. Walk the user through each removal category, one at a time:**
|
|
97
124
|
|
|
98
|
-
**(a) Packages** — for each distro package still installed,
|
|
99
|
-
\`pi remove -l <pkg
|
|
100
|
-
|
|
101
|
-
|
|
125
|
+
**(a) Packages** — for each distro package still installed, remove it from wherever it
|
|
126
|
+
was placed. Use the [placement] shown above: \`pi remove -l <pkg>\` for [local],
|
|
127
|
+
\`pi remove <pkg>\` for [global] (note: global removal affects EVERY project on this
|
|
128
|
+
machine — say so and get explicit confirmation), and BOTH commands for [both]. **Ask per
|
|
129
|
+
package** — the user may want to keep some. Warn if removing a package that other
|
|
130
|
+
components depend on (e.g. if an extension relies on a package's theme). If the user
|
|
131
|
+
skips a package, note it.
|
|
102
132
|
|
|
103
133
|
**(b) Bundled files** — the directives list bundled files (e.g. \`AGENTS.md\`,
|
|
104
|
-
\`settings.json\`, \`extensions/claude-statusline.ts\`).
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
\`settings.json\`, \`extensions/claude-statusline.ts\`). A file may have been placed at
|
|
135
|
+
EITHER scope: check for it at both \`./<path>\` (project-local) AND
|
|
136
|
+
\`~/.pi/agent/<equivalent>\` (global). For each that exists, **show the user the file (or
|
|
137
|
+
a summary) before removing** — they may have customized it and want to keep it. Offer:
|
|
138
|
+
remove / keep, per location. Never silently delete. For \`settings.json\` (local at
|
|
139
|
+
\`./.pi/settings.json\`, global at \`~/.pi/agent/settings.json\`), offer to remove specific
|
|
140
|
+
keys the distro merged (e.g. \`theme\`, \`defaultThinkingLevel\`) rather than deleting the
|
|
141
|
+
whole file — and warn about user customizations. Warn that global settings removal
|
|
142
|
+
affects every project.
|
|
143
|
+
|
|
144
|
+
**(c) AGENTS.md delimited section** — the \`<!-- pi-distro: ${prov.appliedHarness} -->\` ...
|
|
145
|
+
\`<!-- /pi-distro: ${prov.appliedHarness} -->\` block may exist at \`./AGENTS.md\`
|
|
146
|
+
(project-local) and/or \`~/.pi/agent/AGENTS.md\` (global). Offer to remove it from each
|
|
147
|
+
location it was found (see the AGENTS.md status above). Warn that removing the global one
|
|
148
|
+
affects every session. If the user has a standalone (non-delimited) AGENTS.md that wasn't
|
|
149
|
+
added by the distro, leave it.
|
|
150
|
+
|
|
151
|
+
**(d) Extensions / skills / prompts / themes** — if the directives describe these, check
|
|
152
|
+
for them in BOTH \`./.pi/<type>/\` (local) and \`~/.pi/agent/<type>/\` (global). Offer to
|
|
153
|
+
remove each (with the same show-before-delete rule), per location.
|
|
117
154
|
|
|
118
155
|
**2. Remove provenance last.** Only after the user has confirmed the component removals,
|
|
119
156
|
remove \`./.pi/harness.md\` (the provenance file). Confirm with the user before deleting it.
|
package/extensions/update.ts
CHANGED
|
@@ -55,7 +55,7 @@ export async function handleUpdate(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
} else {
|
|
58
|
-
// Local catalogue (
|
|
58
|
+
// Local catalogue (user distros only — official distros use the github: branch above).
|
|
59
59
|
current = await resolveCatalogueEntry(prov.appliedHarness);
|
|
60
60
|
if (!current) {
|
|
61
61
|
ctx.ui.notify(
|
package/extensions/util.ts
CHANGED
|
@@ -5,28 +5,44 @@
|
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { readFileSync, existsSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
8
9
|
|
|
9
10
|
/** Display markdown content in the TUI as a custom message (no LLM turn). */
|
|
10
11
|
export function display(pi: ExtensionAPI, content: string): void {
|
|
11
12
|
pi.sendMessage({ customType: "pi-distro", content, display: true });
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
/** Compare two semver-ish version strings
|
|
15
|
+
/** Compare two semver-ish version strings (tolerates a leading `v` and a
|
|
16
|
+
* `-prerelease` suffix; a prerelease sorts before its release, e.g.
|
|
17
|
+
* 1.0.0-beta < 1.0.0). Returns >0 if a>b, <0 if a<b, 0 if equal. */
|
|
15
18
|
export function compareVersions(a: string, b: string): number {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const parse = (v: string): { nums: number[]; pre: string } => {
|
|
20
|
+
const s = (v || "0").trim().replace(/^[vV]/, "");
|
|
21
|
+
const [core, ...preParts] = s.split("-");
|
|
22
|
+
return {
|
|
23
|
+
nums: core.split(".").map((n) => parseInt(n, 10) || 0),
|
|
24
|
+
pre: preParts.join("-"),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
const pa = parse(a);
|
|
28
|
+
const pb = parse(b);
|
|
29
|
+
const len = Math.max(pa.nums.length, pb.nums.length);
|
|
19
30
|
for (let i = 0; i < len; i++) {
|
|
20
|
-
const va = pa[i] ?? 0;
|
|
21
|
-
const vb = pb[i] ?? 0;
|
|
31
|
+
const va = pa.nums[i] ?? 0;
|
|
32
|
+
const vb = pb.nums[i] ?? 0;
|
|
22
33
|
if (va !== vb) return va - vb;
|
|
23
34
|
}
|
|
35
|
+
if (pa.pre !== pb.pre) {
|
|
36
|
+
if (!pa.pre) return 1; // release > its own prerelease
|
|
37
|
+
if (!pb.pre) return -1;
|
|
38
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
39
|
+
}
|
|
24
40
|
return 0;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
|
-
/** Read the packages array from
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
/** Read the packages array from a pi settings.json, normalised to source strings
|
|
44
|
+
* (pi allows both string and `{ source, ... }` object entries). */
|
|
45
|
+
function readPackagesFile(settingsPath: string): string[] {
|
|
30
46
|
if (!existsSync(settingsPath)) return [];
|
|
31
47
|
try {
|
|
32
48
|
const s = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
@@ -37,8 +53,62 @@ export function readProjectPackages(cwd: string): string[] {
|
|
|
37
53
|
}
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
|
|
56
|
+
/** Read the packages array from <cwd>/.pi/settings.json, normalised to source strings. */
|
|
57
|
+
export function readProjectPackages(cwd: string): string[] {
|
|
58
|
+
return readPackagesFile(join(cwd, ".pi", "settings.json"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read the packages array from ~/.pi/agent/settings.json (globally-installed
|
|
62
|
+
* packages), normalised to source strings. These are packages installed via
|
|
63
|
+
* `pi install <pkg>` (no `-l`), shared across every project on this machine. */
|
|
64
|
+
export function readGlobalPackages(): string[] {
|
|
65
|
+
return readPackagesFile(join(homedir(), ".pi", "agent", "settings.json"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const SCOPE_RULE = `**Choose install scope per component — local vs global.** pi supports two install scopes, and the user chooses which to use for each component of this distro:
|
|
69
|
+
|
|
70
|
+
- **Project-local** (default) — writes to \`./.pi/\` (packages via \`pi install -l\` → \`./.pi/settings.json\`; extensions/skills/prompts/themes into \`./.pi/<type>/\`; settings into \`./.pi/settings.json\`; AGENTS.md at \`./AGENTS.md\`). Scoped to this project only. This is pi-distro's default philosophy — different projects get different harnesses.
|
|
71
|
+
- **Global** — writes to \`~/.pi/agent/\` (packages via \`pi install\` → \`~/.pi/agent/settings.json\`; extensions/skills/prompts/themes into \`~/.pi/agent/<type>/\`; settings into \`~/.pi/agent/settings.json\`; AGENTS.md at \`~/.pi/agent/AGENTS.md\`). Shared across **every project and session on this machine**. Opt-in, never the default.
|
|
72
|
+
|
|
73
|
+
pi merges global and project-local (project-local shadows global on conflict). Both coexist.
|
|
74
|
+
|
|
75
|
+
### Per-type default scopes (use these in the deployment plan unless the distro's directives mark a component \`(global)\`)
|
|
76
|
+
| Component type | Default scope | Global allowed? | Notes |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| Packages | local | ✅ | the headline global option |
|
|
79
|
+
| Extensions | local | ✅ | |
|
|
80
|
+
| Skills | local | ✅ | |
|
|
81
|
+
| Prompts | local | ✅ | |
|
|
82
|
+
| Themes | **global** | ✅ | themes are user-wide by nature; default global |
|
|
83
|
+
| settings.json merge | local | ⚠️ guarded | global settings affect every project — surface blast radius, require explicit confirm |
|
|
84
|
+
| SYSTEM.md / APPEND_SYSTEM.md | local | ⚠️ double-confirm | global = overrides the system prompt in EVERY session — very dangerous; require a second explicit confirmation |
|
|
85
|
+
| AGENTS.md | local | ⚠️ guarded | \`~/.pi/agent/AGENTS.md\` applies to all sessions — surface blast radius, require explicit confirm |
|
|
86
|
+
|
|
87
|
+
### Deployment-plan procedure (follow exactly)
|
|
88
|
+
**1. Build a deployment plan** before touching anything. Group every installable component from the directives by type, each with its default scope (per the table above, or the author's \`(global)\` hint if present). Render it as markdown so the user can see the whole picture at a glance, e.g.:
|
|
89
|
+
\`\`\`
|
|
90
|
+
📦 Packages (default: local)
|
|
91
|
+
- npm:pi-crew [local]
|
|
92
|
+
- npm:pi-web-access [local]
|
|
93
|
+
🎨 Themes (default: global)
|
|
94
|
+
- my-theme.json [global]
|
|
95
|
+
⚙️ settings.json [local] (merge 3 keys)
|
|
96
|
+
📝 SYSTEM.md [local] (global: guarded — affects all sessions)
|
|
97
|
+
\`\`\`
|
|
98
|
+
|
|
99
|
+
**2. Offer the user three presets** (use \`ctx.ui.select\`):
|
|
100
|
+
- **(a) Accept defaults** — keep every component at its default scope (today's project-local behaviour for most things; themes go global). This is the fast path and the recommended option.
|
|
101
|
+
- **(b) All-global (where safe)** — flip every *global-allowed* component to global. **Dangerous types** (settings.json, SYSTEM.md/APPEND_SYSTEM.md, AGENTS.md) stay local and you surface a warning explaining they were not flipped because their blast radius is machine-wide. Themes and any \`(global)\`-hinted items stay global.
|
|
102
|
+
- **(c) Customize** — walk the user through components one at a time (\`ctx.ui.select\` is single-select, so go item by item), offering \`local\` / \`global\` / \`skip\` for each. Use the default scope as the highlighted option. \`ctx.ui.select\` returns undefined on cancel — treat that as \`skip\` for that item and continue.
|
|
103
|
+
|
|
104
|
+
**3. Apply the scope-safety guard.** Whenever the final scope for a **dangerous type** (settings.json, SYSTEM.md/APPEND_SYSTEM.md, AGENTS.md) is **global**, you MUST first surface the blast radius clearly and get an explicit confirmation: "This writes to \`~/.pi/agent/<file>\` and affects **every project and session on this machine**, not just this one. Confirm?" For SYSTEM.md/APPEND_SYSTEM.md global, require a **second** explicit confirmation — global system-prompt overrides are the most dangerous thing a distro can do. If the user declines, fall that component back to local (or skip if they prefer).
|
|
105
|
+
|
|
106
|
+
**4. Install/place per the chosen scope.** For packages: \`pi install -l <pkg>\` for local, \`pi install <pkg>\` for global — only after the redundancy/conflict check and user confirmation. For bundled files: write to the local path (\`./.pi/...\`, \`./AGENTS.md\`) or the global path (\`~/.pi/agent/...\`, \`~/.pi/agent/AGENTS.md\`) per the chosen scope, applying merge-don't-clobber at whichever target. **Never pre-add packages to settings.json by hand** — \`pi install\`/\`pi install -l\` registers them on success and leaves settings untouched on failure.
|
|
107
|
+
|
|
108
|
+
**5. Remember the scope for provenance & removal.** pi-distro does not record scope in provenance (provenance keeps the distro's directives). At \`undeploy\`/\`status\` time, the extension detects where each component actually landed by checking both \`./.pi/...\` and \`~/.pi/agent/...\` (and \`pi list\` for packages). So when you place a component globally, just note in your final report which components went global — the user can \`pi remove <pkg>\` (global) or delete from \`~/.pi/agent/...\` later, and \`/pi-distro undeploy\` will find them there.`;
|
|
109
|
+
|
|
110
|
+
export const MERGE_RULE = `**Merge, don't clobber.** For any target file that already exists (at whichever scope the user chose — \`./.pi/...\` for local or \`~/.pi/agent/...\` for global), do not overwrite silently — show a diff and ask the user whether to overwrite, keep theirs, or merge. Merge JSON settings field-by-field. Append AGENTS.md content under a delimited section. Install pi packages with \`pi install -l\` (project-local) or \`pi install\` (global) per the chosen scope, only after confirming with the user; do NOT pre-add packages to settings.json by hand — \`pi install\`/\`pi install -l\` registers them on success (and leaves settings untouched on failure).`;
|
|
41
111
|
|
|
42
112
|
export const USER_INVOLVEMENT_RULE = `**The user is in the loop for every decision.** pi-distro is a collaborative, agent-driven tool — never make a state-changing decision for the user. Before overwriting a file, installing a package, skipping a step, replacing a tool, applying an upgrade, or resolving any conflict (merge, redundancy, version downgrade, etc.), the agent must (1) surface the decision clearly (what it's about to do and why), (2) present the available options, and (3) wait for the user's explicit choice. Never silently skip, silently overwrite, silently substitute, or proceed on assumption. If a decision is ambiguous or the user is unsure, explain the tradeoffs and let them choose. The agent proposes; the user disposes.`;
|
|
43
113
|
|
|
44
|
-
export const PACKAGE_CONFLICT_RULE = `**Evaluate tool redundancy/conflicts before installing.** When you install a distro's packages, some may provide tools that overlap with already-active tools — either an **exact name collision** (two tools with the same name) or **semantic redundancy** (different names, but doing very similar things, e.g. two web-search tools, two browser tools, two todo tools). pi handles exact name collisions by load order (project-local tools shadow global ones; the conflict is a non-fatal diagnostic, not a fatal error — pi still starts), but redundancy leaves the user with duplicate capability and a confusing tool set. Before installing each package, the **agent must evaluate** whether its purpose overlaps an already-active tool (read the already-active tools
|
|
114
|
+
export const PACKAGE_CONFLICT_RULE = `**Evaluate tool redundancy/conflicts before installing.** When you install a distro's packages, some may provide tools that overlap with already-active tools — either an **exact name collision** (two tools with the same name) or **semantic redundancy** (different names, but doing very similar things, e.g. two web-search tools, two browser tools, two todo tools). pi handles exact name collisions by load order (project-local tools shadow global ones; the conflict is a non-fatal diagnostic, not a fatal error — pi still starts), but redundancy leaves the user with duplicate capability and a confusing tool set. Before installing each package, the **agent must evaluate** whether its purpose overlaps an already-active tool (read the already-active tools, the project packages, and the global packages listed above; also run \`pi list\` if you need a fresh view). If redundancy or a conflict is detected, do NOT install blindly — present the user a choice: (a) **skip** installing it (the capability already exists); (b) **replace** — remove the overlapping package from wherever it lives (\`pi remove -l <old>\` if project-local, \`pi remove <old>\` if global) then install the new one at the chosen scope (\`pi install -l <new>\` or \`pi install <new>\`); (c) **keep both** — install it anyway (e.g. the user prefers the new tool, or they serve subtly different purposes); (d) **cancel**. Only proceed with the user's choice.`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@msdavid/pi-distro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Reusable, composable configurations for the pi coding agent — seed distros, project snapshots, and GitHub sharing with version-aware updates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
"files": [
|
|
29
29
|
"extensions",
|
|
30
30
|
"skills",
|
|
31
|
-
"harnesses",
|
|
32
31
|
"README.md",
|
|
33
32
|
"docs",
|
|
34
33
|
"LICENSE",
|