@devrik-tools/claude-gates 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
@@ -0,0 +1,44 @@
1
+ // The registry and the gates on disk must agree: every PreToolUse gate the registry
2
+ // declares has to have a script, and every gate folder on disk has to be declared. A
3
+ // mismatch (a registry entry with no file, like the old `autocommit`, or an orphan folder)
4
+ // is what made `init` spawn a non-existent gate — this test blocks it from returning.
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, readdirSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { test } from 'node:test';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { loadRegistry, allGates } from '../registry.mjs';
12
+
13
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
14
+ const GATES_DIR = join(REPO, 'plugins', 'gates', 'hooks', 'gates');
15
+
16
+ const registry = loadRegistry();
17
+ const preToolUseGates = allGates(registry).filter(
18
+ (gate) => gate.event === 'PreToolUse',
19
+ );
20
+
21
+ test('every PreToolUse gate in the registry has its script on disk', () => {
22
+ for (const gate of preToolUseGates) {
23
+ const scriptPath = join(REPO, 'plugins', 'gates', 'hooks', gate.script);
24
+ assert.ok(
25
+ existsSync(scriptPath),
26
+ `registry declares gate "${gate.id}" (script ${gate.script}) but the file is missing`,
27
+ );
28
+ }
29
+ });
30
+
31
+ test('every gate folder on disk is declared in the registry', () => {
32
+ const declaredScripts = new Set(preToolUseGates.map((gate) => gate.script));
33
+ const folders = readdirSync(GATES_DIR, { withFileTypes: true })
34
+ .filter((entry) => entry.isDirectory())
35
+ .map((entry) => entry.name);
36
+
37
+ for (const folder of folders) {
38
+ const expectedScript = `gates/${folder}/index.mjs`;
39
+ assert.ok(
40
+ declaredScripts.has(expectedScript),
41
+ `gate folder "${folder}" exists on disk but no registry entry points to ${expectedScript}`,
42
+ );
43
+ }
44
+ });
@@ -0,0 +1,200 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { loadRegistry, validateRegistry, allGates } from '../registry.mjs';
4
+ import {
5
+ MODES,
6
+ resolveSelection,
7
+ adoptionOf,
8
+ summarize,
9
+ } from '../selection.mjs';
10
+
11
+ const registry = loadRegistry();
12
+ const keys = allGates(registry).map((gate) => gate.configKey);
13
+
14
+ test('the shipped registry is valid', () => {
15
+ assert.deepEqual(validateRegistry(registry), []);
16
+ });
17
+
18
+ test('validateRegistry catches duplicate configKey and unknown event', () => {
19
+ const broken = {
20
+ gateVersion: '1.0.0',
21
+ families: [
22
+ {
23
+ id: 'x',
24
+ name: 'X',
25
+ gates: [
26
+ {
27
+ id: 'a',
28
+ configKey: 'k',
29
+ default: true,
30
+ event: 'PreToolUse',
31
+ tools: [],
32
+ description: 'a',
33
+ },
34
+ {
35
+ id: 'b',
36
+ configKey: 'k',
37
+ default: true,
38
+ event: 'Nope',
39
+ tools: [],
40
+ description: 'b',
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ };
46
+ const problems = validateRegistry(broken);
47
+ assert.ok(problems.some((problem) => /duplicate configKey: k/.test(problem)));
48
+ assert.ok(problems.some((problem) => /gates\.1\.event/.test(problem)));
49
+ });
50
+
51
+ // A one-gate registry whose gate fields are overridable, so each test tweaks only the
52
+ // field it checks. Every level carries its required description/name.
53
+ function oneGateRegistry(gateOverrides) {
54
+ return {
55
+ gateVersion: '1.0.0',
56
+ families: [
57
+ {
58
+ id: 'x',
59
+ name: 'X',
60
+ description: 'family x',
61
+ gates: [
62
+ {
63
+ id: 'a',
64
+ configKey: 'k',
65
+ default: true,
66
+ event: 'PreToolUse',
67
+ tools: [],
68
+ script: 'gates/a.mjs',
69
+ description: 'gate a',
70
+ ...gateOverrides,
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ };
76
+ }
77
+
78
+ const ABSOLUTE_SCRIPT = `${'/'}etc/evil.mjs`;
79
+
80
+ test('validateRegistry requires a valid relative .mjs script per gate', () => {
81
+ const withoutScript = oneGateRegistry({});
82
+ delete withoutScript.families[0].gates[0].script;
83
+ assert.ok(validateRegistry(withoutScript).some((p) => /script/.test(p)));
84
+
85
+ assert.ok(
86
+ validateRegistry(oneGateRegistry({ script: ABSOLUTE_SCRIPT })).some((p) =>
87
+ /script/.test(p),
88
+ ),
89
+ );
90
+ assert.ok(
91
+ validateRegistry(oneGateRegistry({ script: '../escape.mjs' })).some((p) =>
92
+ /script/.test(p),
93
+ ),
94
+ );
95
+ assert.deepEqual(
96
+ validateRegistry(oneGateRegistry({ script: 'gates/ok.mjs' })),
97
+ [],
98
+ );
99
+ });
100
+
101
+ test('validateRegistry rejects a param that is not camelCase or has a bad type', () => {
102
+ assert.ok(
103
+ validateRegistry(
104
+ oneGateRegistry({
105
+ params: [{ name: 'Bad-Name', type: 'string', description: 'd' }],
106
+ }),
107
+ ).length > 0,
108
+ );
109
+ assert.ok(
110
+ validateRegistry(
111
+ oneGateRegistry({
112
+ params: [{ name: 'ok', type: 'regex', description: 'd' }],
113
+ }),
114
+ ).length > 0,
115
+ );
116
+ assert.deepEqual(
117
+ validateRegistry(
118
+ oneGateRegistry({
119
+ params: [{ name: 'ok', type: 'string[]', description: 'd' }],
120
+ }),
121
+ ),
122
+ [],
123
+ );
124
+ });
125
+
126
+ test('ALL enables every key; NONE disables every key; both cover all keys', () => {
127
+ const all = resolveSelection(registry, MODES.ALL);
128
+ const none = resolveSelection(registry, MODES.NONE);
129
+ assert.deepEqual(Object.keys(all).sort(), [...keys].sort());
130
+ assert.ok(Object.values(all).every((v) => v === true));
131
+ assert.ok(Object.values(none).every((v) => v === false));
132
+ });
133
+
134
+ test("DEFAULTS mirrors the registry's default flags", () => {
135
+ const map = resolveSelection(registry, MODES.DEFAULTS);
136
+ for (const gate of allGates(registry))
137
+ assert.equal(map[gate.configKey], gate.default, gate.id);
138
+ });
139
+
140
+ test('FAMILIES enables exactly the picked families', () => {
141
+ const map = resolveSelection(registry, MODES.FAMILIES, {
142
+ families: ['security'],
143
+ });
144
+ for (const gate of allGates(registry))
145
+ assert.equal(map[gate.configKey], gate.family === 'security', gate.id);
146
+ });
147
+
148
+ test('GRANULAR enables exactly the picked gates', () => {
149
+ const map = resolveSelection(registry, MODES.GRANULAR, {
150
+ gates: ['bash-commands', 'no-reconfirm'],
151
+ });
152
+ const enabled = Object.entries(map)
153
+ .filter(([, v]) => v)
154
+ .map(([k]) => k)
155
+ .sort();
156
+ assert.deepEqual(
157
+ enabled,
158
+ ['blockDestructiveShellCommands', 'requireNoReconfirmOfApproved'].sort(),
159
+ );
160
+ });
161
+
162
+ test('unknown ids fail loudly instead of being ignored', () => {
163
+ assert.throws(
164
+ () => resolveSelection(registry, MODES.FAMILIES, { families: ['nope'] }),
165
+ /Unknown family/,
166
+ );
167
+ assert.throws(
168
+ () => resolveSelection(registry, MODES.GRANULAR, { gates: ['nope'] }),
169
+ /Unknown gate/,
170
+ );
171
+ assert.throws(
172
+ () => resolveSelection(registry, 'weird'),
173
+ /Unknown selection mode/,
174
+ );
175
+ });
176
+
177
+ test('adoptionOf: true / false / partial', () => {
178
+ assert.equal(adoptionOf(resolveSelection(registry, MODES.ALL)), true);
179
+ assert.equal(adoptionOf(resolveSelection(registry, MODES.NONE)), false);
180
+ assert.equal(
181
+ adoptionOf(
182
+ resolveSelection(registry, MODES.FAMILIES, { families: ['security'] }),
183
+ ),
184
+ 'partial',
185
+ );
186
+ });
187
+
188
+ test('summarize counts per family', () => {
189
+ const rows = summarize(
190
+ registry,
191
+ resolveSelection(registry, MODES.FAMILIES, { families: ['security'] }),
192
+ );
193
+ const security = rows.find((row) => row.family === 'security');
194
+ assert.equal(security.enabled.length, security.total);
195
+ assert.ok(
196
+ rows
197
+ .filter((row) => row.family !== 'security')
198
+ .every((row) => row.enabled.length === 0),
199
+ );
200
+ });
package/cli/config.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // Where the selection is persisted and how it merges with what is already there.
2
+ //
3
+ // Scope "project": `<project root>/.ai/config.json` — the file the hooks read
4
+ // per project. Other keys the project already has (autoCommit, etc.) are kept.
5
+ // Scope "global": `~/.claude/claude-gates/config.json` — the fallback the hooks
6
+ // use when a project has no answer of its own.
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { findUpSync } from 'find-up';
11
+ import {
12
+ CLAUDE_USER_DIRECTORY,
13
+ CONFIG_FILE,
14
+ GLOBAL_STATE_DIRECTORY,
15
+ HOME_DIRECTORY,
16
+ JSON_INDENT,
17
+ PROJECT_ROOT_MARKERS,
18
+ PROJECT_STATE_DIRECTORY,
19
+ } from './constants.mjs';
20
+
21
+ export const SCOPES = Object.freeze({ GLOBAL: 'global', PROJECT: 'project' });
22
+
23
+ /**
24
+ * Climbs from `startDirectory` to the nearest project marker (`.git`/`.ai`).
25
+ * The home directory (and anything above it) is never a project root: a stray
26
+ * `~/.ai` would otherwise turn every folder under home into "the project" and the
27
+ * config would land in the wrong place silently. `.git` may be a file (worktrees),
28
+ * so the matcher checks existence, not type.
29
+ */
30
+ export function findProjectRoot(
31
+ startDirectory,
32
+ { home = HOME_DIRECTORY } = {},
33
+ ) {
34
+ if (startDirectory === home) return startDirectory;
35
+ const markerDirectory = findUpSync(
36
+ (directory) => {
37
+ if (directory === home) return findUpSync.stop;
38
+ const hasMarker = PROJECT_ROOT_MARKERS.some((marker) =>
39
+ existsSync(join(directory, marker)),
40
+ );
41
+ return hasMarker ? directory : undefined;
42
+ },
43
+ { cwd: startDirectory, stopAt: home, type: 'directory' },
44
+ );
45
+ return markerDirectory ?? startDirectory;
46
+ }
47
+
48
+ export function configPathFor(
49
+ scope,
50
+ { cwd = process.cwd(), home = HOME_DIRECTORY } = {},
51
+ ) {
52
+ if (scope === SCOPES.GLOBAL)
53
+ return join(
54
+ home,
55
+ CLAUDE_USER_DIRECTORY,
56
+ GLOBAL_STATE_DIRECTORY,
57
+ CONFIG_FILE,
58
+ );
59
+ if (scope === SCOPES.PROJECT)
60
+ return join(
61
+ findProjectRoot(cwd, { home }),
62
+ PROJECT_STATE_DIRECTORY,
63
+ CONFIG_FILE,
64
+ );
65
+ throw new Error(`Unknown scope: ${scope}`);
66
+ }
67
+
68
+ export function readConfig(path) {
69
+ if (!existsSync(path)) return { exists: false, data: {}, corrupt: false };
70
+ try {
71
+ return {
72
+ exists: true,
73
+ data: JSON.parse(readFileSync(path, 'utf8')),
74
+ corrupt: false,
75
+ };
76
+ } catch {
77
+ return { exists: true, data: {}, corrupt: true };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Merges one gate's new value onto whatever the project already had, preserving the user's
83
+ * edits. A gate with params arrives as `{ enabled, ...defaults }`; if the user already
84
+ * tuned those params, their values win and only `enabled` follows the new selection. A
85
+ * paramless gate is a plain boolean. This is what keeps a re-run of `init` additive: it
86
+ * never overwrites a whitelist or pattern list the user changed by hand.
87
+ */
88
+ function mergeGate(existingValue, newValue) {
89
+ if (typeof newValue === 'boolean') return newValue;
90
+ if (existingValue && typeof existingValue === 'object') {
91
+ return { ...newValue, ...existingValue, enabled: newValue.enabled };
92
+ }
93
+ return newValue;
94
+ }
95
+
96
+ /**
97
+ * Merges the new selection into an existing config without touching unrelated keys. Gates
98
+ * are merged key by key: a gate absent from the new map keeps its old value (a registry
99
+ * that dropped a gate must not silently flip it), and a gate the user configured keeps its
100
+ * params (see mergeGate).
101
+ */
102
+ export function mergeConfig(existing, { adopted, gates, gateVersion }) {
103
+ const mergedGates = { ...(existing.gates ?? {}) };
104
+ for (const [key, value] of Object.entries(gates)) {
105
+ mergedGates[key] = mergeGate(mergedGates[key], value);
106
+ }
107
+ return { ...existing, adopted, gateVersion, gates: mergedGates };
108
+ }
109
+
110
+ export function writeConfig(path, data) {
111
+ mkdirSync(dirname(path), { recursive: true });
112
+ writeFileSync(path, `${JSON.stringify(data, null, JSON_INDENT)}\n`, 'utf8');
113
+ }
@@ -0,0 +1,36 @@
1
+ // Every fixed value the CLI relies on, in one place. Nothing else in `cli/` may
2
+ // carry a literal path segment, file name or exit code (enforced by eslint:
3
+ // no-magic-numbers + no-restricted-syntax on path-like strings).
4
+
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const CLI_DIRECTORY = dirname(fileURLToPath(import.meta.url));
10
+
11
+ export const REPOSITORY_ROOT = join(CLI_DIRECTORY, '..');
12
+ export const REGISTRY_FILE = 'registry.json';
13
+ export const REGISTRY_PATH = join(REPOSITORY_ROOT, REGISTRY_FILE);
14
+ export const MARKETPLACE_PATH = join(
15
+ REPOSITORY_ROOT,
16
+ '.claude-plugin',
17
+ 'marketplace.json',
18
+ );
19
+
20
+ /** Project-scope config lives next to the project's other `.ai/` state. */
21
+ export const PROJECT_STATE_DIRECTORY = '.ai';
22
+ export const CONFIG_FILE = 'config.json';
23
+ /** Markers that identify a project root while climbing from the cwd. */
24
+ export const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
25
+
26
+ /** Global-scope config lives under Claude Code's own user directory. */
27
+ export const CLAUDE_USER_DIRECTORY = '.claude';
28
+ export const GLOBAL_STATE_DIRECTORY = 'claude-gates';
29
+
30
+ export const HOME_DIRECTORY = homedir();
31
+
32
+ export const EXIT_CODE = Object.freeze({ SUCCESS: 0, FAILURE: 1 });
33
+
34
+ export const JSON_INDENT = 2;
35
+
36
+ export const LIST_SEPARATOR = ',';
package/cli/index.mjs ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Entry point (commander). Commands:
3
+ // init interactive (or flag-driven) selection of gates, per project or globally
4
+ // registry --check validates registry.json; --list prints the catalog
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { Command } from 'commander';
9
+ import {
10
+ EXIT_CODE,
11
+ LIST_SEPARATOR,
12
+ REGISTRY_PATH,
13
+ REPOSITORY_ROOT,
14
+ } from './constants.mjs';
15
+ import { loadRegistry, validateRegistry } from './registry.mjs';
16
+
17
+ const packageManifest = JSON.parse(
18
+ readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
19
+ );
20
+
21
+ function fail(message) {
22
+ process.stderr.write(`${message}\n`);
23
+ process.exit(EXIT_CODE.FAILURE);
24
+ }
25
+
26
+ function splitList(value) {
27
+ return value
28
+ .split(LIST_SEPARATOR)
29
+ .map((item) => item.trim())
30
+ .filter(Boolean);
31
+ }
32
+
33
+ function registryCheck() {
34
+ const problems = validateRegistry(
35
+ JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
36
+ );
37
+ if (problems.length > 0) {
38
+ fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
39
+ }
40
+ process.stdout.write('registry.json OK\n');
41
+ }
42
+
43
+ function registryList() {
44
+ const registry = loadRegistry();
45
+ for (const family of registry.families) {
46
+ process.stdout.write(`${family.name} (${family.id})\n`);
47
+ for (const gate of family.gates) {
48
+ const marker = gate.default ? '*' : ' ';
49
+ process.stdout.write(` ${marker} ${gate.id} ${gate.configKey}\n`);
50
+ }
51
+ }
52
+ process.stdout.write('\n* = recommended default\n');
53
+ }
54
+
55
+ const program = new Command()
56
+ .name(packageManifest.name)
57
+ .description(packageManifest.description)
58
+ .version(packageManifest.version);
59
+
60
+ program
61
+ .command('init')
62
+ .description('Choose which gates to adopt and write the config.')
63
+ .option('--project', 'apply to this project (<root>/.ai/config.json)')
64
+ .option('--global', 'apply globally (~/.claude/claude-gates/config.json)')
65
+ .option('--defaults', 'enable the recommended defaults')
66
+ .option('--all', 'enable every gate')
67
+ .option('--none', "record a 'no' so you are not asked again")
68
+ .option(
69
+ '--families <ids>',
70
+ 'enable whole families, comma-separated',
71
+ splitList,
72
+ )
73
+ .option(
74
+ '--gates <ids>',
75
+ 'enable individual gates, comma-separated',
76
+ splitList,
77
+ )
78
+ .option('-y, --yes', 'never prompt; use flags and defaults')
79
+ .option('--no-install', 'write the config but do not install the plugin')
80
+ .option('--dry-run', 'show the selection without writing')
81
+ .action(async (options) => {
82
+ const { runInit } = await import('./init.mjs');
83
+ await runInit(options);
84
+ });
85
+
86
+ program
87
+ .command('registry')
88
+ .description('Inspect registry.json.')
89
+ .option('--check', 'validate the registry')
90
+ .option('--list', 'print families and gates')
91
+ .action((options) => {
92
+ if (options.check) return registryCheck();
93
+ if (options.list) return registryList();
94
+ return fail('registry needs --check or --list');
95
+ });
96
+
97
+ program.parseAsync(process.argv).catch((error) => fail(error.message));