@msdavid/pi-distro 0.3.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 +40 -0
- package/README.md +64 -4
- package/docs/authoring.md +87 -13
- package/extensions/catalogue.ts +58 -22
- package/extensions/deploy.ts +33 -6
- package/extensions/github.ts +40 -3
- package/extensions/index.ts +4 -1
- package/extensions/info.ts +25 -16
- package/extensions/pick.ts +25 -10
- package/extensions/resolve.ts +17 -3
- package/extensions/save.ts +78 -8
- package/extensions/show.ts +9 -3
- package/extensions/undeploy.ts +78 -41
- package/extensions/util.ts +81 -11
- package/package.json +1 -1
- package/skills/pi-distro/SKILL.md +135 -44
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, isOfficialSource } 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";
|
|
@@ -48,9 +50,13 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
|
|
|
48
50
|
// Parse the distro into selectable components.
|
|
49
51
|
const fullMd = readFileSync(entry.harnessMdPath!, "utf-8");
|
|
50
52
|
const directives = extractBody(fullMd);
|
|
51
|
-
const packages =
|
|
53
|
+
const packages = parsePackageListWithScope(directives);
|
|
52
54
|
const files = entry.filesDir ? await listBundledFiles(entry.filesDir) : [];
|
|
53
|
-
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;
|
|
54
60
|
|
|
55
61
|
const snapshot = ctx.getSystemPromptOptions();
|
|
56
62
|
const activeTools = snapshot.selectedTools?.length
|
|
@@ -58,9 +64,11 @@ export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext,
|
|
|
58
64
|
: "_(none / default set)_";
|
|
59
65
|
const pkgs = readProjectPackages(snapshot.cwd);
|
|
60
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)_";
|
|
61
69
|
|
|
62
70
|
const packageList = packages.length > 0
|
|
63
|
-
? packages.map((p) => `- \`${p}
|
|
71
|
+
? packages.map((p) => `- \`${p.source}\` [${p.scope}]`).join("\n")
|
|
64
72
|
: "_(none)_";
|
|
65
73
|
const fileList = files.length > 0
|
|
66
74
|
? files.map((f) => `- \`${f.source}\` → \`./${f.target}\``).join("\n")
|
|
@@ -89,19 +97,25 @@ extensions, themes, prompts, skills) — treat each as individually selectable t
|
|
|
89
97
|
**Already-active tools in this session:**
|
|
90
98
|
${activeTools}
|
|
91
99
|
|
|
92
|
-
**Packages already in this project's .pi/settings.json:**
|
|
100
|
+
**Packages already in this project's .pi/settings.json (project-local):**
|
|
93
101
|
${projectPackages}
|
|
94
102
|
|
|
95
|
-
|
|
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.)
|
|
96
107
|
|
|
97
108
|
### Selection procedure (follow exactly, collaborating with the user)
|
|
98
109
|
|
|
99
110
|
**0. User involvement rule** — ${USER_INVOLVEMENT_RULE}
|
|
100
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
|
+
|
|
101
114
|
**1. Walk the user through each category, one at a time.** For each category (packages, then
|
|
102
115
|
bundled files, then any other components described in the directives), use
|
|
103
116
|
\`ctx.ui.select\`/\`ctx.ui.confirm\` to let the user pick which to apply. Present the items
|
|
104
|
-
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 —
|
|
105
119
|
including none (skip the category entirely).
|
|
106
120
|
|
|
107
121
|
**2. Surface dependencies.** As the user selects, **evaluate cross-component dependencies**
|
|
@@ -115,7 +129,7 @@ ${projectPackages}
|
|
|
115
129
|
**3. Apply only the selected components**, with the same rules as a full deploy:
|
|
116
130
|
- **Merge-don't-clobber** — ${MERGE_RULE}
|
|
117
131
|
- **Package-redundancy check** — ${PACKAGE_CONFLICT_RULE}
|
|
118
|
-
- 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
|
|
119
133
|
settings.json by hand).
|
|
120
134
|
- For each selected bundled file: copy/merge as the user chooses (overwrite / keep theirs /
|
|
121
135
|
merge).
|
|
@@ -129,5 +143,6 @@ ${projectPackages}
|
|
|
129
143
|
|
|
130
144
|
**5. Recommend a restart** if any packages or extensions were installed — they load at
|
|
131
145
|
startup.`);
|
|
132
|
-
// 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.
|
|
133
148
|
}
|
package/extensions/resolve.ts
CHANGED
|
@@ -14,7 +14,13 @@ import {
|
|
|
14
14
|
sourceLabel,
|
|
15
15
|
} from "./catalogue.ts";
|
|
16
16
|
import type { HarnessEntry } from "./catalogue.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
looksLikeGithubRef,
|
|
19
|
+
parseGithubRef,
|
|
20
|
+
fetchGithubDistro,
|
|
21
|
+
fetchOfficialDistro,
|
|
22
|
+
isOfficialCatalogueUnavailable,
|
|
23
|
+
} from "./github.ts";
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* Resolve a distro from a name argument (GitHub ref or local catalogue name).
|
|
@@ -43,15 +49,23 @@ export async function resolveDistro(
|
|
|
43
49
|
|
|
44
50
|
// Local catalogue
|
|
45
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
|
+
: "";
|
|
46
55
|
if (catalogue.length === 0) {
|
|
47
|
-
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
|
+
);
|
|
48
62
|
return undefined;
|
|
49
63
|
}
|
|
50
64
|
if (nameArg) {
|
|
51
65
|
const harness = findHarness(nameArg, catalogue);
|
|
52
66
|
if (!harness) {
|
|
53
67
|
const available = catalogue.map((h) => h.name).join(", ") || "(none)";
|
|
54
|
-
ctx.ui.notify(`Distro '${nameArg}' not found. Available: ${available}`, "error");
|
|
68
|
+
ctx.ui.notify(`Distro '${nameArg}' not found. Available: ${available}${offlineNote}`, "error");
|
|
55
69
|
return undefined;
|
|
56
70
|
}
|
|
57
71
|
return resolveEntry(harness, ctx);
|
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,6 +180,28 @@ 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
|
|
|
@@ -172,7 +242,7 @@ project root:
|
|
|
172
242
|
- any per-extension/skill config files (e.g. \`.pi/<name>.json\`) -> \`files/.pi/\` (same path)
|
|
173
243
|
Use \`cp -r\` for directory trees. Only copy files that actually exist. Do NOT copy:
|
|
174
244
|
\`./.pi/harness.md\` (provenance), \`./.pi/npm/\`, \`./.pi/git/\`, \`./.pi/sessions/\`,
|
|
175
|
-
\`./.pi/state/\`, \`./.pi/tmp/\`, \`./.pi/.crew/\`, or any \`node_modules/\`.
|
|
245
|
+
\`./.pi/state/\`, \`./.pi/tmp/\`, \`./.pi/loops/\`, \`./.pi/.crew/\`, or any \`node_modules/\`.
|
|
176
246
|
|
|
177
247
|
**Theme dedup:** before bundling a \`.pi/themes/<name>.json\`, check whether that theme
|
|
178
248
|
name is already provided by an installed package (run \`pi list\`; package themes surface
|
package/extensions/show.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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
13
|
import { looksLikeGithubRef, parseGithubRef, fetchGithubDistro, fetchOfficialDistro } from "./github.ts";
|
|
@@ -18,7 +18,7 @@ export async function buildShowPreview(harness: HarnessEntry): Promise<string> {
|
|
|
18
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) {
|
|
@@ -44,7 +44,9 @@ export async function buildShowPreview(harness: HarnessEntry): Promise<string> {
|
|
|
44
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
|
|
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/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",
|