@devrik-tools/claude-gates 0.1.2 → 0.1.3
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 +23 -17
- 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/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 +74 -73
- 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 +9 -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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The `task` CLI subcommands are thin wiring over task-store.mjs; these tests exercise the
|
|
2
|
+
// CLI surface itself (spawning `cli/index.mjs task ...` as a real child process, the way a
|
|
3
|
+
// user or the register-requests hook's advice actually invokes it) against a temp project,
|
|
4
|
+
// never against this repo's own .ai/config.json or task store.
|
|
5
|
+
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const CLI_ENTRY = join(
|
|
15
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
16
|
+
'..',
|
|
17
|
+
'index.mjs',
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
function makeProject() {
|
|
21
|
+
const project = mkdtempSync(join(tmpdir(), 'task-cli-'));
|
|
22
|
+
mkdirSync(join(project, '.git'));
|
|
23
|
+
return project;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runCli(args, cwd) {
|
|
27
|
+
try {
|
|
28
|
+
const stdout = execFileSync(process.execPath, [CLI_ENTRY, ...args], {
|
|
29
|
+
cwd,
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
});
|
|
32
|
+
return { code: 0, stdout };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
code: error.status ?? 1,
|
|
36
|
+
stdout: error.stdout ?? '',
|
|
37
|
+
stderr: error.stderr ?? String(error),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('task add persists a new open task under .ai/tasks/active.json', () => {
|
|
43
|
+
const project = makeProject();
|
|
44
|
+
const result = runCli(['task', 'add', 'Fix the thing', '--description', 'details'], project);
|
|
45
|
+
assert.equal(result.code, 0, result.stderr);
|
|
46
|
+
|
|
47
|
+
const activePath = join(project, '.ai', 'tasks', 'active.json');
|
|
48
|
+
assert.ok(existsSync(activePath));
|
|
49
|
+
const active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
50
|
+
assert.equal(active.tasks.length, 1);
|
|
51
|
+
assert.equal(active.tasks[0].title, 'Fix the thing');
|
|
52
|
+
assert.equal(active.tasks[0].status, 'open');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('task list shows the active task, and --all also reaches history', () => {
|
|
56
|
+
const project = makeProject();
|
|
57
|
+
runCli(['task', 'add', 'Task one', '--id', 't1'], project);
|
|
58
|
+
runCli(['task', 'add', 'Task two', '--id', 't2'], project);
|
|
59
|
+
runCli(['task', 'abandon', 't2', '--reason', 'not needed'], project);
|
|
60
|
+
|
|
61
|
+
const active = runCli(['task', 'list'], project);
|
|
62
|
+
assert.match(active.stdout, /Task one/);
|
|
63
|
+
assert.doesNotMatch(active.stdout, /Task two/);
|
|
64
|
+
|
|
65
|
+
const all = runCli(['task', 'list', '--all'], project);
|
|
66
|
+
assert.match(all.stdout, /Task one/);
|
|
67
|
+
assert.match(all.stdout, /Task two/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('task close WITHOUT --evidence fails and leaves the task active', () => {
|
|
71
|
+
const project = makeProject();
|
|
72
|
+
runCli(['task', 'add', 'Needs evidence', '--id', 't1'], project);
|
|
73
|
+
|
|
74
|
+
const result = runCli(['task', 'close', 't1'], project);
|
|
75
|
+
assert.notEqual(result.code, 0);
|
|
76
|
+
assert.match(result.stderr, /evidence/i);
|
|
77
|
+
|
|
78
|
+
const activePath = join(project, '.ai', 'tasks', 'active.json');
|
|
79
|
+
const active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
80
|
+
assert.equal(active.tasks.length, 1, 'the task must still be active');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('task close WITH --evidence succeeds and moves the task to history', () => {
|
|
84
|
+
const project = makeProject();
|
|
85
|
+
runCli(['task', 'add', 'Has evidence', '--id', 't1'], project);
|
|
86
|
+
|
|
87
|
+
const result = runCli(
|
|
88
|
+
['task', 'close', 't1', '--evidence', 'npm test -> 5 passing'],
|
|
89
|
+
project,
|
|
90
|
+
);
|
|
91
|
+
assert.equal(result.code, 0, result.stderr);
|
|
92
|
+
|
|
93
|
+
const historyPath = join(project, '.ai', 'tasks', 'history.json');
|
|
94
|
+
const history = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
95
|
+
assert.equal(history.tasks.length, 1);
|
|
96
|
+
assert.equal(history.tasks[0].status, 'done');
|
|
97
|
+
assert.equal(history.tasks[0].evidence, 'npm test -> 5 passing');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('task abandon needs no evidence and moves the task to history', () => {
|
|
101
|
+
const project = makeProject();
|
|
102
|
+
runCli(['task', 'add', 'Drop me', '--id', 't1'], project);
|
|
103
|
+
|
|
104
|
+
const result = runCli(['task', 'abandon', 't1', '--reason', 'obsolete'], project);
|
|
105
|
+
assert.equal(result.code, 0, result.stderr);
|
|
106
|
+
|
|
107
|
+
const historyPath = join(project, '.ai', 'tasks', 'history.json');
|
|
108
|
+
const history = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
109
|
+
assert.equal(history.tasks[0].status, 'abandoned');
|
|
110
|
+
assert.equal(history.tasks[0].closeReason, 'obsolete');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('task promote links a forge run and keeps the task active as in_forge', () => {
|
|
114
|
+
const project = makeProject();
|
|
115
|
+
runCli(['task', 'add', 'Promote me', '--id', 't1'], project);
|
|
116
|
+
|
|
117
|
+
const result = runCli(['task', 'promote', 't1', 'run-42'], project);
|
|
118
|
+
assert.equal(result.code, 0, result.stderr);
|
|
119
|
+
|
|
120
|
+
const activePath = join(project, '.ai', 'tasks', 'active.json');
|
|
121
|
+
const active = JSON.parse(readFileSync(activePath, 'utf8'));
|
|
122
|
+
assert.equal(active.tasks[0].status, 'in_forge');
|
|
123
|
+
assert.equal(active.tasks[0].forgeRunId, 'run-42');
|
|
124
|
+
});
|
package/cli/config.mjs
CHANGED
|
@@ -1,113 +1,124 @@
|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
// A leading UTF-8 BOM (EF BB BF, decoded as U+FEFF) is not stripped by readFileSync('utf8'),
|
|
69
|
+
// and JSON.parse rejects a string that starts with it. Without stripping it, a config saved
|
|
70
|
+
// by a BOM-adding editor or `PowerShell Set-Content -Encoding utf8` would read back as
|
|
71
|
+
// `corrupt: true` — a valid, user-edited config (with its gate overrides) mistaken for
|
|
72
|
+
// unreadable. init.mjs bails loudly on `corrupt`, but a caller relying on `data` alone (as
|
|
73
|
+
// `mergeConfig` does) would otherwise merge onto `{}` and silently drop every existing gate
|
|
74
|
+
// override the user made. This is the same failure mode fixed in the gates' own config.mjs.
|
|
75
|
+
function stripBom(text) {
|
|
76
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readConfig(path) {
|
|
80
|
+
if (!existsSync(path)) return { exists: false, data: {}, corrupt: false };
|
|
81
|
+
try {
|
|
82
|
+
return {
|
|
83
|
+
exists: true,
|
|
84
|
+
data: JSON.parse(stripBom(readFileSync(path, 'utf8'))),
|
|
85
|
+
corrupt: false,
|
|
86
|
+
};
|
|
87
|
+
} catch {
|
|
88
|
+
return { exists: true, data: {}, corrupt: true };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merges one gate's new value onto whatever the project already had, preserving the user's
|
|
94
|
+
* edits. A gate with params arrives as `{ enabled, ...defaults }`; if the user already
|
|
95
|
+
* tuned those params, their values win and only `enabled` follows the new selection. A
|
|
96
|
+
* paramless gate is a plain boolean. This is what keeps a re-run of `init` additive: it
|
|
97
|
+
* never overwrites a whitelist or pattern list the user changed by hand.
|
|
98
|
+
*/
|
|
99
|
+
function mergeGate(existingValue, newValue) {
|
|
100
|
+
if (typeof newValue === 'boolean') return newValue;
|
|
101
|
+
if (existingValue && typeof existingValue === 'object') {
|
|
102
|
+
return { ...newValue, ...existingValue, enabled: newValue.enabled };
|
|
103
|
+
}
|
|
104
|
+
return newValue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Merges the new selection into an existing config without touching unrelated keys. Gates
|
|
109
|
+
* are merged key by key: a gate absent from the new map keeps its old value (a registry
|
|
110
|
+
* that dropped a gate must not silently flip it), and a gate the user configured keeps its
|
|
111
|
+
* params (see mergeGate).
|
|
112
|
+
*/
|
|
113
|
+
export function mergeConfig(existing, { adopted, gates, gateVersion }) {
|
|
114
|
+
const mergedGates = { ...(existing.gates ?? {}) };
|
|
115
|
+
for (const [key, value] of Object.entries(gates)) {
|
|
116
|
+
mergedGates[key] = mergeGate(mergedGates[key], value);
|
|
117
|
+
}
|
|
118
|
+
return { ...existing, adopted, gateVersion, gates: mergedGates };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function writeConfig(path, data) {
|
|
122
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
123
|
+
writeFileSync(path, `${JSON.stringify(data, null, JSON_INDENT)}\n`, 'utf8');
|
|
124
|
+
}
|
package/cli/index.mjs
CHANGED
|
@@ -1,97 +1,100 @@
|
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
process.
|
|
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
|
-
.option('--
|
|
65
|
-
.option('--
|
|
66
|
-
.option('--
|
|
67
|
-
.option('--
|
|
68
|
-
.option(
|
|
69
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
'
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
.option('--
|
|
80
|
-
.option('--
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
await
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.
|
|
89
|
-
.
|
|
90
|
-
.option('--
|
|
91
|
-
.
|
|
92
|
-
|
|
93
|
-
if (options.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
import { registerTaskCommand } from './task.mjs';
|
|
17
|
+
|
|
18
|
+
const packageManifest = JSON.parse(
|
|
19
|
+
readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function fail(message) {
|
|
23
|
+
process.stderr.write(`${message}\n`);
|
|
24
|
+
process.exit(EXIT_CODE.FAILURE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function splitList(value) {
|
|
28
|
+
return value
|
|
29
|
+
.split(LIST_SEPARATOR)
|
|
30
|
+
.map((item) => item.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function registryCheck() {
|
|
35
|
+
const problems = validateRegistry(
|
|
36
|
+
JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')),
|
|
37
|
+
);
|
|
38
|
+
if (problems.length > 0) {
|
|
39
|
+
fail(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
|
|
40
|
+
}
|
|
41
|
+
process.stdout.write('registry.json OK\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function registryList() {
|
|
45
|
+
const registry = loadRegistry();
|
|
46
|
+
for (const family of registry.families) {
|
|
47
|
+
process.stdout.write(`${family.name} (${family.id})\n`);
|
|
48
|
+
for (const gate of family.gates) {
|
|
49
|
+
const marker = gate.default ? '*' : ' ';
|
|
50
|
+
process.stdout.write(` ${marker} ${gate.id} ${gate.configKey}\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write('\n* = recommended default\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const program = new Command()
|
|
57
|
+
.name(packageManifest.name)
|
|
58
|
+
.description(packageManifest.description)
|
|
59
|
+
.version(packageManifest.version);
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command('init')
|
|
63
|
+
.description('Choose which gates to adopt and write the config.')
|
|
64
|
+
.option('--project', 'apply to this project (<root>/.ai/config.json)')
|
|
65
|
+
.option('--global', 'apply globally (~/.claude/claude-gates/config.json)')
|
|
66
|
+
.option('--defaults', 'enable the recommended defaults')
|
|
67
|
+
.option('--all', 'enable every gate')
|
|
68
|
+
.option('--none', "record a 'no' so you are not asked again")
|
|
69
|
+
.option(
|
|
70
|
+
'--families <ids>',
|
|
71
|
+
'enable whole families, comma-separated',
|
|
72
|
+
splitList,
|
|
73
|
+
)
|
|
74
|
+
.option(
|
|
75
|
+
'--gates <ids>',
|
|
76
|
+
'enable individual gates, comma-separated',
|
|
77
|
+
splitList,
|
|
78
|
+
)
|
|
79
|
+
.option('-y, --yes', 'never prompt; use flags and defaults')
|
|
80
|
+
.option('--no-install', 'write the config but do not install the plugin')
|
|
81
|
+
.option('--dry-run', 'show the selection without writing')
|
|
82
|
+
.action(async (options) => {
|
|
83
|
+
const { runInit } = await import('./init.mjs');
|
|
84
|
+
await runInit(options);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
program
|
|
88
|
+
.command('registry')
|
|
89
|
+
.description('Inspect registry.json.')
|
|
90
|
+
.option('--check', 'validate the registry')
|
|
91
|
+
.option('--list', 'print families and gates')
|
|
92
|
+
.action((options) => {
|
|
93
|
+
if (options.check) return registryCheck();
|
|
94
|
+
if (options.list) return registryList();
|
|
95
|
+
return fail('registry needs --check or --list');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
registerTaskCommand(program);
|
|
99
|
+
|
|
100
|
+
program.parseAsync(process.argv).catch((error) => fail(error.message));
|