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