arcane-cli 1.0.0 → 1.1.1

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 dirname5, join as join17 } from "path";
9
+ import { dirname as dirname10, 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 { join as join5 } from "path";
13
+ import { dirname as dirname3, join as join6 } from "path";
14
14
  import chalk2 from "chalk";
15
15
 
16
16
  // src/modules/copier.ts
@@ -19,13 +19,37 @@ import {
19
19
  access,
20
20
  readdir,
21
21
  readFile,
22
+ rm,
22
23
  writeFile
23
24
  } from "fs/promises";
24
25
  import { createHash } from "crypto";
25
26
  import path from "path";
27
+ function normalizeLineEndings(content) {
28
+ if (!content.includes(13)) return content;
29
+ const out = Buffer.allocUnsafe(content.length);
30
+ let o = 0;
31
+ for (let i = 0; i < content.length; i++) {
32
+ const byte = content[i];
33
+ if (byte === 13) {
34
+ out[o++] = 10;
35
+ if (content[i + 1] === 10) i++;
36
+ continue;
37
+ }
38
+ out[o++] = byte;
39
+ }
40
+ return out.subarray(0, o);
41
+ }
42
+ function hashContent(content) {
43
+ const bytes = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
44
+ return createHash("sha256").update(normalizeLineEndings(bytes)).digest("hex");
45
+ }
26
46
  async function hashFile(filePath) {
47
+ return hashContent(await readFile(filePath));
48
+ }
49
+ async function fileMatchesHash(filePath, recordedHash) {
27
50
  const content = await readFile(filePath);
28
- return createHash("sha256").update(content).digest("hex");
51
+ if (hashContent(content) === recordedHash) return true;
52
+ return createHash("sha256").update(content).digest("hex") === recordedHash;
29
53
  }
30
54
  function validateTargetPath(targetDir, relativePath) {
31
55
  const resolvedBase = path.resolve(targetDir);
@@ -57,10 +81,14 @@ async function copyFile(srcPath, targetDir, relativePath, opts = {}) {
57
81
  );
58
82
  }
59
83
  const content = await readFile(srcPath);
60
- const hash = createHash("sha256").update(content).digest("hex");
84
+ const hash = hashContent(content);
61
85
  await writeFile(destPath, content);
62
86
  return hash;
63
87
  }
88
+ async function removeWithin(targetDir, relativePath) {
89
+ validateTargetPath(targetDir, relativePath);
90
+ await rm(path.resolve(targetDir, relativePath), { force: true });
91
+ }
64
92
  async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
65
93
  validateTargetPath(targetDir, relativeDir);
66
94
  const copiedFiles = [];
@@ -109,6 +137,15 @@ var VALID_TRACKING_MODES = ["internal", "external"];
109
137
  var VALID_EXTERNAL_PROVIDERS = ["ado", "github", "jira", "other"];
110
138
  var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
111
139
  var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
140
+ var VALID_INSTALL_SCOPES = ["repo", "user"];
141
+ function isValidFanoutPath(file) {
142
+ return file.length > 0 && !file.startsWith("/") && !/^[a-zA-Z]:/.test(file) && !file.includes("\\") && !file.split("/").includes("..") && !/[\r\n\0]/.test(file);
143
+ }
144
+ function isValidFanoutRecord(value) {
145
+ return isRecord(value) && Object.entries(value).every(
146
+ ([file, hash]) => isValidFanoutPath(file) && typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)
147
+ );
148
+ }
112
149
  function isValidSubjectRoot(value) {
113
150
  const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
114
151
  /^[a-zA-Z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("\\") || value.split(/[\\/]/).includes("..") || // A stored subject_root is interpolated into agent prompts downstream, so
@@ -132,6 +169,9 @@ function validateSecretsScanExcludePrefixes(value, filePath) {
132
169
  function manifestPath(targetDir) {
133
170
  return path2.join(targetDir, MANIFEST_FILE);
134
171
  }
172
+ function isRecord(value) {
173
+ return typeof value === "object" && value !== null && !Array.isArray(value);
174
+ }
135
175
  function validateTrackingFields(manifest, filePath) {
136
176
  if (manifest.tracking_mode !== void 0 && !VALID_TRACKING_MODES.includes(manifest.tracking_mode)) {
137
177
  throw new ManifestInvalidFieldError(filePath, "tracking_mode", manifest.tracking_mode);
@@ -158,6 +198,12 @@ function validateTrackingFields(manifest, filePath) {
158
198
  if (manifest.secretsScanExcludePrefixes !== void 0) {
159
199
  validateSecretsScanExcludePrefixes(manifest.secretsScanExcludePrefixes, filePath);
160
200
  }
201
+ if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
202
+ throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
203
+ }
204
+ if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
205
+ throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
206
+ }
161
207
  }
162
208
  async function readManifest(targetDir) {
163
209
  const filePath = manifestPath(targetDir);
@@ -179,9 +225,6 @@ async function readManifest(targetDir) {
179
225
  validateTrackingFields(manifest, filePath);
180
226
  return manifest;
181
227
  }
182
- function isRecord(value) {
183
- return typeof value === "object" && value !== null && !Array.isArray(value);
184
- }
185
228
  async function resolveSecretsScanExcludePrefixes(targetDir) {
186
229
  try {
187
230
  const manifest = await readManifest(targetDir);
@@ -822,6 +865,399 @@ function listProfiles() {
822
865
  }));
823
866
  }
824
867
 
868
+ // 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";
870
+ import { homedir } from "os";
871
+ import { dirname, join, resolve } from "path";
872
+
873
+ // src/modules/spell-compiler.ts
874
+ import { parse as parseYaml } from "yaml";
875
+ var MissingFrontmatterError = class extends Error {
876
+ constructor(message) {
877
+ super(message);
878
+ this.name = "MissingFrontmatterError";
879
+ }
880
+ };
881
+ var InvalidSkillNameError = class extends Error {
882
+ constructor(message) {
883
+ super(message);
884
+ this.name = "InvalidSkillNameError";
885
+ }
886
+ };
887
+ var CANONICAL_SPELLS_DIR = ".arcane/spells";
888
+ function canonicalSpellPath(id) {
889
+ return `${CANONICAL_SPELLS_DIR}/${id}.md`;
890
+ }
891
+ var CLIENT_SHIM_PATH_PATTERNS = [
892
+ /^\.github\/prompts\/spell-[a-z0-9-]+\.prompt\.md$/,
893
+ /^\.claude\/commands\/spell-[a-z0-9-]+\.md$/,
894
+ /^\.agents\/skills\/spell-[a-z0-9-]+\/SKILL\.md$/
895
+ ];
896
+ function isClientShimPath(path4) {
897
+ const normalized = path4.replace(/\\/g, "/").replace(/^\.\//, "");
898
+ return CLIENT_SHIM_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
899
+ }
900
+ var FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
901
+ function parsePromptFrontmatter(content) {
902
+ const match = content.match(FRONTMATTER_PATTERN);
903
+ if (!match) {
904
+ throw new MissingFrontmatterError(
905
+ "Prompt file has no YAML frontmatter block (expected a leading --- ... --- block)."
906
+ );
907
+ }
908
+ const raw = parseYaml(match[1]);
909
+ const name = raw?.["name"];
910
+ const description = raw?.["description"];
911
+ if (typeof name !== "string" || name.trim() === "") {
912
+ throw new MissingFrontmatterError('Prompt frontmatter is missing a "name" field.');
913
+ }
914
+ if (typeof description !== "string" || description.trim() === "") {
915
+ throw new MissingFrontmatterError('Prompt frontmatter is missing a "description" field.');
916
+ }
917
+ const claudeDescription = raw?.["claude_description"];
918
+ return {
919
+ name,
920
+ description,
921
+ claudeDescription: typeof claudeDescription === "string" ? claudeDescription : void 0
922
+ };
923
+ }
924
+ var SPELL_NAME_PREFIX = /^Spell\s*—\s*/;
925
+ function deriveStubTitle(name) {
926
+ return name.replace(SPELL_NAME_PREFIX, "").trim();
927
+ }
928
+ function renderClaudeCommandStub(id, frontmatter, canonicalRef = canonicalSpellPath(id)) {
929
+ const title = deriveStubTitle(frontmatter.name);
930
+ const description = frontmatter.claudeDescription ?? frontmatter.description;
931
+ const promptPath = canonicalRef;
932
+ const includeRef = /\s/.test(promptPath) ? `"${promptPath}"` : promptPath;
933
+ return `---
934
+ description: ${description}
935
+ ---
936
+
937
+ # ${title}
938
+
939
+ Invoke the Arcane \`${id}\` spell workflow.
940
+
941
+ See the full prompt at \`${promptPath}\` for the complete workflow definition.
942
+
943
+ ---
944
+
945
+ @${includeRef}
946
+ `;
947
+ }
948
+ var SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
949
+ function renderCodexSkill(id, frontmatter, canonicalPath) {
950
+ if (!SKILL_NAME_PATTERN.test(id)) {
951
+ throw new InvalidSkillNameError(
952
+ `Skill id "${id}" must match ${SKILL_NAME_PATTERN} (lowercase letters, digits, hyphens only).`
953
+ );
954
+ }
955
+ const description = frontmatter.claudeDescription ?? frontmatter.description;
956
+ return `---
957
+ name: ${id}
958
+ description: ${description}
959
+ ---
960
+
961
+ This skill is the Arcane \`${id}\` spell. Read \`${canonicalPath}\` and follow it as the complete workflow.
962
+ `;
963
+ }
964
+
965
+ // src/modules/user-tier.ts
966
+ var USER_TIER_DIR_NAME = ".arcane";
967
+ var USER_TIER_SPELLS_DIR = "spells";
968
+ function resolveHomeDir() {
969
+ return homedir();
970
+ }
971
+ function userTierRoot(homeDir = resolveHomeDir()) {
972
+ return join(homeDir, USER_TIER_DIR_NAME);
973
+ }
974
+ var USER_TIER_COMPONENTS = SPELL_COMPONENT_NAMES;
975
+ var CANONICAL_SPELL_PATH_PATTERN = /^\.arcane\/spells\/(spell-[a-z0-9-]+)\.md$/;
976
+ var STORE_SPELL_PATH_PATTERN = /^spells\/(spell-[a-z0-9-]+)\.md$/;
977
+ function storeSpellPath(id) {
978
+ return `${USER_TIER_SPELLS_DIR}/${id}.md`;
979
+ }
980
+ function spellIdFromCanonicalPath(path4) {
981
+ return path4.replace(/\\/g, "/").match(CANONICAL_SPELL_PATH_PATTERN)?.[1];
982
+ }
983
+ function spellIdFromStorePath(path4) {
984
+ return path4.replace(/\\/g, "/").match(STORE_SPELL_PATH_PATTERN)?.[1];
985
+ }
986
+ function absoluteStoreSpellPath(storeRoot, id) {
987
+ return resolve(storeRoot, storeSpellPath(id)).replace(/\\/g, "/");
988
+ }
989
+ function componentForScope(component, scope) {
990
+ if (scope === "repo") return component;
991
+ const files = [];
992
+ const sourceOverrides = {};
993
+ for (const file of component.files) {
994
+ const id = spellIdFromCanonicalPath(file);
995
+ if (id === void 0) continue;
996
+ const installed = storeSpellPath(id);
997
+ files.push(installed);
998
+ sourceOverrides[installed] = component.sourceOverrides?.[file] ?? file;
999
+ }
1000
+ return { name: component.name, description: component.description, files, sourceOverrides };
1001
+ }
1002
+ function spellIdsInStore(components) {
1003
+ const ids = /* @__PURE__ */ new Set();
1004
+ for (const component of components) {
1005
+ for (const file of component.files) {
1006
+ const id = spellIdFromStorePath(file);
1007
+ if (id !== void 0) ids.add(id);
1008
+ }
1009
+ }
1010
+ return [...ids].sort();
1011
+ }
1012
+ var USER_FANOUT_PATHS = {
1013
+ codex: (id) => `.agents/skills/${id}/SKILL.md`,
1014
+ claude: (id) => `.claude/commands/${id}.md`
1015
+ };
1016
+ var FANOUT_CLIENT_LABELS = {
1017
+ codex: "Codex/Copilot skills",
1018
+ claude: "Claude Code commands"
1019
+ };
1020
+ function clientOfFanoutPath(relativePath) {
1021
+ const normalized = relativePath.replace(/\\/g, "/");
1022
+ if (normalized.startsWith(".agents/skills/")) return "codex";
1023
+ if (normalized.startsWith(".claude/commands/")) return "claude";
1024
+ return void 0;
1025
+ }
1026
+ async function readSpellSource(storeRoot, id, fallbackDir) {
1027
+ for (const candidate of [
1028
+ join(storeRoot, storeSpellPath(id)),
1029
+ ...fallbackDir ? [join(fallbackDir, canonicalSpellPath(id))] : []
1030
+ ]) {
1031
+ try {
1032
+ return await readFile3(candidate, "utf-8");
1033
+ } catch (err) {
1034
+ if (err.code !== "ENOENT") throw err;
1035
+ }
1036
+ }
1037
+ return void 0;
1038
+ }
1039
+ async function renderUserFanout(storeRoot, spellIds, fallbackDir) {
1040
+ const files = [];
1041
+ const missing = [];
1042
+ for (const id of [...spellIds].sort()) {
1043
+ const canonical = await readSpellSource(storeRoot, id, fallbackDir);
1044
+ if (canonical === void 0) {
1045
+ missing.push(id);
1046
+ continue;
1047
+ }
1048
+ const frontmatter = parsePromptFrontmatter(canonical);
1049
+ const absolutePath = absoluteStoreSpellPath(storeRoot, id);
1050
+ files.push({
1051
+ relativePath: USER_FANOUT_PATHS.codex(id),
1052
+ client: "codex",
1053
+ content: renderCodexSkill(id, frontmatter, absolutePath)
1054
+ });
1055
+ files.push({
1056
+ relativePath: USER_FANOUT_PATHS.claude(id),
1057
+ client: "claude",
1058
+ content: renderClaudeCommandStub(id, frontmatter, absolutePath)
1059
+ });
1060
+ }
1061
+ return { files, missing };
1062
+ }
1063
+ async function classifyTarget(path4) {
1064
+ try {
1065
+ const stats = await lstat(path4);
1066
+ return stats.isFile() ? "file" : "other";
1067
+ } catch (err) {
1068
+ if (err.code === "ENOENT") return "absent";
1069
+ throw err;
1070
+ }
1071
+ }
1072
+ function perSpellDirectory(homeDir, relativePath) {
1073
+ if (clientOfFanoutPath(relativePath) !== "codex") return void 0;
1074
+ return dirname(join(homeDir, relativePath));
1075
+ }
1076
+ async function removeDirectoryIfEmpty(dir) {
1077
+ try {
1078
+ const entries = await readdir2(dir);
1079
+ if (entries.length === 0) await rmdir(dir);
1080
+ } catch {
1081
+ }
1082
+ }
1083
+ async function syncUserTierFanout(options) {
1084
+ const rendered = await renderUserFanout(options.storeRoot, options.spellIds, options.fallbackDir);
1085
+ const desired = rendered.files;
1086
+ const previous = options.previous ?? {};
1087
+ const dryRun = Boolean(options.dryRun);
1088
+ const record = {};
1089
+ const outcomes = [];
1090
+ const desiredPaths = new Set(desired.map((f) => f.relativePath));
1091
+ for (const id of rendered.missing) {
1092
+ for (const client of ["codex", "claude"]) {
1093
+ const relativePath = USER_FANOUT_PATHS[client](id);
1094
+ desiredPaths.add(relativePath);
1095
+ if (previous[relativePath] !== void 0) record[relativePath] = previous[relativePath];
1096
+ outcomes.push({ relativePath, status: "unrenderable" });
1097
+ }
1098
+ }
1099
+ for (const file of desired) {
1100
+ validateTargetPath(options.homeDir, file.relativePath);
1101
+ const target = join(options.homeDir, file.relativePath);
1102
+ const recorded = previous[file.relativePath];
1103
+ const newHash = hashContent(file.content);
1104
+ const state = await classifyTarget(target);
1105
+ if (state === "absent") {
1106
+ if (!dryRun) {
1107
+ await mkdir2(dirname(target), { recursive: true });
1108
+ await writeFile3(target, file.content, "utf-8");
1109
+ }
1110
+ record[file.relativePath] = newHash;
1111
+ outcomes.push({ relativePath: file.relativePath, status: "written" });
1112
+ continue;
1113
+ }
1114
+ if (state === "other" || recorded === void 0) {
1115
+ outcomes.push({ relativePath: file.relativePath, status: "collision" });
1116
+ continue;
1117
+ }
1118
+ if (!await fileMatchesHash(target, recorded)) {
1119
+ record[file.relativePath] = recorded;
1120
+ outcomes.push({ relativePath: file.relativePath, status: "customized" });
1121
+ continue;
1122
+ }
1123
+ const currentHash = await hashFile(target);
1124
+ if (currentHash === newHash) {
1125
+ record[file.relativePath] = newHash;
1126
+ outcomes.push({ relativePath: file.relativePath, status: "unchanged" });
1127
+ continue;
1128
+ }
1129
+ if (!dryRun) await writeFile3(target, file.content, "utf-8");
1130
+ record[file.relativePath] = newHash;
1131
+ outcomes.push({ relativePath: file.relativePath, status: "written" });
1132
+ }
1133
+ for (const [relativePath, recorded] of Object.entries(previous)) {
1134
+ if (desiredPaths.has(relativePath)) continue;
1135
+ validateTargetPath(options.homeDir, relativePath);
1136
+ const target = join(options.homeDir, relativePath);
1137
+ const state = await classifyTarget(target);
1138
+ if (state === "absent") {
1139
+ outcomes.push({ relativePath, status: "gone" });
1140
+ continue;
1141
+ }
1142
+ if (state === "other" || !await fileMatchesHash(target, recorded)) {
1143
+ record[relativePath] = recorded;
1144
+ outcomes.push({ relativePath, status: "kept-edited" });
1145
+ continue;
1146
+ }
1147
+ if (!dryRun) {
1148
+ await rm2(target, { force: true });
1149
+ const spellDir = perSpellDirectory(options.homeDir, relativePath);
1150
+ if (spellDir !== void 0) await removeDirectoryIfEmpty(spellDir);
1151
+ }
1152
+ outcomes.push({ relativePath, status: "pruned" });
1153
+ }
1154
+ return { record, outcomes };
1155
+ }
1156
+ function summarizeFanout(outcomes) {
1157
+ const counts = {
1158
+ written: 0,
1159
+ unchanged: 0,
1160
+ customized: 0,
1161
+ collision: 0,
1162
+ pruned: 0,
1163
+ "kept-edited": 0,
1164
+ gone: 0,
1165
+ unrenderable: 0
1166
+ };
1167
+ for (const outcome of outcomes) counts[outcome.status]++;
1168
+ return counts;
1169
+ }
1170
+ function describeFanoutOutcomes(outcomes, dryRun = false) {
1171
+ const counts = summarizeFanout(outcomes);
1172
+ const lines = [];
1173
+ const prefix = dryRun ? "[dry-run] Would " : "";
1174
+ const byClient = (status) => {
1175
+ const n = { codex: 0, claude: 0 };
1176
+ for (const o of outcomes) {
1177
+ if (o.status !== status) continue;
1178
+ const client = clientOfFanoutPath(o.relativePath);
1179
+ if (client !== void 0) n[client]++;
1180
+ }
1181
+ return n;
1182
+ };
1183
+ const paths = (status) => outcomes.filter((o) => o.status === status).map((o) => ` ~/${o.relativePath}`).join("\n");
1184
+ if (counts.written > 0) {
1185
+ const n = byClient("written");
1186
+ lines.push(
1187
+ `${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${n.codex} ${FANOUT_CLIENT_LABELS.codex}, ${n.claude} ${FANOUT_CLIENT_LABELS.claude}.`
1188
+ );
1189
+ }
1190
+ if (counts.pruned > 0) {
1191
+ lines.push(`${prefix}${dryRun ? "remove" : "Removed"} ${counts.pruned} client file(s) no longer needed.`);
1192
+ }
1193
+ if (counts.customized > 0) {
1194
+ lines.push(
1195
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts.customized} customized client file(s) untouched (edited since Arcane wrote them):
1196
+ ${paths("customized")}
1197
+ Delete one to have it regenerated by the next \`spell update --user\`.`
1198
+ );
1199
+ }
1200
+ if (counts.collision > 0) {
1201
+ lines.push(
1202
+ `! ${dryRun ? "Would leave" : "Left"} ${counts.collision} existing path(s) alone (not written by Arcane \u2014 a skill or command of the same name, a directory or a link already exists there):
1203
+ ${paths("collision")}
1204
+ Move or rename it and run \`spell update --user\` to install Arcane's.`
1205
+ );
1206
+ }
1207
+ if (counts.unrenderable > 0) {
1208
+ lines.push(
1209
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts.unrenderable} client file(s) whose spell source is missing from the store and from this CLI (left as they are):
1210
+ ${paths("unrenderable")}
1211
+ Restore the spell with \`spell update --user\`, or delete these files if the spell is no longer shipped.`
1212
+ );
1213
+ }
1214
+ if (counts["kept-edited"] > 0) {
1215
+ lines.push(
1216
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts["kept-edited"]} edited client file(s) that Arcane no longer needs (delete them yourself if unwanted):
1217
+ ${paths("kept-edited")}`
1218
+ );
1219
+ }
1220
+ return lines;
1221
+ }
1222
+ async function inspectUserTierFanout(homeDir, record) {
1223
+ const health = {
1224
+ total: 0,
1225
+ byClient: { codex: 0, claude: 0 },
1226
+ missing: [],
1227
+ customized: []
1228
+ };
1229
+ for (const [relativePath, recorded] of Object.entries(record ?? {})) {
1230
+ health.total++;
1231
+ const client = clientOfFanoutPath(relativePath);
1232
+ if (client !== void 0) health.byClient[client]++;
1233
+ const target = join(homeDir, relativePath);
1234
+ const state = await classifyTarget(target);
1235
+ if (state === "absent") {
1236
+ health.missing.push(relativePath);
1237
+ continue;
1238
+ }
1239
+ if (state === "other" || !await fileMatchesHash(target, recorded)) health.customized.push(relativePath);
1240
+ }
1241
+ return health;
1242
+ }
1243
+ function isUserTierStore(targetDir) {
1244
+ return resolve(targetDir) === resolve(userTierRoot());
1245
+ }
1246
+ function resolveInstallScope(targetDir, manifestScope, userFlag) {
1247
+ if (userFlag) return { scope: "user", misplacedUserManifest: false };
1248
+ if (manifestScope !== "user") return { scope: "repo", misplacedUserManifest: false };
1249
+ return isUserTierStore(targetDir) ? { scope: "user", misplacedUserManifest: false } : { scope: "repo", misplacedUserManifest: true };
1250
+ }
1251
+ function misplacedUserManifestMessage(targetDir) {
1252
+ return `This .arcane.json belongs to the user tier (scope: "user"), but ${targetDir} is not the store (${userTierRoot()}). Run the command with --user to operate on the store, or from the store directory itself.`;
1253
+ }
1254
+ var VSCODE_USER_TIER_NOTE = [
1255
+ "VS Code Copilot: no settings change is needed \u2014 Copilot discovers ~/.agents/skills by default",
1256
+ "(chat.useAgentSkills, on by default) and lists each spell under /. Reload the window after installing.",
1257
+ "VS Code has deprecated chat.promptFilesLocations; the user tier does not use it."
1258
+ ];
1259
+ var CLAUDE_PRECEDENCE_NOTE = "Claude Code runs a personal command over a project command of the same name, so /spell-* in a repository that still carries its own shims now runs the user tier's copy.";
1260
+
825
1261
  // src/modules/banner.ts
826
1262
  import chalk from "chalk";
827
1263
  import gradient from "gradient-string";
@@ -844,7 +1280,7 @@ var WORDMARK = [
844
1280
  var INDENT = " ";
845
1281
  var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
846
1282
  function sleep(ms) {
847
- return new Promise((resolve4) => setTimeout(resolve4, ms));
1283
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
848
1284
  }
849
1285
  function isAnimatable() {
850
1286
  return !!process.stdout.isTTY && process.env["TERM"] !== "dumb" && process.env["CI"] !== "true" && process.env["ARCANE_DISABLE_ANIMATION"] !== "true" && (process.stdout.columns ?? 80) >= LOCKUP_WIDTH;
@@ -962,8 +1398,8 @@ function printWarning(message) {
962
1398
 
963
1399
  // src/modules/agents.ts
964
1400
  import { select, checkbox, Separator } from "@inquirer/prompts";
965
- import { mkdir as mkdir3, writeFile as writeFile5, copyFile as copyFile2 } from "fs/promises";
966
- import { join as join3 } from "path";
1401
+ import { mkdir as mkdir4, writeFile as writeFile6, copyFile as copyFile2 } from "fs/promises";
1402
+ import { join as join4 } from "path";
967
1403
  import { stringify } from "yaml";
968
1404
 
969
1405
  // src/config/agent-roles.ts
@@ -1198,8 +1634,8 @@ async function applyNamingStrategy(strategy, roleIds) {
1198
1634
  }
1199
1635
 
1200
1636
  // src/modules/agent-loader.ts
1201
- import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
1202
- import { join } from "path";
1637
+ import { readFile as readFile4, readdir as readdir3, access as access2 } from "fs/promises";
1638
+ import { join as join2 } from "path";
1203
1639
  import { parse } from "yaml";
1204
1640
 
1205
1641
  // src/modules/agent-schema.ts
@@ -1379,7 +1815,7 @@ var AgentRosterNotFoundError = class extends Error {
1379
1815
  this.name = "AgentRosterNotFoundError";
1380
1816
  }
1381
1817
  };
1382
- function parseYaml(content, source) {
1818
+ function parseYaml2(content, source) {
1383
1819
  try {
1384
1820
  return parse(content);
1385
1821
  } catch (err) {
@@ -1388,44 +1824,44 @@ function parseYaml(content, source) {
1388
1824
  }
1389
1825
  }
1390
1826
  async function loadAgentDefinition(agentsDir, id) {
1391
- const filePath = join(agentsDir, `${id}.yaml`);
1827
+ const filePath = join2(agentsDir, `${id}.yaml`);
1392
1828
  let content;
1393
1829
  try {
1394
- content = await readFile3(filePath, "utf8");
1830
+ content = await readFile4(filePath, "utf8");
1395
1831
  } catch {
1396
1832
  throw new AgentDefinitionNotFoundError(id, agentsDir);
1397
1833
  }
1398
- return validateAgentDefinition(parseYaml(content, filePath), filePath);
1834
+ return validateAgentDefinition(parseYaml2(content, filePath), filePath);
1399
1835
  }
1400
1836
  async function loadRoster(targetDir) {
1401
- const rosterPath = join(targetDir, ".arcane", "agents.yaml");
1837
+ const rosterPath = join2(targetDir, ".arcane", "agents.yaml");
1402
1838
  let content;
1403
1839
  try {
1404
- content = await readFile3(rosterPath, "utf8");
1840
+ content = await readFile4(rosterPath, "utf8");
1405
1841
  } catch {
1406
1842
  throw new AgentRosterNotFoundError();
1407
1843
  }
1408
- return validateAgentRoster(parseYaml(content, rosterPath), rosterPath);
1844
+ return validateAgentRoster(parseYaml2(content, rosterPath), rosterPath);
1409
1845
  }
1410
1846
  async function rosterExists(targetDir) {
1411
1847
  try {
1412
- await access2(join(targetDir, ".arcane", "agents.yaml"));
1848
+ await access2(join2(targetDir, ".arcane", "agents.yaml"));
1413
1849
  return true;
1414
1850
  } catch {
1415
1851
  return false;
1416
1852
  }
1417
1853
  }
1418
1854
  function projectAgentsDir(targetDir) {
1419
- return join(targetDir, ".arcane", "agents");
1855
+ return join2(targetDir, ".arcane", "agents");
1420
1856
  }
1421
1857
 
1422
1858
  // src/modules/agent-generator.ts
1423
- import { writeFile as writeFile4, mkdir as mkdir2 } from "fs/promises";
1424
- import { join as join2 } from "path";
1425
- import { homedir } from "os";
1859
+ import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
1860
+ import { join as join3 } from "path";
1861
+ import { homedir as homedir2 } from "os";
1426
1862
 
1427
1863
  // src/modules/merger.ts
1428
- import { readFile as readFile4, writeFile as writeFile3, rm } from "fs/promises";
1864
+ import { readFile as readFile5, writeFile as writeFile4, rm as rm3 } from "fs/promises";
1429
1865
  import path3 from "path";
1430
1866
  var MARKER_START = "<!-- arcane:start -->";
1431
1867
  var MARKER_END = "<!-- arcane:end -->";
@@ -1440,7 +1876,7 @@ async function mergeIntoFile(targetDir, relativePath, content, opts = {}) {
1440
1876
  const destPath = path3.resolve(targetDir, relativePath);
1441
1877
  let existing;
1442
1878
  try {
1443
- existing = await readFile4(destPath, "utf8");
1879
+ existing = await readFile5(destPath, "utf8");
1444
1880
  } catch (err) {
1445
1881
  const e = err;
1446
1882
  if (e.code === "ENOENT") {
@@ -1456,7 +1892,7 @@ ${content}
1456
1892
  ${MARKER_END}
1457
1893
  `;
1458
1894
  if (!opts.dryRun) {
1459
- await writeFile3(destPath, newContent, "utf8");
1895
+ await writeFile4(destPath, newContent, "utf8");
1460
1896
  }
1461
1897
  return true;
1462
1898
  }
@@ -1481,7 +1917,7 @@ ${MARKER_END}
1481
1917
  ${content}
1482
1918
  ${afterMarker}`;
1483
1919
  if (!opts.dryRun) {
1484
- await writeFile3(destPath, updated, "utf8");
1920
+ await writeFile4(destPath, updated, "utf8");
1485
1921
  }
1486
1922
  return true;
1487
1923
  }
@@ -1491,7 +1927,7 @@ ${content}
1491
1927
  ${MARKER_END}
1492
1928
  `;
1493
1929
  if (!opts.dryRun) {
1494
- await writeFile3(destPath, appended, "utf8");
1930
+ await writeFile4(destPath, appended, "utf8");
1495
1931
  }
1496
1932
  return true;
1497
1933
  }
@@ -1499,7 +1935,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1499
1935
  const destPath = path3.resolve(targetDir, relativePath);
1500
1936
  let existing;
1501
1937
  try {
1502
- existing = await readFile4(destPath, "utf8");
1938
+ existing = await readFile5(destPath, "utf8");
1503
1939
  } catch {
1504
1940
  return false;
1505
1941
  }
@@ -1511,9 +1947,9 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1511
1947
  const stripped = (before + after).replace(/\n{3,}/g, "\n\n").trim();
1512
1948
  if (!opts.dryRun) {
1513
1949
  if (stripped.length === 0) {
1514
- await rm(destPath, { force: true });
1950
+ await rm3(destPath, { force: true });
1515
1951
  } else {
1516
- await writeFile3(destPath, stripped + "\n", "utf8");
1952
+ await writeFile4(destPath, stripped + "\n", "utf8");
1517
1953
  }
1518
1954
  }
1519
1955
  return true;
@@ -1634,7 +2070,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1634
2070
  const skipped = [];
1635
2071
  let hasUnresolvedRoles = false;
1636
2072
  const projectDefs = projectAgentsDir(targetDir);
1637
- const bundledDefs = join2(assetsDir, "agents");
2073
+ const bundledDefs = join3(assetsDir, "agents");
1638
2074
  const resolved = [];
1639
2075
  for (const entry of roster.roster) {
1640
2076
  let def;
@@ -1658,27 +2094,27 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1658
2094
  if (roster.openclaw.enabled && options.openclaw !== false) {
1659
2095
  const openclawRoot = roster.openclaw.workspace_root.replace(
1660
2096
  /^~/,
1661
- homedir()
2097
+ homedir2()
1662
2098
  );
1663
2099
  for (const { entry, def, displayName } of resolved) {
1664
2100
  if (!def.clients.openclaw) continue;
1665
2101
  const workspaceName = entry.id === "main" ? "workspace" : `workspace-${entry.id}`;
1666
- const workspaceDir = join2(openclawRoot, workspaceName);
2102
+ const workspaceDir = join3(openclawRoot, workspaceName);
1667
2103
  try {
1668
2104
  if (!options.dryRun) {
1669
- await mkdir2(workspaceDir, { recursive: true });
1670
- await writeFile4(
1671
- join2(workspaceDir, "IDENTITY.md"),
2105
+ await mkdir3(workspaceDir, { recursive: true });
2106
+ await writeFile5(
2107
+ join3(workspaceDir, "IDENTITY.md"),
1672
2108
  renderIdentity(def, displayName),
1673
2109
  "utf8"
1674
2110
  );
1675
- await writeFile4(
1676
- join2(workspaceDir, "SOUL.md"),
2111
+ await writeFile5(
2112
+ join3(workspaceDir, "SOUL.md"),
1677
2113
  renderSoul(def, displayName),
1678
2114
  "utf8"
1679
2115
  );
1680
- await writeFile4(
1681
- join2(workspaceDir, "TOOLS.md"),
2116
+ await writeFile5(
2117
+ join3(workspaceDir, "TOOLS.md"),
1682
2118
  renderTools(def, displayName),
1683
2119
  "utf8"
1684
2120
  );
@@ -1703,11 +2139,11 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1703
2139
  }
1704
2140
  }))
1705
2141
  };
1706
- const generatedDir = join2(targetDir, ".arcane", "generated");
2142
+ const generatedDir = join3(targetDir, ".arcane", "generated");
1707
2143
  if (!options.dryRun) {
1708
- await mkdir2(generatedDir, { recursive: true });
1709
- await writeFile4(
1710
- join2(generatedDir, "openclaw-roster.json"),
2144
+ await mkdir3(generatedDir, { recursive: true });
2145
+ await writeFile5(
2146
+ join3(generatedDir, "openclaw-roster.json"),
1711
2147
  JSON.stringify(patch, null, 2) + "\n",
1712
2148
  "utf8"
1713
2149
  );
@@ -1719,17 +2155,17 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1719
2155
  synced.push("openclaw-roster.json");
1720
2156
  }
1721
2157
  if (options.copilot !== false) {
1722
- const agentsDir = join2(targetDir, ".github", "agents");
2158
+ const agentsDir = join3(targetDir, ".github", "agents");
1723
2159
  if (!options.dryRun) {
1724
- await mkdir2(agentsDir, { recursive: true });
2160
+ await mkdir3(agentsDir, { recursive: true });
1725
2161
  }
1726
2162
  for (const { def, displayName } of resolved) {
1727
2163
  if (!def.clients.copilot?.agent_file) continue;
1728
2164
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
1729
2165
  const fileName = `${slug}.agent.md`;
1730
2166
  if (!options.dryRun) {
1731
- await writeFile4(
1732
- join2(agentsDir, fileName),
2167
+ await writeFile5(
2168
+ join3(agentsDir, fileName),
1733
2169
  renderCopilotAgent(def, displayName),
1734
2170
  "utf8"
1735
2171
  );
@@ -1870,11 +2306,11 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1870
2306
  },
1871
2307
  roster: rosterEntries
1872
2308
  };
1873
- const arcaneDir = join3(targetDir, ".arcane", "agents");
2309
+ const arcaneDir = join4(targetDir, ".arcane", "agents");
1874
2310
  if (!options.dryRun) {
1875
- await mkdir3(arcaneDir, { recursive: true });
1876
- await writeFile5(
1877
- join3(targetDir, ".arcane", "agents.yaml"),
2311
+ await mkdir4(arcaneDir, { recursive: true });
2312
+ await writeFile6(
2313
+ join4(targetDir, ".arcane", "agents.yaml"),
1878
2314
  stringify(roster),
1879
2315
  "utf8"
1880
2316
  );
@@ -1882,8 +2318,8 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1882
2318
  console.log(" [dry-run] Would write: .arcane/agents.yaml");
1883
2319
  }
1884
2320
  for (const roleId of selectedRoleIds) {
1885
- const src = join3(assetsDir, "agents", `${roleId}.yaml`);
1886
- const dest = join3(arcaneDir, `${roleId}.yaml`);
2321
+ const src = join4(assetsDir, "agents", `${roleId}.yaml`);
2322
+ const dest = join4(arcaneDir, `${roleId}.yaml`);
1887
2323
  if (!options.dryRun) {
1888
2324
  try {
1889
2325
  await copyFile2(src, dest);
@@ -2017,9 +2453,9 @@ async function runAgentsList(targetDir) {
2017
2453
  }
2018
2454
 
2019
2455
  // src/modules/push-safety.ts
2020
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
2456
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm4, readdir as readdir4 } from "fs/promises";
2021
2457
  import { constants } from "fs";
2022
- import { join as join4, resolve, dirname, basename } from "path";
2458
+ import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
2023
2459
 
2024
2460
  // src/modules/git.ts
2025
2461
  import { execFile } from "child_process";
@@ -2116,7 +2552,7 @@ function buildGitEnv() {
2116
2552
  function runGit(cwd, args, options = {}) {
2117
2553
  const commandClass = options.commandClass ?? classifyGitCommand(args);
2118
2554
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
2119
- return new Promise((resolve4, reject) => {
2555
+ return new Promise((resolve5, reject) => {
2120
2556
  const child = execFile(
2121
2557
  "git",
2122
2558
  args,
@@ -2150,7 +2586,7 @@ function runGit(cwd, args, options = {}) {
2150
2586
  reject(error);
2151
2587
  return;
2152
2588
  }
2153
- resolve4({ stdout, stderr });
2589
+ resolve5({ stdout, stderr });
2154
2590
  }
2155
2591
  );
2156
2592
  child.stdin?.end();
@@ -2268,7 +2704,7 @@ function isArcaneHooksPath(root, value) {
2268
2704
  const windows = process.platform === "win32";
2269
2705
  const normalize = (p) => {
2270
2706
  const separated = windows ? p.replace(/\\/g, "/") : p;
2271
- const absolute = resolve(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2707
+ const absolute = resolve2(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2272
2708
  return windows ? absolute.toLowerCase() : absolute;
2273
2709
  };
2274
2710
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
@@ -2316,7 +2752,7 @@ async function repositoryRoot(cwd) {
2316
2752
  ]);
2317
2753
  const commonDir = stdout.trim();
2318
2754
  if (commonDir !== "") {
2319
- const parent = dirname(commonDir);
2755
+ const parent = dirname2(commonDir);
2320
2756
  if (basename(commonDir) === ".git" && parent !== "") return parent;
2321
2757
  }
2322
2758
  } catch {
@@ -2330,10 +2766,10 @@ async function repositoryRoot(cwd) {
2330
2766
  return cwd;
2331
2767
  }
2332
2768
  async function hookFilePath(cwd, hookName = "pre-push") {
2333
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2769
+ return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2334
2770
  }
2335
2771
  async function hooksPathValue(cwd) {
2336
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2772
+ return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2337
2773
  }
2338
2774
  async function installHook(cwd, hookName, body) {
2339
2775
  const root = await repositoryRoot(cwd);
@@ -2352,12 +2788,12 @@ async function installHook(cwd, hookName, body) {
2352
2788
  return { status: "refused-default-hooks", hooks: displaced };
2353
2789
  }
2354
2790
  }
2355
- const hookPath = join4(root, ARCANE_HOOKS_DIR, hookName);
2791
+ const hookPath = join5(root, ARCANE_HOOKS_DIR, hookName);
2356
2792
  if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2357
2793
  return { status: "already-ours" };
2358
2794
  }
2359
- await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2360
- await writeFile6(hookPath, body, "utf-8");
2795
+ await mkdir5(join5(root, ARCANE_HOOKS_DIR), { recursive: true });
2796
+ await writeFile7(hookPath, body, "utf-8");
2361
2797
  await chmod(hookPath, 493);
2362
2798
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2363
2799
  return { status: "installed", path: hookPath };
@@ -2401,12 +2837,12 @@ async function defaultHooks(cwd) {
2401
2837
  let hooksDir;
2402
2838
  try {
2403
2839
  const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
2404
- hooksDir = join4(resolve(cwd, stdout.trim()), "hooks");
2840
+ hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
2405
2841
  } catch {
2406
2842
  return [];
2407
2843
  }
2408
2844
  try {
2409
- return (await readdir3(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2845
+ return (await readdir4(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2410
2846
  } catch {
2411
2847
  return [];
2412
2848
  }
@@ -2415,7 +2851,7 @@ async function hookFileMatches(cwd, hookName, body) {
2415
2851
  const hookPath = await hookFilePath(cwd, hookName);
2416
2852
  if (!await fileExists(hookPath)) return false;
2417
2853
  try {
2418
- if (await readFile5(hookPath, "utf-8") !== body) return false;
2854
+ if (await readFile6(hookPath, "utf-8") !== body) return false;
2419
2855
  } catch {
2420
2856
  return false;
2421
2857
  }
@@ -2434,13 +2870,13 @@ async function removeHook(cwd, hookName) {
2434
2870
  if (!isArcaneHooksPath(root, existing.value)) return;
2435
2871
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2436
2872
  try {
2437
- await rm2(await hookFilePath(cwd, hookName), { force: true });
2873
+ await rm4(await hookFilePath(cwd, hookName), { force: true });
2438
2874
  } catch {
2439
2875
  }
2440
- const hooksDir = join4(root, ARCANE_HOOKS_DIR);
2876
+ const hooksDir = join5(root, ARCANE_HOOKS_DIR);
2441
2877
  let remaining = [];
2442
2878
  try {
2443
- remaining = await readdir3(hooksDir);
2879
+ remaining = await readdir4(hooksDir);
2444
2880
  } catch {
2445
2881
  remaining = [];
2446
2882
  }
@@ -2768,6 +3204,10 @@ function buildProfileDescription(components, profile) {
2768
3204
  return "\n" + parts.join("\n");
2769
3205
  }
2770
3206
  async function runInit(options, targetDir, assetsDir, packageVersion) {
3207
+ if (options.user) {
3208
+ await runInitUser(options, targetDir, assetsDir, packageVersion);
3209
+ return;
3210
+ }
2771
3211
  if (options.profile && !VALID_PROFILES.includes(options.profile)) {
2772
3212
  throw new Error(
2773
3213
  `Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
@@ -2872,7 +3312,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2872
3312
  const installedFiles = [];
2873
3313
  const fileHashes = {};
2874
3314
  for (const file of component.files) {
2875
- const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
3315
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2876
3316
  if (options.dryRun) {
2877
3317
  printDryRun(`Would copy: ${file}`);
2878
3318
  } else {
@@ -2889,7 +3329,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2889
3329
  fileCount++;
2890
3330
  }
2891
3331
  for (const dir of component.directories ?? []) {
2892
- const srcDirPath = join5(assetsDir, dir);
3332
+ const srcDirPath = join6(assetsDir, dir);
2893
3333
  if (options.dryRun) {
2894
3334
  printDryRun(`Would copy directory: ${dir}/`);
2895
3335
  } else {
@@ -2918,7 +3358,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2918
3358
  if (options.dryRun) {
2919
3359
  if (hasAgents) {
2920
3360
  const agentFiles = await import("fs").then(
2921
- (fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
3361
+ (fsModule) => fsModule.promises.readdir(join6(assetsDir, "agents"))
2922
3362
  );
2923
3363
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
2924
3364
  printDryRun("Would run agent setup (interactive):");
@@ -3160,9 +3600,110 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3160
3600
  );
3161
3601
  console.log();
3162
3602
  }
3603
+ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3604
+ if (options.profile) {
3605
+ throw new Error(
3606
+ '"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
3607
+ );
3608
+ }
3609
+ try {
3610
+ await readManifest(storeRoot);
3611
+ console.log(chalk2.hex("#a855f7")(`
3612
+ \u2726 Arcane v${packageVersion}
3613
+ `));
3614
+ console.log(
3615
+ `Already initialized (user tier at ${storeRoot}). Run "spell update --user" to update it.`
3616
+ );
3617
+ return;
3618
+ } catch (err) {
3619
+ if (!(err instanceof ManifestNotFoundError)) throw err;
3620
+ }
3621
+ console.log(chalk2.hex("#a855f7")(`
3622
+ \u2726 Arcane v${packageVersion}
3623
+ `));
3624
+ const homeDir = dirname3(storeRoot);
3625
+ const components = USER_TIER_COMPONENTS.map(
3626
+ (name) => componentForScope(getComponent(name), "user")
3627
+ );
3628
+ const spellIds = components.flatMap((c) => c.files).map((file) => spellIdFromStorePath(file)).filter((id) => id !== void 0);
3629
+ printSection(`\u{1F4E6} User tier \u2014 ${storeRoot}`);
3630
+ console.log();
3631
+ console.log(
3632
+ ` \u2728 ${spellIds.length} Spells \u2192 ${storeRoot}/spells/ \xB7 \u{1F517} ${spellIds.length * 2} client files \u2192 ~/.agents/skills (Codex, Copilot), ~/.claude/commands (Claude Code)`
3633
+ );
3634
+ console.log();
3635
+ if (options.dryRun) {
3636
+ for (const component of components) {
3637
+ for (const file of component.files) printDryRun(`Would copy: ${file}`);
3638
+ }
3639
+ for (const id of spellIds) {
3640
+ for (const client of ["codex", "claude"]) {
3641
+ const relativePath = USER_FANOUT_PATHS[client](id);
3642
+ if (await fileExists(join6(homeDir, relativePath))) {
3643
+ printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
3644
+ } else {
3645
+ printDryRun(`Would write: ~/${relativePath}`);
3646
+ }
3647
+ }
3648
+ }
3649
+ printDryRun(
3650
+ `Would initialize the user tier \u2014 ${spellIds.length} spells, ${spellIds.length * 2} client files`
3651
+ );
3652
+ return;
3653
+ }
3654
+ const installedComponents = [];
3655
+ let fileCount = 0;
3656
+ for (const component of components) {
3657
+ const installedFiles = [];
3658
+ const fileHashes = {};
3659
+ for (const file of component.files) {
3660
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3661
+ fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
3662
+ installedFiles.push(file);
3663
+ fileCount++;
3664
+ }
3665
+ installedComponents.push({
3666
+ name: component.name,
3667
+ files: installedFiles,
3668
+ installedVersion: packageVersion,
3669
+ fileHashes
3670
+ });
3671
+ }
3672
+ const fanout = await syncUserTierFanout({ homeDir, storeRoot, spellIds, fallbackDir: assetsDir });
3673
+ const manifest = {
3674
+ version: packageVersion,
3675
+ // The tier is un-profiled; "full" records the profile its component set
3676
+ // is drawn from, and every command reads `scope` before `profile`.
3677
+ profile: "full",
3678
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3679
+ components: installedComponents,
3680
+ scope: "user",
3681
+ fanout: fanout.record
3682
+ };
3683
+ await writeManifest(storeRoot, manifest);
3684
+ printStep(`\u2728 ${fileCount} Spells installed to ${storeRoot}/spells/`);
3685
+ for (const line of describeFanoutOutcomes(fanout.outcomes)) {
3686
+ if (line.startsWith("!")) printWarning(line.slice(2));
3687
+ else printStep(`\u{1F517} ${line}`);
3688
+ }
3689
+ printSuccess(
3690
+ `Initialized the user tier \u2014 ${fileCount} spells, ${Object.keys(fanout.record).length} client files recorded`
3691
+ );
3692
+ console.log();
3693
+ printSection("\u{1F9ED} Clients");
3694
+ console.log();
3695
+ for (const line of VSCODE_USER_TIER_NOTE) printInfo(line);
3696
+ printInfo(CLAUDE_PRECEDENCE_NOTE);
3697
+ console.log();
3698
+ printSection("\u{1F680} Next steps");
3699
+ console.log();
3700
+ printNextStep(1, "Reload VS Code, and start a fresh Codex or Claude Code session, so the pickers see the new files");
3701
+ printNextStep(2, "`spell status --user` shows the tier; `spell update --user` refreshes it after upgrading the CLI");
3702
+ console.log();
3703
+ }
3163
3704
 
3164
3705
  // src/commands/add.ts
3165
- import { join as join6 } from "path";
3706
+ import { join as join7 } from "path";
3166
3707
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3167
3708
  let manifest;
3168
3709
  try {
@@ -3198,7 +3739,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3198
3739
  const installedFiles = [];
3199
3740
  const fileHashes = {};
3200
3741
  for (const file of component.files) {
3201
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3742
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3202
3743
  if (options.dryRun) {
3203
3744
  console.log(` [dry-run] Would copy: ${file}`);
3204
3745
  } else {
@@ -3208,7 +3749,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3208
3749
  fileCount++;
3209
3750
  }
3210
3751
  for (const dir of component.directories ?? []) {
3211
- const srcDirPath = join6(assetsDir, dir);
3752
+ const srcDirPath = join7(assetsDir, dir);
3212
3753
  if (options.dryRun) {
3213
3754
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3214
3755
  } else {
@@ -3242,13 +3783,13 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3242
3783
  }
3243
3784
 
3244
3785
  // src/commands/update.ts
3245
- import { join as join8 } from "path";
3246
- import { readFile as readFile6, writeFile as writeFile8, rm as rm3 } from "fs/promises";
3786
+ import { dirname as dirname4, join as join9 } from "path";
3787
+ import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
3247
3788
 
3248
3789
  // src/modules/hub.ts
3249
3790
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
3250
- import { readdir as readdir4, writeFile as writeFile7 } from "fs/promises";
3251
- import { join as join7 } from "path";
3791
+ import { readdir as readdir5, writeFile as writeFile8 } from "fs/promises";
3792
+ import { join as join8 } from "path";
3252
3793
  var MANIFEST_RETROFITS = [
3253
3794
  {
3254
3795
  field: "role",
@@ -3387,12 +3928,12 @@ async function runManifestRetrofits(manifest) {
3387
3928
  return patch;
3388
3929
  }
3389
3930
  async function offerRegistryScaffold(targetDir, businessRoot) {
3390
- const registryPath = join7(targetDir, businessRoot, "registry.json");
3931
+ const registryPath = join8(targetDir, businessRoot, "registry.json");
3391
3932
  if (await fileExists(registryPath)) return;
3392
- const businessRootPath = join7(targetDir, businessRoot);
3933
+ const businessRootPath = join8(targetDir, businessRoot);
3393
3934
  let ventureDirs;
3394
3935
  try {
3395
- const entries = await readdir4(businessRootPath, { withFileTypes: true });
3936
+ const entries = await readdir5(businessRootPath, { withFileTypes: true });
3396
3937
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
3397
3938
  } catch {
3398
3939
  return;
@@ -3427,7 +3968,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3427
3968
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3428
3969
  ventures
3429
3970
  };
3430
- await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3971
+ await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3431
3972
  console.log(
3432
3973
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3433
3974
  );
@@ -3575,18 +4116,6 @@ async function fetchPublishedFile(version, relativeAssetPath) {
3575
4116
  }
3576
4117
  }
3577
4118
 
3578
- // src/modules/spell-compiler.ts
3579
- import { parse as parseYaml2 } from "yaml";
3580
- var CLIENT_SHIM_PATH_PATTERNS = [
3581
- /^\.github\/prompts\/spell-[a-z0-9-]+\.prompt\.md$/,
3582
- /^\.claude\/commands\/spell-[a-z0-9-]+\.md$/,
3583
- /^\.agents\/skills\/spell-[a-z0-9-]+\/SKILL\.md$/
3584
- ];
3585
- function isClientShimPath(path4) {
3586
- const normalized = path4.replace(/\\/g, "/").replace(/^\.\//, "");
3587
- return CLIENT_SHIM_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
3588
- }
3589
-
3590
4119
  // src/commands/update.ts
3591
4120
  function migrateLegacyComponents(components) {
3592
4121
  const out = [];
@@ -3608,35 +4137,40 @@ function migrateLegacyComponents(components) {
3608
4137
  return out;
3609
4138
  }
3610
4139
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
3611
- const filePath = join8(targetDir, file);
4140
+ try {
4141
+ validateTargetPath(targetDir, file);
4142
+ } catch {
4143
+ console.log(` ! Refused (path escapes the target directory): ${file}`);
4144
+ return "reported";
4145
+ }
4146
+ const filePath = join9(targetDir, file);
3612
4147
  if (!await fileExists(filePath)) return "not-found";
3613
4148
  if (!prune) return "reported";
3614
4149
  if (recordedHash === void 0) {
3615
4150
  console.log(` ! Orphaned, not pruned (no recorded hash to verify it's untouched): ${file}`);
3616
4151
  return "reported";
3617
4152
  }
3618
- const currentHash = await hashFile(filePath);
3619
- if (currentHash !== recordedHash) {
4153
+ if (!await fileMatchesHash(filePath, recordedHash)) {
3620
4154
  console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
3621
4155
  return "reported";
3622
4156
  }
3623
- await rm3(filePath, { force: true });
4157
+ await removeWithin(targetDir, file);
3624
4158
  console.log(` Pruned orphaned file: ${file}`);
3625
4159
  return "pruned";
3626
4160
  }
3627
- async function findMissingTrackedFiles(targetDir, components) {
4161
+ async function findMissingTrackedFiles(targetDir, components, scope = "repo") {
3628
4162
  const missing = [];
3629
4163
  for (const installed of components) {
3630
4164
  let component;
3631
4165
  try {
3632
- component = getComponent(installed.name);
4166
+ component = componentForScope(getComponent(installed.name), scope);
3633
4167
  } catch {
3634
4168
  continue;
3635
4169
  }
3636
4170
  if (component.initOnly || component.skipExisting) continue;
3637
4171
  for (const file of installed.files) {
3638
4172
  if (!component.files.includes(file)) continue;
3639
- if (!await fileExists(join8(targetDir, file))) missing.push(file);
4173
+ if (!await fileExists(join9(targetDir, file))) missing.push(file);
3640
4174
  }
3641
4175
  }
3642
4176
  return missing;
@@ -3648,46 +4182,70 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3648
4182
  } catch (err) {
3649
4183
  if (err instanceof ManifestNotFoundError) {
3650
4184
  console.error(
3651
- 'Not initialized. Run "spell init" first before updating.'
4185
+ `Not initialized. Run "spell init${options.user ? " --user" : ""}" first before updating.`
3652
4186
  );
3653
4187
  process.exit(1);
3654
4188
  return;
3655
4189
  }
3656
4190
  throw err;
3657
4191
  }
3658
- console.warn(
3659
- `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
3660
- );
3661
- console.warn(
3662
- "Updates can replace managed files. A clean committed baseline is required for recovery."
3663
- );
3664
- const gitState = await inspectGitRepository(targetDir);
3665
- if (gitState.status === "not-repository") {
3666
- console.error(
3667
- "Update refused: this directory is not a Git repository or Git is unavailable."
3668
- );
4192
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4193
+ if (resolvedScope.misplacedUserManifest) {
4194
+ console.error(misplacedUserManifestMessage(targetDir));
3669
4195
  process.exit(1);
3670
4196
  return;
3671
4197
  }
3672
- if (gitState.status === "no-commits") {
3673
- console.error(
3674
- "Update refused: this repository has no commits. Commit the current baseline before updating."
4198
+ const scope = resolvedScope.scope;
4199
+ const homeDir = dirname4(targetDir);
4200
+ if (scope === "repo") {
4201
+ console.warn(
4202
+ `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
3675
4203
  );
3676
- process.exit(1);
3677
- return;
3678
- }
3679
- if (gitState.uncommittedChanges > 0) {
3680
- console.error(
3681
- `Update refused: this repository has ${gitState.uncommittedChanges} uncommitted change${gitState.uncommittedChanges === 1 ? "" : "s"}. Commit or otherwise clean the working tree before updating.`
4204
+ console.warn(
4205
+ "Updates can replace managed files. A clean committed baseline is required for recovery."
3682
4206
  );
3683
- process.exit(1);
3684
- return;
4207
+ const gitState = await inspectGitRepository(targetDir);
4208
+ if (gitState.status === "not-repository") {
4209
+ console.error(
4210
+ "Update refused: this directory is not a Git repository or Git is unavailable."
4211
+ );
4212
+ process.exit(1);
4213
+ return;
4214
+ }
4215
+ if (gitState.status === "no-commits") {
4216
+ console.error(
4217
+ "Update refused: this repository has no commits. Commit the current baseline before updating."
4218
+ );
4219
+ process.exit(1);
4220
+ return;
4221
+ }
4222
+ if (gitState.uncommittedChanges > 0) {
4223
+ console.error(
4224
+ `Update refused: this repository has ${gitState.uncommittedChanges} uncommitted change${gitState.uncommittedChanges === 1 ? "" : "s"}. Commit or otherwise clean the working tree before updating.`
4225
+ );
4226
+ process.exit(1);
4227
+ return;
4228
+ }
3685
4229
  }
3686
4230
  let sameVersionRestore = false;
3687
4231
  if (manifest.version === packageVersion && manifest.components.length > 0) {
3688
- const missing = await findMissingTrackedFiles(targetDir, manifest.components);
4232
+ const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
3689
4233
  if (missing.length === 0) {
3690
4234
  console.log("Already up to date.");
4235
+ if (scope === "user") {
4236
+ const fanout = await syncUserTierFanout({
4237
+ homeDir,
4238
+ storeRoot: targetDir,
4239
+ spellIds: spellIdsInStore(manifest.components),
4240
+ previous: manifest.fanout,
4241
+ dryRun: options.dryRun,
4242
+ fallbackDir: assetsDir
4243
+ });
4244
+ for (const line of describeFanoutOutcomes(fanout.outcomes, options.dryRun)) console.log(` ${line}`);
4245
+ if (!options.dryRun && JSON.stringify(fanout.record) !== JSON.stringify(manifest.fanout ?? {})) {
4246
+ await writeManifest(targetDir, { ...manifest, fanout: fanout.record });
4247
+ }
4248
+ }
3691
4249
  return;
3692
4250
  }
3693
4251
  sameVersionRestore = true;
@@ -3708,7 +4266,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3708
4266
  for (const installed of componentsToUpdate) {
3709
4267
  let component;
3710
4268
  try {
3711
- component = getComponent(installed.name);
4269
+ component = componentForScope(getComponent(installed.name), scope);
3712
4270
  } catch (err) {
3713
4271
  if (err instanceof ComponentNotFoundError) {
3714
4272
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
@@ -3732,8 +4290,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3732
4290
  const updatedFiles = [];
3733
4291
  const fileHashes = {};
3734
4292
  for (const file of component.files) {
3735
- const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3736
- const targetExists = await fileExists(join8(targetDir, file));
4293
+ const srcPath = join9(assetsDir, component.sourceOverrides?.[file] ?? file);
4294
+ const targetExists = await fileExists(join9(targetDir, file));
3737
4295
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
3738
4296
  const recordedHash = installed.fileHashes?.[file];
3739
4297
  if (component.initOnly && !targetExists) {
@@ -3753,7 +4311,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3753
4311
  continue;
3754
4312
  }
3755
4313
  }
3756
- const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await hashFile(join8(targetDir, file)) !== recordedHash;
4314
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && !await fileMatchesHash(join9(targetDir, file), recordedHash);
3757
4315
  if (preserveExisting) {
3758
4316
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
3759
4317
  } else if (editedByOperator && isClientShimPath(file)) {
@@ -3768,7 +4326,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3768
4326
  } else {
3769
4327
  let handled = false;
3770
4328
  if (editedByOperator) {
3771
- const oldVendorContent = await fetchPublishedFile(installed.installedVersion, file);
4329
+ const oldVendorContent = await fetchPublishedFile(
4330
+ installed.installedVersion,
4331
+ component.sourceOverrides?.[file] ?? file
4332
+ );
3772
4333
  if (oldVendorContent === void 0) {
3773
4334
  console.log(
3774
4335
  ` ! Could not fetch the previously published version of ${file} to merge your edits \u2014 left your version untouched. Update it manually if you want the latest.`
@@ -3777,11 +4338,11 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3777
4338
  handled = true;
3778
4339
  } else {
3779
4340
  const [currentContent, newContent] = await Promise.all([
3780
- readFile6(join8(targetDir, file), "utf-8"),
3781
- readFile6(srcPath, "utf-8")
4341
+ readFile7(join9(targetDir, file), "utf-8"),
4342
+ readFile7(srcPath, "utf-8")
3782
4343
  ]);
3783
4344
  const result = merge3(oldVendorContent, currentContent, newContent);
3784
- await writeFile8(join8(targetDir, file), result.content, "utf-8");
4345
+ await writeFile9(join9(targetDir, file), result.content, "utf-8");
3785
4346
  fileHashes[file] = await hashFile(srcPath);
3786
4347
  if (result.hasConflict) {
3787
4348
  conflictedFiles.push(file);
@@ -3803,7 +4364,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3803
4364
  }
3804
4365
  }
3805
4366
  for (const dir of component.directories ?? []) {
3806
- const srcDirPath = join8(assetsDir, dir);
4367
+ const srcDirPath = join9(assetsDir, dir);
3807
4368
  if (options.dryRun) {
3808
4369
  console.log(` [dry-run] Would update directory: ${dir}/`);
3809
4370
  } else {
@@ -3856,12 +4417,29 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3856
4417
  Each keeps working in its client as-is. To converge: move your edits into the canonical spell (.arcane/spells/<id>.md \u2014 later updates merge edits there), delete the customized file, commit, and run \`spell update\` again \u2014 it restores the generated shim at the current version.`
3857
4418
  );
3858
4419
  }
4420
+ let fanoutRecord;
4421
+ if (scope === "user") {
4422
+ const fanout = await syncUserTierFanout({
4423
+ homeDir,
4424
+ storeRoot: targetDir,
4425
+ spellIds: spellIdsInStore(updatedComponents),
4426
+ previous: manifest.fanout,
4427
+ dryRun: options.dryRun,
4428
+ // A dry run has restored nothing yet, so a missing store spell renders
4429
+ // from the vendor asset the real run would write (review finding F1).
4430
+ fallbackDir: assetsDir
4431
+ });
4432
+ const lines = describeFanoutOutcomes(fanout.outcomes, options.dryRun);
4433
+ if (lines.length > 0) console.log();
4434
+ for (const line of lines) console.log(` ${line}`);
4435
+ fanoutRecord = fanout.record;
4436
+ }
3859
4437
  if (options.dryRun) {
3860
4438
  console.log(
3861
4439
  `
3862
4440
  [dry-run] Would update ${fileCount} files.`
3863
4441
  );
3864
- const applicableRetrofits = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
4442
+ const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest)) : [];
3865
4443
  if (applicableRetrofits.length > 0) {
3866
4444
  console.log(
3867
4445
  `[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
@@ -3869,9 +4447,9 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3869
4447
  }
3870
4448
  return;
3871
4449
  }
3872
- const interactive = Boolean(process.stdin.isTTY);
4450
+ const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
3873
4451
  const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
3874
- if (!interactive) {
4452
+ if (!interactive && scope === "repo") {
3875
4453
  const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3876
4454
  if (pending.length > 0) {
3877
4455
  console.log(
@@ -3883,7 +4461,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3883
4461
  ...manifest,
3884
4462
  version: packageVersion,
3885
4463
  components: updatedComponents,
3886
- ...retrofitPatch
4464
+ ...retrofitPatch,
4465
+ ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {}
3887
4466
  };
3888
4467
  await writeManifest(targetDir, updated);
3889
4468
  console.log(
@@ -3898,15 +4477,18 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3898
4477
  Search for "<<<<<<< yours" and resolve before committing.`
3899
4478
  );
3900
4479
  }
3901
- if (manifest.role !== "hub" && updated.role === "hub") {
4480
+ if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
3902
4481
  await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
3903
4482
  }
3904
4483
  }
3905
4484
 
4485
+ // src/commands/status.ts
4486
+ import { dirname as dirname6 } from "path";
4487
+
3906
4488
  // src/modules/version-check.ts
3907
- import { readFile as readFile7, writeFile as writeFile9, mkdir as mkdir5 } from "fs/promises";
3908
- import { dirname as dirname2, join as join9 } from "path";
3909
- import { homedir as homedir2 } from "os";
4489
+ import { readFile as readFile8, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
4490
+ import { dirname as dirname5, join as join10 } from "path";
4491
+ import { homedir as homedir3 } from "os";
3910
4492
  import { createRequire } from "module";
3911
4493
  var require2 = createRequire(import.meta.url);
3912
4494
  function getFeedUrl() {
@@ -3919,11 +4501,11 @@ function getFeedUrl() {
3919
4501
  }
3920
4502
  return url;
3921
4503
  }
3922
- var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
4504
+ var DEFAULT_CACHE_PATH = join10(homedir3(), ".arcane", "version-cache.json");
3923
4505
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3924
4506
  async function readCache(cachePath) {
3925
4507
  try {
3926
- const content = await readFile7(cachePath, "utf-8");
4508
+ const content = await readFile8(cachePath, "utf-8");
3927
4509
  const parsed = JSON.parse(content);
3928
4510
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
3929
4511
  return null;
@@ -3934,9 +4516,9 @@ async function readCache(cachePath) {
3934
4516
  }
3935
4517
  }
3936
4518
  async function writeCache(cachePath, latest) {
3937
- await mkdir5(dirname2(cachePath), { recursive: true });
4519
+ await mkdir6(dirname5(cachePath), { recursive: true });
3938
4520
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3939
- await writeFile9(cachePath, JSON.stringify(entry, null, 2), "utf-8");
4521
+ await writeFile10(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3940
4522
  }
3941
4523
  async function getLatestVersion(feedUrl) {
3942
4524
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -4015,20 +4597,27 @@ function generateVersionDriftDiagram(repoFilesVersion, installedCliVersion, npmL
4015
4597
  }
4016
4598
 
4017
4599
  // src/commands/status.ts
4018
- async function runStatus(targetDir, packageVersion) {
4600
+ async function runStatus(targetDir, packageVersion, options = {}) {
4019
4601
  let manifest;
4020
4602
  try {
4021
4603
  manifest = await readManifest(targetDir);
4022
4604
  } catch (err) {
4023
4605
  if (err instanceof ManifestNotFoundError) {
4024
4606
  console.error(
4025
- 'Not initialized. Run "spell init" first.'
4607
+ `Not initialized. Run "spell init${options.user ? " --user" : ""}" first.`
4026
4608
  );
4027
4609
  process.exit(1);
4028
4610
  return;
4029
4611
  }
4030
4612
  throw err;
4031
4613
  }
4614
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4615
+ if (resolvedScope.misplacedUserManifest) {
4616
+ console.error(misplacedUserManifestMessage(targetDir));
4617
+ process.exit(1);
4618
+ return;
4619
+ }
4620
+ const scope = resolvedScope.scope;
4032
4621
  if (manifest.components.length === 0) {
4033
4622
  console.log("No components installed.");
4034
4623
  return;
@@ -4066,6 +4655,30 @@ async function runStatus(targetDir, packageVersion) {
4066
4655
  );
4067
4656
  }
4068
4657
  console.log("");
4658
+ if (scope === "user") {
4659
+ console.log(` Scope: user \u2014 ${targetDir}`);
4660
+ const health = await inspectUserTierFanout(dirname6(targetDir), manifest.fanout);
4661
+ console.log(
4662
+ ` Client files: ${health.total} (${health.byClient.codex} ${FANOUT_CLIENT_LABELS.codex}, ${health.byClient.claude} ${FANOUT_CLIENT_LABELS.claude})`
4663
+ );
4664
+ if (health.missing.length > 0 || health.customized.length > 0) {
4665
+ const parts = [];
4666
+ if (health.missing.length > 0) parts.push(`${health.missing.length} missing`);
4667
+ if (health.customized.length > 0) parts.push(`${health.customized.length} customized`);
4668
+ console.log(
4669
+ ` ! ${parts.join(", ")} \u2014 \`spell update --user\` regenerates missing files; customized files are never overwritten.`
4670
+ );
4671
+ }
4672
+ } else {
4673
+ console.log(" Scope: repo");
4674
+ try {
4675
+ const store = userTierRoot();
4676
+ const userManifest = await readManifest(store);
4677
+ console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
4678
+ } catch {
4679
+ }
4680
+ }
4681
+ console.log("");
4069
4682
  const latestStr = versionResult.error ? "unable to check" : versionResult.latest ?? manifest.version;
4070
4683
  let footer = ` Installed: ${packageVersion} Latest: ${latestStr}`;
4071
4684
  if (versionResult.updateAvailable) {
@@ -4092,11 +4705,16 @@ async function runStatus(targetDir, packageVersion) {
4092
4705
  }
4093
4706
  }
4094
4707
  }
4708
+ if (scope === "user") {
4709
+ console.log("");
4710
+ for (const line of VSCODE_USER_TIER_NOTE) console.log(` ${line}`);
4711
+ console.log(` ${CLAUDE_PRECEDENCE_NOTE}`);
4712
+ }
4095
4713
  }
4096
4714
 
4097
4715
  // src/commands/uninstall.ts
4098
- import { rm as rm4 } from "fs/promises";
4099
- import { join as join10 } from "path";
4716
+ import { rm as rm5 } from "fs/promises";
4717
+ import { dirname as dirname7, join as join11 } from "path";
4100
4718
  import { confirm as confirm3 } from "@inquirer/prompts";
4101
4719
  async function runUninstall(options, targetDir) {
4102
4720
  let manifest;
@@ -4104,12 +4722,22 @@ async function runUninstall(options, targetDir) {
4104
4722
  manifest = await readManifest(targetDir);
4105
4723
  } catch (err) {
4106
4724
  if (err instanceof ManifestNotFoundError) {
4107
- console.error('Not initialized. Run "spell init" first.');
4725
+ console.error(`Not initialized. Run "spell init${options.user ? " --user" : ""}" first.`);
4108
4726
  process.exit(1);
4109
4727
  return;
4110
4728
  }
4111
4729
  throw err;
4112
4730
  }
4731
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4732
+ if (resolvedScope.misplacedUserManifest) {
4733
+ console.error(misplacedUserManifestMessage(targetDir));
4734
+ process.exit(1);
4735
+ return;
4736
+ }
4737
+ if (resolvedScope.scope === "user") {
4738
+ await uninstallUserTier(options, targetDir, manifest);
4739
+ return;
4740
+ }
4113
4741
  const policy = manifest.push_policy ?? "open";
4114
4742
  const controlsInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
4115
4743
  if ((policy === "blocked" || controlsInForce) && !options.dryRun) {
@@ -4159,24 +4787,26 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4159
4787
  let removed = 0;
4160
4788
  for (const component of manifest.components) {
4161
4789
  for (const file of component.files) {
4162
- const filePath = join10(targetDir, file);
4163
4790
  try {
4164
- await rm4(filePath, { force: true });
4791
+ await removeWithin(targetDir, file);
4165
4792
  removed++;
4166
- } catch {
4793
+ } catch (err) {
4794
+ console.log(
4795
+ ` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`
4796
+ );
4167
4797
  }
4168
4798
  }
4169
4799
  }
4170
- await rm4(join10(targetDir, ".arcane.json"), { force: true });
4800
+ await rm5(join11(targetDir, ".arcane.json"), { force: true });
4171
4801
  const agentDirs = [
4172
4802
  ".github/agents",
4173
4803
  ".arcane/agents",
4174
4804
  ".arcane/generated"
4175
4805
  ];
4176
4806
  for (const dir of agentDirs) {
4177
- await rm4(join10(targetDir, dir), { recursive: true, force: true });
4807
+ await rm5(join11(targetDir, dir), { recursive: true, force: true });
4178
4808
  }
4179
- await rm4(join10(targetDir, ".arcane/agents.yaml"), { force: true });
4809
+ await rm5(join11(targetDir, ".arcane/agents.yaml"), { force: true });
4180
4810
  const markerFiles = [
4181
4811
  "CLAUDE.md",
4182
4812
  "AGENTS.md",
@@ -4192,10 +4822,67 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4192
4822
  console.log(`
4193
4823
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
4194
4824
  }
4825
+ async function uninstallUserTier(options, storeRoot, manifest) {
4826
+ const homeDir = dirname7(storeRoot);
4827
+ const storeFiles = manifest.components.flatMap((c) => c.files);
4828
+ const recordedCount = Object.keys(manifest.fanout ?? {}).length;
4829
+ if (options.dryRun) {
4830
+ for (const file of storeFiles) console.log(`[dry-run] Would remove: ${storeRoot}/${file}`);
4831
+ const preview = await syncUserTierFanout({
4832
+ homeDir,
4833
+ storeRoot,
4834
+ spellIds: [],
4835
+ previous: manifest.fanout,
4836
+ dryRun: true
4837
+ });
4838
+ for (const line of describeFanoutOutcomes(preview.outcomes, true)) console.log(line);
4839
+ console.log(`[dry-run] Would remove: ${storeRoot}/.arcane.json`);
4840
+ console.log(
4841
+ `
4842
+ [dry-run] ${storeFiles.length} spell(s), up to ${recordedCount} client file(s) + manifest would be removed from the user tier.`
4843
+ );
4844
+ return;
4845
+ }
4846
+ if (!options.yes) {
4847
+ const confirmed = await confirm3({
4848
+ message: `This will remove ${storeFiles.length} spells, ${recordedCount} client files and .arcane.json from the user tier at ${storeRoot}. Continue?`,
4849
+ default: false
4850
+ });
4851
+ if (!confirmed) {
4852
+ console.log("Uninstall cancelled.");
4853
+ return;
4854
+ }
4855
+ }
4856
+ const fanout = await syncUserTierFanout({
4857
+ homeDir,
4858
+ storeRoot,
4859
+ spellIds: [],
4860
+ previous: manifest.fanout
4861
+ });
4862
+ let removed = 0;
4863
+ for (const file of storeFiles) {
4864
+ try {
4865
+ await removeWithin(storeRoot, file);
4866
+ removed++;
4867
+ } catch (err) {
4868
+ console.log(` ! Skipped ${file}: ${err instanceof Error ? err.message : String(err)}`);
4869
+ }
4870
+ }
4871
+ await rm5(join11(storeRoot, ".arcane.json"), { force: true });
4872
+ await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
4873
+ await removeDirectoryIfEmpty(storeRoot);
4874
+ for (const line of describeFanoutOutcomes(fanout.outcomes)) console.log(` ${line}`);
4875
+ const pruned = fanout.outcomes.filter((o) => o.status === "pruned").length;
4876
+ const kept = fanout.outcomes.filter((o) => o.status === "kept-edited").length;
4877
+ console.log(
4878
+ `
4879
+ \u2713 Uninstalled the user tier \u2014 ${removed} spells and ${pruned} client files removed${kept > 0 ? `; ${kept} edited client file(s) kept` : ""}.`
4880
+ );
4881
+ }
4195
4882
 
4196
4883
  // src/commands/doctor.ts
4197
- import { access as access4, readFile as readFile9, mkdir as mkdir6, copyFile as fsCopyFile } from "fs/promises";
4198
- import { join as join12, dirname as dirname3 } from "path";
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";
4199
4886
  import { execFile as execFile3 } from "child_process";
4200
4887
  import { promisify as promisify2 } from "util";
4201
4888
 
@@ -4265,8 +4952,8 @@ function evaluateIncidentGate(records) {
4265
4952
  }
4266
4953
 
4267
4954
  // src/modules/denylist-scan.ts
4268
- import { readFile as readFile8, readdir as readdir5 } from "fs/promises";
4269
- import { join as join11, relative } from "path";
4955
+ import { readFile as readFile9, readdir as readdir6 } from "fs/promises";
4956
+ import { join as join12, relative } from "path";
4270
4957
  function escapeRegExp(value) {
4271
4958
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4272
4959
  }
@@ -4283,7 +4970,7 @@ async function scanFile(absolutePath, displayPath, rules) {
4283
4970
  const findings = [];
4284
4971
  let content;
4285
4972
  try {
4286
- content = await readFile8(absolutePath, "utf8");
4973
+ content = await readFile9(absolutePath, "utf8");
4287
4974
  } catch {
4288
4975
  return findings;
4289
4976
  }
@@ -4312,12 +4999,12 @@ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT
4312
4999
  const collected = [];
4313
5000
  let entries;
4314
5001
  try {
4315
- entries = await readdir5(root, { withFileTypes: true });
5002
+ entries = await readdir6(root, { withFileTypes: true });
4316
5003
  } catch {
4317
5004
  return collected;
4318
5005
  }
4319
5006
  for (const entry of entries) {
4320
- const absolutePath = join11(root, entry.name);
5007
+ const absolutePath = join12(root, entry.name);
4321
5008
  if (entry.isDirectory()) {
4322
5009
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
4323
5010
  collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
@@ -4529,13 +5216,13 @@ async function checkVSCodeExtension(id, label) {
4529
5216
  const name = `VS Code extension: ${label}`;
4530
5217
  const home = process.env["HOME"] ?? "";
4531
5218
  const extDirs = [
4532
- join12(home, ".vscode", "extensions"),
4533
- join12(home, ".vscode-insiders", "extensions")
5219
+ join13(home, ".vscode", "extensions"),
5220
+ join13(home, ".vscode-insiders", "extensions")
4534
5221
  ];
4535
5222
  for (const dir of extDirs) {
4536
5223
  try {
4537
- const { readdir: readdir8 } = await import("fs/promises");
4538
- const entries = await readdir8(dir);
5224
+ const { readdir: readdir9 } = await import("fs/promises");
5225
+ const entries = await readdir9(dir);
4539
5226
  const prefix = id.toLowerCase() + "-";
4540
5227
  const found = entries.some(
4541
5228
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -4564,13 +5251,13 @@ async function checkVSCodeExtension(id, label) {
4564
5251
  }
4565
5252
  async function checkArcaneManifest(targetDir) {
4566
5253
  const name = "Arcane manifest (.arcane.json)";
4567
- const manifestPath2 = join12(targetDir, ".arcane.json");
5254
+ const manifestPath2 = join13(targetDir, ".arcane.json");
4568
5255
  try {
4569
5256
  await access4(manifestPath2);
4570
5257
  } catch {
4571
- const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
5258
+ const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
4572
5259
  try {
4573
- const raw = await readFile9(selfHostedManifestPath, "utf8");
5260
+ const raw = await readFile10(selfHostedManifestPath, "utf8");
4574
5261
  const manifest = JSON.parse(raw);
4575
5262
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
4576
5263
  return {
@@ -4589,7 +5276,7 @@ async function checkArcaneManifest(targetDir) {
4589
5276
  };
4590
5277
  }
4591
5278
  try {
4592
- const raw = await readFile9(manifestPath2, "utf8");
5279
+ const raw = await readFile10(manifestPath2, "utf8");
4593
5280
  const manifest = JSON.parse(raw);
4594
5281
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
4595
5282
  return {
@@ -4674,7 +5361,7 @@ async function checkSessionContinuity(targetDir) {
4674
5361
  const results = [];
4675
5362
  for (const file of SESSION_CONTINUITY_FILES) {
4676
5363
  const name = `Session file: ${file}`;
4677
- const filePath = join12(targetDir, file);
5364
+ const filePath = join13(targetDir, file);
4678
5365
  try {
4679
5366
  await access4(filePath);
4680
5367
  results.push({ name, passed: true, message: "present" });
@@ -4692,18 +5379,82 @@ async function checkSessionContinuity(targetDir) {
4692
5379
  async function fixSessionContinuity(targetDir, assetsDir) {
4693
5380
  const created = [];
4694
5381
  for (const file of SESSION_CONTINUITY_FILES) {
4695
- const destPath = join12(targetDir, file);
5382
+ const destPath = join13(targetDir, file);
4696
5383
  try {
4697
5384
  await access4(destPath);
4698
5385
  } catch {
4699
- const srcPath = join12(assetsDir, file);
4700
- await mkdir6(dirname3(destPath), { recursive: true });
5386
+ const srcPath = join13(assetsDir, file);
5387
+ await mkdir7(dirname8(destPath), { recursive: true });
4701
5388
  await fsCopyFile(srcPath, destPath);
4702
5389
  created.push(file);
4703
5390
  }
4704
5391
  }
4705
5392
  return created;
4706
5393
  }
5394
+ async function checkUserTier(homeDir = resolveHomeDir(), cliVersion) {
5395
+ const name = "User tier (ARC-045)";
5396
+ const storeRoot = userTierRoot(homeDir);
5397
+ let manifest;
5398
+ try {
5399
+ manifest = await readManifest(storeRoot);
5400
+ } catch (err) {
5401
+ if (err instanceof ManifestNotFoundError) {
5402
+ return {
5403
+ name,
5404
+ passed: true,
5405
+ blocking: false,
5406
+ message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
5407
+ };
5408
+ }
5409
+ return {
5410
+ name,
5411
+ passed: false,
5412
+ blocking: false,
5413
+ message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
5414
+ };
5415
+ }
5416
+ try {
5417
+ if (typeof manifest.version !== "string" || manifest.version.length === 0) {
5418
+ return {
5419
+ name,
5420
+ passed: false,
5421
+ blocking: false,
5422
+ message: `${storeRoot}/.arcane.json has no version field \u2014 remove the store and re-run \`spell init --user\``
5423
+ };
5424
+ }
5425
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
5426
+ const problems = [];
5427
+ if (cliVersion !== void 0 && majorMinor(manifest.version) !== majorMinor(cliVersion)) {
5428
+ problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
5429
+ }
5430
+ const health = await inspectUserTierFanout(homeDir, manifest.fanout);
5431
+ if (health.missing.length > 0) {
5432
+ problems.push(`${health.missing.length} of ${health.total} client file(s) missing`);
5433
+ }
5434
+ if (problems.length > 0) {
5435
+ return {
5436
+ name,
5437
+ passed: false,
5438
+ blocking: false,
5439
+ message: `${problems.join("; ")} \u2014 run \`spell update --user\` (regenerates missing files; never overwrites edited ones)`
5440
+ };
5441
+ }
5442
+ const customized = health.customized.length > 0 ? `, ${health.customized.length} customized` : "";
5443
+ return {
5444
+ name,
5445
+ passed: true,
5446
+ blocking: false,
5447
+ message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
5448
+ };
5449
+ } catch (err) {
5450
+ return {
5451
+ name,
5452
+ passed: false,
5453
+ blocking: false,
5454
+ message: `could not inspect the user tier at ${storeRoot} (${err instanceof Error ? err.message : String(err)}) \u2014 run \`spell update --user\`; if that fails too, remove the store and re-run \`spell init --user\``
5455
+ };
5456
+ }
5457
+ }
4707
5458
  async function checkPushPolicy(targetDir) {
4708
5459
  const name = "Push policy (EF-09)";
4709
5460
  let manifest;
@@ -4757,10 +5508,10 @@ async function checkPushPolicy(targetDir) {
4757
5508
  }
4758
5509
  async function checkMcpConfig(targetDir) {
4759
5510
  const name = "MCP config (per-server timeout, I12)";
4760
- const filePath = join12(targetDir, ".mcp.json");
5511
+ const filePath = join13(targetDir, ".mcp.json");
4761
5512
  let raw;
4762
5513
  try {
4763
- raw = await readFile9(filePath, "utf8");
5514
+ raw = await readFile10(filePath, "utf8");
4764
5515
  } catch {
4765
5516
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4766
5517
  }
@@ -4813,10 +5564,10 @@ function isDelegationsFile(value) {
4813
5564
  }
4814
5565
  async function checkDelegations(targetDir) {
4815
5566
  const name = "Standing delegations (T13)";
4816
- const filePath = join12(targetDir, ".arcane", "delegations.json");
5567
+ const filePath = join13(targetDir, ".arcane", "delegations.json");
4817
5568
  let raw;
4818
5569
  try {
4819
- raw = await readFile9(filePath, "utf8");
5570
+ raw = await readFile10(filePath, "utf8");
4820
5571
  } catch {
4821
5572
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4822
5573
  }
@@ -4919,7 +5670,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4919
5670
  checkPushPolicy(targetDir),
4920
5671
  checkPlatformBranchPolicy(targetDir),
4921
5672
  checkDelegations(targetDir),
4922
- checkMcpConfig(targetDir)
5673
+ checkMcpConfig(targetDir),
5674
+ checkUserTier(resolveHomeDir(), options.packageVersion)
4923
5675
  ]);
4924
5676
  const sessionResults = await checkSessionContinuity(targetDir);
4925
5677
  results.push(...sessionResults);
@@ -4964,16 +5716,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4964
5716
  }
4965
5717
 
4966
5718
  // src/commands/ward.ts
4967
- import { readFile as readFile11 } from "fs/promises";
5719
+ import { readFile as readFile12 } from "fs/promises";
4968
5720
 
4969
5721
  // src/modules/ward.ts
4970
- import { readdir as readdir6, readFile as readFile10 } from "fs/promises";
4971
- import { join as join13, relative as relative2 } from "path";
5722
+ import { readdir as readdir7, readFile as readFile11 } from "fs/promises";
5723
+ import { join as join14, relative as relative2 } from "path";
4972
5724
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4973
5725
  const findings = [];
4974
5726
  let content;
4975
5727
  try {
4976
- content = await readFile10(absolutePath, "utf8");
5728
+ content = await readFile11(absolutePath, "utf8");
4977
5729
  } catch {
4978
5730
  return findings;
4979
5731
  }
@@ -5026,12 +5778,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
5026
5778
  const findings = [];
5027
5779
  let entries;
5028
5780
  try {
5029
- entries = await readdir6(root, { withFileTypes: true });
5781
+ entries = await readdir7(root, { withFileTypes: true });
5030
5782
  } catch {
5031
5783
  return findings;
5032
5784
  }
5033
5785
  for (const entry of entries) {
5034
- const absolutePath = join13(root, entry.name);
5786
+ const absolutePath = join14(root, entry.name);
5035
5787
  if (entry.isDirectory()) {
5036
5788
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
5037
5789
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -5068,7 +5820,7 @@ async function runWard(repoRoot, options = {}) {
5068
5820
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
5069
5821
  if (findings2.length > 0) {
5070
5822
  try {
5071
- fileLines.set(displayPath, (await readFile10(absolutePath, "utf8")).split("\n"));
5823
+ fileLines.set(displayPath, (await readFile11(absolutePath, "utf8")).split("\n"));
5072
5824
  } catch {
5073
5825
  }
5074
5826
  }
@@ -5096,7 +5848,7 @@ function parseTermsFileContent(raw) {
5096
5848
  }
5097
5849
  async function readTermsFile(filePath) {
5098
5850
  try {
5099
- return parseTermsFileContent(await readFile11(filePath, "utf8"));
5851
+ return parseTermsFileContent(await readFile12(filePath, "utf8"));
5100
5852
  } catch (error) {
5101
5853
  console.warn(
5102
5854
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -5146,7 +5898,7 @@ async function runWardCli(targetDir, options = {}, exclusions = []) {
5146
5898
  }
5147
5899
 
5148
5900
  // src/commands/unblock-push.ts
5149
- import { basename as basename2, resolve as resolve2 } from "path";
5901
+ import { basename as basename2, resolve as resolve3 } from "path";
5150
5902
  import { input as input4 } from "@inquirer/prompts";
5151
5903
  async function runUnblockPush(targetDir) {
5152
5904
  let manifest;
@@ -5178,7 +5930,7 @@ async function runUnblockPush(targetDir) {
5178
5930
  process.exit(1);
5179
5931
  return;
5180
5932
  }
5181
- const repoName = basename2(resolve2(targetDir));
5933
+ const repoName = basename2(resolve3(targetDir));
5182
5934
  if (repoName === "") {
5183
5935
  console.error(
5184
5936
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -5245,15 +5997,15 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
5245
5997
 
5246
5998
  // src/commands/report.ts
5247
5999
  import { access as access5 } from "fs/promises";
5248
- import { join as join16 } from "path";
6000
+ import { join as join17 } from "path";
5249
6001
 
5250
6002
  // src/modules/show-report/generate.ts
5251
- import { mkdir as mkdir7, readdir as readdir7, readFile as readFile13, writeFile as writeFile10 } from "fs/promises";
5252
- import { basename as basename3, dirname as dirname4, isAbsolute, join as join15, relative as relative3, resolve as resolve3 } from "path";
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";
5253
6005
 
5254
6006
  // src/modules/show-report/model.ts
5255
- import { readFile as readFile12 } from "fs/promises";
5256
- import { join as join14 } from "path";
6007
+ import { readFile as readFile13 } from "fs/promises";
6008
+ import { join as join15 } from "path";
5257
6009
 
5258
6010
  // src/modules/show-report/plan-parser.ts
5259
6011
  function parseFrontmatter(content) {
@@ -5485,9 +6237,10 @@ async function getVersionAtRef(cwd, ref) {
5485
6237
  return null;
5486
6238
  }
5487
6239
  }
5488
- async function getCloseCommit(cwd, completedDate) {
6240
+ async function getCloseCommit(cwd, completedDate, generatedOutputs = []) {
5489
6241
  if (!completedDate) {
5490
- const head = await runGitTextOrNull(cwd, ["rev-parse", "HEAD"]);
6242
+ const args = generatedOutputs.length === 0 ? ["rev-parse", "HEAD"] : ["log", "-1", "--format=%H", "HEAD", "--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)];
6243
+ const head = await runGitTextOrNull(cwd, args);
5491
6244
  return head === null || head === "" ? null : head;
5492
6245
  }
5493
6246
  const log = await runGitTextOrNull(cwd, ["log", "--format=%H%x09%cs", "HEAD"]);
@@ -5498,13 +6251,14 @@ async function getCloseCommit(cwd, completedDate) {
5498
6251
  }
5499
6252
  return null;
5500
6253
  }
5501
- async function getCast(cwd, fromRef, toRef) {
6254
+ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
5502
6255
  const ETX = "";
5503
6256
  const log = await runGitTextOrNull(cwd, [
5504
6257
  "log",
5505
6258
  "--no-merges",
5506
6259
  `--format=%B${ETX}`,
5507
- `${fromRef}..${toRef}`
6260
+ `${fromRef}..${toRef}`,
6261
+ ...generatedOutputs.length > 0 ? ["--", ".", ...generatedOutputs.map((p) => `:(exclude)${p}`)] : []
5508
6262
  ]);
5509
6263
  const cast = /* @__PURE__ */ new Map();
5510
6264
  if (log === null || log === "") return cast;
@@ -5521,7 +6275,7 @@ async function getCast(cwd, fromRef, toRef) {
5521
6275
  // src/modules/show-report/model.ts
5522
6276
  async function readOptional(path4) {
5523
6277
  try {
5524
- return await readFile12(path4, "utf8");
6278
+ return await readFile13(path4, "utf8");
5525
6279
  } catch {
5526
6280
  return null;
5527
6281
  }
@@ -5617,13 +6371,13 @@ function buildCorrections(ledgerRows, programName) {
5617
6371
  };
5618
6372
  }
5619
6373
  async function buildShowReportModel(options) {
5620
- const planAbsPath = join14(options.rootDir, options.planRelPath);
5621
- const queueAbsPath = join14(options.rootDir, options.queueRelPath);
6374
+ const planAbsPath = join15(options.rootDir, options.planRelPath);
6375
+ const queueAbsPath = join15(options.rootDir, options.queueRelPath);
5622
6376
  const ledgerRelPath = "docs/verification-ledger.md";
5623
- const ledgerAbsPath = join14(options.rootDir, ledgerRelPath);
5624
- const decisionsAbsPath = join14(options.rootDir, "DECISIONS.md");
6377
+ const ledgerAbsPath = join15(options.rootDir, ledgerRelPath);
6378
+ const decisionsAbsPath = join15(options.rootDir, "DECISIONS.md");
5625
6379
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
5626
- readFile12(planAbsPath, "utf8"),
6380
+ readFile13(planAbsPath, "utf8"),
5627
6381
  readOptional(queueAbsPath),
5628
6382
  readOptional(ledgerAbsPath),
5629
6383
  readOptional(decisionsAbsPath)
@@ -5643,12 +6397,18 @@ async function buildShowReportModel(options) {
5643
6397
  const cast = /* @__PURE__ */ new Map();
5644
6398
  if (frontmatter.baseline) {
5645
6399
  const baselineSha = extractSha(frontmatter.baseline);
5646
- const closeSha = await getCloseCommit(options.rootDir, frontmatter.completed);
6400
+ const reportDir = options.planRelPath.split("/").slice(0, -1).join("/");
6401
+ const generatedOutputs = ["show-report.json", "show-report.html"].map(
6402
+ (f) => reportDir ? `${reportDir}/${f}` : f
6403
+ );
6404
+ const closeSha = await getCloseCommit(options.rootDir, frontmatter.completed, generatedOutputs);
5647
6405
  if (closeSha) {
5648
6406
  const [from, to, castMap] = await Promise.all([
5649
6407
  getVersionAtRef(options.rootDir, baselineSha),
5650
6408
  getVersionAtRef(options.rootDir, closeSha),
5651
- getCast(options.rootDir, baselineSha, closeSha)
6409
+ // Regeneration-only commits are never part of the cast, for a closed
6410
+ // program as much as an active one.
6411
+ getCast(options.rootDir, baselineSha, closeSha, generatedOutputs)
5652
6412
  ]);
5653
6413
  if (from && to) versionSpan = { from, to };
5654
6414
  for (const [name, count] of castMap) cast.set(name, count);
@@ -5786,7 +6546,7 @@ function toPosix(path4) {
5786
6546
  async function programFromPlan(rootDir, slug, planRelPath) {
5787
6547
  let planContent;
5788
6548
  try {
5789
- planContent = await readFile13(join15(rootDir, planRelPath), "utf8");
6549
+ planContent = await readFile14(join16(rootDir, planRelPath), "utf8");
5790
6550
  } catch {
5791
6551
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
5792
6552
  }
@@ -5798,16 +6558,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
5798
6558
  program: {
5799
6559
  slug,
5800
6560
  planRelPath,
5801
- queueRelPath: toPosix(join15(dirname4(planRelPath), "OPERATOR-QUEUE.md")),
6561
+ queueRelPath: toPosix(join16(dirname9(planRelPath), "OPERATOR-QUEUE.md")),
5802
6562
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
5803
6563
  }
5804
6564
  };
5805
6565
  }
5806
6566
  async function discoverPrograms(rootDir) {
5807
- const plansDir = join15(rootDir, "docs", "plans");
6567
+ const plansDir = join16(rootDir, "docs", "plans");
5808
6568
  let entries;
5809
6569
  try {
5810
- entries = (await readdir7(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6570
+ entries = (await readdir8(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
5811
6571
  } catch (error) {
5812
6572
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
5813
6573
  throw error;
@@ -5822,23 +6582,23 @@ async function discoverPrograms(rootDir) {
5822
6582
  return { programs, skipped };
5823
6583
  }
5824
6584
  async function programFromPlanPath(rootDir, planPath) {
5825
- const planAbs = isAbsolute(planPath) ? planPath : resolve3(rootDir, planPath);
6585
+ const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
5826
6586
  const planRelPath = toPosix(relative3(rootDir, planAbs));
5827
- return programFromPlan(rootDir, basename3(dirname4(planAbs)), planRelPath);
6587
+ return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
5828
6588
  }
5829
- function normalizeLineEndings(content) {
6589
+ function normalizeLineEndings2(content) {
5830
6590
  return content.replace(/\r\n?/g, "\n");
5831
6591
  }
5832
6592
  async function readOptionalFile(path4) {
5833
6593
  try {
5834
- return await readFile13(path4, "utf8");
6594
+ return await readFile14(path4, "utf8");
5835
6595
  } catch (error) {
5836
6596
  if (error.code === "ENOENT") return null;
5837
6597
  throw error;
5838
6598
  }
5839
6599
  }
5840
6600
  async function generateReport(rootDir, program2, templateHtml) {
5841
- const planContent = await readFile13(join15(rootDir, program2.planRelPath), "utf8");
6601
+ const planContent = await readFile14(join16(rootDir, program2.planRelPath), "utf8");
5842
6602
  const frontmatter = parseFrontmatter(planContent);
5843
6603
  const model = await buildShowReportModel({
5844
6604
  rootDir,
@@ -5856,7 +6616,7 @@ async function generateReport(rootDir, program2, templateHtml) {
5856
6616
  };
5857
6617
  }
5858
6618
  async function runReportGeneration(options) {
5859
- const templateHtml = await readFile13(options.templatePath, "utf8");
6619
+ const templateHtml = await readFile14(options.templatePath, "utf8");
5860
6620
  const drifted = [];
5861
6621
  const repaired = [];
5862
6622
  const unwritten = [];
@@ -5864,19 +6624,19 @@ async function runReportGeneration(options) {
5864
6624
  const generated = await generateReport(options.rootDir, program2, templateHtml);
5865
6625
  const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
5866
6626
  if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
5867
- const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve3(options.rootDir, options.outDir) : resolve3(options.rootDir, dirname4(program2.planRelPath));
6627
+ const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname9(program2.planRelPath));
5868
6628
  const outRel = toPosix(relative3(options.rootDir, outAbs));
5869
6629
  const outputs = [
5870
6630
  [`${outRel}/show-report.json`, generated.json],
5871
6631
  [`${outRel}/show-report.html`, generated.html]
5872
6632
  ];
5873
6633
  for (const [relPath, expected] of outputs) {
5874
- const actual = await readOptionalFile(join15(options.rootDir, relPath));
5875
- if (actual === null || normalizeLineEndings(actual) !== normalizeLineEndings(expected)) {
6634
+ const actual = await readOptionalFile(join16(options.rootDir, relPath));
6635
+ if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
5876
6636
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
5877
6637
  if (options.mode === "write") {
5878
- await mkdir7(outAbs, { recursive: true });
5879
- await writeFile10(join15(options.rootDir, relPath), expected, "utf8");
6638
+ await mkdir8(outAbs, { recursive: true });
6639
+ await writeFile11(join16(options.rootDir, relPath), expected, "utf8");
5880
6640
  repaired.push(relPath);
5881
6641
  }
5882
6642
  }
@@ -5887,7 +6647,7 @@ async function runReportGeneration(options) {
5887
6647
 
5888
6648
  // src/commands/report.ts
5889
6649
  async function runReport(targetDir, options = {}, assetsDir) {
5890
- const templatePath = join16(assetsDir, "report", "show-report.template.html");
6650
+ const templatePath = join17(assetsDir, "report", "show-report.template.html");
5891
6651
  try {
5892
6652
  await access5(templatePath);
5893
6653
  } catch {
@@ -5950,8 +6710,12 @@ async function runReport(targetDir, options = {}, assetsDir) {
5950
6710
  import { realpathSync } from "fs";
5951
6711
  var require3 = createRequire2(import.meta.url);
5952
6712
  var pkg = require3("../package.json");
5953
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join17(dirname5(fileURLToPath(import.meta.url)), "assets");
6713
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname10(fileURLToPath(import.meta.url)), "assets");
5954
6714
  var program = new Command();
6715
+ 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)";
6716
+ function targetDirFor(opts) {
6717
+ return opts.user ? userTierRoot() : process.cwd();
6718
+ }
5955
6719
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
5956
6720
  if (program.args.length > 0) {
5957
6721
  const [attempted] = program.args;
@@ -5971,15 +6735,16 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
5971
6735
  await printWelcome(pkg.version, process.cwd());
5972
6736
  program.outputHelp();
5973
6737
  });
5974
- program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").action(
6738
+ program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").option("--user", USER_FLAG_DESCRIPTION).action(
5975
6739
  async (opts) => {
5976
6740
  await runInit(
5977
6741
  {
5978
6742
  profile: opts.profile,
5979
6743
  force: opts.force,
5980
- dryRun: opts.dryRun
6744
+ dryRun: opts.dryRun,
6745
+ user: opts.user
5981
6746
  },
5982
- process.cwd(),
6747
+ targetDirFor(opts),
5983
6748
  ASSETS_DIR,
5984
6749
  pkg.version
5985
6750
  );
@@ -5999,25 +6764,29 @@ program.command("add <component>").description("Add a component to an existing A
5999
6764
  program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
6000
6765
  "--prune",
6001
6766
  "Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
6002
- ).action(async (opts) => {
6767
+ ).option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6003
6768
  await runUpdate(
6004
- { dryRun: opts.dryRun, prune: opts.prune },
6005
- process.cwd(),
6769
+ { dryRun: opts.dryRun, prune: opts.prune, user: opts.user },
6770
+ targetDirFor(opts),
6006
6771
  ASSETS_DIR,
6007
6772
  pkg.version
6008
6773
  );
6009
6774
  });
6010
- program.command("status").description("Show installed components and check for updates").action(async () => {
6011
- await runStatus(process.cwd(), pkg.version);
6775
+ program.command("status").description("Show installed components and check for updates").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6776
+ await runStatus(targetDirFor(opts), pkg.version, { user: opts.user });
6012
6777
  });
6013
- program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").action(async (opts) => {
6014
- await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
6778
+ program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6779
+ await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
6015
6780
  });
6016
6781
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
6017
6782
  await runUnblockPush(process.cwd());
6018
6783
  });
6019
6784
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
6020
- await runDoctor(process.cwd(), { fix: opts.fix, leaks: opts.leaks }, ASSETS_DIR);
6785
+ await runDoctor(
6786
+ process.cwd(),
6787
+ { fix: opts.fix, leaks: opts.leaks, packageVersion: pkg.version },
6788
+ ASSETS_DIR
6789
+ );
6021
6790
  });
6022
6791
  program.command("ward").description("Scan the repo for leaked identifiers -- third-party trademarks and the operator's own personal identifiers alike (T18)").option(
6023
6792
  "--terms <terms>",