arcane-cli 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,11 +6,11 @@ const require = __createRequire(import.meta.url);
6
6
  import { Command } from "commander";
7
7
  import { createRequire as createRequire2 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as dirname10, join as join18 } from "path";
9
+ import { dirname as dirname11, join as join18 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
13
- import { dirname as dirname3, join as join6 } from "path";
13
+ import { dirname as dirname4, join as join6 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
@@ -20,6 +20,7 @@ import {
20
20
  readdir,
21
21
  readFile,
22
22
  rm,
23
+ rmdir,
23
24
  writeFile
24
25
  } from "fs/promises";
25
26
  import { createHash } from "crypto";
@@ -89,6 +90,20 @@ async function removeWithin(targetDir, relativePath) {
89
90
  validateTargetPath(targetDir, relativePath);
90
91
  await rm(path.resolve(targetDir, relativePath), { force: true });
91
92
  }
93
+ async function removeEmptyAncestors(targetDir, relativePath) {
94
+ validateTargetPath(targetDir, relativePath);
95
+ const base = path.resolve(targetDir);
96
+ let dir = path.dirname(path.resolve(targetDir, relativePath));
97
+ while (dir !== base && dir.startsWith(base + path.sep)) {
98
+ try {
99
+ if ((await readdir(dir)).length > 0) return;
100
+ await rmdir(dir);
101
+ } catch {
102
+ return;
103
+ }
104
+ dir = path.dirname(dir);
105
+ }
106
+ }
92
107
  async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
93
108
  validateTargetPath(targetDir, relativeDir);
94
109
  const copiedFiles = [];
@@ -201,6 +216,9 @@ function validateTrackingFields(manifest, filePath) {
201
216
  if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
202
217
  throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
203
218
  }
219
+ if (manifest.spell_scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.spell_scope)) {
220
+ throw new ManifestInvalidFieldError(filePath, "spell_scope", manifest.spell_scope);
221
+ }
204
222
  if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
205
223
  throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
206
224
  }
@@ -866,7 +884,7 @@ function listProfiles() {
866
884
  }
867
885
 
868
886
  // src/modules/user-tier.ts
869
- import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir, writeFile as writeFile3 } from "fs/promises";
887
+ import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir as rmdir2, writeFile as writeFile3 } from "fs/promises";
870
888
  import { homedir } from "os";
871
889
  import { dirname, join, resolve } from "path";
872
890
 
@@ -999,6 +1017,14 @@ function componentForScope(component, scope) {
999
1017
  }
1000
1018
  return { name: component.name, description: component.description, files, sourceOverrides };
1001
1019
  }
1020
+ function componentForSpellScope(component, spellScope) {
1021
+ if (spellScope === "repo") return component;
1022
+ if (!SPELL_COMPONENT_NAMES.includes(component.name)) return component;
1023
+ return { ...component, files: [] };
1024
+ }
1025
+ function effectiveSpellScope(manifest) {
1026
+ return manifest.spell_scope ?? "repo";
1027
+ }
1002
1028
  function spellIdsInStore(components) {
1003
1029
  const ids = /* @__PURE__ */ new Set();
1004
1030
  for (const component of components) {
@@ -1009,18 +1035,25 @@ function spellIdsInStore(components) {
1009
1035
  }
1010
1036
  return [...ids].sort();
1011
1037
  }
1038
+ var USER_AGENT_FANOUT_DIR = ".copilot/agents";
1039
+ var SPELL_FANOUT_CLIENTS = ["codex", "claude"];
1012
1040
  var USER_FANOUT_PATHS = {
1013
1041
  codex: (id) => `.agents/skills/${id}/SKILL.md`,
1014
1042
  claude: (id) => `.claude/commands/${id}.md`
1015
1043
  };
1044
+ function userAgentFanoutPath(slug) {
1045
+ return `${USER_AGENT_FANOUT_DIR}/${slug}.agent.md`;
1046
+ }
1016
1047
  var FANOUT_CLIENT_LABELS = {
1017
1048
  codex: "Codex/Copilot skills",
1018
- claude: "Claude Code commands"
1049
+ claude: "Claude Code commands",
1050
+ "copilot-agents": "Copilot agent modes"
1019
1051
  };
1020
1052
  function clientOfFanoutPath(relativePath) {
1021
1053
  const normalized = relativePath.replace(/\\/g, "/");
1022
1054
  if (normalized.startsWith(".agents/skills/")) return "codex";
1023
1055
  if (normalized.startsWith(".claude/commands/")) return "claude";
1056
+ if (normalized.startsWith(`${USER_AGENT_FANOUT_DIR}/`)) return "copilot-agents";
1024
1057
  return void 0;
1025
1058
  }
1026
1059
  async function readSpellSource(storeRoot, id, fallbackDir) {
@@ -1076,20 +1109,22 @@ function perSpellDirectory(homeDir, relativePath) {
1076
1109
  async function removeDirectoryIfEmpty(dir) {
1077
1110
  try {
1078
1111
  const entries = await readdir2(dir);
1079
- if (entries.length === 0) await rmdir(dir);
1112
+ if (entries.length === 0) await rmdir2(dir);
1080
1113
  } catch {
1081
1114
  }
1082
1115
  }
1083
1116
  async function syncUserTierFanout(options) {
1084
1117
  const rendered = await renderUserFanout(options.storeRoot, options.spellIds, options.fallbackDir);
1085
- const desired = rendered.files;
1118
+ const desired = [...rendered.files, ...options.extraFiles ?? []];
1119
+ const owned = options.ownedClients;
1120
+ const ownsClient = (client) => owned === void 0 || client !== void 0 && owned.includes(client);
1086
1121
  const previous = options.previous ?? {};
1087
1122
  const dryRun = Boolean(options.dryRun);
1088
1123
  const record = {};
1089
1124
  const outcomes = [];
1090
1125
  const desiredPaths = new Set(desired.map((f) => f.relativePath));
1091
1126
  for (const id of rendered.missing) {
1092
- for (const client of ["codex", "claude"]) {
1127
+ for (const client of SPELL_FANOUT_CLIENTS) {
1093
1128
  const relativePath = USER_FANOUT_PATHS[client](id);
1094
1129
  desiredPaths.add(relativePath);
1095
1130
  if (previous[relativePath] !== void 0) record[relativePath] = previous[relativePath];
@@ -1132,6 +1167,10 @@ async function syncUserTierFanout(options) {
1132
1167
  }
1133
1168
  for (const [relativePath, recorded] of Object.entries(previous)) {
1134
1169
  if (desiredPaths.has(relativePath)) continue;
1170
+ if (!ownsClient(clientOfFanoutPath(relativePath))) {
1171
+ record[relativePath] = recorded;
1172
+ continue;
1173
+ }
1135
1174
  validateTargetPath(options.homeDir, relativePath);
1136
1175
  const target = join(options.homeDir, relativePath);
1137
1176
  const state = await classifyTarget(target);
@@ -1172,7 +1211,7 @@ function describeFanoutOutcomes(outcomes, dryRun = false) {
1172
1211
  const lines = [];
1173
1212
  const prefix = dryRun ? "[dry-run] Would " : "";
1174
1213
  const byClient = (status) => {
1175
- const n = { codex: 0, claude: 0 };
1214
+ const n = { codex: 0, claude: 0, "copilot-agents": 0 };
1176
1215
  for (const o of outcomes) {
1177
1216
  if (o.status !== status) continue;
1178
1217
  const client = clientOfFanoutPath(o.relativePath);
@@ -1183,8 +1222,9 @@ function describeFanoutOutcomes(outcomes, dryRun = false) {
1183
1222
  const paths = (status) => outcomes.filter((o) => o.status === status).map((o) => ` ~/${o.relativePath}`).join("\n");
1184
1223
  if (counts.written > 0) {
1185
1224
  const n = byClient("written");
1225
+ const breakdown = Object.keys(n).filter((client) => n[client] > 0).map((client) => `${n[client]} ${FANOUT_CLIENT_LABELS[client]}`).join(", ");
1186
1226
  lines.push(
1187
- `${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${n.codex} ${FANOUT_CLIENT_LABELS.codex}, ${n.claude} ${FANOUT_CLIENT_LABELS.claude}.`
1227
+ `${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${breakdown}.`
1188
1228
  );
1189
1229
  }
1190
1230
  if (counts.pruned > 0) {
@@ -1222,7 +1262,7 @@ ${paths("kept-edited")}`
1222
1262
  async function inspectUserTierFanout(homeDir, record) {
1223
1263
  const health = {
1224
1264
  total: 0,
1225
- byClient: { codex: 0, claude: 0 },
1265
+ byClient: { codex: 0, claude: 0, "copilot-agents": 0 },
1226
1266
  missing: [],
1227
1267
  customized: []
1228
1268
  };
@@ -1399,7 +1439,7 @@ function printWarning(message) {
1399
1439
  // src/modules/agents.ts
1400
1440
  import { select, checkbox, Separator } from "@inquirer/prompts";
1401
1441
  import { mkdir as mkdir4, writeFile as writeFile6, copyFile as copyFile2 } from "fs/promises";
1402
- import { join as join4 } from "path";
1442
+ import { dirname as dirname2, join as join4 } from "path";
1403
1443
  import { stringify } from "yaml";
1404
1444
 
1405
1445
  // src/config/agent-roles.ts
@@ -1808,9 +1848,15 @@ var AgentDefinitionNotFoundError = class extends Error {
1808
1848
  }
1809
1849
  };
1810
1850
  var AgentRosterNotFoundError = class extends Error {
1811
- constructor() {
1851
+ /**
1852
+ * Names the tier the caller actually asked for. A `--user` run that reports
1853
+ * the repository's path sends the operator to the wrong file and the wrong
1854
+ * command, which is worse than no message.
1855
+ */
1856
+ constructor(scope = "repo") {
1857
+ const user = scope === "user";
1812
1858
  super(
1813
- 'No agent roster found at .arcane/agents.yaml. Run "spell agents init" to create one.'
1859
+ `No agent roster found at ${user ? "~/.arcane/agents.yaml" : ".arcane/agents.yaml"}. Run "spell agents init${user ? " --user" : ""}" to create one.`
1814
1860
  );
1815
1861
  this.name = "AgentRosterNotFoundError";
1816
1862
  }
@@ -1833,30 +1879,33 @@ async function loadAgentDefinition(agentsDir, id) {
1833
1879
  }
1834
1880
  return validateAgentDefinition(parseYaml2(content, filePath), filePath);
1835
1881
  }
1836
- async function loadRoster(targetDir) {
1837
- const rosterPath = join2(targetDir, ".arcane", "agents.yaml");
1882
+ function agentsBaseDir(targetDir, scope = "repo") {
1883
+ return scope === "user" ? targetDir : join2(targetDir, ".arcane");
1884
+ }
1885
+ async function loadRoster(targetDir, scope = "repo") {
1886
+ const rosterPath = join2(agentsBaseDir(targetDir, scope), "agents.yaml");
1838
1887
  let content;
1839
1888
  try {
1840
1889
  content = await readFile4(rosterPath, "utf8");
1841
1890
  } catch {
1842
- throw new AgentRosterNotFoundError();
1891
+ throw new AgentRosterNotFoundError(scope);
1843
1892
  }
1844
1893
  return validateAgentRoster(parseYaml2(content, rosterPath), rosterPath);
1845
1894
  }
1846
- async function rosterExists(targetDir) {
1895
+ async function rosterExists(targetDir, scope = "repo") {
1847
1896
  try {
1848
- await access2(join2(targetDir, ".arcane", "agents.yaml"));
1897
+ await access2(join2(agentsBaseDir(targetDir, scope), "agents.yaml"));
1849
1898
  return true;
1850
1899
  } catch {
1851
1900
  return false;
1852
1901
  }
1853
1902
  }
1854
- function projectAgentsDir(targetDir) {
1855
- return join2(targetDir, ".arcane", "agents");
1903
+ function projectAgentsDir(targetDir, scope = "repo") {
1904
+ return join2(agentsBaseDir(targetDir, scope), "agents");
1856
1905
  }
1857
1906
 
1858
1907
  // src/modules/agent-generator.ts
1859
- import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
1908
+ import { writeFile as writeFile5, mkdir as mkdir3, readdir as readdir4 } from "fs/promises";
1860
1909
  import { join as join3 } from "path";
1861
1910
  import { homedir as homedir2 } from "os";
1862
1911
 
@@ -2065,11 +2114,23 @@ function renderAgentRosterSection(entries) {
2065
2114
  ${rows}
2066
2115
  `;
2067
2116
  }
2117
+ async function existingAgentFiles(agentsDir) {
2118
+ try {
2119
+ const entries = await readdir4(agentsDir);
2120
+ return entries.filter((name) => name.endsWith(".agent.md")).sort().map((name) => `.github/agents/${name}`);
2121
+ } catch {
2122
+ return [];
2123
+ }
2124
+ }
2068
2125
  async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2069
2126
  const synced = [];
2070
2127
  const skipped = [];
2071
2128
  let hasUnresolvedRoles = false;
2072
- const projectDefs = projectAgentsDir(targetDir);
2129
+ const userTier = options.scope === "user";
2130
+ const repoOptedOut = !userTier && options.spellScope === "user";
2131
+ const userAgentFiles = [];
2132
+ const unmanagedAgentFiles = [];
2133
+ const projectDefs = projectAgentsDir(targetDir, userTier ? "user" : "repo");
2073
2134
  const bundledDefs = join3(assetsDir, "agents");
2074
2135
  const resolved = [];
2075
2136
  for (const entry of roster.roster) {
@@ -2091,7 +2152,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2091
2152
  displayName: entry.name ?? entry.definition
2092
2153
  });
2093
2154
  }
2094
- if (roster.openclaw.enabled && options.openclaw !== false) {
2155
+ if (!userTier && roster.openclaw.enabled && options.openclaw !== false) {
2095
2156
  const openclawRoot = roster.openclaw.workspace_root.replace(
2096
2157
  /^~/,
2097
2158
  homedir2()
@@ -2156,34 +2217,46 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2156
2217
  }
2157
2218
  if (options.copilot !== false) {
2158
2219
  const agentsDir = join3(targetDir, ".github", "agents");
2159
- if (!options.dryRun) {
2160
- await mkdir3(agentsDir, { recursive: true });
2220
+ if (!options.dryRun && !userTier) {
2221
+ await mkdir3(repoOptedOut ? join3(targetDir, ".github") : agentsDir, { recursive: true });
2161
2222
  }
2162
2223
  for (const { def, displayName } of resolved) {
2163
2224
  if (!def.clients.copilot?.agent_file) continue;
2164
2225
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
2165
2226
  const fileName = `${slug}.agent.md`;
2227
+ const content = renderCopilotAgent(def, displayName);
2228
+ if (userTier) {
2229
+ const relativePath = userAgentFanoutPath(slug);
2230
+ userAgentFiles.push({ relativePath, client: "copilot-agents", content });
2231
+ synced.push(`~/${relativePath}`);
2232
+ continue;
2233
+ }
2234
+ if (repoOptedOut) continue;
2166
2235
  if (!options.dryRun) {
2167
- await writeFile5(
2168
- join3(agentsDir, fileName),
2169
- renderCopilotAgent(def, displayName),
2170
- "utf8"
2171
- );
2236
+ await writeFile5(join3(agentsDir, fileName), content, "utf8");
2172
2237
  } else {
2173
2238
  console.log(` [dry-run] Would write: .github/agents/${fileName}`);
2174
2239
  }
2175
2240
  synced.push(`.github/agents/${fileName}`);
2176
2241
  }
2177
- const copilotSection = `${renderSpellRoutingSection()}
2242
+ if (repoOptedOut) {
2243
+ unmanagedAgentFiles.push(...await existingAgentFiles(agentsDir));
2244
+ }
2245
+ if (!userTier) {
2246
+ const copilotSection = `${renderSpellRoutingSection()}
2178
2247
  ${renderAgentRosterSection(resolved)}`;
2179
- const copilotMerged = await mergeIntoFile(
2180
- targetDir,
2181
- ".github/copilot-instructions.md",
2182
- copilotSection,
2183
- { force: true, dryRun: options.dryRun }
2184
- );
2185
- if (copilotMerged)
2186
- synced.push(".github/copilot-instructions.md (agents section)");
2248
+ const copilotMerged = await mergeIntoFile(
2249
+ targetDir,
2250
+ ".github/copilot-instructions.md",
2251
+ copilotSection,
2252
+ { force: true, dryRun: options.dryRun }
2253
+ );
2254
+ if (copilotMerged)
2255
+ synced.push(".github/copilot-instructions.md (agents section)");
2256
+ }
2257
+ }
2258
+ if (userTier) {
2259
+ return { synced, skipped, hasUnresolvedRoles, userAgentFiles, unmanagedAgentFiles };
2187
2260
  }
2188
2261
  if (options.claude !== false) {
2189
2262
  const claudeSection = `${renderSpellRoutingSection()}
@@ -2207,12 +2280,83 @@ ${renderAgentRosterSection(resolved)}`;
2207
2280
  );
2208
2281
  if (codexMerged) synced.push("AGENTS.md (agents section)");
2209
2282
  }
2210
- return { synced, skipped, hasUnresolvedRoles };
2283
+ return { synced, skipped, hasUnresolvedRoles, userAgentFiles, unmanagedAgentFiles };
2211
2284
  }
2212
2285
 
2213
2286
  // src/modules/agents.ts
2287
+ function agentsTargetDir(scope, cwd) {
2288
+ return scope === "user" ? userTierRoot() : cwd;
2289
+ }
2290
+ async function requireUserStore(storeRoot) {
2291
+ try {
2292
+ return await readManifest(storeRoot);
2293
+ } catch {
2294
+ console.error(
2295
+ `
2296
+ \u2717 No user tier at ${storeRoot}.
2297
+ Run \`spell init --user\` once to create it, then re-run this command.
2298
+ `
2299
+ );
2300
+ process.exit(1);
2301
+ throw new Error("unreachable");
2302
+ }
2303
+ }
2304
+ async function repoSpellScope(targetDir) {
2305
+ try {
2306
+ return effectiveSpellScope(await readManifest(targetDir));
2307
+ } catch {
2308
+ return "repo";
2309
+ }
2310
+ }
2311
+ async function performAgentSync(targetDir, assetsDir, roster, options) {
2312
+ const scope = options.scope ?? "repo";
2313
+ const spellScope = scope === "user" ? void 0 : await repoSpellScope(targetDir);
2314
+ const result = await syncAgents(targetDir, assetsDir, roster, {
2315
+ ...options,
2316
+ scope,
2317
+ ...spellScope === void 0 ? {} : { spellScope }
2318
+ });
2319
+ if (scope === "user") {
2320
+ const manifest = await requireUserStore(targetDir);
2321
+ const fanout = await syncUserTierFanout({
2322
+ homeDir: dirname2(targetDir),
2323
+ storeRoot: targetDir,
2324
+ spellIds: [],
2325
+ extraFiles: result.userAgentFiles,
2326
+ ownedClients: ["copilot-agents"],
2327
+ previous: manifest.fanout,
2328
+ dryRun: options.dryRun,
2329
+ fallbackDir: assetsDir
2330
+ });
2331
+ for (const line of describeFanoutOutcomes(fanout.outcomes, options.dryRun)) {
2332
+ console.log(` ${line}`);
2333
+ }
2334
+ if (!options.dryRun) {
2335
+ await writeManifest(targetDir, { ...manifest, fanout: fanout.record });
2336
+ }
2337
+ }
2338
+ if (result.unmanagedAgentFiles.length > 0) {
2339
+ console.log(
2340
+ `
2341
+ ! This repository takes its spells from the user tier (spell_scope: "user"), so it no
2342
+ longer receives agent files of its own. ${result.unmanagedAgentFiles.length} are still on disk
2343
+ from before the opt-out. Nothing here deletes them -- no component ever tracked
2344
+ them, so there is no recorded hash to prove one untouched. Remove them yourself:
2345
+ `
2346
+ );
2347
+ for (const file of result.unmanagedAgentFiles) console.log(` ${file}`);
2348
+ console.log(`
2349
+ git rm ${result.unmanagedAgentFiles.join(" ")}
2350
+ `);
2351
+ }
2352
+ return result;
2353
+ }
2214
2354
  async function runAgentsInit(options, targetDir, assetsDir) {
2215
- if (!options.force && await rosterExists(targetDir)) {
2355
+ const scope = options.scope ?? "repo";
2356
+ if (scope === "user") await requireUserStore(targetDir);
2357
+ const baseDir = agentsBaseDir(targetDir, scope);
2358
+ const label = scope === "user" ? "~/.arcane" : ".arcane";
2359
+ if (!options.force && await rosterExists(targetDir, scope)) {
2216
2360
  console.log(
2217
2361
  'Agent roster already exists at .arcane/agents.yaml. Run "spell agents sync" to regenerate outputs, or use --force to reinitialize.'
2218
2362
  );
@@ -2306,16 +2450,12 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2306
2450
  },
2307
2451
  roster: rosterEntries
2308
2452
  };
2309
- const arcaneDir = join4(targetDir, ".arcane", "agents");
2453
+ const arcaneDir = join4(baseDir, "agents");
2310
2454
  if (!options.dryRun) {
2311
2455
  await mkdir4(arcaneDir, { recursive: true });
2312
- await writeFile6(
2313
- join4(targetDir, ".arcane", "agents.yaml"),
2314
- stringify(roster),
2315
- "utf8"
2316
- );
2456
+ await writeFile6(join4(baseDir, "agents.yaml"), stringify(roster), "utf8");
2317
2457
  } else {
2318
- console.log(" [dry-run] Would write: .arcane/agents.yaml");
2458
+ console.log(` [dry-run] Would write: ${label}/agents.yaml`);
2319
2459
  }
2320
2460
  for (const roleId of selectedRoleIds) {
2321
2461
  const src = join4(assetsDir, "agents", `${roleId}.yaml`);
@@ -2327,7 +2467,7 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2327
2467
  console.warn(` ! Could not copy definition for "${roleId}" \u2014 skipping`);
2328
2468
  }
2329
2469
  } else {
2330
- console.log(` [dry-run] Would copy: .arcane/agents/${roleId}.yaml`);
2470
+ console.log(` [dry-run] Would copy: ${label}/agents/${roleId}.yaml`);
2331
2471
  }
2332
2472
  }
2333
2473
  if (options.dryRun) {
@@ -2338,20 +2478,24 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2338
2478
  return;
2339
2479
  }
2340
2480
  printStep("Syncing agent definitions to all clients...");
2341
- const { synced, skipped, hasUnresolvedRoles } = await syncAgents(
2481
+ const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2342
2482
  targetDir,
2343
2483
  assetsDir,
2344
2484
  roster,
2345
- { dryRun: false }
2485
+ { dryRun: false, scope }
2346
2486
  );
2347
2487
  printSuccess("Agent roster initialized");
2348
2488
  console.log(` \u{1F4CB} Profile: ${profileId} \xB7 Naming: ${namingStrategy} \xB7 Agents: ${rosterEntries.length}`);
2349
- console.log(` \u{1F504} Synced: ${synced.length} outputs (Copilot, Claude, Codex, OpenClaw)`);
2489
+ console.log(
2490
+ ` \u{1F504} Synced: ${synced.length} outputs (${scope === "user" ? "Copilot agent modes in ~/.copilot/agents" : "Copilot, Claude, Codex, OpenClaw"})`
2491
+ );
2350
2492
  if (skipped.length > 0) {
2351
2493
  console.log(` \u26A0\uFE0F Skipped: ${skipped.length} (${skipped.join(", ")})`);
2352
2494
  }
2353
2495
  console.log();
2354
- console.log(` \u{1F4A1} Customize: edit .arcane/agents.yaml \u2192 spell agents sync`);
2496
+ console.log(
2497
+ ` \u{1F4A1} Customize: edit ${label}/agents.yaml \u2192 spell agents sync${scope === "user" ? " --user" : ""}`
2498
+ );
2355
2499
  console.log();
2356
2500
  if (hasUnresolvedRoles) {
2357
2501
  console.error(
@@ -2361,9 +2505,10 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
2361
2505
  }
2362
2506
  }
2363
2507
  async function runAgentsSync(options, targetDir, assetsDir) {
2508
+ const scope = options.scope ?? "repo";
2364
2509
  let roster;
2365
2510
  try {
2366
- roster = await loadRoster(targetDir);
2511
+ roster = await loadRoster(targetDir, scope);
2367
2512
  } catch (err) {
2368
2513
  if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
2369
2514
  console.error(err.message);
@@ -2377,11 +2522,11 @@ async function runAgentsSync(options, targetDir, assetsDir) {
2377
2522
  } else {
2378
2523
  console.log("\n Syncing agents...\n");
2379
2524
  }
2380
- const { synced, skipped, hasUnresolvedRoles } = await syncAgents(
2525
+ const { synced, skipped, hasUnresolvedRoles } = await performAgentSync(
2381
2526
  targetDir,
2382
2527
  assetsDir,
2383
2528
  roster,
2384
- options
2529
+ { ...options, scope }
2385
2530
  );
2386
2531
  if (options.dryRun) {
2387
2532
  console.log(`
@@ -2408,10 +2553,10 @@ async function runAgentsSync(options, targetDir, assetsDir) {
2408
2553
  process.exitCode = 1;
2409
2554
  }
2410
2555
  }
2411
- async function runAgentsList(targetDir) {
2556
+ async function runAgentsList(targetDir, scope = "repo") {
2412
2557
  let roster;
2413
2558
  try {
2414
- roster = await loadRoster(targetDir);
2559
+ roster = await loadRoster(targetDir, scope);
2415
2560
  } catch (err) {
2416
2561
  if (err instanceof AgentRosterNotFoundError || err instanceof AgentConfigValidationError) {
2417
2562
  console.error(err.message);
@@ -2453,9 +2598,9 @@ async function runAgentsList(targetDir) {
2453
2598
  }
2454
2599
 
2455
2600
  // src/modules/push-safety.ts
2456
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir4 } from "fs/promises";
2601
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir5 } from "fs/promises";
2457
2602
  import { constants } from "fs";
2458
- import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
2603
+ import { join as join5, resolve as resolve2, dirname as dirname3, basename } from "path";
2459
2604
 
2460
2605
  // src/modules/git.ts
2461
2606
  import { execFile } from "child_process";
@@ -2752,7 +2897,7 @@ async function repositoryRoot(cwd) {
2752
2897
  ]);
2753
2898
  const commonDir = stdout.trim();
2754
2899
  if (commonDir !== "") {
2755
- const parent = dirname2(commonDir);
2900
+ const parent = dirname3(commonDir);
2756
2901
  if (basename(commonDir) === ".git" && parent !== "") return parent;
2757
2902
  }
2758
2903
  } catch {
@@ -2842,7 +2987,7 @@ async function defaultHooks(cwd) {
2842
2987
  return [];
2843
2988
  }
2844
2989
  try {
2845
- return (await readdir4(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2990
+ return (await readdir5(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2846
2991
  } catch {
2847
2992
  return [];
2848
2993
  }
@@ -2876,7 +3021,7 @@ async function removeHook(cwd, hookName) {
2876
3021
  const hooksDir = join5(root, ARCANE_HOOKS_DIR);
2877
3022
  let remaining = [];
2878
3023
  try {
2879
- remaining = await readdir4(hooksDir);
3024
+ remaining = await readdir5(hooksDir);
2880
3025
  } catch {
2881
3026
  remaining = [];
2882
3027
  }
@@ -3274,16 +3419,35 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3274
3419
  profile = await select2({
3275
3420
  message: "Select an installation profile:\n",
3276
3421
  choices: profiles.map((p) => {
3277
- const profileComponents = getProfile(p.id);
3422
+ const profileComponents2 = getProfile(p.id);
3278
3423
  return {
3279
3424
  value: p.id,
3280
3425
  name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
3281
- description: buildProfileDescription(profileComponents, p.id)
3426
+ description: buildProfileDescription(profileComponents2, p.id)
3282
3427
  };
3283
3428
  })
3284
3429
  });
3285
3430
  }
3286
- const components = getProfile(profile);
3431
+ const profileComponents = getProfile(profile);
3432
+ let spell_scope;
3433
+ if (!options.profile && !options.dryRun) {
3434
+ let storeVersion;
3435
+ try {
3436
+ storeVersion = (await readManifest(userTierRoot())).version;
3437
+ } catch {
3438
+ }
3439
+ if (storeVersion !== void 0) {
3440
+ console.log();
3441
+ const useTier = await confirm({
3442
+ message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
3443
+ default: false
3444
+ });
3445
+ spell_scope = useTier ? "user" : "repo";
3446
+ }
3447
+ }
3448
+ const components = profileComponents.map(
3449
+ (component) => componentForSpellScope(component, spell_scope ?? "repo")
3450
+ );
3287
3451
  const groups = categorizeComponents(components);
3288
3452
  console.log();
3289
3453
  printSection(`\u{1F4E6} Profile: ${profile}`);
@@ -3470,6 +3634,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3470
3634
  profile,
3471
3635
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3472
3636
  components: installedComponents,
3637
+ ...spell_scope ? { spell_scope } : {},
3473
3638
  ...role ? { role } : {},
3474
3639
  ...tracking_mode ? { tracking_mode, external_provider } : {},
3475
3640
  ...subject_root !== void 0 ? { subject_root } : {},
@@ -3621,7 +3786,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3621
3786
  console.log(chalk2.hex("#a855f7")(`
3622
3787
  \u2726 Arcane v${packageVersion}
3623
3788
  `));
3624
- const homeDir = dirname3(storeRoot);
3789
+ const homeDir = dirname4(storeRoot);
3625
3790
  const components = USER_TIER_COMPONENTS.map(
3626
3791
  (name) => componentForScope(getComponent(name), "user")
3627
3792
  );
@@ -3669,7 +3834,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3669
3834
  fileHashes
3670
3835
  });
3671
3836
  }
3672
- const fanout = await syncUserTierFanout({ homeDir, storeRoot, spellIds, fallbackDir: assetsDir });
3837
+ const fanout = await syncUserTierFanout({ homeDir, storeRoot, spellIds, fallbackDir: assetsDir, ownedClients: SPELL_FANOUT_CLIENTS });
3673
3838
  const manifest = {
3674
3839
  version: packageVersion,
3675
3840
  // The tier is un-profiled; "full" records the profile its component set
@@ -3783,12 +3948,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3783
3948
  }
3784
3949
 
3785
3950
  // src/commands/update.ts
3786
- import { dirname as dirname4, join as join9 } from "path";
3951
+ import { dirname as dirname5, join as join9 } from "path";
3787
3952
  import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
3788
3953
 
3789
3954
  // src/modules/hub.ts
3790
3955
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
3791
- import { readdir as readdir5, writeFile as writeFile8 } from "fs/promises";
3956
+ import { readdir as readdir6, writeFile as writeFile8 } from "fs/promises";
3792
3957
  import { join as join8 } from "path";
3793
3958
  var MANIFEST_RETROFITS = [
3794
3959
  {
@@ -3933,7 +4098,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3933
4098
  const businessRootPath = join8(targetDir, businessRoot);
3934
4099
  let ventureDirs;
3935
4100
  try {
3936
- const entries = await readdir5(businessRootPath, { withFileTypes: true });
4101
+ const entries = await readdir6(businessRootPath, { withFileTypes: true });
3937
4102
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
3938
4103
  } catch {
3939
4104
  return;
@@ -4155,26 +4320,57 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
4155
4320
  return "reported";
4156
4321
  }
4157
4322
  await removeWithin(targetDir, file);
4323
+ await removeEmptyAncestors(targetDir, file);
4158
4324
  console.log(` Pruned orphaned file: ${file}`);
4159
4325
  return "pruned";
4160
4326
  }
4161
- async function findMissingTrackedFiles(targetDir, components, scope = "repo") {
4327
+ function findReadoptedSpellComponents(components, scope = "repo", spellScope = "repo") {
4328
+ if (scope !== "repo" || spellScope !== "repo") return /* @__PURE__ */ new Set();
4329
+ return new Set(
4330
+ components.filter((c) => SPELL_COMPONENT_NAMES.includes(c.name) && c.files.length === 0).map((c) => c.name)
4331
+ );
4332
+ }
4333
+ async function findMissingTrackedFiles(targetDir, components, scope = "repo", spellScope = "repo", readopted = /* @__PURE__ */ new Set()) {
4162
4334
  const missing = [];
4163
4335
  for (const installed of components) {
4164
4336
  let component;
4165
4337
  try {
4166
- component = componentForScope(getComponent(installed.name), scope);
4338
+ component = componentForSpellScope(
4339
+ componentForScope(getComponent(installed.name), scope),
4340
+ scope === "repo" ? spellScope : "repo"
4341
+ );
4167
4342
  } catch {
4168
4343
  continue;
4169
4344
  }
4170
4345
  if (component.initOnly || component.skipExisting) continue;
4171
- for (const file of installed.files) {
4346
+ const candidates = readopted.has(installed.name) ? component.files : installed.files;
4347
+ for (const file of candidates) {
4172
4348
  if (!component.files.includes(file)) continue;
4173
4349
  if (!await fileExists(join9(targetDir, file))) missing.push(file);
4174
4350
  }
4175
4351
  }
4176
4352
  return missing;
4177
4353
  }
4354
+ function findUnmanagedTrackedFiles(components, scope = "repo", spellScope = "repo") {
4355
+ const unmanaged = [];
4356
+ for (const installed of components) {
4357
+ let component;
4358
+ try {
4359
+ component = componentForSpellScope(
4360
+ componentForScope(getComponent(installed.name), scope),
4361
+ scope === "repo" ? spellScope : "repo"
4362
+ );
4363
+ } catch {
4364
+ unmanaged.push(...installed.files);
4365
+ continue;
4366
+ }
4367
+ const shipped = new Set(component.files);
4368
+ for (const file of installed.files) {
4369
+ if (!shipped.has(file)) unmanaged.push(file);
4370
+ }
4371
+ }
4372
+ return unmanaged;
4373
+ }
4178
4374
  async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4179
4375
  let manifest;
4180
4376
  try {
@@ -4196,7 +4392,9 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4196
4392
  return;
4197
4393
  }
4198
4394
  const scope = resolvedScope.scope;
4199
- const homeDir = dirname4(targetDir);
4395
+ const spellScope = effectiveSpellScope(manifest);
4396
+ const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
4397
+ const homeDir = dirname5(targetDir);
4200
4398
  if (scope === "repo") {
4201
4399
  console.warn(
4202
4400
  `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
@@ -4229,11 +4427,13 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4229
4427
  }
4230
4428
  let sameVersionRestore = false;
4231
4429
  if (manifest.version === packageVersion && manifest.components.length > 0) {
4232
- const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
4233
- if (missing.length === 0) {
4430
+ const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
4431
+ const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
4432
+ if (missing.length === 0 && unmanaged.length === 0) {
4234
4433
  console.log("Already up to date.");
4235
4434
  if (scope === "user") {
4236
4435
  const fanout = await syncUserTierFanout({
4436
+ ownedClients: SPELL_FANOUT_CLIENTS,
4237
4437
  homeDir,
4238
4438
  storeRoot: targetDir,
4239
4439
  spellIds: spellIdsInStore(manifest.components),
@@ -4249,9 +4449,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4249
4449
  return;
4250
4450
  }
4251
4451
  sameVersionRestore = true;
4252
- console.log(
4253
- `${options.dryRun ? "[dry-run] " : ""}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
4254
- );
4452
+ const prefix = options.dryRun ? "[dry-run] " : "";
4453
+ if (missing.length > 0) {
4454
+ console.log(
4455
+ `${prefix}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
4456
+ );
4457
+ } else {
4458
+ console.log(
4459
+ `${prefix}Already at v${packageVersion}, but ${unmanaged.length} tracked file${unmanaged.length === 1 ? " is" : "s are"} no longer managed here \u2014 reviewing.`
4460
+ );
4461
+ }
4255
4462
  }
4256
4463
  if (manifest.components.length === 0) {
4257
4464
  console.log("No components installed. Nothing to update.");
@@ -4266,7 +4473,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4266
4473
  for (const installed of componentsToUpdate) {
4267
4474
  let component;
4268
4475
  try {
4269
- component = componentForScope(getComponent(installed.name), scope);
4476
+ component = componentForSpellScope(
4477
+ componentForScope(getComponent(installed.name), scope),
4478
+ scope === "repo" ? spellScope : "repo"
4479
+ );
4270
4480
  } catch (err) {
4271
4481
  if (err instanceof ComponentNotFoundError) {
4272
4482
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
@@ -4301,7 +4511,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4301
4511
  continue;
4302
4512
  }
4303
4513
  if (sameVersionRestore) {
4304
- const tracked = installed.files.includes(file);
4514
+ const tracked = installed.files.includes(file) || readopted.has(installed.name);
4305
4515
  const restorable = tracked && !targetExists && !component.skipExisting;
4306
4516
  if (!restorable) {
4307
4517
  if (tracked) {
@@ -4378,13 +4588,24 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
4378
4588
  }
4379
4589
  for (const file of installed.files) {
4380
4590
  if (updatedFiles.includes(file)) continue;
4591
+ const recordedHash = installed.fileHashes?.[file];
4381
4592
  const status = await resolveOrphan(
4382
4593
  targetDir,
4383
4594
  file,
4384
- installed.fileHashes?.[file],
4595
+ recordedHash,
4385
4596
  Boolean(options.prune) && !options.dryRun
4386
4597
  );
4387
4598
  orphanReport.push({ file, status });
4599
+ if (status === "reported") {
4600
+ try {
4601
+ validateTargetPath(targetDir, file);
4602
+ if (await fileExists(join9(targetDir, file))) {
4603
+ updatedFiles.push(file);
4604
+ if (recordedHash !== void 0) fileHashes[file] = recordedHash;
4605
+ }
4606
+ } catch {
4607
+ }
4608
+ }
4388
4609
  }
4389
4610
  updatedComponents.push({
4390
4611
  ...installed,
@@ -4404,6 +4625,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4404
4625
  const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
4405
4626
  console.log(` ${file} \u2014 ${marker}`);
4406
4627
  }
4628
+ if (scope === "repo" && spellScope === "user") {
4629
+ console.log(
4630
+ ' This repository takes its spells from the user tier (spell_scope: "user"), so the files above are no longer managed here \u2014 every client reads ~/.arcane instead.'
4631
+ );
4632
+ }
4407
4633
  if (!options.prune) {
4408
4634
  console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
4409
4635
  }
@@ -4420,6 +4646,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4420
4646
  let fanoutRecord;
4421
4647
  if (scope === "user") {
4422
4648
  const fanout = await syncUserTierFanout({
4649
+ ownedClients: SPELL_FANOUT_CLIENTS,
4423
4650
  homeDir,
4424
4651
  storeRoot: targetDir,
4425
4652
  spellIds: spellIdsInStore(updatedComponents),
@@ -4483,11 +4710,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
4483
4710
  }
4484
4711
 
4485
4712
  // src/commands/status.ts
4486
- import { dirname as dirname6 } from "path";
4713
+ import { dirname as dirname7 } from "path";
4487
4714
 
4488
4715
  // src/modules/version-check.ts
4489
4716
  import { readFile as readFile8, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
4490
- import { dirname as dirname5, join as join10 } from "path";
4717
+ import { dirname as dirname6, join as join10 } from "path";
4491
4718
  import { homedir as homedir3 } from "os";
4492
4719
  import { createRequire } from "module";
4493
4720
  var require2 = createRequire(import.meta.url);
@@ -4516,7 +4743,7 @@ async function readCache(cachePath) {
4516
4743
  }
4517
4744
  }
4518
4745
  async function writeCache(cachePath, latest) {
4519
- await mkdir6(dirname5(cachePath), { recursive: true });
4746
+ await mkdir6(dirname6(cachePath), { recursive: true });
4520
4747
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
4521
4748
  await writeFile10(cachePath, JSON.stringify(entry, null, 2), "utf-8");
4522
4749
  }
@@ -4657,7 +4884,7 @@ async function runStatus(targetDir, packageVersion, options = {}) {
4657
4884
  console.log("");
4658
4885
  if (scope === "user") {
4659
4886
  console.log(` Scope: user \u2014 ${targetDir}`);
4660
- const health = await inspectUserTierFanout(dirname6(targetDir), manifest.fanout);
4887
+ const health = await inspectUserTierFanout(dirname7(targetDir), manifest.fanout);
4661
4888
  console.log(
4662
4889
  ` Client files: ${health.total} (${health.byClient.codex} ${FANOUT_CLIENT_LABELS.codex}, ${health.byClient.claude} ${FANOUT_CLIENT_LABELS.claude})`
4663
4890
  );
@@ -4670,12 +4897,18 @@ async function runStatus(targetDir, packageVersion, options = {}) {
4670
4897
  );
4671
4898
  }
4672
4899
  } else {
4673
- console.log(" Scope: repo");
4900
+ const spellScope = effectiveSpellScope(manifest);
4901
+ console.log(spellScope === "user" ? " Scope: repo (spells: user tier)" : " Scope: repo");
4902
+ const store = userTierRoot();
4674
4903
  try {
4675
- const store = userTierRoot();
4676
4904
  const userManifest = await readManifest(store);
4677
4905
  console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
4678
4906
  } catch {
4907
+ if (spellScope === "user") {
4908
+ console.log(
4909
+ ` ! User tier: not installed at ${store} \u2014 this repository expects it for every spell. Run \`spell init --user\` (\`spell doctor\` explains).`
4910
+ );
4911
+ }
4679
4912
  }
4680
4913
  }
4681
4914
  console.log("");
@@ -4714,7 +4947,7 @@ async function runStatus(targetDir, packageVersion, options = {}) {
4714
4947
 
4715
4948
  // src/commands/uninstall.ts
4716
4949
  import { rm as rm5 } from "fs/promises";
4717
- import { dirname as dirname7, join as join11 } from "path";
4950
+ import { dirname as dirname8, join as join11 } from "path";
4718
4951
  import { confirm as confirm3 } from "@inquirer/prompts";
4719
4952
  async function runUninstall(options, targetDir) {
4720
4953
  let manifest;
@@ -4823,7 +5056,7 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4823
5056
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
4824
5057
  }
4825
5058
  async function uninstallUserTier(options, storeRoot, manifest) {
4826
- const homeDir = dirname7(storeRoot);
5059
+ const homeDir = dirname8(storeRoot);
4827
5060
  const storeFiles = manifest.components.flatMap((c) => c.files);
4828
5061
  const recordedCount = Object.keys(manifest.fanout ?? {}).length;
4829
5062
  if (options.dryRun) {
@@ -4869,6 +5102,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
4869
5102
  }
4870
5103
  }
4871
5104
  await rm5(join11(storeRoot, ".arcane.json"), { force: true });
5105
+ await rm5(join11(storeRoot, "agents"), { recursive: true, force: true });
5106
+ await rm5(join11(storeRoot, "agents.yaml"), { force: true });
4872
5107
  await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
4873
5108
  await removeDirectoryIfEmpty(storeRoot);
4874
5109
  for (const line of describeFanoutOutcomes(fanout.outcomes)) console.log(` ${line}`);
@@ -4881,8 +5116,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
4881
5116
  }
4882
5117
 
4883
5118
  // src/commands/doctor.ts
4884
- import { access as access4, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
4885
- import { join as join13, dirname as dirname8 } from "path";
5119
+ import { access as access4, readdir as readdir8, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
5120
+ import { join as join13, dirname as dirname9 } from "path";
4886
5121
  import { execFile as execFile3 } from "child_process";
4887
5122
  import { promisify as promisify2 } from "util";
4888
5123
 
@@ -4952,7 +5187,7 @@ function evaluateIncidentGate(records) {
4952
5187
  }
4953
5188
 
4954
5189
  // src/modules/denylist-scan.ts
4955
- import { readFile as readFile9, readdir as readdir6 } from "fs/promises";
5190
+ import { readFile as readFile9, readdir as readdir7 } from "fs/promises";
4956
5191
  import { join as join12, relative } from "path";
4957
5192
  function escapeRegExp(value) {
4958
5193
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4999,7 +5234,7 @@ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT
4999
5234
  const collected = [];
5000
5235
  let entries;
5001
5236
  try {
5002
- entries = await readdir6(root, { withFileTypes: true });
5237
+ entries = await readdir7(root, { withFileTypes: true });
5003
5238
  } catch {
5004
5239
  return collected;
5005
5240
  }
@@ -5221,8 +5456,8 @@ async function checkVSCodeExtension(id, label) {
5221
5456
  ];
5222
5457
  for (const dir of extDirs) {
5223
5458
  try {
5224
- const { readdir: readdir9 } = await import("fs/promises");
5225
- const entries = await readdir9(dir);
5459
+ const { readdir: readdir11 } = await import("fs/promises");
5460
+ const entries = await readdir11(dir);
5226
5461
  const prefix = id.toLowerCase() + "-";
5227
5462
  const found = entries.some(
5228
5463
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -5384,7 +5619,7 @@ async function fixSessionContinuity(targetDir, assetsDir) {
5384
5619
  await access4(destPath);
5385
5620
  } catch {
5386
5621
  const srcPath = join13(assetsDir, file);
5387
- await mkdir7(dirname8(destPath), { recursive: true });
5622
+ await mkdir7(dirname9(destPath), { recursive: true });
5388
5623
  await fsCopyFile(srcPath, destPath);
5389
5624
  created.push(file);
5390
5625
  }
@@ -5646,6 +5881,74 @@ async function checkPlatformBranchPolicy(targetDir) {
5646
5881
  message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
5647
5882
  };
5648
5883
  }
5884
+ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
5885
+ const name = "Spell scope (ARC-045)";
5886
+ let manifest;
5887
+ try {
5888
+ manifest = await readManifest(targetDir);
5889
+ } catch {
5890
+ return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
5891
+ }
5892
+ if (effectiveSpellScope(manifest) === "repo") {
5893
+ return {
5894
+ name,
5895
+ passed: true,
5896
+ blocking: false,
5897
+ message: "repo \u2014 this repository carries its own spells (the default)"
5898
+ };
5899
+ }
5900
+ const storeRoot = userTierRoot(homeDir);
5901
+ let store;
5902
+ try {
5903
+ store = await readManifest(storeRoot);
5904
+ } catch (err) {
5905
+ return {
5906
+ name,
5907
+ passed: false,
5908
+ message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
5909
+ };
5910
+ }
5911
+ let spellCount = 0;
5912
+ let probed = false;
5913
+ const probe = "spell-status.md";
5914
+ try {
5915
+ const entries = await readdir8(join13(storeRoot, USER_TIER_SPELLS_DIR));
5916
+ const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
5917
+ spellCount = spells.length;
5918
+ probed = spells.includes(probe);
5919
+ } catch {
5920
+ }
5921
+ if (spellCount === 0 || !probed) {
5922
+ return {
5923
+ name,
5924
+ passed: false,
5925
+ message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
5926
+ };
5927
+ }
5928
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
5929
+ if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
5930
+ return {
5931
+ name,
5932
+ passed: false,
5933
+ blocking: false,
5934
+ message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
5935
+ };
5936
+ }
5937
+ const repoExpectsAgents = await rosterExists(targetDir);
5938
+ if (repoExpectsAgents && !await rosterExists(storeRoot, "user")) {
5939
+ return {
5940
+ name,
5941
+ passed: false,
5942
+ message: `this repository has an agent roster and takes its client files from the user tier, but ${storeRoot} has no roster of its own \u2014 no agent mode can reach a client here. Run \`spell agents init --user\`, or set spell_scope back to "repo" and run \`spell agents sync\`.`
5943
+ };
5944
+ }
5945
+ return {
5946
+ name,
5947
+ passed: true,
5948
+ blocking: false,
5949
+ message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe} present${repoExpectsAgents ? ", agent roster present in the tier" : ""}`
5950
+ };
5951
+ }
5649
5952
  async function runDoctor(targetDir, options = {}, assetsDir) {
5650
5953
  console.log("\nspell doctor \u2014 checking your Arcane environment\n");
5651
5954
  if (options.leaks) {
@@ -5671,7 +5974,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
5671
5974
  checkPlatformBranchPolicy(targetDir),
5672
5975
  checkDelegations(targetDir),
5673
5976
  checkMcpConfig(targetDir),
5674
- checkUserTier(resolveHomeDir(), options.packageVersion)
5977
+ checkUserTier(resolveHomeDir(), options.packageVersion),
5978
+ checkSpellScope(targetDir)
5675
5979
  ]);
5676
5980
  const sessionResults = await checkSessionContinuity(targetDir);
5677
5981
  results.push(...sessionResults);
@@ -5719,7 +6023,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
5719
6023
  import { readFile as readFile12 } from "fs/promises";
5720
6024
 
5721
6025
  // src/modules/ward.ts
5722
- import { readdir as readdir7, readFile as readFile11 } from "fs/promises";
6026
+ import { readdir as readdir9, readFile as readFile11 } from "fs/promises";
5723
6027
  import { join as join14, relative as relative2 } from "path";
5724
6028
  async function scanFileAllRules(absolutePath, displayPath, rules) {
5725
6029
  const findings = [];
@@ -5778,7 +6082,7 @@ async function scanFilenames(root, rules, repoRoot = root) {
5778
6082
  const findings = [];
5779
6083
  let entries;
5780
6084
  try {
5781
- entries = await readdir7(root, { withFileTypes: true });
6085
+ entries = await readdir9(root, { withFileTypes: true });
5782
6086
  } catch {
5783
6087
  return findings;
5784
6088
  }
@@ -6000,8 +6304,8 @@ import { access as access5 } from "fs/promises";
6000
6304
  import { join as join17 } from "path";
6001
6305
 
6002
6306
  // src/modules/show-report/generate.ts
6003
- import { mkdir as mkdir8, readdir as readdir8, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6004
- import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
6307
+ import { mkdir as mkdir8, readdir as readdir10, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
6308
+ import { basename as basename3, dirname as dirname10, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
6005
6309
 
6006
6310
  // src/modules/show-report/model.ts
6007
6311
  import { readFile as readFile13 } from "fs/promises";
@@ -6558,7 +6862,7 @@ async function programFromPlan(rootDir, slug, planRelPath) {
6558
6862
  program: {
6559
6863
  slug,
6560
6864
  planRelPath,
6561
- queueRelPath: toPosix(join16(dirname9(planRelPath), "OPERATOR-QUEUE.md")),
6865
+ queueRelPath: toPosix(join16(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
6562
6866
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
6563
6867
  }
6564
6868
  };
@@ -6567,7 +6871,7 @@ async function discoverPrograms(rootDir) {
6567
6871
  const plansDir = join16(rootDir, "docs", "plans");
6568
6872
  let entries;
6569
6873
  try {
6570
- entries = (await readdir8(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6874
+ entries = (await readdir10(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6571
6875
  } catch (error) {
6572
6876
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
6573
6877
  throw error;
@@ -6584,7 +6888,7 @@ async function discoverPrograms(rootDir) {
6584
6888
  async function programFromPlanPath(rootDir, planPath) {
6585
6889
  const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
6586
6890
  const planRelPath = toPosix(relative3(rootDir, planAbs));
6587
- return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
6891
+ return programFromPlan(rootDir, basename3(dirname10(planAbs)), planRelPath);
6588
6892
  }
6589
6893
  function normalizeLineEndings2(content) {
6590
6894
  return content.replace(/\r\n?/g, "\n");
@@ -6624,7 +6928,7 @@ async function runReportGeneration(options) {
6624
6928
  const generated = await generateReport(options.rootDir, program2, templateHtml);
6625
6929
  const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
6626
6930
  if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
6627
- const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname9(program2.planRelPath));
6931
+ const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname10(program2.planRelPath));
6628
6932
  const outRel = toPosix(relative3(options.rootDir, outAbs));
6629
6933
  const outputs = [
6630
6934
  [`${outRel}/show-report.json`, generated.json],
@@ -6710,9 +7014,10 @@ async function runReport(targetDir, options = {}, assetsDir) {
6710
7014
  import { realpathSync } from "fs";
6711
7015
  var require3 = createRequire2(import.meta.url);
6712
7016
  var pkg = require3("../package.json");
6713
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname10(fileURLToPath(import.meta.url)), "assets");
7017
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname11(fileURLToPath(import.meta.url)), "assets");
6714
7018
  var program = new Command();
6715
7019
  var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
7020
+ var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";
6716
7021
  function targetDirFor(opts) {
6717
7022
  return opts.user ? userTierRoot() : process.cwd();
6718
7023
  }
@@ -6814,39 +7119,44 @@ agentsCommand.command("init").description(
6814
7119
  ).option(
6815
7120
  "--naming <strategy>",
6816
7121
  "Naming strategy: arcanos | generic | random | custom"
6817
- ).option("--force", "Reinitialize even if roster already exists").option("--dry-run", "Preview what would be created without writing files").action(
7122
+ ).option("--force", "Reinitialize even if roster already exists").option("--dry-run", "Preview what would be created without writing files").option("--user", USER_TIER_AGENTS_DESCRIPTION).action(
6818
7123
  async (opts) => {
7124
+ const scope = opts.user ? "user" : "repo";
6819
7125
  await runAgentsInit(
6820
7126
  {
6821
7127
  profile: opts.profile,
6822
7128
  naming: opts.naming,
6823
7129
  force: opts.force,
6824
- dryRun: opts.dryRun
7130
+ dryRun: opts.dryRun,
7131
+ scope
6825
7132
  },
6826
- process.cwd(),
7133
+ agentsTargetDir(scope, process.cwd()),
6827
7134
  ASSETS_DIR
6828
7135
  );
6829
7136
  }
6830
7137
  );
6831
7138
  agentsCommand.command("sync").description(
6832
7139
  "Regenerate all client instruction files from canonical agent definitions"
6833
- ).option("--dry-run", "Preview what would be generated without writing files").option("--no-openclaw", "Skip OpenClaw workspace output").option("--no-copilot", "Skip Copilot output").option("--no-claude", "Skip Claude output").option("--no-codex", "Skip Codex output").action(
7140
+ ).option("--dry-run", "Preview what would be generated without writing files").option("--no-openclaw", "Skip OpenClaw workspace output").option("--no-copilot", "Skip Copilot output").option("--no-claude", "Skip Claude output").option("--no-codex", "Skip Codex output").option("--user", USER_TIER_AGENTS_DESCRIPTION).action(
6834
7141
  async (opts) => {
7142
+ const scope = opts.user ? "user" : "repo";
6835
7143
  await runAgentsSync(
6836
7144
  {
6837
7145
  dryRun: opts.dryRun,
6838
7146
  openclaw: opts.openclaw,
6839
7147
  copilot: opts.copilot,
6840
7148
  claude: opts.claude,
6841
- codex: opts.codex
7149
+ codex: opts.codex,
7150
+ scope
6842
7151
  },
6843
- process.cwd(),
7152
+ agentsTargetDir(scope, process.cwd()),
6844
7153
  ASSETS_DIR
6845
7154
  );
6846
7155
  }
6847
7156
  );
6848
- agentsCommand.command("list").description("Show the current agent roster with assigned names and roles").action(async () => {
6849
- await runAgentsList(process.cwd());
7157
+ agentsCommand.command("list").description("Show the current agent roster with assigned names and roles").option("--user", USER_TIER_AGENTS_DESCRIPTION).action(async (opts) => {
7158
+ const scope = opts.user ? "user" : "repo";
7159
+ await runAgentsList(agentsTargetDir(scope, process.cwd()), scope);
6850
7160
  });
6851
7161
  if (realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
6852
7162
  await program.parseAsync(process.argv);