arcane-cli 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/dist/assets/.arcane/governance/portable-bootstrap.md +1 -1
- package/dist/index.js +199 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -231,7 +231,24 @@ spell update --user # after upgrading the CLI
|
|
|
231
231
|
spell uninstall --user # removes only what it wrote; an edited client file is kept and named
|
|
232
232
|
```
|
|
233
233
|
|
|
234
|
-
Codex, VS Code Copilot and Claude Code each discover the user tier from their own home-directory locations (`~/.agents/skills` and `~/.claude/commands`), so no VS Code setting is needed.
|
|
234
|
+
Codex, VS Code Copilot and Claude Code each discover the user tier from their own home-directory locations (`~/.agents/skills` and `~/.claude/commands`), so no VS Code setting is needed. Governance stays per repository — only spell delivery moves up a level.
|
|
235
|
+
|
|
236
|
+
**Then let a repository stop carrying its own copies.** `spell init` offers this when the machine already has a tier, and an existing repository opts in by setting one field:
|
|
237
|
+
|
|
238
|
+
```jsonc
|
|
239
|
+
// .arcane.json
|
|
240
|
+
{ "spell_scope": "user" } // absent, or "repo", means this repository carries its own spells
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
spell update # lists the spell files this repository no longer manages — deletes nothing
|
|
245
|
+
spell update --prune # removes the untouched ones; anything you edited is kept and named
|
|
246
|
+
spell doctor # fails loudly if the user tier it now depends on is missing
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
This is what removes the duplication: with the repository's own copies gone, one workspace holding ten Arcane projects shows one set of `/spell-*` entries, not ten. It is also what makes the answer predictable — while both tiers own a command name, which copy a client runs is not something you can read off the docs.
|
|
250
|
+
|
|
251
|
+
**Shared, team and cloud repositories should stay `repo`.** The field is committed and inherited by every clone, so opting in tells *everyone* who clones that repository to get their spells from a machine-wide install they may not have. Reverse it by setting the field back and running `spell update`, which reinstalls every spell file.
|
|
235
252
|
|
|
236
253
|
---
|
|
237
254
|
|
|
@@ -52,7 +52,7 @@ Paste this entire file into any AI client (Claude web, ChatGPT, etc.) to give it
|
|
|
52
52
|
## Where Documents Live
|
|
53
53
|
|
|
54
54
|
- **Framework-managed standards:** `.arcane/governance/`. Spells reference this single installed layer; **do not create a duplicate root `governance/` tree. Enforcement: explicitly advisory prose (ARC-023) — no check scans a repo for a duplicate root-level `governance/` directory.**
|
|
55
|
-
- **Framework-managed spells:** `.arcane/spells/<id>.md` is each spell's one authored source (ARC-045). The files each AI client discovers — `.github/prompts/<id>.prompt.md` for Copilot, `.claude/commands/<id>.md` for Claude Code, `.agents/skills/<id>/SKILL.md` for Codex — are thin generated shims that point back at it; edit the canonical file, never a shim. **Enforcement: executable check (ARC-023) — `npm run check:self-host-parity` re-renders every shim from its canonical file and fails on any difference; `spell update` keeps a hand-edited shim untouched and names it rather than merging into it.** The same canonical spells can also be installed once per machine — `spell init --user` puts them at `~/.arcane/spells/<id>.md` with one Codex/Copilot skill under `~/.agents/skills/` and one Claude Code command under `~/.claude/commands/` per spell (ARC-045 decision 3); governance never moves up a tier.
|
|
55
|
+
- **Framework-managed spells:** `.arcane/spells/<id>.md` is each spell's one authored source (ARC-045). The files each AI client discovers — `.github/prompts/<id>.prompt.md` for Copilot, `.claude/commands/<id>.md` for Claude Code, `.agents/skills/<id>/SKILL.md` for Codex — are thin generated shims that point back at it; edit the canonical file, never a shim. **Enforcement: executable check (ARC-023) — `npm run check:self-host-parity` re-renders every shim from its canonical file and fails on any difference; `spell update` keeps a hand-edited shim untouched and names it rather than merging into it.** The same canonical spells can also be installed once per machine — `spell init --user` puts them at `~/.arcane/spells/<id>.md` with one Codex/Copilot skill under `~/.agents/skills/` and one Claude Code command under `~/.claude/commands/` per spell (ARC-045 decision 3); governance never moves up a tier. A repository may then opt out of carrying its own copies entirely with `spell_scope: "user"` in `.arcane.json` (ARC-045 decision 4) — `spell update --prune` removes what it no longer manages, keeping anything you edited, and `spell doctor` fails loudly if the tier it now depends on is missing.
|
|
56
56
|
- **Project-owned orientation and continuity:** `README.md`, `project.md`, `TODO.md`, `DECISIONS.md`, `ai-context/`, and `journal/`. Arcane creates missing files once and preserves existing content.
|
|
57
57
|
- **Project/domain documents:** use explicit descriptive paths such as `docs/`, `security/`, `infrastructure/`, or a configured business root. These add project context alongside framework standards.
|
|
58
58
|
- **Overrides survive `arcane update`:** editing a managed `.arcane/governance/` standard no longer risks silent loss. Additive project documents were always safe; a hand-edit to a shipped standard now is too. **Enforcement: executable check (ARC-038, shipped BC-31)** — `spell update` compares each file's on-disk content against its recorded hash at install time: unchanged, it refreshes normally; edited, it fetches the exact previously-installed published version as a three-way merge base against your edit and the new vendor content, auto-merging non-overlapping changes and leaving standard conflict markers only on genuine collisions. If the prior published version can't be fetched, your edit is left untouched with a warning rather than replaced.
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
readdir,
|
|
21
21
|
readFile,
|
|
22
22
|
rm,
|
|
23
|
+
rmdir,
|
|
23
24
|
writeFile
|
|
24
25
|
} from "fs/promises";
|
|
25
26
|
import { createHash } from "crypto";
|
|
@@ -89,6 +90,20 @@ async function removeWithin(targetDir, relativePath) {
|
|
|
89
90
|
validateTargetPath(targetDir, relativePath);
|
|
90
91
|
await rm(path.resolve(targetDir, relativePath), { force: true });
|
|
91
92
|
}
|
|
93
|
+
async function removeEmptyAncestors(targetDir, relativePath) {
|
|
94
|
+
validateTargetPath(targetDir, relativePath);
|
|
95
|
+
const base = path.resolve(targetDir);
|
|
96
|
+
let dir = path.dirname(path.resolve(targetDir, relativePath));
|
|
97
|
+
while (dir !== base && dir.startsWith(base + path.sep)) {
|
|
98
|
+
try {
|
|
99
|
+
if ((await readdir(dir)).length > 0) return;
|
|
100
|
+
await rmdir(dir);
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
dir = path.dirname(dir);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
92
107
|
async function copyDirectory(srcDir, targetDir, relativeDir, opts = {}) {
|
|
93
108
|
validateTargetPath(targetDir, relativeDir);
|
|
94
109
|
const copiedFiles = [];
|
|
@@ -201,6 +216,9 @@ function validateTrackingFields(manifest, filePath) {
|
|
|
201
216
|
if (manifest.scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.scope)) {
|
|
202
217
|
throw new ManifestInvalidFieldError(filePath, "scope", manifest.scope);
|
|
203
218
|
}
|
|
219
|
+
if (manifest.spell_scope !== void 0 && !VALID_INSTALL_SCOPES.includes(manifest.spell_scope)) {
|
|
220
|
+
throw new ManifestInvalidFieldError(filePath, "spell_scope", manifest.spell_scope);
|
|
221
|
+
}
|
|
204
222
|
if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
|
|
205
223
|
throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
|
|
206
224
|
}
|
|
@@ -866,7 +884,7 @@ function listProfiles() {
|
|
|
866
884
|
}
|
|
867
885
|
|
|
868
886
|
// src/modules/user-tier.ts
|
|
869
|
-
import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir, writeFile as writeFile3 } from "fs/promises";
|
|
887
|
+
import { lstat, mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rm as rm2, rmdir as rmdir2, writeFile as writeFile3 } from "fs/promises";
|
|
870
888
|
import { homedir } from "os";
|
|
871
889
|
import { dirname, join, resolve } from "path";
|
|
872
890
|
|
|
@@ -999,6 +1017,14 @@ function componentForScope(component, scope) {
|
|
|
999
1017
|
}
|
|
1000
1018
|
return { name: component.name, description: component.description, files, sourceOverrides };
|
|
1001
1019
|
}
|
|
1020
|
+
function componentForSpellScope(component, spellScope) {
|
|
1021
|
+
if (spellScope === "repo") return component;
|
|
1022
|
+
if (!SPELL_COMPONENT_NAMES.includes(component.name)) return component;
|
|
1023
|
+
return { ...component, files: [] };
|
|
1024
|
+
}
|
|
1025
|
+
function effectiveSpellScope(manifest) {
|
|
1026
|
+
return manifest.spell_scope ?? "repo";
|
|
1027
|
+
}
|
|
1002
1028
|
function spellIdsInStore(components) {
|
|
1003
1029
|
const ids = /* @__PURE__ */ new Set();
|
|
1004
1030
|
for (const component of components) {
|
|
@@ -1076,7 +1102,7 @@ function perSpellDirectory(homeDir, relativePath) {
|
|
|
1076
1102
|
async function removeDirectoryIfEmpty(dir) {
|
|
1077
1103
|
try {
|
|
1078
1104
|
const entries = await readdir2(dir);
|
|
1079
|
-
if (entries.length === 0) await
|
|
1105
|
+
if (entries.length === 0) await rmdir2(dir);
|
|
1080
1106
|
} catch {
|
|
1081
1107
|
}
|
|
1082
1108
|
}
|
|
@@ -3274,16 +3300,35 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3274
3300
|
profile = await select2({
|
|
3275
3301
|
message: "Select an installation profile:\n",
|
|
3276
3302
|
choices: profiles.map((p) => {
|
|
3277
|
-
const
|
|
3303
|
+
const profileComponents2 = getProfile(p.id);
|
|
3278
3304
|
return {
|
|
3279
3305
|
value: p.id,
|
|
3280
3306
|
name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
|
|
3281
|
-
description: buildProfileDescription(
|
|
3307
|
+
description: buildProfileDescription(profileComponents2, p.id)
|
|
3282
3308
|
};
|
|
3283
3309
|
})
|
|
3284
3310
|
});
|
|
3285
3311
|
}
|
|
3286
|
-
const
|
|
3312
|
+
const profileComponents = getProfile(profile);
|
|
3313
|
+
let spell_scope;
|
|
3314
|
+
if (!options.profile && !options.dryRun) {
|
|
3315
|
+
let storeVersion;
|
|
3316
|
+
try {
|
|
3317
|
+
storeVersion = (await readManifest(userTierRoot())).version;
|
|
3318
|
+
} catch {
|
|
3319
|
+
}
|
|
3320
|
+
if (storeVersion !== void 0) {
|
|
3321
|
+
console.log();
|
|
3322
|
+
const useTier = await confirm({
|
|
3323
|
+
message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
|
|
3324
|
+
default: false
|
|
3325
|
+
});
|
|
3326
|
+
spell_scope = useTier ? "user" : "repo";
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
const components = profileComponents.map(
|
|
3330
|
+
(component) => componentForSpellScope(component, spell_scope ?? "repo")
|
|
3331
|
+
);
|
|
3287
3332
|
const groups = categorizeComponents(components);
|
|
3288
3333
|
console.log();
|
|
3289
3334
|
printSection(`\u{1F4E6} Profile: ${profile}`);
|
|
@@ -3470,6 +3515,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3470
3515
|
profile,
|
|
3471
3516
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3472
3517
|
components: installedComponents,
|
|
3518
|
+
...spell_scope ? { spell_scope } : {},
|
|
3473
3519
|
...role ? { role } : {},
|
|
3474
3520
|
...tracking_mode ? { tracking_mode, external_provider } : {},
|
|
3475
3521
|
...subject_root !== void 0 ? { subject_root } : {},
|
|
@@ -4155,26 +4201,57 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
|
4155
4201
|
return "reported";
|
|
4156
4202
|
}
|
|
4157
4203
|
await removeWithin(targetDir, file);
|
|
4204
|
+
await removeEmptyAncestors(targetDir, file);
|
|
4158
4205
|
console.log(` Pruned orphaned file: ${file}`);
|
|
4159
4206
|
return "pruned";
|
|
4160
4207
|
}
|
|
4161
|
-
|
|
4208
|
+
function findReadoptedSpellComponents(components, scope = "repo", spellScope = "repo") {
|
|
4209
|
+
if (scope !== "repo" || spellScope !== "repo") return /* @__PURE__ */ new Set();
|
|
4210
|
+
return new Set(
|
|
4211
|
+
components.filter((c) => SPELL_COMPONENT_NAMES.includes(c.name) && c.files.length === 0).map((c) => c.name)
|
|
4212
|
+
);
|
|
4213
|
+
}
|
|
4214
|
+
async function findMissingTrackedFiles(targetDir, components, scope = "repo", spellScope = "repo", readopted = /* @__PURE__ */ new Set()) {
|
|
4162
4215
|
const missing = [];
|
|
4163
4216
|
for (const installed of components) {
|
|
4164
4217
|
let component;
|
|
4165
4218
|
try {
|
|
4166
|
-
component =
|
|
4219
|
+
component = componentForSpellScope(
|
|
4220
|
+
componentForScope(getComponent(installed.name), scope),
|
|
4221
|
+
scope === "repo" ? spellScope : "repo"
|
|
4222
|
+
);
|
|
4167
4223
|
} catch {
|
|
4168
4224
|
continue;
|
|
4169
4225
|
}
|
|
4170
4226
|
if (component.initOnly || component.skipExisting) continue;
|
|
4171
|
-
|
|
4227
|
+
const candidates = readopted.has(installed.name) ? component.files : installed.files;
|
|
4228
|
+
for (const file of candidates) {
|
|
4172
4229
|
if (!component.files.includes(file)) continue;
|
|
4173
4230
|
if (!await fileExists(join9(targetDir, file))) missing.push(file);
|
|
4174
4231
|
}
|
|
4175
4232
|
}
|
|
4176
4233
|
return missing;
|
|
4177
4234
|
}
|
|
4235
|
+
function findUnmanagedTrackedFiles(components, scope = "repo", spellScope = "repo") {
|
|
4236
|
+
const unmanaged = [];
|
|
4237
|
+
for (const installed of components) {
|
|
4238
|
+
let component;
|
|
4239
|
+
try {
|
|
4240
|
+
component = componentForSpellScope(
|
|
4241
|
+
componentForScope(getComponent(installed.name), scope),
|
|
4242
|
+
scope === "repo" ? spellScope : "repo"
|
|
4243
|
+
);
|
|
4244
|
+
} catch {
|
|
4245
|
+
unmanaged.push(...installed.files);
|
|
4246
|
+
continue;
|
|
4247
|
+
}
|
|
4248
|
+
const shipped = new Set(component.files);
|
|
4249
|
+
for (const file of installed.files) {
|
|
4250
|
+
if (!shipped.has(file)) unmanaged.push(file);
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
return unmanaged;
|
|
4254
|
+
}
|
|
4178
4255
|
async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
4179
4256
|
let manifest;
|
|
4180
4257
|
try {
|
|
@@ -4196,6 +4273,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4196
4273
|
return;
|
|
4197
4274
|
}
|
|
4198
4275
|
const scope = resolvedScope.scope;
|
|
4276
|
+
const spellScope = effectiveSpellScope(manifest);
|
|
4277
|
+
const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
|
|
4199
4278
|
const homeDir = dirname4(targetDir);
|
|
4200
4279
|
if (scope === "repo") {
|
|
4201
4280
|
console.warn(
|
|
@@ -4229,8 +4308,9 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4229
4308
|
}
|
|
4230
4309
|
let sameVersionRestore = false;
|
|
4231
4310
|
if (manifest.version === packageVersion && manifest.components.length > 0) {
|
|
4232
|
-
const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope);
|
|
4233
|
-
|
|
4311
|
+
const missing = await findMissingTrackedFiles(targetDir, manifest.components, scope, spellScope, readopted);
|
|
4312
|
+
const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
|
|
4313
|
+
if (missing.length === 0 && unmanaged.length === 0) {
|
|
4234
4314
|
console.log("Already up to date.");
|
|
4235
4315
|
if (scope === "user") {
|
|
4236
4316
|
const fanout = await syncUserTierFanout({
|
|
@@ -4249,9 +4329,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4249
4329
|
return;
|
|
4250
4330
|
}
|
|
4251
4331
|
sameVersionRestore = true;
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4332
|
+
const prefix = options.dryRun ? "[dry-run] " : "";
|
|
4333
|
+
if (missing.length > 0) {
|
|
4334
|
+
console.log(
|
|
4335
|
+
`${prefix}Already at v${packageVersion}, but ${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing \u2014 restoring.`
|
|
4336
|
+
);
|
|
4337
|
+
} else {
|
|
4338
|
+
console.log(
|
|
4339
|
+
`${prefix}Already at v${packageVersion}, but ${unmanaged.length} tracked file${unmanaged.length === 1 ? " is" : "s are"} no longer managed here \u2014 reviewing.`
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4255
4342
|
}
|
|
4256
4343
|
if (manifest.components.length === 0) {
|
|
4257
4344
|
console.log("No components installed. Nothing to update.");
|
|
@@ -4266,7 +4353,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4266
4353
|
for (const installed of componentsToUpdate) {
|
|
4267
4354
|
let component;
|
|
4268
4355
|
try {
|
|
4269
|
-
component =
|
|
4356
|
+
component = componentForSpellScope(
|
|
4357
|
+
componentForScope(getComponent(installed.name), scope),
|
|
4358
|
+
scope === "repo" ? spellScope : "repo"
|
|
4359
|
+
);
|
|
4270
4360
|
} catch (err) {
|
|
4271
4361
|
if (err instanceof ComponentNotFoundError) {
|
|
4272
4362
|
console.log(` ! ${installed.name} not in registry \u2014 skipping.`);
|
|
@@ -4301,7 +4391,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4301
4391
|
continue;
|
|
4302
4392
|
}
|
|
4303
4393
|
if (sameVersionRestore) {
|
|
4304
|
-
const tracked = installed.files.includes(file);
|
|
4394
|
+
const tracked = installed.files.includes(file) || readopted.has(installed.name);
|
|
4305
4395
|
const restorable = tracked && !targetExists && !component.skipExisting;
|
|
4306
4396
|
if (!restorable) {
|
|
4307
4397
|
if (tracked) {
|
|
@@ -4378,13 +4468,24 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4378
4468
|
}
|
|
4379
4469
|
for (const file of installed.files) {
|
|
4380
4470
|
if (updatedFiles.includes(file)) continue;
|
|
4471
|
+
const recordedHash = installed.fileHashes?.[file];
|
|
4381
4472
|
const status = await resolveOrphan(
|
|
4382
4473
|
targetDir,
|
|
4383
4474
|
file,
|
|
4384
|
-
|
|
4475
|
+
recordedHash,
|
|
4385
4476
|
Boolean(options.prune) && !options.dryRun
|
|
4386
4477
|
);
|
|
4387
4478
|
orphanReport.push({ file, status });
|
|
4479
|
+
if (status === "reported") {
|
|
4480
|
+
try {
|
|
4481
|
+
validateTargetPath(targetDir, file);
|
|
4482
|
+
if (await fileExists(join9(targetDir, file))) {
|
|
4483
|
+
updatedFiles.push(file);
|
|
4484
|
+
if (recordedHash !== void 0) fileHashes[file] = recordedHash;
|
|
4485
|
+
}
|
|
4486
|
+
} catch {
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4388
4489
|
}
|
|
4389
4490
|
updatedComponents.push({
|
|
4390
4491
|
...installed,
|
|
@@ -4404,6 +4505,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4404
4505
|
const marker = status === "pruned" ? "pruned" : options.prune ? "kept (see reason above)" : "not removed";
|
|
4405
4506
|
console.log(` ${file} \u2014 ${marker}`);
|
|
4406
4507
|
}
|
|
4508
|
+
if (scope === "repo" && spellScope === "user") {
|
|
4509
|
+
console.log(
|
|
4510
|
+
' This repository takes its spells from the user tier (spell_scope: "user"), so the files above are no longer managed here \u2014 every client reads ~/.arcane instead.'
|
|
4511
|
+
);
|
|
4512
|
+
}
|
|
4407
4513
|
if (!options.prune) {
|
|
4408
4514
|
console.log(" Run `spell update --prune` to remove the ones that are safe to delete.");
|
|
4409
4515
|
}
|
|
@@ -4670,12 +4776,18 @@ async function runStatus(targetDir, packageVersion, options = {}) {
|
|
|
4670
4776
|
);
|
|
4671
4777
|
}
|
|
4672
4778
|
} else {
|
|
4673
|
-
|
|
4779
|
+
const spellScope = effectiveSpellScope(manifest);
|
|
4780
|
+
console.log(spellScope === "user" ? " Scope: repo (spells: user tier)" : " Scope: repo");
|
|
4781
|
+
const store = userTierRoot();
|
|
4674
4782
|
try {
|
|
4675
|
-
const store = userTierRoot();
|
|
4676
4783
|
const userManifest = await readManifest(store);
|
|
4677
4784
|
console.log(` User tier: v${userManifest.version} at ${store} (spell status --user)`);
|
|
4678
4785
|
} catch {
|
|
4786
|
+
if (spellScope === "user") {
|
|
4787
|
+
console.log(
|
|
4788
|
+
` ! User tier: not installed at ${store} \u2014 this repository expects it for every spell. Run \`spell init --user\` (\`spell doctor\` explains).`
|
|
4789
|
+
);
|
|
4790
|
+
}
|
|
4679
4791
|
}
|
|
4680
4792
|
}
|
|
4681
4793
|
console.log("");
|
|
@@ -4881,7 +4993,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
4881
4993
|
}
|
|
4882
4994
|
|
|
4883
4995
|
// src/commands/doctor.ts
|
|
4884
|
-
import { access as access4, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
4996
|
+
import { access as access4, readdir as readdir7, readFile as readFile10, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
4885
4997
|
import { join as join13, dirname as dirname8 } from "path";
|
|
4886
4998
|
import { execFile as execFile3 } from "child_process";
|
|
4887
4999
|
import { promisify as promisify2 } from "util";
|
|
@@ -5221,8 +5333,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
5221
5333
|
];
|
|
5222
5334
|
for (const dir of extDirs) {
|
|
5223
5335
|
try {
|
|
5224
|
-
const { readdir:
|
|
5225
|
-
const entries = await
|
|
5336
|
+
const { readdir: readdir10 } = await import("fs/promises");
|
|
5337
|
+
const entries = await readdir10(dir);
|
|
5226
5338
|
const prefix = id.toLowerCase() + "-";
|
|
5227
5339
|
const found = entries.some(
|
|
5228
5340
|
(e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
|
|
@@ -5646,6 +5758,66 @@ async function checkPlatformBranchPolicy(targetDir) {
|
|
|
5646
5758
|
message: "remote is neither GitHub nor Azure DevOps \u2014 platform policy verification not available for this provider"
|
|
5647
5759
|
};
|
|
5648
5760
|
}
|
|
5761
|
+
async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
|
|
5762
|
+
const name = "Spell scope (ARC-045)";
|
|
5763
|
+
let manifest;
|
|
5764
|
+
try {
|
|
5765
|
+
manifest = await readManifest(targetDir);
|
|
5766
|
+
} catch {
|
|
5767
|
+
return { name, passed: true, blocking: false, message: "no .arcane.json \u2014 nothing to check" };
|
|
5768
|
+
}
|
|
5769
|
+
if (effectiveSpellScope(manifest) === "repo") {
|
|
5770
|
+
return {
|
|
5771
|
+
name,
|
|
5772
|
+
passed: true,
|
|
5773
|
+
blocking: false,
|
|
5774
|
+
message: "repo \u2014 this repository carries its own spells (the default)"
|
|
5775
|
+
};
|
|
5776
|
+
}
|
|
5777
|
+
const storeRoot = userTierRoot(homeDir);
|
|
5778
|
+
let store;
|
|
5779
|
+
try {
|
|
5780
|
+
store = await readManifest(storeRoot);
|
|
5781
|
+
} catch (err) {
|
|
5782
|
+
return {
|
|
5783
|
+
name,
|
|
5784
|
+
passed: false,
|
|
5785
|
+
message: `this repository takes its spells from the user tier (spell_scope: "user"), but ${storeRoot} ${err instanceof ManifestNotFoundError ? "is not installed" : `could not be read (${err instanceof Error ? err.message : String(err)})`} \u2014 no client can reach a spell here. Run \`spell init --user\`, or set spell_scope back to "repo" and run \`spell update\`.`
|
|
5786
|
+
};
|
|
5787
|
+
}
|
|
5788
|
+
let spellCount = 0;
|
|
5789
|
+
let probed = false;
|
|
5790
|
+
const probe = "spell-status.md";
|
|
5791
|
+
try {
|
|
5792
|
+
const entries = await readdir7(join13(storeRoot, USER_TIER_SPELLS_DIR));
|
|
5793
|
+
const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
|
|
5794
|
+
spellCount = spells.length;
|
|
5795
|
+
probed = spells.includes(probe);
|
|
5796
|
+
} catch {
|
|
5797
|
+
}
|
|
5798
|
+
if (spellCount === 0 || !probed) {
|
|
5799
|
+
return {
|
|
5800
|
+
name,
|
|
5801
|
+
passed: false,
|
|
5802
|
+
message: `the user tier at ${storeRoot} holds ${spellCount} spell(s)${spellCount > 0 && !probed ? ` and not ${probe}` : ""} \u2014 this repository expects it to supply every spell. Run \`spell update --user\`.`
|
|
5803
|
+
};
|
|
5804
|
+
}
|
|
5805
|
+
const majorMinor = (v) => v.split(".").slice(0, 2).join(".");
|
|
5806
|
+
if (typeof manifest.version === "string" && majorMinor(store.version) !== majorMinor(manifest.version)) {
|
|
5807
|
+
return {
|
|
5808
|
+
name,
|
|
5809
|
+
passed: false,
|
|
5810
|
+
blocking: false,
|
|
5811
|
+
message: `this repository is at v${manifest.version} but the user tier it uses is at v${store.version} \u2014 run \`spell update --user\` (and \`spell update\` here) to bring them together.`
|
|
5812
|
+
};
|
|
5813
|
+
}
|
|
5814
|
+
return {
|
|
5815
|
+
name,
|
|
5816
|
+
passed: true,
|
|
5817
|
+
blocking: false,
|
|
5818
|
+
message: `user \u2014 ${spellCount} spell(s) from ${storeRoot} (v${store.version}), ${probe} present`
|
|
5819
|
+
};
|
|
5820
|
+
}
|
|
5649
5821
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
5650
5822
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
5651
5823
|
if (options.leaks) {
|
|
@@ -5671,7 +5843,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
5671
5843
|
checkPlatformBranchPolicy(targetDir),
|
|
5672
5844
|
checkDelegations(targetDir),
|
|
5673
5845
|
checkMcpConfig(targetDir),
|
|
5674
|
-
checkUserTier(resolveHomeDir(), options.packageVersion)
|
|
5846
|
+
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
5847
|
+
checkSpellScope(targetDir)
|
|
5675
5848
|
]);
|
|
5676
5849
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
5677
5850
|
results.push(...sessionResults);
|
|
@@ -5719,7 +5892,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
5719
5892
|
import { readFile as readFile12 } from "fs/promises";
|
|
5720
5893
|
|
|
5721
5894
|
// src/modules/ward.ts
|
|
5722
|
-
import { readdir as
|
|
5895
|
+
import { readdir as readdir8, readFile as readFile11 } from "fs/promises";
|
|
5723
5896
|
import { join as join14, relative as relative2 } from "path";
|
|
5724
5897
|
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
5725
5898
|
const findings = [];
|
|
@@ -5778,7 +5951,7 @@ async function scanFilenames(root, rules, repoRoot = root) {
|
|
|
5778
5951
|
const findings = [];
|
|
5779
5952
|
let entries;
|
|
5780
5953
|
try {
|
|
5781
|
-
entries = await
|
|
5954
|
+
entries = await readdir8(root, { withFileTypes: true });
|
|
5782
5955
|
} catch {
|
|
5783
5956
|
return findings;
|
|
5784
5957
|
}
|
|
@@ -6000,7 +6173,7 @@ import { access as access5 } from "fs/promises";
|
|
|
6000
6173
|
import { join as join17 } from "path";
|
|
6001
6174
|
|
|
6002
6175
|
// src/modules/show-report/generate.ts
|
|
6003
|
-
import { mkdir as mkdir8, readdir as
|
|
6176
|
+
import { mkdir as mkdir8, readdir as readdir9, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
|
|
6004
6177
|
import { basename as basename3, dirname as dirname9, isAbsolute, join as join16, relative as relative3, resolve as resolve4 } from "path";
|
|
6005
6178
|
|
|
6006
6179
|
// src/modules/show-report/model.ts
|
|
@@ -6567,7 +6740,7 @@ async function discoverPrograms(rootDir) {
|
|
|
6567
6740
|
const plansDir = join16(rootDir, "docs", "plans");
|
|
6568
6741
|
let entries;
|
|
6569
6742
|
try {
|
|
6570
|
-
entries = (await
|
|
6743
|
+
entries = (await readdir9(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
6571
6744
|
} catch (error) {
|
|
6572
6745
|
if (error.code === "ENOENT") return { programs: [], skipped: [] };
|
|
6573
6746
|
throw error;
|