@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,138 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { buildUpdatePlan } from "../lib/update-plan.mjs";
3
+ import { printJson, section, keyValue, pass, warn, info } from "../lib/output.mjs";
4
+ import { runDistroScript } from "../lib/distro-scripts.mjs";
5
+ import { isWindows } from "../lib/platform.mjs";
6
+ import { runCommand } from "../lib/shell-runner.mjs";
7
+ import { writeState } from "../lib/state-store.mjs";
8
+ import { beginUpdateLifecycle } from "../lib/update-safety.mjs";
9
+
10
+ export async function updateCommand(ctx, argv) {
11
+ const { options } = parseCommandOptions(argv, {
12
+ bools: [
13
+ "check",
14
+ "repair",
15
+ "dry-run",
16
+ "json",
17
+ "no-remote",
18
+ "no-npm",
19
+ "allow-dirty",
20
+ "include-pi",
21
+ "include-external",
22
+ "all",
23
+ "yes",
24
+ "strict-shared-skills",
25
+ ],
26
+ });
27
+ const dryRun = ctx.dryRun || options.dryRun;
28
+ const json = ctx.json || options.json;
29
+ if (options.check) {
30
+ const plan = buildUpdatePlan(ctx, {
31
+ noRemote: ctx.noRemote || options.noRemote,
32
+ strictSharedSkills: options.strictSharedSkills,
33
+ });
34
+ if (json) {
35
+ printJson(plan);
36
+ return;
37
+ }
38
+ printPlan(plan);
39
+ return;
40
+ }
41
+
42
+ const plan = buildUpdatePlan(ctx, {
43
+ noRemote: ctx.noRemote || options.noRemote,
44
+ strictSharedSkills: options.strictSharedSkills,
45
+ });
46
+ const lifecycle = beginUpdateLifecycle(ctx, {
47
+ operation: options.repair ? "repair" : "update",
48
+ dryRun,
49
+ plan,
50
+ });
51
+ if (!dryRun && lifecycle.backedUp.length > 0) {
52
+ info(`Preserved runtime backup: ${lifecycle.backupDir}`);
53
+ }
54
+
55
+ const args = isWindows()
56
+ ? buildWindowsUpdateArgs(ctx, options, dryRun)
57
+ : buildBashUpdateArgs(ctx, options, dryRun);
58
+ try {
59
+ runDistroScript(ctx, { sh: "pi67-update.sh", ps1: "pi67-update.ps1" }, args, { dryRun: false });
60
+ if (!dryRun) writeState(ctx, options.repair ? "repair" : "update");
61
+ } finally {
62
+ lifecycle.release();
63
+ }
64
+
65
+ if (options.includePi || options.all) {
66
+ if (ctx.yes || options.yes) {
67
+ runCommand("pi", ["update", "--all"], { dryRun });
68
+ } else {
69
+ warn("Pi upstream update skipped; rerun with --include-pi --yes if you really want `pi update --all`.");
70
+ }
71
+ }
72
+ if (options.includeExternal || options.all) {
73
+ warn("External repo updates are explicit; run `pi-67 external update browser67` or `pi-67 external update design-craft`.");
74
+ }
75
+ }
76
+
77
+ function buildBashUpdateArgs(ctx, options, dryRun) {
78
+ const args = ["--agent-dir", ctx.agentDir, "--repo-root", ctx.repoRoot, "--skills-dir", ctx.skillsDir];
79
+ if (dryRun) args.push("--dry-run");
80
+ if (options.repair) args.push("--force-npm");
81
+ if (options.noNpm) args.push("--no-npm");
82
+ if (options.allowDirty) args.push("--allow-dirty");
83
+ if (options.strictSharedSkills) args.push("--strict-shared-skills");
84
+ return args;
85
+ }
86
+
87
+ function buildWindowsUpdateArgs(ctx, options, dryRun) {
88
+ const args = ["-AgentDir", ctx.agentDir, "-RepoRoot", ctx.repoRoot, "-SkillsDir", ctx.skillsDir];
89
+ if (dryRun) args.push("-DryRun");
90
+ if (options.repair) args.push("-ForceNpm");
91
+ if (options.noNpm) args.push("-NoNpm");
92
+ if (options.allowDirty) args.push("-AllowDirty");
93
+ if (options.strictSharedSkills) args.push("-StrictSharedSkills");
94
+ return args;
95
+ }
96
+
97
+ function printPlan(plan) {
98
+ section("pi-67 update check");
99
+ keyValue("Agent dir", plan.paths.agentDir);
100
+ keyValue("Repo root", plan.paths.repoRoot);
101
+ keyValue("Manager", `${plan.manager.package}@${plan.manager.version}`);
102
+ if (plan.manager.registry?.skipped) {
103
+ keyValue("Manager latest", "skipped");
104
+ } else if (plan.manager.registry?.ok) {
105
+ keyValue("Manager latest", `${plan.manager.registry.latestVersion}${plan.manager.registry.outdated ? " update available" : ""}`);
106
+ } else if (plan.manager.registry?.message) {
107
+ keyValue("Manager latest", `unknown (${plan.manager.registry.message})`);
108
+ }
109
+ keyValue("Distro", plan.distro.version || "unknown");
110
+ keyValue("Git", plan.git?.isRepo ? `${plan.git.branchLine || plan.git.branch || ""} ${plan.git.commit || ""}` : "not a git repo");
111
+ keyValue("Dirty", plan.git?.dirty ? "yes" : "no");
112
+ keyValue("Remote", plan.remote?.skipped ? "skipped" : (plan.remote?.commit || plan.remote?.message || "unknown"));
113
+ keyValue("Provider", plan.settings.defaultProvider || "unset");
114
+ keyValue("Model", plan.settings.defaultModel || "unset");
115
+ keyValue("Theme", plan.settings.theme || "unset");
116
+ keyValue("Theme installed", plan.settings.themeInstalled ? "yes" : "no");
117
+ keyValue("Shared skills", `${plan.skills.identical} ok, ${plan.skills.missing} missing, ${plan.skills.conflicts} conflicts`);
118
+ for (const repo of plan.external) {
119
+ keyValue(`External ${repo.name}`, repo.exists ? `${repo.git?.dirty ? "dirty" : "clean"} ${repo.git?.commit || ""}` : "missing");
120
+ }
121
+ if (plan.actions?.length > 0) {
122
+ section("Planned safe actions");
123
+ for (const action of plan.actions) {
124
+ info(`${action.id}: ${action.operation}; writes=${action.writes.join(", ")}; preserves=${action.preserves.join(", ")}`);
125
+ }
126
+ }
127
+ if (plan.blocked?.length > 0) {
128
+ section("Blocked actions");
129
+ for (const item of plan.blocked) warn(`${item.id}: ${item.reason}`);
130
+ }
131
+ if (plan.warnings?.length > 0) {
132
+ section("Warnings");
133
+ for (const item of plan.warnings) warn(item);
134
+ }
135
+ section("Recommendations");
136
+ for (const item of plan.recommendations) info(item);
137
+ if (!plan.git?.dirty) pass("update check completed without local dirty blocker");
138
+ }
@@ -0,0 +1,51 @@
1
+ import { captureCommand } from "../lib/shell-runner.mjs";
2
+ import { gitStatus } from "../lib/git.mjs";
3
+ import { currentTheme } from "../lib/theme-policy.mjs";
4
+ import { keyValue, printJson } from "../lib/output.mjs";
5
+ import { platformName } from "../lib/platform.mjs";
6
+ import { readCliPackageJson, readTextIfExists } from "../lib/paths.mjs";
7
+ import path from "node:path";
8
+
9
+ export async function versionCommand(ctx, argv) {
10
+ const json = ctx.json || argv.includes("--json");
11
+ const pkg = readCliPackageJson();
12
+ const git = gitStatus(ctx.repoRoot);
13
+ const pi = captureCommand("pi", ["--version"]);
14
+ const data = {
15
+ schema: "pi67.version.v1",
16
+ manager: {
17
+ package: pkg.name,
18
+ version: pkg.version,
19
+ },
20
+ distro: {
21
+ version: readTextIfExists(path.join(ctx.repoRoot, "VERSION")).trim(),
22
+ commit: git.commit || "",
23
+ branch: git.branch || "",
24
+ dirty: Boolean(git.dirty),
25
+ },
26
+ runtime: {
27
+ node: process.version,
28
+ platform: platformName(),
29
+ pi: pi.ok ? pi.stdout.trim() || pi.stderr.trim() : "",
30
+ },
31
+ paths: {
32
+ agentDir: ctx.agentDir,
33
+ repoRoot: ctx.repoRoot,
34
+ skillsDir: ctx.skillsDir,
35
+ packagesDir: ctx.packagesDir,
36
+ },
37
+ theme: currentTheme(ctx),
38
+ };
39
+ if (json) {
40
+ printJson(data);
41
+ return;
42
+ }
43
+ keyValue("manager", `${data.manager.package}@${data.manager.version}`);
44
+ keyValue("pi-67 distro", data.distro.version || "unknown");
45
+ keyValue("git", `${data.distro.commit || "unknown"}${data.distro.dirty ? " dirty" : ""}`);
46
+ keyValue("pi", data.runtime.pi || "not found");
47
+ keyValue("node", data.runtime.node);
48
+ keyValue("platform", data.runtime.platform);
49
+ keyValue("agentDir", data.paths.agentDir);
50
+ keyValue("theme", data.theme || "unset");
51
+ }
@@ -0,0 +1,69 @@
1
+ import { parseCommandOptions } from "../lib/args.mjs";
2
+ import { runCommand } from "../lib/shell-runner.mjs";
3
+ import { scriptPath } from "../lib/paths.mjs";
4
+ import { isWindows, findPowerShell } from "../lib/platform.mjs";
5
+ import { CliError } from "../lib/output.mjs";
6
+
7
+ export async function xtalpiCommand(ctx, argv) {
8
+ const [sub = "health", ...rest] = argv;
9
+ if (sub === "health") return health(ctx, rest);
10
+ if (sub === "smoke") return smoke(ctx, rest);
11
+ if (sub === "capability") return capability(ctx, rest);
12
+ throw new CliError(`unknown xtalpi command: ${sub}`, 2);
13
+ }
14
+
15
+ function health(ctx, argv) {
16
+ const { options } = parseCommandOptions(argv, {
17
+ strings: ["model", "provider", "timeout-ms", "attempts"],
18
+ bools: ["dry-run"],
19
+ });
20
+ const args = [scriptPath(ctx, "pi67-xtalpi-provider-health.mjs"), "--agent-dir", ctx.agentDir];
21
+ if (options.provider) args.push("--provider", options.provider);
22
+ if (options.model) args.push("--model", options.model);
23
+ if (options.timeoutMs) args.push("--timeout-ms", options.timeoutMs);
24
+ if (options.attempts) args.push("--attempts", options.attempts);
25
+ runCommand("node", args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
26
+ }
27
+
28
+ function smoke(ctx, argv) {
29
+ const { options } = parseCommandOptions(argv, {
30
+ strings: ["case", "profile"],
31
+ bools: ["quick", "extension-low-risk", "extension-expanded", "self-test", "dry-run"],
32
+ });
33
+ if (isWindows()) {
34
+ const pwsh = findPowerShell();
35
+ if (!pwsh) throw new CliError("PowerShell executable not found");
36
+ const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath(ctx, "pi67-xtalpi-pi-tools-smoke.ps1")];
37
+ if (options.selfTest) args.push("-SelfTest");
38
+ else if (options.case) args.push("-Case", options.case);
39
+ else args.push("-Profile", profileFromOptions(options));
40
+ runCommand(pwsh, args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
41
+ return;
42
+ }
43
+ const args = [scriptPath(ctx, "pi67-xtalpi-pi-tools-smoke.sh")];
44
+ if (options.selfTest) args.push("--self-test");
45
+ else if (options.case) args.push("--case", options.case);
46
+ else args.push("--profile", profileFromOptions(options));
47
+ runCommand("bash", args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
48
+ }
49
+
50
+ function capability(ctx, argv) {
51
+ const { options } = parseCommandOptions(argv, {
52
+ strings: ["model", "provider", "timeout-ms"],
53
+ bools: ["dry-run", "self-test"],
54
+ });
55
+ const args = [scriptPath(ctx, "pi67-xtalpi-provider-capability-probe.mjs"), "--agent-dir", ctx.agentDir];
56
+ if (options.selfTest) args.push("--self-test");
57
+ if (options.provider) args.push("--provider", options.provider);
58
+ if (options.model) args.push("--model", options.model);
59
+ if (options.timeoutMs) args.push("--timeout-ms", options.timeoutMs);
60
+ runCommand("node", args, { cwd: ctx.repoRoot, dryRun: ctx.dryRun || options.dryRun });
61
+ }
62
+
63
+ function profileFromOptions(options) {
64
+ if (options.profile) return options.profile;
65
+ if (options.extensionLowRisk) return "extension-low-risk";
66
+ if (options.extensionExpanded) return "extension-expanded";
67
+ if (options.quick) return "quick";
68
+ return "quick";
69
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "schema": "pi67.distro-manifest.base.v1",
3
+ "ownership": {
4
+ "pi67Managed": "Installed, repaired, and smoked by pi-67; updates must preserve local user runtime choices.",
5
+ "userManaged": "Reported only; pi-67 must not remove, downgrade, or overwrite without an explicit user command."
6
+ },
7
+ "commands": {
8
+ "update": "pi-67 update",
9
+ "repair": "pi-67 update --repair",
10
+ "alwaysFreshRepair": "npx -y @bigking67/pi-67@latest update --repair",
11
+ "upstreamPiExtensions": "pi update --extensions"
12
+ },
13
+ "runtimeFiles": {
14
+ "preserve": [
15
+ "settings.json",
16
+ "models.json",
17
+ "auth.json",
18
+ "mcp.json",
19
+ "image-gen.json",
20
+ "settings.json.theme"
21
+ ],
22
+ "templateOnly": [
23
+ "models.example.json",
24
+ "auth.example.json",
25
+ "mcp.example.json",
26
+ "image-gen.example.json"
27
+ ]
28
+ },
29
+ "theme": {
30
+ "packageName": "@victor-software-house/pi-curated-themes",
31
+ "preserveSetting": "settings.json:theme",
32
+ "selection": {
33
+ "file": "settings.json",
34
+ "path": "theme"
35
+ },
36
+ "legacyPreserveFiles": [
37
+ "settings.json.theme"
38
+ ],
39
+ "policy": "install-theme-package-only-never-select-theme-on-update",
40
+ "explicitCommands": [
41
+ "pi-67 themes current",
42
+ "pi-67 themes list",
43
+ "pi-67 themes set <name>"
44
+ ]
45
+ },
46
+ "sharedSkills": {
47
+ "sourceDir": "shared-skills",
48
+ "activeDirDefault": "~/.agents/skills",
49
+ "policy": "copy-by-default-preserve-different-existing-skills-unless-strict"
50
+ },
51
+ "localExtensions": [
52
+ {
53
+ "name": "xtalpi-pi-tools",
54
+ "path": "extensions/xtalpi-pi-tools",
55
+ "required": true,
56
+ "policy": "pi-local-json-action-provider"
57
+ },
58
+ {
59
+ "name": "pi-rules-loader",
60
+ "path": "extensions/pi-rules-loader",
61
+ "required": true,
62
+ "policy": "local-rules-index-extension"
63
+ }
64
+ ],
65
+ "runtimePackageSpecs": [
66
+ {
67
+ "spec": "git:github.com/justhil/pi-image-gen",
68
+ "packageName": "pi-image-gen",
69
+ "owner": "pi67-managed",
70
+ "policy": "runtime-package-preserve-user-config"
71
+ },
72
+ {
73
+ "spec": "git:github.com/arpagon/pi-rewind",
74
+ "packageName": "pi-rewind",
75
+ "owner": "pi67-managed",
76
+ "policy": "runtime-package-preserve-user-config"
77
+ }
78
+ ],
79
+ "externalReposPolicy": {
80
+ "defaultRoot": "~/.agents/packages",
81
+ "dirtyRepo": "preserve-and-block-update",
82
+ "detachedRepo": "preserve-and-block-update",
83
+ "explicitCommand": "pi-67 external update <name>"
84
+ }
85
+ }
@@ -0,0 +1,147 @@
1
+ {
2
+ "schema": "pi67.extension-registry.v1",
3
+ "governance": {
4
+ "defaultUserConfigPolicy": "preserve-and-merge-only",
5
+ "forbiddenUpdateBehavior": [
6
+ "overwrite-user-config",
7
+ "select-theme-during-update",
8
+ "overwrite-different-shared-skill",
9
+ "update-dirty-external-repo",
10
+ "fallback-to-upstream-pi-extension-update"
11
+ ],
12
+ "configPatchModes": [
13
+ "merge-preserve",
14
+ "template-only",
15
+ "report-only"
16
+ ]
17
+ },
18
+ "extensions": [
19
+ {
20
+ "id": "xtalpi-pi-tools",
21
+ "kind": "local-provider",
22
+ "owner": "pi67-managed",
23
+ "path": "extensions/xtalpi-pi-tools",
24
+ "installStrategy": "package-owned-clean-overwrite",
25
+ "updateStrategy": "update-owned-code-preserve-user-config",
26
+ "repairStrategy": "local-json-action-protocol-repair",
27
+ "configPatches": [
28
+ {
29
+ "file": "models.json",
30
+ "path": "providers.xtalpi-pi-tools",
31
+ "mode": "merge-preserve"
32
+ },
33
+ {
34
+ "file": "settings.json",
35
+ "path": "defaultProvider",
36
+ "mode": "merge-preserve"
37
+ }
38
+ ],
39
+ "smoke": [
40
+ "pi-67 xtalpi health",
41
+ "pi-67 xtalpi smoke --quick"
42
+ ],
43
+ "notes": "xtalpi is treated as plain chat-completions transport; Pi local adapter owns JSON action protocol, repair, validation, and smoke gates."
44
+ },
45
+ {
46
+ "id": "pi-rules-loader",
47
+ "kind": "local-extension",
48
+ "owner": "pi67-managed",
49
+ "path": "extensions/pi-rules-loader",
50
+ "installStrategy": "package-owned-clean-overwrite",
51
+ "updateStrategy": "update-owned-code-preserve-user-config",
52
+ "repairStrategy": "rules-index-refresh",
53
+ "configPatches": [],
54
+ "smoke": [
55
+ "pi-67 manifest"
56
+ ],
57
+ "notes": "Rules index extension is package-owned; user AGENTS/settings remain user-owned."
58
+ },
59
+ {
60
+ "id": "pi-curated-themes",
61
+ "kind": "theme-package",
62
+ "owner": "pi67-managed",
63
+ "packageName": "@victor-software-house/pi-curated-themes",
64
+ "installStrategy": "install-theme-package",
65
+ "updateStrategy": "install-theme-package-only-never-select-theme-on-update",
66
+ "repairStrategy": "verify-theme-assets-without-changing-selection",
67
+ "configPatches": [
68
+ {
69
+ "file": "settings.json",
70
+ "path": "theme",
71
+ "mode": "report-only"
72
+ }
73
+ ],
74
+ "explicitCommands": [
75
+ "pi-67 themes current",
76
+ "pi-67 themes list",
77
+ "pi-67 themes doctor",
78
+ "pi-67 themes set <name>"
79
+ ],
80
+ "smoke": [
81
+ "pi-67 themes current",
82
+ "pi-67 themes doctor"
83
+ ],
84
+ "notes": "Update may install theme assets but must never change the selected theme."
85
+ },
86
+ {
87
+ "id": "shared-skills",
88
+ "kind": "skill-pack",
89
+ "owner": "pi67-managed",
90
+ "path": "shared-skills",
91
+ "target": "~/.agents/skills",
92
+ "installStrategy": "copy-missing-skills",
93
+ "updateStrategy": "copy-by-default-preserve-different-existing-skills-unless-strict",
94
+ "repairStrategy": "inventory-and-copy-missing-only",
95
+ "configPatches": [],
96
+ "explicitCommands": [
97
+ "pi-67 skills inventory",
98
+ "pi-67 skills sync",
99
+ "pi-67 skills migrate"
100
+ ],
101
+ "smoke": [
102
+ "pi-67 skills inventory"
103
+ ],
104
+ "notes": "Different existing shared skills are user-owned by default; strict mode blocks instead of overwriting."
105
+ },
106
+ {
107
+ "id": "browser67",
108
+ "kind": "external-repo",
109
+ "owner": "external",
110
+ "repoUrl": "https://github.com/bigKING67/browser67.git",
111
+ "target": "~/.agents/packages/browser67",
112
+ "installStrategy": "explicit-external-install",
113
+ "updateStrategy": "preserve-and-block-update-when-dirty",
114
+ "repairStrategy": "doctor-only-when-dirty",
115
+ "configPatches": [],
116
+ "explicitCommands": [
117
+ "pi-67 external list",
118
+ "pi-67 external doctor",
119
+ "pi-67 external update browser67"
120
+ ],
121
+ "smoke": [
122
+ "pi-67 external list"
123
+ ],
124
+ "notes": "External repo updates are explicit and must not reset, clean, or overwrite dirty worktrees."
125
+ },
126
+ {
127
+ "id": "design-craft",
128
+ "kind": "external-repo",
129
+ "owner": "external",
130
+ "repoUrl": "https://github.com/bigKING67/design-craft",
131
+ "target": "~/.agents/packages/design-craft",
132
+ "installStrategy": "explicit-external-install",
133
+ "updateStrategy": "preserve-and-block-update-when-dirty",
134
+ "repairStrategy": "doctor-only-when-dirty",
135
+ "configPatches": [],
136
+ "explicitCommands": [
137
+ "pi-67 external list",
138
+ "pi-67 external doctor",
139
+ "pi-67 external update design-craft"
140
+ ],
141
+ "smoke": [
142
+ "pi-67 external list"
143
+ ],
144
+ "notes": "Frontend skill repo is external; pi-67 reports dirty state and blocks destructive updates."
145
+ }
146
+ ]
147
+ }
@@ -0,0 +1,87 @@
1
+ import { CliError } from "./output.mjs";
2
+
3
+ const VALUE_GLOBALS = new Set(["agent-dir", "repo-root", "skills-dir", "packages-dir"]);
4
+ const BOOL_GLOBALS = new Set(["json", "dry-run", "yes", "help", "no-remote"]);
5
+
6
+ export function splitGlobalArgs(argv) {
7
+ const globals = {};
8
+ const rest = [];
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const arg = argv[index];
11
+ if (arg === "-h") {
12
+ globals.help = true;
13
+ continue;
14
+ }
15
+ if (!arg.startsWith("--")) {
16
+ rest.push(arg);
17
+ continue;
18
+ }
19
+
20
+ const raw = arg.slice(2);
21
+ const eq = raw.indexOf("=");
22
+ const name = eq === -1 ? raw : raw.slice(0, eq);
23
+ const inlineValue = eq === -1 ? undefined : raw.slice(eq + 1);
24
+
25
+ if (VALUE_GLOBALS.has(name)) {
26
+ const value = inlineValue ?? argv[index + 1];
27
+ if (!value || value.startsWith("--")) {
28
+ throw new CliError(`--${name} requires a value`, 2);
29
+ }
30
+ globals[toCamel(name)] = value;
31
+ if (inlineValue === undefined) index += 1;
32
+ continue;
33
+ }
34
+ if (BOOL_GLOBALS.has(name)) {
35
+ globals[toCamel(name)] = true;
36
+ continue;
37
+ }
38
+ rest.push(arg);
39
+ }
40
+ return { globals, rest };
41
+ }
42
+
43
+ export function parseCommandOptions(argv, spec = {}) {
44
+ const strings = new Set(spec.strings || []);
45
+ const bools = new Set(spec.bools || []);
46
+ const positionals = [];
47
+ const options = {};
48
+
49
+ for (let index = 0; index < argv.length; index += 1) {
50
+ const arg = argv[index];
51
+ if (arg === "-h") {
52
+ options.help = true;
53
+ continue;
54
+ }
55
+ if (!arg.startsWith("--")) {
56
+ positionals.push(arg);
57
+ continue;
58
+ }
59
+
60
+ const raw = arg.slice(2);
61
+ const eq = raw.indexOf("=");
62
+ const name = eq === -1 ? raw : raw.slice(0, eq);
63
+ const key = toCamel(name);
64
+ const inlineValue = eq === -1 ? undefined : raw.slice(eq + 1);
65
+
66
+ if (strings.has(name)) {
67
+ const value = inlineValue ?? argv[index + 1];
68
+ if (!value || value.startsWith("--")) {
69
+ throw new CliError(`--${name} requires a value`, 2);
70
+ }
71
+ options[key] = value;
72
+ if (inlineValue === undefined) index += 1;
73
+ continue;
74
+ }
75
+ if (bools.has(name)) {
76
+ options[key] = true;
77
+ continue;
78
+ }
79
+ throw new CliError(`unknown option: --${name}`, 2);
80
+ }
81
+
82
+ return { options, positionals };
83
+ }
84
+
85
+ export function toCamel(name) {
86
+ return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
87
+ }
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export function readJsonFile(file) {
5
+ const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
6
+ return JSON.parse(raw);
7
+ }
8
+
9
+ export function readJsonFileIfExists(file) {
10
+ if (!fs.existsSync(file)) return null;
11
+ return readJsonFile(file);
12
+ }
13
+
14
+ export function writeJsonAtomic(file, value) {
15
+ const dir = path.dirname(file);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.tmp`);
18
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
19
+ fs.renameSync(tmp, file);
20
+ }