arcane-cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,11 +6,11 @@ const require = __createRequire(import.meta.url);
6
6
  import { Command } from "commander";
7
7
  import { createRequire as createRequire2 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as 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
@@ -109,6 +109,15 @@ var VALID_TRACKING_MODES = ["internal", "external"];
109
109
  var VALID_EXTERNAL_PROVIDERS = ["ado", "github", "jira", "other"];
110
110
  var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
111
111
  var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
112
+ var VALID_INSTALL_SCOPES = ["repo", "user"];
113
+ function isValidFanoutPath(file) {
114
+ return file.length > 0 && !file.startsWith("/") && !/^[a-zA-Z]:/.test(file) && !file.includes("\\") && !file.split("/").includes("..") && !/[\r\n\0]/.test(file);
115
+ }
116
+ function isValidFanoutRecord(value) {
117
+ return isRecord(value) && Object.entries(value).every(
118
+ ([file, hash]) => isValidFanoutPath(file) && typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)
119
+ );
120
+ }
112
121
  function isValidSubjectRoot(value) {
113
122
  const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
114
123
  /^[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 +141,9 @@ function validateSecretsScanExcludePrefixes(value, filePath) {
132
141
  function manifestPath(targetDir) {
133
142
  return path2.join(targetDir, MANIFEST_FILE);
134
143
  }
144
+ function isRecord(value) {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value);
146
+ }
135
147
  function validateTrackingFields(manifest, filePath) {
136
148
  if (manifest.tracking_mode !== void 0 && !VALID_TRACKING_MODES.includes(manifest.tracking_mode)) {
137
149
  throw new ManifestInvalidFieldError(filePath, "tracking_mode", manifest.tracking_mode);
@@ -158,6 +170,12 @@ function validateTrackingFields(manifest, filePath) {
158
170
  if (manifest.secretsScanExcludePrefixes !== void 0) {
159
171
  validateSecretsScanExcludePrefixes(manifest.secretsScanExcludePrefixes, filePath);
160
172
  }
173
+ if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
174
+ throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
175
+ }
176
+ if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
177
+ throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
178
+ }
161
179
  }
162
180
  async function readManifest(targetDir) {
163
181
  const filePath = manifestPath(targetDir);
@@ -179,9 +197,6 @@ async function readManifest(targetDir) {
179
197
  validateTrackingFields(manifest, filePath);
180
198
  return manifest;
181
199
  }
182
- function isRecord(value) {
183
- return typeof value === "object" && value !== null && !Array.isArray(value);
184
- }
185
200
  async function resolveSecretsScanExcludePrefixes(targetDir) {
186
201
  try {
187
202
  const manifest = await readManifest(targetDir);
@@ -822,6 +837,400 @@ function listProfiles() {
822
837
  }));
823
838
  }
824
839
 
840
+ // src/modules/user-tier.ts
841
+ import { createHash as createHash2 } from "crypto";
842
+ import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm, rmdir, writeFile as writeFile3 } from "fs/promises";
843
+ import { homedir } from "os";
844
+ import { dirname, join, resolve } from "path";
845
+
846
+ // src/modules/spell-compiler.ts
847
+ import { parse as parseYaml } from "yaml";
848
+ var MissingFrontmatterError = class extends Error {
849
+ constructor(message) {
850
+ super(message);
851
+ this.name = "MissingFrontmatterError";
852
+ }
853
+ };
854
+ var InvalidSkillNameError = class extends Error {
855
+ constructor(message) {
856
+ super(message);
857
+ this.name = "InvalidSkillNameError";
858
+ }
859
+ };
860
+ var CANONICAL_SPELLS_DIR = ".arcane/spells";
861
+ function canonicalSpellPath(id) {
862
+ return `${CANONICAL_SPELLS_DIR}/${id}.md`;
863
+ }
864
+ var CLIENT_SHIM_PATH_PATTERNS = [
865
+ /^\.github\/prompts\/spell-[a-z0-9-]+\.prompt\.md$/,
866
+ /^\.claude\/commands\/spell-[a-z0-9-]+\.md$/,
867
+ /^\.agents\/skills\/spell-[a-z0-9-]+\/SKILL\.md$/
868
+ ];
869
+ function isClientShimPath(path4) {
870
+ const normalized = path4.replace(/\\/g, "/").replace(/^\.\//, "");
871
+ return CLIENT_SHIM_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
872
+ }
873
+ var FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
874
+ function parsePromptFrontmatter(content) {
875
+ const match = content.match(FRONTMATTER_PATTERN);
876
+ if (!match) {
877
+ throw new MissingFrontmatterError(
878
+ "Prompt file has no YAML frontmatter block (expected a leading --- ... --- block)."
879
+ );
880
+ }
881
+ const raw = parseYaml(match[1]);
882
+ const name = raw?.["name"];
883
+ const description = raw?.["description"];
884
+ if (typeof name !== "string" || name.trim() === "") {
885
+ throw new MissingFrontmatterError('Prompt frontmatter is missing a "name" field.');
886
+ }
887
+ if (typeof description !== "string" || description.trim() === "") {
888
+ throw new MissingFrontmatterError('Prompt frontmatter is missing a "description" field.');
889
+ }
890
+ const claudeDescription = raw?.["claude_description"];
891
+ return {
892
+ name,
893
+ description,
894
+ claudeDescription: typeof claudeDescription === "string" ? claudeDescription : void 0
895
+ };
896
+ }
897
+ var SPELL_NAME_PREFIX = /^Spell\s*—\s*/;
898
+ function deriveStubTitle(name) {
899
+ return name.replace(SPELL_NAME_PREFIX, "").trim();
900
+ }
901
+ function renderClaudeCommandStub(id, frontmatter, canonicalRef = canonicalSpellPath(id)) {
902
+ const title = deriveStubTitle(frontmatter.name);
903
+ const description = frontmatter.claudeDescription ?? frontmatter.description;
904
+ const promptPath = canonicalRef;
905
+ return `---
906
+ description: ${description}
907
+ ---
908
+
909
+ # ${title}
910
+
911
+ Invoke the Arcane \`${id}\` spell workflow.
912
+
913
+ See the full prompt at \`${promptPath}\` for the complete workflow definition.
914
+
915
+ ---
916
+
917
+ @${promptPath}
918
+ `;
919
+ }
920
+ var SKILL_NAME_PATTERN = /^[a-z0-9-]+$/;
921
+ function renderCodexSkill(id, frontmatter, canonicalPath) {
922
+ if (!SKILL_NAME_PATTERN.test(id)) {
923
+ throw new InvalidSkillNameError(
924
+ `Skill id "${id}" must match ${SKILL_NAME_PATTERN} (lowercase letters, digits, hyphens only).`
925
+ );
926
+ }
927
+ const description = frontmatter.claudeDescription ?? frontmatter.description;
928
+ return `---
929
+ name: ${id}
930
+ description: ${description}
931
+ ---
932
+
933
+ This skill is the Arcane \`${id}\` spell. Read \`${canonicalPath}\` and follow it as the complete workflow.
934
+ `;
935
+ }
936
+
937
+ // src/modules/user-tier.ts
938
+ var USER_TIER_DIR_NAME = ".arcane";
939
+ var USER_TIER_SPELLS_DIR = "spells";
940
+ function resolveHomeDir() {
941
+ return homedir();
942
+ }
943
+ function userTierRoot(homeDir = resolveHomeDir()) {
944
+ return join(homeDir, USER_TIER_DIR_NAME);
945
+ }
946
+ var USER_TIER_COMPONENTS = SPELL_COMPONENT_NAMES;
947
+ var CANONICAL_SPELL_PATH_PATTERN = /^\.arcane\/spells\/(spell-[a-z0-9-]+)\.md$/;
948
+ var STORE_SPELL_PATH_PATTERN = /^spells\/(spell-[a-z0-9-]+)\.md$/;
949
+ function storeSpellPath(id) {
950
+ return `${USER_TIER_SPELLS_DIR}/${id}.md`;
951
+ }
952
+ function spellIdFromCanonicalPath(path4) {
953
+ return path4.replace(/\\/g, "/").match(CANONICAL_SPELL_PATH_PATTERN)?.[1];
954
+ }
955
+ function spellIdFromStorePath(path4) {
956
+ return path4.replace(/\\/g, "/").match(STORE_SPELL_PATH_PATTERN)?.[1];
957
+ }
958
+ function absoluteStoreSpellPath(storeRoot, id) {
959
+ return resolve(storeRoot, storeSpellPath(id)).replace(/\\/g, "/");
960
+ }
961
+ function componentForScope(component, scope) {
962
+ if (scope === "repo") return component;
963
+ const files = [];
964
+ const sourceOverrides = {};
965
+ for (const file of component.files) {
966
+ const id = spellIdFromCanonicalPath(file);
967
+ if (id === void 0) continue;
968
+ const installed = storeSpellPath(id);
969
+ files.push(installed);
970
+ sourceOverrides[installed] = component.sourceOverrides?.[file] ?? file;
971
+ }
972
+ return { name: component.name, description: component.description, files, sourceOverrides };
973
+ }
974
+ function spellIdsInStore(components) {
975
+ const ids = /* @__PURE__ */ new Set();
976
+ for (const component of components) {
977
+ for (const file of component.files) {
978
+ const id = spellIdFromStorePath(file);
979
+ if (id !== void 0) ids.add(id);
980
+ }
981
+ }
982
+ return [...ids].sort();
983
+ }
984
+ var USER_FANOUT_PATHS = {
985
+ codex: (id) => `.agents/skills/${id}/SKILL.md`,
986
+ claude: (id) => `.claude/commands/${id}.md`
987
+ };
988
+ var FANOUT_CLIENT_LABELS = {
989
+ codex: "Codex/Copilot skills",
990
+ claude: "Claude Code commands"
991
+ };
992
+ function clientOfFanoutPath(relativePath) {
993
+ const normalized = relativePath.replace(/\\/g, "/");
994
+ if (normalized.startsWith(".agents/skills/")) return "codex";
995
+ if (normalized.startsWith(".claude/commands/")) return "claude";
996
+ return void 0;
997
+ }
998
+ async function readSpellSource(storeRoot, id, fallbackDir) {
999
+ for (const candidate of [
1000
+ join(storeRoot, storeSpellPath(id)),
1001
+ ...fallbackDir ? [join(fallbackDir, canonicalSpellPath(id))] : []
1002
+ ]) {
1003
+ try {
1004
+ return await readFile3(candidate, "utf-8");
1005
+ } catch (err) {
1006
+ if (err.code !== "ENOENT") throw err;
1007
+ }
1008
+ }
1009
+ return void 0;
1010
+ }
1011
+ async function renderUserFanout(storeRoot, spellIds, fallbackDir) {
1012
+ const files = [];
1013
+ const missing = [];
1014
+ for (const id of [...spellIds].sort()) {
1015
+ const canonical = await readSpellSource(storeRoot, id, fallbackDir);
1016
+ if (canonical === void 0) {
1017
+ missing.push(id);
1018
+ continue;
1019
+ }
1020
+ const frontmatter = parsePromptFrontmatter(canonical);
1021
+ const absolutePath = absoluteStoreSpellPath(storeRoot, id);
1022
+ files.push({
1023
+ relativePath: USER_FANOUT_PATHS.codex(id),
1024
+ client: "codex",
1025
+ content: renderCodexSkill(id, frontmatter, absolutePath)
1026
+ });
1027
+ files.push({
1028
+ relativePath: USER_FANOUT_PATHS.claude(id),
1029
+ client: "claude",
1030
+ content: renderClaudeCommandStub(id, frontmatter, absolutePath)
1031
+ });
1032
+ }
1033
+ return { files, missing };
1034
+ }
1035
+ async function classifyTarget(path4) {
1036
+ try {
1037
+ const stats = await lstat(path4);
1038
+ return stats.isFile() ? "file" : "other";
1039
+ } catch (err) {
1040
+ if (err.code === "ENOENT") return "absent";
1041
+ throw err;
1042
+ }
1043
+ }
1044
+ function sha256(content) {
1045
+ return createHash2("sha256").update(content, "utf-8").digest("hex");
1046
+ }
1047
+ function perSpellDirectory(homeDir, relativePath) {
1048
+ if (clientOfFanoutPath(relativePath) !== "codex") return void 0;
1049
+ return dirname(join(homeDir, relativePath));
1050
+ }
1051
+ async function removeDirectoryIfEmpty(dir) {
1052
+ try {
1053
+ const entries = await readdir2(dir);
1054
+ if (entries.length === 0) await rmdir(dir);
1055
+ } catch {
1056
+ }
1057
+ }
1058
+ async function syncUserTierFanout(options) {
1059
+ const rendered = await renderUserFanout(options.storeRoot, options.spellIds, options.fallbackDir);
1060
+ const desired = rendered.files;
1061
+ const previous = options.previous ?? {};
1062
+ const dryRun = Boolean(options.dryRun);
1063
+ const record = {};
1064
+ const outcomes = [];
1065
+ const desiredPaths = new Set(desired.map((f) => f.relativePath));
1066
+ for (const id of rendered.missing) {
1067
+ for (const client of ["codex", "claude"]) {
1068
+ const relativePath = USER_FANOUT_PATHS[client](id);
1069
+ desiredPaths.add(relativePath);
1070
+ if (previous[relativePath] !== void 0) record[relativePath] = previous[relativePath];
1071
+ outcomes.push({ relativePath, status: "unrenderable" });
1072
+ }
1073
+ }
1074
+ for (const file of desired) {
1075
+ const target = join(options.homeDir, file.relativePath);
1076
+ const recorded = previous[file.relativePath];
1077
+ const newHash = sha256(file.content);
1078
+ const state = await classifyTarget(target);
1079
+ if (state === "absent") {
1080
+ if (!dryRun) {
1081
+ await mkdir2(dirname(target), { recursive: true });
1082
+ await writeFile3(target, file.content, "utf-8");
1083
+ }
1084
+ record[file.relativePath] = newHash;
1085
+ outcomes.push({ relativePath: file.relativePath, status: "written" });
1086
+ continue;
1087
+ }
1088
+ if (state === "other" || recorded === void 0) {
1089
+ outcomes.push({ relativePath: file.relativePath, status: "collision" });
1090
+ continue;
1091
+ }
1092
+ const currentHash = await hashFile(target);
1093
+ if (currentHash !== recorded) {
1094
+ record[file.relativePath] = recorded;
1095
+ outcomes.push({ relativePath: file.relativePath, status: "customized" });
1096
+ continue;
1097
+ }
1098
+ if (currentHash === newHash) {
1099
+ record[file.relativePath] = newHash;
1100
+ outcomes.push({ relativePath: file.relativePath, status: "unchanged" });
1101
+ continue;
1102
+ }
1103
+ if (!dryRun) await writeFile3(target, file.content, "utf-8");
1104
+ record[file.relativePath] = newHash;
1105
+ outcomes.push({ relativePath: file.relativePath, status: "written" });
1106
+ }
1107
+ for (const [relativePath, recorded] of Object.entries(previous)) {
1108
+ if (desiredPaths.has(relativePath)) continue;
1109
+ const target = join(options.homeDir, relativePath);
1110
+ const state = await classifyTarget(target);
1111
+ if (state === "absent") {
1112
+ outcomes.push({ relativePath, status: "gone" });
1113
+ continue;
1114
+ }
1115
+ if (state === "other" || await hashFile(target) !== recorded) {
1116
+ record[relativePath] = recorded;
1117
+ outcomes.push({ relativePath, status: "kept-edited" });
1118
+ continue;
1119
+ }
1120
+ if (!dryRun) {
1121
+ await rm(target, { force: true });
1122
+ const spellDir = perSpellDirectory(options.homeDir, relativePath);
1123
+ if (spellDir !== void 0) await removeDirectoryIfEmpty(spellDir);
1124
+ }
1125
+ outcomes.push({ relativePath, status: "pruned" });
1126
+ }
1127
+ return { record, outcomes };
1128
+ }
1129
+ function summarizeFanout(outcomes) {
1130
+ const counts = {
1131
+ written: 0,
1132
+ unchanged: 0,
1133
+ customized: 0,
1134
+ collision: 0,
1135
+ pruned: 0,
1136
+ "kept-edited": 0,
1137
+ gone: 0,
1138
+ unrenderable: 0
1139
+ };
1140
+ for (const outcome of outcomes) counts[outcome.status]++;
1141
+ return counts;
1142
+ }
1143
+ function describeFanoutOutcomes(outcomes, dryRun = false) {
1144
+ const counts = summarizeFanout(outcomes);
1145
+ const lines = [];
1146
+ const prefix = dryRun ? "[dry-run] Would " : "";
1147
+ const byClient = (status) => {
1148
+ const n = { codex: 0, claude: 0 };
1149
+ for (const o of outcomes) {
1150
+ if (o.status !== status) continue;
1151
+ const client = clientOfFanoutPath(o.relativePath);
1152
+ if (client !== void 0) n[client]++;
1153
+ }
1154
+ return n;
1155
+ };
1156
+ const paths = (status) => outcomes.filter((o) => o.status === status).map((o) => ` ~/${o.relativePath}`).join("\n");
1157
+ if (counts.written > 0) {
1158
+ const n = byClient("written");
1159
+ lines.push(
1160
+ `${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${n.codex} ${FANOUT_CLIENT_LABELS.codex}, ${n.claude} ${FANOUT_CLIENT_LABELS.claude}.`
1161
+ );
1162
+ }
1163
+ if (counts.pruned > 0) {
1164
+ lines.push(`${prefix}${dryRun ? "remove" : "Removed"} ${counts.pruned} client file(s) no longer needed.`);
1165
+ }
1166
+ if (counts.customized > 0) {
1167
+ lines.push(
1168
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts.customized} customized client file(s) untouched (edited since Arcane wrote them):
1169
+ ${paths("customized")}
1170
+ Delete one to have it regenerated by the next \`spell update --user\`.`
1171
+ );
1172
+ }
1173
+ if (counts.collision > 0) {
1174
+ lines.push(
1175
+ `! ${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):
1176
+ ${paths("collision")}
1177
+ Move or rename it and run \`spell update --user\` to install Arcane's.`
1178
+ );
1179
+ }
1180
+ if (counts.unrenderable > 0) {
1181
+ lines.push(
1182
+ `! ${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):
1183
+ ${paths("unrenderable")}
1184
+ Restore the spell with \`spell update --user\`, or delete these files if the spell is no longer shipped.`
1185
+ );
1186
+ }
1187
+ if (counts["kept-edited"] > 0) {
1188
+ lines.push(
1189
+ `! ${dryRun ? "Would keep" : "Kept"} ${counts["kept-edited"]} edited client file(s) that Arcane no longer needs (delete them yourself if unwanted):
1190
+ ${paths("kept-edited")}`
1191
+ );
1192
+ }
1193
+ return lines;
1194
+ }
1195
+ async function inspectUserTierFanout(homeDir, record) {
1196
+ const health = {
1197
+ total: 0,
1198
+ byClient: { codex: 0, claude: 0 },
1199
+ missing: [],
1200
+ customized: []
1201
+ };
1202
+ for (const [relativePath, recorded] of Object.entries(record ?? {})) {
1203
+ health.total++;
1204
+ const client = clientOfFanoutPath(relativePath);
1205
+ if (client !== void 0) health.byClient[client]++;
1206
+ const target = join(homeDir, relativePath);
1207
+ const state = await classifyTarget(target);
1208
+ if (state === "absent") {
1209
+ health.missing.push(relativePath);
1210
+ continue;
1211
+ }
1212
+ if (state === "other" || await hashFile(target) !== recorded) health.customized.push(relativePath);
1213
+ }
1214
+ return health;
1215
+ }
1216
+ function isUserTierStore(targetDir) {
1217
+ return resolve(targetDir) === resolve(userTierRoot());
1218
+ }
1219
+ function resolveInstallScope(targetDir, manifestScope, userFlag) {
1220
+ if (userFlag) return { scope: "user", misplacedUserManifest: false };
1221
+ if (manifestScope !== "user") return { scope: "repo", misplacedUserManifest: false };
1222
+ return isUserTierStore(targetDir) ? { scope: "user", misplacedUserManifest: false } : { scope: "repo", misplacedUserManifest: true };
1223
+ }
1224
+ function misplacedUserManifestMessage(targetDir) {
1225
+ 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.`;
1226
+ }
1227
+ var VSCODE_USER_TIER_NOTE = [
1228
+ "VS Code Copilot: no settings change is needed \u2014 Copilot discovers ~/.agents/skills by default",
1229
+ "(chat.useAgentSkills, on by default) and lists each spell under /. Reload the window after installing.",
1230
+ "VS Code has deprecated chat.promptFilesLocations; the user tier does not use it."
1231
+ ];
1232
+ 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.";
1233
+
825
1234
  // src/modules/banner.ts
826
1235
  import chalk from "chalk";
827
1236
  import gradient from "gradient-string";
@@ -844,7 +1253,7 @@ var WORDMARK = [
844
1253
  var INDENT = " ";
845
1254
  var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
846
1255
  function sleep(ms) {
847
- return new Promise((resolve4) => setTimeout(resolve4, ms));
1256
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
848
1257
  }
849
1258
  function isAnimatable() {
850
1259
  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 +1371,8 @@ function printWarning(message) {
962
1371
 
963
1372
  // src/modules/agents.ts
964
1373
  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";
1374
+ import { mkdir as mkdir4, writeFile as writeFile6, copyFile as copyFile2 } from "fs/promises";
1375
+ import { join as join4 } from "path";
967
1376
  import { stringify } from "yaml";
968
1377
 
969
1378
  // src/config/agent-roles.ts
@@ -1198,8 +1607,8 @@ async function applyNamingStrategy(strategy, roleIds) {
1198
1607
  }
1199
1608
 
1200
1609
  // src/modules/agent-loader.ts
1201
- import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
1202
- import { join } from "path";
1610
+ import { readFile as readFile4, readdir as readdir3, access as access2 } from "fs/promises";
1611
+ import { join as join2 } from "path";
1203
1612
  import { parse } from "yaml";
1204
1613
 
1205
1614
  // src/modules/agent-schema.ts
@@ -1379,7 +1788,7 @@ var AgentRosterNotFoundError = class extends Error {
1379
1788
  this.name = "AgentRosterNotFoundError";
1380
1789
  }
1381
1790
  };
1382
- function parseYaml(content, source) {
1791
+ function parseYaml2(content, source) {
1383
1792
  try {
1384
1793
  return parse(content);
1385
1794
  } catch (err) {
@@ -1388,44 +1797,44 @@ function parseYaml(content, source) {
1388
1797
  }
1389
1798
  }
1390
1799
  async function loadAgentDefinition(agentsDir, id) {
1391
- const filePath = join(agentsDir, `${id}.yaml`);
1800
+ const filePath = join2(agentsDir, `${id}.yaml`);
1392
1801
  let content;
1393
1802
  try {
1394
- content = await readFile3(filePath, "utf8");
1803
+ content = await readFile4(filePath, "utf8");
1395
1804
  } catch {
1396
1805
  throw new AgentDefinitionNotFoundError(id, agentsDir);
1397
1806
  }
1398
- return validateAgentDefinition(parseYaml(content, filePath), filePath);
1807
+ return validateAgentDefinition(parseYaml2(content, filePath), filePath);
1399
1808
  }
1400
1809
  async function loadRoster(targetDir) {
1401
- const rosterPath = join(targetDir, ".arcane", "agents.yaml");
1810
+ const rosterPath = join2(targetDir, ".arcane", "agents.yaml");
1402
1811
  let content;
1403
1812
  try {
1404
- content = await readFile3(rosterPath, "utf8");
1813
+ content = await readFile4(rosterPath, "utf8");
1405
1814
  } catch {
1406
1815
  throw new AgentRosterNotFoundError();
1407
1816
  }
1408
- return validateAgentRoster(parseYaml(content, rosterPath), rosterPath);
1817
+ return validateAgentRoster(parseYaml2(content, rosterPath), rosterPath);
1409
1818
  }
1410
1819
  async function rosterExists(targetDir) {
1411
1820
  try {
1412
- await access2(join(targetDir, ".arcane", "agents.yaml"));
1821
+ await access2(join2(targetDir, ".arcane", "agents.yaml"));
1413
1822
  return true;
1414
1823
  } catch {
1415
1824
  return false;
1416
1825
  }
1417
1826
  }
1418
1827
  function projectAgentsDir(targetDir) {
1419
- return join(targetDir, ".arcane", "agents");
1828
+ return join2(targetDir, ".arcane", "agents");
1420
1829
  }
1421
1830
 
1422
1831
  // 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";
1832
+ import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
1833
+ import { join as join3 } from "path";
1834
+ import { homedir as homedir2 } from "os";
1426
1835
 
1427
1836
  // src/modules/merger.ts
1428
- import { readFile as readFile4, writeFile as writeFile3, rm } from "fs/promises";
1837
+ import { readFile as readFile5, writeFile as writeFile4, rm as rm2 } from "fs/promises";
1429
1838
  import path3 from "path";
1430
1839
  var MARKER_START = "<!-- arcane:start -->";
1431
1840
  var MARKER_END = "<!-- arcane:end -->";
@@ -1440,7 +1849,7 @@ async function mergeIntoFile(targetDir, relativePath, content, opts = {}) {
1440
1849
  const destPath = path3.resolve(targetDir, relativePath);
1441
1850
  let existing;
1442
1851
  try {
1443
- existing = await readFile4(destPath, "utf8");
1852
+ existing = await readFile5(destPath, "utf8");
1444
1853
  } catch (err) {
1445
1854
  const e = err;
1446
1855
  if (e.code === "ENOENT") {
@@ -1456,7 +1865,7 @@ ${content}
1456
1865
  ${MARKER_END}
1457
1866
  `;
1458
1867
  if (!opts.dryRun) {
1459
- await writeFile3(destPath, newContent, "utf8");
1868
+ await writeFile4(destPath, newContent, "utf8");
1460
1869
  }
1461
1870
  return true;
1462
1871
  }
@@ -1481,7 +1890,7 @@ ${MARKER_END}
1481
1890
  ${content}
1482
1891
  ${afterMarker}`;
1483
1892
  if (!opts.dryRun) {
1484
- await writeFile3(destPath, updated, "utf8");
1893
+ await writeFile4(destPath, updated, "utf8");
1485
1894
  }
1486
1895
  return true;
1487
1896
  }
@@ -1491,7 +1900,7 @@ ${content}
1491
1900
  ${MARKER_END}
1492
1901
  `;
1493
1902
  if (!opts.dryRun) {
1494
- await writeFile3(destPath, appended, "utf8");
1903
+ await writeFile4(destPath, appended, "utf8");
1495
1904
  }
1496
1905
  return true;
1497
1906
  }
@@ -1499,7 +1908,7 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1499
1908
  const destPath = path3.resolve(targetDir, relativePath);
1500
1909
  let existing;
1501
1910
  try {
1502
- existing = await readFile4(destPath, "utf8");
1911
+ existing = await readFile5(destPath, "utf8");
1503
1912
  } catch {
1504
1913
  return false;
1505
1914
  }
@@ -1511,9 +1920,9 @@ async function stripMarkerSection(targetDir, relativePath, opts = {}) {
1511
1920
  const stripped = (before + after).replace(/\n{3,}/g, "\n\n").trim();
1512
1921
  if (!opts.dryRun) {
1513
1922
  if (stripped.length === 0) {
1514
- await rm(destPath, { force: true });
1923
+ await rm2(destPath, { force: true });
1515
1924
  } else {
1516
- await writeFile3(destPath, stripped + "\n", "utf8");
1925
+ await writeFile4(destPath, stripped + "\n", "utf8");
1517
1926
  }
1518
1927
  }
1519
1928
  return true;
@@ -1634,7 +2043,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1634
2043
  const skipped = [];
1635
2044
  let hasUnresolvedRoles = false;
1636
2045
  const projectDefs = projectAgentsDir(targetDir);
1637
- const bundledDefs = join2(assetsDir, "agents");
2046
+ const bundledDefs = join3(assetsDir, "agents");
1638
2047
  const resolved = [];
1639
2048
  for (const entry of roster.roster) {
1640
2049
  let def;
@@ -1658,27 +2067,27 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1658
2067
  if (roster.openclaw.enabled && options.openclaw !== false) {
1659
2068
  const openclawRoot = roster.openclaw.workspace_root.replace(
1660
2069
  /^~/,
1661
- homedir()
2070
+ homedir2()
1662
2071
  );
1663
2072
  for (const { entry, def, displayName } of resolved) {
1664
2073
  if (!def.clients.openclaw) continue;
1665
2074
  const workspaceName = entry.id === "main" ? "workspace" : `workspace-${entry.id}`;
1666
- const workspaceDir = join2(openclawRoot, workspaceName);
2075
+ const workspaceDir = join3(openclawRoot, workspaceName);
1667
2076
  try {
1668
2077
  if (!options.dryRun) {
1669
- await mkdir2(workspaceDir, { recursive: true });
1670
- await writeFile4(
1671
- join2(workspaceDir, "IDENTITY.md"),
2078
+ await mkdir3(workspaceDir, { recursive: true });
2079
+ await writeFile5(
2080
+ join3(workspaceDir, "IDENTITY.md"),
1672
2081
  renderIdentity(def, displayName),
1673
2082
  "utf8"
1674
2083
  );
1675
- await writeFile4(
1676
- join2(workspaceDir, "SOUL.md"),
2084
+ await writeFile5(
2085
+ join3(workspaceDir, "SOUL.md"),
1677
2086
  renderSoul(def, displayName),
1678
2087
  "utf8"
1679
2088
  );
1680
- await writeFile4(
1681
- join2(workspaceDir, "TOOLS.md"),
2089
+ await writeFile5(
2090
+ join3(workspaceDir, "TOOLS.md"),
1682
2091
  renderTools(def, displayName),
1683
2092
  "utf8"
1684
2093
  );
@@ -1703,11 +2112,11 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1703
2112
  }
1704
2113
  }))
1705
2114
  };
1706
- const generatedDir = join2(targetDir, ".arcane", "generated");
2115
+ const generatedDir = join3(targetDir, ".arcane", "generated");
1707
2116
  if (!options.dryRun) {
1708
- await mkdir2(generatedDir, { recursive: true });
1709
- await writeFile4(
1710
- join2(generatedDir, "openclaw-roster.json"),
2117
+ await mkdir3(generatedDir, { recursive: true });
2118
+ await writeFile5(
2119
+ join3(generatedDir, "openclaw-roster.json"),
1711
2120
  JSON.stringify(patch, null, 2) + "\n",
1712
2121
  "utf8"
1713
2122
  );
@@ -1719,17 +2128,17 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
1719
2128
  synced.push("openclaw-roster.json");
1720
2129
  }
1721
2130
  if (options.copilot !== false) {
1722
- const agentsDir = join2(targetDir, ".github", "agents");
2131
+ const agentsDir = join3(targetDir, ".github", "agents");
1723
2132
  if (!options.dryRun) {
1724
- await mkdir2(agentsDir, { recursive: true });
2133
+ await mkdir3(agentsDir, { recursive: true });
1725
2134
  }
1726
2135
  for (const { def, displayName } of resolved) {
1727
2136
  if (!def.clients.copilot?.agent_file) continue;
1728
2137
  const slug = displayName.toLowerCase().replace(/\s+/g, "-");
1729
2138
  const fileName = `${slug}.agent.md`;
1730
2139
  if (!options.dryRun) {
1731
- await writeFile4(
1732
- join2(agentsDir, fileName),
2140
+ await writeFile5(
2141
+ join3(agentsDir, fileName),
1733
2142
  renderCopilotAgent(def, displayName),
1734
2143
  "utf8"
1735
2144
  );
@@ -1870,11 +2279,11 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1870
2279
  },
1871
2280
  roster: rosterEntries
1872
2281
  };
1873
- const arcaneDir = join3(targetDir, ".arcane", "agents");
2282
+ const arcaneDir = join4(targetDir, ".arcane", "agents");
1874
2283
  if (!options.dryRun) {
1875
- await mkdir3(arcaneDir, { recursive: true });
1876
- await writeFile5(
1877
- join3(targetDir, ".arcane", "agents.yaml"),
2284
+ await mkdir4(arcaneDir, { recursive: true });
2285
+ await writeFile6(
2286
+ join4(targetDir, ".arcane", "agents.yaml"),
1878
2287
  stringify(roster),
1879
2288
  "utf8"
1880
2289
  );
@@ -1882,8 +2291,8 @@ All 12 roles across ${allCategories.join(", ")}` : "\nPick individual roles from
1882
2291
  console.log(" [dry-run] Would write: .arcane/agents.yaml");
1883
2292
  }
1884
2293
  for (const roleId of selectedRoleIds) {
1885
- const src = join3(assetsDir, "agents", `${roleId}.yaml`);
1886
- const dest = join3(arcaneDir, `${roleId}.yaml`);
2294
+ const src = join4(assetsDir, "agents", `${roleId}.yaml`);
2295
+ const dest = join4(arcaneDir, `${roleId}.yaml`);
1887
2296
  if (!options.dryRun) {
1888
2297
  try {
1889
2298
  await copyFile2(src, dest);
@@ -2017,9 +2426,9 @@ async function runAgentsList(targetDir) {
2017
2426
  }
2018
2427
 
2019
2428
  // 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";
2429
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile7, chmod, access as access3, rm as rm3, readdir as readdir4 } from "fs/promises";
2021
2430
  import { constants } from "fs";
2022
- import { join as join4, resolve, dirname, basename } from "path";
2431
+ import { join as join5, resolve as resolve2, dirname as dirname2, basename } from "path";
2023
2432
 
2024
2433
  // src/modules/git.ts
2025
2434
  import { execFile } from "child_process";
@@ -2116,7 +2525,7 @@ function buildGitEnv() {
2116
2525
  function runGit(cwd, args, options = {}) {
2117
2526
  const commandClass = options.commandClass ?? classifyGitCommand(args);
2118
2527
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
2119
- return new Promise((resolve4, reject) => {
2528
+ return new Promise((resolve5, reject) => {
2120
2529
  const child = execFile(
2121
2530
  "git",
2122
2531
  args,
@@ -2150,7 +2559,7 @@ function runGit(cwd, args, options = {}) {
2150
2559
  reject(error);
2151
2560
  return;
2152
2561
  }
2153
- resolve4({ stdout, stderr });
2562
+ resolve5({ stdout, stderr });
2154
2563
  }
2155
2564
  );
2156
2565
  child.stdin?.end();
@@ -2268,7 +2677,7 @@ function isArcaneHooksPath(root, value) {
2268
2677
  const windows = process.platform === "win32";
2269
2678
  const normalize = (p) => {
2270
2679
  const separated = windows ? p.replace(/\\/g, "/") : p;
2271
- const absolute = resolve(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2680
+ const absolute = resolve2(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2272
2681
  return windows ? absolute.toLowerCase() : absolute;
2273
2682
  };
2274
2683
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
@@ -2316,7 +2725,7 @@ async function repositoryRoot(cwd) {
2316
2725
  ]);
2317
2726
  const commonDir = stdout.trim();
2318
2727
  if (commonDir !== "") {
2319
- const parent = dirname(commonDir);
2728
+ const parent = dirname2(commonDir);
2320
2729
  if (basename(commonDir) === ".git" && parent !== "") return parent;
2321
2730
  }
2322
2731
  } catch {
@@ -2330,10 +2739,10 @@ async function repositoryRoot(cwd) {
2330
2739
  return cwd;
2331
2740
  }
2332
2741
  async function hookFilePath(cwd, hookName = "pre-push") {
2333
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2742
+ return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
2334
2743
  }
2335
2744
  async function hooksPathValue(cwd) {
2336
- return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2745
+ return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2337
2746
  }
2338
2747
  async function installHook(cwd, hookName, body) {
2339
2748
  const root = await repositoryRoot(cwd);
@@ -2352,12 +2761,12 @@ async function installHook(cwd, hookName, body) {
2352
2761
  return { status: "refused-default-hooks", hooks: displaced };
2353
2762
  }
2354
2763
  }
2355
- const hookPath = join4(root, ARCANE_HOOKS_DIR, hookName);
2764
+ const hookPath = join5(root, ARCANE_HOOKS_DIR, hookName);
2356
2765
  if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
2357
2766
  return { status: "already-ours" };
2358
2767
  }
2359
- await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2360
- await writeFile6(hookPath, body, "utf-8");
2768
+ await mkdir5(join5(root, ARCANE_HOOKS_DIR), { recursive: true });
2769
+ await writeFile7(hookPath, body, "utf-8");
2361
2770
  await chmod(hookPath, 493);
2362
2771
  await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2363
2772
  return { status: "installed", path: hookPath };
@@ -2401,12 +2810,12 @@ async function defaultHooks(cwd) {
2401
2810
  let hooksDir;
2402
2811
  try {
2403
2812
  const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
2404
- hooksDir = join4(resolve(cwd, stdout.trim()), "hooks");
2813
+ hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
2405
2814
  } catch {
2406
2815
  return [];
2407
2816
  }
2408
2817
  try {
2409
- return (await readdir3(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2818
+ return (await readdir4(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2410
2819
  } catch {
2411
2820
  return [];
2412
2821
  }
@@ -2415,7 +2824,7 @@ async function hookFileMatches(cwd, hookName, body) {
2415
2824
  const hookPath = await hookFilePath(cwd, hookName);
2416
2825
  if (!await fileExists(hookPath)) return false;
2417
2826
  try {
2418
- if (await readFile5(hookPath, "utf-8") !== body) return false;
2827
+ if (await readFile6(hookPath, "utf-8") !== body) return false;
2419
2828
  } catch {
2420
2829
  return false;
2421
2830
  }
@@ -2434,13 +2843,13 @@ async function removeHook(cwd, hookName) {
2434
2843
  if (!isArcaneHooksPath(root, existing.value)) return;
2435
2844
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2436
2845
  try {
2437
- await rm2(await hookFilePath(cwd, hookName), { force: true });
2846
+ await rm3(await hookFilePath(cwd, hookName), { force: true });
2438
2847
  } catch {
2439
2848
  }
2440
- const hooksDir = join4(root, ARCANE_HOOKS_DIR);
2849
+ const hooksDir = join5(root, ARCANE_HOOKS_DIR);
2441
2850
  let remaining = [];
2442
2851
  try {
2443
- remaining = await readdir3(hooksDir);
2852
+ remaining = await readdir4(hooksDir);
2444
2853
  } catch {
2445
2854
  remaining = [];
2446
2855
  }
@@ -2768,6 +3177,10 @@ function buildProfileDescription(components, profile) {
2768
3177
  return "\n" + parts.join("\n");
2769
3178
  }
2770
3179
  async function runInit(options, targetDir, assetsDir, packageVersion) {
3180
+ if (options.user) {
3181
+ await runInitUser(options, targetDir, assetsDir, packageVersion);
3182
+ return;
3183
+ }
2771
3184
  if (options.profile && !VALID_PROFILES.includes(options.profile)) {
2772
3185
  throw new Error(
2773
3186
  `Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
@@ -2872,7 +3285,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2872
3285
  const installedFiles = [];
2873
3286
  const fileHashes = {};
2874
3287
  for (const file of component.files) {
2875
- const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
3288
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
2876
3289
  if (options.dryRun) {
2877
3290
  printDryRun(`Would copy: ${file}`);
2878
3291
  } else {
@@ -2889,7 +3302,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2889
3302
  fileCount++;
2890
3303
  }
2891
3304
  for (const dir of component.directories ?? []) {
2892
- const srcDirPath = join5(assetsDir, dir);
3305
+ const srcDirPath = join6(assetsDir, dir);
2893
3306
  if (options.dryRun) {
2894
3307
  printDryRun(`Would copy directory: ${dir}/`);
2895
3308
  } else {
@@ -2918,7 +3331,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2918
3331
  if (options.dryRun) {
2919
3332
  if (hasAgents) {
2920
3333
  const agentFiles = await import("fs").then(
2921
- (fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
3334
+ (fsModule) => fsModule.promises.readdir(join6(assetsDir, "agents"))
2922
3335
  );
2923
3336
  const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
2924
3337
  printDryRun("Would run agent setup (interactive):");
@@ -3160,9 +3573,110 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
3160
3573
  );
3161
3574
  console.log();
3162
3575
  }
3576
+ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
3577
+ if (options.profile) {
3578
+ throw new Error(
3579
+ '"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
3580
+ );
3581
+ }
3582
+ try {
3583
+ await readManifest(storeRoot);
3584
+ console.log(chalk2.hex("#a855f7")(`
3585
+ \u2726 Arcane v${packageVersion}
3586
+ `));
3587
+ console.log(
3588
+ `Already initialized (user tier at ${storeRoot}). Run "spell update --user" to update it.`
3589
+ );
3590
+ return;
3591
+ } catch (err) {
3592
+ if (!(err instanceof ManifestNotFoundError)) throw err;
3593
+ }
3594
+ console.log(chalk2.hex("#a855f7")(`
3595
+ \u2726 Arcane v${packageVersion}
3596
+ `));
3597
+ const homeDir = dirname3(storeRoot);
3598
+ const components = USER_TIER_COMPONENTS.map(
3599
+ (name) => componentForScope(getComponent(name), "user")
3600
+ );
3601
+ const spellIds = components.flatMap((c) => c.files).map((file) => spellIdFromStorePath(file)).filter((id) => id !== void 0);
3602
+ printSection(`\u{1F4E6} User tier \u2014 ${storeRoot}`);
3603
+ console.log();
3604
+ console.log(
3605
+ ` \u2728 ${spellIds.length} Spells \u2192 ${storeRoot}/spells/ \xB7 \u{1F517} ${spellIds.length * 2} client files \u2192 ~/.agents/skills (Codex, Copilot), ~/.claude/commands (Claude Code)`
3606
+ );
3607
+ console.log();
3608
+ if (options.dryRun) {
3609
+ for (const component of components) {
3610
+ for (const file of component.files) printDryRun(`Would copy: ${file}`);
3611
+ }
3612
+ for (const id of spellIds) {
3613
+ for (const client of ["codex", "claude"]) {
3614
+ const relativePath = USER_FANOUT_PATHS[client](id);
3615
+ if (await fileExists(join6(homeDir, relativePath))) {
3616
+ printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
3617
+ } else {
3618
+ printDryRun(`Would write: ~/${relativePath}`);
3619
+ }
3620
+ }
3621
+ }
3622
+ printDryRun(
3623
+ `Would initialize the user tier \u2014 ${spellIds.length} spells, ${spellIds.length * 2} client files`
3624
+ );
3625
+ return;
3626
+ }
3627
+ const installedComponents = [];
3628
+ let fileCount = 0;
3629
+ for (const component of components) {
3630
+ const installedFiles = [];
3631
+ const fileHashes = {};
3632
+ for (const file of component.files) {
3633
+ const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3634
+ fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
3635
+ installedFiles.push(file);
3636
+ fileCount++;
3637
+ }
3638
+ installedComponents.push({
3639
+ name: component.name,
3640
+ files: installedFiles,
3641
+ installedVersion: packageVersion,
3642
+ fileHashes
3643
+ });
3644
+ }
3645
+ const fanout = await syncUserTierFanout({ homeDir, storeRoot, spellIds, fallbackDir: assetsDir });
3646
+ const manifest = {
3647
+ version: packageVersion,
3648
+ // The tier is un-profiled; "full" records the profile its component set
3649
+ // is drawn from, and every command reads `scope` before `profile`.
3650
+ profile: "full",
3651
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
3652
+ components: installedComponents,
3653
+ scope: "user",
3654
+ fanout: fanout.record
3655
+ };
3656
+ await writeManifest(storeRoot, manifest);
3657
+ printStep(`\u2728 ${fileCount} Spells installed to ${storeRoot}/spells/`);
3658
+ for (const line of describeFanoutOutcomes(fanout.outcomes)) {
3659
+ if (line.startsWith("!")) printWarning(line.slice(2));
3660
+ else printStep(`\u{1F517} ${line}`);
3661
+ }
3662
+ printSuccess(
3663
+ `Initialized the user tier \u2014 ${fileCount} spells, ${Object.keys(fanout.record).length} client files recorded`
3664
+ );
3665
+ console.log();
3666
+ printSection("\u{1F9ED} Clients");
3667
+ console.log();
3668
+ for (const line of VSCODE_USER_TIER_NOTE) printInfo(line);
3669
+ printInfo(CLAUDE_PRECEDENCE_NOTE);
3670
+ console.log();
3671
+ printSection("\u{1F680} Next steps");
3672
+ console.log();
3673
+ printNextStep(1, "Reload VS Code, and start a fresh Codex or Claude Code session, so the pickers see the new files");
3674
+ printNextStep(2, "`spell status --user` shows the tier; `spell update --user` refreshes it after upgrading the CLI");
3675
+ console.log();
3676
+ }
3163
3677
 
3164
3678
  // src/commands/add.ts
3165
- import { join as join6 } from "path";
3679
+ import { join as join7 } from "path";
3166
3680
  async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3167
3681
  let manifest;
3168
3682
  try {
@@ -3198,7 +3712,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3198
3712
  const installedFiles = [];
3199
3713
  const fileHashes = {};
3200
3714
  for (const file of component.files) {
3201
- const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
3715
+ const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
3202
3716
  if (options.dryRun) {
3203
3717
  console.log(` [dry-run] Would copy: ${file}`);
3204
3718
  } else {
@@ -3208,7 +3722,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3208
3722
  fileCount++;
3209
3723
  }
3210
3724
  for (const dir of component.directories ?? []) {
3211
- const srcDirPath = join6(assetsDir, dir);
3725
+ const srcDirPath = join7(assetsDir, dir);
3212
3726
  if (options.dryRun) {
3213
3727
  console.log(` [dry-run] Would copy directory: ${dir}/`);
3214
3728
  } else {
@@ -3242,13 +3756,13 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
3242
3756
  }
3243
3757
 
3244
3758
  // 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";
3759
+ import { dirname as dirname4, join as join9 } from "path";
3760
+ import { readFile as readFile7, writeFile as writeFile9, rm as rm4 } from "fs/promises";
3247
3761
 
3248
3762
  // src/modules/hub.ts
3249
3763
  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";
3764
+ import { readdir as readdir5, writeFile as writeFile8 } from "fs/promises";
3765
+ import { join as join8 } from "path";
3252
3766
  var MANIFEST_RETROFITS = [
3253
3767
  {
3254
3768
  field: "role",
@@ -3387,12 +3901,12 @@ async function runManifestRetrofits(manifest) {
3387
3901
  return patch;
3388
3902
  }
3389
3903
  async function offerRegistryScaffold(targetDir, businessRoot) {
3390
- const registryPath = join7(targetDir, businessRoot, "registry.json");
3904
+ const registryPath = join8(targetDir, businessRoot, "registry.json");
3391
3905
  if (await fileExists(registryPath)) return;
3392
- const businessRootPath = join7(targetDir, businessRoot);
3906
+ const businessRootPath = join8(targetDir, businessRoot);
3393
3907
  let ventureDirs;
3394
3908
  try {
3395
- const entries = await readdir4(businessRootPath, { withFileTypes: true });
3909
+ const entries = await readdir5(businessRootPath, { withFileTypes: true });
3396
3910
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
3397
3911
  } catch {
3398
3912
  return;
@@ -3427,7 +3941,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3427
3941
  updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
3428
3942
  ventures
3429
3943
  };
3430
- await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3944
+ await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
3431
3945
  console.log(
3432
3946
  ` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
3433
3947
  );
@@ -3575,18 +4089,6 @@ async function fetchPublishedFile(version, relativeAssetPath) {
3575
4089
  }
3576
4090
  }
3577
4091
 
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
4092
  // src/commands/update.ts
3591
4093
  function migrateLegacyComponents(components) {
3592
4094
  const out = [];
@@ -3608,7 +4110,7 @@ function migrateLegacyComponents(components) {
3608
4110
  return out;
3609
4111
  }
3610
4112
  async function resolveOrphan(targetDir, file, recordedHash, prune) {
3611
- const filePath = join8(targetDir, file);
4113
+ const filePath = join9(targetDir, file);
3612
4114
  if (!await fileExists(filePath)) return "not-found";
3613
4115
  if (!prune) return "reported";
3614
4116
  if (recordedHash === void 0) {
@@ -3620,23 +4122,23 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
3620
4122
  console.log(` ! Orphaned but edited since install \u2014 not pruning: ${file}`);
3621
4123
  return "reported";
3622
4124
  }
3623
- await rm3(filePath, { force: true });
4125
+ await rm4(filePath, { force: true });
3624
4126
  console.log(` Pruned orphaned file: ${file}`);
3625
4127
  return "pruned";
3626
4128
  }
3627
- async function findMissingTrackedFiles(targetDir, components) {
4129
+ async function findMissingTrackedFiles(targetDir, components, scope = "repo") {
3628
4130
  const missing = [];
3629
4131
  for (const installed of components) {
3630
4132
  let component;
3631
4133
  try {
3632
- component = getComponent(installed.name);
4134
+ component = componentForScope(getComponent(installed.name), scope);
3633
4135
  } catch {
3634
4136
  continue;
3635
4137
  }
3636
4138
  if (component.initOnly || component.skipExisting) continue;
3637
4139
  for (const file of installed.files) {
3638
4140
  if (!component.files.includes(file)) continue;
3639
- if (!await fileExists(join8(targetDir, file))) missing.push(file);
4141
+ if (!await fileExists(join9(targetDir, file))) missing.push(file);
3640
4142
  }
3641
4143
  }
3642
4144
  return missing;
@@ -3648,46 +4150,70 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3648
4150
  } catch (err) {
3649
4151
  if (err instanceof ManifestNotFoundError) {
3650
4152
  console.error(
3651
- 'Not initialized. Run "spell init" first before updating.'
4153
+ `Not initialized. Run "spell init${options.user ? " --user" : ""}" first before updating.`
3652
4154
  );
3653
4155
  process.exit(1);
3654
4156
  return;
3655
4157
  }
3656
4158
  throw err;
3657
4159
  }
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
- );
4160
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4161
+ if (resolvedScope.misplacedUserManifest) {
4162
+ console.error(misplacedUserManifestMessage(targetDir));
3669
4163
  process.exit(1);
3670
4164
  return;
3671
4165
  }
3672
- if (gitState.status === "no-commits") {
3673
- console.error(
3674
- "Update refused: this repository has no commits. Commit the current baseline before updating."
4166
+ const scope = resolvedScope.scope;
4167
+ const homeDir = dirname4(targetDir);
4168
+ if (scope === "repo") {
4169
+ console.warn(
4170
+ `WARNING: Arcane v${packageVersion} update safety notice: commit your work before updating.`
3675
4171
  );
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.`
4172
+ console.warn(
4173
+ "Updates can replace managed files. A clean committed baseline is required for recovery."
3682
4174
  );
3683
- process.exit(1);
3684
- return;
4175
+ const gitState = await inspectGitRepository(targetDir);
4176
+ if (gitState.status === "not-repository") {
4177
+ console.error(
4178
+ "Update refused: this directory is not a Git repository or Git is unavailable."
4179
+ );
4180
+ process.exit(1);
4181
+ return;
4182
+ }
4183
+ if (gitState.status === "no-commits") {
4184
+ console.error(
4185
+ "Update refused: this repository has no commits. Commit the current baseline before updating."
4186
+ );
4187
+ process.exit(1);
4188
+ return;
4189
+ }
4190
+ if (gitState.uncommittedChanges > 0) {
4191
+ console.error(
4192
+ `Update refused: this repository has ${gitState.uncommittedChanges} uncommitted change${gitState.uncommittedChanges === 1 ? "" : "s"}. Commit or otherwise clean the working tree before updating.`
4193
+ );
4194
+ process.exit(1);
4195
+ return;
4196
+ }
3685
4197
  }
3686
4198
  let sameVersionRestore = false;
3687
4199
  if (manifest.version === packageVersion && manifest.components.length > 0) {
3688
- const missing = await findMissingTrackedFiles(targetDir, manifest.components);
4200
+ const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
3689
4201
  if (missing.length === 0) {
3690
4202
  console.log("Already up to date.");
4203
+ if (scope === "user") {
4204
+ const fanout = await syncUserTierFanout({
4205
+ homeDir,
4206
+ storeRoot: targetDir,
4207
+ spellIds: spellIdsInStore(manifest.components),
4208
+ previous: manifest.fanout,
4209
+ dryRun: options.dryRun,
4210
+ fallbackDir: assetsDir
4211
+ });
4212
+ for (const line of describeFanoutOutcomes(fanout.outcomes, options.dryRun)) console.log(` ${line}`);
4213
+ if (!options.dryRun && JSON.stringify(fanout.record) !== JSON.stringify(manifest.fanout ?? {})) {
4214
+ await writeManifest(targetDir, { ...manifest, fanout: fanout.record });
4215
+ }
4216
+ }
3691
4217
  return;
3692
4218
  }
3693
4219
  sameVersionRestore = true;
@@ -3708,7 +4234,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3708
4234
  for (const installed of componentsToUpdate) {
3709
4235
  let component;
3710
4236
  try {
3711
- component = getComponent(installed.name);
4237
+ component = componentForScope(getComponent(installed.name), scope);
3712
4238
  } catch (err) {
3713
4239
  if (err instanceof ComponentNotFoundError) {
3714
4240
  console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
@@ -3732,8 +4258,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3732
4258
  const updatedFiles = [];
3733
4259
  const fileHashes = {};
3734
4260
  for (const file of component.files) {
3735
- const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
3736
- const targetExists = await fileExists(join8(targetDir, file));
4261
+ const srcPath = join9(assetsDir, component.sourceOverrides?.[file] ?? file);
4262
+ const targetExists = await fileExists(join9(targetDir, file));
3737
4263
  const preserveExisting = Boolean(component.skipExisting) && targetExists;
3738
4264
  const recordedHash = installed.fileHashes?.[file];
3739
4265
  if (component.initOnly && !targetExists) {
@@ -3753,7 +4279,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3753
4279
  continue;
3754
4280
  }
3755
4281
  }
3756
- const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await hashFile(join8(targetDir, file)) !== recordedHash;
4282
+ const editedByOperator = recordedHash !== void 0 && targetExists && !preserveExisting && await hashFile(join9(targetDir, file)) !== recordedHash;
3757
4283
  if (preserveExisting) {
3758
4284
  console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
3759
4285
  } else if (editedByOperator && isClientShimPath(file)) {
@@ -3768,7 +4294,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3768
4294
  } else {
3769
4295
  let handled = false;
3770
4296
  if (editedByOperator) {
3771
- const oldVendorContent = await fetchPublishedFile(installed.installedVersion, file);
4297
+ const oldVendorContent = await fetchPublishedFile(
4298
+ installed.installedVersion,
4299
+ component.sourceOverrides?.[file] ?? file
4300
+ );
3772
4301
  if (oldVendorContent === void 0) {
3773
4302
  console.log(
3774
4303
  ` ! 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 +4306,11 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3777
4306
  handled = true;
3778
4307
  } else {
3779
4308
  const [currentContent, newContent] = await Promise.all([
3780
- readFile6(join8(targetDir, file), "utf-8"),
3781
- readFile6(srcPath, "utf-8")
4309
+ readFile7(join9(targetDir, file), "utf-8"),
4310
+ readFile7(srcPath, "utf-8")
3782
4311
  ]);
3783
4312
  const result = merge3(oldVendorContent, currentContent, newContent);
3784
- await writeFile8(join8(targetDir, file), result.content, "utf-8");
4313
+ await writeFile9(join9(targetDir, file), result.content, "utf-8");
3785
4314
  fileHashes[file] = await hashFile(srcPath);
3786
4315
  if (result.hasConflict) {
3787
4316
  conflictedFiles.push(file);
@@ -3803,7 +4332,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3803
4332
  }
3804
4333
  }
3805
4334
  for (const dir of component.directories ?? []) {
3806
- const srcDirPath = join8(assetsDir, dir);
4335
+ const srcDirPath = join9(assetsDir, dir);
3807
4336
  if (options.dryRun) {
3808
4337
  console.log(` [dry-run] Would update directory: ${dir}/`);
3809
4338
  } else {
@@ -3856,12 +4385,29 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3856
4385
  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
4386
  );
3858
4387
  }
4388
+ let fanoutRecord;
4389
+ if (scope === "user") {
4390
+ const fanout = await syncUserTierFanout({
4391
+ homeDir,
4392
+ storeRoot: targetDir,
4393
+ spellIds: spellIdsInStore(updatedComponents),
4394
+ previous: manifest.fanout,
4395
+ dryRun: options.dryRun,
4396
+ // A dry run has restored nothing yet, so a missing store spell renders
4397
+ // from the vendor asset the real run would write (review finding F1).
4398
+ fallbackDir: assetsDir
4399
+ });
4400
+ const lines = describeFanoutOutcomes(fanout.outcomes, options.dryRun);
4401
+ if (lines.length > 0) console.log();
4402
+ for (const line of lines) console.log(` ${line}`);
4403
+ fanoutRecord = fanout.record;
4404
+ }
3859
4405
  if (options.dryRun) {
3860
4406
  console.log(
3861
4407
  `
3862
4408
  [dry-run] Would update ${fileCount} files.`
3863
4409
  );
3864
- const applicableRetrofits = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
4410
+ const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest)) : [];
3865
4411
  if (applicableRetrofits.length > 0) {
3866
4412
  console.log(
3867
4413
  `[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
@@ -3869,9 +4415,9 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3869
4415
  }
3870
4416
  return;
3871
4417
  }
3872
- const interactive = Boolean(process.stdin.isTTY);
4418
+ const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
3873
4419
  const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
3874
- if (!interactive) {
4420
+ if (!interactive && scope === "repo") {
3875
4421
  const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
3876
4422
  if (pending.length > 0) {
3877
4423
  console.log(
@@ -3883,7 +4429,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3883
4429
  ...manifest,
3884
4430
  version: packageVersion,
3885
4431
  components: updatedComponents,
3886
- ...retrofitPatch
4432
+ ...retrofitPatch,
4433
+ ...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {}
3887
4434
  };
3888
4435
  await writeManifest(targetDir, updated);
3889
4436
  console.log(
@@ -3898,15 +4445,18 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
3898
4445
  Search for "<<<<<<< yours" and resolve before committing.`
3899
4446
  );
3900
4447
  }
3901
- if (manifest.role !== "hub" && updated.role === "hub") {
4448
+ if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
3902
4449
  await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
3903
4450
  }
3904
4451
  }
3905
4452
 
4453
+ // src/commands/status.ts
4454
+ import { dirname as dirname6 } from "path";
4455
+
3906
4456
  // 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";
4457
+ import { readFile as readFile8, writeFile as writeFile10, mkdir as mkdir6 } from "fs/promises";
4458
+ import { dirname as dirname5, join as join10 } from "path";
4459
+ import { homedir as homedir3 } from "os";
3910
4460
  import { createRequire } from "module";
3911
4461
  var require2 = createRequire(import.meta.url);
3912
4462
  function getFeedUrl() {
@@ -3919,11 +4469,11 @@ function getFeedUrl() {
3919
4469
  }
3920
4470
  return url;
3921
4471
  }
3922
- var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
4472
+ var DEFAULT_CACHE_PATH = join10(homedir3(), ".arcane", "version-cache.json");
3923
4473
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
3924
4474
  async function readCache(cachePath) {
3925
4475
  try {
3926
- const content = await readFile7(cachePath, "utf-8");
4476
+ const content = await readFile8(cachePath, "utf-8");
3927
4477
  const parsed = JSON.parse(content);
3928
4478
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
3929
4479
  return null;
@@ -3934,9 +4484,9 @@ async function readCache(cachePath) {
3934
4484
  }
3935
4485
  }
3936
4486
  async function writeCache(cachePath, latest) {
3937
- await mkdir5(dirname2(cachePath), { recursive: true });
4487
+ await mkdir6(dirname5(cachePath), { recursive: true });
3938
4488
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3939
- await writeFile9(cachePath, JSON.stringify(entry, null, 2), "utf-8");
4489
+ await writeFile10(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3940
4490
  }
3941
4491
  async function getLatestVersion(feedUrl) {
3942
4492
  const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
@@ -4015,20 +4565,27 @@ function generateVersionDriftDiagram(repoFilesVersion, installedCliVersion, npmL
4015
4565
  }
4016
4566
 
4017
4567
  // src/commands/status.ts
4018
- async function runStatus(targetDir, packageVersion) {
4568
+ async function runStatus(targetDir, packageVersion, options = {}) {
4019
4569
  let manifest;
4020
4570
  try {
4021
4571
  manifest = await readManifest(targetDir);
4022
4572
  } catch (err) {
4023
4573
  if (err instanceof ManifestNotFoundError) {
4024
4574
  console.error(
4025
- 'Not initialized. Run "spell init" first.'
4575
+ `Not initialized. Run "spell init${options.user ? " --user" : ""}" first.`
4026
4576
  );
4027
4577
  process.exit(1);
4028
4578
  return;
4029
4579
  }
4030
4580
  throw err;
4031
4581
  }
4582
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4583
+ if (resolvedScope.misplacedUserManifest) {
4584
+ console.error(misplacedUserManifestMessage(targetDir));
4585
+ process.exit(1);
4586
+ return;
4587
+ }
4588
+ const scope = resolvedScope.scope;
4032
4589
  if (manifest.components.length === 0) {
4033
4590
  console.log("No components installed.");
4034
4591
  return;
@@ -4066,6 +4623,30 @@ async function runStatus(targetDir, packageVersion) {
4066
4623
  );
4067
4624
  }
4068
4625
  console.log("");
4626
+ if (scope === "user") {
4627
+ console.log(` Scope: user \u2014 ${targetDir}`);
4628
+ const health = await inspectUserTierFanout(dirname6(targetDir), manifest.fanout);
4629
+ console.log(
4630
+ ` Client files: ${health.total} (${health.byClient.codex} ${FANOUT_CLIENT_LABELS.codex}, ${health.byClient.claude} ${FANOUT_CLIENT_LABELS.claude})`
4631
+ );
4632
+ if (health.missing.length > 0 || health.customized.length > 0) {
4633
+ const parts = [];
4634
+ if (health.missing.length > 0) parts.push(`${health.missing.length} missing`);
4635
+ if (health.customized.length > 0) parts.push(`${health.customized.length} customized`);
4636
+ console.log(
4637
+ ` ! ${parts.join(", ")} \u2014 \`spell update --user\` regenerates missing files; customized files are never overwritten.`
4638
+ );
4639
+ }
4640
+ } else {
4641
+ console.log(" Scope: repo");
4642
+ try {
4643
+ const store = userTierRoot();
4644
+ const userManifest = await readManifest(store);
4645
+ console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
4646
+ } catch {
4647
+ }
4648
+ }
4649
+ console.log("");
4069
4650
  const latestStr = versionResult.error ? "unable to check" : versionResult.latest ?? manifest.version;
4070
4651
  let footer = ` Installed: ${packageVersion} Latest: ${latestStr}`;
4071
4652
  if (versionResult.updateAvailable) {
@@ -4092,11 +4673,16 @@ async function runStatus(targetDir, packageVersion) {
4092
4673
  }
4093
4674
  }
4094
4675
  }
4676
+ if (scope === "user") {
4677
+ console.log("");
4678
+ for (const line of VSCODE_USER_TIER_NOTE) console.log(` ${line}`);
4679
+ console.log(` ${CLAUDE_PRECEDENCE_NOTE}`);
4680
+ }
4095
4681
  }
4096
4682
 
4097
4683
  // src/commands/uninstall.ts
4098
- import { rm as rm4 } from "fs/promises";
4099
- import { join as join10 } from "path";
4684
+ import { rm as rm5 } from "fs/promises";
4685
+ import { dirname as dirname7, join as join11 } from "path";
4100
4686
  import { confirm as confirm3 } from "@inquirer/prompts";
4101
4687
  async function runUninstall(options, targetDir) {
4102
4688
  let manifest;
@@ -4104,12 +4690,22 @@ async function runUninstall(options, targetDir) {
4104
4690
  manifest = await readManifest(targetDir);
4105
4691
  } catch (err) {
4106
4692
  if (err instanceof ManifestNotFoundError) {
4107
- console.error('Not initialized. Run "spell init" first.');
4693
+ console.error(`Not initialized. Run "spell init${options.user ? " --user" : ""}" first.`);
4108
4694
  process.exit(1);
4109
4695
  return;
4110
4696
  }
4111
4697
  throw err;
4112
4698
  }
4699
+ const resolvedScope = resolveInstallScope(targetDir, manifest.scope, options.user);
4700
+ if (resolvedScope.misplacedUserManifest) {
4701
+ console.error(misplacedUserManifestMessage(targetDir));
4702
+ process.exit(1);
4703
+ return;
4704
+ }
4705
+ if (resolvedScope.scope === "user") {
4706
+ await uninstallUserTier(options, targetDir, manifest);
4707
+ return;
4708
+ }
4113
4709
  const policy = manifest.push_policy ?? "open";
4114
4710
  const controlsInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
4115
4711
  if ((policy === "blocked" || controlsInForce) && !options.dryRun) {
@@ -4159,24 +4755,24 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4159
4755
  let removed = 0;
4160
4756
  for (const component of manifest.components) {
4161
4757
  for (const file of component.files) {
4162
- const filePath = join10(targetDir, file);
4758
+ const filePath = join11(targetDir, file);
4163
4759
  try {
4164
- await rm4(filePath, { force: true });
4760
+ await rm5(filePath, { force: true });
4165
4761
  removed++;
4166
4762
  } catch {
4167
4763
  }
4168
4764
  }
4169
4765
  }
4170
- await rm4(join10(targetDir, ".arcane.json"), { force: true });
4766
+ await rm5(join11(targetDir, ".arcane.json"), { force: true });
4171
4767
  const agentDirs = [
4172
4768
  ".github/agents",
4173
4769
  ".arcane/agents",
4174
4770
  ".arcane/generated"
4175
4771
  ];
4176
4772
  for (const dir of agentDirs) {
4177
- await rm4(join10(targetDir, dir), { recursive: true, force: true });
4773
+ await rm5(join11(targetDir, dir), { recursive: true, force: true });
4178
4774
  }
4179
- await rm4(join10(targetDir, ".arcane/agents.yaml"), { force: true });
4775
+ await rm5(join11(targetDir, ".arcane/agents.yaml"), { force: true });
4180
4776
  const markerFiles = [
4181
4777
  "CLAUDE.md",
4182
4778
  "AGENTS.md",
@@ -4192,10 +4788,63 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
4192
4788
  console.log(`
4193
4789
  \u2713 Uninstalled \u2014 ${removed} files removed.`);
4194
4790
  }
4791
+ async function uninstallUserTier(options, storeRoot, manifest) {
4792
+ const homeDir = dirname7(storeRoot);
4793
+ const storeFiles = manifest.components.flatMap((c) => c.files);
4794
+ const recordedCount = Object.keys(manifest.fanout ?? {}).length;
4795
+ if (options.dryRun) {
4796
+ for (const file of storeFiles) console.log(`[dry-run] Would remove: ${storeRoot}/${file}`);
4797
+ const preview = await syncUserTierFanout({
4798
+ homeDir,
4799
+ storeRoot,
4800
+ spellIds: [],
4801
+ previous: manifest.fanout,
4802
+ dryRun: true
4803
+ });
4804
+ for (const line of describeFanoutOutcomes(preview.outcomes, true)) console.log(line);
4805
+ console.log(`[dry-run] Would remove: ${storeRoot}/.arcane.json`);
4806
+ console.log(
4807
+ `
4808
+ [dry-run] ${storeFiles.length} spell(s), up to ${recordedCount} client file(s) + manifest would be removed from the user tier.`
4809
+ );
4810
+ return;
4811
+ }
4812
+ if (!options.yes) {
4813
+ const confirmed = await confirm3({
4814
+ message: `This will remove ${storeFiles.length} spells, ${recordedCount} client files and .arcane.json from the user tier at ${storeRoot}. Continue?`,
4815
+ default: false
4816
+ });
4817
+ if (!confirmed) {
4818
+ console.log("Uninstall cancelled.");
4819
+ return;
4820
+ }
4821
+ }
4822
+ const fanout = await syncUserTierFanout({
4823
+ homeDir,
4824
+ storeRoot,
4825
+ spellIds: [],
4826
+ previous: manifest.fanout
4827
+ });
4828
+ let removed = 0;
4829
+ for (const file of storeFiles) {
4830
+ await rm5(join11(storeRoot, file), { force: true });
4831
+ removed++;
4832
+ }
4833
+ await rm5(join11(storeRoot, ".arcane.json"), { force: true });
4834
+ await removeDirectoryIfEmpty(join11(storeRoot, USER_TIER_SPELLS_DIR));
4835
+ await removeDirectoryIfEmpty(storeRoot);
4836
+ for (const line of describeFanoutOutcomes(fanout.outcomes)) console.log(` ${line}`);
4837
+ const pruned = fanout.outcomes.filter((o) => o.status === "pruned").length;
4838
+ const kept = fanout.outcomes.filter((o) => o.status === "kept-edited").length;
4839
+ console.log(
4840
+ `
4841
+ \u2713 Uninstalled the user tier \u2014 ${removed} spells and ${pruned} client files removed${kept > 0 ? `; ${kept} edited client file(s) kept` : ""}.`
4842
+ );
4843
+ }
4195
4844
 
4196
4845
  // 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";
4846
+ import { access as access4, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
4847
+ import { join as join13, dirname as dirname8 } from "path";
4199
4848
  import { execFile as execFile3 } from "child_process";
4200
4849
  import { promisify as promisify2 } from "util";
4201
4850
 
@@ -4265,8 +4914,8 @@ function evaluateIncidentGate(records) {
4265
4914
  }
4266
4915
 
4267
4916
  // src/modules/denylist-scan.ts
4268
- import { readFile as readFile8, readdir as readdir5 } from "fs/promises";
4269
- import { join as join11, relative } from "path";
4917
+ import { readFile as readFile9, readdir as readdir6 } from "fs/promises";
4918
+ import { join as join12, relative } from "path";
4270
4919
  function escapeRegExp(value) {
4271
4920
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4272
4921
  }
@@ -4283,7 +4932,7 @@ async function scanFile(absolutePath, displayPath, rules) {
4283
4932
  const findings = [];
4284
4933
  let content;
4285
4934
  try {
4286
- content = await readFile8(absolutePath, "utf8");
4935
+ content = await readFile9(absolutePath, "utf8");
4287
4936
  } catch {
4288
4937
  return findings;
4289
4938
  }
@@ -4312,12 +4961,12 @@ async function collectScannableFiles(root, repoRoot = root, extensions = DEFAULT
4312
4961
  const collected = [];
4313
4962
  let entries;
4314
4963
  try {
4315
- entries = await readdir5(root, { withFileTypes: true });
4964
+ entries = await readdir6(root, { withFileTypes: true });
4316
4965
  } catch {
4317
4966
  return collected;
4318
4967
  }
4319
4968
  for (const entry of entries) {
4320
- const absolutePath = join11(root, entry.name);
4969
+ const absolutePath = join12(root, entry.name);
4321
4970
  if (entry.isDirectory()) {
4322
4971
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
4323
4972
  collected.push(...await collectScannableFiles(absolutePath, repoRoot, extensions));
@@ -4529,13 +5178,13 @@ async function checkVSCodeExtension(id, label) {
4529
5178
  const name = `VS Code extension: ${label}`;
4530
5179
  const home = process.env["HOME"] ?? "";
4531
5180
  const extDirs = [
4532
- join12(home, ".vscode", "extensions"),
4533
- join12(home, ".vscode-insiders", "extensions")
5181
+ join13(home, ".vscode", "extensions"),
5182
+ join13(home, ".vscode-insiders", "extensions")
4534
5183
  ];
4535
5184
  for (const dir of extDirs) {
4536
5185
  try {
4537
- const { readdir: readdir8 } = await import("fs/promises");
4538
- const entries = await readdir8(dir);
5186
+ const { readdir: readdir9 } = await import("fs/promises");
5187
+ const entries = await readdir9(dir);
4539
5188
  const prefix = id.toLowerCase() + "-";
4540
5189
  const found = entries.some(
4541
5190
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -4564,13 +5213,13 @@ async function checkVSCodeExtension(id, label) {
4564
5213
  }
4565
5214
  async function checkArcaneManifest(targetDir) {
4566
5215
  const name = "Arcane manifest (.arcane.json)";
4567
- const manifestPath2 = join12(targetDir, ".arcane.json");
5216
+ const manifestPath2 = join13(targetDir, ".arcane.json");
4568
5217
  try {
4569
5218
  await access4(manifestPath2);
4570
5219
  } catch {
4571
- const selfHostedManifestPath = join12(targetDir, "src", "assets", ".arcane.json");
5220
+ const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
4572
5221
  try {
4573
- const raw = await readFile9(selfHostedManifestPath, "utf8");
5222
+ const raw = await readFile10(selfHostedManifestPath, "utf8");
4574
5223
  const manifest = JSON.parse(raw);
4575
5224
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
4576
5225
  return {
@@ -4589,7 +5238,7 @@ async function checkArcaneManifest(targetDir) {
4589
5238
  };
4590
5239
  }
4591
5240
  try {
4592
- const raw = await readFile9(manifestPath2, "utf8");
5241
+ const raw = await readFile10(manifestPath2, "utf8");
4593
5242
  const manifest = JSON.parse(raw);
4594
5243
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
4595
5244
  return {
@@ -4674,7 +5323,7 @@ async function checkSessionContinuity(targetDir) {
4674
5323
  const results = [];
4675
5324
  for (const file of SESSION_CONTINUITY_FILES) {
4676
5325
  const name = `Session file: ${file}`;
4677
- const filePath = join12(targetDir, file);
5326
+ const filePath = join13(targetDir, file);
4678
5327
  try {
4679
5328
  await access4(filePath);
4680
5329
  results.push({ name, passed: true, message: "present" });
@@ -4692,18 +5341,82 @@ async function checkSessionContinuity(targetDir) {
4692
5341
  async function fixSessionContinuity(targetDir, assetsDir) {
4693
5342
  const created = [];
4694
5343
  for (const file of SESSION_CONTINUITY_FILES) {
4695
- const destPath = join12(targetDir, file);
5344
+ const destPath = join13(targetDir, file);
4696
5345
  try {
4697
5346
  await access4(destPath);
4698
5347
  } catch {
4699
- const srcPath = join12(assetsDir, file);
4700
- await mkdir6(dirname3(destPath), { recursive: true });
5348
+ const srcPath = join13(assetsDir, file);
5349
+ await mkdir7(dirname8(destPath), { recursive: true });
4701
5350
  await fsCopyFile(srcPath, destPath);
4702
5351
  created.push(file);
4703
5352
  }
4704
5353
  }
4705
5354
  return created;
4706
5355
  }
5356
+ async function checkUserTier(homeDir = resolveHomeDir(), cliVersion) {
5357
+ const name = "User tier (ARC-045)";
5358
+ const storeRoot = userTierRoot(homeDir);
5359
+ let manifest;
5360
+ try {
5361
+ manifest = await readManifest(storeRoot);
5362
+ } catch (err) {
5363
+ if (err instanceof ManifestNotFoundError) {
5364
+ return {
5365
+ name,
5366
+ passed: true,
5367
+ blocking: false,
5368
+ message: "no user tier installed (optional \u2014 `spell init --user` shares every spell across the repositories on this machine)"
5369
+ };
5370
+ }
5371
+ return {
5372
+ name,
5373
+ passed: false,
5374
+ blocking: false,
5375
+ message: `could not read ${storeRoot}/.arcane.json (${err instanceof Error ? err.message : String(err)}) \u2014 remove it and re-run \`spell init --user\``
5376
+ };
5377
+ }
5378
+ try {
5379
+ if (typeof manifest.version !== "string" || manifest.version.length === 0) {
5380
+ return {
5381
+ name,
5382
+ passed: false,
5383
+ blocking: false,
5384
+ message: `${storeRoot}/.arcane.json has no version field \u2014 remove the store and re-run \`spell init --user\``
5385
+ };
5386
+ }
5387
+ const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
5388
+ const problems = [];
5389
+ if (cliVersion !== void 0 && majorMinor(manifest.version) !== majorMinor(cliVersion)) {
5390
+ problems.push(`installed at v${manifest.version}, this CLI is v${cliVersion}`);
5391
+ }
5392
+ const health = await inspectUserTierFanout(homeDir, manifest.fanout);
5393
+ if (health.missing.length > 0) {
5394
+ problems.push(`${health.missing.length} of ${health.total} client file(s) missing`);
5395
+ }
5396
+ if (problems.length > 0) {
5397
+ return {
5398
+ name,
5399
+ passed: false,
5400
+ blocking: false,
5401
+ message: `${problems.join("; ")} \u2014 run \`spell update --user\` (regenerates missing files; never overwrites edited ones)`
5402
+ };
5403
+ }
5404
+ const customized = health.customized.length > 0 ? `, ${health.customized.length} customized` : "";
5405
+ return {
5406
+ name,
5407
+ passed: true,
5408
+ blocking: false,
5409
+ message: `v${manifest.version} at ${storeRoot} \u2014 ${health.total} client file(s) in place${customized}`
5410
+ };
5411
+ } catch (err) {
5412
+ return {
5413
+ name,
5414
+ passed: false,
5415
+ blocking: false,
5416
+ 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\``
5417
+ };
5418
+ }
5419
+ }
4707
5420
  async function checkPushPolicy(targetDir) {
4708
5421
  const name = "Push policy (EF-09)";
4709
5422
  let manifest;
@@ -4757,10 +5470,10 @@ async function checkPushPolicy(targetDir) {
4757
5470
  }
4758
5471
  async function checkMcpConfig(targetDir) {
4759
5472
  const name = "MCP config (per-server timeout, I12)";
4760
- const filePath = join12(targetDir, ".mcp.json");
5473
+ const filePath = join13(targetDir, ".mcp.json");
4761
5474
  let raw;
4762
5475
  try {
4763
- raw = await readFile9(filePath, "utf8");
5476
+ raw = await readFile10(filePath, "utf8");
4764
5477
  } catch {
4765
5478
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
4766
5479
  }
@@ -4813,10 +5526,10 @@ function isDelegationsFile(value) {
4813
5526
  }
4814
5527
  async function checkDelegations(targetDir) {
4815
5528
  const name = "Standing delegations (T13)";
4816
- const filePath = join12(targetDir, ".arcane", "delegations.json");
5529
+ const filePath = join13(targetDir, ".arcane", "delegations.json");
4817
5530
  let raw;
4818
5531
  try {
4819
- raw = await readFile9(filePath, "utf8");
5532
+ raw = await readFile10(filePath, "utf8");
4820
5533
  } catch {
4821
5534
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
4822
5535
  }
@@ -4919,7 +5632,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4919
5632
  checkPushPolicy(targetDir),
4920
5633
  checkPlatformBranchPolicy(targetDir),
4921
5634
  checkDelegations(targetDir),
4922
- checkMcpConfig(targetDir)
5635
+ checkMcpConfig(targetDir),
5636
+ checkUserTier(resolveHomeDir(), options.packageVersion)
4923
5637
  ]);
4924
5638
  const sessionResults = await checkSessionContinuity(targetDir);
4925
5639
  results.push(...sessionResults);
@@ -4964,16 +5678,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
4964
5678
  }
4965
5679
 
4966
5680
  // src/commands/ward.ts
4967
- import { readFile as readFile11 } from "fs/promises";
5681
+ import { readFile as readFile12 } from "fs/promises";
4968
5682
 
4969
5683
  // 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";
5684
+ import { readdir as readdir7, readFile as readFile11 } from "fs/promises";
5685
+ import { join as join14, relative as relative2 } from "path";
4972
5686
  async function scanFileAllRules(absolutePath, displayPath, rules) {
4973
5687
  const findings = [];
4974
5688
  let content;
4975
5689
  try {
4976
- content = await readFile10(absolutePath, "utf8");
5690
+ content = await readFile11(absolutePath, "utf8");
4977
5691
  } catch {
4978
5692
  return findings;
4979
5693
  }
@@ -5026,12 +5740,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
5026
5740
  const findings = [];
5027
5741
  let entries;
5028
5742
  try {
5029
- entries = await readdir6(root, { withFileTypes: true });
5743
+ entries = await readdir7(root, { withFileTypes: true });
5030
5744
  } catch {
5031
5745
  return findings;
5032
5746
  }
5033
5747
  for (const entry of entries) {
5034
- const absolutePath = join13(root, entry.name);
5748
+ const absolutePath = join14(root, entry.name);
5035
5749
  if (entry.isDirectory()) {
5036
5750
  if (SKIP_DIRECTORIES.has(entry.name)) continue;
5037
5751
  findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
@@ -5068,7 +5782,7 @@ async function runWard(repoRoot, options = {}) {
5068
5782
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
5069
5783
  if (findings2.length > 0) {
5070
5784
  try {
5071
- fileLines.set(displayPath, (await readFile10(absolutePath, "utf8")).split("\n"));
5785
+ fileLines.set(displayPath, (await readFile11(absolutePath, "utf8")).split("\n"));
5072
5786
  } catch {
5073
5787
  }
5074
5788
  }
@@ -5096,7 +5810,7 @@ function parseTermsFileContent(raw) {
5096
5810
  }
5097
5811
  async function readTermsFile(filePath) {
5098
5812
  try {
5099
- return parseTermsFileContent(await readFile11(filePath, "utf8"));
5813
+ return parseTermsFileContent(await readFile12(filePath, "utf8"));
5100
5814
  } catch (error) {
5101
5815
  console.warn(
5102
5816
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -5146,7 +5860,7 @@ async function runWardCli(targetDir, options = {}, exclusions = []) {
5146
5860
  }
5147
5861
 
5148
5862
  // src/commands/unblock-push.ts
5149
- import { basename as basename2, resolve as resolve2 } from "path";
5863
+ import { basename as basename2, resolve as resolve3 } from "path";
5150
5864
  import { input as input4 } from "@inquirer/prompts";
5151
5865
  async function runUnblockPush(targetDir) {
5152
5866
  let manifest;
@@ -5178,7 +5892,7 @@ async function runUnblockPush(targetDir) {
5178
5892
  process.exit(1);
5179
5893
  return;
5180
5894
  }
5181
- const repoName = basename2(resolve2(targetDir));
5895
+ const repoName = basename2(resolve3(targetDir));
5182
5896
  if (repoName === "") {
5183
5897
  console.error(
5184
5898
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -5245,15 +5959,15 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
5245
5959
 
5246
5960
  // src/commands/report.ts
5247
5961
  import { access as access5 } from "fs/promises";
5248
- import { join as join16 } from "path";
5962
+ import { join as join17 } from "path";
5249
5963
 
5250
5964
  // 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";
5965
+ import { mkdir as mkdir8, readdir as readdir8, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
5966
+ import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
5253
5967
 
5254
5968
  // src/modules/show-report/model.ts
5255
- import { readFile as readFile12 } from "fs/promises";
5256
- import { join as join14 } from "path";
5969
+ import { readFile as readFile13 } from "fs/promises";
5970
+ import { join as join15 } from "path";
5257
5971
 
5258
5972
  // src/modules/show-report/plan-parser.ts
5259
5973
  function parseFrontmatter(content) {
@@ -5521,7 +6235,7 @@ async function getCast(cwd, fromRef, toRef) {
5521
6235
  // src/modules/show-report/model.ts
5522
6236
  async function readOptional(path4) {
5523
6237
  try {
5524
- return await readFile12(path4, "utf8");
6238
+ return await readFile13(path4, "utf8");
5525
6239
  } catch {
5526
6240
  return null;
5527
6241
  }
@@ -5617,13 +6331,13 @@ function buildCorrections(ledgerRows, programName) {
5617
6331
  };
5618
6332
  }
5619
6333
  async function buildShowReportModel(options) {
5620
- const planAbsPath = join14(options.rootDir, options.planRelPath);
5621
- const queueAbsPath = join14(options.rootDir, options.queueRelPath);
6334
+ const planAbsPath = join15(options.rootDir, options.planRelPath);
6335
+ const queueAbsPath = join15(options.rootDir, options.queueRelPath);
5622
6336
  const ledgerRelPath = "docs/verification-ledger.md";
5623
- const ledgerAbsPath = join14(options.rootDir, ledgerRelPath);
5624
- const decisionsAbsPath = join14(options.rootDir, "DECISIONS.md");
6337
+ const ledgerAbsPath = join15(options.rootDir, ledgerRelPath);
6338
+ const decisionsAbsPath = join15(options.rootDir, "DECISIONS.md");
5625
6339
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
5626
- readFile12(planAbsPath, "utf8"),
6340
+ readFile13(planAbsPath, "utf8"),
5627
6341
  readOptional(queueAbsPath),
5628
6342
  readOptional(ledgerAbsPath),
5629
6343
  readOptional(decisionsAbsPath)
@@ -5786,7 +6500,7 @@ function toPosix(path4) {
5786
6500
  async function programFromPlan(rootDir, slug, planRelPath) {
5787
6501
  let planContent;
5788
6502
  try {
5789
- planContent = await readFile13(join15(rootDir, planRelPath), "utf8");
6503
+ planContent = await readFile14(join16(rootDir, planRelPath), "utf8");
5790
6504
  } catch {
5791
6505
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
5792
6506
  }
@@ -5798,16 +6512,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
5798
6512
  program: {
5799
6513
  slug,
5800
6514
  planRelPath,
5801
- queueRelPath: toPosix(join15(dirname4(planRelPath), "OPERATOR-QUEUE.md")),
6515
+ queueRelPath: toPosix(join16(dirname9(planRelPath), "OPERATOR-QUEUE.md")),
5802
6516
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
5803
6517
  }
5804
6518
  };
5805
6519
  }
5806
6520
  async function discoverPrograms(rootDir) {
5807
- const plansDir = join15(rootDir, "docs", "plans");
6521
+ const plansDir = join16(rootDir, "docs", "plans");
5808
6522
  let entries;
5809
6523
  try {
5810
- entries = (await readdir7(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
6524
+ entries = (await readdir8(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
5811
6525
  } catch (error) {
5812
6526
  if (error.code === "ENOENT") return { programs: [], skipped: [] };
5813
6527
  throw error;
@@ -5822,23 +6536,23 @@ async function discoverPrograms(rootDir) {
5822
6536
  return { programs, skipped };
5823
6537
  }
5824
6538
  async function programFromPlanPath(rootDir, planPath) {
5825
- const planAbs = isAbsolute(planPath) ? planPath : resolve3(rootDir, planPath);
6539
+ const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
5826
6540
  const planRelPath = toPosix(relative3(rootDir, planAbs));
5827
- return programFromPlan(rootDir, basename3(dirname4(planAbs)), planRelPath);
6541
+ return programFromPlan(rootDir, basename3(dirname9(planAbs)), planRelPath);
5828
6542
  }
5829
6543
  function normalizeLineEndings(content) {
5830
6544
  return content.replace(/\r\n?/g, "\n");
5831
6545
  }
5832
6546
  async function readOptionalFile(path4) {
5833
6547
  try {
5834
- return await readFile13(path4, "utf8");
6548
+ return await readFile14(path4, "utf8");
5835
6549
  } catch (error) {
5836
6550
  if (error.code === "ENOENT") return null;
5837
6551
  throw error;
5838
6552
  }
5839
6553
  }
5840
6554
  async function generateReport(rootDir, program2, templateHtml) {
5841
- const planContent = await readFile13(join15(rootDir, program2.planRelPath), "utf8");
6555
+ const planContent = await readFile14(join16(rootDir, program2.planRelPath), "utf8");
5842
6556
  const frontmatter = parseFrontmatter(planContent);
5843
6557
  const model = await buildShowReportModel({
5844
6558
  rootDir,
@@ -5856,7 +6570,7 @@ async function generateReport(rootDir, program2, templateHtml) {
5856
6570
  };
5857
6571
  }
5858
6572
  async function runReportGeneration(options) {
5859
- const templateHtml = await readFile13(options.templatePath, "utf8");
6573
+ const templateHtml = await readFile14(options.templatePath, "utf8");
5860
6574
  const drifted = [];
5861
6575
  const repaired = [];
5862
6576
  const unwritten = [];
@@ -5864,19 +6578,19 @@ async function runReportGeneration(options) {
5864
6578
  const generated = await generateReport(options.rootDir, program2, templateHtml);
5865
6579
  const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
5866
6580
  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));
6581
+ const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname9(program2.planRelPath));
5868
6582
  const outRel = toPosix(relative3(options.rootDir, outAbs));
5869
6583
  const outputs = [
5870
6584
  [`${outRel}/show-report.json`, generated.json],
5871
6585
  [`${outRel}/show-report.html`, generated.html]
5872
6586
  ];
5873
6587
  for (const [relPath, expected] of outputs) {
5874
- const actual = await readOptionalFile(join15(options.rootDir, relPath));
6588
+ const actual = await readOptionalFile(join16(options.rootDir, relPath));
5875
6589
  if (actual === null || normalizeLineEndings(actual) !== normalizeLineEndings(expected)) {
5876
6590
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
5877
6591
  if (options.mode === "write") {
5878
- await mkdir7(outAbs, { recursive: true });
5879
- await writeFile10(join15(options.rootDir, relPath), expected, "utf8");
6592
+ await mkdir8(outAbs, { recursive: true });
6593
+ await writeFile11(join16(options.rootDir, relPath), expected, "utf8");
5880
6594
  repaired.push(relPath);
5881
6595
  }
5882
6596
  }
@@ -5887,7 +6601,7 @@ async function runReportGeneration(options) {
5887
6601
 
5888
6602
  // src/commands/report.ts
5889
6603
  async function runReport(targetDir, options = {}, assetsDir) {
5890
- const templatePath = join16(assetsDir, "report", "show-report.template.html");
6604
+ const templatePath = join17(assetsDir, "report", "show-report.template.html");
5891
6605
  try {
5892
6606
  await access5(templatePath);
5893
6607
  } catch {
@@ -5950,8 +6664,12 @@ async function runReport(targetDir, options = {}, assetsDir) {
5950
6664
  import { realpathSync } from "fs";
5951
6665
  var require3 = createRequire2(import.meta.url);
5952
6666
  var pkg = require3("../package.json");
5953
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join17(dirname5(fileURLToPath(import.meta.url)), "assets");
6667
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join18(dirname10(fileURLToPath(import.meta.url)), "assets");
5954
6668
  var program = new Command();
6669
+ 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)";
6670
+ function targetDirFor(opts) {
6671
+ return opts.user ? userTierRoot() : process.cwd();
6672
+ }
5955
6673
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
5956
6674
  if (program.args.length > 0) {
5957
6675
  const [attempted] = program.args;
@@ -5971,15 +6689,16 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
5971
6689
  await printWelcome(pkg.version, process.cwd());
5972
6690
  program.outputHelp();
5973
6691
  });
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(
6692
+ 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
6693
  async (opts) => {
5976
6694
  await runInit(
5977
6695
  {
5978
6696
  profile: opts.profile,
5979
6697
  force: opts.force,
5980
- dryRun: opts.dryRun
6698
+ dryRun: opts.dryRun,
6699
+ user: opts.user
5981
6700
  },
5982
- process.cwd(),
6701
+ targetDirFor(opts),
5983
6702
  ASSETS_DIR,
5984
6703
  pkg.version
5985
6704
  );
@@ -5999,25 +6718,29 @@ program.command("add <component>").description("Add a component to an existing A
5999
6718
  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
6719
  "--prune",
6001
6720
  "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) => {
6721
+ ).option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6003
6722
  await runUpdate(
6004
- { dryRun: opts.dryRun, prune: opts.prune },
6005
- process.cwd(),
6723
+ { dryRun: opts.dryRun, prune: opts.prune, user: opts.user },
6724
+ targetDirFor(opts),
6006
6725
  ASSETS_DIR,
6007
6726
  pkg.version
6008
6727
  );
6009
6728
  });
6010
- program.command("status").description("Show installed components and check for updates").action(async () => {
6011
- await runStatus(process.cwd(), pkg.version);
6729
+ program.command("status").description("Show installed components and check for updates").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
6730
+ await runStatus(targetDirFor(opts), pkg.version, { user: opts.user });
6012
6731
  });
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());
6732
+ 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) => {
6733
+ await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
6015
6734
  });
6016
6735
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
6017
6736
  await runUnblockPush(process.cwd());
6018
6737
  });
6019
6738
  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);
6739
+ await runDoctor(
6740
+ process.cwd(),
6741
+ { fix: opts.fix, leaks: opts.leaks, packageVersion: pkg.version },
6742
+ ASSETS_DIR
6743
+ );
6021
6744
  });
6022
6745
  program.command("ward").description("Scan the repo for leaked identifiers -- third-party trademarks and the operator's own personal identifiers alike (T18)").option(
6023
6746
  "--terms <terms>",