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/README.md +13 -0
- package/dist/assets/.arcane/governance/portable-bootstrap.md +1 -1
- package/dist/index.js +951 -228
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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((
|
|
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
|
|
966
|
-
import { join as
|
|
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
|
|
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
|
|
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 =
|
|
1800
|
+
const filePath = join2(agentsDir, `${id}.yaml`);
|
|
1392
1801
|
let content;
|
|
1393
1802
|
try {
|
|
1394
|
-
content = await
|
|
1803
|
+
content = await readFile4(filePath, "utf8");
|
|
1395
1804
|
} catch {
|
|
1396
1805
|
throw new AgentDefinitionNotFoundError(id, agentsDir);
|
|
1397
1806
|
}
|
|
1398
|
-
return validateAgentDefinition(
|
|
1807
|
+
return validateAgentDefinition(parseYaml2(content, filePath), filePath);
|
|
1399
1808
|
}
|
|
1400
1809
|
async function loadRoster(targetDir) {
|
|
1401
|
-
const rosterPath =
|
|
1810
|
+
const rosterPath = join2(targetDir, ".arcane", "agents.yaml");
|
|
1402
1811
|
let content;
|
|
1403
1812
|
try {
|
|
1404
|
-
content = await
|
|
1813
|
+
content = await readFile4(rosterPath, "utf8");
|
|
1405
1814
|
} catch {
|
|
1406
1815
|
throw new AgentRosterNotFoundError();
|
|
1407
1816
|
}
|
|
1408
|
-
return validateAgentRoster(
|
|
1817
|
+
return validateAgentRoster(parseYaml2(content, rosterPath), rosterPath);
|
|
1409
1818
|
}
|
|
1410
1819
|
async function rosterExists(targetDir) {
|
|
1411
1820
|
try {
|
|
1412
|
-
await access2(
|
|
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
|
|
1828
|
+
return join2(targetDir, ".arcane", "agents");
|
|
1420
1829
|
}
|
|
1421
1830
|
|
|
1422
1831
|
// src/modules/agent-generator.ts
|
|
1423
|
-
import { writeFile as
|
|
1424
|
-
import { join as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1923
|
+
await rm2(destPath, { force: true });
|
|
1515
1924
|
} else {
|
|
1516
|
-
await
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
2075
|
+
const workspaceDir = join3(openclawRoot, workspaceName);
|
|
1667
2076
|
try {
|
|
1668
2077
|
if (!options.dryRun) {
|
|
1669
|
-
await
|
|
1670
|
-
await
|
|
1671
|
-
|
|
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
|
|
1676
|
-
|
|
2084
|
+
await writeFile5(
|
|
2085
|
+
join3(workspaceDir, "SOUL.md"),
|
|
1677
2086
|
renderSoul(def, displayName),
|
|
1678
2087
|
"utf8"
|
|
1679
2088
|
);
|
|
1680
|
-
await
|
|
1681
|
-
|
|
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 =
|
|
2115
|
+
const generatedDir = join3(targetDir, ".arcane", "generated");
|
|
1707
2116
|
if (!options.dryRun) {
|
|
1708
|
-
await
|
|
1709
|
-
await
|
|
1710
|
-
|
|
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 =
|
|
2131
|
+
const agentsDir = join3(targetDir, ".github", "agents");
|
|
1723
2132
|
if (!options.dryRun) {
|
|
1724
|
-
await
|
|
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
|
|
1732
|
-
|
|
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 =
|
|
2282
|
+
const arcaneDir = join4(targetDir, ".arcane", "agents");
|
|
1874
2283
|
if (!options.dryRun) {
|
|
1875
|
-
await
|
|
1876
|
-
await
|
|
1877
|
-
|
|
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 =
|
|
1886
|
-
const dest =
|
|
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
|
|
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
|
|
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((
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
2742
|
+
return join5(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
|
|
2334
2743
|
}
|
|
2335
2744
|
async function hooksPathValue(cwd) {
|
|
2336
|
-
return
|
|
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 =
|
|
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
|
|
2360
|
-
await
|
|
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 =
|
|
2813
|
+
hooksDir = join5(resolve2(cwd, stdout.trim()), "hooks");
|
|
2405
2814
|
} catch {
|
|
2406
2815
|
return [];
|
|
2407
2816
|
}
|
|
2408
2817
|
try {
|
|
2409
|
-
return (await
|
|
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
|
|
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
|
|
2846
|
+
await rm3(await hookFilePath(cwd, hookName), { force: true });
|
|
2438
2847
|
} catch {
|
|
2439
2848
|
}
|
|
2440
|
-
const hooksDir =
|
|
2849
|
+
const hooksDir = join5(root, ARCANE_HOOKS_DIR);
|
|
2441
2850
|
let remaining = [];
|
|
2442
2851
|
try {
|
|
2443
|
-
remaining = await
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
3246
|
-
import { readFile as
|
|
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
|
|
3251
|
-
import { join as
|
|
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 =
|
|
3904
|
+
const registryPath = join8(targetDir, businessRoot, "registry.json");
|
|
3391
3905
|
if (await fileExists(registryPath)) return;
|
|
3392
|
-
const businessRootPath =
|
|
3906
|
+
const businessRootPath = join8(targetDir, businessRoot);
|
|
3393
3907
|
let ventureDirs;
|
|
3394
3908
|
try {
|
|
3395
|
-
const entries = await
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3659
|
-
|
|
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
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
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
|
-
|
|
3677
|
-
|
|
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
|
-
|
|
3684
|
-
|
|
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 =
|
|
3736
|
-
const targetExists = await fileExists(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3781
|
-
|
|
4309
|
+
readFile7(join9(targetDir, file), "utf-8"),
|
|
4310
|
+
readFile7(srcPath, "utf-8")
|
|
3782
4311
|
]);
|
|
3783
4312
|
const result = merge3(oldVendorContent, currentContent, newContent);
|
|
3784
|
-
await
|
|
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 =
|
|
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
|
|
3908
|
-
import { dirname as
|
|
3909
|
-
import { homedir as
|
|
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 =
|
|
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
|
|
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
|
|
4487
|
+
await mkdir6(dirname5(cachePath), { recursive: true });
|
|
3938
4488
|
const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3939
|
-
await
|
|
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
|
-
|
|
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
|
|
4099
|
-
import { join as
|
|
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(
|
|
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 =
|
|
4758
|
+
const filePath = join11(targetDir, file);
|
|
4163
4759
|
try {
|
|
4164
|
-
await
|
|
4760
|
+
await rm5(filePath, { force: true });
|
|
4165
4761
|
removed++;
|
|
4166
4762
|
} catch {
|
|
4167
4763
|
}
|
|
4168
4764
|
}
|
|
4169
4765
|
}
|
|
4170
|
-
await
|
|
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
|
|
4773
|
+
await rm5(join11(targetDir, dir), { recursive: true, force: true });
|
|
4178
4774
|
}
|
|
4179
|
-
await
|
|
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
|
|
4198
|
-
import { join as
|
|
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
|
|
4269
|
-
import { join as
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
4533
|
-
|
|
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:
|
|
4538
|
-
const entries = await
|
|
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 =
|
|
5216
|
+
const manifestPath2 = join13(targetDir, ".arcane.json");
|
|
4568
5217
|
try {
|
|
4569
5218
|
await access4(manifestPath2);
|
|
4570
5219
|
} catch {
|
|
4571
|
-
const selfHostedManifestPath =
|
|
5220
|
+
const selfHostedManifestPath = join13(targetDir, "src", "assets", ".arcane.json");
|
|
4572
5221
|
try {
|
|
4573
|
-
const raw = await
|
|
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
|
|
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 =
|
|
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 =
|
|
5344
|
+
const destPath = join13(targetDir, file);
|
|
4696
5345
|
try {
|
|
4697
5346
|
await access4(destPath);
|
|
4698
5347
|
} catch {
|
|
4699
|
-
const srcPath =
|
|
4700
|
-
await
|
|
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 =
|
|
5473
|
+
const filePath = join13(targetDir, ".mcp.json");
|
|
4761
5474
|
let raw;
|
|
4762
5475
|
try {
|
|
4763
|
-
raw = await
|
|
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 =
|
|
5529
|
+
const filePath = join13(targetDir, ".arcane", "delegations.json");
|
|
4817
5530
|
let raw;
|
|
4818
5531
|
try {
|
|
4819
|
-
raw = await
|
|
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
|
|
5681
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
4968
5682
|
|
|
4969
5683
|
// src/modules/ward.ts
|
|
4970
|
-
import { readdir as
|
|
4971
|
-
import { join as
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
5962
|
+
import { join as join17 } from "path";
|
|
5249
5963
|
|
|
5250
5964
|
// src/modules/show-report/generate.ts
|
|
5251
|
-
import { mkdir as
|
|
5252
|
-
import { basename as basename3, dirname as
|
|
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
|
|
5256
|
-
import { join as
|
|
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
|
|
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 =
|
|
5621
|
-
const queueAbsPath =
|
|
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 =
|
|
5624
|
-
const decisionsAbsPath =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
6521
|
+
const plansDir = join16(rootDir, "docs", "plans");
|
|
5808
6522
|
let entries;
|
|
5809
6523
|
try {
|
|
5810
|
-
entries = (await
|
|
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 :
|
|
6539
|
+
const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
|
|
5826
6540
|
const planRelPath = toPosix(relative3(rootDir, planAbs));
|
|
5827
|
-
return programFromPlan(rootDir, basename3(
|
|
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
|
|
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
|
|
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
|
|
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 :
|
|
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(
|
|
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
|
|
5879
|
-
await
|
|
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 =
|
|
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"] ??
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 },
|
|
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(
|
|
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>",
|