@bigking67/pi-67 0.10.3 → 0.10.5

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,49 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.5]
4
+
5
+ - Uses `git fetch` plus `git merge --ff-only FETCH_HEAD` in Bash and
6
+ PowerShell updaters, allowing the updater to inspect incoming changed paths
7
+ before deciding whether dirty preserved runtime files need temporary cleanup.
8
+ - Keeps dirty runtime config in place without creating a backup when the remote
9
+ is already current or incoming changed paths do not touch those files.
10
+ - Reports `preserve-in-place-no-backup` from `pi-67 update --check` when a
11
+ dirty runtime marker is harmless and the remote already matches local HEAD.
12
+ - Updates docs to separate current `~/.pi/pi67/backups/` runtime backups from
13
+ legacy read-only `~/.pi/agent-backups/` conflict snapshots.
14
+ - Tightens release/smoke gates by checking README version drift, rejecting
15
+ simulated placeholder final answers, and requiring package metadata smoke
16
+ cases to include the real package version.
17
+ - Adds `pi-67 xtalpi run` and a Windows PowerShell xtalpi launcher. The stable
18
+ launcher defaults `PI_OBSERVATIONAL_MEMORY_PASSIVE=true` to keep post-final
19
+ observational-memory background writes from holding the main task lifecycle
20
+ open.
21
+ - Bounds `pi skill list` in doctor with POSIX
22
+ `--skill-list-timeout-seconds` and PowerShell `-SkillListTimeoutSeconds`.
23
+ - Removes legacy `xtalpi-compat` runtime path examples from bootstrap docs.
24
+
25
+ ## [0.10.4]
26
+
27
+ - Makes all command-level `--help` paths side-effect free, including
28
+ `pi-67 update --help`, with self-tests proving help output does not create
29
+ runtime backup directories.
30
+ - Blocks unsafe dirty update plans before the update lifecycle starts, so a
31
+ blocked `pi-67 update` does not first write a runtime backup.
32
+ - Adds `pi-67 backups prune` and `pi-67 backups archive` for dry-run-first
33
+ backup retention, with per-kind `--keep-last` semantics.
34
+ - Adds `pi-67 skills plan` and `pi-67 skills diff <name>` for explicit
35
+ shared-skill drift review without overwriting existing global skills.
36
+ - Adds `pi-67 xtalpi trend`, `pi-67 xtalpi drift`, and `pi-67 xtalpi stress
37
+ --until-done`; drift defaults to full-suite artifacts to avoid targeted smoke
38
+ noise.
39
+ - Aligns runtime backup ownership around script-level
40
+ `pre-update-runtime-*` snapshots while the npm manager owns update planning,
41
+ locking, and orchestration.
42
+ - Reuses equivalent runtime backups across both `manifest.json` and
43
+ `backup-manifest.json` formats.
44
+ - Expands Windows CI coverage to Node 22 and Node 24 plus npm manager CLI
45
+ contract smoke commands.
46
+
3
47
  ## [0.10.3]
4
48
 
5
49
  - Deduplicates real update/repair runtime backups when preserved config files
package/README.md CHANGED
@@ -73,14 +73,21 @@ check or repair before trusting the globally installed manager.
73
73
  - user-added global skills
74
74
  - dirty external repos such as browser67 or design-craft
75
75
 
76
- Before a real `update` or `repair`, the npm manager acquires
77
- `~/.pi/pi67/locks/update.lock` and snapshots preserved runtime files under
78
- `~/.pi/pi67/backups/<timestamp>-update/`. This makes the public `npx -y
79
- @bigking67/pi-67@latest update --repair` path safe even for in-place checkouts
80
- where `settings.json` is tracked but user-owned.
81
- If the preserved runtime files are unchanged from the latest same-operation
82
- backup, the manager reuses that snapshot instead of writing another timestamped
83
- directory.
76
+ Before a real `update` or `repair`, the npm manager builds the update plan,
77
+ blocks unsafe non-runtime dirty worktrees, and acquires
78
+ `~/.pi/pi67/locks/update.lock`. Runtime config backup/restore is delegated to
79
+ the Bash or PowerShell updater script only when an in-place checkout needs to
80
+ temporarily clear dirty preserved runtime files. The updater fetches first,
81
+ compares incoming `HEAD..FETCH_HEAD` changed paths, and creates a runtime
82
+ snapshot only when the incoming update touches those dirty preserved files.
83
+ Those script-level snapshots live under
84
+ `~/.pi/pi67/backups/pre-update-runtime-*`.
85
+ This keeps `--help`, blocked update plans, already-up-to-date updates,
86
+ non-overlapping incoming updates, and the public `npx -y
87
+ @bigking67/pi-67@latest update --repair` orchestration path free of duplicate
88
+ manager-owned runtime backups. If a backup is actually needed and an identical
89
+ runtime snapshot already exists, the script-level updater reuses it instead of
90
+ writing another timestamped directory.
84
91
 
85
92
  Runtime backups are first-class CLI state:
86
93
 
@@ -91,6 +98,8 @@ pi-67 backups inspect <backup-id-or-path>
91
98
  pi-67 backups inspect <pre-update-id> --legacy
92
99
  pi-67 backups restore --from <backup-id-or-path> --dry-run
93
100
  pi-67 backups restore --from <backup-id-or-path> --yes
101
+ pi-67 backups prune --keep-last 10 --dry-run
102
+ pi-67 backups archive --keep-last 10 --older-than 30d --dry-run
94
103
  ```
95
104
 
96
105
  The restore command only restores preserved runtime files and writes a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Cross-platform manager CLI for the pi-67 Pi Coding Agent distribution.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/check.mjs CHANGED
@@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { npmLatestVersion, npmPublishTargetStatus, npmRegistryPackageUrl } from "../src/lib/npm-registry.mjs";
7
7
  import { commandCandidatesForPlatform } from "../src/lib/shell-runner.mjs";
8
+ import { parseCommandOptions, splitGlobalArgs } from "../src/lib/args.mjs";
8
9
  import { readExtensionRegistry, validateExtensionRegistry } from "../src/lib/extension-registry.mjs";
9
10
  import { buildPlanDecisions, classifyGitShort } from "../src/lib/update-plan.mjs";
10
11
  import {
@@ -29,6 +30,8 @@ for (const file of files.filter((item) => item.endsWith(".json"))) {
29
30
  JSON.parse(fs.readFileSync(file, "utf8"));
30
31
  }
31
32
  await runNpmRegistrySelfTests();
33
+ runArgsSelfTests();
34
+ runCliHelpContractSelfTests();
32
35
  runPublishTargetSelfTests();
33
36
  runShellRunnerSelfTests();
34
37
  runExtensionRegistrySelfTests();
@@ -44,6 +47,67 @@ function walk(dir, files) {
44
47
  }
45
48
  }
46
49
 
50
+ function runArgsSelfTests() {
51
+ const afterCommandHelp = splitGlobalArgs(["skills", "--help"]);
52
+ assert(
53
+ !afterCommandHelp.globals.help && afterCommandHelp.rest.join(" ") === "skills --help",
54
+ "command-level --help must not be consumed as global help after a command is seen",
55
+ );
56
+ const globalHelp = splitGlobalArgs(["--help"]);
57
+ assert(globalHelp.globals.help && globalHelp.rest.length === 0, "global --help must still be parsed globally");
58
+ assert(
59
+ parseCommandOptions(["--help"], { bools: [] }).options.help,
60
+ "command option parser must accept --help for every command",
61
+ );
62
+ }
63
+
64
+ function runCliHelpContractSelfTests() {
65
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi67-help-contract-"));
66
+ const home = path.join(tmpRoot, "home");
67
+ const agentDir = path.join(tmpRoot, "agent");
68
+ const skillsDir = path.join(tmpRoot, "skills");
69
+ fs.mkdirSync(home, { recursive: true });
70
+ fs.mkdirSync(agentDir, { recursive: true });
71
+ fs.mkdirSync(skillsDir, { recursive: true });
72
+ const env = { ...process.env, HOME: home, USERPROFILE: home };
73
+ const globalArgs = ["--agent-dir", agentDir, "--repo-root", root, "--skills-dir", skillsDir];
74
+ const commands = [
75
+ ["--help"],
76
+ ["install", "--help"],
77
+ ["update", "--help"],
78
+ ["doctor", "--help"],
79
+ ["smoke", "--help"],
80
+ ["status", "--help"],
81
+ ["report", "--help"],
82
+ ["version", "--help"],
83
+ ["xtalpi", "--help"],
84
+ ["themes", "--help"],
85
+ ["skills", "--help"],
86
+ ["extensions", "--help"],
87
+ ["external", "--help"],
88
+ ["self-update", "--help"],
89
+ ["publish-check", "--help"],
90
+ ["manifest", "--help"],
91
+ ["backups", "--help"],
92
+ ];
93
+ const backupRoot = path.join(home, ".pi", "pi67", "backups");
94
+ for (const command of commands) {
95
+ const result = spawnSync(process.execPath, [path.join(root, "bin", "pi-67.mjs"), ...globalArgs, ...command], {
96
+ cwd: root,
97
+ env,
98
+ encoding: "utf8",
99
+ });
100
+ assert(result.status === 0, `help command failed: pi-67 ${command.join(" ")}\n${result.stderr || result.stdout}`);
101
+ assert(!result.stderr.trim(), `help command wrote stderr: pi-67 ${command.join(" ")}\n${result.stderr}`);
102
+ assert(result.stdout.includes("Usage:"), `help command must print Usage: pi-67 ${command.join(" ")}`);
103
+ }
104
+ assert(
105
+ !fs.existsSync(backupRoot) || fs.readdirSync(backupRoot).length === 0,
106
+ "help commands must not create runtime backups",
107
+ );
108
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
109
+ }
110
+
47
111
  function runPublishTargetSelfTests() {
48
112
  const unpublished = {
49
113
  skipped: false,
@@ -283,6 +347,18 @@ function runUpdatePlanSelfTests() {
283
347
  "dirty runtime config alone must not block the distro update plan",
284
348
  );
285
349
 
350
+ const dirtyRuntimeRemoteCurrent = decisionsFixture({
351
+ git: { dirty: true, short: " M settings.json", commit: "abcdef123456" },
352
+ remote: { ok: true, commit: "abcdef1234567890" },
353
+ });
354
+ const currentRemoteAction = buildPlanDecisions(dirtyRuntimeRemoteCurrent).actions.find((item) => item.id === "user-runtime-config");
355
+ assert(
356
+ currentRemoteAction?.operation === "preserve-in-place-no-backup" &&
357
+ currentRemoteAction.createsNewBackup === false &&
358
+ currentRemoteAction.writes.length === 0,
359
+ "dirty runtime config must not plan a new backup when the remote already matches local HEAD",
360
+ );
361
+
286
362
  const dirtyReadme = decisionsFixture({
287
363
  git: { dirty: true, short: " M README.md" },
288
364
  });
@@ -384,6 +460,26 @@ function runUpdateSafetySelfTests() {
384
460
  listRuntimeBackups(ctx).length === backupCountBeforeDedupe,
385
461
  "deduplicated update lifecycle snapshots must not create new backup directories",
386
462
  );
463
+ const backupCountBeforeDelegated = listRuntimeBackups(ctx).length;
464
+ const delegatedLifecycle = beginUpdateLifecycle({
465
+ agentDir,
466
+ repoRoot: agentDir,
467
+ stateDir,
468
+ }, {
469
+ operation: "test-delegated",
470
+ backupRuntime: false,
471
+ });
472
+ assert(fs.existsSync(delegatedLifecycle.lockPath), "delegated update lifecycle must still acquire a lock");
473
+ assert(
474
+ delegatedLifecycle.backupSkipped && !delegatedLifecycle.backupDir && delegatedLifecycle.backedUp.length === 0,
475
+ "delegated update lifecycle must skip manager-owned runtime backups",
476
+ );
477
+ delegatedLifecycle.release();
478
+ assert(!fs.existsSync(delegatedLifecycle.lockPath), "delegated update lifecycle must release lock");
479
+ assert(
480
+ listRuntimeBackups(ctx).length === backupCountBeforeDelegated,
481
+ "delegated update lifecycle must not create runtime backup directories",
482
+ );
387
483
  assert(
388
484
  inspectRuntimeBackup(ctx, lifecycle.backupDir).fileCount >= 2,
389
485
  "update lifecycle backups must be inspectable",
package/src/cli.mjs CHANGED
@@ -81,15 +81,15 @@ Commands:
81
81
  status Read-only status summary
82
82
  report Generate pi67-report.json
83
83
  version Print manager and distro versions
84
- xtalpi xtalpi health/smoke/capability helpers
84
+ xtalpi xtalpi health/smoke/capability/trend/drift/stress helpers
85
85
  themes current/list/doctor/set without update-time overwrite
86
- skills inventory/sync/migrate shared skills
86
+ skills inventory/plan/diff/sync/migrate shared skills
87
87
  extensions list/doctor/inspect/plan extension ownership policy
88
88
  external list/install/update/doctor external repos
89
89
  self-update Explicitly update the npm manager package
90
90
  publish-check Verify npm publish readiness and trusted publishing
91
91
  manifest Show managed package/config/theme ownership policy
92
- backups list/inspect/restore preserved runtime backups
92
+ backups list/inspect/restore/prune/archive runtime backups
93
93
 
94
94
  Examples:
95
95
  pi-67 install
@@ -101,8 +101,11 @@ Examples:
101
101
  pi-67 manifest
102
102
  pi-67 manifest --validate
103
103
  pi-67 backups list
104
+ pi-67 backups prune --keep-last 10 --dry-run
104
105
  pi-67 extensions doctor
106
+ pi-67 skills plan
105
107
  pi-67 xtalpi smoke --quick
108
+ pi-67 xtalpi trend
106
109
  pi-67 themes current
107
110
  `);
108
111
  }
@@ -1,4 +1,6 @@
1
1
  import path from "node:path";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
2
4
  import { parseCommandOptions } from "../lib/args.mjs";
3
5
  import {
4
6
  inspectLegacyConflictBackup,
@@ -14,6 +16,8 @@ export async function backupsCommand(ctx, argv) {
14
16
  if (subcommand === "list") return listCommand(ctx, argv.slice(1));
15
17
  if (subcommand === "inspect") return inspectCommand(ctx, argv.slice(1));
16
18
  if (subcommand === "restore") return restoreCommand(ctx, argv.slice(1));
19
+ if (subcommand === "prune") return pruneCommand(ctx, argv.slice(1));
20
+ if (subcommand === "archive") return archiveCommand(ctx, argv.slice(1));
17
21
  if (subcommand === "-h" || subcommand === "--help" || subcommand === "help") {
18
22
  printBackupsHelp();
19
23
  return;
@@ -25,6 +29,7 @@ function listCommand(ctx, argv) {
25
29
  const { options } = parseCommandOptions(argv, {
26
30
  bools: ["json", "include-legacy"],
27
31
  });
32
+ if (options.help) return printBackupsHelp();
28
33
  const backups = listRuntimeBackups(ctx);
29
34
  const legacyConflictBackups = listLegacyConflictBackups(ctx);
30
35
  if (ctx.json || options.json) {
@@ -62,6 +67,7 @@ function inspectCommand(ctx, argv) {
62
67
  const { options, positionals } = parseCommandOptions(argv, {
63
68
  bools: ["json", "legacy"],
64
69
  });
70
+ if (options.help) return printBackupsHelp();
65
71
  const backup = options.legacy ? inspectLegacyConflictBackup(ctx, positionals[0]) : inspectRuntimeBackup(ctx, positionals[0]);
66
72
  if (ctx.json || options.json) {
67
73
  printJson({
@@ -96,6 +102,7 @@ function restoreCommand(ctx, argv) {
96
102
  strings: ["from"],
97
103
  bools: ["json", "dry-run", "yes"],
98
104
  });
105
+ if (options.help) return printBackupsHelp();
99
106
  const dryRun = ctx.dryRun || options.dryRun;
100
107
  if (!options.from) {
101
108
  throw new CliError("backups restore requires --from <backup-id-or-path>", 2);
@@ -128,13 +135,186 @@ function restoreCommand(ctx, argv) {
128
135
  }
129
136
  }
130
137
 
138
+ function pruneCommand(ctx, argv) {
139
+ const { options } = parseCommandOptions(argv, {
140
+ strings: ["keep-last", "older-than"],
141
+ bools: ["json", "dry-run", "yes", "include-legacy"],
142
+ });
143
+ if (options.help) return printBackupsHelp();
144
+ const dryRun = ctx.dryRun || options.dryRun;
145
+ const plan = buildRetentionPlan(ctx, options);
146
+ if (!dryRun && !(ctx.yes || options.yes)) {
147
+ throw new CliError("backups prune deletes backup directories; rerun with --dry-run or --yes", 2);
148
+ }
149
+ if (!dryRun) {
150
+ for (const item of plan.selected) {
151
+ fs.rmSync(item.path, { recursive: true, force: true });
152
+ }
153
+ }
154
+ const result = {
155
+ schema: "pi67.backups-prune.v1",
156
+ createdAt: new Date().toISOString(),
157
+ dryRun,
158
+ selector: plan.selector,
159
+ selected: plan.selected,
160
+ kept: plan.kept,
161
+ deleted: dryRun ? [] : plan.selected,
162
+ };
163
+ if (ctx.json || options.json) return printJson(result);
164
+ section("pi-67 backups prune");
165
+ keyValue("Dry run", dryRun ? "yes" : "no");
166
+ keyValue("Selected", result.selected.length);
167
+ keyValue("Kept", result.kept.length);
168
+ for (const item of result.selected) {
169
+ warn(`${dryRun ? "would delete" : "deleted"} ${item.kind}: ${item.id}`);
170
+ }
171
+ if (result.selected.length === 0) pass("no backups matched the retention selector");
172
+ }
173
+
174
+ function archiveCommand(ctx, argv) {
175
+ const { options } = parseCommandOptions(argv, {
176
+ strings: ["keep-last", "older-than", "to"],
177
+ bools: ["json", "dry-run", "yes", "include-legacy"],
178
+ });
179
+ if (options.help) return printBackupsHelp();
180
+ const dryRun = ctx.dryRun || options.dryRun;
181
+ const plan = buildRetentionPlan(ctx, options);
182
+ const archiveRoot = path.resolve(expandTilde(options.to || path.join(ctx.stateDir, "backups-archive")));
183
+ if (!dryRun && !(ctx.yes || options.yes)) {
184
+ throw new CliError("backups archive moves backup directories; rerun with --dry-run or --yes", 2);
185
+ }
186
+ const archived = [];
187
+ if (!dryRun) {
188
+ fs.mkdirSync(archiveRoot, { recursive: true, mode: 0o700 });
189
+ for (const item of plan.selected) {
190
+ const targetRoot = path.join(archiveRoot, item.kind);
191
+ fs.mkdirSync(targetRoot, { recursive: true, mode: 0o700 });
192
+ const target = uniqueArchivePath(path.join(targetRoot, item.id));
193
+ moveDirectorySync(item.path, target);
194
+ archived.push({ ...item, archivedPath: target });
195
+ }
196
+ }
197
+ const result = {
198
+ schema: "pi67.backups-archive.v1",
199
+ createdAt: new Date().toISOString(),
200
+ dryRun,
201
+ archiveRoot,
202
+ selector: plan.selector,
203
+ selected: plan.selected,
204
+ kept: plan.kept,
205
+ archived: dryRun ? [] : archived,
206
+ };
207
+ if (ctx.json || options.json) return printJson(result);
208
+ section("pi-67 backups archive");
209
+ keyValue("Dry run", dryRun ? "yes" : "no");
210
+ keyValue("Archive root", archiveRoot);
211
+ keyValue("Selected", result.selected.length);
212
+ for (const item of result.selected) {
213
+ info(`${dryRun ? "would archive" : "archived"} ${item.kind}: ${item.id}`);
214
+ }
215
+ if (result.selected.length === 0) pass("no backups matched the retention selector");
216
+ }
217
+
218
+ function buildRetentionPlan(ctx, options) {
219
+ const keepLast = parseOptionalNonNegativeInt(options.keepLast, "--keep-last");
220
+ const olderThanMs = parseDurationMs(options.olderThan, "--older-than");
221
+ if (keepLast === undefined && olderThanMs === undefined) {
222
+ throw new CliError("backups retention requires --keep-last N and/or --older-than Nd|Nh|Nm", 2);
223
+ }
224
+ const now = Date.now();
225
+ const runtime = listRuntimeBackups(ctx).map((item) => ({ ...item, kind: "runtime" }));
226
+ const legacy = options.includeLegacy
227
+ ? listLegacyConflictBackups(ctx).map((item) => ({ ...item, kind: "legacy" }))
228
+ : [];
229
+ const all = [...runtime, ...legacy].sort((left, right) => backupTime(right) - backupTime(left));
230
+ const rankByKind = new Map();
231
+ const selected = [];
232
+ const kept = [];
233
+ for (const item of all) {
234
+ const rank = rankByKind.get(item.kind) || 0;
235
+ rankByKind.set(item.kind, rank + 1);
236
+ const keptByCount = keepLast !== undefined && rank < keepLast;
237
+ const oldEnough = olderThanMs === undefined || now - backupTime(item) >= olderThanMs;
238
+ if (!keptByCount && oldEnough) selected.push(item);
239
+ else kept.push(item);
240
+ }
241
+ return {
242
+ selector: {
243
+ keepLast: keepLast ?? null,
244
+ olderThan: options.olderThan || "",
245
+ includeLegacy: Boolean(options.includeLegacy),
246
+ },
247
+ selected,
248
+ kept,
249
+ };
250
+ }
251
+
252
+ function parseOptionalNonNegativeInt(value, name) {
253
+ if (value === undefined) return undefined;
254
+ const number = Number(value);
255
+ if (!Number.isInteger(number) || number < 0) {
256
+ throw new CliError(`${name} must be a non-negative integer`, 2);
257
+ }
258
+ return number;
259
+ }
260
+
261
+ function parseDurationMs(value, name) {
262
+ if (value === undefined) return undefined;
263
+ const match = /^(\d+)(m|h|d)$/i.exec(String(value).trim());
264
+ if (!match) throw new CliError(`${name} must look like 30d, 12h, or 90m`, 2);
265
+ const amount = Number(match[1]);
266
+ const unit = match[2].toLowerCase();
267
+ const multiplier = unit === "d" ? 24 * 60 * 60 * 1000 : unit === "h" ? 60 * 60 * 1000 : 60 * 1000;
268
+ return amount * multiplier;
269
+ }
270
+
271
+ function backupTime(item) {
272
+ const parsed = Date.parse(item.createdAt || "");
273
+ if (Number.isFinite(parsed)) return parsed;
274
+ try {
275
+ return fs.statSync(item.path).mtimeMs;
276
+ } catch {
277
+ return 0;
278
+ }
279
+ }
280
+
281
+ function uniqueArchivePath(candidate) {
282
+ if (!fs.existsSync(candidate)) return candidate;
283
+ for (let index = 1; index < 1000; index += 1) {
284
+ const next = `${candidate}.${index}`;
285
+ if (!fs.existsSync(next)) return next;
286
+ }
287
+ throw new CliError(`could not allocate archive path for ${candidate}`, 1);
288
+ }
289
+
290
+ function expandTilde(input) {
291
+ if (input === "~") return os.homedir();
292
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
293
+ return path.join(os.homedir(), input.slice(2));
294
+ }
295
+ return input;
296
+ }
297
+
298
+ function moveDirectorySync(source, target) {
299
+ try {
300
+ fs.renameSync(source, target);
301
+ return;
302
+ } catch (error) {
303
+ if (error.code !== "EXDEV") throw error;
304
+ }
305
+ fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: true });
306
+ fs.rmSync(source, { recursive: true, force: true });
307
+ }
308
+
131
309
  function printBackupsHelp() {
132
- process.stdout.write(`pi-67 backups - inspect and restore preserved runtime backups
310
+ process.stdout.write(`pi-67 backups - inspect, restore, prune, and archive runtime backups
133
311
 
134
312
  Usage:
135
313
  pi-67 backups list [--include-legacy] [--json]
136
314
  pi-67 backups inspect <backup-id-or-path> [--legacy] [--json]
137
315
  pi-67 backups restore --from <backup-id-or-path> [--dry-run] [--yes] [--json]
316
+ pi-67 backups prune --keep-last N [--older-than 30d] [--include-legacy] [--dry-run|--yes] [--json]
317
+ pi-67 backups archive --keep-last N [--older-than 30d] [--to DIR] [--include-legacy] [--dry-run|--yes] [--json]
138
318
 
139
319
  Examples:
140
320
  pi-67 backups list
@@ -143,5 +323,11 @@ Examples:
143
323
  pi-67 backups inspect pre-update-20260707-235901 --legacy
144
324
  pi-67 backups restore --from 20260707T120000Z-update --dry-run
145
325
  pi-67 backups restore --from 20260707T120000Z-update --yes
326
+ pi-67 backups prune --keep-last 10 --dry-run
327
+ pi-67 backups archive --keep-last 10 --older-than 30d --dry-run
328
+
329
+ Retention:
330
+ --keep-last is applied per backup kind. Legacy backups are ignored unless
331
+ --include-legacy is provided. prune/archive require --dry-run or --yes.
146
332
  `);
147
333
  }
@@ -5,8 +5,12 @@ import { isWindows } from "../lib/platform.mjs";
5
5
  export async function doctorCommand(ctx, argv) {
6
6
  const { options } = parseCommandOptions(argv, {
7
7
  bools: ["json", "quiet", "dry-run", "deep-mcp", "strict-shared-skills"],
8
- strings: ["mcp-timeout-ms"],
8
+ strings: ["mcp-timeout-ms", "skill-list-timeout-seconds"],
9
9
  });
10
+ if (options.help) {
11
+ printDoctorHelp();
12
+ return;
13
+ }
10
14
  const args = isWindows()
11
15
  ? ["-AgentDir", ctx.agentDir, "-RepoRoot", ctx.repoRoot, "-SkillsDir", ctx.skillsDir]
12
16
  : ["--agent-dir", ctx.agentDir, "--repo-root", ctx.repoRoot, "--skills-dir", ctx.skillsDir];
@@ -15,7 +19,32 @@ export async function doctorCommand(ctx, argv) {
15
19
  if (options.strictSharedSkills) args.push(isWindows() ? "-StrictSharedSkills" : "--strict-shared-skills");
16
20
  if (!isWindows() && options.deepMcp) args.push("--deep-mcp");
17
21
  if (!isWindows() && options.mcpTimeoutMs) args.push("--mcp-timeout-ms", options.mcpTimeoutMs);
22
+ if (options.skillListTimeoutSeconds) {
23
+ args.push(isWindows() ? "-SkillListTimeoutSeconds" : "--skill-list-timeout-seconds", options.skillListTimeoutSeconds);
24
+ }
18
25
  runDistroScript(ctx, { sh: "pi67-doctor.sh", ps1: "pi67-doctor.ps1" }, args, {
19
26
  dryRun: ctx.dryRun || options.dryRun,
20
27
  });
21
28
  }
29
+
30
+ function printDoctorHelp() {
31
+ process.stdout.write(`pi-67 doctor - run readiness diagnostics
32
+
33
+ Usage:
34
+ pi-67 doctor [--json] [--quiet] [--deep-mcp] [--strict-shared-skills]
35
+
36
+ Options:
37
+ --json Emit machine-readable diagnostics.
38
+ --quiet Reduce human output where supported.
39
+ --deep-mcp Run deeper MCP probes on POSIX platforms.
40
+ --mcp-timeout-ms N Timeout for deep MCP probes on POSIX platforms.
41
+ --skill-list-timeout-seconds N
42
+ Timeout for pi skill list on POSIX platforms.
43
+ --strict-shared-skills Treat differing shared skills as blocking.
44
+ --dry-run Print the script invocation without running it.
45
+
46
+ Examples:
47
+ pi-67 doctor
48
+ pi-67 doctor --json
49
+ `);
50
+ }
@@ -20,6 +20,7 @@ export async function extensionsCommand(ctx, argv) {
20
20
 
21
21
  function list(ctx, argv) {
22
22
  const { options } = parseCommandOptions(argv, { bools: ["json"] });
23
+ if (options.help) return printExtensionsHelp();
23
24
  const manifest = buildDistroManifest(ctx);
24
25
  const data = {
25
26
  schema: "pi67.extensions-list.v1",
@@ -41,6 +42,7 @@ async function doctor(ctx, argv) {
41
42
  const { options } = parseCommandOptions(argv, {
42
43
  bools: ["json", "strict-shared-skills", "no-remote"],
43
44
  });
45
+ if (options.help) return printExtensionsHelp();
44
46
  const manifest = buildDistroManifest(ctx);
45
47
  const validation = validateExtensionRegistry(manifest.extensionRegistry, {
46
48
  manifest,
@@ -94,6 +96,7 @@ async function doctor(ctx, argv) {
94
96
 
95
97
  function inspect(ctx, argv) {
96
98
  const { options, positionals } = parseCommandOptions(argv, { bools: ["json"] });
99
+ if (options.help) return printExtensionsHelp();
97
100
  const id = positionals[0];
98
101
  if (!id) throw new CliError("extensions inspect requires an extension id", 2);
99
102
  const manifest = buildDistroManifest(ctx);
@@ -124,6 +127,7 @@ async function plan(ctx, argv) {
124
127
  const { options } = parseCommandOptions(argv, {
125
128
  bools: ["json", "strict-shared-skills", "no-remote"],
126
129
  });
130
+ if (options.help) return printExtensionsHelp();
127
131
  const updatePlan = await buildUpdatePlan(ctx, {
128
132
  noRemote: ctx.noRemote || options.noRemote,
129
133
  strictSharedSkills: options.strictSharedSkills,
@@ -144,7 +148,8 @@ async function plan(ctx, argv) {
144
148
  }
145
149
 
146
150
  function updateHint(_ctx, argv) {
147
- const { positionals } = parseCommandOptions(argv, { bools: ["dry-run"] });
151
+ const { options, positionals } = parseCommandOptions(argv, { bools: ["dry-run"] });
152
+ if (options.help) return printExtensionsHelp();
148
153
  const id = positionals[0] || "<id>";
149
154
  throw new CliError(
150
155
  [
@@ -4,6 +4,10 @@ import { CliError, info, keyValue, printJson, section, warn } from "../lib/outpu
4
4
 
5
5
  export async function externalCommand(ctx, argv) {
6
6
  const [sub = "list", ...rest] = argv;
7
+ if (sub === "-h" || sub === "--help" || sub === "help") {
8
+ printExternalHelp();
9
+ return;
10
+ }
7
11
  if (sub === "list") return list(ctx, rest);
8
12
  if (sub === "install") return install(ctx, rest);
9
13
  if (sub === "update") return update(ctx, rest);
@@ -13,6 +17,7 @@ export async function externalCommand(ctx, argv) {
13
17
 
14
18
  function list(ctx, argv) {
15
19
  const { options } = parseCommandOptions(argv, { bools: ["json"] });
20
+ if (options.help) return printExternalHelp();
16
21
  const data = { schema: "pi67.external-list.v1", repos: listExternal(ctx) };
17
22
  if (ctx.json || options.json) return printJson(data);
18
23
  section("pi-67 external repos");
@@ -23,6 +28,7 @@ function list(ctx, argv) {
23
28
 
24
29
  function install(ctx, argv) {
25
30
  const { options, positionals } = parseCommandOptions(argv, { bools: ["json", "dry-run"] });
31
+ if (options.help) return printExternalHelp();
26
32
  const name = positionals[0];
27
33
  assertExternalName(name);
28
34
  const data = installExternal(ctx, name, { dryRun: ctx.dryRun || options.dryRun });
@@ -32,6 +38,7 @@ function install(ctx, argv) {
32
38
 
33
39
  function update(ctx, argv) {
34
40
  const { options, positionals } = parseCommandOptions(argv, { bools: ["json", "dry-run"] });
41
+ if (options.help) return printExternalHelp();
35
42
  const name = positionals[0];
36
43
  assertExternalName(name);
37
44
  const data = updateExternal(ctx, name, { dryRun: ctx.dryRun || options.dryRun });
@@ -41,6 +48,7 @@ function update(ctx, argv) {
41
48
 
42
49
  function doctor(ctx, argv) {
43
50
  const { options, positionals } = parseCommandOptions(argv, { bools: ["json"] });
51
+ if (options.help) return printExternalHelp();
44
52
  const name = positionals[0];
45
53
  assertExternalName(name);
46
54
  const data = externalStatus(ctx, name);
@@ -61,3 +69,23 @@ function assertExternalName(name) {
61
69
  if (!name) throw new CliError(`external repo name required. Available: ${Object.keys(EXTERNAL_REPOS).join(", ")}`, 2);
62
70
  if (!EXTERNAL_REPOS[name]) throw new CliError(`unknown external repo: ${name}`, 2);
63
71
  }
72
+
73
+ function printExternalHelp() {
74
+ process.stdout.write(`pi-67 external - manage external companion repositories
75
+
76
+ Usage:
77
+ pi-67 external list [--json]
78
+ pi-67 external install <browser67|design-craft> [--dry-run] [--json]
79
+ pi-67 external update <browser67|design-craft> [--dry-run] [--json]
80
+ pi-67 external doctor <browser67|design-craft> [--json]
81
+
82
+ Safety:
83
+ External repos are explicit opt-in. Dirty external repos block update instead
84
+ of being overwritten.
85
+
86
+ Examples:
87
+ pi-67 external list
88
+ pi-67 external install browser67 --dry-run
89
+ pi-67 external doctor design-craft
90
+ `);
91
+ }