@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.
@@ -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 configFiles: string[] = [];
32
- const pushFile = (fp: string) => {
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
- configFiles.push(`### ${fp}\n_(${st.size} bytes — read with the \`read\` tool if needed)_`);
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
- configFiles.push(`### ${fp}\n\`\`\`\n${readFileSync(fp, "utf-8")}\n\`\`\``);
41
- } catch { /* unreadable (binary?) — skip */ }
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 a seed name, warn and ask whether to overwrite), then create
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 (those are package
144
- seeds and are read-only). Before overwriting, back the old distro up: copy
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
@@ -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, parsePackageList } from "./catalogue.ts";
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 = parsePackageList(body);
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}\``).join("\n") : "_(none)_"}
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
  }
@@ -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
- const installedPackages = readProjectPackages(cwd);
39
-
40
- // Which of the distro's packages are still installed?
41
- const distroPackagesInstalled = packages.filter((p) => installedPackages.includes(p));
42
- const distroPackagesMissing = packages.filter((p) => !installedPackages.includes(p));
43
-
44
- // Check for the AGENTS.md delimited section.
45
- const agentsMdPath = join(cwd, "AGENTS.md");
46
- let agentsMdSection = "not present";
47
- if (existsSync(agentsMdPath)) {
48
- const content = readFileSync(agentsMdPath, "utf-8");
49
- if (content.includes(`<!-- pi-distro: ${prov.appliedHarness} -->`)) {
50
- agentsMdSection = "present";
51
- } else {
52
- agentsMdSection = "not found (already removed or never added)";
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((p) => `- \`${p}\` — installed (can be removed with \`pi remove -l\`)`).join("\n")
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((p) => `- \`${p}\``).join("\n")}`
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 in this project (${distroPackagesInstalled.length} of ${packages.length}):**
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
- ${installedPackages.length > 0 ? installedPackages.map((p) => `- \`${p}\``).join("\n") : "_(none)_"}
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, offer to remove it with
99
- \`pi remove -l <pkg>\`. **Ask per package** — the user may want to keep some. Warn if
100
- removing a package that other components depend on (e.g. if an extension relies on a
101
- package's theme). If the user skips a package, note it.
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\`). For each, check if the file exists
105
- in the project. If it does, **show the user the file (or a summary) before removing** —
106
- they may have customized it and want to keep it. Offer: remove / keep. Never silently
107
- delete. For \`settings.json\`, offer to remove specific keys the distro merged (e.g.
108
- \`theme\`, \`defaultThinkingLevel\`) rather than deleting the whole file and warn about
109
- user customizations.
110
-
111
- **(c) AGENTS.md delimited section** if the \`<!-- pi-distro: ${prov.appliedHarness} -->\` ...
112
- \`<!-- /pi-distro: ${prov.appliedHarness} -->\` block exists, offer to remove it. If the
113
- user has a standalone (non-delimited) AGENTS.md that wasn't added by the distro, leave it.
114
-
115
- **(d) Extensions / skills / prompts / themes** if the directives describe these and they
116
- exist in the project, offer to remove each (with the same show-before-delete rule).
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 fileand 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.
@@ -55,7 +55,7 @@ export async function handleUpdate(pi: ExtensionAPI, ctx: ExtensionCommandContex
55
55
  return;
56
56
  }
57
57
  } else {
58
- // Local catalogue (seed or user).
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(
@@ -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. Returns >0 if a>b, <0 if a<b, 0 if equal. */
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 pa = (a || "0").split(".").map((n) => parseInt(n, 10) || 0);
17
- const pb = (b || "0").split(".").map((n) => parseInt(n, 10) || 0);
18
- const len = Math.max(pa.length, pb.length);
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 <cwd>/.pi/settings.json, normalised to source strings. */
28
- export function readProjectPackages(cwd: string): string[] {
29
- const settingsPath = join(cwd, ".pi", "settings.json");
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
- export const MERGE_RULE = `**Merge, don't clobber.** For any target file that already exists, 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) only after confirming with the user; do NOT pre-add packages to ./.pi/settings.json by hand \`pi install -l\` registers them on success (and leaves settings untouched on failure).`;
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 and project packages above, and run \`pi list\` to see globally-installed packages). 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** — \`pi remove -l <old>\` the overlapping package then \`pi install -l <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.`;
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.2.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",