@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-07-13
|
|
4
|
+
|
|
5
|
+
- **Install scope: local vs global.** Every component of a distro can now be installed
|
|
6
|
+
**project-locally** (default, `./.pi/`) or **globally** (`~/.pi/agent/`, opt-in). At
|
|
7
|
+
deploy/pick time the agent builds a deployment plan and offers three presets
|
|
8
|
+
(accept-defaults / all-global-where-safe / customize), with safety guards for dangerous
|
|
9
|
+
types (settings.json, SYSTEM.md/APPEND_SYSTEM.md, AGENTS.md). Authors can suggest a
|
|
10
|
+
global default per package with a `(global)` marker. `status`/`undeploy` detect where
|
|
11
|
+
each component actually landed by checking both scopes; `save` captures global config
|
|
12
|
+
too (marked `(global)`).
|
|
13
|
+
- **Offline/rate-limit awareness**: when the official catalogue can't be fetched from
|
|
14
|
+
GitHub, `list`, `status`, and the selectors now say so explicitly instead of implying
|
|
15
|
+
distros don't exist or were removed.
|
|
16
|
+
- **Official distro names in tab-completion**: the last successful catalogue listing is
|
|
17
|
+
cached to `~/.pi/harnesses/.official-cache.json`, so `deploy`/`show`/`pick` completion
|
|
18
|
+
offers official names, not just local ones.
|
|
19
|
+
- **Robustness fixes**: provenance header parsing is now field-order-independent;
|
|
20
|
+
`compareVersions` handles `v` prefixes and prerelease suffixes (`1.0.0-beta < 1.0.0`);
|
|
21
|
+
the `(global)` scope marker is only honored between the package ref and the description
|
|
22
|
+
dash (prose mentions no longer count); an h1 heading now also terminates the
|
|
23
|
+
`## pi packages` section; `status` renders object-form package entries correctly.
|
|
24
|
+
- **Save snapshot hygiene**: skips `.pi/loops/` runtime state, detects binary files
|
|
25
|
+
instead of inlining garbage, and caps the total inlined snapshot at 256 KB (per-file
|
|
26
|
+
cap unchanged; omitted files are listed by path).
|
|
27
|
+
- **GitHub temp clones cleaned up**: deploy/pick kickoffs now instruct the agent to
|
|
28
|
+
remove the temporary clone after copying bundled files.
|
|
29
|
+
- **CLI polish**: extra arguments are warned about instead of silently ignored; the
|
|
30
|
+
command description lists all subcommands.
|
|
31
|
+
- **Distro fixes**: `web-fullstack` 0.2.0 — removed the non-functional `tools` key from
|
|
32
|
+
bundled settings (pi restricts tools via the `--tools` launch flag, now documented in
|
|
33
|
+
the directives); `minimal` 0.1.1 — removed the empty `packages` array from bundled
|
|
34
|
+
settings (merge hazard); `trip-planner` 0.3.0 — statusline now provided by
|
|
35
|
+
`npm:pi-cc-status` (bundled extension removed), supi post-install aligned with
|
|
36
|
+
cc-knockoff's config-file flow, description shortened to the ≤300-char limit;
|
|
37
|
+
`cc-knockoff` 1.2.1 — README global-settings path corrected to
|
|
38
|
+
`~/.pi/agent/settings.json`.
|
|
39
|
+
- **New test suite for shipped distros** (`tests/harnesses.test.ts`): validates
|
|
40
|
+
frontmatter rules (name/dir match, plain semver, description length), README presence,
|
|
41
|
+
and that the bundled-files manifest matches `files/` on disk in both directions.
|
|
42
|
+
|
|
3
43
|
## 0.3.0 - 2026-07-10
|
|
4
44
|
|
|
5
45
|
- **Dynamic official distros from GitHub**: official distros (`minimal`, `web-fullstack`,
|
package/README.md
CHANGED
|
@@ -23,11 +23,13 @@ existing setup — nothing is silently overwritten.
|
|
|
23
23
|
## Get a fully-configured coder in 60 seconds
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
# 1. Install the package
|
|
26
|
+
# 1. Install the package (in your shell)
|
|
27
27
|
pi install npm:@msdavid/pi-distro
|
|
28
28
|
|
|
29
|
-
# 2.
|
|
30
|
-
|
|
29
|
+
# 2. Start pi, then deploy a distro from inside the session
|
|
30
|
+
# (cc-knockoff = a Claude Code-style multi-agent coder)
|
|
31
|
+
pi
|
|
32
|
+
> /pi-distro deploy cc-knockoff
|
|
31
33
|
|
|
32
34
|
# 3. Restart pi — done.
|
|
33
35
|
```
|
|
@@ -46,7 +48,10 @@ explore-before-acting methodology — all configured and ready to go.
|
|
|
46
48
|
substitutes, or chooses. Every state-changing decision (file merge, package install, tool
|
|
47
49
|
conflict, upgrade) is surfaced with options, and the agent waits for your explicit choice
|
|
48
50
|
- 📦 **Project-local by default** — each project gets its own isolated configuration, so
|
|
49
|
-
different projects can use different harnesses (coding, research, automation, trading…)
|
|
51
|
+
different projects can use different harnesses (coding, research, automation, trading…).
|
|
52
|
+
Need a component everywhere? **Install globally** — at deploy time, choose
|
|
53
|
+
accept-defaults / all-global / customize per component (with safety guards for
|
|
54
|
+
machine-wide changes)
|
|
50
55
|
- 🔄 **Round-trip** — snapshot your live config back into a reusable distro with `/pi-distro save`
|
|
51
56
|
- 🐙 **GitHub distros** — deploy distros straight from any GitHub repo, so your personal
|
|
52
57
|
config follows you to any machine (`/pi-distro deploy owner/repo`)
|
|
@@ -110,6 +115,57 @@ Other advantages:
|
|
|
110
115
|
You can still use global packages and settings alongside project-level ones — pi merges
|
|
111
116
|
them. But the distro itself lives at the project level.
|
|
112
117
|
|
|
118
|
+
## Installing locally vs globally
|
|
119
|
+
|
|
120
|
+
Although pi-distro defaults to **project-local** installation (so each project gets its own
|
|
121
|
+
isolated harness), you can choose to install any component **globally** — shared across
|
|
122
|
+
every project and session on your machine. This is an opt-in choice made at deploy time; the
|
|
123
|
+
project-local default is never changed silently.
|
|
124
|
+
|
|
125
|
+
When you run `/pi-distro deploy` (or `/pi-distro pick`), the agent builds a **deployment
|
|
126
|
+
plan** listing every component with its default scope, then offers three presets:
|
|
127
|
+
|
|
128
|
+
- **Accept defaults** — keep each component at its default scope (recommended). Most things
|
|
129
|
+
go project-local; themes default to global (they're user-wide by nature).
|
|
130
|
+
- **All-global (where safe)** — install every safe component globally. Dangerous types
|
|
131
|
+
(settings, `SYSTEM.md`, `AGENTS.md`) stay project-local with a surfaced warning, because
|
|
132
|
+
their blast radius is machine-wide.
|
|
133
|
+
- **Customize** — walk components one at a time and pick `local` / `global` / `skip` for each.
|
|
134
|
+
|
|
135
|
+
### What can go global
|
|
136
|
+
|
|
137
|
+
| Component | Default | Global? |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| Packages | local | ✅ |
|
|
140
|
+
| Extensions | local | ✅ |
|
|
141
|
+
| Skills | local | ✅ |
|
|
142
|
+
| Prompts | local | ✅ |
|
|
143
|
+
| Themes | **global** | ✅ |
|
|
144
|
+
| `settings.json` merge | local | ⚠️ guarded (explicit confirm) |
|
|
145
|
+
| `SYSTEM.md` / `APPEND_SYSTEM.md` | local | ⚠️ double-confirm |
|
|
146
|
+
| `AGENTS.md` | local | ⚠️ guarded (explicit confirm) |
|
|
147
|
+
|
|
148
|
+
Global placement writes to `~/.pi/agent/` (packages via `pi install` →
|
|
149
|
+
`~/.pi/agent/settings.json`; extensions/themes/skills/prompts into `~/.pi/agent/<type>/`).
|
|
150
|
+
Project-local writes to `./.pi/` (packages via `pi install -l`). pi merges the two;
|
|
151
|
+
project-local shadows global on conflict.
|
|
152
|
+
|
|
153
|
+
### Tracking global installs
|
|
154
|
+
|
|
155
|
+
Provenance (`./.pi/harness.md`) records the distro's directives — it does **not** record
|
|
156
|
+
scope. So `/pi-distro status` and `/pi-distro undeploy` detect where each component
|
|
157
|
+
actually landed by checking **both** `./.pi/...` and `~/.pi/agent/...` (plus `pi list` for
|
|
158
|
+
packages). When you undeploy, the agent offers the right removal command per location
|
|
159
|
+
(`pi remove -l` for local, `pi remove` for global). `/pi-distro save` captures
|
|
160
|
+
globally-installed config too (marked `(global)` in the snapshot), so saved distros stay
|
|
161
|
+
reproducible.
|
|
162
|
+
|
|
163
|
+
### Authoring a distro with a global hint
|
|
164
|
+
|
|
165
|
+
Distro authors can suggest a global default for a package with the `(global)` marker — see
|
|
166
|
+
[docs/authoring.md](docs/authoring.md). The hint is a suggested default; the user's
|
|
167
|
+
preset still governs at deploy time.
|
|
168
|
+
|
|
113
169
|
## Workflows
|
|
114
170
|
|
|
115
171
|
### Global setup (install once, use everywhere)
|
|
@@ -256,6 +312,10 @@ Selectors and `/pi-distro list` show each distro's source clearly: **Official**
|
|
|
256
312
|
`msdavid/pi-distro` repo), **Local** (your `~/.pi/harnesses/`), or **GitHub (<owner>/<repo>)**
|
|
257
313
|
for distros from other repos.
|
|
258
314
|
|
|
315
|
+
If GitHub is unreachable (offline, or the unauthenticated API rate limit is hit), the
|
|
316
|
+
catalogue degrades to local-only and `/pi-distro list`, `/pi-distro status`, and the
|
|
317
|
+
selectors say so explicitly — official distros are temporarily hidden, not gone.
|
|
318
|
+
|
|
259
319
|
On a name collision, the **user distro takes precedence** — save a distro with the same
|
|
260
320
|
name as an official distro to override it. Official distros come from a trusted repo (the
|
|
261
321
|
package's own repo), so they skip the GitHub security confirmation that other-repo
|
package/docs/authoring.md
CHANGED
|
@@ -125,25 +125,45 @@ manifest the agent uses during `deploy`.
|
|
|
125
125
|
### `## pi packages to install`
|
|
126
126
|
|
|
127
127
|
List pi packages the distro depends on. The agent installs these with `pi install -l`
|
|
128
|
-
(**project-local** — writes to `./.pi/settings.json
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
(**project-local** — writes to `./.pi/settings.json`) **or** `pi install` (**global** — writes
|
|
129
|
+
to `~/.pi/agent/settings.json`, shared across every project) per the user's deploy-time
|
|
130
|
+
scope choice, **only after confirming with the user** AND after a
|
|
131
|
+
**redundancy/conflict evaluation**: the agent compares each package's stated purpose against
|
|
132
|
+
the project's already-active tools and installed packages (both local and global, via
|
|
133
|
+
`pi list`), checking for both exact tool-name collisions and semantic redundancy (different
|
|
134
|
+
names, similar function). If overlap is detected, it offers the user **skip / replace /
|
|
135
|
+
keep both / cancel** instead of installing blindly. (Exact name collisions are non-fatal in
|
|
136
|
+
pi — project-local tools shadow global ones — but redundancy leaves a confusing duplicate
|
|
137
|
+
tool set, so the agent surfaces it for the user to decide.)
|
|
138
|
+
|
|
139
|
+
**Do not list these packages in the bundled `settings.json` `packages` array.**
|
|
140
|
+
`pi install -l` / `pi install` are the single mechanisms that register a package: each
|
|
141
|
+
installs the package AND appends the source to the corresponding settings file on success,
|
|
142
|
+
and leaves settings untouched on failure. This way a failed install never leaves a dangling,
|
|
143
|
+
unresolvable entry in settings.
|
|
144
|
+
|
|
145
|
+
#### Scope hint: `(global)`
|
|
146
|
+
|
|
147
|
+
By default, a package is suggested for **project-local** install. To suggest **global** as
|
|
148
|
+
the author-intended default, suffix the entry with `(global)`:
|
|
141
149
|
|
|
142
150
|
```markdown
|
|
143
151
|
## pi packages to install
|
|
144
152
|
- `npm:pi-browse` — for web search and content extraction
|
|
153
|
+
- `npm:my-shared-tool` (global) — a tool the user wants in every project
|
|
145
154
|
```
|
|
146
155
|
|
|
156
|
+
The hint is a **suggested default** for the deployment plan — it does not force global
|
|
157
|
+
install. At deploy time the user picks a preset (accept-defaults / all-global-where-safe /
|
|
158
|
+
customize) which governs the final scope. Use `(global)` for packages that are genuinely
|
|
159
|
+
user-wide (a favorite status-line, a shared utility); leave it off for project-specific
|
|
160
|
+
dependencies.
|
|
161
|
+
|
|
162
|
+
> **Marker placement:** the `(global)` / `(local)` marker must appear **between the
|
|
163
|
+
> package reference and the description dash** (as in the example above). A `(global)`
|
|
164
|
+
> that appears inside the description prose after the `—` dash is intentionally ignored,
|
|
165
|
+
> so descriptions can mention the word without changing the scope.
|
|
166
|
+
|
|
147
167
|
### `## Hooks`
|
|
148
168
|
|
|
149
169
|
Describe extensions/hooks to create under `./.pi/extensions/`. These are bundled as
|
|
@@ -161,6 +181,56 @@ Reference bundled skills (under `files/.pi/skills/`) and prompts (under
|
|
|
161
181
|
`files/.pi/prompts/`) that should be deployed, or describe skills/prompts the agent
|
|
162
182
|
should create.
|
|
163
183
|
|
|
184
|
+
## Install scope: local vs global
|
|
185
|
+
|
|
186
|
+
Every component a distro installs can be placed either **project-local** (the default,
|
|
187
|
+
under `./.pi/` — scoped to this project) or **global** (under `~/.pi/agent/` — shared
|
|
188
|
+
across every project and session on the machine). pi merges the two; project-local
|
|
189
|
+
shadows global on conflict. pi-distro's default philosophy is **project-local**, because
|
|
190
|
+
different projects need different harnesses — global is an opt-in choice the user makes at
|
|
191
|
+
deploy time.
|
|
192
|
+
|
|
193
|
+
### Per-type default scopes
|
|
194
|
+
|
|
195
|
+
| Component type | Default scope | Global allowed? |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| Packages | local | yes |
|
|
198
|
+
| Extensions | local | yes |
|
|
199
|
+
| Skills | local | yes |
|
|
200
|
+
| Prompts | local | yes |
|
|
201
|
+
| Themes | **global** | yes |
|
|
202
|
+
| settings.json merge | local | guarded (explicit confirm) |
|
|
203
|
+
| SYSTEM.md / APPEND_SYSTEM.md | local | double-confirm |
|
|
204
|
+
| AGENTS.md | local | guarded (explicit confirm) |
|
|
205
|
+
|
|
206
|
+
At deploy (and `/pi-distro pick`), the agent builds a **deployment plan** from the
|
|
207
|
+
directives — grouping every component with its default scope — and offers the user three
|
|
208
|
+
presets:
|
|
209
|
+
|
|
210
|
+
- **(a) Accept defaults** — keep each component at its default scope (recommended).
|
|
211
|
+
- **(b) All-global (where safe)** — flip every global-allowed component to global;
|
|
212
|
+
dangerous types (settings, SYSTEM.md, AGENTS.md) stay local with a surfaced warning.
|
|
213
|
+
- **(c) Customize** — walk items one at a time, offering `local` / `global` / `skip`.
|
|
214
|
+
|
|
215
|
+
When a dangerous type's final scope is global, the agent surfaces the blast radius
|
|
216
|
+
("affects every project/session on this machine") and requires explicit confirmation
|
|
217
|
+
(double-confirm for SYSTEM.md/APPEND_SYSTEM.md).
|
|
218
|
+
|
|
219
|
+
### `## Global deployment notes`
|
|
220
|
+
|
|
221
|
+
If your distro is intended to place some bundled files **globally** (e.g. an extension that
|
|
222
|
+
should live at `~/.pi/agent/extensions/` rather than `./.pi/extensions/`), add a `## Global
|
|
223
|
+
deployment notes` section listing which file targets should go global:
|
|
224
|
+
|
|
225
|
+
```markdown
|
|
226
|
+
## Global deployment notes
|
|
227
|
+
- `.pi/extensions/my-shared-ext.ts` → deploy globally to `~/.pi/agent/extensions/my-shared-ext.ts`
|
|
228
|
+
- `.pi/themes/shared-theme.json` → deploy globally to `~/.pi/agent/themes/shared-theme.json`
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The agent reads this note and places those files at the global path per the user's scope
|
|
232
|
+
choice. For packages, prefer the `(global)` marker in `## pi packages to install` instead.
|
|
233
|
+
|
|
164
234
|
## Merge-don't-clobber expectations
|
|
165
235
|
|
|
166
236
|
When a distro is deployed via `/pi-distro deploy`, **existing files are never
|
|
@@ -234,5 +304,9 @@ Never keep the same version when updating a distro — the version should always
|
|
|
234
304
|
that something changed. When authoring a distro by hand, pick a starting version (e.g.
|
|
235
305
|
`0.1.0`) and bump it on each meaningful change.
|
|
236
306
|
|
|
307
|
+
Write versions as plain `MAJOR.MINOR.PATCH` — no `v` prefix. Prerelease suffixes
|
|
308
|
+
(`1.0.0-beta`) are tolerated and sort before their release, but plain releases are
|
|
309
|
+
recommended for distros.
|
|
310
|
+
|
|
237
311
|
The saved distro is immediately available in `/pi-distro list` and can be deployed
|
|
238
312
|
into other projects with `/pi-distro deploy`.
|
package/extensions/catalogue.ts
CHANGED
|
@@ -32,17 +32,35 @@ export interface BundledFile {
|
|
|
32
32
|
target: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export interface PackageEntry {
|
|
36
|
+
source: string;
|
|
37
|
+
scope: "local" | "global"; // author hint; the user's deploy-time preset still governs
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
/** Parse pi package references (npm:...) from a directives body. */
|
|
36
41
|
export function parsePackageList(body: string): string[] {
|
|
37
|
-
|
|
42
|
+
return parsePackageListWithScope(body).map((p) => p.source);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse pi package references with their author-specified scope hint.
|
|
46
|
+
* A package may be suffixed with `(global)` to suggest global install; otherwise
|
|
47
|
+
* the default scope is `local`. The hint is a *default* — the user's deploy-time
|
|
48
|
+
* preset (accept-defaults / all-global / customize) still governs the final scope. */
|
|
49
|
+
export function parsePackageListWithScope(body: string): PackageEntry[] {
|
|
50
|
+
const packages: PackageEntry[] = [];
|
|
38
51
|
let inSection = false;
|
|
39
52
|
for (const line of body.split("\n")) {
|
|
40
53
|
const t = line.trim();
|
|
41
54
|
if (/^##\s+pi\s*packages/i.test(t)) { inSection = true; continue; }
|
|
42
55
|
if (inSection) {
|
|
43
|
-
if (
|
|
44
|
-
const m = t.match(/^-\s+`(npm:[^`]+)
|
|
45
|
-
if (m)
|
|
56
|
+
if (/^#{1,2}\s/.test(t)) { inSection = false; continue; } // any h1/h2 ends the section
|
|
57
|
+
const m = t.match(/^-\s+`(npm:[^`]+)`([^—–]*)/);
|
|
58
|
+
if (m) {
|
|
59
|
+
// The scope marker must appear between the package ref and the
|
|
60
|
+
// description dash — "(global)" inside description prose is ignored.
|
|
61
|
+
const scopeM = m[2].match(/\((global|local)\)/);
|
|
62
|
+
packages.push({ source: m[1], scope: scopeM?.[1] === "global" ? "global" : "local" });
|
|
63
|
+
}
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
66
|
return packages;
|
|
@@ -55,35 +73,53 @@ export interface Provenance {
|
|
|
55
73
|
lastUpdated: string;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
|
-
/** Parse the provenance header from a harness.md content.
|
|
76
|
+
/** Parse the provenance header from a harness.md content. Fields are matched
|
|
77
|
+
* independently within the provenance comment block, so their order (and any
|
|
78
|
+
* extra fields an agent may have added) doesn't matter. */
|
|
59
79
|
export function parseProvenance(content: string): Provenance | null {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Synchronous catalogue name read for autocomplete (best-effort
|
|
73
|
-
*
|
|
74
|
-
*
|
|
80
|
+
const block = content.match(/<!--\s*pi-distro provenance([\s\S]*?)-->/);
|
|
81
|
+
if (!block) return null;
|
|
82
|
+
const field = (name: string): string | undefined =>
|
|
83
|
+
block[1].match(new RegExp(`${name}:[ \\t]*(.+)`))?.[1].trim();
|
|
84
|
+
const appliedHarness = field("appliedHarness");
|
|
85
|
+
const appliedVersion = field("appliedVersion");
|
|
86
|
+
const sourceCatalogue = field("sourceCatalogue");
|
|
87
|
+
const lastUpdated = field("lastUpdated");
|
|
88
|
+
if (!appliedHarness || !appliedVersion || !sourceCatalogue || !lastUpdated) return null;
|
|
89
|
+
return { appliedHarness, appliedVersion, sourceCatalogue, lastUpdated };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Synchronous catalogue name read for autocomplete (best-effort).
|
|
93
|
+
* User distros are read from disk (by frontmatter name, falling back to the
|
|
94
|
+
* directory name). Official distros live on GitHub and can't be fetched
|
|
95
|
+
* synchronously — but the last successful async listing is cached to
|
|
96
|
+
* `.official-cache.json` (see github.ts), so their names complete too once
|
|
97
|
+
* any command has read the catalogue. */
|
|
75
98
|
export function getCatalogueNamesSync(): string[] {
|
|
76
99
|
const dir = getUserHarnessesDir();
|
|
77
100
|
const names: string[] = [];
|
|
78
101
|
if (existsSync(dir)) {
|
|
79
102
|
try {
|
|
80
103
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
81
|
-
if (entry.isDirectory() && entry.name !== ".trash"
|
|
82
|
-
|
|
104
|
+
if (entry.isDirectory() && entry.name !== ".trash") {
|
|
105
|
+
const harnessMdPath = join(dir, entry.name, "harness.md");
|
|
106
|
+
if (!existsSync(harnessMdPath)) continue;
|
|
107
|
+
try {
|
|
108
|
+
const fm = parseFrontmatter(readFileSync(harnessMdPath, "utf-8"));
|
|
109
|
+
names.push(fm?.name ?? entry.name);
|
|
110
|
+
} catch {
|
|
111
|
+
names.push(entry.name);
|
|
112
|
+
}
|
|
83
113
|
}
|
|
84
114
|
}
|
|
85
115
|
} catch { /* ignore */ }
|
|
86
116
|
}
|
|
117
|
+
try {
|
|
118
|
+
const cached = JSON.parse(readFileSync(join(dir, ".official-cache.json"), "utf-8"));
|
|
119
|
+
if (Array.isArray(cached)) {
|
|
120
|
+
for (const n of cached) if (typeof n === "string") names.push(n);
|
|
121
|
+
}
|
|
122
|
+
} catch { /* no cache yet — officials appear after the first catalogue read */ }
|
|
87
123
|
return [...new Set(names)].sort();
|
|
88
124
|
}
|
|
89
125
|
|
package/extensions/deploy.ts
CHANGED
|
@@ -10,17 +10,19 @@ import type {
|
|
|
10
10
|
import { readFileSync, existsSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
|
|
13
|
-
import { listBundledFiles, parseProvenance } from "./catalogue.ts";
|
|
13
|
+
import { listBundledFiles, parseProvenance, parsePackageListWithScope } from "./catalogue.ts";
|
|
14
14
|
import type { HarnessEntry } from "./catalogue.ts";
|
|
15
15
|
import { extractBody } from "./frontmatter.ts";
|
|
16
|
-
import { parseGithubRef, isOfficialSource } from "./github.ts";
|
|
16
|
+
import { parseGithubRef, isOfficialSource, tempCloneRoot } from "./github.ts";
|
|
17
17
|
import { resolveDistro } from "./resolve.ts";
|
|
18
18
|
import { buildShowPreview } from "./show.ts";
|
|
19
19
|
import {
|
|
20
20
|
display,
|
|
21
21
|
compareVersions,
|
|
22
22
|
readProjectPackages,
|
|
23
|
+
readGlobalPackages,
|
|
23
24
|
MERGE_RULE,
|
|
25
|
+
SCOPE_RULE,
|
|
24
26
|
USER_INVOLVEMENT_RULE,
|
|
25
27
|
PACKAGE_CONFLICT_RULE,
|
|
26
28
|
} from "./util.ts";
|
|
@@ -37,7 +39,11 @@ export async function sendDeployKickoff(
|
|
|
37
39
|
const fileList = files.length > 0
|
|
38
40
|
? files.map((f) => `- \`${f.source}\` → \`./${f.target}\``).join("\n")
|
|
39
41
|
: "_(no bundled files)_";
|
|
40
|
-
const
|
|
42
|
+
const cloneRoot = harness.dir ? tempCloneRoot(harness.dir) : undefined;
|
|
43
|
+
const cloneCleanupNote = cloneRoot
|
|
44
|
+
? `\n\nThe distro was fetched to a temporary GitHub clone. After all bundled files have been copied into the project, remove the clone: \`rm -rf ${cloneRoot}\``
|
|
45
|
+
: "";
|
|
46
|
+
const filesDirNote = (harness.filesDir ? `\n\nBundled files are located at: \`${harness.filesDir}\`` : "") + cloneCleanupNote;
|
|
41
47
|
|
|
42
48
|
const snapshot = ctx.getSystemPromptOptions();
|
|
43
49
|
const activeTools = snapshot.selectedTools?.length
|
|
@@ -45,6 +51,15 @@ export async function sendDeployKickoff(
|
|
|
45
51
|
: "_(none / default set)_";
|
|
46
52
|
const pkgs = readProjectPackages(snapshot.cwd);
|
|
47
53
|
const projectPackages = pkgs.length > 0 ? pkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
|
|
54
|
+
const gpkgs = readGlobalPackages();
|
|
55
|
+
const globalPackages = gpkgs.length > 0 ? gpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
|
|
56
|
+
|
|
57
|
+
// Author-specified scope hints (a package may be marked `(global)` in the directives).
|
|
58
|
+
// These are *defaults* for the deployment plan — the user's preset still governs.
|
|
59
|
+
const scopedPackages = parsePackageListWithScope(directives);
|
|
60
|
+
const packagePlan = scopedPackages.length > 0
|
|
61
|
+
? scopedPackages.map((p) => `- \`${p.source}\` [${p.scope}]`).join("\n")
|
|
62
|
+
: "_(none)_";
|
|
48
63
|
|
|
49
64
|
// Version comparison against existing provenance (upgrade / downgrade / same / first deploy)
|
|
50
65
|
const provenancePath = join(snapshot.cwd, ".pi", "harness.md");
|
|
@@ -77,20 +92,31 @@ ${directives}
|
|
|
77
92
|
### Bundled files manifest
|
|
78
93
|
${fileList}${filesDirNote}
|
|
79
94
|
|
|
95
|
+
### Packages with author scope hints
|
|
96
|
+
${packagePlan}
|
|
97
|
+
|
|
98
|
+
(\`[local]\` / \`[global]\` are the distro author's suggested default scope. The user's deploy-time preset — see the scope rule below — governs the final scope.)
|
|
99
|
+
|
|
80
100
|
### Current project state (for conflict detection)
|
|
81
101
|
**Already-active tools in this session:**
|
|
82
102
|
${activeTools}
|
|
83
103
|
|
|
84
|
-
**Packages already in this project's .pi/settings.json:**
|
|
104
|
+
**Packages already in this project's .pi/settings.json (project-local):**
|
|
85
105
|
${projectPackages}
|
|
86
106
|
|
|
87
|
-
|
|
107
|
+
**Packages already in ~/.pi/agent/settings.json (global, every project):**
|
|
108
|
+
${globalPackages}
|
|
109
|
+
|
|
110
|
+
(Run \`pi list\` to see both local and global packages in one view.)
|
|
88
111
|
|
|
89
112
|
${versionNote}
|
|
90
113
|
|
|
91
114
|
### User involvement rule
|
|
92
115
|
${USER_INVOLVEMENT_RULE}
|
|
93
116
|
|
|
117
|
+
### Scope rule (install locally or globally)
|
|
118
|
+
${SCOPE_RULE}
|
|
119
|
+
|
|
94
120
|
### Merge rule
|
|
95
121
|
${MERGE_RULE}
|
|
96
122
|
|
|
@@ -138,5 +164,6 @@ export async function handleDeploy(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
138
164
|
}
|
|
139
165
|
|
|
140
166
|
await sendDeployKickoff(pi, ctx, entry);
|
|
141
|
-
// GitHub temp dir left in
|
|
167
|
+
// GitHub temp dir is left in place so the agent can read the bundled files;
|
|
168
|
+
// the kickoff instructs the agent to remove it after copying them.
|
|
142
169
|
}
|
package/extensions/github.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execSync } from "node:child_process";
|
|
14
|
-
import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import { tmpdir } from "node:os";
|
|
16
|
+
import { tmpdir, homedir } from "node:os";
|
|
17
17
|
import { parseFrontmatter } from "./frontmatter.ts";
|
|
18
18
|
import type { HarnessEntry } from "./catalogue.ts";
|
|
19
19
|
|
|
@@ -152,6 +152,21 @@ export function fetchGithubDistro(
|
|
|
152
152
|
let officialListCache: { entries: HarnessEntry[]; fetchedAt: number } | null = null;
|
|
153
153
|
const OFFICIAL_LIST_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
154
154
|
|
|
155
|
+
/** Whether the last official-catalogue fetch failed (network down, GitHub API
|
|
156
|
+
* rate limit, …). Lets callers tell "GitHub unreachable" apart from "distro
|
|
157
|
+
* genuinely doesn't exist" in their messaging. */
|
|
158
|
+
let officialUnavailable = false;
|
|
159
|
+
|
|
160
|
+
/** True if the most recent official-catalogue fetch failed. */
|
|
161
|
+
export function isOfficialCatalogueUnavailable(): boolean {
|
|
162
|
+
return officialUnavailable;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** On-disk cache of official distro names, for synchronous autocomplete. */
|
|
166
|
+
function officialNamesCachePath(): string {
|
|
167
|
+
return join(homedir(), ".pi", "harnesses", ".official-cache.json");
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
/**
|
|
156
171
|
* List official distros from OFFICIAL_REPO's `harnesses/` directory via the GitHub
|
|
157
172
|
* Contents API (one call) + a raw frontmatter fetch per distro.
|
|
@@ -179,10 +194,11 @@ async function listOfficialDistrosUncached(): Promise<HarnessEntry[]> {
|
|
|
179
194
|
const resp = await fetch(contentsUrl, {
|
|
180
195
|
headers: { "Accept": "application/vnd.github+json", "User-Agent": "pi-distro" },
|
|
181
196
|
});
|
|
182
|
-
if (!resp.ok) return [];
|
|
197
|
+
if (!resp.ok) { officialUnavailable = true; return []; }
|
|
183
198
|
const listing = (await resp.json()) as Array<{ name: string; type: string }>;
|
|
184
199
|
dirs = listing.filter((e) => e.type === "dir").map((e) => e.name);
|
|
185
200
|
} catch {
|
|
201
|
+
officialUnavailable = true;
|
|
186
202
|
return [];
|
|
187
203
|
}
|
|
188
204
|
|
|
@@ -209,9 +225,30 @@ async function listOfficialDistrosUncached(): Promise<HarnessEntry[]> {
|
|
|
209
225
|
}
|
|
210
226
|
}));
|
|
211
227
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
228
|
+
// The raw fetches all failing (with a non-empty dir listing) is also "unavailable".
|
|
229
|
+
officialUnavailable = dirs.length > 0 && entries.length === 0;
|
|
230
|
+
if (!officialUnavailable) {
|
|
231
|
+
try {
|
|
232
|
+
mkdirSync(join(homedir(), ".pi", "harnesses"), { recursive: true });
|
|
233
|
+
writeFileSync(officialNamesCachePath(), JSON.stringify(entries.map((e) => e.name)));
|
|
234
|
+
} catch { /* cache is best-effort */ }
|
|
235
|
+
}
|
|
212
236
|
return entries;
|
|
213
237
|
}
|
|
214
238
|
|
|
239
|
+
/**
|
|
240
|
+
* For a directory inside one of our temp GitHub clones, return the clone root
|
|
241
|
+
* (the `pi-distro-gh-*` mkdtemp dir), or undefined if the dir is not a temp
|
|
242
|
+
* clone. Used to tell the deploying agent which directory to remove once the
|
|
243
|
+
* bundled files have been copied.
|
|
244
|
+
*/
|
|
245
|
+
export function tempCloneRoot(dir: string): string | undefined {
|
|
246
|
+
const prefix = join(tmpdir(), "pi-distro-gh-");
|
|
247
|
+
if (!dir.startsWith(prefix)) return undefined;
|
|
248
|
+
const first = dir.slice(tmpdir().length + 1).split(/[\\/]/)[0];
|
|
249
|
+
return join(tmpdir(), first);
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
/** Invalidate the official-list cache (used by tests / explicit refresh). */
|
|
216
253
|
export function clearOfficialListCache(): void {
|
|
217
254
|
officialListCache = null;
|
package/extensions/index.ts
CHANGED
|
@@ -35,11 +35,14 @@ const SUBCOMMANDS = [
|
|
|
35
35
|
|
|
36
36
|
export default function (pi: ExtensionAPI): void {
|
|
37
37
|
pi.registerCommand("pi-distro", {
|
|
38
|
-
description: "Manage pi distros (deploy, save, list, show, status, remove)",
|
|
38
|
+
description: "Manage pi distros (deploy, undeploy, pick, update, save, list, show, status, remove)",
|
|
39
39
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
40
40
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
41
41
|
const sub = parts[0] ?? "";
|
|
42
42
|
const nameArg = parts[1];
|
|
43
|
+
if (parts.length > 2) {
|
|
44
|
+
ctx.ui.notify(`Ignoring extra arguments: ${parts.slice(2).join(" ")}`, "warning");
|
|
45
|
+
}
|
|
43
46
|
switch (sub) {
|
|
44
47
|
case "": await handleHelp(pi); break;
|
|
45
48
|
case "deploy": await handleDeploy(pi, ctx, nameArg); break;
|
package/extensions/info.ts
CHANGED
|
@@ -18,14 +18,25 @@ import {
|
|
|
18
18
|
sourceLabel,
|
|
19
19
|
} from "./catalogue.ts";
|
|
20
20
|
import { resolveCatalogueEntry } from "./resolve.ts";
|
|
21
|
-
import {
|
|
22
|
-
|
|
21
|
+
import {
|
|
22
|
+
isOfficialSource,
|
|
23
|
+
officialNameFromSource,
|
|
24
|
+
listOfficialDistros,
|
|
25
|
+
isOfficialCatalogueUnavailable,
|
|
26
|
+
} from "./github.ts";
|
|
27
|
+
import { display, compareVersions, readProjectPackages, readGlobalPackages } from "./util.ts";
|
|
23
28
|
|
|
24
29
|
// --- list ---
|
|
25
30
|
|
|
26
31
|
export async function handleList(pi: ExtensionAPI): Promise<void> {
|
|
27
32
|
const catalogue = await readCatalogue();
|
|
28
|
-
|
|
33
|
+
const offlineNote = isOfficialCatalogueUnavailable()
|
|
34
|
+
? "\n\n⚠️ Official distros could not be fetched from GitHub (offline or rate-limited?) — showing local distros only. Try again later."
|
|
35
|
+
: "";
|
|
36
|
+
if (catalogue.length === 0) {
|
|
37
|
+
display(pi, `No distros found in the catalogue.${offlineNote}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
29
40
|
const rows = catalogue.map((h) => {
|
|
30
41
|
const desc = h.description.length > 50 ? h.description.slice(0, 47) + "..." : h.description;
|
|
31
42
|
return `| ${h.name} | ${h.title} | ${h.version} | ${sourceLabel(h.source)} | ${desc} |`;
|
|
@@ -39,7 +50,7 @@ export async function handleList(pi: ExtensionAPI): Promise<void> {
|
|
|
39
50
|
|------|-------|---------|--------|-------------|
|
|
40
51
|
${rows.join("\n")}
|
|
41
52
|
|
|
42
|
-
_Official: ${officialCount} (GitHub) · Local: ${localCount}${otherGhCount > 0 ? ` · Other GitHub: ${otherGhCount}` : ""} · Total: ${catalogue.length}_`);
|
|
53
|
+
_Official: ${officialCount} (GitHub) · Local: ${localCount}${otherGhCount > 0 ? ` · Other GitHub: ${otherGhCount}` : ""} · Total: ${catalogue.length}_${offlineNote}`);
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
// --- status ---
|
|
@@ -67,6 +78,8 @@ export async function handleStatus(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
67
78
|
} else if (cmp < 0) {
|
|
68
79
|
updateSection = `### Version note\nApplied v${prov.appliedVersion} is newer than the official catalogue's v${current.version} (downgrade).`;
|
|
69
80
|
} // same version → no section
|
|
81
|
+
} else if (isOfficialCatalogueUnavailable()) {
|
|
82
|
+
updateSection = `### Update check\nCould not check for updates: the official catalogue is unreachable (offline or GitHub rate limit?). Try again later.`;
|
|
70
83
|
} else {
|
|
71
84
|
updateSection = `### Update check\nOfficial distro '${prov.appliedHarness}' is no longer in the catalogue (removed or renamed).`;
|
|
72
85
|
}
|
|
@@ -97,16 +110,10 @@ export async function handleStatus(pi: ExtensionAPI, ctx: ExtensionCommandContex
|
|
|
97
110
|
const skillsList = snapshot.skills?.map((s) => `- ${s.name}`).join("\n") ?? "_(none)_";
|
|
98
111
|
const contextList = snapshot.contextFiles?.map((c) => `- ${c.path}`).join("\n") ?? "_(none)_";
|
|
99
112
|
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
105
|
-
if (Array.isArray(settings.packages) && settings.packages.length > 0) {
|
|
106
|
-
packages = settings.packages.map((p: string) => `- \`${p}\``).join("\n");
|
|
107
|
-
}
|
|
108
|
-
} catch { /* ignore */ }
|
|
109
|
-
}
|
|
113
|
+
const lpkgs = readProjectPackages(cwd);
|
|
114
|
+
const localPackages = lpkgs.length > 0 ? lpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
|
|
115
|
+
const gpkgs = readGlobalPackages();
|
|
116
|
+
const globalPackages = gpkgs.length > 0 ? gpkgs.map((p) => `- \`${p}\``).join("\n") : "_(none)_";
|
|
110
117
|
|
|
111
118
|
display(pi, `## Project distro status
|
|
112
119
|
|
|
@@ -119,8 +126,10 @@ ${updateSection}
|
|
|
119
126
|
${skillsList}
|
|
120
127
|
- **Context files:**
|
|
121
128
|
${contextList}
|
|
122
|
-
- **Installed packages:**
|
|
123
|
-
${
|
|
129
|
+
- **Installed packages (project-local, ./.pi/settings.json):**
|
|
130
|
+
${localPackages}
|
|
131
|
+
- **Installed packages (global, ~/.pi/agent/settings.json):**
|
|
132
|
+
${globalPackages}`);
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
// --- remove ---
|