arcane-cli 1.12.1 → 1.12.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
@@ -17,14 +17,285 @@ import chalk2 from "chalk";
17
17
  import {
18
18
  mkdir,
19
19
  access,
20
+ lstat,
20
21
  readdir,
21
22
  readFile,
22
23
  rm,
23
24
  rmdir,
25
+ unlink,
24
26
  writeFile
25
27
  } from "fs/promises";
26
28
  import { createHash } from "crypto";
27
29
  import path from "path";
30
+
31
+ // src/modules/git.ts
32
+ import { execFile } from "child_process";
33
+ var GitTimeoutError = class extends Error {
34
+ constructor(message, options) {
35
+ super(message, options);
36
+ this.name = "GitTimeoutError";
37
+ }
38
+ };
39
+ var NETWORK_SUBCOMMANDS = /* @__PURE__ */ new Set(["fetch", "pull", "push", "clone", "ls-remote", "send-email"]);
40
+ var WRITE_SUBCOMMANDS = /* @__PURE__ */ new Set([
41
+ "init",
42
+ "commit",
43
+ "add",
44
+ "checkout",
45
+ "switch",
46
+ "merge",
47
+ "rebase",
48
+ "reset",
49
+ "tag",
50
+ "mv",
51
+ "rm",
52
+ "cherry-pick",
53
+ "revert",
54
+ "stash",
55
+ "apply",
56
+ "am",
57
+ "restore",
58
+ "submodule",
59
+ "sparse-checkout",
60
+ "maintenance",
61
+ "notes"
62
+ ]);
63
+ var AMBIGUOUS_AS_WRITE = /* @__PURE__ */ new Set(["branch", "config", "remote", "worktree"]);
64
+ var READ_SUBCOMMANDS = /* @__PURE__ */ new Set([
65
+ "status",
66
+ "rev-parse",
67
+ "log",
68
+ "diff",
69
+ "show",
70
+ "describe",
71
+ "cat-file",
72
+ "ls-files",
73
+ "ls-tree",
74
+ "symbolic-ref",
75
+ "show-ref",
76
+ "for-each-ref",
77
+ "merge-base",
78
+ "blame",
79
+ "shortlog",
80
+ "grep",
81
+ "reflog",
82
+ "diff-tree",
83
+ "rev-list",
84
+ "name-rev",
85
+ "count-objects",
86
+ "verify-commit",
87
+ "verify-tag",
88
+ "help",
89
+ "version"
90
+ ]);
91
+ var GLOBAL_FLAGS_WITH_SEPARATE_VALUE = /* @__PURE__ */ new Set(["-c", "-C"]);
92
+ var DEFAULT_TIMEOUTS_MS = {
93
+ read: 15e3,
94
+ write: 3e4,
95
+ network: 12e4
96
+ };
97
+ function classifyGitCommand(args) {
98
+ let subcommand;
99
+ for (let i = 0; i < args.length; i++) {
100
+ const arg = args[i];
101
+ if (GLOBAL_FLAGS_WITH_SEPARATE_VALUE.has(arg)) {
102
+ i++;
103
+ continue;
104
+ }
105
+ if (arg.startsWith("-")) continue;
106
+ subcommand = arg;
107
+ break;
108
+ }
109
+ if (!subcommand) return "read";
110
+ if (NETWORK_SUBCOMMANDS.has(subcommand)) return "network";
111
+ if (WRITE_SUBCOMMANDS.has(subcommand) || AMBIGUOUS_AS_WRITE.has(subcommand)) return "write";
112
+ if (READ_SUBCOMMANDS.has(subcommand)) return "read";
113
+ return "write";
114
+ }
115
+ function buildGitEnv() {
116
+ return {
117
+ ...process.env,
118
+ GIT_TERMINAL_PROMPT: "0",
119
+ GCM_INTERACTIVE: "Never",
120
+ GIT_OPTIONAL_LOCKS: "0"
121
+ };
122
+ }
123
+ function runGit(cwd, args, options = {}) {
124
+ const commandClass = options.commandClass ?? classifyGitCommand(args);
125
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
126
+ return new Promise((resolve6, reject) => {
127
+ const child = execFile(
128
+ "git",
129
+ args,
130
+ {
131
+ cwd,
132
+ env: buildGitEnv(),
133
+ timeout: timeoutMs,
134
+ // SIGTERM (Node's default) rather than SIGKILL: gives git a chance
135
+ // to run its own cleanup on POSIX, relevant to EF-20's own concern
136
+ // about a killed operation leaving refs/locks in an uncertain state.
137
+ // Moot on Windows -- Node docs: both map to an abrupt TerminateProcess
138
+ // there -- but this module isn't Windows-gated.
139
+ killSignal: "SIGTERM",
140
+ windowsHide: true,
141
+ encoding: "utf8"
142
+ },
143
+ (error, stdout, stderr) => {
144
+ if (error) {
145
+ if (error.killed) {
146
+ reject(
147
+ new GitTimeoutError(
148
+ // NOTE: embeds the raw args, including any secrets a future
149
+ // network-class caller might pass (e.g. a credentialed
150
+ // clone URL). No current caller does; redact before one does.
151
+ `git ${args.join(" ")} timed out after ${timeoutMs}ms (class: ${commandClass})`,
152
+ { cause: error }
153
+ )
154
+ );
155
+ return;
156
+ }
157
+ reject(error);
158
+ return;
159
+ }
160
+ resolve6({ stdout, stderr });
161
+ }
162
+ );
163
+ child.stdin?.end();
164
+ });
165
+ }
166
+ async function inspectGitRepository(cwd) {
167
+ try {
168
+ const { stdout: stdout2 } = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
169
+ if (stdout2.trim() !== "true") {
170
+ return { status: "not-repository" };
171
+ }
172
+ } catch {
173
+ return { status: "not-repository" };
174
+ }
175
+ try {
176
+ await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
177
+ } catch {
178
+ return { status: "no-commits" };
179
+ }
180
+ const { stdout } = await runGit(cwd, ["status", "--porcelain", "-uall"]);
181
+ const lines = stdout.trim();
182
+ return {
183
+ status: "ready",
184
+ uncommittedChanges: lines.length === 0 ? 0 : lines.split("\n").length
185
+ };
186
+ }
187
+ async function correctUnbornMasterDefault(cwd) {
188
+ let current;
189
+ try {
190
+ const { stdout } = await runGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
191
+ current = stdout.trim();
192
+ } catch {
193
+ return { corrected: false, to: "main" };
194
+ }
195
+ if (current !== "master") {
196
+ return { corrected: false, to: "main" };
197
+ }
198
+ try {
199
+ await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
200
+ return { corrected: false, to: "main" };
201
+ } catch {
202
+ }
203
+ let mainRef;
204
+ try {
205
+ mainRef = await runGit(cwd, ["for-each-ref", "--format=%(objectname)", "refs/heads/main"]);
206
+ } catch {
207
+ return { corrected: false, to: "main", blockedReason: "target-unreadable" };
208
+ }
209
+ if (mainRef.stdout.trim() !== "") {
210
+ return { corrected: false, to: "main" };
211
+ }
212
+ if (mainRef.stderr.trim() !== "") {
213
+ return { corrected: false, to: "main", blockedReason: "target-unreadable" };
214
+ }
215
+ try {
216
+ await runGit(cwd, ["symbolic-ref", "-q", "refs/heads/main"]);
217
+ return { corrected: false, to: "main", blockedReason: "target-unreadable" };
218
+ } catch (error) {
219
+ if (error.code !== 1) {
220
+ return { corrected: false, to: "main", blockedReason: "target-unreadable" };
221
+ }
222
+ }
223
+ await runGit(cwd, ["symbolic-ref", "HEAD", "refs/heads/main"]);
224
+ return { corrected: true, from: current, to: "main" };
225
+ }
226
+ async function ensureLocalPullRebase(cwd) {
227
+ let rawLocalValue;
228
+ try {
229
+ const { stdout } = await runGit(cwd, ["config", "--local", "--get", "pull.rebase"]);
230
+ rawLocalValue = stdout.trim();
231
+ } catch {
232
+ rawLocalValue = void 0;
233
+ }
234
+ if (rawLocalValue === void 0) {
235
+ await runGit(cwd, ["config", "--local", "pull.rebase", "true"]);
236
+ return { action: "set" };
237
+ }
238
+ try {
239
+ const { stdout } = await runGit(cwd, ["config", "--local", "--type=bool", "--get", "pull.rebase"]);
240
+ if (stdout.trim() === "false") {
241
+ return { action: "explicit-false-preserved" };
242
+ }
243
+ return { action: "already-set" };
244
+ } catch {
245
+ return { action: "already-set" };
246
+ }
247
+ }
248
+ async function listSymlinkTrackedPaths(cwd) {
249
+ const paths = /* @__PURE__ */ new Set();
250
+ let stdout;
251
+ try {
252
+ ({ stdout } = await runGit(cwd, ["ls-files", "-s", "-z"]));
253
+ } catch {
254
+ return paths;
255
+ }
256
+ for (const entry of stdout.split("\0")) {
257
+ const tab = entry.indexOf(" ");
258
+ if (tab < 0) continue;
259
+ const [mode] = entry.slice(0, tab).split(" ");
260
+ if (mode === "120000") paths.add(entry.slice(tab + 1));
261
+ }
262
+ return paths;
263
+ }
264
+ async function recordRegularFileInIndex(cwd, relativePath) {
265
+ const { stdout } = await runGit(cwd, ["hash-object", "-w", "--", relativePath]);
266
+ const { stdout: prefix } = await runGit(cwd, ["rev-parse", "--show-prefix"]);
267
+ const rootRelative = `${prefix.trim()}${relativePath.replace(/\\/g, "/")}`;
268
+ await runGit(cwd, ["update-index", "--add", "--cacheinfo", `100644,${stdout.trim()},${rootRelative}`]);
269
+ }
270
+ async function isUneditedTrackedFile(cwd, relativePath) {
271
+ let status;
272
+ try {
273
+ ({ stdout: status } = await runGit(cwd, ["status", "--porcelain", "-z", "--", relativePath]));
274
+ } catch {
275
+ return { verdict: "unknown", detail: "not a git repository, or git is unavailable" };
276
+ }
277
+ if (status.startsWith("??")) return { verdict: "untracked", detail: "git does not track it" };
278
+ if (status.length > 0) return { verdict: "edited", detail: "it has uncommitted changes" };
279
+ try {
280
+ const { stdout: shallow } = await runGit(cwd, ["rev-parse", "--is-shallow-repository"]);
281
+ if (shallow.trim() === "true") {
282
+ return { verdict: "unknown", detail: "this is a shallow clone, so the history is truncated" };
283
+ }
284
+ } catch {
285
+ }
286
+ let log;
287
+ try {
288
+ ({ stdout: log } = await runGit(cwd, ["log", "--format=%H", "--", relativePath]));
289
+ } catch {
290
+ return { verdict: "unknown", detail: "git log could not read its history" };
291
+ }
292
+ const commits = log.split("\n").filter(Boolean).length;
293
+ if (commits === 0) return { verdict: "untracked", detail: "no commit contains it" };
294
+ if (commits > 1) return { verdict: "edited", detail: `${commits} commits touched it` };
295
+ return { verdict: "unedited", detail: "tracked, clean, and added by its only commit" };
296
+ }
297
+
298
+ // src/modules/copier.ts
28
299
  function normalizeLineEndings(content) {
29
300
  if (!content.includes(13)) return content;
30
301
  const out = Buffer.allocUnsafe(content.length);
@@ -72,19 +343,64 @@ async function fileExists(filePath) {
72
343
  return false;
73
344
  }
74
345
  }
75
- async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
76
- validateTargetPath(targetDir, relativePath);
77
- const destPath = path.resolve(targetDir, relativePath);
78
- await ensureDir(path.dirname(destPath));
79
- if (!opts.force && await fileExists(destPath)) {
80
- throw new Error(
81
- `Destination already exists: "${relativePath}". Use --force to overwrite.`
82
- );
346
+ async function replaceSymlinkedDestination(destPath, relativePath, opts) {
347
+ let isLink;
348
+ try {
349
+ isLink = (await lstat(destPath)).isSymbolicLink();
350
+ } catch {
351
+ return null;
83
352
  }
84
- const content = await readFile(srcPath);
85
- const hash = hashContent(content);
86
- await writeFile(destPath, content);
87
- return hash;
353
+ if (isLink) {
354
+ await unlink(destPath);
355
+ return "symlink";
356
+ }
357
+ if (opts.linkTracked?.has(relativePath.replace(/\\/g, "/"))) {
358
+ await unlink(destPath);
359
+ return "index-symlink";
360
+ }
361
+ return null;
362
+ }
363
+ function looksLikeBarePath(body) {
364
+ const trimmed = body.trim();
365
+ return trimmed.length <= 512 && /^\.{1,2}\/\S+$/.test(trimmed) && !trimmed.includes("://");
366
+ }
367
+ async function managedPathLinkShape(targetDir, relativePath, linkTracked, bodyHeuristic) {
368
+ const destPath = path.resolve(targetDir, relativePath);
369
+ try {
370
+ if ((await lstat(destPath)).isSymbolicLink()) return "symlink";
371
+ } catch {
372
+ return null;
373
+ }
374
+ if (linkTracked?.has(relativePath.replace(/\\/g, "/"))) return "index-symlink";
375
+ if (!bodyHeuristic) return null;
376
+ try {
377
+ return looksLikeBarePath(await readFile(destPath, "utf-8")) ? "path-body" : null;
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+ async function writeManagedFile(targetDir, relativePath, content, opts = {}) {
383
+ validateTargetPath(targetDir, relativePath);
384
+ const destPath = path.resolve(targetDir, relativePath);
385
+ await ensureDir(path.dirname(destPath));
386
+ const shape = await replaceSymlinkedDestination(destPath, relativePath, opts);
387
+ await writeFile(destPath, content);
388
+ if (shape === "index-symlink") await recordRegularFileInIndex(targetDir, relativePath);
389
+ if (shape !== null) opts.conversions?.push({ file: relativePath, shape });
390
+ }
391
+ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
392
+ validateTargetPath(targetDir, relativePath);
393
+ const destPath = path.resolve(targetDir, relativePath);
394
+ await ensureDir(path.dirname(destPath));
395
+ if (!opts.force && await fileExists(destPath)) {
396
+ throw new Error(
397
+ `Destination already exists: "${relativePath}". Use --force to overwrite.`
398
+ );
399
+ }
400
+ const content = await readFile(srcPath);
401
+ const hash = hashContent(content);
402
+ await writeManagedFile(targetDir, relativePath, content, opts);
403
+ return hash;
88
404
  }
89
405
  async function removeWithin(targetDir, relativePath) {
90
406
  validateTargetPath(targetDir, relativePath);
@@ -976,7 +1292,7 @@ function listProfiles() {
976
1292
  }
977
1293
 
978
1294
  // src/modules/user-tier.ts
979
- import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir as rmdir2, writeFile as writeFile3 } from "fs/promises";
1295
+ import { lstat as lstat2, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir as rmdir2, writeFile as writeFile3 } from "fs/promises";
980
1296
  import { homedir } from "os";
981
1297
  import { dirname, join, resolve } from "path";
982
1298
 
@@ -1192,7 +1508,7 @@ async function renderUserFanout(storeRoot, spellIds, fallbackDir) {
1192
1508
  }
1193
1509
  async function classifyTarget(path4) {
1194
1510
  try {
1195
- const stats = await lstat(path4);
1511
+ const stats = await lstat2(path4);
1196
1512
  return stats.isFile() ? "file" : "other";
1197
1513
  } catch (err) {
1198
1514
  if (err.code === "ENOENT") return "absent";
@@ -2194,7 +2510,10 @@ ${rules}
2194
2510
  **Denied:** ${def.tools.denied.join(", ")}
2195
2511
  `;
2196
2512
  }
2197
- function renderSpellRoutingSection() {
2513
+ function renderSpellRoutingSection(spellScope) {
2514
+ const userTierRule = spellScope === "user" ? `
2515
+ This repository takes its spells from the user tier (\`~/.arcane\`), not from files it carries. If a spell named here does not resolve for your client, stop and say that \`spell init --user\` is needed on this machine; do not improvise the workflow from general knowledge.
2516
+ ` : "";
2198
2517
  return `## Spell Routing
2199
2518
 
2200
2519
  | When you're about to... | Invoke |
@@ -2208,7 +2527,7 @@ function renderSpellRoutingSection() {
2208
2527
  | Review code or a PR | \`spell-review\` |
2209
2528
 
2210
2529
  If a spell exists for the workflow you are about to perform, invoke it \u2014 do not improvise the workflow from general knowledge, even when the user doesn't name the spell.
2211
- `;
2530
+ ${userTierRule}`;
2212
2531
  }
2213
2532
  async function loadBranchNamingFragment(assetsDir) {
2214
2533
  try {
@@ -2370,7 +2689,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2370
2689
  unmanagedAgentFiles.push(...await existingAgentFiles(agentsDir));
2371
2690
  }
2372
2691
  if (!userTier) {
2373
- const copilotSection = `${renderSpellRoutingSection()}
2692
+ const copilotSection = `${renderSpellRoutingSection(options.spellScope)}
2374
2693
  ${branchNaming}${renderAgentRosterSection(resolved)}`;
2375
2694
  const copilotMerged = await mergeIntoFile(
2376
2695
  targetDir,
@@ -2386,7 +2705,7 @@ ${branchNaming}${renderAgentRosterSection(resolved)}`;
2386
2705
  return { synced, skipped, hasUnresolvedRoles, userAgentFiles, unmanagedAgentFiles };
2387
2706
  }
2388
2707
  if (options.claude !== false) {
2389
- const claudeSection = `${renderSpellRoutingSection()}
2708
+ const claudeSection = `${renderSpellRoutingSection(options.spellScope)}
2390
2709
  ${branchNaming}${renderAgentRosterSection(resolved)}`;
2391
2710
  const claudeMerged = await mergeIntoFile(
2392
2711
  targetDir,
@@ -2397,7 +2716,7 @@ ${branchNaming}${renderAgentRosterSection(resolved)}`;
2397
2716
  if (claudeMerged) synced.push("CLAUDE.md (agents section)");
2398
2717
  }
2399
2718
  if (options.codex !== false) {
2400
- const codexSection = `${renderSpellRoutingSection()}
2719
+ const codexSection = `${renderSpellRoutingSection(options.spellScope)}
2401
2720
  ${branchNaming}${renderAgentRosterSection(resolved)}`;
2402
2721
  const codexMerged = await mergeIntoFile(
2403
2722
  targetDir,
@@ -2589,389 +2908,171 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2589
2908
  schema_version: 2,
2590
2909
  naming_strategy: namingStrategy,
2591
2910
  agent_profile: profileId,
2592
- openclaw: {
2593
- enabled: true,
2594
- workspace_root: "~/.openclaw"
2595
- },
2596
- roster: rosterEntries
2597
- };
2598
- const arcaneDir = join4(baseDir, "agents");
2599
- if (!options.dryRun) {
2600
- await mkdir4(arcaneDir, { recursive: true });
2601
- await writeFile6(join4(baseDir, "agents.yaml"), stringify(roster), "utf8");
2602
- } else {
2603
- console.log(` [dry-run] Would write: ${label2}/agents.yaml`);
2604
- }
2605
- for (const roleId of selectedRoleIds) {
2606
- const src = join4(assetsDir, "agents", `${roleId}.yaml`);
2607
- const dest = join4(arcaneDir, `${roleId}.yaml`);
2608
- if (!options.dryRun) {
2609
- try {
2610
- await copyFile2(src, dest);
2611
- } catch {
2612
- console.warn(` ! Could not copy definition for "${roleId}" \u2014 skipping`);
2613
- }
2614
- } else {
2615
- console.log(` [dry-run] Would copy: ${label2}/agents/${roleId}.yaml`);
2616
- }
2617
- }
2618
- if (options.dryRun) {
2619
- console.log(
2620
- `
2621
- [dry-run] Would initialize roster with ${selectedRoleIds.length} agents`
2622
- );
2623
- return;
2624
- }
2625
- printStep("Syncing agent definitions to all clients...");
2626
- const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2627
- targetDir,
2628
- assetsDir,
2629
- roster,
2630
- { dryRun: false, scope }
2631
- );
2632
- printSuccess("Agent roster initialized");
2633
- console.log(` \u{1F4CB} Profile: ${profileId} \xB7 Naming: ${namingStrategy} \xB7 Agents: ${rosterEntries.length}`);
2634
- console.log(
2635
- ` \u{1F504} Synced: ${synced.length} outputs (${scope === "user" ? "agent personas in ~/.claude/agents" : "Copilot, Claude, Codex, OpenClaw"})`
2636
- );
2637
- if (skipped.length > 0) {
2638
- console.log(` \u26A0\uFE0F Skipped: ${skipped.length} (${skipped.join(", ")})`);
2639
- }
2640
- console.log();
2641
- console.log(
2642
- ` \u{1F4A1} Customize: edit ${label2}/agents.yaml \u2192 spell agents sync${scope === "user" ? " --user" : ""}`
2643
- );
2644
- console.log();
2645
- if (hasUnresolvedRoles) {
2646
- console.error(
2647
- ' \u2717 One or more rostered roles could not be resolved (see "Skipped" above) \u2014 a malformed or missing agent template must not pass silently.'
2648
- );
2649
- process.exitCode = 1;
2650
- }
2651
- }
2652
- async function runAgentsSync(options, targetDir, assetsDir) {
2653
- const scope = options.scope ?? "repo";
2654
- let roster;
2655
- let definitionsDir;
2656
- let fromUserTier = false;
2657
- try {
2658
- ({ roster, definitionsDir, fromUserTier } = await resolveRoster(targetDir, scope));
2659
- } catch (err) {
2660
- if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
2661
- console.error(err.message);
2662
- process.exit(1);
2663
- return;
2664
- }
2665
- throw err;
2666
- }
2667
- if (options.dryRun) {
2668
- console.log("\n [dry-run] Would sync agents from roster:\n");
2669
- } else {
2670
- console.log("\n Syncing agents...\n");
2671
- }
2672
- if (fromUserTier && scope === "repo") {
2673
- console.log(
2674
- " This repository has no roster of its own and takes its spells from the user tier,\n so its roster tables are built from ~/.arcane/agents.yaml.\n"
2675
- );
2676
- }
2677
- const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2678
- targetDir,
2679
- assetsDir,
2680
- roster,
2681
- { ...options, scope, ...definitionsDir === void 0 ? {} : { definitionsDir } }
2682
- );
2683
- if (options.dryRun) {
2684
- console.log(`
2685
- [dry-run] ${synced.length} outputs would be generated`);
2686
- return;
2687
- }
2688
- console.log(`
2689
- \u2713 Synced ${synced.length} outputs`);
2690
- for (const item of synced) {
2691
- console.log(` + ${item}`);
2692
- }
2693
- if (skipped.length > 0) {
2694
- console.log(`
2695
- ! Skipped ${skipped.length}:`);
2696
- for (const item of skipped) {
2697
- console.log(` - ${item}`);
2698
- }
2699
- }
2700
- console.log();
2701
- if (hasUnresolvedRoles) {
2702
- console.error(
2703
- ' \u2717 One or more rostered roles could not be resolved (see "Skipped" above) \u2014 a malformed or missing agent template must not pass silently.'
2704
- );
2705
- process.exitCode = 1;
2706
- }
2707
- }
2708
- async function runAgentsList(targetDir, scope = "repo") {
2709
- let roster;
2710
- try {
2711
- roster = await loadRoster(targetDir, scope);
2712
- } catch (err) {
2713
- if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
2714
- console.error(err.message);
2715
- process.exit(1);
2716
- return;
2717
- }
2718
- throw err;
2719
- }
2720
- const nameWidth = Math.max(
2721
- "Name".length,
2722
- ...roster.roster.map((e) => (e.name ?? e.definition).length)
2723
- );
2724
- const defWidth = Math.max(
2725
- "Role ID".length,
2726
- ...roster.roster.map((e) => e.definition.length)
2727
- );
2728
- const idWidth = Math.max(
2729
- "OpenClaw ID".length,
2730
- ...roster.roster.map((e) => e.id.length)
2731
- );
2732
- const pad = (s, n) => s.padEnd(n);
2733
- console.log(
2734
- `
2735
- Profile: ${roster.agent_profile} | Naming: ${roster.naming_strategy} | Agents: ${roster.roster.length}
2736
- `
2737
- );
2738
- console.log(
2739
- ` ${pad("Name", nameWidth)} ${pad("Role ID", defWidth)} ${pad("OpenClaw ID", idWidth)}`
2740
- );
2741
- console.log(
2742
- ` ${"-".repeat(nameWidth)} ${"-".repeat(defWidth)} ${"-".repeat(idWidth)}`
2743
- );
2744
- for (const entry of roster.roster) {
2745
- console.log(
2746
- ` ${pad(entry.name ?? "(unnamed)", nameWidth)} ${pad(entry.definition, defWidth)} ${pad(entry.id, idWidth)}`
2747
- );
2748
- }
2749
- console.log();
2750
- }
2751
-
2752
- // src/modules/hub.ts
2753
- import { confirm, select as select2, input as input2 } from "@inquirer/prompts";
2754
- import { readdir as readdir5, writeFile as writeFile7 } from "fs/promises";
2755
- import { join as join5 } from "path";
2756
-
2757
- // src/modules/git.ts
2758
- import { execFile } from "child_process";
2759
- var GitTimeoutError = class extends Error {
2760
- constructor(message, options) {
2761
- super(message, options);
2762
- this.name = "GitTimeoutError";
2763
- }
2764
- };
2765
- var NETWORK_SUBCOMMANDS = /* @__PURE__ */ new Set(["fetch", "pull", "push", "clone", "ls-remote", "send-email"]);
2766
- var WRITE_SUBCOMMANDS = /* @__PURE__ */ new Set([
2767
- "init",
2768
- "commit",
2769
- "add",
2770
- "checkout",
2771
- "switch",
2772
- "merge",
2773
- "rebase",
2774
- "reset",
2775
- "tag",
2776
- "mv",
2777
- "rm",
2778
- "cherry-pick",
2779
- "revert",
2780
- "stash",
2781
- "apply",
2782
- "am",
2783
- "restore",
2784
- "submodule",
2785
- "sparse-checkout",
2786
- "maintenance",
2787
- "notes"
2788
- ]);
2789
- var AMBIGUOUS_AS_WRITE = /* @__PURE__ */ new Set(["branch", "config", "remote", "worktree"]);
2790
- var READ_SUBCOMMANDS = /* @__PURE__ */ new Set([
2791
- "status",
2792
- "rev-parse",
2793
- "log",
2794
- "diff",
2795
- "show",
2796
- "describe",
2797
- "cat-file",
2798
- "ls-files",
2799
- "ls-tree",
2800
- "symbolic-ref",
2801
- "show-ref",
2802
- "for-each-ref",
2803
- "merge-base",
2804
- "blame",
2805
- "shortlog",
2806
- "grep",
2807
- "reflog",
2808
- "diff-tree",
2809
- "rev-list",
2810
- "name-rev",
2811
- "count-objects",
2812
- "verify-commit",
2813
- "verify-tag",
2814
- "help",
2815
- "version"
2816
- ]);
2817
- var GLOBAL_FLAGS_WITH_SEPARATE_VALUE = /* @__PURE__ */ new Set(["-c", "-C"]);
2818
- var DEFAULT_TIMEOUTS_MS = {
2819
- read: 15e3,
2820
- write: 3e4,
2821
- network: 12e4
2822
- };
2823
- function classifyGitCommand(args) {
2824
- let subcommand;
2825
- for (let i = 0; i < args.length; i++) {
2826
- const arg = args[i];
2827
- if (GLOBAL_FLAGS_WITH_SEPARATE_VALUE.has(arg)) {
2828
- i++;
2829
- continue;
2830
- }
2831
- if (arg.startsWith("-")) continue;
2832
- subcommand = arg;
2833
- break;
2834
- }
2835
- if (!subcommand) return "read";
2836
- if (NETWORK_SUBCOMMANDS.has(subcommand)) return "network";
2837
- if (WRITE_SUBCOMMANDS.has(subcommand) || AMBIGUOUS_AS_WRITE.has(subcommand)) return "write";
2838
- if (READ_SUBCOMMANDS.has(subcommand)) return "read";
2839
- return "write";
2840
- }
2841
- function buildGitEnv() {
2842
- return {
2843
- ...process.env,
2844
- GIT_TERMINAL_PROMPT: "0",
2845
- GCM_INTERACTIVE: "Never",
2846
- GIT_OPTIONAL_LOCKS: "0"
2847
- };
2848
- }
2849
- function runGit(cwd, args, options = {}) {
2850
- const commandClass = options.commandClass ?? classifyGitCommand(args);
2851
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
2852
- return new Promise((resolve6, reject) => {
2853
- const child = execFile(
2854
- "git",
2855
- args,
2856
- {
2857
- cwd,
2858
- env: buildGitEnv(),
2859
- timeout: timeoutMs,
2860
- // SIGTERM (Node's default) rather than SIGKILL: gives git a chance
2861
- // to run its own cleanup on POSIX, relevant to EF-20's own concern
2862
- // about a killed operation leaving refs/locks in an uncertain state.
2863
- // Moot on Windows -- Node docs: both map to an abrupt TerminateProcess
2864
- // there -- but this module isn't Windows-gated.
2865
- killSignal: "SIGTERM",
2866
- windowsHide: true,
2867
- encoding: "utf8"
2868
- },
2869
- (error, stdout, stderr) => {
2870
- if (error) {
2871
- if (error.killed) {
2872
- reject(
2873
- new GitTimeoutError(
2874
- // NOTE: embeds the raw args, including any secrets a future
2875
- // network-class caller might pass (e.g. a credentialed
2876
- // clone URL). No current caller does; redact before one does.
2877
- `git ${args.join(" ")} timed out after ${timeoutMs}ms (class: ${commandClass})`,
2878
- { cause: error }
2879
- )
2880
- );
2881
- return;
2882
- }
2883
- reject(error);
2884
- return;
2885
- }
2886
- resolve6({ stdout, stderr });
2911
+ openclaw: {
2912
+ enabled: true,
2913
+ workspace_root: "~/.openclaw"
2914
+ },
2915
+ roster: rosterEntries
2916
+ };
2917
+ const arcaneDir = join4(baseDir, "agents");
2918
+ if (!options.dryRun) {
2919
+ await mkdir4(arcaneDir, { recursive: true });
2920
+ await writeFile6(join4(baseDir, "agents.yaml"), stringify(roster), "utf8");
2921
+ } else {
2922
+ console.log(` [dry-run] Would write: ${label2}/agents.yaml`);
2923
+ }
2924
+ for (const roleId of selectedRoleIds) {
2925
+ const src = join4(assetsDir, "agents", `${roleId}.yaml`);
2926
+ const dest = join4(arcaneDir, `${roleId}.yaml`);
2927
+ if (!options.dryRun) {
2928
+ try {
2929
+ await copyFile2(src, dest);
2930
+ } catch {
2931
+ console.warn(` ! Could not copy definition for "${roleId}" \u2014 skipping`);
2887
2932
  }
2888
- );
2889
- child.stdin?.end();
2890
- });
2891
- }
2892
- async function inspectGitRepository(cwd) {
2893
- try {
2894
- const { stdout: stdout2 } = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
2895
- if (stdout2.trim() !== "true") {
2896
- return { status: "not-repository" };
2933
+ } else {
2934
+ console.log(` [dry-run] Would copy: ${label2}/agents/${roleId}.yaml`);
2897
2935
  }
2898
- } catch {
2899
- return { status: "not-repository" };
2900
2936
  }
2901
- try {
2902
- await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
2903
- } catch {
2904
- return { status: "no-commits" };
2937
+ if (options.dryRun) {
2938
+ console.log(
2939
+ `
2940
+ [dry-run] Would initialize roster with ${selectedRoleIds.length} agents`
2941
+ );
2942
+ return;
2905
2943
  }
2906
- const { stdout } = await runGit(cwd, ["status", "--porcelain", "-uall"]);
2907
- const lines = stdout.trim();
2908
- return {
2909
- status: "ready",
2910
- uncommittedChanges: lines.length === 0 ? 0 : lines.split("\n").length
2911
- };
2912
- }
2913
- async function correctUnbornMasterDefault(cwd) {
2914
- let current;
2915
- try {
2916
- const { stdout } = await runGit(cwd, ["symbolic-ref", "--short", "HEAD"]);
2917
- current = stdout.trim();
2918
- } catch {
2919
- return { corrected: false, to: "main" };
2944
+ printStep("Syncing agent definitions to all clients...");
2945
+ const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2946
+ targetDir,
2947
+ assetsDir,
2948
+ roster,
2949
+ { dryRun: false, scope }
2950
+ );
2951
+ printSuccess("Agent roster initialized");
2952
+ console.log(` \u{1F4CB} Profile: ${profileId} \xB7 Naming: ${namingStrategy} \xB7 Agents: ${rosterEntries.length}`);
2953
+ console.log(
2954
+ ` \u{1F504} Synced: ${synced.length} outputs (${scope === "user" ? "agent personas in ~/.claude/agents" : "Copilot, Claude, Codex, OpenClaw"})`
2955
+ );
2956
+ if (skipped.length > 0) {
2957
+ console.log(` \u26A0\uFE0F Skipped: ${skipped.length} (${skipped.join(", ")})`);
2920
2958
  }
2921
- if (current !== "master") {
2922
- return { corrected: false, to: "main" };
2959
+ console.log();
2960
+ console.log(
2961
+ ` \u{1F4A1} Customize: edit ${label2}/agents.yaml \u2192 spell agents sync${scope === "user" ? " --user" : ""}`
2962
+ );
2963
+ console.log();
2964
+ if (hasUnresolvedRoles) {
2965
+ console.error(
2966
+ ' \u2717 One or more rostered roles could not be resolved (see "Skipped" above) \u2014 a malformed or missing agent template must not pass silently.'
2967
+ );
2968
+ process.exitCode = 1;
2923
2969
  }
2970
+ }
2971
+ async function runAgentsSync(options, targetDir, assetsDir) {
2972
+ const scope = options.scope ?? "repo";
2973
+ let roster;
2974
+ let definitionsDir;
2975
+ let fromUserTier = false;
2924
2976
  try {
2925
- await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
2926
- return { corrected: false, to: "main" };
2927
- } catch {
2977
+ ({ roster, definitionsDir, fromUserTier } = await resolveRoster(targetDir, scope));
2978
+ } catch (err) {
2979
+ if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
2980
+ console.error(err.message);
2981
+ process.exit(1);
2982
+ return;
2983
+ }
2984
+ throw err;
2928
2985
  }
2929
- let mainRef;
2930
- try {
2931
- mainRef = await runGit(cwd, ["for-each-ref", "--format=%(objectname)", "refs/heads/main"]);
2932
- } catch {
2933
- return { corrected: false, to: "main", blockedReason: "target-unreadable" };
2986
+ if (options.dryRun) {
2987
+ console.log("\n [dry-run] Would sync agents from roster:\n");
2988
+ } else {
2989
+ console.log("\n Syncing agents...\n");
2934
2990
  }
2935
- if (mainRef.stdout.trim() !== "") {
2936
- return { corrected: false, to: "main" };
2991
+ if (fromUserTier && scope === "repo") {
2992
+ console.log(
2993
+ " This repository has no roster of its own and takes its spells from the user tier,\n so its roster tables are built from ~/.arcane/agents.yaml.\n"
2994
+ );
2937
2995
  }
2938
- if (mainRef.stderr.trim() !== "") {
2939
- return { corrected: false, to: "main", blockedReason: "target-unreadable" };
2996
+ const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2997
+ targetDir,
2998
+ assetsDir,
2999
+ roster,
3000
+ { ...options, scope, ...definitionsDir === void 0 ? {} : { definitionsDir } }
3001
+ );
3002
+ if (options.dryRun) {
3003
+ console.log(`
3004
+ [dry-run] ${synced.length} outputs would be generated`);
3005
+ return;
2940
3006
  }
2941
- try {
2942
- await runGit(cwd, ["symbolic-ref", "-q", "refs/heads/main"]);
2943
- return { corrected: false, to: "main", blockedReason: "target-unreadable" };
2944
- } catch (error) {
2945
- if (error.code !== 1) {
2946
- return { corrected: false, to: "main", blockedReason: "target-unreadable" };
2947
- }
3007
+ console.log(`
3008
+ \u2713 Synced ${synced.length} outputs`);
3009
+ for (const item of synced) {
3010
+ console.log(` + ${item}`);
2948
3011
  }
2949
- await runGit(cwd, ["symbolic-ref", "HEAD", "refs/heads/main"]);
2950
- return { corrected: true, from: current, to: "main" };
2951
- }
2952
- async function ensureLocalPullRebase(cwd) {
2953
- let rawLocalValue;
2954
- try {
2955
- const { stdout } = await runGit(cwd, ["config", "--local", "--get", "pull.rebase"]);
2956
- rawLocalValue = stdout.trim();
2957
- } catch {
2958
- rawLocalValue = void 0;
3012
+ if (skipped.length > 0) {
3013
+ console.log(`
3014
+ ! Skipped ${skipped.length}:`);
3015
+ for (const item of skipped) {
3016
+ console.log(` - ${item}`);
3017
+ }
2959
3018
  }
2960
- if (rawLocalValue === void 0) {
2961
- await runGit(cwd, ["config", "--local", "pull.rebase", "true"]);
2962
- return { action: "set" };
3019
+ console.log();
3020
+ if (hasUnresolvedRoles) {
3021
+ console.error(
3022
+ ' \u2717 One or more rostered roles could not be resolved (see "Skipped" above) \u2014 a malformed or missing agent template must not pass silently.'
3023
+ );
3024
+ process.exitCode = 1;
2963
3025
  }
3026
+ }
3027
+ async function runAgentsList(targetDir, scope = "repo") {
3028
+ let roster;
2964
3029
  try {
2965
- const { stdout } = await runGit(cwd, ["config", "--local", "--type=bool", "--get", "pull.rebase"]);
2966
- if (stdout.trim() === "false") {
2967
- return { action: "explicit-false-preserved" };
3030
+ roster = await loadRoster(targetDir, scope);
3031
+ } catch (err) {
3032
+ if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
3033
+ console.error(err.message);
3034
+ process.exit(1);
3035
+ return;
2968
3036
  }
2969
- return { action: "already-set" };
2970
- } catch {
2971
- return { action: "already-set" };
3037
+ throw err;
3038
+ }
3039
+ const nameWidth = Math.max(
3040
+ "Name".length,
3041
+ ...roster.roster.map((e) => (e.name ?? e.definition).length)
3042
+ );
3043
+ const defWidth = Math.max(
3044
+ "Role ID".length,
3045
+ ...roster.roster.map((e) => e.definition.length)
3046
+ );
3047
+ const idWidth = Math.max(
3048
+ "OpenClaw ID".length,
3049
+ ...roster.roster.map((e) => e.id.length)
3050
+ );
3051
+ const pad = (s, n) => s.padEnd(n);
3052
+ console.log(
3053
+ `
3054
+ Profile: ${roster.agent_profile} | Naming: ${roster.naming_strategy} | Agents: ${roster.roster.length}
3055
+ `
3056
+ );
3057
+ console.log(
3058
+ ` ${pad("Name", nameWidth)} ${pad("Role ID", defWidth)} ${pad("OpenClaw ID", idWidth)}`
3059
+ );
3060
+ console.log(
3061
+ ` ${"-".repeat(nameWidth)} ${"-".repeat(defWidth)} ${"-".repeat(idWidth)}`
3062
+ );
3063
+ for (const entry of roster.roster) {
3064
+ console.log(
3065
+ ` ${pad(entry.name ?? "(unnamed)", nameWidth)} ${pad(entry.definition, defWidth)} ${pad(entry.id, idWidth)}`
3066
+ );
2972
3067
  }
3068
+ console.log();
2973
3069
  }
2974
3070
 
3071
+ // src/modules/hub.ts
3072
+ import { confirm, select as select2, input as input2 } from "@inquirer/prompts";
3073
+ import { readdir as readdir5, writeFile as writeFile7 } from "fs/promises";
3074
+ import { join as join5 } from "path";
3075
+
2975
3076
  // src/modules/ado-context.ts
2976
3077
  function remoteUrlHost(url) {
2977
3078
  const trimmed = url.trim();
@@ -4491,14 +4592,14 @@ async function planComponentInstall(component, targetDir, assetsDir, opts = {})
4491
4592
  }
4492
4593
  return { component, files, toCopy: of("missing", "identical"), kept: [], conflicts: of("differs", "directory") };
4493
4594
  }
4494
- async function executeInstallPlan(plan, targetDir) {
4595
+ async function executeInstallPlan(plan, targetDir, opts = {}) {
4495
4596
  const files = [];
4496
4597
  const fileHashes = {};
4497
4598
  const written = [];
4498
4599
  try {
4499
4600
  for (const { file, src, state } of plan.toCopy) {
4500
4601
  const previous = state === "missing" ? null : await readFile8(join9(targetDir, file));
4501
- fileHashes[file] = await copyFile(src, targetDir, file, { force: true });
4602
+ fileHashes[file] = await copyFile(src, targetDir, file, { force: true, ...opts });
4502
4603
  written.push({ file, previous });
4503
4604
  files.push(file);
4504
4605
  }
@@ -4652,7 +4753,7 @@ async function runAdd(names, options, targetDir, assetsDir, packageVersion) {
4652
4753
 
4653
4754
  // src/commands/update.ts
4654
4755
  import { dirname as dirname6, join as join11 } from "path";
4655
- import { readFile as readFile9, writeFile as writeFile10 } from "fs/promises";
4756
+ import { readFile as readFile9 } from "fs/promises";
4656
4757
 
4657
4758
  // src/modules/merge3.ts
4658
4759
  var CONFLICT_START = "<<<<<<< yours (edited)";
@@ -4945,11 +5046,11 @@ function describeInstallPlan(plan) {
4945
5046
  }
4946
5047
  return lines;
4947
5048
  }
4948
- async function executeUpdateInstalls(plan, targetDir, packageVersion) {
5049
+ async function executeUpdateInstalls(plan, targetDir, packageVersion, copyOpts = {}) {
4949
5050
  const installed = [];
4950
5051
  const lines = [];
4951
5052
  for (const item of plan.items) {
4952
- const { files, fileHashes } = await executeInstallPlan(item.plan, targetDir);
5053
+ const { files, fileHashes } = await executeInstallPlan(item.plan, targetDir, copyOpts);
4953
5054
  installed.push({
4954
5055
  name: item.plan.component.name,
4955
5056
  files,
@@ -4986,7 +5087,7 @@ async function missingTierWarning(scope, spellScope, homeDir) {
4986
5087
  } catch {
4987
5088
  return [
4988
5089
  "",
4989
- ` ! This repository takes its spells from the user tier, and ${storeRoot} is not installed.`,
5090
+ ` ! This repository takes its spells from the user tier, and ${storeRoot} is not installed (or its manifest cannot be read).`,
4990
5091
  " No client can reach a spell here: the routing block in CLAUDE.md and AGENTS.md still",
4991
5092
  " names spells that resolve to nothing, so an agent improvises the workflow instead of",
4992
5093
  " failing. Fix it with one of:",
@@ -5092,8 +5193,17 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
5092
5193
  if (!await fileExists(filePath)) return "not-found";
5093
5194
  if (!prune) return "reported";
5094
5195
  if (recordedHash === void 0) {
5095
- console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
5096
- return "reported";
5196
+ const evidence = await isUneditedTrackedFile(targetDir, file);
5197
+ if (evidence.verdict !== "unedited") {
5198
+ console.log(
5199
+ ` ! Orphaned, not pruned: ${file} \u2014 no recorded hash, and git cannot confirm it is untouched (${evidence.detail}). Delete it yourself (git rm -- <path> if git tracks it), then run spell update --prune again to drop it from .arcane.json.`
5200
+ );
5201
+ return "reported";
5202
+ }
5203
+ await removeWithin(targetDir, file);
5204
+ await removeEmptyAncestors(targetDir, file);
5205
+ console.log(` Pruned orphaned file (no recorded hash; git shows it ${evidence.detail}): ${file}`);
5206
+ return "pruned";
5097
5207
  }
5098
5208
  if (!await fileMatchesHash(filePath, recordedHash)) {
5099
5209
  console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
@@ -5237,7 +5347,15 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5237
5347
  const spellScope = effectiveSpellScope(manifest);
5238
5348
  const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
5239
5349
  const homeDir = dirname6(targetDir);
5240
- for (const line of await missingTierWarning(scope, spellScope)) console.log(line);
5350
+ const tierGate = await missingTierWarning(scope, spellScope);
5351
+ if (tierGate.length > 0) {
5352
+ for (const line of tierGate) console.error(line);
5353
+ console.error(
5354
+ " \u2717 Update refused: nothing was written. Install the tier or change spell_scope, then run `spell update` again."
5355
+ );
5356
+ process.exit(1);
5357
+ return;
5358
+ }
5241
5359
  if (scope === "repo") {
5242
5360
  console.warn(
5243
5361
  `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
@@ -5268,6 +5386,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5268
5386
  return;
5269
5387
  }
5270
5388
  }
5389
+ const linkTracked = scope === "repo" ? await listSymlinkTrackedPaths(targetDir) : /* @__PURE__ */ new Set();
5390
+ const conversions = [];
5271
5391
  const registryChanges = {
5272
5392
  retired: findTrackedRetirements(manifest.components),
5273
5393
  ...scope === "repo" ? {
@@ -5307,7 +5427,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5307
5427
  for (const line of describeInstallPlan(installPlan)) console.log(line);
5308
5428
  return [];
5309
5429
  }
5310
- const { installed, lines } = await executeUpdateInstalls(installPlan, targetDir, packageVersion);
5430
+ const { installed, lines } = await executeUpdateInstalls(installPlan, targetDir, packageVersion, {
5431
+ linkTracked,
5432
+ conversions
5433
+ });
5311
5434
  for (const line of lines) console.log(line);
5312
5435
  return installed;
5313
5436
  };
@@ -5423,7 +5546,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5423
5546
  if (options.prune && !options.dryRun && installed.files.every((f) => orphanReport.find((o) => o.file === f)?.status !== "reported")) {
5424
5547
  continue;
5425
5548
  }
5426
- updatedComponents.push(installed);
5549
+ const keptHashes = {};
5550
+ for (const file of stillTracked) {
5551
+ const hash = installed.fileHashes?.[file];
5552
+ if (hash !== void 0) keptHashes[file] = hash;
5553
+ }
5554
+ updatedComponents.push({
5555
+ ...installed,
5556
+ files: stillTracked,
5557
+ ...installed.fileHashes ? { fileHashes: keptHashes } : {}
5558
+ });
5427
5559
  continue;
5428
5560
  }
5429
5561
  throw err;
@@ -5452,7 +5584,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5452
5584
  continue;
5453
5585
  }
5454
5586
  }
5455
- const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join11(targetDir, file), recordedHash);
5587
+ const linkShape = targetExists ? await managedPathLinkShape(targetDir, file, linkTracked, isClientShimPath(file)) : null;
5588
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && linkShape === null && !await fileMatchesHash(join11(targetDir, file), recordedHash);
5456
5589
  if (preserveExisting) {
5457
5590
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
5458
5591
  } else if (editedByOperator && isClientShimPath(file)) {
@@ -5462,7 +5595,9 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5462
5595
  ` ${options.dryRun ? "[dry-run] Would keep" : "Kept"} customized (not merged): ${file}`
5463
5596
  );
5464
5597
  } else if (options.dryRun) {
5465
- console.log(` [dry-run] Would ${sameVersionRestore ? "restore missing" : "update"}: ${file}`);
5598
+ console.log(
5599
+ ` [dry-run] Would ${linkShape !== null ? "convert to a regular file (it is a symlink, or a link written out as text)" : sameVersionRestore ? "restore missing" : "update"}: ${file}`
5600
+ );
5466
5601
  fileCount++;
5467
5602
  } else {
5468
5603
  let handled = false;
@@ -5483,7 +5618,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5483
5618
  readFile9(srcPath, "utf-8")
5484
5619
  ]);
5485
5620
  const result = merge3(oldVendorContent, currentContent, newContent);
5486
- await writeFile10(join11(targetDir, file), result.content, "utf-8");
5621
+ await writeManagedFile(targetDir, file, result.content, { linkTracked, conversions });
5487
5622
  fileHashes[file] = await hashFile(srcPath);
5488
5623
  if (result.hasConflict) {
5489
5624
  conflictedFiles.push(file);
@@ -5495,7 +5630,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5495
5630
  }
5496
5631
  }
5497
5632
  if (!handled) {
5498
- fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: true });
5633
+ fileHashes[file] = await copyFile(srcPath, targetDir, file, { force: true, linkTracked, conversions });
5634
+ if (linkShape === "path-body") conversions.push({ file, shape: "path-body" });
5499
5635
  if (sameVersionRestore) console.log(` Restored missing: ${file}`);
5500
5636
  }
5501
5637
  fileCount++;
@@ -5509,7 +5645,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5509
5645
  if (options.dryRun) {
5510
5646
  console.log(` [dry-run] Would update directory: ${dir}/`);
5511
5647
  } else {
5512
- const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: true });
5648
+ const copied = await copyDirectory(srcDirPath, targetDir, dir, { force: true, linkTracked, conversions });
5513
5649
  for (const { path: copiedPath, hash } of copied) {
5514
5650
  updatedFiles.push(copiedPath);
5515
5651
  fileHashes[copiedPath] = hash;
@@ -5573,6 +5709,21 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
5573
5709
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
5574
5710
  }
5575
5711
  }
5712
+ if (conversions.length > 0) {
5713
+ console.log(`
5714
+ Converted ${conversions.length} symlinked managed file(s) to regular files (#357):`);
5715
+ const describe = {
5716
+ "index-symlink": "the index tracked it as a symlink (mode 120000); rewritten and staged as a regular file",
5717
+ symlink: "was a symlink in the working tree; now a regular file (stage the typechange with git add)",
5718
+ "path-body": "its body was only a link target, a symlink written out as text; rewritten as a real file"
5719
+ };
5720
+ for (const c of conversions) console.log(` ${c.file} \u2014 ${describe[c.shape]}`);
5721
+ if (conversions.some((c) => c.shape === "index-symlink")) {
5722
+ console.log(
5723
+ " Commit the staged typechange, or every other checkout keeps reading a link whose target is the file's own text."
5724
+ );
5725
+ }
5726
+ }
5576
5727
  printRegistryChanges();
5577
5728
  if (customizedShims.length > 0) {
5578
5729
  const verb = options.dryRun ? "[dry-run] Would keep" : "Kept";
@@ -5668,7 +5819,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
5668
5819
  import { dirname as dirname8 } from "path";
5669
5820
 
5670
5821
  // src/modules/version-check.ts
5671
- import { readFile as readFile10, writeFile as writeFile11, mkdir as mkdir6 } from "fs/promises";
5822
+ import { readFile as readFile10, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
5672
5823
  import { dirname as dirname7, join as join12 } from "path";
5673
5824
  import { homedir as homedir3 } from "os";
5674
5825
  import { createRequire } from "module";
@@ -5700,7 +5851,7 @@ async function readCache(cachePath) {
5700
5851
  async function writeCache(cachePath, latest) {
5701
5852
  await mkdir6(dirname7(cachePath), { recursive: true });
5702
5853
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
5703
- await writeFile11(cachePath, JSON.stringify(entry, null, 2), "utf-8");
5854
+ await writeFile10(cachePath, JSON.stringify(entry, null, 2), "utf-8");
5704
5855
  }
5705
5856
  async function getLatestVersion(feedUrl) {
5706
5857
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -7139,6 +7290,49 @@ async function checkPlatformBranchPolicy(targetDir) {
7139
7290
  message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
7140
7291
  };
7141
7292
  }
7293
+ async function checkManagedFileModes(targetDir) {
7294
+ const name = "Managed files are regular files (#357)";
7295
+ let manifest;
7296
+ try {
7297
+ manifest = await readManifest(targetDir);
7298
+ } catch {
7299
+ return { name, passed: true, skipped: true, message: "no .arcane.json \u2014 nothing to check" };
7300
+ }
7301
+ try {
7302
+ await runGit(targetDir, ["rev-parse", "--git-dir"]);
7303
+ } catch {
7304
+ return { name, passed: true, skipped: true, message: "not a git repository \u2014 the index cannot be read" };
7305
+ }
7306
+ const managed = [...new Set(manifest.components.flatMap((c) => c.files))];
7307
+ const linkTracked = await listSymlinkTrackedPaths(targetDir);
7308
+ const asLinks = managed.filter((file) => linkTracked.has(file));
7309
+ const pathBodies = [];
7310
+ for (const file of managed) {
7311
+ if (!isClientShimPath(file) || asLinks.includes(file)) continue;
7312
+ let body;
7313
+ try {
7314
+ body = await readFile13(join16(targetDir, file), "utf-8");
7315
+ } catch {
7316
+ continue;
7317
+ }
7318
+ if (looksLikeBarePath(body)) pathBodies.push(file);
7319
+ }
7320
+ if (asLinks.length === 0 && pathBodies.length === 0) {
7321
+ return { name, passed: true, message: `${managed.length} managed path(s), all regular files` };
7322
+ }
7323
+ const parts = [];
7324
+ if (asLinks.length > 0) {
7325
+ parts.push(`${asLinks.length} tracked as a symlink (mode 120000): ${asLinks.join(", ")}`);
7326
+ }
7327
+ if (pathBodies.length > 0) {
7328
+ parts.push(`${pathBodies.length} client shim(s) whose body is only a path: ${pathBodies.join(", ")}`);
7329
+ }
7330
+ return {
7331
+ name,
7332
+ passed: false,
7333
+ message: parts.join("; ") + " \u2014 clients silently skip or empty these spells. Run `spell update` (it rewrites them as regular files and stages the typechange), then commit."
7334
+ };
7335
+ }
7142
7336
  async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
7143
7337
  const name = "Spell scope (ARC-045)";
7144
7338
  let manifest;
@@ -7234,6 +7428,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
7234
7428
  checkMcpConfig(targetDir),
7235
7429
  checkUserTier(resolveHomeDir(), options.packageVersion),
7236
7430
  checkSpellScope(targetDir),
7431
+ checkManagedFileModes(targetDir),
7237
7432
  checkGovernancePlaceholders(targetDir),
7238
7433
  checkComponentRequires(targetDir),
7239
7434
  checkNewlyAvailableComponents(targetDir)
@@ -8451,7 +8646,7 @@ import { access as access5 } from "fs/promises";
8451
8646
  import { join as join22 } from "path";
8452
8647
 
8453
8648
  // src/modules/show-report/generate.ts
8454
- import { mkdir as mkdir8, readdir as readdir11, readFile as readFile18, writeFile as writeFile12 } from "fs/promises";
8649
+ import { mkdir as mkdir8, readdir as readdir11, readFile as readFile18, writeFile as writeFile11 } from "fs/promises";
8455
8650
  import { basename as basename4, dirname as dirname12, isAbsolute, join as join21, relative as relative3, resolve as resolve5 } from "path";
8456
8651
 
8457
8652
  // src/modules/show-report/model.ts
@@ -9122,7 +9317,7 @@ async function runReportGeneration(options) {
9122
9317
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
9123
9318
  if (options.mode === "write") {
9124
9319
  await mkdir8(outAbs, { recursive: true });
9125
- await writeFile12(join21(options.rootDir, relPath), expected, "utf8");
9320
+ await writeFile11(join21(options.rootDir, relPath), expected, "utf8");
9126
9321
  repaired.push(relPath);
9127
9322
  }
9128
9323
  }