@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 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. Deploy a distro (cc-knockoff = a Claude Code-style multi-agent coder)
30
- /pi-distro deploy cc-knockoff
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`, 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.
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`.
@@ -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
- const packages: string[] = [];
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 (/^##\s/.test(t)) { inSection = false; continue; }
44
- const m = t.match(/^-\s+`(npm:[^`]+)`/);
45
- if (m) packages.push(m[1]);
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 m = content.match(
61
- /appliedHarness:\s*(.+?)\s*\n\s*appliedVersion:\s*(.+?)\s*\n\s*sourceCatalogue:\s*(.+?)\s*\n\s*lastUpdated:\s*(.+?)\s*\n/,
62
- );
63
- if (!m) return null;
64
- return {
65
- appliedHarness: m[1].trim(),
66
- appliedVersion: m[2].trim(),
67
- sourceCatalogue: m[3].trim(),
68
- lastUpdated: m[4].trim(),
69
- };
70
- }
71
-
72
- /** Synchronous catalogue name read for autocomplete (best-effort, local only).
73
- * Official distros live on GitHub and aren't available to synchronous
74
- * tab-completion they appear once a command runs and reads the catalogue. */
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" && existsSync(join(dir, entry.name, "harness.md"))) {
82
- names.push(entry.name);
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
 
@@ -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 filesDirNote = harness.filesDir ? `\n\nBundled files are located at: \`${harness.filesDir}\`` : "";
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
- (Run \`pi list\` to also see globally-installed packages.)
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 /tmp for the agent to read bundled files (ephemeral).
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
  }
@@ -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;
@@ -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;
@@ -18,14 +18,25 @@ import {
18
18
  sourceLabel,
19
19
  } from "./catalogue.ts";
20
20
  import { resolveCatalogueEntry } from "./resolve.ts";
21
- import { isOfficialSource, officialNameFromSource, listOfficialDistros } from "./github.ts";
22
- import { display, compareVersions } from "./util.ts";
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
- if (catalogue.length === 0) { display(pi, "No distros found in the catalogue."); return; }
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
- let packages = "_(none)_";
101
- const settingsPath = join(cwd, ".pi", "settings.json");
102
- if (existsSync(settingsPath)) {
103
- try {
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
- ${packages}`);
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 ---