@msdavid/pi-distro 0.2.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 +54 -0
- package/LICENSE +21 -0
- package/README.md +394 -0
- package/docs/authoring.md +238 -0
- package/docs/preview.png +0 -0
- package/docs/preview.svg +58 -0
- package/extensions/catalogue.ts +221 -0
- package/extensions/deploy.ts +141 -0
- package/extensions/frontmatter.ts +158 -0
- package/extensions/github.ts +110 -0
- package/extensions/index.ts +86 -0
- package/extensions/info.ts +133 -0
- package/extensions/pick.ts +132 -0
- package/extensions/resolve.ts +70 -0
- package/extensions/save.ts +217 -0
- package/extensions/show.ts +96 -0
- package/extensions/undeploy.ts +124 -0
- package/extensions/update.ts +109 -0
- package/extensions/util.ts +44 -0
- package/harnesses/minimal/README.md +21 -0
- package/harnesses/minimal/files/AGENTS.md +20 -0
- package/harnesses/minimal/files/settings.json +4 -0
- package/harnesses/minimal/harness.md +24 -0
- package/harnesses/pi-distro-one/README.md +50 -0
- package/harnesses/pi-distro-one/files/.pi/extensions/claude-statusline.ts +220 -0
- package/harnesses/pi-distro-one/files/AGENTS.md +166 -0
- package/harnesses/pi-distro-one/files/settings.json +9 -0
- package/harnesses/pi-distro-one/harness.md +79 -0
- package/harnesses/web-fullstack/README.md +25 -0
- package/harnesses/web-fullstack/files/.pi/prompts/review.md +12 -0
- package/harnesses/web-fullstack/files/AGENTS.md +37 -0
- package/harnesses/web-fullstack/files/settings.json +11 -0
- package/harnesses/web-fullstack/harness.md +40 -0
- package/package.json +65 -0
- package/skills/pi-distro/SKILL.md +359 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny inline YAML frontmatter parser for harness.md files.
|
|
3
|
+
*
|
|
4
|
+
* No external yaml dependency β only handles the fields used by harness.md:
|
|
5
|
+
* name, title, description, version, author, tags.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Simple `key: value` pairs
|
|
9
|
+
* - YAML `>` folded scalar for multi-line descriptions
|
|
10
|
+
* - Inline array `[a, b, c]`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Frontmatter {
|
|
14
|
+
name?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
version?: string;
|
|
18
|
+
author?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse the YAML frontmatter block from a harness.md (or any markdown) string.
|
|
25
|
+
* Returns the parsed fields and null if no frontmatter block is found.
|
|
26
|
+
*/
|
|
27
|
+
export function parseFrontmatter(content: string): Frontmatter | null {
|
|
28
|
+
const lines = content.split(/\r?\n/);
|
|
29
|
+
|
|
30
|
+
// Must start with ---
|
|
31
|
+
if (lines[0]?.trim() !== "---") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Find closing ---
|
|
36
|
+
let end = -1;
|
|
37
|
+
for (let i = 1; i < lines.length; i++) {
|
|
38
|
+
if (lines[i]?.trim() === "---") {
|
|
39
|
+
end = i;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (end === -1) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const yamlLines = lines.slice(1, end);
|
|
48
|
+
return parseYamlLines(yamlLines);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Serialize a frontmatter object back to a YAML block string (with --- fences).
|
|
53
|
+
*/
|
|
54
|
+
export function serializeFrontmatter(fm: Record<string, unknown>): string {
|
|
55
|
+
const lines: string[] = ["---"];
|
|
56
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
57
|
+
if (value === undefined || value === null) continue;
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
lines.push(`${key}: [${value.map((v) => String(v)).join(", ")}]`);
|
|
60
|
+
} else if (typeof value === "string" && value.includes("\n")) {
|
|
61
|
+
// Use folded scalar for multi-line
|
|
62
|
+
lines.push(`${key}: >`);
|
|
63
|
+
for (const sub of value.split("\n")) {
|
|
64
|
+
lines.push(` ${sub}`);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
lines.push(`${key}: ${formatScalar(value)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
lines.push("---");
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract the body (everything after the frontmatter block).
|
|
76
|
+
* If there's no frontmatter, returns the full content.
|
|
77
|
+
*/
|
|
78
|
+
export function extractBody(content: string): string {
|
|
79
|
+
const lines = content.split(/\r?\n/);
|
|
80
|
+
if (lines[0]?.trim() !== "---") {
|
|
81
|
+
return content;
|
|
82
|
+
}
|
|
83
|
+
let end = -1;
|
|
84
|
+
for (let i = 1; i < lines.length; i++) {
|
|
85
|
+
if (lines[i]?.trim() === "---") {
|
|
86
|
+
end = i;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (end === -1) {
|
|
91
|
+
return content;
|
|
92
|
+
}
|
|
93
|
+
return lines.slice(end + 1).join("\n").replace(/^\n+/, "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Internal helpers ---
|
|
97
|
+
|
|
98
|
+
function parseYamlLines(lines: string[]): Frontmatter {
|
|
99
|
+
const result: Frontmatter = {};
|
|
100
|
+
let i = 0;
|
|
101
|
+
while (i < lines.length) {
|
|
102
|
+
const line = lines[i];
|
|
103
|
+
if (!line || line.trim() === "" || line.trim().startsWith("#")) {
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const match = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
|
109
|
+
if (!match) {
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const key = match[1];
|
|
114
|
+
let value = match[2].trim();
|
|
115
|
+
|
|
116
|
+
// Handle folded scalar (> or >-)
|
|
117
|
+
if (value === ">" || value === ">-") {
|
|
118
|
+
const folded: string[] = [];
|
|
119
|
+
i++;
|
|
120
|
+
while (i < lines.length && (lines[i]?.startsWith(" ") || lines[i]?.startsWith("\t") || lines[i]?.trim() === "")) {
|
|
121
|
+
folded.push((lines[i] ?? "").replace(/^[ \t]+/, ""));
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
result[key] = folded.join(" ").trim();
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Handle inline array
|
|
129
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
130
|
+
result[key] = value
|
|
131
|
+
.slice(1, -1)
|
|
132
|
+
.split(",")
|
|
133
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Strip surrounding quotes
|
|
140
|
+
result[key] = value.replace(/^["']|["']$/g, "");
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatScalar(value: unknown): string {
|
|
147
|
+
if (typeof value === "string") {
|
|
148
|
+
// Quote if contains special chars
|
|
149
|
+
if (/[:#{}\[\],&*?|<>=!%@`]/.test(value) || value.includes("\n")) {
|
|
150
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
155
|
+
return String(value);
|
|
156
|
+
}
|
|
157
|
+
return String(value);
|
|
158
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub distro pull logic for pi-distro.
|
|
3
|
+
*
|
|
4
|
+
* Supports fetching distros from GitHub repositories via shallow clone.
|
|
5
|
+
* Used by `show <gh-repo>` and `deploy <gh-distro>` subcommands.
|
|
6
|
+
*
|
|
7
|
+
* Address format:
|
|
8
|
+
* owner/repo β harness.md at repo root
|
|
9
|
+
* owner/repo/subpath β harness.md at <subpath>/
|
|
10
|
+
* https://github.com/... β full URLs also accepted
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
18
|
+
import type { HarnessEntry } from "./catalogue.ts";
|
|
19
|
+
|
|
20
|
+
export interface GithubRef {
|
|
21
|
+
owner: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
subPath: string; // "" for root
|
|
24
|
+
displayRef: string; // "owner/repo" or "owner/repo/subpath"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a GitHub reference: `owner/repo[/subpath]` or a full GitHub URL.
|
|
29
|
+
* Returns undefined if the string doesn't look like a valid GitHub ref.
|
|
30
|
+
*/
|
|
31
|
+
export function parseGithubRef(ref: string): GithubRef | undefined {
|
|
32
|
+
let s = ref.trim();
|
|
33
|
+
s = s.replace(/^https?:\/\/github\.com\//, "");
|
|
34
|
+
s = s.replace(/^github\.com\//, "");
|
|
35
|
+
s = s.replace(/\/$/, ""); // strip trailing slash before .git so "repo.git/" is handled
|
|
36
|
+
s = s.replace(/\.git$/, "");
|
|
37
|
+
const parts = s.split("/").filter(Boolean);
|
|
38
|
+
if (parts.length < 2) return undefined;
|
|
39
|
+
const [owner, repo, ...rest] = parts;
|
|
40
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(owner)) return undefined;
|
|
41
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(repo)) return undefined;
|
|
42
|
+
const subPath = rest.join("/");
|
|
43
|
+
return {
|
|
44
|
+
owner,
|
|
45
|
+
repo,
|
|
46
|
+
subPath,
|
|
47
|
+
displayRef: subPath ? `${owner}/${repo}/${subPath}` : `${owner}/${repo}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether an argument looks like a GitHub reference (contains a `/`). */
|
|
52
|
+
export function looksLikeGithubRef(arg: string): boolean {
|
|
53
|
+
return arg.includes("/") && parseGithubRef(arg) !== undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shallow-clone a GitHub repo to a temp dir and return a HarnessEntry
|
|
58
|
+
* pointing at the distro within it.
|
|
59
|
+
*
|
|
60
|
+
* Throws on clone failure or if harness.md is not found / unparseable.
|
|
61
|
+
* The caller is responsible for calling `cleanup()` to remove the temp dir.
|
|
62
|
+
*/
|
|
63
|
+
export function fetchGithubDistro(
|
|
64
|
+
ref: GithubRef,
|
|
65
|
+
): { entry: HarnessEntry; cleanup: () => void } {
|
|
66
|
+
const cloneUrl = `https://github.com/${ref.owner}/${ref.repo}.git`;
|
|
67
|
+
const tmp = mkdtempSync(join(tmpdir(), "pi-distro-gh-"));
|
|
68
|
+
try {
|
|
69
|
+
execSync(`git clone --depth 1 "${cloneUrl}" "${tmp}"`, {
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
timeout: 30_000,
|
|
72
|
+
encoding: "utf-8",
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Failed to clone ${ref.displayRef}: ${err instanceof Error ? err.message : String(err)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const distroDir = ref.subPath ? join(tmp, ref.subPath) : tmp;
|
|
82
|
+
const harnessMdPath = join(distroDir, "harness.md");
|
|
83
|
+
if (!existsSync(harnessMdPath)) {
|
|
84
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
85
|
+
throw new Error(
|
|
86
|
+
`No harness.md found ${ref.subPath ? `at '${ref.subPath}/' ` : ""}in ${ref.displayRef}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const content = readFileSync(harnessMdPath, "utf-8");
|
|
91
|
+
const fm = parseFrontmatter(content);
|
|
92
|
+
if (!fm?.name) {
|
|
93
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
94
|
+
throw new Error(`Invalid harness.md in ${ref.displayRef}: missing 'name' field`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const filesDir = join(distroDir, "files");
|
|
98
|
+
const entry: HarnessEntry = {
|
|
99
|
+
name: fm.name,
|
|
100
|
+
title: fm.title ?? fm.name,
|
|
101
|
+
description: fm.description ?? "",
|
|
102
|
+
version: fm.version ?? "0.0.0",
|
|
103
|
+
source: `github:${ref.displayRef}`,
|
|
104
|
+
dir: distroDir,
|
|
105
|
+
harnessMdPath,
|
|
106
|
+
filesDir: existsSync(filesDir) ? filesDir : undefined,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return { entry, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
|
|
110
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-distro extension engine.
|
|
3
|
+
*
|
|
4
|
+
* Registers a single slash command `/pi-distro` that dispatches to
|
|
5
|
+
* subcommands: deploy, undeploy, pick, update, save, list, show, status, remove.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
import { getCatalogueNamesSync } from "./catalogue.ts";
|
|
15
|
+
import { display } from "./util.ts";
|
|
16
|
+
import { handleShow } from "./show.ts";
|
|
17
|
+
import { handleDeploy } from "./deploy.ts";
|
|
18
|
+
import { handlePick } from "./pick.ts";
|
|
19
|
+
import { handleUndeploy } from "./undeploy.ts";
|
|
20
|
+
import { handleUpdate } from "./update.ts";
|
|
21
|
+
import { handleSave } from "./save.ts";
|
|
22
|
+
import { handleList, handleStatus, handleRemove } from "./info.ts";
|
|
23
|
+
|
|
24
|
+
const SUBCOMMANDS = [
|
|
25
|
+
{ name: "deploy", desc: "Deploy a distro (local name or gh: owner/repo[/sub])" },
|
|
26
|
+
{ name: "undeploy", desc: "Remove an applied distro from the current project" },
|
|
27
|
+
{ name: "pick", desc: "Partially deploy: select which packages/configs to apply from a distro" },
|
|
28
|
+
{ name: "update", desc: "Update the applied distro if a newer version exists" },
|
|
29
|
+
{ name: "save", desc: "Snapshot the current project config as a distro" },
|
|
30
|
+
{ name: "list", desc: "List all available distros" },
|
|
31
|
+
{ name: "show", desc: "Preview a distro dry-run (local name or gh: owner/repo[/sub])" },
|
|
32
|
+
{ name: "status", desc: "Show the current project's distro status" },
|
|
33
|
+
{ name: "remove", desc: "Delete a user distro" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export default function (pi: ExtensionAPI): void {
|
|
37
|
+
pi.registerCommand("pi-distro", {
|
|
38
|
+
description: "Manage pi distros (deploy, save, list, show, status, remove)",
|
|
39
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
40
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
41
|
+
const sub = parts[0] ?? "";
|
|
42
|
+
const nameArg = parts[1];
|
|
43
|
+
switch (sub) {
|
|
44
|
+
case "": await handleHelp(pi); break;
|
|
45
|
+
case "deploy": await handleDeploy(pi, ctx, nameArg); break;
|
|
46
|
+
case "undeploy": await handleUndeploy(pi, ctx); break;
|
|
47
|
+
case "pick": await handlePick(pi, ctx, nameArg); break;
|
|
48
|
+
case "update": await handleUpdate(pi, ctx); break;
|
|
49
|
+
case "save": await handleSave(pi, ctx); break;
|
|
50
|
+
case "list": await handleList(pi); break;
|
|
51
|
+
case "show": await handleShow(pi, nameArg); break;
|
|
52
|
+
case "status": await handleStatus(pi, ctx); break;
|
|
53
|
+
case "remove": await handleRemove(pi, ctx, nameArg); break;
|
|
54
|
+
default:
|
|
55
|
+
ctx.ui.notify(`Unknown subcommand: ${sub}`, "error");
|
|
56
|
+
await handleHelp(pi);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
60
|
+
const items: AutocompleteItem[] = SUBCOMMANDS.map((s) => ({
|
|
61
|
+
value: s.name, label: s.name, description: s.desc,
|
|
62
|
+
}));
|
|
63
|
+
const parts = prefix.split(/\s+/);
|
|
64
|
+
const sub = parts[0];
|
|
65
|
+
const namePrefix = parts.slice(1).join(" ");
|
|
66
|
+
if ((sub === "show" || sub === "remove" || sub === "deploy" || sub === "pick")) {
|
|
67
|
+
try {
|
|
68
|
+
for (const name of getCatalogueNamesSync()) {
|
|
69
|
+
if (!namePrefix || name.startsWith(namePrefix)) {
|
|
70
|
+
items.push({ value: `${sub} ${name}`, label: name, description: "distro name" });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch { /* ignore */ }
|
|
74
|
+
}
|
|
75
|
+
const filtered = items.filter((i) => i.value.startsWith(prefix) || i.label.startsWith(prefix));
|
|
76
|
+
return filtered.length > 0 ? filtered : null;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- help (no arg) ---
|
|
82
|
+
|
|
83
|
+
async function handleHelp(pi: ExtensionAPI): Promise<void> {
|
|
84
|
+
const lines = SUBCOMMANDS.map((s) => ` **${s.name}** β ${s.desc}`);
|
|
85
|
+
display(pi, `**pi-distro** β reusable pi configurations.\n\nUsage: \`/pi-distro <subcommand>\`\n\n${lines.join("\n")}`);
|
|
86
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only / catalogue-management commands:
|
|
3
|
+
* `/pi-distro list`, `/pi-distro status`, `/pi-distro remove`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, cpSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
readCatalogue,
|
|
15
|
+
findHarness,
|
|
16
|
+
parseProvenance,
|
|
17
|
+
getUserHarnessesDir,
|
|
18
|
+
} from "./catalogue.ts";
|
|
19
|
+
import { resolveCatalogueEntry } from "./resolve.ts";
|
|
20
|
+
import { display, compareVersions } from "./util.ts";
|
|
21
|
+
|
|
22
|
+
// --- list ---
|
|
23
|
+
|
|
24
|
+
export async function handleList(pi: ExtensionAPI): Promise<void> {
|
|
25
|
+
const catalogue = await readCatalogue();
|
|
26
|
+
if (catalogue.length === 0) { display(pi, "No distros found in the catalogue."); return; }
|
|
27
|
+
const rows = catalogue.map((h) => {
|
|
28
|
+
const desc = h.description.length > 50 ? h.description.slice(0, 47) + "..." : h.description;
|
|
29
|
+
return `| ${h.name} | ${h.title} | ${h.version} | ${h.source} | ${desc} |`;
|
|
30
|
+
});
|
|
31
|
+
const seedCount = catalogue.filter((h) => h.source === "seed").length;
|
|
32
|
+
const userCount = catalogue.filter((h) => h.source === "user").length;
|
|
33
|
+
display(pi, `## Distro catalogue
|
|
34
|
+
|
|
35
|
+
| NAME | TITLE | VERSION | SOURCE | DESCRIPTION |
|
|
36
|
+
|------|-------|---------|--------|-------------|
|
|
37
|
+
${rows.join("\n")}
|
|
38
|
+
|
|
39
|
+
_Seeds: ${seedCount} Β· User: ${userCount} Β· Total: ${catalogue.length}_`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- status ---
|
|
43
|
+
|
|
44
|
+
export async function handleStatus(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
45
|
+
const snapshot = ctx.getSystemPromptOptions();
|
|
46
|
+
const cwd = snapshot.cwd;
|
|
47
|
+
const provenancePath = join(cwd, ".pi", "harness.md");
|
|
48
|
+
|
|
49
|
+
let provenanceSection: string;
|
|
50
|
+
let updateSection = "";
|
|
51
|
+
if (existsSync(provenancePath)) {
|
|
52
|
+
const prov = parseProvenance(readFileSync(provenancePath, "utf-8"));
|
|
53
|
+
if (prov) {
|
|
54
|
+
provenanceSection = `### Applied distro\n- **Name:** ${prov.appliedHarness}\n- **Version:** ${prov.appliedVersion}\n- **Source:** ${prov.sourceCatalogue}\n- **Last updated:** ${prov.lastUpdated}`;
|
|
55
|
+
// Check for updates (local catalogue only; GitHub sources are not auto-fetched on status).
|
|
56
|
+
if (!prov.sourceCatalogue.startsWith("github:")) {
|
|
57
|
+
const current = await resolveCatalogueEntry(prov.appliedHarness);
|
|
58
|
+
if (current) {
|
|
59
|
+
const cmp = compareVersions(current.version, prov.appliedVersion);
|
|
60
|
+
if (cmp > 0) {
|
|
61
|
+
updateSection = `### Update available\n**${prov.appliedHarness}** v${prov.appliedVersion} β v${current.version}. Run \`/pi-distro update\` to apply.`;
|
|
62
|
+
} else if (cmp < 0) {
|
|
63
|
+
updateSection = `### Version note\nApplied v${prov.appliedVersion} is newer than the catalogue's v${current.version} (downgrade).`;
|
|
64
|
+
} // same version β no section
|
|
65
|
+
} else {
|
|
66
|
+
updateSection = `### Update check\nDistro '${prov.appliedHarness}' is no longer in the catalogue (removed or renamed).`;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
updateSection = `### Update check\nApplied from GitHub (\`${prov.sourceCatalogue}\`). Run \`/pi-distro update\` to fetch the latest and check for updates.`;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
provenanceSection = "### Applied distro\nA provenance file exists but could not be parsed.";
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
provenanceSection = "### Applied distro\nNo distro provenance found in this project.";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tools = snapshot.selectedTools?.join(", ") ?? "(default)";
|
|
79
|
+
const skillsList = snapshot.skills?.map((s) => `- ${s.name}`).join("\n") ?? "_(none)_";
|
|
80
|
+
const contextList = snapshot.contextFiles?.map((c) => `- ${c.path}`).join("\n") ?? "_(none)_";
|
|
81
|
+
|
|
82
|
+
let packages = "_(none)_";
|
|
83
|
+
const settingsPath = join(cwd, ".pi", "settings.json");
|
|
84
|
+
if (existsSync(settingsPath)) {
|
|
85
|
+
try {
|
|
86
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
87
|
+
if (Array.isArray(settings.packages) && settings.packages.length > 0) {
|
|
88
|
+
packages = settings.packages.map((p: string) => `- \`${p}\``).join("\n");
|
|
89
|
+
}
|
|
90
|
+
} catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
display(pi, `## Project distro status
|
|
94
|
+
|
|
95
|
+
${provenanceSection}
|
|
96
|
+
${updateSection}
|
|
97
|
+
### Live configuration
|
|
98
|
+
- **Working dir:** ${cwd}
|
|
99
|
+
- **Active tools:** ${tools}
|
|
100
|
+
- **Skills:**
|
|
101
|
+
${skillsList}
|
|
102
|
+
- **Context files:**
|
|
103
|
+
${contextList}
|
|
104
|
+
- **Installed packages:**
|
|
105
|
+
${packages}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- remove ---
|
|
109
|
+
|
|
110
|
+
export async function handleRemove(pi: ExtensionAPI, ctx: ExtensionCommandContext, name?: string): Promise<void> {
|
|
111
|
+
if (!name) { ctx.ui.notify("Usage: /pi-distro remove <name>", "error"); return; }
|
|
112
|
+
const catalogue = await readCatalogue();
|
|
113
|
+
const harness = findHarness(name, catalogue);
|
|
114
|
+
if (!harness) {
|
|
115
|
+
const available = catalogue.map((h) => h.name).join(", ") || "(none)";
|
|
116
|
+
ctx.ui.notify(`Distro '${name}' not found. Available: ${available}`, "error");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (harness.source === "seed") {
|
|
120
|
+
ctx.ui.notify(`'${name}' is a package seed and cannot be removed.`, "error");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const confirmed = await ctx.ui.confirm("Remove harness?", `This deletes '~/.pi/harnesses/${name}/'. A backup will be saved to .trash/.`);
|
|
124
|
+
if (!confirmed) return;
|
|
125
|
+
|
|
126
|
+
// Back up to .trash, then delete
|
|
127
|
+
const userDir = getUserHarnessesDir();
|
|
128
|
+
const trashDir = join(userDir, ".trash", `${name}-${Date.now()}`);
|
|
129
|
+
mkdirSync(trashDir, { recursive: true });
|
|
130
|
+
cpSync(harness.dir, join(trashDir, name), { recursive: true });
|
|
131
|
+
rmSync(harness.dir, { recursive: true, force: true });
|
|
132
|
+
ctx.ui.notify(`Removed distro '${name}'. (Backup in ~/.pi/harnesses/.trash/)`, "info");
|
|
133
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/pi-distro pick` β partial deploy: select which components to apply from a distro.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionCommandContext,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { listBundledFiles, parsePackageList } from "./catalogue.ts";
|
|
13
|
+
import { extractBody } from "./frontmatter.ts";
|
|
14
|
+
import { parseGithubRef } from "./github.ts";
|
|
15
|
+
import { resolveDistro } from "./resolve.ts";
|
|
16
|
+
import { buildShowPreview } from "./show.ts";
|
|
17
|
+
import {
|
|
18
|
+
display,
|
|
19
|
+
readProjectPackages,
|
|
20
|
+
MERGE_RULE,
|
|
21
|
+
USER_INVOLVEMENT_RULE,
|
|
22
|
+
PACKAGE_CONFLICT_RULE,
|
|
23
|
+
} from "./util.ts";
|
|
24
|
+
|
|
25
|
+
export async function handlePick(pi: ExtensionAPI, ctx: ExtensionCommandContext, nameArg?: string): Promise<void> {
|
|
26
|
+
const resolved = await resolveDistro(pi, ctx, nameArg, "pick from");
|
|
27
|
+
if (!resolved) return;
|
|
28
|
+
const { entry, cleanup } = resolved;
|
|
29
|
+
|
|
30
|
+
// GitHub trust gate (same as deploy β the user must confirm before anything is applied).
|
|
31
|
+
if (cleanup) {
|
|
32
|
+
const ref = parseGithubRef(nameArg!)!;
|
|
33
|
+
const preview = await buildShowPreview(entry);
|
|
34
|
+
const warning = `\n\n---\n\nβ οΈ **Security warning:** This distro was fetched from \`${ref.displayRef}\` on GitHub. Picking components from an unknown distro is still **dangerous** β packages can execute code, extensions run at startup, and directives inject agent instructions. Review everything above carefully. You are responsible for what you install.`;
|
|
35
|
+
display(pi, preview + warning);
|
|
36
|
+
const confirmed = await ctx.ui.confirm(
|
|
37
|
+
"Pick from GitHub distro?",
|
|
38
|
+
`You're about to pick components from \`${entry.name}\` (\`${ref.displayRef}\`). Review the preview above and confirm to proceed to selection.`,
|
|
39
|
+
);
|
|
40
|
+
if (!confirmed) {
|
|
41
|
+
cleanup();
|
|
42
|
+
ctx.ui.notify("Pick cancelled.", "info");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse the distro into selectable components.
|
|
48
|
+
const fullMd = readFileSync(entry.harnessMdPath, "utf-8");
|
|
49
|
+
const directives = extractBody(fullMd);
|
|
50
|
+
const packages = parsePackageList(directives);
|
|
51
|
+
const files = entry.filesDir ? await listBundledFiles(entry.filesDir) : [];
|
|
52
|
+
const filesDirNote = entry.filesDir ? `\n\nBundled files are located at: \`${entry.filesDir}\`` : "";
|
|
53
|
+
|
|
54
|
+
const snapshot = ctx.getSystemPromptOptions();
|
|
55
|
+
const activeTools = snapshot.selectedTools?.length
|
|
56
|
+
? snapshot.selectedTools.map((t) => `- \`${t}\``).join("\n")
|
|
57
|
+
: "_(none / default set)_";
|
|
58
|
+
const pkgs = readProjectPackages(snapshot.cwd);
|
|
59
|
+
const projectPackages = pkgs.length > 0 ? pkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
|
|
60
|
+
|
|
61
|
+
const packageList = packages.length > 0
|
|
62
|
+
? packages.map((p) => `- \`${p}\``).join("\n")
|
|
63
|
+
: "_(none)_";
|
|
64
|
+
const fileList = files.length > 0
|
|
65
|
+
? files.map((f) => `- \`${f.source}\` β \`./${f.target}\``).join("\n")
|
|
66
|
+
: "_(none)_";
|
|
67
|
+
|
|
68
|
+
pi.sendUserMessage(`## Picking components from distro: ${entry.name} (v${entry.version})
|
|
69
|
+
|
|
70
|
+
This is a **partial deploy**. The user wants to select which components to apply from this
|
|
71
|
+
distro β not the whole thing. Walk them through the selection, then apply only what they
|
|
72
|
+
choose. This lets users combine pieces from different distros to build their own config.
|
|
73
|
+
|
|
74
|
+
### Directives (full β for your reference on what each component does)
|
|
75
|
+
${directives}
|
|
76
|
+
|
|
77
|
+
### Selectable components
|
|
78
|
+
**π¦ Packages (${packages.length}):**
|
|
79
|
+
${packageList}
|
|
80
|
+
|
|
81
|
+
**π Bundled files (${files.length}):**
|
|
82
|
+
${fileList}${filesDirNote}
|
|
83
|
+
|
|
84
|
+
The directives body above may also describe other components (settings keys, context,
|
|
85
|
+
extensions, themes, prompts, skills) β treat each as individually selectable too.
|
|
86
|
+
|
|
87
|
+
### Current project state (for conflict detection)
|
|
88
|
+
**Already-active tools in this session:**
|
|
89
|
+
${activeTools}
|
|
90
|
+
|
|
91
|
+
**Packages already in this project's .pi/settings.json:**
|
|
92
|
+
${projectPackages}
|
|
93
|
+
|
|
94
|
+
(Run \`pi list\` to also see globally-installed packages.)
|
|
95
|
+
|
|
96
|
+
### Selection procedure (follow exactly, collaborating with the user)
|
|
97
|
+
|
|
98
|
+
**0. User involvement rule** β ${USER_INVOLVEMENT_RULE}
|
|
99
|
+
|
|
100
|
+
**1. Walk the user through each category, one at a time.** For each category (packages, then
|
|
101
|
+
bundled files, then any other components described in the directives), use
|
|
102
|
+
\`ctx.ui.select\`/\`ctx.ui.confirm\` to let the user pick which to apply. Present the items
|
|
103
|
+
with their one-line purpose (from the directives). Let the user select any subset β
|
|
104
|
+
including none (skip the category entirely).
|
|
105
|
+
|
|
106
|
+
**2. Surface dependencies.** As the user selects, **evaluate cross-component dependencies**
|
|
107
|
+
and warn about them before applying. For example: if the user picks the
|
|
108
|
+
\`claude-statusline.ts\` extension but skips \`pi-crew\`, point out that the statusline
|
|
109
|
+
references a theme provided by pi-crew and will error without it β ask whether to also
|
|
110
|
+
install pi-crew or skip the statusline. The directives prose is your source for these
|
|
111
|
+
relationships; reason about them and explain the tradeoffs to the user. Never silently
|
|
112
|
+
install a dependency the user didn't pick β always ask.
|
|
113
|
+
|
|
114
|
+
**3. Apply only the selected components**, with the same rules as a full deploy:
|
|
115
|
+
- **Merge-don't-clobber** β ${MERGE_RULE}
|
|
116
|
+
- **Package-redundancy check** β ${PACKAGE_CONFLICT_RULE}
|
|
117
|
+
- For each selected package: \`pi install -l\` after confirming (do NOT pre-add to
|
|
118
|
+
settings.json by hand).
|
|
119
|
+
- For each selected bundled file: copy/merge as the user chooses (overwrite / keep theirs /
|
|
120
|
+
merge).
|
|
121
|
+
|
|
122
|
+
**4. Do NOT write standard provenance.** A partial deploy is not "this distro was applied" β
|
|
123
|
+
it's a custom configuration built from pieces. Do not write or update \`./.pi/harness.md\`
|
|
124
|
+
with \`appliedHarness\`/\`appliedVersion\` provenance for a partial deploy. Instead, after
|
|
125
|
+
applying the selected components, **suggest the next step**: tell the user "This is a
|
|
126
|
+
custom configuration. Run \`/pi-distro save\` to snapshot it as your own reusable distro"
|
|
127
|
+
(which will then write clean provenance for the saved distro).
|
|
128
|
+
|
|
129
|
+
**5. Recommend a restart** if any packages or extensions were installed β they load at
|
|
130
|
+
startup.`);
|
|
131
|
+
// GitHub temp dir left in /tmp for the agent to read bundled files (ephemeral).
|
|
132
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distro resolution: turn a name argument (GitHub ref or local catalogue name,
|
|
3
|
+
* or an interactive selector) into a HarnessEntry.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
readCatalogue,
|
|
12
|
+
findHarness,
|
|
13
|
+
} from "./catalogue.ts";
|
|
14
|
+
import type { HarnessEntry } from "./catalogue.ts";
|
|
15
|
+
import { looksLikeGithubRef, parseGithubRef, fetchGithubDistro } from "./github.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a distro from a name argument (GitHub ref or local catalogue name).
|
|
19
|
+
* If nameArg is omitted, shows the interactive selector.
|
|
20
|
+
* Returns { entry, cleanup? } or undefined on cancel/error (notifies the user on error).
|
|
21
|
+
* `cleanup` is present only for GitHub distros (to remove the temp clone).
|
|
22
|
+
*/
|
|
23
|
+
export async function resolveDistro(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
ctx: ExtensionCommandContext,
|
|
26
|
+
nameArg: string | undefined,
|
|
27
|
+
verb: string,
|
|
28
|
+
): Promise<{ entry: HarnessEntry; cleanup?: () => void } | undefined> {
|
|
29
|
+
// GitHub ref? (contains a /)
|
|
30
|
+
if (nameArg && looksLikeGithubRef(nameArg)) {
|
|
31
|
+
const ref = parseGithubRef(nameArg)!;
|
|
32
|
+
ctx.ui.notify(`Fetching ${ref.displayRef} from GitHubβ¦`, "info");
|
|
33
|
+
try {
|
|
34
|
+
const fetched = fetchGithubDistro(ref);
|
|
35
|
+
return { entry: fetched.entry, cleanup: fetched.cleanup };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Local catalogue
|
|
43
|
+
const catalogue = await readCatalogue();
|
|
44
|
+
if (catalogue.length === 0) {
|
|
45
|
+
ctx.ui.notify("No distros found. Run /pi-distro save to create one.", "warning");
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (nameArg) {
|
|
49
|
+
const harness = findHarness(nameArg, catalogue);
|
|
50
|
+
if (!harness) {
|
|
51
|
+
const available = catalogue.map((h) => h.name).join(", ") || "(none)";
|
|
52
|
+
ctx.ui.notify(`Distro '${nameArg}' not found. Available: ${available}`, "error");
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return { entry: harness };
|
|
56
|
+
}
|
|
57
|
+
// No nameArg β interactive selector
|
|
58
|
+
const theme = ctx.ui.theme;
|
|
59
|
+
const labels = catalogue.map((h) => `${theme.bold(h.name)} β ${h.description}`);
|
|
60
|
+
const selected = await ctx.ui.select(`Select a distro to ${verb}:`, labels);
|
|
61
|
+
if (selected === undefined) return undefined;
|
|
62
|
+
const harness = catalogue[labels.indexOf(selected)];
|
|
63
|
+
return harness ? { entry: harness } : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve the current catalogue entry for a distro by name (local catalogue only). */
|
|
67
|
+
export async function resolveCatalogueEntry(name: string): Promise<HarnessEntry | undefined> {
|
|
68
|
+
const catalogue = await readCatalogue();
|
|
69
|
+
return findHarness(name, catalogue);
|
|
70
|
+
}
|