@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.
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { CliError } from "./output.mjs";
4
5
 
5
6
  export function inventorySkills(ctx) {
6
7
  const sourceRoot = path.join(ctx.repoRoot, "shared-skills");
@@ -29,17 +30,105 @@ export function inventorySkills(ctx) {
29
30
  };
30
31
  }
31
32
 
32
- export function syncSkills(ctx, { dryRun = false } = {}) {
33
+ export function diffSkill(ctx, name) {
33
34
  const inventory = inventorySkills(ctx);
35
+ const entry = inventory.entries.find((item) => item.name === name);
36
+ if (!entry) {
37
+ throw new CliError(`unknown shared skill: ${name}`, 2);
38
+ }
39
+ const sourceFiles = fileManifest(entry.source);
40
+ const targetFiles = entry.targetExists ? fileManifest(entry.target) : new Map();
41
+ const sourceNames = new Set(sourceFiles.keys());
42
+ const targetNames = new Set(targetFiles.keys());
43
+ const added = [...sourceNames].filter((rel) => !targetNames.has(rel)).sort();
44
+ const removed = [...targetNames].filter((rel) => !sourceNames.has(rel)).sort();
45
+ const modified = [...sourceNames]
46
+ .filter((rel) => targetNames.has(rel) && sourceFiles.get(rel).sha256 !== targetFiles.get(rel).sha256)
47
+ .sort();
48
+ return {
49
+ schema: "pi67.skills-diff.v1",
50
+ name,
51
+ source: entry.source,
52
+ target: entry.target,
53
+ sourceHash: entry.sourceHash,
54
+ targetExists: entry.targetExists,
55
+ targetHash: entry.targetHash,
56
+ identical: entry.identical,
57
+ conflict: entry.conflict,
58
+ diff: {
59
+ added,
60
+ removed,
61
+ modified,
62
+ sourceFileCount: sourceFiles.size,
63
+ targetFileCount: targetFiles.size,
64
+ },
65
+ };
66
+ }
67
+
68
+ export function planSkills(ctx, { names = [] } = {}) {
69
+ const inventory = inventorySkills(ctx);
70
+ const selected = normalizeNames(names, inventory);
71
+ const entries = selected.length === 0
72
+ ? inventory.entries.filter((entry) => !entry.identical)
73
+ : inventory.entries.filter((entry) => selected.includes(entry.name) && !entry.identical);
74
+ const actions = entries.map((entry) => ({
75
+ name: entry.name,
76
+ source: entry.source,
77
+ target: entry.target,
78
+ targetExists: entry.targetExists,
79
+ conflict: entry.conflict,
80
+ action: entry.conflict ? "preserve-conflict" : "copy-missing",
81
+ reason: entry.conflict
82
+ ? "target differs; default update preserves the existing global skill"
83
+ : "target is missing and can be copied safely",
84
+ }));
85
+ return {
86
+ schema: "pi67.skills-plan.v1",
87
+ sourceRoot: inventory.sourceRoot,
88
+ skillsDir: inventory.skillsDir,
89
+ selected,
90
+ summary: inventory.summary,
91
+ actions,
92
+ };
93
+ }
94
+
95
+ export function syncSkills(ctx, { dryRun = false, names = [], yes = false } = {}) {
96
+ const inventory = inventorySkills(ctx);
97
+ const selected = normalizeNames(names, inventory);
98
+ const selectedSet = new Set(selected);
99
+ const targeted = selected.length > 0;
34
100
  const actions = [];
35
101
  fs.mkdirSync(ctx.skillsDir, { recursive: true });
36
102
  for (const entry of inventory.entries) {
103
+ if (targeted && !selectedSet.has(entry.name)) continue;
37
104
  if (entry.identical) {
38
105
  actions.push({ name: entry.name, action: "skip", reason: "identical" });
39
106
  continue;
40
107
  }
41
108
  if (entry.conflict) {
42
- actions.push({ name: entry.name, action: "warn", reason: "target differs; preserved" });
109
+ if (!targeted || !yes) {
110
+ actions.push({
111
+ name: entry.name,
112
+ action: "warn",
113
+ reason: targeted
114
+ ? "target differs; rerun with --yes to replace this explicitly named skill after backup"
115
+ : "target differs; bulk conflict overwrite is intentionally blocked",
116
+ });
117
+ continue;
118
+ }
119
+ const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-skills-sync`, entry.name);
120
+ actions.push({
121
+ name: entry.name,
122
+ action: dryRun ? "replace-dry-run" : "replace",
123
+ reason: "target differs and was explicitly named with --yes",
124
+ backupDir,
125
+ });
126
+ if (!dryRun) {
127
+ fs.mkdirSync(path.dirname(backupDir), { recursive: true, mode: 0o700 });
128
+ fs.cpSync(entry.target, backupDir, { recursive: true, force: true });
129
+ fs.rmSync(entry.target, { recursive: true, force: true });
130
+ fs.cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true });
131
+ }
43
132
  continue;
44
133
  }
45
134
  actions.push({ name: entry.name, action: dryRun ? "copy-dry-run" : "copy", reason: "missing" });
@@ -47,7 +136,7 @@ export function syncSkills(ctx, { dryRun = false } = {}) {
47
136
  fs.cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true });
48
137
  }
49
138
  }
50
- return { ...inventory, actions };
139
+ return { ...inventory, selected, actions };
51
140
  }
52
141
 
53
142
  function listSkillDirs(root) {
@@ -73,6 +162,44 @@ function hashDir(root) {
73
162
  return hash.digest("hex");
74
163
  }
75
164
 
165
+ function fileManifest(root) {
166
+ const result = new Map();
167
+ if (!fs.existsSync(root)) return result;
168
+ const files = [];
169
+ walk(root, files);
170
+ for (const file of files.sort()) {
171
+ const rel = path.relative(root, file).replace(/\\/g, "/");
172
+ const stat = fs.statSync(file);
173
+ result.set(rel, {
174
+ path: rel,
175
+ bytes: stat.size,
176
+ sha256: sha256File(file),
177
+ });
178
+ }
179
+ return result;
180
+ }
181
+
182
+ function sha256File(file) {
183
+ const hash = crypto.createHash("sha256");
184
+ hash.update(fs.readFileSync(file));
185
+ return hash.digest("hex");
186
+ }
187
+
188
+ function normalizeNames(names, inventory) {
189
+ const selected = [...new Set((names || []).filter(Boolean).map(String))];
190
+ const known = new Set(inventory.entries.map((entry) => entry.name));
191
+ for (const name of selected) {
192
+ if (!known.has(name)) {
193
+ throw new CliError(`unknown shared skill: ${name}`, 2);
194
+ }
195
+ }
196
+ return selected;
197
+ }
198
+
199
+ function timestamp() {
200
+ return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
201
+ }
202
+
76
203
  function walk(dir, files) {
77
204
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
78
205
  const full = path.join(dir, entry.name);
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { gitStatus, remoteHead } from "./git.mjs";
3
+ import { gitStatus, gitText, remoteHead } from "./git.mjs";
4
4
  import { currentTheme, hasTheme, listThemes } from "./theme-policy.mjs";
5
5
  import { readJsonFileIfExists } from "./config-json.mjs";
6
6
  import { listExternal } from "./external-repos.mjs";
@@ -39,6 +39,8 @@ export async function buildUpdatePlan(ctx, options = {}) {
39
39
  currentVersion: pkg.version,
40
40
  noRemote: options.noRemote,
41
41
  });
42
+ const dirtyClass = classifyGitShort(git?.short || "");
43
+ const benignRuntime = classifyBenignRuntimeDiff(ctx, dirtyClass.preservedRuntime);
42
44
 
43
45
  const recommendations = [];
44
46
  if (managerRegistry.outdated) {
@@ -49,8 +51,13 @@ export async function buildUpdatePlan(ctx, options = {}) {
49
51
  recommendations.push("Run: pi-67 install");
50
52
  } else if (!git?.isRepo) {
51
53
  recommendations.push("Agent dir exists but is not a git checkout; inspect before installing.");
52
- } else if (git.dirty) {
54
+ } else if (git.dirty && dirtyClass.unsafeTracked.length > 0) {
53
55
  recommendations.push("Resolve or commit local changes before pi-67 update.");
56
+ } else if (git.dirty && benignRuntime.benign) {
57
+ recommendations.push("No manual action required for benign settings runtime markers; pi-67 can preserve them during update.");
58
+ recommendations.push("Optional: normalize local settings runtime marker if you want a clean git status.");
59
+ } else if (git.dirty) {
60
+ recommendations.push("No manual action required for user runtime config; pi-67 backs up/restores it during update.");
54
61
  } else {
55
62
  recommendations.push("Run: pi-67 update");
56
63
  }
@@ -66,6 +73,7 @@ export async function buildUpdatePlan(ctx, options = {}) {
66
73
  const decisions = buildPlanDecisions({
67
74
  ctx,
68
75
  git,
76
+ benignRuntime,
69
77
  managerRegistry,
70
78
  manifest,
71
79
  skills,
@@ -129,6 +137,7 @@ export function buildPlanDecisions(context) {
129
137
  ...new Set([...(context.manifest.runtimeFiles?.preserve || []), ...PRESERVED_RUNTIME_FILES]),
130
138
  ];
131
139
  const dirty = classifyGitShort(context.git?.short || "");
140
+ const benignRuntime = context.benignRuntime || { benign: false, reasons: [] };
132
141
 
133
142
  if (context.managerRegistry.outdated) {
134
143
  actions.push({
@@ -162,12 +171,20 @@ export function buildPlanDecisions(context) {
162
171
  id: "user-runtime-config",
163
172
  kind: "runtime-config",
164
173
  operation: "backup-and-restore-during-update",
165
- writes: ["~/.pi/pi67/backups/<timestamp>-update"],
174
+ writes: ["~/.pi/pi67/backups/pre-update-runtime-*"],
166
175
  preserves: dirty.preservedRuntime,
167
176
  risk: "low",
168
- reason: "only user-owned runtime config files are dirty; update snapshots and restores them instead of overwriting",
177
+ reason: benignRuntime.benign
178
+ ? `benign runtime marker only: ${benignRuntime.reasons.join("; ")}`
179
+ : "only user-owned runtime config files are dirty; update snapshots and restores them instead of overwriting",
180
+ benign: benignRuntime.benign,
181
+ benignReasons: benignRuntime.reasons,
169
182
  });
170
- warnings.push(`dirty user runtime config will be preserved across update: ${dirty.preservedRuntime.join(", ")}`);
183
+ warnings.push(
184
+ benignRuntime.benign
185
+ ? `benign user runtime marker will be preserved across update: ${dirty.preservedRuntime.join(", ")}`
186
+ : `dirty user runtime config will be preserved across update: ${dirty.preservedRuntime.join(", ")}`,
187
+ );
171
188
  }
172
189
  if (context.git?.dirty && dirty.untracked.length > 0) {
173
190
  warnings.push(`untracked files are present and will be preserved unless Git reports a path collision: ${dirty.untracked.join(", ")}`);
@@ -298,6 +315,36 @@ export function classifyGitShort(short) {
298
315
  return result;
299
316
  }
300
317
 
318
+ export function classifyBenignRuntimeDiff(ctx, preservedRuntimePaths = []) {
319
+ const paths = [...new Set(preservedRuntimePaths)].sort();
320
+ if (paths.length !== 1 || paths[0] !== "settings.json") {
321
+ return { benign: false, reasons: [] };
322
+ }
323
+ const diff = gitText(ctx.repoRoot, ["diff", "--", "settings.json"]);
324
+ if (!diff) return { benign: false, reasons: [] };
325
+ const changed = diff.split(/\r?\n/).filter((line) =>
326
+ (line.startsWith("+") || line.startsWith("-")) &&
327
+ !line.startsWith("+++") &&
328
+ !line.startsWith("---"));
329
+ const meaningful = changed.filter((line) => {
330
+ const body = line.slice(1).trim();
331
+ return body !== "" && body !== "}";
332
+ });
333
+ const reasons = [];
334
+ const markerOnly = meaningful.length > 0 &&
335
+ meaningful.every((line) => /^[-+]\s*"lastChangelogVersion"\s*:/.test(line));
336
+ if (markerOnly) {
337
+ reasons.push("settings.json lastChangelogVersion runtime marker changed");
338
+ }
339
+ if (changed.some((line) => ["-", "+", "-}", "+}"].includes(line.trim()))) {
340
+ reasons.push("settings.json trailing newline state changed");
341
+ }
342
+ return {
343
+ benign: markerOnly || (meaningful.length === 0 && reasons.length > 0),
344
+ reasons,
345
+ };
346
+ }
347
+
301
348
  function parseStatusPath(line) {
302
349
  let file = "";
303
350
  if (line.length >= 3 && line[2] === " ") {
@@ -18,16 +18,21 @@ const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1000;
18
18
  export function beginUpdateLifecycle(ctx, options = {}) {
19
19
  const operation = options.operation || "update";
20
20
  const dryRun = Boolean(options.dryRun);
21
+ const backupRuntime = options.backupRuntime !== false;
21
22
  const lockPath = path.join(ctx.stateDir, "locks", "update.lock");
22
23
  const backupDir = path.join(ctx.stateDir, "backups", `${timestamp()}-${operation}`);
23
24
 
24
25
  if (dryRun) {
25
26
  info(`DRY-RUN would acquire update lock: ${lockPath}`);
26
- info(`DRY-RUN would snapshot preserved runtime files into: ${backupDir}`);
27
+ if (backupRuntime) {
28
+ info(`DRY-RUN would snapshot preserved runtime files into: ${backupDir}`);
29
+ }
27
30
  return {
28
31
  lockPath,
29
32
  backupDir,
30
33
  backedUp: [],
34
+ backupSkipped: !backupRuntime,
35
+ backupReason: backupRuntime ? "" : "runtime backup is delegated to the updater script when needed",
31
36
  release() {},
32
37
  };
33
38
  }
@@ -35,14 +40,34 @@ export function beginUpdateLifecycle(ctx, options = {}) {
35
40
  acquireLock(lockPath, { operation });
36
41
  let released = false;
37
42
  try {
38
- const backedUp = createRuntimeBackup(ctx, backupDir, {
43
+ if (!backupRuntime) {
44
+ return {
45
+ lockPath,
46
+ backupDir: "",
47
+ backedUp: [],
48
+ backupSkipped: true,
49
+ backupReason: "runtime backup is delegated to the updater script when needed",
50
+ reusedBackupDir: "",
51
+ release() {
52
+ if (released) return;
53
+ released = true;
54
+ releaseLock(lockPath);
55
+ },
56
+ };
57
+ }
58
+ const backup = createRuntimeBackup(ctx, backupDir, {
39
59
  operation,
40
60
  plan: options.plan,
61
+ dedupe: true,
62
+ returnResult: true,
41
63
  });
42
64
  return {
43
65
  lockPath,
44
- backupDir,
45
- backedUp,
66
+ backupDir: backup.backupDir,
67
+ backedUp: backup.backedUp,
68
+ backupSkipped: backup.skipped,
69
+ backupReason: backup.reason || "",
70
+ reusedBackupDir: backup.reusedBackupDir || "",
46
71
  release() {
47
72
  if (released) return;
48
73
  released = true;
@@ -56,34 +81,37 @@ export function beginUpdateLifecycle(ctx, options = {}) {
56
81
  }
57
82
 
58
83
  export function createRuntimeBackup(ctx, backupDir, options = {}) {
84
+ const operation = options.operation || "update";
85
+ const preserved = collectPreservedRuntimeFiles(ctx);
86
+ if (options.dedupe) {
87
+ const equivalent = findEquivalentRuntimeBackup(ctx, preserved, operation);
88
+ if (equivalent) {
89
+ const result = {
90
+ backupDir: equivalent.path,
91
+ backedUp: [],
92
+ skipped: true,
93
+ reusedBackupDir: equivalent.path,
94
+ reason: `same preserved runtime snapshot already exists: ${equivalent.id}`,
95
+ };
96
+ return options.returnResult ? result : result.backedUp;
97
+ }
98
+ }
99
+
59
100
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
60
101
  const filesDir = path.join(backupDir, "files");
61
102
  fs.mkdirSync(filesDir, { recursive: true, mode: 0o700 });
62
103
 
63
- const preserved = [];
64
- const backedUp = [];
65
104
  for (const rel of PRESERVED_RUNTIME_FILES) {
105
+ const item = preserved.find((entry) => entry.path === rel);
106
+ if (!item?.exists) continue;
66
107
  const source = path.join(ctx.agentDir, rel);
67
- if (!fs.existsSync(source)) {
68
- preserved.push({
69
- path: rel,
70
- exists: false,
71
- });
72
- continue;
73
- }
74
108
  const target = path.join(filesDir, rel.replace(/[\\/]/g, "__"));
75
109
  fs.copyFileSync(source, target);
76
110
  chmodPrivate(target);
77
- const item = {
78
- path: rel,
79
- exists: true,
80
- bytes: fs.statSync(source).size,
81
- sha256: sha256File(source),
82
- };
83
- preserved.push(item);
84
- backedUp.push(item);
85
111
  }
86
112
 
113
+ const backedUp = preserved.filter((item) => item.exists !== false);
114
+
87
115
  if (options.plan) {
88
116
  fs.writeFileSync(
89
117
  path.join(backupDir, "update-plan.json"),
@@ -96,14 +124,15 @@ export function createRuntimeBackup(ctx, backupDir, options = {}) {
96
124
  `${JSON.stringify({
97
125
  schema: "pi67.update-backup.v1",
98
126
  createdAt: new Date().toISOString(),
99
- operation: options.operation || "update",
127
+ operation,
100
128
  agentDir: ctx.agentDir,
101
129
  repoRoot: ctx.repoRoot,
102
130
  files: preserved,
103
131
  }, null, 2)}\n`,
104
132
  { mode: 0o600 },
105
133
  );
106
- return backedUp;
134
+ const result = { backupDir, backedUp, skipped: false, reusedBackupDir: "", reason: "" };
135
+ return options.returnResult ? result : backedUp;
107
136
  }
108
137
 
109
138
  export function listRuntimeBackups(ctx) {
@@ -246,6 +275,39 @@ function isStaleLock(lockPath) {
246
275
  return false;
247
276
  }
248
277
 
278
+ function collectPreservedRuntimeFiles(ctx) {
279
+ return PRESERVED_RUNTIME_FILES.map((rel) => {
280
+ const source = path.join(ctx.agentDir, rel);
281
+ if (!fs.existsSync(source)) {
282
+ return { path: rel, exists: false };
283
+ }
284
+ const stat = fs.statSync(source);
285
+ return {
286
+ path: rel,
287
+ exists: true,
288
+ bytes: stat.size,
289
+ sha256: sha256File(source),
290
+ };
291
+ });
292
+ }
293
+
294
+ function findEquivalentRuntimeBackup(ctx, preserved, operation) {
295
+ return listRuntimeBackups(ctx).find((backup) =>
296
+ backup.operation === operation && sameRuntimeFiles(backup.files, preserved));
297
+ }
298
+
299
+ function sameRuntimeFiles(left, right) {
300
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
301
+ const leftByPath = new Map(left.map((item) => [item.path, item]));
302
+ return right.every((rightItem) => {
303
+ const leftItem = leftByPath.get(rightItem.path);
304
+ if (!leftItem) return false;
305
+ if ((leftItem.exists !== false) !== (rightItem.exists !== false)) return false;
306
+ if (rightItem.exists === false) return true;
307
+ return leftItem.bytes === rightItem.bytes && leftItem.sha256 === rightItem.sha256;
308
+ });
309
+ }
310
+
249
311
  function sha256File(file) {
250
312
  const hash = crypto.createHash("sha256");
251
313
  hash.update(fs.readFileSync(file));