@devrik-tools/claude-gates 0.1.2 → 0.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/.claude-plugin/marketplace.json +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- package/registry.json +793 -686
package/cli/install.mjs
CHANGED
|
@@ -1,80 +1,150 @@
|
|
|
1
|
-
// Installs the gates plugin into Claude Code from the selection `init` just wrote.
|
|
2
|
-
// Additive by design: `claude plugin install` merges the plugin's hooks alongside whatever
|
|
3
|
-
// the user already has — it never rewrites their settings. If the `claude` binary is not
|
|
4
|
-
// reachable, this degrades to printing the manual command, and `init` still succeeds.
|
|
5
|
-
|
|
6
|
-
import { execFileSync } from 'node:child_process';
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { SCOPES } from './config.mjs';
|
|
9
|
-
import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
|
|
10
|
-
|
|
11
|
-
const CLAUDE_BIN = 'claude';
|
|
12
|
-
|
|
13
|
-
// Config scope decides where the plugin is installed: a project selection stays local to
|
|
14
|
-
// this project (its .claude/settings.json); a global selection installs for every project.
|
|
15
|
-
const PLUGIN_SCOPE = Object.freeze({
|
|
16
|
-
[SCOPES.PROJECT]: 'project',
|
|
17
|
-
[SCOPES.GLOBAL]: 'user',
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
1
|
+
// Installs the gates plugin into Claude Code from the selection `init` just wrote.
|
|
2
|
+
// Additive by design: `claude plugin install` merges the plugin's hooks alongside whatever
|
|
3
|
+
// the user already has — it never rewrites their settings. If the `claude` binary is not
|
|
4
|
+
// reachable, this degrades to printing the manual command, and `init` still succeeds.
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { SCOPES } from './config.mjs';
|
|
9
|
+
import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
|
|
10
|
+
|
|
11
|
+
const CLAUDE_BIN = 'claude';
|
|
12
|
+
|
|
13
|
+
// Config scope decides where the plugin is installed: a project selection stays local to
|
|
14
|
+
// this project (its .claude/settings.json); a global selection installs for every project.
|
|
15
|
+
const PLUGIN_SCOPE = Object.freeze({
|
|
16
|
+
[SCOPES.PROJECT]: 'project',
|
|
17
|
+
[SCOPES.GLOBAL]: 'user',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function marketplaceManifest() {
|
|
21
|
+
return JSON.parse(readFileSync(MARKETPLACE_PATH, 'utf8').replace(/^/, ''));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The name Claude Code actually registered THIS directory's marketplace under. It is usually
|
|
26
|
+
* the manifest's `name`, but not always: if the user added the same directory earlier under a
|
|
27
|
+
* different name (e.g. `devrik`), Claude Code keeps that original registration name, and
|
|
28
|
+
* `plugin marketplace add` is a no-op that does not rename it. Installing as `plugin@<name>`
|
|
29
|
+
* then fails with "not found in marketplace <name>". So we ask Claude Code which registered
|
|
30
|
+
* marketplace points at our REPOSITORY_ROOT and use that name; we fall back to the manifest
|
|
31
|
+
* name when the listing is unavailable (e.g. no `claude` binary, or a parsing change).
|
|
32
|
+
*/
|
|
33
|
+
function registeredMarketplaceName(runClaude, fallbackName) {
|
|
34
|
+
try {
|
|
35
|
+
const listing = runClaude(['plugin', 'marketplace', 'list']);
|
|
36
|
+
// Each marketplace block prints a name line then a `Source: … (<path>)` line. Find the
|
|
37
|
+
// block whose source path is our repo root and return its name.
|
|
38
|
+
const root = REPOSITORY_ROOT.replace(/[\\/]+$/, '');
|
|
39
|
+
const lines = listing.split(/\r?\n/);
|
|
40
|
+
let currentName = null;
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
const nameMatch = line.match(/^\s*(?:❯\s*)?([A-Za-z0-9_-]+)\s*$/);
|
|
43
|
+
if (nameMatch) currentName = nameMatch[1];
|
|
44
|
+
const sourceMatch = line.match(/Source:.*\(([^)]+)\)/);
|
|
45
|
+
if (sourceMatch && currentName) {
|
|
46
|
+
const sourcePath = sourceMatch[1].replace(/[\\/]+$/, '');
|
|
47
|
+
if (sourcePath.toLowerCase() === root.toLowerCase()) return currentName;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Listing unavailable — fall back to the manifest name below.
|
|
52
|
+
}
|
|
53
|
+
return fallbackName;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every plugin the manifest declares, paired with the marketplace's registered name. */
|
|
57
|
+
function marketplaceAndPlugins(runClaude = realClaude) {
|
|
58
|
+
const manifest = marketplaceManifest();
|
|
59
|
+
const marketplace = registeredMarketplaceName(runClaude, manifest.name);
|
|
60
|
+
return manifest.plugins.map((plugin) => ({
|
|
61
|
+
marketplace,
|
|
62
|
+
plugin: plugin.name,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The `claude plugin install …` lines, one per plugin the manifest declares. */
|
|
67
|
+
export function pluginInstallCommands() {
|
|
68
|
+
return marketplaceAndPlugins().map(
|
|
69
|
+
({ marketplace, plugin }) => `claude plugin install ${plugin}@${marketplace}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Back-compat single-line form: the first plugin's install command. */
|
|
74
|
+
export function pluginInstallCommand() {
|
|
75
|
+
return pluginInstallCommands()[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function realClaude(commandArguments) {
|
|
79
|
+
return execFileSync(CLAUDE_BIN, commandArguments, {
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
stdio: 'pipe',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reasonFor(error) {
|
|
86
|
+
return error?.code === 'ENOENT'
|
|
87
|
+
? 'the `claude` command was not found on PATH'
|
|
88
|
+
: (error?.stderr || error?.message || String(error)).trim().split('\n')[0];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Registers the marketplace (idempotent: a second add just reports it already exists, which
|
|
93
|
+
* is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
|
|
94
|
+
* the config choice. A project that adopts claude-gates gets all of its plugins (gates,
|
|
95
|
+
* tasks, …), not just the first — a single failed install does not stop the rest from being
|
|
96
|
+
* attempted, so one broken plugin never silently blocks another that would have worked.
|
|
97
|
+
*
|
|
98
|
+
* Returns { installed, scope, results } where `installed` is true only if every plugin
|
|
99
|
+
* installed; `results` is a per-plugin { plugin, installed, reason? } list so a caller can
|
|
100
|
+
* report exactly which ones need the manual command.
|
|
101
|
+
*
|
|
102
|
+
* `runClaude` is an injectable seam (defaults to the real `claude` binary) so tests can
|
|
103
|
+
* exercise the multi-plugin partial-failure logic without actually invoking the CLI and
|
|
104
|
+
* installing plugins on the machine running the test.
|
|
105
|
+
*/
|
|
106
|
+
export function installPlugin(
|
|
107
|
+
configScope,
|
|
108
|
+
{ cwd = process.cwd(), runClaude = realClaude } = {},
|
|
109
|
+
) {
|
|
110
|
+
const scope = PLUGIN_SCOPE[configScope] ?? 'user';
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
runClaude(['plugin', 'marketplace', 'add', REPOSITORY_ROOT, '--scope', 'user']);
|
|
114
|
+
} catch {
|
|
115
|
+
// Already registered, or the marketplace add is a no-op — install can still proceed.
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Resolve the registered marketplace name AFTER the add, so a fresh registration is seen and
|
|
119
|
+
// a pre-existing one (under any name) is matched by its source path. Doing it here, not at
|
|
120
|
+
// module top, means the name reflects the live registration this run just ensured.
|
|
121
|
+
const targets = marketplaceAndPlugins(runClaude);
|
|
122
|
+
|
|
123
|
+
const results = targets.map(({ marketplace, plugin }) => {
|
|
124
|
+
try {
|
|
125
|
+
runClaude([
|
|
126
|
+
'plugin',
|
|
127
|
+
'install',
|
|
128
|
+
`${plugin}@${marketplace}`,
|
|
129
|
+
'--yes',
|
|
130
|
+
'--scope',
|
|
131
|
+
scope,
|
|
132
|
+
]);
|
|
133
|
+
return { plugin, installed: true };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { plugin, installed: false, reason: reasonFor(error) };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const installed = results.every((result) => result.installed);
|
|
140
|
+
if (installed) return { installed: true, scope, results };
|
|
141
|
+
|
|
142
|
+
const failed = results.filter((result) => !result.installed);
|
|
143
|
+
return {
|
|
144
|
+
installed: false,
|
|
145
|
+
scope,
|
|
146
|
+
results,
|
|
147
|
+
reason: failed.map((result) => `${result.plugin}: ${result.reason}`).join('; '),
|
|
148
|
+
cwd,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/cli/materialize.mjs
CHANGED
|
@@ -1,51 +1,102 @@
|
|
|
1
|
-
// Materializes each gate's default params into the config that `init` writes, so every
|
|
2
|
-
// configurable value (a whitelist, a pattern list, a threshold) lands in the user's
|
|
3
|
-
// .ai/config.json ready to edit. The values come from the gates themselves — each gate,
|
|
4
|
-
// run with CLAUDE_GATES_DUMP_DEFAULTS set, prints its own defaults — so there is a single
|
|
5
|
-
// source of truth (the gate) and nothing is duplicated in the registry.
|
|
6
|
-
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { REPOSITORY_ROOT } from './constants.mjs';
|
|
10
|
-
import { allGates } from './registry.mjs';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
1
|
+
// Materializes each gate's default params into the config that `init` writes, so every
|
|
2
|
+
// configurable value (a whitelist, a pattern list, a threshold) lands in the user's
|
|
3
|
+
// .ai/config.json ready to edit. The values come from the gates themselves — each gate,
|
|
4
|
+
// run with CLAUDE_GATES_DUMP_DEFAULTS set, prints its own defaults — so there is a single
|
|
5
|
+
// source of truth (the gate) and nothing is duplicated in the registry.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { REPOSITORY_ROOT } from './constants.mjs';
|
|
10
|
+
import { allGates } from './registry.mjs';
|
|
11
|
+
import { MODES } from './selection.mjs';
|
|
12
|
+
|
|
13
|
+
const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
|
|
14
|
+
|
|
15
|
+
/** A gate's `hooks/` directory: each plugin (registry's per-family `plugin`) owns its own. */
|
|
16
|
+
function pluginHooksDir(pluginName) {
|
|
17
|
+
return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs one gate in defaults-dump mode and returns its built-in params. Not every gate
|
|
22
|
+
* supports the defaults-dump protocol (only the `gates` plugin's PreToolUse gates do, via
|
|
23
|
+
* runGate in hook-io.mjs); a script that does not recognize the env var, does not exist, or
|
|
24
|
+
* errors for any other reason yields {} rather than aborting the whole init — the same
|
|
25
|
+
* fallback already relied on before multi-plugin support.
|
|
26
|
+
*/
|
|
27
|
+
function defaultParametersOf(gate) {
|
|
28
|
+
try {
|
|
29
|
+
const out = execFileSync(
|
|
30
|
+
process.execPath,
|
|
31
|
+
[join(pluginHooksDir(gate.plugin), gate.script)],
|
|
32
|
+
{ encoding: 'utf8', env: { ...process.env, [DUMP_ENV]: '1' } },
|
|
33
|
+
);
|
|
34
|
+
return JSON.parse(out).defaultParams ?? {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The existing entry's params, stripped of `enabled` — {} for a boolean or missing entry. */
|
|
41
|
+
function existingParamsOf(existingEntry) {
|
|
42
|
+
if (!existingEntry || typeof existingEntry !== 'object') return {};
|
|
43
|
+
const { enabled: _enabled, ...params } = existingEntry;
|
|
44
|
+
return params;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turns the flat `{ configKey: enabled }` selection into the config's gates map. A gate
|
|
49
|
+
* that declares params (per the registry) becomes `{ enabled, ...defaults }` so its knobs
|
|
50
|
+
* are visible and editable; a gate with no params stays a plain boolean, keeping the file
|
|
51
|
+
* compact. Only gates with params are spawned, so paramless selections cost nothing.
|
|
52
|
+
*
|
|
53
|
+
* `existingGates` (the project's current config, if any) and `mode` (the selection mode
|
|
54
|
+
* this run used) together decide how much of an already-present gate survives:
|
|
55
|
+
*
|
|
56
|
+
* - mode === DEFAULTS: a "fill the gaps" sweep, not a directed choice. A gate already in
|
|
57
|
+
* `existingGates` is carried over VERBATIM (enabled + every param) — materializing
|
|
58
|
+
* defaults must never be what resets a disablement or a custom pattern list the user
|
|
59
|
+
* deliberately set.
|
|
60
|
+
* - any other mode (ALL / FAMILIES / GRANULAR): the user named this gate (directly, or
|
|
61
|
+
* via its family) this run, so `enabled` follows the new selection — that is the whole
|
|
62
|
+
* point of picking it. Its PARAMS still survive from the existing entry, since there is
|
|
63
|
+
* no per-run way to pass new param values through `init`'s flags today; only `enabled`
|
|
64
|
+
* is something this run actually decided.
|
|
65
|
+
*
|
|
66
|
+
* A gate absent from `existingGates` always gets a freshly computed value regardless of
|
|
67
|
+
* mode. `mergeConfig` still does the key-by-key merge against the rest of the file
|
|
68
|
+
* (unrelated top-level keys, gates the new registry dropped).
|
|
69
|
+
*/
|
|
70
|
+
export function materializeGates(
|
|
71
|
+
registry,
|
|
72
|
+
enabledMap,
|
|
73
|
+
existingGates = {},
|
|
74
|
+
mode = MODES.DEFAULTS,
|
|
75
|
+
) {
|
|
76
|
+
const gates = {};
|
|
77
|
+
for (const gate of allGates(registry)) {
|
|
78
|
+
const hasExisting = Object.prototype.hasOwnProperty.call(
|
|
79
|
+
existingGates,
|
|
80
|
+
gate.configKey,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (hasExisting && mode === MODES.DEFAULTS) {
|
|
84
|
+
gates[gate.configKey] = existingGates[gate.configKey];
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const enabled = enabledMap[gate.configKey] === true;
|
|
89
|
+
const hasParameters = Array.isArray(gate.params) && gate.params.length > 0;
|
|
90
|
+
if (!hasParameters) {
|
|
91
|
+
gates[gate.configKey] = enabled;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const defaults = defaultParametersOf(gate);
|
|
96
|
+
const params = hasExisting
|
|
97
|
+
? { ...defaults, ...existingParamsOf(existingGates[gate.configKey]) }
|
|
98
|
+
: defaults;
|
|
99
|
+
gates[gate.configKey] = { enabled, ...params };
|
|
100
|
+
}
|
|
101
|
+
return gates;
|
|
102
|
+
}
|
package/cli/registry.mjs
CHANGED
|
@@ -1,127 +1,136 @@
|
|
|
1
|
-
// Loads and validates registry.json — the catalog that menus, config and hooks derive from.
|
|
2
|
-
// Validation is a zod schema so the shape is declared once and errors are uniform.
|
|
3
|
-
// Fails loudly on a corrupt catalog: a menu built on invalid data would write a
|
|
4
|
-
// configuration that no gate recognizes.
|
|
5
|
-
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { z } from 'zod';
|
|
8
|
-
import { REGISTRY_PATH } from './constants.mjs';
|
|
9
|
-
|
|
10
|
-
export const HOOK_EVENTS = [
|
|
11
|
-
'PreToolUse',
|
|
12
|
-
'SessionStart',
|
|
13
|
-
'PostToolUse',
|
|
14
|
-
'Stop',
|
|
15
|
-
'UserPromptSubmit',
|
|
16
|
-
];
|
|
17
|
-
export const TOOL_GROUPS = [
|
|
18
|
-
'write',
|
|
19
|
-
'shell',
|
|
20
|
-
'delegation',
|
|
21
|
-
'execution',
|
|
22
|
-
'question',
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
/** The kinds of value a gate param can take, so the CLI can describe and validate it. */
|
|
26
|
-
export const PARAM_TYPES = ['string', 'number', 'boolean', 'string[]'];
|
|
27
|
-
|
|
28
|
-
const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
29
|
-
const CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
|
|
30
|
-
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
31
|
-
// A gate's script is a path relative to the plugin's hooks/ directory: `gates/x.mjs`
|
|
32
|
-
// (a PreToolUse gate) or `x.mjs` (a session hook). Never absolute, never climbing out.
|
|
33
|
-
const SCRIPT_PATTERN = /^(?!\/|[A-Za-z]:|\.\.\/)[\w./-]+\.mjs$/;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A configurable param a project may override. The registry declares that the param
|
|
37
|
-
* EXISTS and its type; the default VALUE lives in the gate's own source, so the user
|
|
38
|
-
* reads it there and knows exactly what a project override replaces.
|
|
39
|
-
*/
|
|
40
|
-
const parameterSchema = z.object({
|
|
41
|
-
name: z.string().regex(CONFIG_KEY_PATTERN, 'param name must be camelCase'),
|
|
42
|
-
type: z.enum(PARAM_TYPES),
|
|
43
|
-
description: z.string().min(1),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const gateSchema = z.object({
|
|
47
|
-
id: z.string().regex(ID_PATTERN, 'gate id must be kebab-case'),
|
|
48
|
-
configKey: z
|
|
49
|
-
.string()
|
|
50
|
-
.regex(CONFIG_KEY_PATTERN, 'configKey must be camelCase'),
|
|
51
|
-
default: z.boolean(),
|
|
52
|
-
event: z.enum(HOOK_EVENTS),
|
|
53
|
-
tools: z.array(z.enum(TOOL_GROUPS)),
|
|
54
|
-
script: z
|
|
55
|
-
.string()
|
|
56
|
-
.regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
|
|
57
|
-
description: z.string().min(1),
|
|
58
|
-
// Optional: a gate with no configurable params omits it.
|
|
59
|
-
params: z.array(parameterSchema).optional(),
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const familySchema = z.object({
|
|
63
|
-
id: z.string().regex(ID_PATTERN, 'family id must be kebab-case'),
|
|
64
|
-
name: z.string().min(1),
|
|
65
|
-
description: z.string().min(1),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
...duplicatesIn(
|
|
90
|
-
(id) => `duplicate
|
|
91
|
-
),
|
|
92
|
-
...duplicatesIn(gates.map((gate) => gate?.
|
|
93
|
-
(
|
|
94
|
-
),
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
// Loads and validates registry.json — the catalog that menus, config and hooks derive from.
|
|
2
|
+
// Validation is a zod schema so the shape is declared once and errors are uniform.
|
|
3
|
+
// Fails loudly on a corrupt catalog: a menu built on invalid data would write a
|
|
4
|
+
// configuration that no gate recognizes.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { REGISTRY_PATH } from './constants.mjs';
|
|
9
|
+
|
|
10
|
+
export const HOOK_EVENTS = [
|
|
11
|
+
'PreToolUse',
|
|
12
|
+
'SessionStart',
|
|
13
|
+
'PostToolUse',
|
|
14
|
+
'Stop',
|
|
15
|
+
'UserPromptSubmit',
|
|
16
|
+
];
|
|
17
|
+
export const TOOL_GROUPS = [
|
|
18
|
+
'write',
|
|
19
|
+
'shell',
|
|
20
|
+
'delegation',
|
|
21
|
+
'execution',
|
|
22
|
+
'question',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** The kinds of value a gate param can take, so the CLI can describe and validate it. */
|
|
26
|
+
export const PARAM_TYPES = ['string', 'number', 'boolean', 'string[]'];
|
|
27
|
+
|
|
28
|
+
const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
29
|
+
const CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
|
|
30
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
31
|
+
// A gate's script is a path relative to the plugin's hooks/ directory: `gates/x.mjs`
|
|
32
|
+
// (a PreToolUse gate) or `x.mjs` (a session hook). Never absolute, never climbing out.
|
|
33
|
+
const SCRIPT_PATTERN = /^(?!\/|[A-Za-z]:|\.\.\/)[\w./-]+\.mjs$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A configurable param a project may override. The registry declares that the param
|
|
37
|
+
* EXISTS and its type; the default VALUE lives in the gate's own source, so the user
|
|
38
|
+
* reads it there and knows exactly what a project override replaces.
|
|
39
|
+
*/
|
|
40
|
+
const parameterSchema = z.object({
|
|
41
|
+
name: z.string().regex(CONFIG_KEY_PATTERN, 'param name must be camelCase'),
|
|
42
|
+
type: z.enum(PARAM_TYPES),
|
|
43
|
+
description: z.string().min(1),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const gateSchema = z.object({
|
|
47
|
+
id: z.string().regex(ID_PATTERN, 'gate id must be kebab-case'),
|
|
48
|
+
configKey: z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(CONFIG_KEY_PATTERN, 'configKey must be camelCase'),
|
|
51
|
+
default: z.boolean(),
|
|
52
|
+
event: z.enum(HOOK_EVENTS),
|
|
53
|
+
tools: z.array(z.enum(TOOL_GROUPS)),
|
|
54
|
+
script: z
|
|
55
|
+
.string()
|
|
56
|
+
.regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
|
|
57
|
+
description: z.string().min(1),
|
|
58
|
+
// Optional: a gate with no configurable params omits it.
|
|
59
|
+
params: z.array(parameterSchema).optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const familySchema = z.object({
|
|
63
|
+
id: z.string().regex(ID_PATTERN, 'family id must be kebab-case'),
|
|
64
|
+
name: z.string().min(1),
|
|
65
|
+
description: z.string().min(1),
|
|
66
|
+
// Which plugin's hooks/ directory the family's gate scripts resolve against. Optional so
|
|
67
|
+
// existing families need no change: absent means the original 'gates' plugin.
|
|
68
|
+
plugin: z.string().regex(ID_PATTERN, 'plugin id must be kebab-case').optional(),
|
|
69
|
+
gates: z.array(gateSchema).min(1),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function duplicatesIn(items) {
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const duplicates = new Set();
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
if (seen.has(item)) duplicates.add(item);
|
|
77
|
+
seen.add(item);
|
|
78
|
+
}
|
|
79
|
+
return [...duplicates];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Uniqueness across families is not expressible per-field, so it is a second pass. */
|
|
83
|
+
function duplicateProblems(candidate) {
|
|
84
|
+
const families = Array.isArray(candidate?.families) ? candidate.families : [];
|
|
85
|
+
const gates = families.flatMap((family) =>
|
|
86
|
+
Array.isArray(family?.gates) ? family.gates : [],
|
|
87
|
+
);
|
|
88
|
+
return [
|
|
89
|
+
...duplicatesIn(families.map((family) => family?.id)).map(
|
|
90
|
+
(id) => `duplicate family id: ${id}`,
|
|
91
|
+
),
|
|
92
|
+
...duplicatesIn(gates.map((gate) => gate?.id)).map(
|
|
93
|
+
(id) => `duplicate gate id: ${id}`,
|
|
94
|
+
),
|
|
95
|
+
...duplicatesIn(gates.map((gate) => gate?.configKey)).map(
|
|
96
|
+
(key) => `duplicate configKey: ${key}`,
|
|
97
|
+
),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const registrySchema = z.object({
|
|
102
|
+
gateVersion: z.string().regex(VERSION_PATTERN, 'gateVersion must be x.y.z'),
|
|
103
|
+
families: z.array(familySchema).min(1),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Returns a list of human-readable problems; empty when the registry is valid. */
|
|
107
|
+
export function validateRegistry(candidate) {
|
|
108
|
+
const result = registrySchema.safeParse(candidate);
|
|
109
|
+
const schemaProblems = result.success
|
|
110
|
+
? []
|
|
111
|
+
: result.error.issues.map(
|
|
112
|
+
(issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`,
|
|
113
|
+
);
|
|
114
|
+
return [...schemaProblems, ...duplicateProblems(candidate)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function loadRegistry(path = REGISTRY_PATH) {
|
|
118
|
+
const candidate = JSON.parse(readFileSync(path, 'utf8'));
|
|
119
|
+
const problems = validateRegistry(candidate);
|
|
120
|
+
if (problems.length > 0) {
|
|
121
|
+
throw new Error(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
|
|
122
|
+
}
|
|
123
|
+
return candidate;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DEFAULT_PLUGIN = 'gates';
|
|
127
|
+
|
|
128
|
+
export function allGates(registry) {
|
|
129
|
+
return registry.families.flatMap((family) =>
|
|
130
|
+
family.gates.map((gate) => ({
|
|
131
|
+
...gate,
|
|
132
|
+
family: family.id,
|
|
133
|
+
plugin: family.plugin ?? DEFAULT_PLUGIN,
|
|
134
|
+
})),
|
|
135
|
+
);
|
|
136
|
+
}
|