@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,238 @@
|
|
|
1
|
+
# Authoring a Distro
|
|
2
|
+
|
|
3
|
+
A **distro** is a directory containing a `harness.md` file and an optional `files/`
|
|
4
|
+
directory. Distros are reusable pi configurations that can be deployed into any
|
|
5
|
+
project via `/pi-distro deploy` (from the local catalogue or GitHub), and live
|
|
6
|
+
projects can be snapshotted back into distros via `/pi-distro save`.
|
|
7
|
+
|
|
8
|
+
## Sharing distros via GitHub
|
|
9
|
+
|
|
10
|
+
Any GitHub repository containing a `harness.md` (and optional `files/`) at the root
|
|
11
|
+
or under a subpath can be deployed directly:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
/pi-distro show owner/repo # preview before deploying
|
|
15
|
+
/pi-distro deploy owner/repo # deploy from repo root
|
|
16
|
+
/pi-distro deploy owner/repo/my-distro # deploy from a subpath
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The extension shallow-clones the repo (`git clone --depth 1`), displays a security
|
|
20
|
+
warning + full preview, and requires explicit confirmation before proceeding. This
|
|
21
|
+
makes it easy to share distros — just push a repo with a `harness.md` and `files/`.
|
|
22
|
+
|
|
23
|
+
When authoring a distro intended for GitHub sharing, include a `README.md` so users
|
|
24
|
+
can understand what the distro does before deploying it.
|
|
25
|
+
|
|
26
|
+
## Directory layout
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
<name>/
|
|
30
|
+
├── harness.md # REQUIRED: frontmatter + directives
|
|
31
|
+
├── README.md # RECOMMENDED: extended human-readable description
|
|
32
|
+
└── files/ # OPTIONAL: bundled files deployed into the target
|
|
33
|
+
├── AGENTS.md
|
|
34
|
+
├── settings.json # becomes ./.pi/settings.json (merged, not overwritten)
|
|
35
|
+
├── .pi/
|
|
36
|
+
│ ├── extensions/
|
|
37
|
+
│ │ └── hooks.ts # "hooks" are just extensions
|
|
38
|
+
│ ├── prompts/
|
|
39
|
+
│ │ └── review.md
|
|
40
|
+
│ └── skills/
|
|
41
|
+
│ └── …/SKILL.md
|
|
42
|
+
└── …
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The directory name **must match** the `name` field in the frontmatter.
|
|
46
|
+
|
|
47
|
+
## README.md (recommended)
|
|
48
|
+
|
|
49
|
+
Each distro should include a `README.md` alongside `harness.md`. While the frontmatter
|
|
50
|
+
`description` is a one-liner (≤300 chars) shown in selectors and listings, the README is
|
|
51
|
+
the extended human-readable description: a few paragraphs covering what the distro sets
|
|
52
|
+
up, which packages it installs and why, what workflow it targets, and any prerequisites.
|
|
53
|
+
|
|
54
|
+
The README is for users browsing the catalogue or reading the distro on disk. It is **not**
|
|
55
|
+
consumed by the extension and is **not** copied into the target project on deploy — it
|
|
56
|
+
lives only in the catalogue.
|
|
57
|
+
|
|
58
|
+
When you run `/pi-distro save`, the agent writes a `README.md` for the new distro as part
|
|
59
|
+
of the authoring flow. If you author a distro by hand, include one too.
|
|
60
|
+
|
|
61
|
+
## Frontmatter fields
|
|
62
|
+
|
|
63
|
+
The frontmatter is YAML between `---` fences at the top of `harness.md`.
|
|
64
|
+
|
|
65
|
+
| Field | Required | Description |
|
|
66
|
+
|---------------|----------|--------------------------------------------------------------------|
|
|
67
|
+
| `name` | Yes | Unique slug. Lowercase a-z/0-9/hyphens. Must match the directory name. |
|
|
68
|
+
| `title` | Yes | Human-readable title (shown in the selector). |
|
|
69
|
+
| `description` | Yes | One-liner shown in the selector. ≤300 characters. |
|
|
70
|
+
| `version` | Yes | Semantic version (e.g. `1.0.0`, `0.1.0`). Bump on every update (see below). |
|
|
71
|
+
| `author` | No | Author name or handle. |
|
|
72
|
+
| `tags` | No | Array of tags for categorisation (e.g. `[web, react, node]`). |
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
---
|
|
78
|
+
name: my-distro
|
|
79
|
+
title: My Distro
|
|
80
|
+
description: >
|
|
81
|
+
A concise description of what this distro configures.
|
|
82
|
+
version: 1.0.0
|
|
83
|
+
author: Jane Doe
|
|
84
|
+
tags: [api, backend]
|
|
85
|
+
---
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Bundled `files/` directory
|
|
89
|
+
|
|
90
|
+
The `files/` directory contains files that are deployed into the target project
|
|
91
|
+
verbatim. The directory structure inside `files/` mirrors the target project layout:
|
|
92
|
+
|
|
93
|
+
| Source in `files/` | Target in project |
|
|
94
|
+
|----------------------------------------|----------------------------------|
|
|
95
|
+
| `files/AGENTS.md` | `./AGENTS.md` |
|
|
96
|
+
| `files/settings.json` | `./.pi/settings.json` |
|
|
97
|
+
| `files/.pi/extensions/hooks.ts` | `./.pi/extensions/hooks.ts` |
|
|
98
|
+
| `files/.pi/prompts/review.md` | `./.pi/prompts/review.md` |
|
|
99
|
+
| `files/.pi/skills/my-skill/SKILL.md` | `./.pi/skills/my-skill/SKILL.md` |
|
|
100
|
+
|
|
101
|
+
The `harness.md` body should include a **Bundled files** section that lists each file
|
|
102
|
+
and its target path, so the agent knows what to deploy.
|
|
103
|
+
|
|
104
|
+
## Directive conventions
|
|
105
|
+
|
|
106
|
+
The body of `harness.md` (after the frontmatter) is agent-readable prose instructing
|
|
107
|
+
the agent how to set up the project. Use structured sections:
|
|
108
|
+
|
|
109
|
+
> **Authoring for `/pi-distro pick`:** since users may pick individual components from your
|
|
110
|
+
> distro, it helps to keep components as independent as possible and to state dependencies
|
|
111
|
+
> explicitly in the directives prose (e.g. "this extension requires the `pi-crew` package
|
|
112
|
+
> for its theme"). The agent uses these notes to warn users during a partial deploy.
|
|
113
|
+
|
|
114
|
+
### `## Bundled files`
|
|
115
|
+
|
|
116
|
+
List each bundled file with its target path and any merge instructions. This is the
|
|
117
|
+
manifest the agent uses during `deploy`.
|
|
118
|
+
|
|
119
|
+
```markdown
|
|
120
|
+
## Bundled files
|
|
121
|
+
- `files/AGENTS.md` → `./AGENTS.md`
|
|
122
|
+
- `files/settings.json` → `./.pi/settings.json` (merge with existing settings)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### `## pi packages to install`
|
|
126
|
+
|
|
127
|
+
List pi packages the distro depends on. The agent installs these with `pi install -l`
|
|
128
|
+
(**project-local** — writes to `./.pi/settings.json`, not global) **only after confirming
|
|
129
|
+
with the user** AND after a **redundancy/conflict evaluation**: the agent compares each
|
|
130
|
+
package's stated purpose against the project's already-active tools (and `pi list`),
|
|
131
|
+
checking for both exact tool-name collisions and semantic redundancy (different names,
|
|
132
|
+
similar function). If overlap is detected, it offers the user **skip / replace / keep both /
|
|
133
|
+
cancel** instead of installing blindly. (Exact name collisions are non-fatal in pi —
|
|
134
|
+
project-local tools shadow global ones — but redundancy leaves a confusing duplicate tool
|
|
135
|
+
set, so the agent surfaces it for the user to decide.)
|
|
136
|
+
|
|
137
|
+
**Do not list these packages in the bundled `settings.json` `packages` array.** `pi install -l`
|
|
138
|
+
is the single mechanism that registers a package: it installs the package AND appends the
|
|
139
|
+
source to `./.pi/settings.json` on success, and leaves settings untouched on failure. This
|
|
140
|
+
way a failed install never leaves a dangling, unresolvable entry in settings.
|
|
141
|
+
|
|
142
|
+
```markdown
|
|
143
|
+
## pi packages to install
|
|
144
|
+
- `npm:pi-browse` — for web search and content extraction
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `## Hooks`
|
|
148
|
+
|
|
149
|
+
Describe extensions/hooks to create under `./.pi/extensions/`. These are bundled as
|
|
150
|
+
files (e.g. `files/.pi/extensions/hooks.ts`) or described as instructions for the agent
|
|
151
|
+
to write.
|
|
152
|
+
|
|
153
|
+
### `## Context`
|
|
154
|
+
|
|
155
|
+
Describe what context to write or configure. This may reference the bundled
|
|
156
|
+
`AGENTS.md` or instruct the agent to create project-specific context.
|
|
157
|
+
|
|
158
|
+
### `## Skills / prompts`
|
|
159
|
+
|
|
160
|
+
Reference bundled skills (under `files/.pi/skills/`) and prompts (under
|
|
161
|
+
`files/.pi/prompts/`) that should be deployed, or describe skills/prompts the agent
|
|
162
|
+
should create.
|
|
163
|
+
|
|
164
|
+
## Merge-don't-clobber expectations
|
|
165
|
+
|
|
166
|
+
When a distro is deployed via `/pi-distro deploy`, **existing files are never
|
|
167
|
+
silently overwritten**. The agent:
|
|
168
|
+
|
|
169
|
+
1. Checks if each target file already exists.
|
|
170
|
+
2. If it does, shows the user a diff (or summary of differences).
|
|
171
|
+
3. Asks the user whether to **overwrite**, **keep theirs**, or **merge**.
|
|
172
|
+
4. For JSON files (e.g. `settings.json`): merges field-by-field when the user chooses
|
|
173
|
+
merge — combining keys from both objects without destroying user customisations.
|
|
174
|
+
5. For `AGENTS.md`: appends bundled content under a delimited section rather than
|
|
175
|
+
replacing. If the section already exists (re-deploy), replaces only that section.
|
|
176
|
+
|
|
177
|
+
This ensures distros compose with existing configurations rather than clobbering
|
|
178
|
+
them.
|
|
179
|
+
|
|
180
|
+
## Provenance file format
|
|
181
|
+
|
|
182
|
+
Every project that has had a distro applied carries a provenance file at
|
|
183
|
+
`./.pi/harness.md`. This file is itself a valid `harness.md` (the applied distro's
|
|
184
|
+
frontmatter + directives) with a provenance header injected at the top of the body:
|
|
185
|
+
|
|
186
|
+
```markdown
|
|
187
|
+
<!-- pi-distro provenance
|
|
188
|
+
appliedHarness: <name>
|
|
189
|
+
appliedVersion: <version>
|
|
190
|
+
sourceCatalogue: <user|seed|none>
|
|
191
|
+
lastUpdated: <ISO8601>
|
|
192
|
+
-->
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `appliedHarness`: the name of the distro that was deployed.
|
|
196
|
+
- `appliedVersion`: the version from the distro frontmatter.
|
|
197
|
+
- `sourceCatalogue`: `seed` (from the package) or `user` (from `~/.pi/harnesses/`).
|
|
198
|
+
- `lastUpdated`: ISO 8601 timestamp of the last apply/save.
|
|
199
|
+
|
|
200
|
+
The provenance file is updated automatically by the `deploy`, `save`, and `undeploy`
|
|
201
|
+
commands. When `/pi-distro save` snapshots a project, the provenance file is
|
|
202
|
+
regenerated to reflect the current live config.
|
|
203
|
+
|
|
204
|
+
## Saving a live config as a distro
|
|
205
|
+
|
|
206
|
+
To capture your current project's pi configuration as a reusable distro:
|
|
207
|
+
|
|
208
|
+
1. Run `/pi-distro save` in the project.
|
|
209
|
+
2. The extension captures a live snapshot (tools, skills, context files, raw config
|
|
210
|
+
files) and asks the agent to draft a `harness.md` that reproduces the configuration.
|
|
211
|
+
3. Review the draft — the agent proposes a `name`, `title`, `description`, and the
|
|
212
|
+
directive sections. Confirm or request edits.
|
|
213
|
+
4. Choose **save as new** (prompts for a name, creates `~/.pi/harnesses/<name>/`) or
|
|
214
|
+
**update existing** (selects from your user distros, backs up the old version to
|
|
215
|
+
`~/.pi/harnesses/.trash/` before overwriting, and **bumps the version** — see below).
|
|
216
|
+
5. The extension writes `harness.md` + `files/` to `~/.pi/harnesses/<name>/` and
|
|
217
|
+
updates `./.pi/harness.md` provenance.
|
|
218
|
+
|
|
219
|
+
### Versioning
|
|
220
|
+
|
|
221
|
+
The `version` field tracks which revision of a distro is applied. Two places use it:
|
|
222
|
+
|
|
223
|
+
- **On deploy**, the extension compares the incoming version against the project's existing
|
|
224
|
+
provenance (`appliedVersion`) and includes a version note: **upgrade** (proceed normally),
|
|
225
|
+
**downgrade** (asks the user to confirm — may regress features), **same version** (asks
|
|
226
|
+
whether to skip or force re-deploy), or **different distro** (treat as a distro switch).
|
|
227
|
+
- **On save-update**, the agent bumps the version using semver:
|
|
228
|
+
- **patch** (`0.1.0` → `0.1.1`) — small tweaks, bug fixes, doc updates.
|
|
229
|
+
- **minor** (`0.1.0` → `0.2.0`) — new capabilities, added packages, config additions.
|
|
230
|
+
- **major** (`0.1.0` → `1.0.0`) — breaking changes (removed packages, changed conventions,
|
|
231
|
+
incompatible settings).
|
|
232
|
+
|
|
233
|
+
Never keep the same version when updating a distro — the version should always reflect
|
|
234
|
+
that something changed. When authoring a distro by hand, pick a starting version (e.g.
|
|
235
|
+
`0.1.0`) and bump it on each meaningful change.
|
|
236
|
+
|
|
237
|
+
The saved distro is immediately available in `/pi-distro list` and can be deployed
|
|
238
|
+
into other projects with `/pi-distro deploy`.
|
package/docs/preview.png
ADDED
|
Binary file
|
package/docs/preview.svg
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" font-family="sans-serif">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#15161c"/>
|
|
5
|
+
<stop offset="1" stop-color="#0c0d12"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="accent" x1="0" y1="0" x2="0" y2="1">
|
|
8
|
+
<stop offset="0" stop-color="#7c9eff"/>
|
|
9
|
+
<stop offset="1" stop-color="#5b7cff"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
|
|
13
|
+
<rect width="1280" height="720" fill="url(#bg)"/>
|
|
14
|
+
|
|
15
|
+
<!-- subtle grid -->
|
|
16
|
+
<g stroke="#ffffff" stroke-opacity="0.03" stroke-width="1">
|
|
17
|
+
<line x1="0" y1="180" x2="1280" y2="180"/>
|
|
18
|
+
<line x1="0" y1="360" x2="1280" y2="360"/>
|
|
19
|
+
<line x1="0" y1="540" x2="1280" y2="540"/>
|
|
20
|
+
<line x1="320" y1="0" x2="320" y2="720"/>
|
|
21
|
+
<line x1="640" y1="0" x2="640" y2="720"/>
|
|
22
|
+
<line x1="960" y1="0" x2="960" y2="720"/>
|
|
23
|
+
</g>
|
|
24
|
+
|
|
25
|
+
<!-- left accent bar -->
|
|
26
|
+
<rect x="0" y="0" width="8" height="720" fill="url(#accent)"/>
|
|
27
|
+
|
|
28
|
+
<!-- pi-package badge -->
|
|
29
|
+
<g transform="translate(1040,72)">
|
|
30
|
+
<rect width="168" height="44" rx="22" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/>
|
|
31
|
+
<text x="84" y="29" text-anchor="middle" font-size="18" font-weight="600" fill="#9fb2ff">pi-package</text>
|
|
32
|
+
</g>
|
|
33
|
+
|
|
34
|
+
<!-- title -->
|
|
35
|
+
<text x="120" y="312" font-size="92" font-weight="700" fill="#f4f5fb" letter-spacing="-2">pi-distro</text>
|
|
36
|
+
|
|
37
|
+
<!-- tagline -->
|
|
38
|
+
<text x="124" y="372" font-size="30" fill="#a6accc">Reusable, composable configurations for the pi coding agent.</text>
|
|
39
|
+
|
|
40
|
+
<!-- command chips -->
|
|
41
|
+
<g font-family="monospace" font-size="24" fill="#d7dcf2">
|
|
42
|
+
<g transform="translate(120,440)">
|
|
43
|
+
<rect width="290" height="56" rx="12" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.1"/>
|
|
44
|
+
<text x="20" y="37">/pi-distro deploy</text>
|
|
45
|
+
</g>
|
|
46
|
+
<g transform="translate(430,440)">
|
|
47
|
+
<rect width="250" height="56" rx="12" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.1"/>
|
|
48
|
+
<text x="20" y="37">/pi-distro save</text>
|
|
49
|
+
</g>
|
|
50
|
+
<g transform="translate(700,440)">
|
|
51
|
+
<rect width="250" height="56" rx="12" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.1"/>
|
|
52
|
+
<text x="20" y="37">/pi-distro pick</text>
|
|
53
|
+
</g>
|
|
54
|
+
</g>
|
|
55
|
+
|
|
56
|
+
<!-- footer hint -->
|
|
57
|
+
<text x="120" y="640" font-size="20" fill="#6b7088">Seed distros · user snapshots · GitHub sharing · version-aware updates</text>
|
|
58
|
+
</svg>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalogue reading logic for pi-distro.
|
|
3
|
+
*
|
|
4
|
+
* Reads seed harnesses from the installed package dir and user harnesses
|
|
5
|
+
* from ~/.pi/harnesses/. On name collision, user takes precedence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, dirname, relative } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parseFrontmatter, extractBody } from "./frontmatter.ts";
|
|
13
|
+
|
|
14
|
+
export interface HarnessEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
version: string;
|
|
19
|
+
source: string; // "seed", "user", or "github:<owner>/<repo>[/subpath]"
|
|
20
|
+
dir: string;
|
|
21
|
+
harnessMdPath: string;
|
|
22
|
+
filesDir?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BundledFile {
|
|
26
|
+
source: string;
|
|
27
|
+
target: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse pi package references (npm:...) from a directives body. */
|
|
31
|
+
export function parsePackageList(body: string): string[] {
|
|
32
|
+
const packages: string[] = [];
|
|
33
|
+
let inSection = false;
|
|
34
|
+
for (const line of body.split("\n")) {
|
|
35
|
+
const t = line.trim();
|
|
36
|
+
if (/^##\s+pi\s*packages/i.test(t)) { inSection = true; continue; }
|
|
37
|
+
if (inSection) {
|
|
38
|
+
if (/^##\s/.test(t)) { inSection = false; continue; }
|
|
39
|
+
const m = t.match(/^-\s+`(npm:[^`]+)`/);
|
|
40
|
+
if (m) packages.push(m[1]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return packages;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Provenance {
|
|
47
|
+
appliedHarness: string;
|
|
48
|
+
appliedVersion: string;
|
|
49
|
+
sourceCatalogue: string;
|
|
50
|
+
lastUpdated: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parse the provenance header from a harness.md content. */
|
|
54
|
+
export function parseProvenance(content: string): Provenance | null {
|
|
55
|
+
const m = content.match(
|
|
56
|
+
/appliedHarness:\s*(.+?)\s*\n\s*appliedVersion:\s*(.+?)\s*\n\s*sourceCatalogue:\s*(.+?)\s*\n\s*lastUpdated:\s*(.+?)\s*\n/,
|
|
57
|
+
);
|
|
58
|
+
if (!m) return null;
|
|
59
|
+
return {
|
|
60
|
+
appliedHarness: m[1].trim(),
|
|
61
|
+
appliedVersion: m[2].trim(),
|
|
62
|
+
sourceCatalogue: m[3].trim(),
|
|
63
|
+
lastUpdated: m[4].trim(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Synchronous catalogue name read for autocomplete (best-effort). */
|
|
68
|
+
export function getCatalogueNamesSync(): string[] {
|
|
69
|
+
const names: string[] = [];
|
|
70
|
+
for (const dir of [getSeedHarnessesDir(), getUserHarnessesDir()]) {
|
|
71
|
+
if (!existsSync(dir)) continue;
|
|
72
|
+
try {
|
|
73
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
if (entry.isDirectory() && entry.name !== ".trash" && existsSync(join(dir, entry.name, "harness.md"))) {
|
|
75
|
+
names.push(entry.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
return [...new Set(names)].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the package root directory (the dir containing package.json).
|
|
85
|
+
* Uses import.meta.url — in extensions/index.ts this resolves to the
|
|
86
|
+
* extensions/ dir, so the package dir is one level up.
|
|
87
|
+
*/
|
|
88
|
+
export function getPackageDir(): string {
|
|
89
|
+
const extensionsDir = dirname(fileURLToPath(import.meta.url));
|
|
90
|
+
return dirname(extensionsDir);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Directory containing seed harnesses shipped with the package. */
|
|
94
|
+
export function getSeedHarnessesDir(): string {
|
|
95
|
+
return join(getPackageDir(), "harnesses");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Directory containing user-saved harnesses (~/.pi/harnesses/). */
|
|
99
|
+
export function getUserHarnessesDir(): string {
|
|
100
|
+
return join(homedir(), ".pi", "harnesses");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the effective catalogue: seeds ∪ user harnesses.
|
|
105
|
+
* On name collision, user takes precedence.
|
|
106
|
+
* Returns sorted: seeds first (alphabetical), then user (alphabetical).
|
|
107
|
+
*/
|
|
108
|
+
export async function readCatalogue(): Promise<HarnessEntry[]> {
|
|
109
|
+
const seeds = readHarnessesFromDir(getSeedHarnessesDir(), "seed");
|
|
110
|
+
const users = readHarnessesFromDir(getUserHarnessesDir(), "user");
|
|
111
|
+
|
|
112
|
+
// User takes precedence on collision
|
|
113
|
+
const userNames = new Set(users.map((u) => u.name));
|
|
114
|
+
const filteredSeeds = seeds.filter((s) => !userNames.has(s.name));
|
|
115
|
+
|
|
116
|
+
// Sort each group alphabetically
|
|
117
|
+
filteredSeeds.sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
users.sort((a, b) => a.name.localeCompare(b.name));
|
|
119
|
+
|
|
120
|
+
return [...filteredSeeds, ...users];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Find a harness by name in the catalogue. */
|
|
124
|
+
export function findHarness(
|
|
125
|
+
name: string,
|
|
126
|
+
catalogue: HarnessEntry[],
|
|
127
|
+
): HarnessEntry | undefined {
|
|
128
|
+
return catalogue.find((h) => h.name === name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Walk a files/ directory tree and return relative paths.
|
|
133
|
+
* source = relative path within files/, target = same relative path in the project.
|
|
134
|
+
*/
|
|
135
|
+
export async function listBundledFiles(
|
|
136
|
+
filesDir: string,
|
|
137
|
+
): Promise<BundledFile[]> {
|
|
138
|
+
if (!existsSync(filesDir)) return [];
|
|
139
|
+
const results: BundledFile[] = [];
|
|
140
|
+
walkDir(filesDir, filesDir, results);
|
|
141
|
+
return results.sort((a, b) => a.source.localeCompare(b.source));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read the harness.md body (content after frontmatter). */
|
|
145
|
+
export async function readHarnessBody(harnessMdPath: string): Promise<string> {
|
|
146
|
+
const content = readFileSync(harnessMdPath, "utf-8");
|
|
147
|
+
return extractBody(content);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read the full harness.md file content. */
|
|
151
|
+
export async function readFullHarnessMd(harnessMdPath: string): Promise<string> {
|
|
152
|
+
return readFileSync(harnessMdPath, "utf-8");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Internal helpers ---
|
|
156
|
+
|
|
157
|
+
function readHarnessesFromDir(
|
|
158
|
+
dir: string,
|
|
159
|
+
source: "seed" | "user",
|
|
160
|
+
): HarnessEntry[] {
|
|
161
|
+
if (!existsSync(dir)) return [];
|
|
162
|
+
|
|
163
|
+
let entries: string[];
|
|
164
|
+
try {
|
|
165
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
166
|
+
.filter((e) => e.isDirectory() && e.name !== ".trash")
|
|
167
|
+
.map((e) => e.name);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const harnesses: HarnessEntry[] = [];
|
|
173
|
+
for (const name of entries) {
|
|
174
|
+
const harnessDir = join(dir, name);
|
|
175
|
+
const harnessMdPath = join(harnessDir, "harness.md");
|
|
176
|
+
if (!existsSync(harnessMdPath)) continue;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const content = readFileSync(harnessMdPath, "utf-8");
|
|
180
|
+
const fm = parseFrontmatter(content);
|
|
181
|
+
if (!fm?.name) continue;
|
|
182
|
+
|
|
183
|
+
const filesDir = join(harnessDir, "files");
|
|
184
|
+
harnesses.push({
|
|
185
|
+
name: fm.name,
|
|
186
|
+
title: fm.title ?? fm.name,
|
|
187
|
+
description: fm.description ?? "",
|
|
188
|
+
version: fm.version ?? "0.0.0",
|
|
189
|
+
source,
|
|
190
|
+
dir: harnessDir,
|
|
191
|
+
harnessMdPath,
|
|
192
|
+
filesDir: existsSync(filesDir) ? filesDir : undefined,
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
// Skip unparseable harnesses
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return harnesses;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function walkDir(
|
|
202
|
+
base: string,
|
|
203
|
+
current: string,
|
|
204
|
+
results: BundledFile[],
|
|
205
|
+
): void {
|
|
206
|
+
let entries;
|
|
207
|
+
try {
|
|
208
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const fullPath = join(current, entry.name);
|
|
214
|
+
if (entry.isDirectory()) {
|
|
215
|
+
walkDir(base, fullPath, results);
|
|
216
|
+
} else if (entry.isFile()) {
|
|
217
|
+
const rel = relative(base, fullPath);
|
|
218
|
+
results.push({ source: rel, target: rel });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/pi-distro deploy` — apply a distro (local name or GitHub ref) to the project.
|
|
3
|
+
* sendDeployKickoff() is also reused by `/pi-distro update`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { listBundledFiles, parseProvenance } from "./catalogue.ts";
|
|
14
|
+
import type { HarnessEntry } from "./catalogue.ts";
|
|
15
|
+
import { extractBody } from "./frontmatter.ts";
|
|
16
|
+
import { parseGithubRef } from "./github.ts";
|
|
17
|
+
import { resolveDistro } from "./resolve.ts";
|
|
18
|
+
import { buildShowPreview } from "./show.ts";
|
|
19
|
+
import {
|
|
20
|
+
display,
|
|
21
|
+
compareVersions,
|
|
22
|
+
readProjectPackages,
|
|
23
|
+
MERGE_RULE,
|
|
24
|
+
USER_INVOLVEMENT_RULE,
|
|
25
|
+
PACKAGE_CONFLICT_RULE,
|
|
26
|
+
} from "./util.ts";
|
|
27
|
+
|
|
28
|
+
/** Build and send the deploy kickoff message for a resolved harness. */
|
|
29
|
+
export async function sendDeployKickoff(
|
|
30
|
+
pi: ExtensionAPI,
|
|
31
|
+
ctx: ExtensionCommandContext,
|
|
32
|
+
harness: HarnessEntry,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const body = readFileSync(harness.harnessMdPath, "utf-8");
|
|
35
|
+
const directives = extractBody(body);
|
|
36
|
+
const files = harness.filesDir ? await listBundledFiles(harness.filesDir) : [];
|
|
37
|
+
const fileList = files.length > 0
|
|
38
|
+
? files.map((f) => `- \`${f.source}\` → \`./${f.target}\``).join("\n")
|
|
39
|
+
: "_(no bundled files)_";
|
|
40
|
+
const filesDirNote = harness.filesDir ? `\n\nBundled files are located at: \`${harness.filesDir}\`` : "";
|
|
41
|
+
|
|
42
|
+
const snapshot = ctx.getSystemPromptOptions();
|
|
43
|
+
const activeTools = snapshot.selectedTools?.length
|
|
44
|
+
? snapshot.selectedTools.map((t) => `- \`${t}\``).join("\n")
|
|
45
|
+
: "_(none / default set)_";
|
|
46
|
+
const pkgs = readProjectPackages(snapshot.cwd);
|
|
47
|
+
const projectPackages = pkgs.length > 0 ? pkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
|
|
48
|
+
|
|
49
|
+
// Version comparison against existing provenance (upgrade / downgrade / same / first deploy)
|
|
50
|
+
const provenancePath = join(snapshot.cwd, ".pi", "harness.md");
|
|
51
|
+
let versionNote = "";
|
|
52
|
+
if (existsSync(provenancePath)) {
|
|
53
|
+
const prov = parseProvenance(readFileSync(provenancePath, "utf-8"));
|
|
54
|
+
if (prov) {
|
|
55
|
+
if (prov.appliedHarness !== harness.name) {
|
|
56
|
+
versionNote = `### Version note\nThis project currently has distro **${prov.appliedHarness}** (v${prov.appliedVersion}) applied. You are now deploying **${harness.name}** (v${harness.version}) — a different distro. The agent should treat this as a distro switch (merge with existing config; do not assume the new distro's files are a superset of the old).`;
|
|
57
|
+
} else {
|
|
58
|
+
const cmp = compareVersions(harness.version, prov.appliedVersion);
|
|
59
|
+
if (cmp > 0) {
|
|
60
|
+
versionNote = `### Version note\n**Upgrading** ${prov.appliedHarness}: v${prov.appliedVersion} → v${harness.version}. Re-apply the distro (merge-don't-clobber still applies — existing user customisations should be preserved).`;
|
|
61
|
+
} else if (cmp < 0) {
|
|
62
|
+
versionNote = `### Version note\n**Downgrade warning:** the project has v${prov.appliedVersion} of ${harness.name} applied, but you are deploying v${harness.version} (an older version). Ask the user to confirm before proceeding — this may remove features or regress fixes. If they confirm, proceed with merge-don't-clobber.`;
|
|
63
|
+
} else {
|
|
64
|
+
versionNote = `### Version note\n**Same version** (v${harness.version}) is already applied. Ask the user whether to (a) skip (no changes — the project already has this exact distro) or (b) force re-deploy (re-run the merge, useful if files were manually edited or the distro was updated in-place). Only proceed if they choose (b).`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
versionNote = `### Version note\nFirst deploy of distro **${harness.name}** (v${harness.version}) in this project — no existing provenance found.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pi.sendUserMessage(`## Deploying distro: ${harness.name} (v${harness.version})
|
|
73
|
+
|
|
74
|
+
### Directives
|
|
75
|
+
${directives}
|
|
76
|
+
|
|
77
|
+
### Bundled files manifest
|
|
78
|
+
${fileList}${filesDirNote}
|
|
79
|
+
|
|
80
|
+
### Current project state (for conflict detection)
|
|
81
|
+
**Already-active tools in this session:**
|
|
82
|
+
${activeTools}
|
|
83
|
+
|
|
84
|
+
**Packages already in this project's .pi/settings.json:**
|
|
85
|
+
${projectPackages}
|
|
86
|
+
|
|
87
|
+
(Run \`pi list\` to also see globally-installed packages.)
|
|
88
|
+
|
|
89
|
+
${versionNote}
|
|
90
|
+
|
|
91
|
+
### User involvement rule
|
|
92
|
+
${USER_INVOLVEMENT_RULE}
|
|
93
|
+
|
|
94
|
+
### Merge rule
|
|
95
|
+
${MERGE_RULE}
|
|
96
|
+
|
|
97
|
+
### Package-conflict rule
|
|
98
|
+
${PACKAGE_CONFLICT_RULE}
|
|
99
|
+
|
|
100
|
+
### Provenance
|
|
101
|
+
When finished, write or update \`./.pi/harness.md\` provenance with:
|
|
102
|
+
\`\`\`
|
|
103
|
+
<!-- pi-distro provenance
|
|
104
|
+
appliedHarness: ${harness.name}
|
|
105
|
+
appliedVersion: ${harness.version}
|
|
106
|
+
sourceCatalogue: ${harness.source}
|
|
107
|
+
lastUpdated: ${new Date().toISOString()}
|
|
108
|
+
-->
|
|
109
|
+
\`\`\`
|
|
110
|
+
Place this provenance header at the top of the body, followed by the harness.md directives.
|
|
111
|
+
|
|
112
|
+
### Post-deploy
|
|
113
|
+
After all files are placed, packages installed, and provenance written, tell the user:
|
|
114
|
+
"Distro **${harness.name}** deployed. **Restart pi** to activate newly-installed packages and extensions." A restart is required because pi loads extensions and packages at startup — newly installed ones are not available until the next session start.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function handleDeploy(pi: ExtensionAPI, ctx: ExtensionCommandContext, nameArg?: string): Promise<void> {
|
|
118
|
+
const resolved = await resolveDistro(pi, ctx, nameArg, "deploy");
|
|
119
|
+
if (!resolved) return;
|
|
120
|
+
const { entry, cleanup } = resolved;
|
|
121
|
+
|
|
122
|
+
// GitHub trust gate (local distros are trusted by being in the catalogue).
|
|
123
|
+
if (cleanup) {
|
|
124
|
+
const ref = parseGithubRef(nameArg!)!;
|
|
125
|
+
const preview = await buildShowPreview(entry);
|
|
126
|
+
const warning = `\n\n---\n\n⚠️ **Security warning:** This distro was fetched from \`${ref.displayRef}\` on GitHub. Installing unknown distros is **dangerous** — they can install arbitrary packages, write extensions that execute code, and inject agent instructions. Review everything above carefully before proceeding. You are responsible for what you install.`;
|
|
127
|
+
display(pi, preview + warning);
|
|
128
|
+
const confirmed = await ctx.ui.confirm(
|
|
129
|
+
"Deploy from GitHub?",
|
|
130
|
+
`This will install the distro \`${entry.name}\` from \`${ref.displayRef}\`. It may install packages and write files to your project. Only proceed if you trust this source and have reviewed the preview above.`,
|
|
131
|
+
);
|
|
132
|
+
if (!confirmed) {
|
|
133
|
+
cleanup();
|
|
134
|
+
ctx.ui.notify("Deploy cancelled.", "info");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await sendDeployKickoff(pi, ctx, entry);
|
|
140
|
+
// GitHub temp dir left in /tmp for the agent to read bundled files (ephemeral).
|
|
141
|
+
}
|