@bigking67/pi-67 0.10.2 → 0.10.4

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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.4]
4
+
5
+ - Makes all command-level `--help` paths side-effect free, including
6
+ `pi-67 update --help`, with self-tests proving help output does not create
7
+ runtime backup directories.
8
+ - Blocks unsafe dirty update plans before the update lifecycle starts, so a
9
+ blocked `pi-67 update` does not first write a runtime backup.
10
+ - Adds `pi-67 backups prune` and `pi-67 backups archive` for dry-run-first
11
+ backup retention, with per-kind `--keep-last` semantics.
12
+ - Adds `pi-67 skills plan` and `pi-67 skills diff <name>` for explicit
13
+ shared-skill drift review without overwriting existing global skills.
14
+ - Adds `pi-67 xtalpi trend`, `pi-67 xtalpi drift`, and `pi-67 xtalpi stress
15
+ --until-done`; drift defaults to full-suite artifacts to avoid targeted smoke
16
+ noise.
17
+ - Aligns runtime backup ownership around script-level
18
+ `pre-update-runtime-*` snapshots while the npm manager owns update planning,
19
+ locking, and orchestration.
20
+ - Reuses equivalent runtime backups across both `manifest.json` and
21
+ `backup-manifest.json` formats.
22
+ - Expands Windows CI coverage to Node 22 and Node 24 plus npm manager CLI
23
+ contract smoke commands.
24
+
25
+ ## [0.10.3]
26
+
27
+ - Deduplicates real update/repair runtime backups when preserved config files
28
+ are unchanged from the latest same-operation backup.
29
+ - Deduplicates direct PowerShell `scripts/pi67-update.ps1` dirty runtime-config
30
+ preservation backups under `~/.pi/pi67/backups/`.
31
+ - Removes the legacy PowerShell updater path that wrote new
32
+ `~/.pi/agent-backups/pre-update-*` snapshots; runtime preservation now stays
33
+ under `~/.pi/pi67/backups/`.
34
+ - Avoids writing a theme backup when `pi-67 themes set <name>` is already the
35
+ active theme.
36
+
3
37
  ## [0.10.2]
4
38
 
5
39
  - Reads `Manager latest` through the npm registry HTTP API instead of spawning
package/README.md CHANGED
@@ -73,11 +73,17 @@ 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.
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 for `git pull --ff-only`. Those
81
+ script-level snapshots live under `~/.pi/pi67/backups/pre-update-runtime-*`.
82
+ This keeps `--help`, blocked update plans, and the public `npx -y
83
+ @bigking67/pi-67@latest update --repair` orchestration path free of duplicate
84
+ manager-owned runtime backups. If an identical runtime snapshot already exists,
85
+ the script-level updater reuses it instead of writing another timestamped
86
+ directory.
81
87
 
82
88
  Runtime backups are first-class CLI state:
83
89
 
@@ -88,15 +94,18 @@ pi-67 backups inspect <backup-id-or-path>
88
94
  pi-67 backups inspect <pre-update-id> --legacy
89
95
  pi-67 backups restore --from <backup-id-or-path> --dry-run
90
96
  pi-67 backups restore --from <backup-id-or-path> --yes
97
+ pi-67 backups prune --keep-last 10 --dry-run
98
+ pi-67 backups archive --keep-last 10 --older-than 30d --dry-run
91
99
  ```
92
100
 
93
101
  The restore command only restores preserved runtime files and writes a
94
102
  pre-restore backup before overwriting current local config.
95
103
 
96
104
  Legacy PowerShell `~/.pi/agent-backups/pre-update-*` directories are read-only
97
- known-conflict snapshots from older migration paths. They are listed only with
98
- `--include-legacy` and are intentionally separate from restorable runtime
99
- backups under `~/.pi/pi67/backups/`.
105
+ known-conflict snapshots from older migration paths; the normal updater no
106
+ longer writes new ones. They are listed only with `--include-legacy` and are
107
+ intentionally separate from restorable runtime backups under
108
+ `~/.pi/pi67/backups/`.
100
109
 
101
110
  Theme changes are explicit only:
102
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigking67/pi-67",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
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,
@@ -370,6 +434,40 @@ function runUpdateSafetySelfTests() {
370
434
  listRuntimeBackups(ctx).some((item) => item.path === lifecycle.backupDir),
371
435
  "update lifecycle backups must be listable",
372
436
  );
437
+ const backupCountBeforeDedupe = listRuntimeBackups(ctx).length;
438
+ const duplicateLifecycle = beginUpdateLifecycle({
439
+ agentDir,
440
+ repoRoot: agentDir,
441
+ stateDir,
442
+ }, {
443
+ operation: "test",
444
+ });
445
+ assert(duplicateLifecycle.backupSkipped, "unchanged update lifecycle snapshots must be deduplicated");
446
+ duplicateLifecycle.release();
447
+ assert(
448
+ listRuntimeBackups(ctx).length === backupCountBeforeDedupe,
449
+ "deduplicated update lifecycle snapshots must not create new backup directories",
450
+ );
451
+ const backupCountBeforeDelegated = listRuntimeBackups(ctx).length;
452
+ const delegatedLifecycle = beginUpdateLifecycle({
453
+ agentDir,
454
+ repoRoot: agentDir,
455
+ stateDir,
456
+ }, {
457
+ operation: "test-delegated",
458
+ backupRuntime: false,
459
+ });
460
+ assert(fs.existsSync(delegatedLifecycle.lockPath), "delegated update lifecycle must still acquire a lock");
461
+ assert(
462
+ delegatedLifecycle.backupSkipped && !delegatedLifecycle.backupDir && delegatedLifecycle.backedUp.length === 0,
463
+ "delegated update lifecycle must skip manager-owned runtime backups",
464
+ );
465
+ delegatedLifecycle.release();
466
+ assert(!fs.existsSync(delegatedLifecycle.lockPath), "delegated update lifecycle must release lock");
467
+ assert(
468
+ listRuntimeBackups(ctx).length === backupCountBeforeDelegated,
469
+ "delegated update lifecycle must not create runtime backup directories",
470
+ );
373
471
  assert(
374
472
  inspectRuntimeBackup(ctx, lifecycle.backupDir).fileCount >= 2,
375
473
  "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
  }
@@ -7,6 +7,10 @@ export async function doctorCommand(ctx, argv) {
7
7
  bools: ["json", "quiet", "dry-run", "deep-mcp", "strict-shared-skills"],
8
8
  strings: ["mcp-timeout-ms"],
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];
@@ -19,3 +23,23 @@ export async function doctorCommand(ctx, argv) {
19
23
  dryRun: ctx.dryRun || options.dryRun,
20
24
  });
21
25
  }
26
+
27
+ function printDoctorHelp() {
28
+ process.stdout.write(`pi-67 doctor - run readiness diagnostics
29
+
30
+ Usage:
31
+ pi-67 doctor [--json] [--quiet] [--deep-mcp] [--strict-shared-skills]
32
+
33
+ Options:
34
+ --json Emit machine-readable diagnostics.
35
+ --quiet Reduce human output where supported.
36
+ --deep-mcp Run deeper MCP probes on POSIX platforms.
37
+ --mcp-timeout-ms N Timeout for deep MCP probes on POSIX platforms.
38
+ --strict-shared-skills Treat differing shared skills as blocking.
39
+ --dry-run Print the script invocation without running it.
40
+
41
+ Examples:
42
+ pi-67 doctor
43
+ pi-67 doctor --json
44
+ `);
45
+ }
@@ -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
+ }
@@ -13,6 +13,10 @@ export async function installCommand(ctx, argv) {
13
13
  strings: ["repo", "branch"],
14
14
  bools: ["dry-run", "yes", "repair"],
15
15
  });
16
+ if (options.help) {
17
+ printInstallHelp();
18
+ return;
19
+ }
16
20
  const dryRun = ctx.dryRun || options.dryRun;
17
21
  const repo = options.repo || DEFAULT_REPO_URL;
18
22
 
@@ -46,3 +50,21 @@ export async function installCommand(ctx, argv) {
46
50
  if (!dryRun) writeState(ctx, "install");
47
51
  info("Install finished. Run `pi-67 doctor` next.");
48
52
  }
53
+
54
+ function printInstallHelp() {
55
+ process.stdout.write(`pi-67 install - clone/install pi-67 safely
56
+
57
+ Usage:
58
+ pi-67 install [--repo URL] [--branch NAME] [--dry-run] [--repair]
59
+
60
+ Options:
61
+ --repo URL Git repository URL. Defaults to the pi-67 upstream repo.
62
+ --branch NAME Clone a specific branch.
63
+ --dry-run Print planned writes without changing files.
64
+ --repair Force owned asset repair during the installer update phase.
65
+
66
+ Examples:
67
+ pi-67 install
68
+ pi-67 install --dry-run
69
+ `);
70
+ }