@bigking67/pi-67 0.10.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +182 -0
  3. package/bin/pi-67.mjs +13 -0
  4. package/package.json +47 -0
  5. package/schemas/pi67-distro-manifest.schema.json +130 -0
  6. package/schemas/pi67-extension-registry.schema.json +110 -0
  7. package/schemas/pi67-publish-check.schema.json +122 -0
  8. package/schemas/pi67-state.schema.json +31 -0
  9. package/schemas/pi67-update-plan.schema.json +141 -0
  10. package/scripts/check.mjs +401 -0
  11. package/src/cli.mjs +108 -0
  12. package/src/commands/backups.mjs +124 -0
  13. package/src/commands/doctor.mjs +21 -0
  14. package/src/commands/extensions.mjs +184 -0
  15. package/src/commands/external.mjs +63 -0
  16. package/src/commands/install.mjs +48 -0
  17. package/src/commands/manifest.mjs +74 -0
  18. package/src/commands/publish-check.mjs +366 -0
  19. package/src/commands/report.mjs +20 -0
  20. package/src/commands/self-update.mjs +16 -0
  21. package/src/commands/skills.mjs +49 -0
  22. package/src/commands/smoke.mjs +18 -0
  23. package/src/commands/status.mjs +21 -0
  24. package/src/commands/themes.mjs +69 -0
  25. package/src/commands/update.mjs +138 -0
  26. package/src/commands/version.mjs +51 -0
  27. package/src/commands/xtalpi.mjs +69 -0
  28. package/src/data/distro-manifest.json +85 -0
  29. package/src/data/extension-registry.json +147 -0
  30. package/src/lib/args.mjs +87 -0
  31. package/src/lib/config-json.mjs +20 -0
  32. package/src/lib/distro-manifest.mjs +131 -0
  33. package/src/lib/distro-scripts.mjs +27 -0
  34. package/src/lib/extension-registry.mjs +250 -0
  35. package/src/lib/external-repos.mjs +71 -0
  36. package/src/lib/git.mjs +55 -0
  37. package/src/lib/npm-registry.mjs +288 -0
  38. package/src/lib/output.mjs +35 -0
  39. package/src/lib/paths.mjs +79 -0
  40. package/src/lib/platform.mjs +27 -0
  41. package/src/lib/shell-runner.mjs +40 -0
  42. package/src/lib/skill-policy.mjs +85 -0
  43. package/src/lib/state-store.mjs +31 -0
  44. package/src/lib/theme-policy.mjs +34 -0
  45. package/src/lib/update-plan.mjs +312 -0
  46. package/src/lib/update-safety.mjs +310 -0
@@ -0,0 +1,366 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parseCommandOptions } from "../lib/args.mjs";
4
+ import { gitStatus } from "../lib/git.mjs";
5
+ import { npmLatestVersion, npmPackageScopeStatus, npmPublishTargetStatus } from "../lib/npm-registry.mjs";
6
+ import { captureCommand } from "../lib/shell-runner.mjs";
7
+ import { readCliPackageJson, readTextIfExists, packageRoot } from "../lib/paths.mjs";
8
+ import { buildDistroManifest } from "../lib/distro-manifest.mjs";
9
+ import { REQUIRED_EXTENSION_REGISTRY_IDS, validateExtensionRegistry } from "../lib/extension-registry.mjs";
10
+ import { fail, info, keyValue, pass, printJson, section, warn } from "../lib/output.mjs";
11
+
12
+ export async function publishCheckCommand(ctx, argv) {
13
+ const { options } = parseCommandOptions(argv, {
14
+ bools: ["json", "no-remote", "no-pack", "quiet", "strict", "allow-first-publish"],
15
+ });
16
+ const json = ctx.json || options.json;
17
+ const quiet = options.quiet;
18
+ const noRemote = ctx.noRemote || options.noRemote;
19
+ const noPack = options.noPack;
20
+ const strict = options.strict;
21
+ const report = buildPublishCheck(ctx, {
22
+ noRemote,
23
+ noPack,
24
+ allowFirstPublish: options.allowFirstPublish,
25
+ });
26
+ if (quiet) {
27
+ // Intentionally silent for npm lifecycle hooks; exit status carries failure.
28
+ } else if (json) {
29
+ printJson(report);
30
+ } else {
31
+ printReport(report);
32
+ }
33
+ if (strict && report.blockers.length > 0) {
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+
38
+ function buildPublishCheck(ctx, options) {
39
+ const pkg = readCliPackageJson();
40
+ const rootPackage = readJsonIfExists(path.join(ctx.repoRoot, "package.json")) || {};
41
+ const versionFile = readTextIfExists(path.join(ctx.repoRoot, "VERSION")).trim();
42
+ const workflowFile = path.join(ctx.repoRoot, ".github", "workflows", "npm-publish.yml");
43
+ const workflow = workflowCheck(workflowFile);
44
+ const git = fs.existsSync(ctx.repoRoot) ? gitStatus(ctx.repoRoot) : { isRepo: false };
45
+ const registry = npmLatestVersion(pkg.name, {
46
+ currentVersion: pkg.version,
47
+ noRemote: options.noRemote,
48
+ timeoutMs: 10000,
49
+ });
50
+ const scope = npmPackageScopeStatus(pkg.name, {
51
+ noRemote: options.noRemote,
52
+ timeoutMs: 10000,
53
+ });
54
+ const target = npmPublishTargetStatus(pkg.name, {
55
+ noRemote: options.noRemote,
56
+ timeoutMs: 10000,
57
+ registry,
58
+ scope,
59
+ allowFirstPublish: options.allowFirstPublish,
60
+ });
61
+ const auth = npmAuthCheck({ noRemote: options.noRemote });
62
+ const pack = options.noPack ? skipped("pack dry-run skipped") : npmPackCheck();
63
+ const manifest = buildDistroManifest(ctx);
64
+ const manifestRelease = manifestReleaseCheck(manifest);
65
+
66
+ const checks = [
67
+ check("package_name", pkg.name === "@bigking67/pi-67", `expected @bigking67/pi-67, got ${pkg.name}`),
68
+ check("package_version_matches_VERSION", pkg.version === versionFile, `${pkg.version} != ${versionFile}`),
69
+ check("root_package_version_matches_VERSION", rootPackage.version === versionFile, `${rootPackage.version || "missing"} != ${versionFile}`),
70
+ check("bin_pi_67", pkg.bin?.["pi-67"] === "bin/pi-67.mjs", "missing pi-67 bin"),
71
+ check("bin_pi67_alias", pkg.bin?.pi67 === "bin/pi-67.mjs", "missing pi67 alias"),
72
+ check("publish_public", pkg.publishConfig?.access === "public", "scoped package must publish as public"),
73
+ check("trusted_publish_workflow", workflow.ok, workflow.message),
74
+ check("npm_scope", !scope.blocking || target.code === "first_publish_scope_probe_confirmed", scope.message),
75
+ check("npm_publish_target", !target.blocking, target.message),
76
+ check("distro_manifest", manifestRelease.ok, manifestRelease.message),
77
+ check("npm_pack_dry_run", pack.ok || pack.skipped, pack.message),
78
+ ];
79
+
80
+ const exactVersionPublished = registry.ok && registry.latestVersion === pkg.version;
81
+ const registryNotPublished = !registry.ok && registry.message === "not published on npm registry yet";
82
+ const scopeMissing = Boolean(scope.blocking && target.code !== "first_publish_scope_probe_confirmed");
83
+ const blockers = checks.filter((item) => !item.ok).map((item) => `${item.name}: ${item.message}`);
84
+ if (exactVersionPublished) {
85
+ blockers.push(`npm_version_already_published: ${pkg.name}@${pkg.version}`);
86
+ }
87
+
88
+ const warnings = [];
89
+ if (!git.isRepo) warnings.push("repo root is not a git checkout; release provenance will be weaker");
90
+ else if (git.dirty) warnings.push("repo has local changes; commit scoped release changes before publishing");
91
+ if (registryNotPublished) warnings.push("npm package is not published yet; configure Trusted Publisher or do one manual first publish");
92
+ if (scope.skipped) warnings.push("npm scope check skipped");
93
+ else if (target.code === "first_publish_scope_probe_confirmed") {
94
+ warnings.push(`npm scope probe did not prove ${scope.scope}; explicit first-publish confirmation lets final npm publish validate write permission`);
95
+ }
96
+ else if (scope.scoped && !scope.ok && !scope.blocking) warnings.push(`npm scope check did not complete: ${scope.message}`);
97
+ if (target.skipped) warnings.push("npm publish target check skipped");
98
+ else if (target.firstPublish && target.allowFirstPublish && target.ok) {
99
+ warnings.push("first publish confirmation is explicit; npm still validates Trusted Publisher only during final npm publish");
100
+ }
101
+ if (!options.noRemote && !auth.ok) warnings.push("local npm auth is missing; this is acceptable for GitHub Trusted Publishing");
102
+ if (registry.skipped) warnings.push("npm registry check skipped");
103
+ if (auth.skipped) warnings.push("npm auth check skipped");
104
+ if (pack.skipped) warnings.push("npm pack dry-run skipped");
105
+ warnings.push(...manifestRelease.warnings);
106
+
107
+ let status = "ready";
108
+ if (blockers.length > 0) status = "blocked";
109
+ else if (registryNotPublished || !auth.ok || git.dirty || registry.skipped || auth.skipped) status = "ready_with_notes";
110
+
111
+ return {
112
+ schema: "pi67.publish-check.v1",
113
+ createdAt: new Date().toISOString(),
114
+ status,
115
+ package: {
116
+ name: pkg.name,
117
+ version: pkg.version,
118
+ root: packageRoot(),
119
+ },
120
+ distro: {
121
+ version: versionFile,
122
+ rootPackageVersion: rootPackage.version || "",
123
+ },
124
+ paths: {
125
+ repoRoot: ctx.repoRoot,
126
+ agentDir: ctx.agentDir,
127
+ workflow: workflowFile,
128
+ },
129
+ git,
130
+ workflow,
131
+ scope,
132
+ target,
133
+ manifest: {
134
+ summary: manifest.summary,
135
+ release: manifestRelease,
136
+ },
137
+ registry,
138
+ auth,
139
+ pack,
140
+ checks,
141
+ blockers,
142
+ warnings,
143
+ nextSteps: nextSteps({
144
+ status,
145
+ registryNotPublished,
146
+ exactVersionPublished,
147
+ authOk: auth.ok,
148
+ scopeMissing,
149
+ scopeName: scope.scope,
150
+ packageName: pkg.name,
151
+ firstPublishNeedsConfirmation: target.code === "first_publish_requires_confirmation",
152
+ allowFirstPublish: target.allowFirstPublish,
153
+ }),
154
+ };
155
+ }
156
+
157
+ function manifestReleaseCheck(manifest) {
158
+ const requiredPreserve = ["settings.json", "models.json", "auth.json", "mcp.json", "image-gen.json", "settings.json.theme"];
159
+ const missingPreserve = requiredPreserve.filter((file) => !manifest.runtimeFiles?.preserve?.includes(file));
160
+ const requiredCommands = ["update", "repair", "alwaysFreshRepair", "upstreamPiExtensions"];
161
+ const missingCommands = requiredCommands.filter((name) => !manifest.commands?.[name]);
162
+ const missingLocalExtensions = (manifest.localExtensions || [])
163
+ .filter((item) => item.required && !item.exists)
164
+ .map((item) => item.name);
165
+ const registry = manifest.extensionRegistry || {};
166
+ const registryValidation = validateExtensionRegistry(registry, {
167
+ manifest,
168
+ requiredIds: REQUIRED_EXTENSION_REGISTRY_IDS,
169
+ });
170
+ const userManagedPackages = manifest.userManagedPackages || [];
171
+ const policyProblems = [];
172
+
173
+ if (manifest.theme?.preserveSetting !== "settings.json:theme") {
174
+ policyProblems.push("theme preserveSetting must be settings.json:theme");
175
+ }
176
+ if (manifest.theme?.selection?.file !== "settings.json" || manifest.theme?.selection?.path !== "theme") {
177
+ policyProblems.push("theme selection must be the settings.json theme field");
178
+ }
179
+ if (manifest.theme?.policy !== "install-theme-package-only-never-select-theme-on-update") {
180
+ policyProblems.push("theme policy must preserve selected theme during update");
181
+ }
182
+ if (manifest.sharedSkills?.policy !== "copy-by-default-preserve-different-existing-skills-unless-strict") {
183
+ policyProblems.push("shared skills policy must preserve existing differences unless strict");
184
+ }
185
+ if (manifest.externalReposPolicy?.dirtyRepo !== "preserve-and-block-update") {
186
+ policyProblems.push("dirty external repos must block update instead of being overwritten");
187
+ }
188
+
189
+ const problems = [
190
+ ...missingPreserve.map((item) => `missing preserved runtime file policy: ${item}`),
191
+ ...missingCommands.map((item) => `missing canonical command policy: ${item}`),
192
+ ...missingLocalExtensions.map((item) => `required local extension missing: ${item}`),
193
+ ...registryValidation.problems,
194
+ ...userManagedPackages.map((item) => `repo baseline contains user-managed runtime package: ${item.spec}`),
195
+ ...policyProblems,
196
+ ];
197
+
198
+ const warnings = [];
199
+ if (manifest.summary?.runtimePackages === 0) {
200
+ warnings.push("distro manifest has no runtime packages");
201
+ }
202
+ warnings.push(...registryValidation.warnings);
203
+
204
+ return {
205
+ ok: problems.length === 0,
206
+ message: problems.length === 0 ? "ownership manifest release policy ready" : problems.join("; "),
207
+ problems,
208
+ warnings,
209
+ extensionRegistry: {
210
+ ok: registryValidation.ok,
211
+ summary: registryValidation.summary,
212
+ warnings: registryValidation.warnings,
213
+ },
214
+ };
215
+ }
216
+
217
+ function workflowCheck(file) {
218
+ const text = readTextIfExists(file);
219
+ if (!text) return { ok: false, file, message: "npm publish workflow missing" };
220
+ const required = [
221
+ "workflow_dispatch",
222
+ "id-token: write",
223
+ "Use npm with trusted publishing support",
224
+ "npm install -g npm@latest",
225
+ "Validate npm publish target",
226
+ "first_publish_confirm",
227
+ "publish-check --strict --no-pack",
228
+ "--allow-first-publish",
229
+ "npm publish ./packages/pi67-cli --access public --tag",
230
+ "auth_mode",
231
+ "token-bootstrap",
232
+ "inputs.auth_mode == 'token-bootstrap' && secrets.NPM_TOKEN",
233
+ "auth_mode=token-bootstrap requires repository secret NPM_TOKEN",
234
+ "No long-lived token is configured here",
235
+ ];
236
+ const missing = required.filter((fragment) => !text.includes(fragment));
237
+ const forbidden = [];
238
+ const ok = missing.length === 0 && forbidden.length === 0;
239
+ return {
240
+ ok,
241
+ file,
242
+ trustedPublishing: ok,
243
+ missing,
244
+ forbidden,
245
+ message: ok ? "trusted publishing workflow ready" : `workflow drift: missing=${missing.join(",") || "-"} forbidden=${forbidden.join(",") || "-"}`,
246
+ };
247
+ }
248
+
249
+ function npmAuthCheck(options) {
250
+ if (options.noRemote) return skipped("remote auth check skipped");
251
+ const result = captureCommand("npm", ["whoami"], { timeoutMs: 10000 });
252
+ return {
253
+ skipped: false,
254
+ ok: result.ok,
255
+ username: result.ok ? result.stdout.trim() : "",
256
+ message: result.ok ? "npm auth available" : compactMessage(result.stderr || result.error || "npm auth unavailable"),
257
+ };
258
+ }
259
+
260
+ function npmPackCheck() {
261
+ const result = captureCommand("npm", ["pack", "--dry-run", packageRoot()], { timeoutMs: 30000 });
262
+ return {
263
+ skipped: false,
264
+ ok: result.ok,
265
+ message: result.ok ? "npm pack dry-run passed" : compactMessage(result.stderr || result.error || "npm pack dry-run failed"),
266
+ tarball: result.ok ? compactMessage(result.stdout) : "",
267
+ };
268
+ }
269
+
270
+ function check(name, ok, message) {
271
+ return { name, ok: Boolean(ok), message: ok ? "ok" : message };
272
+ }
273
+
274
+ function skipped(message) {
275
+ return { skipped: true, ok: false, message };
276
+ }
277
+
278
+ function readJsonIfExists(file) {
279
+ try {
280
+ return JSON.parse(fs.readFileSync(file, "utf8"));
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function nextSteps(context) {
287
+ if (context.exactVersionPublished) {
288
+ return [
289
+ "Bump VERSION, root package.json, packages/pi67-cli/package.json, and changelogs before publishing again.",
290
+ ];
291
+ }
292
+ const steps = [
293
+ "GitHub Actions -> npm publish pi-67 manager -> Run workflow with dry_run=true.",
294
+ ];
295
+ if (context.scopeMissing) {
296
+ steps.push(`Create or claim the npm scope ${context.scopeName} before publishing, or rename the npm package to an owned scope/name.`);
297
+ }
298
+ if (context.firstPublishNeedsConfirmation) {
299
+ steps.push(`After npm scope and Trusted Publisher are configured, rerun with --allow-first-publish or set the workflow first_publish_confirm input to ${context.packageName}.`);
300
+ }
301
+ if (context.registryNotPublished) {
302
+ steps.push(`Configure npm Trusted Publisher for ${context.packageName}, or do one manual npm-authenticated first publish.`);
303
+ }
304
+ steps.push("After dry-run succeeds, rerun the workflow with dry_run=false.");
305
+ if (!context.authOk) {
306
+ steps.push("Local npm login is optional when using Trusted Publishing; use `npm adduser` only for manual fallback publish.");
307
+ }
308
+ return steps;
309
+ }
310
+
311
+ function printReport(report) {
312
+ section("pi-67 npm publish check");
313
+ keyValue("Package", `${report.package.name}@${report.package.version}`);
314
+ keyValue("Distro", report.distro.version || "unknown");
315
+ keyValue("Workflow", report.workflow.ok ? "trusted publishing ready" : report.workflow.message);
316
+ keyValue("npm scope", report.scope.message);
317
+ keyValue("Publish target", report.target.message);
318
+ keyValue("Manifest", report.manifest.release.ok ? "ownership policy ready" : report.manifest.release.message);
319
+ keyValue("Registry", registryLabel(report.registry));
320
+ keyValue("npm auth", report.auth.skipped ? "skipped" : report.auth.ok ? report.auth.username : "not logged in");
321
+ keyValue("Pack", report.pack.skipped ? "skipped" : report.pack.ok ? "passed" : report.pack.message);
322
+ keyValue("Git", report.git?.isRepo ? `${report.git.commit || "unknown"}${report.git.dirty ? " dirty" : ""}` : "not a git repo");
323
+ section("Checks");
324
+ for (const item of report.checks) {
325
+ if (item.ok) pass(item.name);
326
+ else fail(`${item.name}: ${item.message}`);
327
+ }
328
+ for (const item of report.warnings) warn(item);
329
+ section("Next steps");
330
+ for (const item of report.nextSteps) info(item);
331
+ section("Result");
332
+ if (report.status === "blocked") fail(report.blockers.join("; "));
333
+ else if (report.status === "ready_with_notes") warn("publish path is structurally ready; see notes above");
334
+ else pass("publish path ready");
335
+ }
336
+
337
+ function registryLabel(registry) {
338
+ if (registry.skipped) return "skipped";
339
+ if (registry.ok) return `latest ${registry.latestVersion}${registry.outdated ? " (manager older)" : ""}`;
340
+ return registry.message || "unknown";
341
+ }
342
+
343
+ function compactMessage(value) {
344
+ return redactLocalPaths(String(value || ""))
345
+ .split(/\r?\n/)
346
+ .map((line) => line.trim())
347
+ .filter(Boolean)
348
+ .join(" ")
349
+ .slice(0, 240);
350
+ }
351
+
352
+ function redactLocalPaths(value) {
353
+ const homeCandidates = [
354
+ process.env.HOME,
355
+ process.env.USERPROFILE,
356
+ process.env.HOMEDRIVE && process.env.HOMEPATH
357
+ ? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
358
+ : "",
359
+ ].filter(Boolean);
360
+ let output = value;
361
+ for (const home of homeCandidates) {
362
+ const escaped = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
363
+ output = output.replace(new RegExp(escaped, "g"), "<home>");
364
+ }
365
+ return output;
366
+ }
@@ -0,0 +1,20 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { runDistroScript } from "../lib/distro-scripts.mjs";
3
+ import { isWindows } from "../lib/platform.mjs";
4
+
5
+ export async function reportCommand(ctx, argv) {
6
+ const { options } = parseCommandOptions(argv, {
7
+ bools: ["dry-run", "no-doctor"],
8
+ strings: ["operation", "output"],
9
+ });
10
+ const args = isWindows()
11
+ ? ["-AgentDir", ctx.agentDir, "-RepoRoot", ctx.repoRoot, "-SkillsDir", ctx.skillsDir]
12
+ : ["--agent-dir", ctx.agentDir, "--repo-root", ctx.repoRoot, "--skills-dir", ctx.skillsDir];
13
+ if (options.operation) args.push(isWindows() ? "-Operation" : "--operation", options.operation);
14
+ if (options.output) args.push(isWindows() ? "-Output" : "--output", options.output);
15
+ if (options.noDoctor) args.push(isWindows() ? "-NoDoctor" : "--no-doctor");
16
+ if (ctx.dryRun || options.dryRun) args.push(isWindows() ? "-DryRun" : "--dry-run");
17
+ runDistroScript(ctx, { sh: "pi67-report.sh", ps1: "pi67-report.ps1" }, args, {
18
+ dryRun: false,
19
+ });
20
+ }
@@ -0,0 +1,16 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { readCliPackageJson } from "../lib/paths.mjs";
3
+ import { runCommand } from "../lib/shell-runner.mjs";
4
+ import { info, pass } from "../lib/output.mjs";
5
+
6
+ export async function selfUpdateCommand(ctx, argv) {
7
+ const { options } = parseCommandOptions(argv, { bools: ["dry-run"] });
8
+ const pkg = readCliPackageJson();
9
+ const dryRun = ctx.dryRun || options.dryRun;
10
+ runCommand("npm", ["install", "-g", `${pkg.name}@latest`], { dryRun });
11
+ if (dryRun) {
12
+ info(`DRY-RUN would update ${pkg.name} globally via npm`);
13
+ return;
14
+ }
15
+ pass(`${pkg.name} manager updated. Run \`pi-67 version\` to verify the active PATH entry.`);
16
+ }
@@ -0,0 +1,49 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { inventorySkills, syncSkills } from "../lib/skill-policy.mjs";
3
+ import { CliError, info, keyValue, printJson, section, warn } from "../lib/output.mjs";
4
+
5
+ export async function skillsCommand(ctx, argv) {
6
+ const [sub = "inventory", ...rest] = argv;
7
+ if (sub === "inventory") return inventory(ctx, rest);
8
+ if (sub === "sync") return sync(ctx, rest);
9
+ if (sub === "migrate") return migrate(ctx, rest);
10
+ throw new CliError(`unknown skills command: ${sub}`, 2);
11
+ }
12
+
13
+ function inventory(ctx, argv) {
14
+ const { options } = parseCommandOptions(argv, { bools: ["json"] });
15
+ const data = inventorySkills(ctx);
16
+ if (ctx.json || options.json) return printJson(data);
17
+ section("pi-67 shared skills inventory");
18
+ keyValue("Source", data.sourceRoot);
19
+ keyValue("Target", data.skillsDir);
20
+ keyValue("Identical", data.summary.identical);
21
+ keyValue("Missing", data.summary.missing);
22
+ keyValue("Conflicts", data.summary.conflicts);
23
+ for (const entry of data.entries.filter((item) => !item.identical)) {
24
+ const status = entry.conflict ? "conflict" : "missing";
25
+ info(`${status}: ${entry.name}`);
26
+ }
27
+ }
28
+
29
+ function sync(ctx, argv) {
30
+ const { options } = parseCommandOptions(argv, { bools: ["json", "dry-run"] });
31
+ const data = syncSkills(ctx, { dryRun: ctx.dryRun || options.dryRun });
32
+ if (ctx.json || options.json) return printJson(data);
33
+ section("pi-67 shared skills sync");
34
+ for (const action of data.actions) {
35
+ if (action.action === "warn") warn(`${action.name}: ${action.reason}`);
36
+ else info(`${action.name}: ${action.action}`);
37
+ }
38
+ }
39
+
40
+ function migrate(ctx, argv) {
41
+ const { options } = parseCommandOptions(argv, { bools: ["dry-run", "json"] });
42
+ const data = syncSkills(ctx, { dryRun: true });
43
+ data.schema = "pi67.skills-migrate-preview.v1";
44
+ if (ctx.json || options.json) return printJson(data);
45
+ section("pi-67 skills migrate preview");
46
+ warn("migrate is intentionally dry-run in the npm manager; use `pi-67 skills sync` to copy missing skills.");
47
+ keyValue("Missing", data.summary.missing);
48
+ keyValue("Conflicts", data.summary.conflicts);
49
+ }
@@ -0,0 +1,18 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { runDistroScript } from "../lib/distro-scripts.mjs";
3
+ import { isWindows } from "../lib/platform.mjs";
4
+
5
+ export async function smokeCommand(ctx, argv) {
6
+ const { options } = parseCommandOptions(argv, {
7
+ bools: ["ci", "quick", "dry-run"],
8
+ });
9
+ const args = [];
10
+ if (isWindows()) {
11
+ if (options.ci || options.quick) args.push("-Ci");
12
+ } else if (options.ci || options.quick) {
13
+ args.push("--ci");
14
+ }
15
+ runDistroScript(ctx, { sh: "pi67-smoke.sh", ps1: "pi67-smoke.ps1" }, args, {
16
+ dryRun: ctx.dryRun || options.dryRun,
17
+ });
18
+ }
@@ -0,0 +1,21 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { buildUpdatePlan } from "../lib/update-plan.mjs";
3
+ import { printJson, section, keyValue } from "../lib/output.mjs";
4
+
5
+ export async function statusCommand(ctx, argv) {
6
+ const { options } = parseCommandOptions(argv, {
7
+ bools: ["json", "no-remote"],
8
+ });
9
+ const plan = buildUpdatePlan(ctx, { noRemote: ctx.noRemote || options.noRemote });
10
+ if (ctx.json || options.json) {
11
+ printJson(plan);
12
+ return;
13
+ }
14
+ section("pi-67 status");
15
+ keyValue("Distro", plan.distro.version || "unknown");
16
+ keyValue("Git", plan.git?.isRepo ? `${plan.git.branchLine || plan.git.branch || ""} ${plan.git.commit || ""}` : "not a git repo");
17
+ keyValue("Provider", plan.settings.defaultProvider || "unset");
18
+ keyValue("Model", plan.settings.defaultModel || "unset");
19
+ keyValue("Theme", plan.settings.theme || "unset");
20
+ keyValue("Shared skills", `${plan.skills.identical} ok, ${plan.skills.missing} missing, ${plan.skills.conflicts} conflicts`);
21
+ }
@@ -0,0 +1,69 @@
1
+ import path from "node:path";
2
+ import { parseCommandOptions } from "../lib/args.mjs";
3
+ import { currentTheme, hasTheme, listThemes } from "../lib/theme-policy.mjs";
4
+ import { readJsonFileIfExists, writeJsonAtomic } from "../lib/config-json.mjs";
5
+ import { createRuntimeBackup } from "../lib/update-safety.mjs";
6
+ import { CliError, keyValue, printJson, section, pass, fail, info } from "../lib/output.mjs";
7
+
8
+ export async function themesCommand(ctx, argv) {
9
+ const [sub = "current", ...rest] = argv;
10
+ if (sub === "current") return current(ctx, rest);
11
+ if (sub === "list") return list(ctx, rest);
12
+ if (sub === "doctor") return doctor(ctx, rest);
13
+ if (sub === "set") return setTheme(ctx, rest);
14
+ throw new CliError(`unknown themes command: ${sub}`, 2);
15
+ }
16
+
17
+ function current(ctx, argv) {
18
+ const { options } = parseCommandOptions(argv, { bools: ["json"] });
19
+ const value = currentTheme(ctx);
20
+ const data = {
21
+ schema: "pi67.theme-current.v1",
22
+ theme: value,
23
+ installed: value ? hasTheme(ctx, value) : false,
24
+ };
25
+ if (ctx.json || options.json) return printJson(data);
26
+ keyValue("Current theme", value || "unset");
27
+ keyValue("Installed", data.installed ? "yes" : "no");
28
+ }
29
+
30
+ function list(ctx, argv) {
31
+ const { options } = parseCommandOptions(argv, { bools: ["json"] });
32
+ const themes = listThemes(ctx);
33
+ if (ctx.json || options.json) return printJson({ schema: "pi67.theme-list.v1", themes });
34
+ section("Available themes");
35
+ for (const theme of themes) info(theme);
36
+ }
37
+
38
+ function doctor(ctx, argv) {
39
+ const { options } = parseCommandOptions(argv, { bools: ["json"] });
40
+ const theme = currentTheme(ctx);
41
+ const installed = theme ? hasTheme(ctx, theme) : false;
42
+ const data = { schema: "pi67.theme-doctor.v1", theme, installed };
43
+ if (ctx.json || options.json) return printJson(data);
44
+ if (!theme) fail("settings.json theme field is unset");
45
+ else if (installed) pass(`theme exists: ${theme}`);
46
+ else fail(`theme is configured but missing from installed theme packages: ${theme}`);
47
+ }
48
+
49
+ function setTheme(ctx, argv) {
50
+ const { options, positionals } = parseCommandOptions(argv, { bools: ["force", "dry-run"] });
51
+ const name = positionals[0];
52
+ if (!name) throw new CliError("themes set requires a theme name", 2);
53
+ if (!options.force && !hasTheme(ctx, name)) {
54
+ throw new CliError(`theme not installed: ${name}`);
55
+ }
56
+ const settingsFile = path.join(ctx.agentDir, "settings.json");
57
+ const settings = readJsonFileIfExists(settingsFile) || {};
58
+ const previous = settings.theme || "";
59
+ settings.theme = name;
60
+ if (ctx.dryRun || options.dryRun) {
61
+ info(`DRY-RUN set theme ${previous || "unset"} -> ${name}`);
62
+ return;
63
+ }
64
+ const backupDir = path.join(ctx.stateDir, "backups", `${new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z")}-themes-set`);
65
+ createRuntimeBackup(ctx, backupDir, { operation: "themes-set" });
66
+ writeJsonAtomic(settingsFile, settings);
67
+ pass(`theme set: ${previous || "unset"} -> ${name}`);
68
+ info(`Preserved runtime backup: ${backupDir}`);
69
+ }