@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 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 in an old form, or a `.agents/` hook whose file is gone) and a dead `.claude/skills` link - never a developer's own hook or a real directory. Idempotent |
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 (node_modules/@venizia/vulcan or bun is missing) - run bun install\" >&2; exit 2"
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)( --?[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"
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-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-0",
48
- "@venizia/vulcan-okf": "0.2.0-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(9)} ${[id, text].filter(Boolean).join(' ')}\n`);
48
+ process.stderr.write(` ${change.padEnd(width)} ${[id, text].filter(Boolean).join(' ')}\n`);
40
49
  }
41
50
 
42
51
  const settingsBroken = actions.some(
@@ -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
- ...listDirectories({ directory: join(opts.root, 'packages') }).map(name =>
281
- join('packages', name),
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
  }
@@ -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
- /** The directory every shipped hook lives in; a command that runs one is VULCAN's. */
8
- const SHIPPED_HOOKS_PATH = '@venizia/vulcan/base/hooks/';
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
- return JSON.parse(readFileSync(opts.path, 'utf8'));
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 in a form this VULCAN no
43
- * longer writes, or a `.agents/` hook whose file is gone. Any other command is a developer's own.
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.includes(SHIPPED_HOOKS_PATH)) {
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 = (settings?.hooks ?? {}) as THookEntries;
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
- const kept = entries
66
- .map(entry => ({
67
- ...entry,
68
- hooks: (entry.hooks ?? []).filter(hook => hook.command !== opts.command),
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
- commandsOf({ settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }) }),
102
+ extractHookCommands({
103
+ settings: readJson({ path: join(directory, SyncConstants.MANAGED_FILE) }),
104
+ }),
100
105
  );
101
106
  const shipped = new Set(
102
- commandsOf({
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(commandsOf({ settings: readJson({ path: settingsPath }) })),
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
- const deadLinks = existsSync(skills)
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 }))
@@ -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 kept = innerCurrent.filter(entry => !managedKeys.has(toKey({ value: entry })));
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;