@venizia/vulcan 0.2.0-0 → 0.2.0-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 +3 -3
- package/base/claude/settings.json +2 -2
- package/package.json +3 -3
- package/src/commands/sync.command.ts +13 -4
- package/src/doctor/checks.ts +11 -5
- package/src/router.ts +5 -0
- package/src/sync/common/constants.ts +2 -0
- package/src/sync/planner.ts +13 -0
- package/src/sync/prune.ts +34 -28
- package/src/sync/settings.ts +74 -1
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ flag `bun x` fetches it when `@venizia/vulcan` is not installed yet.
|
|
|
17
17
|
| Command | What it does |
|
|
18
18
|
|---|---|
|
|
19
19
|
| `init [--config ts\|mts\|mjs\|js]` | Scaffold the project layer: `AGENTS.md`, `.configs/vulcan.config.*`, the three lint configs, `.agents/rules.md`, `.agents/knowledge/index.md`, a Makefile `-include`. Never overwrites; prints what to add to existing files, and the atlas `.mcp.json` entry for projects with a wiki |
|
|
20
|
-
| `sync [--dry-run] [--prune]` | Write the gitignored agent adapters: `CLAUDE.md` (`@AGENTS.md`), `.claude/settings.json` (shipped + slot + project hooks + `claude.settings` over your personal keys; what it applied is recorded in `.claude/vulcan-managed.json`, so a removed choice is removed), `.claude/skills/` links, and the committed `.agents/vulcan.manifest.json`. Prints each base rule added, changed or removed since the manifest last recorded it, with its new text, then records the new hashes. `--prune` also removes what sync proves stale and did not write: an old VULCAN or plugin-era hook command (a shipped hook
|
|
20
|
+
| `sync [--dry-run] [--prune]` | Write the gitignored agent adapters: `CLAUDE.md` (`@AGENTS.md`), `.claude/settings.json` (shipped + slot + project hooks + `claude.settings` over your personal keys; what it applied is recorded in `.claude/vulcan-managed.json`, so a removed choice is removed, and a hook it wrote is taken back by its command even after you edited its entry), `.claude/skills/` links, and the committed `.agents/vulcan.manifest.json`. Prints each base rule added, changed or removed since the manifest last recorded it, with its new text, then records the new hashes. `--prune` also removes what sync proves stale and did not write: an old VULCAN or plugin-era hook command (a shipped hook exactly as an older VULCAN released it, or a `.agents/` hook whose file is gone) and a dead `.claude/skills` link - never a developer's own hook, a customised shipped command, a real directory or a symlinked `.claude/skills`. `--dry-run` writes nothing and labels each line `would create`, `would replace` or `would remove`. Idempotent |
|
|
21
21
|
| `doctor` | Check the project follows VULCAN; exit 1 on any violation |
|
|
22
22
|
| `upgrade [<version\|tag>]` | Install the target `@venizia/vulcan` (default: the `next` dist-tag), then run `sync` from the newly installed CLI |
|
|
23
|
-
| `x <tool> [args...]` | Run a shipped tool at the pinned version: `okf`, `atlas` |
|
|
23
|
+
| `x <tool> [args...]` | Run a shipped tool at the pinned version: `okf`, `atlas`. `x --help` prints the help; `x <tool> --help` goes to the tool |
|
|
24
24
|
| `help`, `version` | |
|
|
25
25
|
|
|
26
26
|
Diagnostics go to stderr; `vulcan` writes nothing to stdout of its own, so `vulcan x atlas mcp` is a
|
|
@@ -63,7 +63,7 @@ or shadows a shipped one.
|
|
|
63
63
|
shadowed) · `make` (includes `vulcan.mk`, no shipped target redefined) · `hooks` (shipped hooks are
|
|
64
64
|
not re-registered) · `adapters` (sync is current, no hand-written `CLAUDE.md`, valid settings) ·
|
|
65
65
|
`prune` (no stale hook command or dead skill link - `sync --prune` removes them) ·
|
|
66
|
-
`style` (`@venizia/dev-configs` declared by the package holding the lint config or the root, every lint config builds on it).
|
|
66
|
+
`style` (`@venizia/dev-configs` declared by the package holding the lint config or the root, every lint config builds on it; the packages are `packages/*` and every configured package `path`).
|
|
67
67
|
|
|
68
68
|
The session-start digest prints the base write boundaries, the slot choices, the project's own rules (bold `| **W-00** |` rows and `| V-W-02, V-W-03 |` rows that elaborate base rules), the project's `<!-- minimap-shape:start -->` ... `<!-- minimap-shape:end -->` block from `.agents/rules.md` verbatim, and the `digest.oftenDropped` rules.
|
|
69
69
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/session-start.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; echo \"vulcan: the session-start hook cannot run
|
|
10
|
+
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/session-start.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; [ -f \"$f\" ] || echo \"vulcan: the session-start hook cannot run: node_modules/@venizia/vulcan is missing - run bun install\" >&2; command -v bun >/dev/null 2>&1 || echo \"vulcan: the session-start hook cannot run: bun is not on PATH - put bun on PATH (Claude Code may have started without your shell's PATH)\" >&2; exit 2"
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"hooks": [
|
|
19
19
|
{
|
|
20
20
|
"type": "command",
|
|
21
|
-
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; if grep -Eq '\"command\" *: *\"bun (install|i)(
|
|
21
|
+
"command": "f=\"${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts\"; if [ -f \"$f\" ] && command -v bun >/dev/null 2>&1; then bun \"$f\"; s=$?; [ \"$s\" -eq 0 ] && exit 0; exit 2; fi; if grep -Eq '\"command\" *: *\"bun (install|i)( (--frozen-lockfile|--production|-p|--no-save|--ignore-scripts|--force|--verbose|--silent))*\"'; then exit 0; fi; [ -f \"$f\" ] || echo \"vulcan: the guard-writes hook cannot run: node_modules/@venizia/vulcan is missing - run bun install (the one command that passes until then)\" >&2; command -v bun >/dev/null 2>&1 || echo \"vulcan: the guard-writes hook cannot run: bun is not on PATH - put bun on PATH (Claude Code may have started without your shell's PATH)\" >&2; exit 2"
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venizia/vulcan",
|
|
3
|
-
"version": "0.2.0-
|
|
3
|
+
"version": "0.2.0-1",
|
|
4
4
|
"description": "VULCAN - one setup, one workflow for every project: init, sync, doctor, upgrade, and `vulcan x` for the shipped tools. Bun-only.",
|
|
5
5
|
"homepage": "https://github.com/VENIZIA-AI/vulcan/tree/main/packages/cli",
|
|
6
6
|
"repository": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"test": "NODE_ENV=test bun test --env-file=.env.test"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@venizia/vulcan-atlas": "0.2.0-
|
|
48
|
-
"@venizia/vulcan-okf": "0.2.0-
|
|
47
|
+
"@venizia/vulcan-atlas": "0.2.0-1",
|
|
48
|
+
"@venizia/vulcan-okf": "0.2.0-1",
|
|
49
49
|
"zod": "^4.5.4"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
@@ -6,6 +6,15 @@ import { diffBaseRules, planPrune, planSync, SyncConstants, SyncStatuses } from
|
|
|
6
6
|
|
|
7
7
|
const DRY_RUN_FLAG = '--dry-run';
|
|
8
8
|
const PRUNE_FLAG = '--prune';
|
|
9
|
+
const LABEL_WIDTH = 9;
|
|
10
|
+
|
|
11
|
+
/** Under --dry-run nothing is written, so a write is labelled as what it would do. */
|
|
12
|
+
const DRY_RUN_LABELS: Record<string, string> = {
|
|
13
|
+
[SyncStatuses.CREATED]: 'would create',
|
|
14
|
+
[SyncStatuses.REPLACED]: 'would replace',
|
|
15
|
+
[SyncStatuses.REMOVED]: 'would remove',
|
|
16
|
+
};
|
|
17
|
+
const DRY_RUN_LABEL_WIDTH = Math.max(...Object.values(DRY_RUN_LABELS).map(label => label.length));
|
|
9
18
|
|
|
10
19
|
export const runSyncCommand = async (opts: ICommandContext): Promise<number> => {
|
|
11
20
|
const loaded = await loadConfig({ root: opts.cwd });
|
|
@@ -22,6 +31,7 @@ export const runSyncCommand = async (opts: ICommandContext): Promise<number> =>
|
|
|
22
31
|
const actions = [...planSync({ root: opts.cwd, config: loaded.config }), ...pruned];
|
|
23
32
|
// Read before the manifest action rewrites the hashes it compares against.
|
|
24
33
|
const changes = diffBaseRules({ root: opts.cwd });
|
|
34
|
+
const width = dryRun ? DRY_RUN_LABEL_WIDTH : LABEL_WIDTH;
|
|
25
35
|
|
|
26
36
|
for (const action of actions) {
|
|
27
37
|
if (!dryRun) {
|
|
@@ -29,14 +39,13 @@ export const runSyncCommand = async (opts: ICommandContext): Promise<number> =>
|
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
const note = action.note === undefined ? '' : ` (${action.note})`;
|
|
42
|
+
const label = (dryRun ? DRY_RUN_LABELS[action.status] : undefined) ?? action.status;
|
|
32
43
|
|
|
33
|
-
process.stderr.write(
|
|
34
|
-
` ${action.status.padEnd(9)} ${relative(opts.cwd, action.path)}${note}\n`,
|
|
35
|
-
);
|
|
44
|
+
process.stderr.write(` ${label.padEnd(width)} ${relative(opts.cwd, action.path)}${note}\n`);
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
for (const { id, change, text } of changes) {
|
|
39
|
-
process.stderr.write(` ${change.padEnd(
|
|
48
|
+
process.stderr.write(` ${change.padEnd(width)} ${[id, text].filter(Boolean).join(' ')}\n`);
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
const settingsBroken = actions.some(
|
package/src/doctor/checks.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Glob } from 'bun';
|
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
import { BASE_DIRECTORY, ProjectLayout } from '../common/index.ts';
|
|
5
|
+
import { resolvePackages } from '../config/index.ts';
|
|
5
6
|
import type { TResolvedConfig } from '../config/index.ts';
|
|
6
7
|
import {
|
|
7
8
|
parseBaseReferences,
|
|
@@ -271,15 +272,20 @@ const hasStylePackage = (opts: { directory: string }): boolean => {
|
|
|
271
272
|
/**
|
|
272
273
|
* D9: the shared coding style - every lint config builds on it, and the package whose `.configs/`
|
|
273
274
|
* holds the config declares it, or the root does. A project with no lint config declares it at the
|
|
274
|
-
* root.
|
|
275
|
+
* root. Owners are `packages/*` and every configured package's `path`, such as `umbras/kit`.
|
|
275
276
|
*/
|
|
276
277
|
export const checkStyle = (opts: ICheckContext): IViolation[] => {
|
|
277
278
|
const violations: IViolation[] = [];
|
|
278
279
|
const owners = [
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
join('packages'
|
|
282
|
-
|
|
280
|
+
...new Set([
|
|
281
|
+
'.',
|
|
282
|
+
...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
|
|
283
|
+
join('packages', name),
|
|
284
|
+
),
|
|
285
|
+
...resolvePackages({ config: opts.config }).map(
|
|
286
|
+
entry => relative(opts.root, join(opts.root, entry.path)) || '.',
|
|
287
|
+
),
|
|
288
|
+
]),
|
|
283
289
|
];
|
|
284
290
|
const isRootDeclared = hasStylePackage({ directory: opts.root });
|
|
285
291
|
let lintConfigs = 0;
|
package/src/router.ts
CHANGED
|
@@ -66,6 +66,11 @@ export const runVulcan = async (opts: { argv: string[]; cwd: string }): Promise<
|
|
|
66
66
|
return ExitCodes.FAILURE;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// `x` hands its arguments to the tool, so only a help flag in the tool's place is vulcan's.
|
|
70
|
+
if (name === VulcanCommands.X && HELP_ALIASES.has(args[0] ?? '')) {
|
|
71
|
+
return runHelpCommand();
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
const accepted = ACCEPTED_ARGS[name];
|
|
70
75
|
|
|
71
76
|
if (accepted !== undefined) {
|
|
@@ -26,6 +26,8 @@ export class SyncConstants {
|
|
|
26
26
|
static readonly MANAGED_FILE = 'vulcan-managed.json';
|
|
27
27
|
static readonly SKILLS_DIRECTORY = 'skills';
|
|
28
28
|
static readonly SKILL_FILE = 'SKILL.md';
|
|
29
|
+
/** The settings key of hook entries: sync takes a managed hook back by its command, not its whole entry. */
|
|
30
|
+
static readonly HOOKS_KEY = 'hooks';
|
|
29
31
|
/** Objects of independent entries: merged one level deep, so personal entries survive shared ones. */
|
|
30
32
|
static readonly MERGE_ONE_LEVEL = new Set(['permissions', 'hooks', 'enabledPlugins', 'env']);
|
|
31
33
|
}
|
package/src/sync/planner.ts
CHANGED
|
@@ -162,6 +162,19 @@ const findSkillDirectories = (opts: { directory: string }): string[] =>
|
|
|
162
162
|
/** Shipped and project skills, linked flat into `.claude/skills/<name>`; a real directory there is left alone. */
|
|
163
163
|
const planSkillLinks = (opts: { root: string }): ISyncAction[] => {
|
|
164
164
|
const target = join(opts.root, SyncConstants.CLAUDE_DIRECTORY, SyncConstants.SKILLS_DIRECTORY);
|
|
165
|
+
|
|
166
|
+
// A symlinked skills directory points outside the project: writing links through it would land them there.
|
|
167
|
+
if (isSymlink({ path: target })) {
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
path: target,
|
|
171
|
+
status: SyncStatuses.SKIPPED,
|
|
172
|
+
note: 'a symlink - left untouched',
|
|
173
|
+
apply: NO_CHANGE,
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
165
178
|
const stableSkills = join(stableBaseDirectoryOf(opts), SyncConstants.SKILLS_DIRECTORY);
|
|
166
179
|
const shipped = findSkillDirectories({
|
|
167
180
|
directory: join(BASE_DIRECTORY, SyncConstants.SKILLS_DIRECTORY),
|
package/src/sync/prune.ts
CHANGED
|
@@ -3,9 +3,18 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { BASE_DIRECTORY } from '../common/index.ts';
|
|
4
4
|
import { SyncConstants, SyncStatuses } from './common/index.ts';
|
|
5
5
|
import type { ISyncAction, TSettings } from './common/index.ts';
|
|
6
|
+
import { extractHookCommands, isSettingsObject, removeHookCommands } from './settings.ts';
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The exact hook commands released VULCAN versions shipped (0.1.0-x, then 0.2.0-0). Only these are
|
|
10
|
+
* an old VULCAN form: a shipped hook a developer customised is theirs, and never pruned.
|
|
11
|
+
*/
|
|
12
|
+
const RETIRED_SHIPPED_COMMANDS = new Set([
|
|
13
|
+
`bun "\${CLAUDE_PROJECT_DIR}"/node_modules/@venizia/vulcan/base/hooks/session-start.ts`,
|
|
14
|
+
`bun "\${CLAUDE_PROJECT_DIR}"/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts`,
|
|
15
|
+
`f="\${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/session-start.ts"; if [ -f "$f" ] && command -v bun >/dev/null 2>&1; then bun "$f"; s=$?; [ "$s" -eq 0 ] && exit 0; exit 2; fi; echo "vulcan: the session-start hook cannot run (node_modules/@venizia/vulcan or bun is missing) - run bun install" >&2; exit 2`,
|
|
16
|
+
`f="\${CLAUDE_PROJECT_DIR}/node_modules/@venizia/vulcan/base/hooks/guard-writes.ts"; if [ -f "$f" ] && command -v bun >/dev/null 2>&1; then bun "$f"; s=$?; [ "$s" -eq 0 ] && exit 0; exit 2; fi; if grep -Eq '"command" *: *"bun (install|i)( --?[a-z][a-z0-9-]*(=[A-Za-z0-9._-]+)?)*"'; then exit 0; fi; echo "vulcan: the guard-writes hook cannot run (node_modules/@venizia/vulcan or bun is missing) - run bun install (the one command that passes until then)" >&2; exit 2`,
|
|
17
|
+
]);
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* A hook a project kept under its own `.agents/` - where plugin-era setups put theirs: the whole
|
|
@@ -15,15 +24,15 @@ const SHIPPED_HOOKS_PATH = '@venizia/vulcan/base/hooks/';
|
|
|
15
24
|
const AGENTS_HOOK =
|
|
16
25
|
/^(?:bun|node)\s+(?:"?\$\{?CLAUDE_PROJECT_DIR\}?"?\/)?(\.agents\/[\w./-]+\.ts)\s*$/;
|
|
17
26
|
|
|
18
|
-
type THookEntries = Record<string, { hooks?: { command?: string }[] }[]>;
|
|
19
|
-
|
|
20
27
|
const readJson = (opts: { path: string }): TSettings | undefined => {
|
|
21
28
|
if (!existsSync(opts.path)) {
|
|
22
29
|
return undefined;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
try {
|
|
26
|
-
|
|
33
|
+
const parsed = { value: JSON.parse(readFileSync(opts.path, 'utf8')) };
|
|
34
|
+
|
|
35
|
+
return isSettingsObject(parsed) ? parsed.value : undefined;
|
|
27
36
|
} catch (error) {
|
|
28
37
|
console.error(
|
|
29
38
|
`[readJson] ${opts.path} | ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -33,17 +42,12 @@ const readJson = (opts: { path: string }): TSettings | undefined => {
|
|
|
33
42
|
}
|
|
34
43
|
};
|
|
35
44
|
|
|
36
|
-
const commandsOf = (opts: { settings: TSettings | undefined }): string[] =>
|
|
37
|
-
Object.values((opts.settings?.hooks ?? {}) as THookEntries).flatMap(entries =>
|
|
38
|
-
entries.flatMap(entry => (entry.hooks ?? []).flatMap(hook => hook.command ?? [])),
|
|
39
|
-
);
|
|
40
|
-
|
|
41
45
|
/**
|
|
42
|
-
* A command sync did not write that is known to be stale: a shipped hook
|
|
43
|
-
*
|
|
46
|
+
* A command sync did not write that is known to be stale: a shipped hook exactly as an older VULCAN
|
|
47
|
+
* released it, or a `.agents/` hook whose file is gone. Any other command is a developer's own.
|
|
44
48
|
*/
|
|
45
49
|
const isStaleCommand = (opts: { root: string; command: string; shipped: Set<string> }): boolean => {
|
|
46
|
-
if (opts.command
|
|
50
|
+
if (RETIRED_SHIPPED_COMMANDS.has(opts.command)) {
|
|
47
51
|
return !opts.shipped.has(opts.command);
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -55,24 +59,23 @@ const isStaleCommand = (opts: { root: string; command: string; shipped: Set<stri
|
|
|
55
59
|
/** Takes one command out of the settings file; an entry or an event left empty goes with it. */
|
|
56
60
|
const removeHookCommand = (opts: { path: string; command: string }): void => {
|
|
57
61
|
const settings = readJson({ path: opts.path });
|
|
58
|
-
const hooks =
|
|
62
|
+
const hooks = { value: settings?.[SyncConstants.HOOKS_KEY] };
|
|
59
63
|
|
|
60
|
-
if (settings === undefined) {
|
|
64
|
+
if (settings === undefined || !isSettingsObject(hooks)) {
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
for (const [event, entries] of Object.entries(hooks)) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.filter(entry => entry.hooks.length > 0);
|
|
68
|
+
for (const [event, entries] of Object.entries(hooks.value)) {
|
|
69
|
+
if (!Array.isArray(entries)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const kept = removeHookCommands({ entries, commands: new Set([opts.command]) });
|
|
71
74
|
|
|
72
75
|
if (kept.length > 0) {
|
|
73
|
-
hooks[event] = kept;
|
|
76
|
+
hooks.value[event] = kept;
|
|
74
77
|
} else {
|
|
75
|
-
delete hooks[event];
|
|
78
|
+
delete hooks.value[event];
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
@@ -96,20 +99,23 @@ export const planPrune = (opts: { root: string }): ISyncAction[] => {
|
|
|
96
99
|
const directory = join(opts.root, SyncConstants.CLAUDE_DIRECTORY);
|
|
97
100
|
const settingsPath = join(directory, SyncConstants.SETTINGS_FILE);
|
|
98
101
|
const managed = new Set(
|
|
99
|
-
|
|
102
|
+
extractHookCommands({
|
|
103
|
+
settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }),
|
|
104
|
+
}),
|
|
100
105
|
);
|
|
101
106
|
const shipped = new Set(
|
|
102
|
-
|
|
107
|
+
extractHookCommands({
|
|
103
108
|
settings: readJson({ path: join(BASE_DIRECTORY, 'claude', SyncConstants.SETTINGS_FILE) }),
|
|
104
109
|
}),
|
|
105
110
|
);
|
|
106
111
|
const staleCommands = [
|
|
107
|
-
...new Set(
|
|
112
|
+
...new Set(extractHookCommands({ settings: readJson({ path: settingsPath }) })),
|
|
108
113
|
].filter(
|
|
109
114
|
command => !managed.has(command) && isStaleCommand({ root: opts.root, command, shipped }),
|
|
110
115
|
);
|
|
111
116
|
const skills = join(directory, SyncConstants.SKILLS_DIRECTORY);
|
|
112
|
-
|
|
117
|
+
// lstat, not stat: a symlinked skills directory leads out of the project and is never walked.
|
|
118
|
+
const deadLinks = lstatSync(skills, { throwIfNoEntry: false })?.isDirectory()
|
|
113
119
|
? readdirSync(skills)
|
|
114
120
|
.map(name => join(skills, name))
|
|
115
121
|
.filter(path => isDeadLink({ path }))
|
package/src/sync/settings.ts
CHANGED
|
@@ -48,6 +48,71 @@ export const mergeSettings = (opts: { base: TSettings; overlay: TSettings }): TS
|
|
|
48
48
|
|
|
49
49
|
const toKey = (opts: { value: unknown }): string => JSON.stringify(opts.value);
|
|
50
50
|
|
|
51
|
+
/** The command of a hook; `undefined` when `hook` is not an object with a command string. */
|
|
52
|
+
const toHookCommand = (opts: { hook: unknown }): string | undefined => {
|
|
53
|
+
const hook = { value: opts.hook };
|
|
54
|
+
|
|
55
|
+
return isSettingsObject(hook) && typeof hook.value.command === 'string'
|
|
56
|
+
? hook.value.command
|
|
57
|
+
: undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A hook entry and its hooks; `undefined` when `entry` is not an object with a `hooks` array. */
|
|
61
|
+
const toHookEntry = (opts: {
|
|
62
|
+
entry: unknown;
|
|
63
|
+
}): { entry: TSettings; hooks: unknown[] } | undefined => {
|
|
64
|
+
const entry = { value: opts.entry };
|
|
65
|
+
|
|
66
|
+
return isSettingsObject(entry) && Array.isArray(entry.value.hooks)
|
|
67
|
+
? { entry: entry.value, hooks: entry.value.hooks }
|
|
68
|
+
: undefined;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Every hook command in one event's entries; anything that is not an entry or a command is skipped. */
|
|
72
|
+
export const extractEntryCommands = (opts: { entries: unknown }): string[] =>
|
|
73
|
+
Array.isArray(opts.entries)
|
|
74
|
+
? opts.entries.flatMap(entry =>
|
|
75
|
+
(toHookEntry({ entry })?.hooks ?? []).flatMap(hook => toHookCommand({ hook }) ?? []),
|
|
76
|
+
)
|
|
77
|
+
: [];
|
|
78
|
+
|
|
79
|
+
/** Every hook command of a settings file, across its events. */
|
|
80
|
+
export const extractHookCommands = (opts: { settings: TSettings | undefined }): string[] => {
|
|
81
|
+
const hooks = { value: opts.settings?.[SyncConstants.HOOKS_KEY] };
|
|
82
|
+
|
|
83
|
+
return isSettingsObject(hooks)
|
|
84
|
+
? Object.values(hooks.value).flatMap(entries => extractEntryCommands({ entries }))
|
|
85
|
+
: [];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `entries` without the hooks whose command is in `commands`: an entry's other hooks and keys stay,
|
|
90
|
+
* an entry left with no hooks goes, and anything that is not an entry is kept as it is.
|
|
91
|
+
*/
|
|
92
|
+
export const removeHookCommands = (opts: {
|
|
93
|
+
entries: unknown[];
|
|
94
|
+
commands: Set<string>;
|
|
95
|
+
}): unknown[] =>
|
|
96
|
+
opts.entries.flatMap(entry => {
|
|
97
|
+
const hookEntry = toHookEntry({ entry });
|
|
98
|
+
|
|
99
|
+
if (hookEntry === undefined) {
|
|
100
|
+
return [entry];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const kept = hookEntry.hooks.filter(hook => {
|
|
104
|
+
const command = toHookCommand({ hook });
|
|
105
|
+
|
|
106
|
+
return command === undefined || !opts.commands.has(command);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (kept.length === hookEntry.hooks.length) {
|
|
110
|
+
return [entry];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return kept.length > 0 ? [{ ...hookEntry.entry, hooks: kept }] : [];
|
|
114
|
+
});
|
|
115
|
+
|
|
51
116
|
/** `settings` with its keys in `reference`'s order: keys `reference` has keep their place, new keys follow. */
|
|
52
117
|
const orderKeysLike = (opts: { settings: TSettings; reference: TSettings }): TSettings => {
|
|
53
118
|
const keys = [
|
|
@@ -109,7 +174,15 @@ export const removeManagedSettings = (opts: {
|
|
|
109
174
|
|
|
110
175
|
if (Array.isArray(innerCurrent) && Array.isArray(innerManaged)) {
|
|
111
176
|
const managedKeys = new Set(innerManaged.map(entry => toKey({ value: entry })));
|
|
112
|
-
const
|
|
177
|
+
const unmatched = innerCurrent.filter(entry => !managedKeys.has(toKey({ value: entry })));
|
|
178
|
+
// A hand-edited entry no longer matches whole: its managed hooks still go, by command.
|
|
179
|
+
const kept =
|
|
180
|
+
key === SyncConstants.HOOKS_KEY
|
|
181
|
+
? removeHookCommands({
|
|
182
|
+
entries: unmatched,
|
|
183
|
+
commands: new Set(extractEntryCommands({ entries: innerManaged })),
|
|
184
|
+
})
|
|
185
|
+
: unmatched;
|
|
113
186
|
|
|
114
187
|
if (kept.length > 0) {
|
|
115
188
|
inner[innerKey] = kept;
|