arcane-cli 0.31.0 → 0.32.2

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/dist/index.js CHANGED
@@ -16,11 +16,17 @@ import chalk2 from "chalk";
16
16
  // src/modules/copier.ts
17
17
  import {
18
18
  mkdir,
19
- copyFile as fsCopyFile,
20
19
  access,
21
- readdir
20
+ readdir,
21
+ readFile,
22
+ writeFile
22
23
  } from "fs/promises";
24
+ import { createHash } from "crypto";
23
25
  import path from "path";
26
+ async function hashFile(filePath) {
27
+ const content = await readFile(filePath);
28
+ return createHash("sha256").update(content).digest("hex");
29
+ }
24
30
  function validateTargetPath(targetDir, relativePath) {
25
31
  const resolvedBase = path.resolve(targetDir);
26
32
  const resolvedFull = path.resolve(targetDir, relativePath);
@@ -50,7 +56,10 @@ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
50
56
  `Destination already exists: "${relativePath}". Use --force to overwrite.`
51
57
  );
52
58
  }
53
- await fsCopyFile(srcPath, destPath);
59
+ const content = await readFile(srcPath);
60
+ const hash = createHash("sha256").update(content).digest("hex");
61
+ await writeFile(destPath, content);
62
+ return hash;
54
63
  }
55
64
  async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
56
65
  validateTargetPath(targetDir, relativeDir);
@@ -63,15 +72,15 @@ async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
63
72
  const subFiles = await copyDirectory(srcEntryPath, targetDir, relEntryPath, opts);
64
73
  copiedFiles.push(...subFiles);
65
74
  } else {
66
- await copyFile(srcEntryPath, targetDir, relEntryPath, opts);
67
- copiedFiles.push(relEntryPath);
75
+ const hash = await copyFile(srcEntryPath, targetDir, relEntryPath, opts);
76
+ copiedFiles.push({ path: relEntryPath, hash });
68
77
  }
69
78
  }
70
79
  return copiedFiles;
71
80
  }
72
81
 
73
82
  // src/modules/manifest.ts
74
- import { readFile, writeFile } from "fs/promises";
83
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
75
84
  import path2 from "path";
76
85
  var ManifestNotFoundError = class extends Error {
77
86
  constructor(manifestPath2) {
@@ -154,7 +163,7 @@ async function readManifest(targetDir) {
154
163
  const filePath = manifestPath(targetDir);
155
164
  let content;
156
165
  try {
157
- content = await readFile(filePath, "utf-8");
166
+ content = await readFile2(filePath, "utf-8");
158
167
  } catch (err) {
159
168
  if (err.code === "ENOENT") {
160
169
  throw new ManifestNotFoundError(filePath);
@@ -180,7 +189,7 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
180
189
  } catch {
181
190
  }
182
191
  try {
183
- const raw = await readFile(path2.join(targetDir, "src", "assets", ".arcane.json"), "utf-8");
192
+ const raw = await readFile2(path2.join(targetDir, "src", "assets", ".arcane.json"), "utf-8");
184
193
  const parsed = JSON.parse(raw);
185
194
  if (isRecord(parsed) && parsed["selfHosted"] === true && parsed["tracking_mode"] === "internal") {
186
195
  const prefixes = parsed["secretsScanExcludePrefixes"];
@@ -192,7 +201,7 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
192
201
  }
193
202
  async function writeManifest(targetDir, manifest) {
194
203
  const filePath = manifestPath(targetDir);
195
- await writeFile(filePath, JSON.stringify(manifest, null, 2), "utf-8");
204
+ await writeFile2(filePath, JSON.stringify(manifest, null, 2), "utf-8");
196
205
  }
197
206
  function addComponent(manifest, component) {
198
207
  return {
@@ -866,7 +875,7 @@ function printWarning(message) {
866
875
 
867
876
  // src/modules/agents.ts
868
877
  import { select, checkbox, Separator } from "@inquirer/prompts";
869
- import { mkdir as mkdir3, writeFile as writeFile4, copyFile as copyFile2 } from "fs/promises";
878
+ import { mkdir as mkdir3, writeFile as writeFile5, copyFile as copyFile2 } from "fs/promises";
870
879
  import { join as join3 } from "path";
871
880
  import { stringify } from "yaml";
872
881
 
@@ -1102,7 +1111,7 @@ async function applyNamingStrategy(strategy, roleIds) {
1102
1111
  }
1103
1112
 
1104
1113
  // src/modules/agent-loader.ts
1105
- import { readFile as readFile2, readdir as readdir2, access as access2 } from "fs/promises";
1114
+ import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
1106
1115
  import { join } from "path";
1107
1116
  import { parse } from "yaml";
1108
1117
 
@@ -1295,7 +1304,7 @@ async function loadAgentDefinition(agentsDir, id) {
1295
1304
  const filePath = join(agentsDir, `${id}.yaml`);
1296
1305
  let content;
1297
1306
  try {
1298
- content = await readFile2(filePath, "utf8");
1307
+ content = await readFile3(filePath, "utf8");
1299
1308
  } catch {
1300
1309
  throw new AgentDefinitionNotFoundError(id, agentsDir);
1301
1310
  }
@@ -1305,7 +1314,7 @@ async function loadRoster(targetDir) {
1305
1314
  const rosterPath = join(targetDir, ".arcane", "agents.yaml");
1306
1315
  let content;
1307
1316
  try {
1308
- content = await readFile2(rosterPath, "utf8");
1317
+ content = await readFile3(rosterPath, "utf8");
1309
1318
  } catch {
1310
1319
  throw new AgentRosterNotFoundError();
1311
1320
  }
@@ -1324,12 +1333,12 @@ function projectAgentsDir(targetDir) {
1324
1333
  }
1325
1334
 
1326
1335
  // src/modules/agent-generator.ts
1327
- import { writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
1336
+ import { writeFile as writeFile4, mkdir as mkdir2 } from "fs/promises";
1328
1337
  import { join as join2 } from "path";
1329
1338
  import { homedir } from "os";
1330
1339
 
1331
1340
  // src/modules/merger.ts
1332
- import { readFile as readFile3, writeFile as writeFile2, rm } from "fs/promises";
1341
+ import { readFile as readFile4, writeFile as writeFile3, rm } from "fs/promises";
1333
1342
  import path3 from "path";
1334
1343
  var MARKER_START = "<!-- arcane:start -->";
1335
1344
  var MARKER_END = "<!-- arcane:end -->";
@@ -1344,7 +1353,7 @@ async function mergeIntoFile(targetDir, relativePath, content, opts = {}) {
1344
1353
  const destPath = path3.resolve(targetDir, relativePath);
1345
1354
  let existing;
1346
1355
  try {
1347
- existing = await readFile3(destPath, "utf8");
1356
+ existing = await readFile4(destPath, "utf8");
1348
1357
  } catch (err) {
1349
1358
  const e = err;
1350
1359
  if (e.code === "ENOENT") {
@@ -1360,7 +1369,7 @@ ${content}
1360
1369
  ${MARKER_END}
1361
1370
  `;
1362
1371
  if (!opts.dryRun) {
1363
- await writeFile2(destPath, newContent, "utf8");
1372
+ await writeFile3(destPath, newContent, "utf8");
1364
1373
  }
1365
1374
  return true;
1366
1375
  }
@@ -1385,7 +1394,7 @@ ${MARKER_END}
1385
1394
  ${content}
1386
1395
  ${afterMarker}`;
1387
1396
  if (!opts.dryRun) {
1388
- await writeFile2(destPath, updated, "utf8");
1397
+ await writeFile3(destPath, updated, "utf8");
1389
1398
  }
1390
1399
  return true;
1391
1400
  }
@@ -1395,7 +1404,7 @@ ${content}
1395
1404
  ${MARKER_END}
1396
1405
  `;
1397
1406
  if (!opts.dryRun) {
1398
- await writeFile2(destPath, appended, "utf8");
1407
+ await writeFile3(destPath, appended, "utf8");
1399
1408
  }
1400
1409
  return true;
1401
1410
  }
@@ -1403,7 +1412,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1403
1412
  const destPath = path3.resolve(targetDir, relativePath);
1404
1413
  let existing;
1405
1414
  try {
1406
- existing = await readFile3(destPath, "utf8");
1415
+ existing = await readFile4(destPath, "utf8");
1407
1416
  } catch {
1408
1417
  return false;
1409
1418
  }
@@ -1417,7 +1426,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1417
1426
  if (stripped.length === 0) {
1418
1427
  await rm(destPath, { force: true });
1419
1428
  } else {
1420
- await writeFile2(destPath, stripped + "\n", "utf8");
1429
+ await writeFile3(destPath, stripped + "\n", "utf8");
1421
1430
  }
1422
1431
  }
1423
1432
  return true;
@@ -1571,17 +1580,17 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1571
1580
  try {
1572
1581
  if (!options.dryRun) {
1573
1582
  await mkdir2(workspaceDir, { recursive: true });
1574
- await writeFile3(
1583
+ await writeFile4(
1575
1584
  join2(workspaceDir, "IDENTITY.md"),
1576
1585
  renderIdentity(def, displayName),
1577
1586
  "utf8"
1578
1587
  );
1579
- await writeFile3(
1588
+ await writeFile4(
1580
1589
  join2(workspaceDir, "SOUL.md"),
1581
1590
  renderSoul(def, displayName),
1582
1591
  "utf8"
1583
1592
  );
1584
- await writeFile3(
1593
+ await writeFile4(
1585
1594
  join2(workspaceDir, "TOOLS.md"),
1586
1595
  renderTools(def, displayName),
1587
1596
  "utf8"
@@ -1610,7 +1619,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1610
1619
  const generatedDir = join2(targetDir, ".arcane", "generated");
1611
1620
  if (!options.dryRun) {
1612
1621
  await mkdir2(generatedDir, { recursive: true });
1613
- await writeFile3(
1622
+ await writeFile4(
1614
1623
  join2(generatedDir, "openclaw-roster.json"),
1615
1624
  JSON.stringify(patch, null, 2) + "\n",
1616
1625
  "utf8"
@@ -1632,7 +1641,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1632
1641
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
1633
1642
  const fileName = `${slug}.agent.md`;
1634
1643
  if (!options.dryRun) {
1635
- await writeFile3(
1644
+ await writeFile4(
1636
1645
  join2(agentsDir, fileName),
1637
1646
  renderCopilotAgent(def, displayName),
1638
1647
  "utf8"
@@ -1777,7 +1786,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1777
1786
  const arcaneDir = join3(targetDir, ".arcane", "agents");
1778
1787
  if (!options.dryRun) {
1779
1788
  await mkdir3(arcaneDir, { recursive: true });
1780
- await writeFile4(
1789
+ await writeFile5(
1781
1790
  join3(targetDir, ".arcane", "agents.yaml"),
1782
1791
  stringify(roster),
1783
1792
  "utf8"
@@ -1921,7 +1930,7 @@ async function runAgentsList(targetDir) {
1921
1930
  }
1922
1931
 
1923
1932
  // src/modules/push-safety.ts
1924
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
1933
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
1925
1934
  import { constants } from "fs";
1926
1935
  import { join as join4, resolve, dirname, basename } from "path";
1927
1936
 
@@ -2261,7 +2270,7 @@ async function installHook(cwd, hookName, body) {
2261
2270
  return { status: "already-ours" };
2262
2271
  }
2263
2272
  await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2264
- await writeFile5(hookPath, body, "utf-8");
2273
+ await writeFile6(hookPath, body, "utf-8");
2265
2274
  await chmod(hookPath, 493);
2266
2275
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2267
2276
  return { status: "installed", path: hookPath };
@@ -2296,7 +2305,7 @@ async function hookFileMatches(cwd, hookName, body) {
2296
2305
  const hookPath = await hookFilePath(cwd, hookName);
2297
2306
  if (!await fileExists(hookPath)) return false;
2298
2307
  try {
2299
- if (await readFile4(hookPath, "utf-8") !== body) return false;
2308
+ if (await readFile5(hookPath, "utf-8") !== body) return false;
2300
2309
  } catch {
2301
2310
  return false;
2302
2311
  }
@@ -2755,13 +2764,14 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2755
2764
  let fileCount = 0;
2756
2765
  for (const component of components) {
2757
2766
  const installedFiles = [];
2767
+ const fileHashes = {};
2758
2768
  for (const file of component.files) {
2759
2769
  const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
2760
2770
  if (options.dryRun) {
2761
2771
  printDryRun(`Would copy: ${file}`);
2762
2772
  } else {
2763
2773
  try {
2764
- await copyFile(srcPath, targetDir, file, { force: options.force });
2774
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: options.force });
2765
2775
  } catch (err) {
2766
2776
  if (component.skipExisting && err instanceof Error && err.message.includes("already exists")) {
2767
2777
  continue;
@@ -2778,7 +2788,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2778
2788
  printDryRun(`Would copy directory: ${dir}/`);
2779
2789
  } else {
2780
2790
  const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: options.force });
2781
- installedFiles.push(...copied);
2791
+ for (const { path: copiedPath, hash } of copied) {
2792
+ installedFiles.push(copiedPath);
2793
+ fileHashes[copiedPath] = hash;
2794
+ }
2782
2795
  fileCount += copied.length;
2783
2796
  }
2784
2797
  }
@@ -2786,7 +2799,8 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2786
2799
  installedComponents.push({
2787
2800
  name: component.name,
2788
2801
  files: installedFiles,
2789
- installedVersion: packageVersion
2802
+ installedVersion: packageVersion,
2803
+ fileHashes
2790
2804
  });
2791
2805
  }
2792
2806
  }
@@ -3067,12 +3081,13 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3067
3081
  }
3068
3082
  let fileCount = 0;
3069
3083
  const installedFiles = [];
3084
+ const fileHashes = {};
3070
3085
  for (const file of component.files) {
3071
3086
  const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3072
3087
  if (options.dryRun) {
3073
3088
  console.log(` [dry-run] Would copy: ${file}`);
3074
3089
  } else {
3075
- await copyFile(srcPath, targetDir, file, { force: options.force });
3090
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: options.force });
3076
3091
  }
3077
3092
  installedFiles.push(file);
3078
3093
  fileCount++;
@@ -3083,7 +3098,10 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3083
3098
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3084
3099
  } else {
3085
3100
  const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: options.force });
3086
- installedFiles.push(...copied);
3101
+ for (const { path: copiedPath, hash } of copied) {
3102
+ installedFiles.push(copiedPath);
3103
+ fileHashes[copiedPath] = hash;
3104
+ }
3087
3105
  fileCount += copied.length;
3088
3106
  }
3089
3107
  }
@@ -3097,7 +3115,8 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3097
3115
  const newComponent = {
3098
3116
  name,
3099
3117
  files: installedFiles,
3100
- installedVersion: packageVersion
3118
+ installedVersion: packageVersion,
3119
+ fileHashes
3101
3120
  };
3102
3121
  const updated = addComponent(manifest, newComponent);
3103
3122
  await writeManifest(targetDir, updated);
@@ -3109,10 +3128,11 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3109
3128
 
3110
3129
  // src/commands/update.ts
3111
3130
  import { join as join8 } from "path";
3131
+ import { readFile as readFile6, writeFile as writeFile8, rm as rm3 } from "fs/promises";
3112
3132
 
3113
3133
  // src/modules/hub.ts
3114
3134
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
3115
- import { readdir as readdir4, writeFile as writeFile6 } from "fs/promises";
3135
+ import { readdir as readdir4, writeFile as writeFile7 } from "fs/promises";
3116
3136
  import { join as join7 } from "path";
3117
3137
  var MANIFEST_RETROFITS = [
3118
3138
  {
@@ -3292,12 +3312,154 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3292
3312
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3293
3313
  ventures
3294
3314
  };
3295
- await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3315
+ await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3296
3316
  console.log(
3297
3317
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3298
3318
  );
3299
3319
  }
3300
3320
 
3321
+ // src/modules/merge3.ts
3322
+ var CONFLICT_START = "<<<<<<< yours (edited)";
3323
+ var CONFLICT_MID = "=======";
3324
+ var CONFLICT_END = ">>>>>>> incoming (vendor update)";
3325
+ function computeLcsDiff(base, other) {
3326
+ const n = base.length;
3327
+ const m = other.length;
3328
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
3329
+ for (let i2 = n - 1; i2 >= 0; i2--) {
3330
+ for (let j2 = m - 1; j2 >= 0; j2--) {
3331
+ dp[i2][j2] = base[i2] === other[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
3332
+ }
3333
+ }
3334
+ const ops = [];
3335
+ let i = 0;
3336
+ let j = 0;
3337
+ let pendingBaseStart = 0;
3338
+ let pendingOtherStart = 0;
3339
+ const flushReplace = (baseEnd, otherEnd) => {
3340
+ if (baseEnd > pendingBaseStart || otherEnd > pendingOtherStart) {
3341
+ ops.push({
3342
+ type: "replace",
3343
+ baseStart: pendingBaseStart,
3344
+ baseEnd,
3345
+ otherStart: pendingOtherStart,
3346
+ otherEnd
3347
+ });
3348
+ }
3349
+ };
3350
+ while (i < n && j < m) {
3351
+ if (base[i] === other[j]) {
3352
+ flushReplace(i, j);
3353
+ const eqBaseStart = i;
3354
+ const eqOtherStart = j;
3355
+ while (i < n && j < m && base[i] === other[j]) {
3356
+ i++;
3357
+ j++;
3358
+ }
3359
+ ops.push({ type: "equal", baseStart: eqBaseStart, baseEnd: i, otherStart: eqOtherStart, otherEnd: j });
3360
+ pendingBaseStart = i;
3361
+ pendingOtherStart = j;
3362
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
3363
+ i++;
3364
+ } else {
3365
+ j++;
3366
+ }
3367
+ }
3368
+ flushReplace(n, m);
3369
+ return ops;
3370
+ }
3371
+ function arraysEqual(a, b) {
3372
+ if (a.length !== b.length) return false;
3373
+ return a.every((line, idx) => line === b[idx]);
3374
+ }
3375
+ var MAX_LINES_FOR_LCS = 4e3;
3376
+ function splitLines(text) {
3377
+ return text.split("\n");
3378
+ }
3379
+ function merge3(base, ours, theirs) {
3380
+ const baseLines = splitLines(base);
3381
+ const oursLines = splitLines(ours);
3382
+ const theirsLines = splitLines(theirs);
3383
+ if (baseLines.length > MAX_LINES_FOR_LCS || oursLines.length > MAX_LINES_FOR_LCS || theirsLines.length > MAX_LINES_FOR_LCS) {
3384
+ return {
3385
+ content: [CONFLICT_START, ours, CONFLICT_MID, theirs, CONFLICT_END].join("\n"),
3386
+ hasConflict: true
3387
+ };
3388
+ }
3389
+ const oursOps = computeLcsDiff(baseLines, oursLines).filter((op) => op.type === "replace");
3390
+ const theirsOps = computeLcsDiff(baseLines, theirsLines).filter((op) => op.type === "replace");
3391
+ if (oursOps.length === 0 && theirsOps.length === 0) {
3392
+ return { content: base, hasConflict: false };
3393
+ }
3394
+ const intervals = [
3395
+ ...oursOps.map((op) => ({
3396
+ baseStart: op.baseStart,
3397
+ baseEnd: op.baseEnd,
3398
+ source: "ours",
3399
+ lines: oursLines.slice(op.otherStart, op.otherEnd)
3400
+ })),
3401
+ ...theirsOps.map((op) => ({
3402
+ baseStart: op.baseStart,
3403
+ baseEnd: op.baseEnd,
3404
+ source: "theirs",
3405
+ lines: theirsLines.slice(op.otherStart, op.otherEnd)
3406
+ }))
3407
+ ].sort((a, b) => a.baseStart - b.baseStart || a.baseEnd - b.baseEnd);
3408
+ const groups = [];
3409
+ let groupMaxEnd = -1;
3410
+ for (const iv of intervals) {
3411
+ const current = groups[groups.length - 1];
3412
+ if (current && iv.baseStart < groupMaxEnd) {
3413
+ current.push(iv);
3414
+ groupMaxEnd = Math.max(groupMaxEnd, iv.baseEnd);
3415
+ } else {
3416
+ groups.push([iv]);
3417
+ groupMaxEnd = iv.baseEnd;
3418
+ }
3419
+ }
3420
+ const resultLines = [];
3421
+ let hasConflict = false;
3422
+ let basePos = 0;
3423
+ for (const group of groups) {
3424
+ const groupStart = Math.min(...group.map((g) => g.baseStart));
3425
+ const groupEnd = Math.max(...group.map((g) => g.baseEnd));
3426
+ resultLines.push(...baseLines.slice(basePos, groupStart));
3427
+ const oursInGroup = group.filter((g) => g.source === "ours");
3428
+ const theirsInGroup = group.filter((g) => g.source === "theirs");
3429
+ if (oursInGroup.length > 0 && theirsInGroup.length === 0) {
3430
+ for (const g of oursInGroup) resultLines.push(...g.lines);
3431
+ } else if (theirsInGroup.length > 0 && oursInGroup.length === 0) {
3432
+ for (const g of theirsInGroup) resultLines.push(...g.lines);
3433
+ } else {
3434
+ const oursGroupLines = oursInGroup.flatMap((g) => g.lines);
3435
+ const theirsGroupLines = theirsInGroup.flatMap((g) => g.lines);
3436
+ if (arraysEqual(oursGroupLines, theirsGroupLines)) {
3437
+ resultLines.push(...oursGroupLines);
3438
+ } else {
3439
+ hasConflict = true;
3440
+ resultLines.push(CONFLICT_START, ...oursGroupLines, CONFLICT_MID, ...theirsGroupLines, CONFLICT_END);
3441
+ }
3442
+ }
3443
+ basePos = groupEnd;
3444
+ }
3445
+ resultLines.push(...baseLines.slice(basePos));
3446
+ return { content: resultLines.join("\n"), hasConflict };
3447
+ }
3448
+
3449
+ // src/modules/npm-registry.ts
3450
+ var NPM_PACKAGE_NAME = "arcane-cli";
3451
+ async function fetchPublishedFile(version, relativeAssetPath) {
3452
+ const normalized = relativeAssetPath.replace(/\\/g, "/").replace(/^\/+/, "");
3453
+ const url = `https://unpkg.com/${NPM_PACKAGE_NAME}@${version}/dist/assets/${normalized}`;
3454
+ try {
3455
+ const response = await fetch(url);
3456
+ if (!response.ok) return void 0;
3457
+ return await response.text();
3458
+ } catch {
3459
+ return void 0;
3460
+ }
3461
+ }
3462
+
3301
3463
  // src/commands/update.ts
3302
3464
  function migrateLegacyComponents(components) {
3303
3465
  const out = [];
@@ -3318,6 +3480,23 @@ function migrateLegacyComponents(components) {
3318
3480
  }
3319
3481
  return out;
3320
3482
  }
3483
+ async function resolveOrphan(targetDir, file, recordedHash, prune) {
3484
+ const filePath = join8(targetDir, file);
3485
+ if (!await fileExists(filePath)) return "not-found";
3486
+ if (!prune) return "reported";
3487
+ if (recordedHash === void 0) {
3488
+ console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
3489
+ return "reported";
3490
+ }
3491
+ const currentHash = await hashFile(filePath);
3492
+ if (currentHash !== recordedHash) {
3493
+ console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
3494
+ return "reported";
3495
+ }
3496
+ await rm3(filePath, { force: true });
3497
+ console.log(` Pruned orphaned file: ${file}`);
3498
+ return "pruned";
3499
+ }
3321
3500
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3322
3501
  let manifest;
3323
3502
  try {
@@ -3370,6 +3549,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3370
3549
  }
3371
3550
  let fileCount = 0;
3372
3551
  const updatedComponents = [];
3552
+ const conflictedFiles = [];
3553
+ const orphanReport = [];
3373
3554
  const componentsToUpdate = migrateLegacyComponents(manifest.components);
3374
3555
  for (const installed of componentsToUpdate) {
3375
3556
  let component;
@@ -3378,12 +3559,25 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3378
3559
  } catch (err) {
3379
3560
  if (err instanceof ComponentNotFoundError) {
3380
3561
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
3562
+ for (const file of installed.files) {
3563
+ const status = await resolveOrphan(
3564
+ targetDir,
3565
+ file,
3566
+ installed.fileHashes?.[file],
3567
+ Boolean(options.prune) && !options.dryRun
3568
+ );
3569
+ orphanReport.push({ file, status });
3570
+ }
3571
+ if (options.prune && !options.dryRun && installed.files.every((f) => orphanReport.find((o) => o.file === f)?.status !== "reported")) {
3572
+ continue;
3573
+ }
3381
3574
  updatedComponents.push(installed);
3382
3575
  continue;
3383
3576
  }
3384
3577
  throw err;
3385
3578
  }
3386
3579
  const updatedFiles = [];
3580
+ const fileHashes = {};
3387
3581
  for (const file of component.files) {
3388
3582
  const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3389
3583
  const targetExists = await fileExists(join8(targetDir, file));
@@ -3400,7 +3594,38 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3400
3594
  console.log(` [dry-run] Would update: ${file}`);
3401
3595
  fileCount++;
3402
3596
  } else {
3403
- await copyFile(srcPath, targetDir, file, { force: true });
3597
+ const recordedHash = installed.fileHashes?.[file];
3598
+ let handled = false;
3599
+ if (recordedHash !== void 0 && targetExists) {
3600
+ const currentHash = await hashFile(join8(targetDir, file));
3601
+ if (currentHash !== recordedHash) {
3602
+ const oldVendorContent = await fetchPublishedFile(installed.installedVersion, file);
3603
+ if (oldVendorContent === void 0) {
3604
+ console.log(
3605
+ ` ! Could not fetch the previously published version of ${file} to merge your edits \u2014 left your version untouched. Update it manually if you want the latest.`
3606
+ );
3607
+ handled = true;
3608
+ } else {
3609
+ const [currentContent, newContent] = await Promise.all([
3610
+ readFile6(join8(targetDir, file), "utf-8"),
3611
+ readFile6(srcPath, "utf-8")
3612
+ ]);
3613
+ const result = merge3(oldVendorContent, currentContent, newContent);
3614
+ await writeFile8(join8(targetDir, file), result.content, "utf-8");
3615
+ fileHashes[file] = await hashFile(join8(targetDir, file));
3616
+ if (result.hasConflict) {
3617
+ conflictedFiles.push(file);
3618
+ console.log(` \u26A0 Merge conflict in ${file} \u2014 resolve the <<<<<<< markers before committing.`);
3619
+ } else {
3620
+ console.log(` Merged your edits into: ${file}`);
3621
+ }
3622
+ handled = true;
3623
+ }
3624
+ }
3625
+ }
3626
+ if (!handled) {
3627
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: true });
3628
+ }
3404
3629
  fileCount++;
3405
3630
  }
3406
3631
  if (!preserveExisting || installed.files.includes(file)) {
@@ -3413,16 +3638,45 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3413
3638
  console.log(` [dry-run] Would update directory: ${dir}/`);
3414
3639
  } else {
3415
3640
  const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: true });
3416
- updatedFiles.push(...copied);
3641
+ for (const { path: copiedPath, hash } of copied) {
3642
+ updatedFiles.push(copiedPath);
3643
+ fileHashes[copiedPath] = hash;
3644
+ }
3417
3645
  fileCount += copied.length;
3418
3646
  }
3419
3647
  }
3648
+ for (const file of installed.files) {
3649
+ if (updatedFiles.includes(file)) continue;
3650
+ const status = await resolveOrphan(
3651
+ targetDir,
3652
+ file,
3653
+ installed.fileHashes?.[file],
3654
+ Boolean(options.prune) && !options.dryRun
3655
+ );
3656
+ orphanReport.push({ file, status });
3657
+ }
3420
3658
  updatedComponents.push({
3421
3659
  ...installed,
3422
3660
  files: updatedFiles,
3423
- installedVersion: packageVersion
3661
+ installedVersion: packageVersion,
3662
+ fileHashes
3424
3663
  });
3425
3664
  }
3665
+ const orphansToReport = orphanReport.filter((o) => o.status !== "not-found");
3666
+ if (orphansToReport.length > 0) {
3667
+ const verb = options.dryRun ? "[dry-run] Found" : "Found";
3668
+ console.log(
3669
+ `
3670
+ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update, no longer part of any current component):`
3671
+ );
3672
+ for (const { file, status } of orphansToReport) {
3673
+ const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
3674
+ console.log(` ${file} \u2014 ${marker}`);
3675
+ }
3676
+ if (!options.prune) {
3677
+ console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
3678
+ }
3679
+ }
3426
3680
  if (options.dryRun) {
3427
3681
  console.log(
3428
3682
  `
@@ -3457,13 +3711,21 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3457
3711
  `
3458
3712
  \u2713 Updated ${fileCount} files.`
3459
3713
  );
3714
+ if (conflictedFiles.length > 0) {
3715
+ console.log(
3716
+ `
3717
+ \u26A0 ${conflictedFiles.length} file(s) have unresolved merge conflicts:
3718
+ ` + conflictedFiles.map((f) => ` ${f}`).join("\n") + `
3719
+ Search for "<<<<<<< yours" and resolve before committing.`
3720
+ );
3721
+ }
3460
3722
  if (manifest.role !== "hub" && updated.role === "hub") {
3461
3723
  await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
3462
3724
  }
3463
3725
  }
3464
3726
 
3465
3727
  // src/modules/version-check.ts
3466
- import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
3728
+ import { readFile as readFile7, writeFile as writeFile9, mkdir as mkdir5 } from "fs/promises";
3467
3729
  import { dirname as dirname2, join as join9 } from "path";
3468
3730
  import { homedir as homedir2 } from "os";
3469
3731
  import { createRequire } from "module";
@@ -3482,7 +3744,7 @@ var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
3482
3744
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3483
3745
  async function readCache(cachePath) {
3484
3746
  try {
3485
- const content = await readFile5(cachePath, "utf-8");
3747
+ const content = await readFile7(cachePath, "utf-8");
3486
3748
  const parsed = JSON.parse(content);
3487
3749
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
3488
3750
  return null;
@@ -3495,7 +3757,7 @@ async function readCache(cachePath) {
3495
3757
  async function writeCache(cachePath, latest) {
3496
3758
  await mkdir5(dirname2(cachePath), { recursive: true });
3497
3759
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3498
- await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3760
+ await writeFile9(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3499
3761
  }
3500
3762
  async function getLatestVersion(feedUrl) {
3501
3763
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -3654,7 +3916,7 @@ async function runStatus(targetDir, packageVersion) {
3654
3916
  }
3655
3917
 
3656
3918
  // src/commands/uninstall.ts
3657
- import { rm as rm3 } from "fs/promises";
3919
+ import { rm as rm4 } from "fs/promises";
3658
3920
  import { join as join10 } from "path";
3659
3921
  import { confirm as confirm3 } from "@inquirer/prompts";
3660
3922
  async function runUninstall(options, targetDir) {
@@ -3720,22 +3982,22 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3720
3982
  for (const file of component.files) {
3721
3983
  const filePath = join10(targetDir, file);
3722
3984
  try {
3723
- await rm3(filePath, { force: true });
3985
+ await rm4(filePath, { force: true });
3724
3986
  removed++;
3725
3987
  } catch {
3726
3988
  }
3727
3989
  }
3728
3990
  }
3729
- await rm3(join10(targetDir, ".arcane.json"), { force: true });
3991
+ await rm4(join10(targetDir, ".arcane.json"), { force: true });
3730
3992
  const agentDirs = [
3731
3993
  ".github/agents",
3732
3994
  ".arcane/agents",
3733
3995
  ".arcane/generated"
3734
3996
  ];
3735
3997
  for (const dir of agentDirs) {
3736
- await rm3(join10(targetDir, dir), { recursive: true, force: true });
3998
+ await rm4(join10(targetDir, dir), { recursive: true, force: true });
3737
3999
  }
3738
- await rm3(join10(targetDir, ".arcane/agents.yaml"), { force: true });
4000
+ await rm4(join10(targetDir, ".arcane/agents.yaml"), { force: true });
3739
4001
  const markerFiles = [
3740
4002
  "CLAUDE.md",
3741
4003
  "AGENTS.md",
@@ -3753,7 +4015,7 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3753
4015
  }
3754
4016
 
3755
4017
  // src/commands/doctor.ts
3756
- import { access as access4, readFile as readFile7, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
4018
+ import { access as access4, readFile as readFile9, mkdir as mkdir6, copyFile as fsCopyFile } from "fs/promises";
3757
4019
  import { join as join12, dirname as dirname3 } from "path";
3758
4020
  import { execFile as execFile3 } from "child_process";
3759
4021
  import { promisify as promisify2 } from "util";
@@ -3824,7 +4086,7 @@ function evaluateIncidentGate(records) {
3824
4086
  }
3825
4087
 
3826
4088
  // src/modules/denylist-scan.ts
3827
- import { readFile as readFile6, readdir as readdir5 } from "fs/promises";
4089
+ import { readFile as readFile8, readdir as readdir5 } from "fs/promises";
3828
4090
  import { join as join11, relative } from "path";
3829
4091
  function escapeRegExp(value) {
3830
4092
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -3842,7 +4104,7 @@ async function scanFile(absolutePath, displayPath, rules) {
3842
4104
  const findings = [];
3843
4105
  let content;
3844
4106
  try {
3845
- content = await readFile6(absolutePath, "utf8");
4107
+ content = await readFile8(absolutePath, "utf8");
3846
4108
  } catch {
3847
4109
  return findings;
3848
4110
  }
@@ -4129,7 +4391,7 @@ async function checkArcaneManifest(targetDir) {
4129
4391
  } catch {
4130
4392
  const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
4131
4393
  try {
4132
- const raw = await readFile7(selfHostedManifestPath, "utf8");
4394
+ const raw = await readFile9(selfHostedManifestPath, "utf8");
4133
4395
  const manifest = JSON.parse(raw);
4134
4396
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
4135
4397
  return {
@@ -4148,7 +4410,7 @@ async function checkArcaneManifest(targetDir) {
4148
4410
  };
4149
4411
  }
4150
4412
  try {
4151
- const raw = await readFile7(manifestPath2, "utf8");
4413
+ const raw = await readFile9(manifestPath2, "utf8");
4152
4414
  const manifest = JSON.parse(raw);
4153
4415
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
4154
4416
  return {
@@ -4257,7 +4519,7 @@ async function fixSessionContinuity(targetDir, assetsDir) {
4257
4519
  } catch {
4258
4520
  const srcPath = join12(assetsDir, file);
4259
4521
  await mkdir6(dirname3(destPath), { recursive: true });
4260
- await fsCopyFile2(srcPath, destPath);
4522
+ await fsCopyFile(srcPath, destPath);
4261
4523
  created.push(file);
4262
4524
  }
4263
4525
  }
@@ -4319,7 +4581,7 @@ async function checkMcpConfig(targetDir) {
4319
4581
  const filePath = join12(targetDir, ".mcp.json");
4320
4582
  let raw;
4321
4583
  try {
4322
- raw = await readFile7(filePath, "utf8");
4584
+ raw = await readFile9(filePath, "utf8");
4323
4585
  } catch {
4324
4586
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4325
4587
  }
@@ -4375,7 +4637,7 @@ async function checkDelegations(targetDir) {
4375
4637
  const filePath = join12(targetDir, ".arcane", "delegations.json");
4376
4638
  let raw;
4377
4639
  try {
4378
- raw = await readFile7(filePath, "utf8");
4640
+ raw = await readFile9(filePath, "utf8");
4379
4641
  } catch {
4380
4642
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4381
4643
  }
@@ -4523,13 +4785,13 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4523
4785
  }
4524
4786
 
4525
4787
  // src/modules/ward.ts
4526
- import { readdir as readdir6, readFile as readFile8 } from "fs/promises";
4788
+ import { readdir as readdir6, readFile as readFile10 } from "fs/promises";
4527
4789
  import { join as join13, relative as relative2 } from "path";
4528
4790
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4529
4791
  const findings = [];
4530
4792
  let content;
4531
4793
  try {
4532
- content = await readFile8(absolutePath, "utf8");
4794
+ content = await readFile10(absolutePath, "utf8");
4533
4795
  } catch {
4534
4796
  return findings;
4535
4797
  }
@@ -4624,7 +4886,7 @@ async function runWard(repoRoot, options = {}) {
4624
4886
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
4625
4887
  if (findings2.length > 0) {
4626
4888
  try {
4627
- fileLines.set(displayPath, (await readFile8(absolutePath, "utf8")).split("\n"));
4889
+ fileLines.set(displayPath, (await readFile10(absolutePath, "utf8")).split("\n"));
4628
4890
  } catch {
4629
4891
  }
4630
4892
  }
@@ -4833,9 +5095,12 @@ program.command("add <component>").description("Add a component to an existing A
4833
5095
  );
4834
5096
  }
4835
5097
  );
4836
- program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").action(async (opts) => {
5098
+ program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
5099
+ "--prune",
5100
+ "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
5101
+ ).action(async (opts) => {
4837
5102
  await runUpdate(
4838
- { dryRun: opts.dryRun },
5103
+ { dryRun: opts.dryRun, prune: opts.prune },
4839
5104
  process.cwd(),
4840
5105
  ASSETS_DIR,
4841
5106
  pkg.version