@devrik-tools/claude-gates 0.8.0 → 1.0.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 +3 -3
- package/README.es.md +102 -9
- package/README.md +93 -9
- package/cli/artifacts.mjs +213 -0
- package/cli/constants.mjs +14 -0
- package/cli/doctor.mjs +2 -1
- package/cli/index.mjs +54 -2
- package/cli/init.mjs +95 -9
- package/cli/install.mjs +53 -1
- package/cli/registry.mjs +2 -0
- package/cli/selection.mjs +23 -1
- package/cli/smoke-fixtures.json +53 -3
- package/cli/task.mjs +69 -4
- package/package.json +5 -4
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +4 -11
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +11 -12
- package/plugins/gates/hooks/gates/library-docs/index.mjs +107 -31
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/skill-first/index.mjs +138 -0
- package/plugins/gates/hooks/gates/skill-first/track.mjs +66 -0
- package/plugins/gates/hooks/hooks.json +61 -1
- package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +9 -2
- package/plugins/gates/hooks/lib/signals.mjs +91 -0
- package/plugins/gates/hooks/lib/testing.mjs +15 -4
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -0
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +106 -5
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// artifacts.mjs — the one definition of what a GENERATED artifact looks like and where it
|
|
2
|
+
// goes. Everything an agent produces that is not source code lands in one of a few shapes:
|
|
3
|
+
// a deterministic check it wrote so we can see whether something works, an audit result, a
|
|
4
|
+
// note explaining what it did, or an entry in the recurrence registry when it tripped on
|
|
5
|
+
// the same thing twice. Before this module each of those existed exactly once, in its own
|
|
6
|
+
// ad-hoc shape and its own ad-hoc place — GATES.md at the repo root, `.ai/tasks/
|
|
7
|
+
// .audit-reuse.md` hidden inside the task store, `impl.md` buried under a feature's task
|
|
8
|
+
// directory — so nothing could be found by convention and nothing could be checked.
|
|
9
|
+
//
|
|
10
|
+
// The module is deliberately shaped like registry.mjs, the pattern this repo already uses
|
|
11
|
+
// for "declare it once, validate it with zod": the KINDS table below is the contract, the
|
|
12
|
+
// generator renders from it, and the conformance test validates against it. A new required
|
|
13
|
+
// section is added in one place and both halves follow.
|
|
14
|
+
//
|
|
15
|
+
// Front matter is the machine-readable half and is identical across kinds, so a tool can
|
|
16
|
+
// answer "what is this file, who asked for it, is it still open" without parsing prose.
|
|
17
|
+
// The required SECTIONS are the human half, and they differ per kind because what makes a
|
|
18
|
+
// check trustworthy (a command, an expectation, the evidence it actually produced) is not
|
|
19
|
+
// what makes an audit trustworthy (what was searched, what exists, what was decided).
|
|
20
|
+
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import {
|
|
23
|
+
ARTIFACT_DIRECTORIES,
|
|
24
|
+
ARTIFACT_EXTENSION,
|
|
25
|
+
PROJECT_STATE_DIRECTORY,
|
|
26
|
+
} from './constants.mjs';
|
|
27
|
+
|
|
28
|
+
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
29
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
30
|
+
|
|
31
|
+
/** Statuses an artifact can carry. `open` is the only one a generator ever writes. */
|
|
32
|
+
export const ARTIFACT_STATUSES = ['open', 'passed', 'failed', 'closed'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The contract, per kind. `sections` are the level-2 headings the body must contain, in
|
|
36
|
+
* any order; `summary` is what the kind is for, shown by `new --help` and in the docs.
|
|
37
|
+
*/
|
|
38
|
+
export const KINDS = Object.freeze({
|
|
39
|
+
check: {
|
|
40
|
+
directory: ARTIFACT_DIRECTORIES.check,
|
|
41
|
+
summary:
|
|
42
|
+
'A deterministic verification: the command to run, what it must print, and the ' +
|
|
43
|
+
'evidence it actually printed. Written so a claim can be re-checked by anyone.',
|
|
44
|
+
sections: ['Check', 'Expect', 'Evidence'],
|
|
45
|
+
},
|
|
46
|
+
audit: {
|
|
47
|
+
directory: ARTIFACT_DIRECTORIES.audit,
|
|
48
|
+
summary:
|
|
49
|
+
'The result of looking before building: what was searched, what already exists, ' +
|
|
50
|
+
'what is genuinely missing, and the decision that followed.',
|
|
51
|
+
sections: ['Searched', 'Exists', 'Missing', 'Decision'],
|
|
52
|
+
},
|
|
53
|
+
note: {
|
|
54
|
+
directory: ARTIFACT_DIRECTORIES.note,
|
|
55
|
+
summary:
|
|
56
|
+
'What was done and why, for work whose reasoning would otherwise live only in a ' +
|
|
57
|
+
'chat log: the situation, what changed, and what it cost or left open.',
|
|
58
|
+
sections: ['Context', 'Change', 'Outcome'],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const ARTIFACT_KINDS = Object.keys(KINDS);
|
|
63
|
+
|
|
64
|
+
export const frontMatterSchema = z.object({
|
|
65
|
+
kind: z.enum(ARTIFACT_KINDS),
|
|
66
|
+
slug: z.string().regex(SLUG_PATTERN, 'slug must be kebab-case'),
|
|
67
|
+
title: z.string().min(1, 'title must not be empty'),
|
|
68
|
+
created: z.string().regex(ISO_DATE_PATTERN, 'created must be YYYY-MM-DD'),
|
|
69
|
+
status: z.enum(ARTIFACT_STATUSES),
|
|
70
|
+
source: z.string().min(1, 'source must say what asked for this artifact'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// The recurrence registry is not a document but a record, so it is a JSON schema rather
|
|
74
|
+
// than front matter. The shape is READ FROM the recurrence-lock gate, not invented here:
|
|
75
|
+
// the gate counts `classes[].occurrences[]` and reopens on anything whose `status` is not
|
|
76
|
+
// closed, so those three fields are what a generated entry must carry.
|
|
77
|
+
export const recurrenceSchema = z.object({
|
|
78
|
+
class: z.string().min(1, 'class names the defect class, not one instance'),
|
|
79
|
+
occurrences: z
|
|
80
|
+
.array(z.object({ id: z.string().min(1), note: z.string().optional() }))
|
|
81
|
+
.min(1),
|
|
82
|
+
status: z.enum(['open', 'closed', 'cerrada']),
|
|
83
|
+
block: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1, 'block names the deterministic guard that closes the class')
|
|
86
|
+
.optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const recurrenceRegistrySchema = z.object({
|
|
90
|
+
classes: z.array(recurrenceSchema),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** Where an artifact of this kind and slug belongs, relative to the project root. */
|
|
94
|
+
export function artifactPathFor(kind, slug) {
|
|
95
|
+
const definition = KINDS[kind];
|
|
96
|
+
if (!definition) throw new Error(`unknown artifact kind: ${kind}`);
|
|
97
|
+
if (!SLUG_PATTERN.test(slug))
|
|
98
|
+
throw new Error(`slug must be kebab-case: ${slug}`);
|
|
99
|
+
return [
|
|
100
|
+
PROJECT_STATE_DIRECTORY,
|
|
101
|
+
definition.directory,
|
|
102
|
+
`${slug}${ARTIFACT_EXTENSION}`,
|
|
103
|
+
].join('/');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// Line by line, no multi-line regex, so a long body can never backtrack — the same
|
|
108
|
+
// approach lib/capabilities.mjs uses on skill front matter.
|
|
109
|
+
function splitFrontMatter(text) {
|
|
110
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
111
|
+
if (lines[0]?.trim() !== '---') return { fields: null, body: text ?? '' };
|
|
112
|
+
const fields = {};
|
|
113
|
+
let index = 1;
|
|
114
|
+
for (; index < lines.length; index += 1) {
|
|
115
|
+
if (lines[index].trim() === '---') break;
|
|
116
|
+
const separator = lines[index].indexOf(':');
|
|
117
|
+
if (separator < 0) continue;
|
|
118
|
+
const key = lines[index].slice(0, separator).trim();
|
|
119
|
+
fields[key] = lines[index]
|
|
120
|
+
.slice(separator + 1)
|
|
121
|
+
.trim()
|
|
122
|
+
.replace(/^["']|["']$/g, '');
|
|
123
|
+
}
|
|
124
|
+
return { fields, body: lines.slice(index + 1).join('\n') };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SECTION_PREFIX = '## ';
|
|
128
|
+
|
|
129
|
+
function headingsIn(body) {
|
|
130
|
+
return body
|
|
131
|
+
.split(/\r?\n/)
|
|
132
|
+
.filter((line) => line.startsWith(SECTION_PREFIX))
|
|
133
|
+
.map((line) => line.slice(SECTION_PREFIX.length).trim());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every way an artifact file breaks the contract, as human-readable strings. Empty means
|
|
138
|
+
* it conforms. Returning a list (never throwing) is what lets the conformance test report
|
|
139
|
+
* every drifted file in one run instead of stopping at the first.
|
|
140
|
+
*/
|
|
141
|
+
export function artifactProblems(text, { kind, slug } = {}) {
|
|
142
|
+
const { fields, body } = splitFrontMatter(text);
|
|
143
|
+
if (fields === null)
|
|
144
|
+
return ['missing front matter (the file must open with `---`)'];
|
|
145
|
+
|
|
146
|
+
const parsed = frontMatterSchema.safeParse(fields);
|
|
147
|
+
const problems = parsed.success
|
|
148
|
+
? []
|
|
149
|
+
: parsed.error.issues.map(
|
|
150
|
+
(issue) =>
|
|
151
|
+
`front matter ${issue.path.join('.') || '(root)'}: ${issue.message}`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (kind && fields.kind !== kind)
|
|
155
|
+
problems.push(
|
|
156
|
+
`kind is "${fields.kind}" but the file sits in the ${kind} directory`,
|
|
157
|
+
);
|
|
158
|
+
if (slug && fields.slug !== slug)
|
|
159
|
+
problems.push(`slug is "${fields.slug}" but the file is named "${slug}"`);
|
|
160
|
+
|
|
161
|
+
const definition = KINDS[fields.kind];
|
|
162
|
+
if (definition) {
|
|
163
|
+
const present = new Set(headingsIn(body));
|
|
164
|
+
const missing = definition.sections.filter(
|
|
165
|
+
(section) => !present.has(section),
|
|
166
|
+
);
|
|
167
|
+
if (missing.length > 0)
|
|
168
|
+
problems.push(`missing required section(s): ${missing.join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
return problems;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Generation ──────────────────────────────────────────────────────────────────────
|
|
174
|
+
const PLACEHOLDERS = {
|
|
175
|
+
Check: 'The exact command, copy-pasteable, that decides this.',
|
|
176
|
+
Expect:
|
|
177
|
+
'What that command must print or exit with for this to count as passing.',
|
|
178
|
+
Evidence:
|
|
179
|
+
'What it ACTUALLY printed when run. Never fill this in before running it.',
|
|
180
|
+
Searched:
|
|
181
|
+
'Where you looked: this repo, installed deps, the registry, the web.',
|
|
182
|
+
Exists: 'What you found that already covers part of this.',
|
|
183
|
+
Missing:
|
|
184
|
+
'What genuinely does not exist yet, and is therefore worth building.',
|
|
185
|
+
Decision: 'What was decided and why, in one or two sentences.',
|
|
186
|
+
Context: 'The situation this work started from.',
|
|
187
|
+
Change: 'What actually changed, concretely.',
|
|
188
|
+
Outcome: 'The result, including what it cost or left open.',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function todayIso(now = new Date()) {
|
|
192
|
+
return now.toISOString().slice(0, 'YYYY-MM-DD'.length);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The skeleton for a new artifact: valid front matter plus every required section. */
|
|
196
|
+
export function renderArtifact({ kind, slug, title, source, now }) {
|
|
197
|
+
const definition = KINDS[kind];
|
|
198
|
+
if (!definition) throw new Error(`unknown artifact kind: ${kind}`);
|
|
199
|
+
const frontMatter = [
|
|
200
|
+
'---',
|
|
201
|
+
`kind: ${kind}`,
|
|
202
|
+
`slug: ${slug}`,
|
|
203
|
+
`title: ${title}`,
|
|
204
|
+
`created: ${todayIso(now)}`,
|
|
205
|
+
'status: open',
|
|
206
|
+
`source: ${source}`,
|
|
207
|
+
'---',
|
|
208
|
+
];
|
|
209
|
+
const sections = definition.sections.map(
|
|
210
|
+
(section) => `## ${section}\n\n${PLACEHOLDERS[section] ?? 'TODO'}\n`,
|
|
211
|
+
);
|
|
212
|
+
return `${frontMatter.join('\n')}\n\n# ${title}\n\n${sections.join('\n')}`;
|
|
213
|
+
}
|
package/cli/constants.mjs
CHANGED
|
@@ -23,6 +23,20 @@ export const CONFIG_FILE = 'config.json';
|
|
|
23
23
|
/** Markers that identify a project root while climbing from the cwd. */
|
|
24
24
|
export const PROJECT_ROOT_MARKERS = ['.git', PROJECT_STATE_DIRECTORY];
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Where each kind of GENERATED artifact lives, relative to the project's `.ai/` root.
|
|
28
|
+
* Declared here (not inside artifacts.mjs) for the same reason every other path is: one
|
|
29
|
+
* place to read, one place to change. These four directories are the whole answer to
|
|
30
|
+
* "where does this go" — an artifact that fits no kind does not get invented a home.
|
|
31
|
+
*/
|
|
32
|
+
export const ARTIFACT_DIRECTORIES = Object.freeze({
|
|
33
|
+
check: 'checks',
|
|
34
|
+
audit: 'audits',
|
|
35
|
+
note: 'notes',
|
|
36
|
+
});
|
|
37
|
+
export const RECURRENCES_FILE = 'reincidencias.json';
|
|
38
|
+
export const ARTIFACT_EXTENSION = '.md';
|
|
39
|
+
|
|
26
40
|
/** Global-scope config lives under Claude Code's own user directory. */
|
|
27
41
|
export const CLAUDE_USER_DIRECTORY = '.claude';
|
|
28
42
|
export const GLOBAL_STATE_DIRECTORY = 'claude-gates';
|
package/cli/doctor.mjs
CHANGED
|
@@ -53,7 +53,8 @@ function withoutTrailingSlashes(text) {
|
|
|
53
53
|
return text.slice(0, end).toLowerCase();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/** Semver-ish numeric compare, shared with install.mjs so both judge staleness alike. */
|
|
57
|
+
export function compareVersions(a, b) {
|
|
57
58
|
const left = String(a).split('.').map(Number);
|
|
58
59
|
const right = String(b).split('.').map(Number);
|
|
59
60
|
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
package/cli/index.mjs
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
// Entry point (commander). Commands:
|
|
3
3
|
// init interactive (or flag-driven) selection of gates, per project or globally
|
|
4
4
|
// registry --check validates registry.json; --list prints the catalog
|
|
5
|
+
// new scaffold a generated artifact (check/audit/note) in the standard shape
|
|
5
6
|
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
8
9
|
import { Command } from 'commander';
|
|
10
|
+
import {
|
|
11
|
+
ARTIFACT_KINDS,
|
|
12
|
+
KINDS,
|
|
13
|
+
artifactPathFor,
|
|
14
|
+
renderArtifact,
|
|
15
|
+
} from './artifacts.mjs';
|
|
9
16
|
import { SCOPES, configPathFor } from './config.mjs';
|
|
10
17
|
import {
|
|
11
18
|
EXIT_CODE,
|
|
@@ -174,6 +181,10 @@ program
|
|
|
174
181
|
.option('--defaults', 'enable the recommended defaults')
|
|
175
182
|
.option('--all', 'enable every gate')
|
|
176
183
|
.option('--none', "record a 'no' so you are not asked again")
|
|
184
|
+
.option(
|
|
185
|
+
'--new',
|
|
186
|
+
'only gates this config has never decided about; nothing already in the file is shown, asked about or changed',
|
|
187
|
+
)
|
|
177
188
|
.option(
|
|
178
189
|
'--families <ids>',
|
|
179
190
|
'enable whole families, comma-separated',
|
|
@@ -267,6 +278,47 @@ program
|
|
|
267
278
|
)
|
|
268
279
|
.action(() => smokeGates());
|
|
269
280
|
|
|
281
|
+
// Width the kind name is padded to in `new --help`, so the summaries line up.
|
|
282
|
+
const ARTIFACT_KIND_COLUMN_WIDTH = 6;
|
|
283
|
+
|
|
284
|
+
function kindHelp() {
|
|
285
|
+
return ARTIFACT_KINDS.map(
|
|
286
|
+
(kind) =>
|
|
287
|
+
` ${kind.padEnd(ARTIFACT_KIND_COLUMN_WIDTH)} ${KINDS[kind].summary}`,
|
|
288
|
+
).join('\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function newArtifact(kind, slug, options) {
|
|
292
|
+
if (!ARTIFACT_KINDS.includes(kind))
|
|
293
|
+
return fail(`unknown kind "${kind}". Known kinds:\n${kindHelp()}`);
|
|
294
|
+
const title = options.title ?? slug.replaceAll('-', ' ');
|
|
295
|
+
const source = options.source ?? 'unspecified';
|
|
296
|
+
|
|
297
|
+
let relativePath;
|
|
298
|
+
try {
|
|
299
|
+
relativePath = artifactPathFor(kind, slug);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return fail(error.message);
|
|
302
|
+
}
|
|
303
|
+
const path = join(process.cwd(), relativePath);
|
|
304
|
+
if (existsSync(path) && !options.force)
|
|
305
|
+
return fail(`${relativePath} already exists. Pass --force to overwrite.`);
|
|
306
|
+
|
|
307
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
308
|
+
writeFileSync(path, renderArtifact({ kind, slug, title, source }), 'utf8');
|
|
309
|
+
process.stdout.write(`Wrote ${relativePath}\n`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
program
|
|
313
|
+
.command('new <kind> <slug>')
|
|
314
|
+
.description(
|
|
315
|
+
`Create a generated artifact with the standard shape. Kinds:\n${kindHelp()}`,
|
|
316
|
+
)
|
|
317
|
+
.option('--title <text>', 'one-line title (default: the slug, spaced)')
|
|
318
|
+
.option('--source <text>', 'what asked for this artifact')
|
|
319
|
+
.option('--force', 'overwrite an existing file')
|
|
320
|
+
.action((kind, slug, options) => newArtifact(kind, slug, options));
|
|
321
|
+
|
|
270
322
|
registerTaskCommand(program);
|
|
271
323
|
|
|
272
324
|
program.parseAsync(process.argv).catch((error) => fail(error.message));
|
package/cli/init.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { loadRegistry, allGates } from './registry.mjs';
|
|
20
20
|
import {
|
|
21
21
|
MODES,
|
|
22
|
+
newGatesFor,
|
|
22
23
|
resolveSelection,
|
|
23
24
|
adoptionOf,
|
|
24
25
|
namedGatesFor,
|
|
@@ -32,6 +33,11 @@ const MODE_OPTIONS = [
|
|
|
32
33
|
hint: 'gates marked default in the registry',
|
|
33
34
|
},
|
|
34
35
|
{ value: MODES.ALL, label: 'Everything', hint: 'every gate in every family' },
|
|
36
|
+
{
|
|
37
|
+
value: MODES.NEW,
|
|
38
|
+
label: 'Only what is new',
|
|
39
|
+
hint: 'lists just the gates this config has never decided about',
|
|
40
|
+
},
|
|
35
41
|
{ value: MODES.FAMILIES, label: 'By family', hint: 'pick whole families' },
|
|
36
42
|
{ value: MODES.GRANULAR, label: 'Granular', hint: 'pick individual gates' },
|
|
37
43
|
{
|
|
@@ -46,6 +52,7 @@ const MODE_BY_OPTION = {
|
|
|
46
52
|
defaults: MODES.DEFAULTS,
|
|
47
53
|
all: MODES.ALL,
|
|
48
54
|
none: MODES.NONE,
|
|
55
|
+
new: MODES.NEW,
|
|
49
56
|
families: MODES.FAMILIES,
|
|
50
57
|
gates: MODES.GRANULAR,
|
|
51
58
|
};
|
|
@@ -153,21 +160,89 @@ async function askGates(registry) {
|
|
|
153
160
|
);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The "only what is new" prompt: the same grouped picker as `askGates`, but built from
|
|
165
|
+
* `newGatesFor` so nothing already decided in this config is even shown — the point is to
|
|
166
|
+
* adopt what a release added without re-answering, or accidentally flipping, the rest.
|
|
167
|
+
* Everything starts checked when it is a recommended default, matching the other pickers.
|
|
168
|
+
*/
|
|
169
|
+
async function askNewGates(io, registry, existingGates) {
|
|
170
|
+
const fresh = newGatesFor(registry, existingGates);
|
|
171
|
+
if (fresh.length === 0) return { picks: [], none: true };
|
|
172
|
+
|
|
173
|
+
const options = {};
|
|
174
|
+
for (const gate of fresh) {
|
|
175
|
+
const family = registry.families.find((entry) => entry.id === gate.family);
|
|
176
|
+
const label = family?.name ?? gate.family;
|
|
177
|
+
options[label] ??= [];
|
|
178
|
+
options[label].push({
|
|
179
|
+
value: gate.id,
|
|
180
|
+
label: gate.id,
|
|
181
|
+
hint: gate.description,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const picks = bail(
|
|
185
|
+
await io.groupMultiselect({
|
|
186
|
+
message: `${fresh.length} gate(s) this config has never decided about (space to toggle, enter to confirm)`,
|
|
187
|
+
options,
|
|
188
|
+
initialValues: fresh
|
|
189
|
+
.filter((gate) => gate.default)
|
|
190
|
+
.map((gate) => gate.id),
|
|
191
|
+
required: false,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
return { picks, none: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
156
197
|
/** `claude plugin install <plugin>@<marketplace>`, read from the marketplace manifest, never hard-coded. */
|
|
157
198
|
|
|
158
199
|
/** Fills in whatever the flags left undecided, asking only when there is a TTY. */
|
|
159
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Fills `picks.gates` for the "only what is new" mode. Needs the config that is about to be
|
|
202
|
+
* written, so it is resolved here rather than in the generic prompt step: the list of new
|
|
203
|
+
* gates is a function of what that file already decided. Scripted runs (`--new --yes`) take
|
|
204
|
+
* the new gates that are recommended defaults — "adopt what the release added" is the only
|
|
205
|
+
* sensible unattended reading of the mode.
|
|
206
|
+
*/
|
|
207
|
+
async function decideNewPicks(io, registry, path, interactive) {
|
|
208
|
+
const existingGates = readConfig(path).data.gates ?? {};
|
|
209
|
+
if (!interactive)
|
|
210
|
+
return {
|
|
211
|
+
picks: newGatesFor(registry, existingGates)
|
|
212
|
+
.filter((gate) => gate.default)
|
|
213
|
+
.map((gate) => gate.id),
|
|
214
|
+
none: newGatesFor(registry, existingGates).length === 0,
|
|
215
|
+
};
|
|
216
|
+
return askNewGates(io, registry, existingGates);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Fills the picks the FAMILIES/GRANULAR modes need, when a flag did not already supply them. */
|
|
220
|
+
async function askPicksFor(mode, registry, picks, interactive) {
|
|
221
|
+
if (!interactive) return;
|
|
222
|
+
if (mode === MODES.FAMILIES && picks.families.length === 0)
|
|
223
|
+
picks.families = await askFamilies(registry);
|
|
224
|
+
if (mode === MODES.GRANULAR && picks.gates.length === 0)
|
|
225
|
+
picks.gates = await askGates(registry);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function decide(flags, registry, cwd, interactive, io) {
|
|
160
229
|
const scope =
|
|
161
230
|
flags.scope ?? (interactive ? await askScope(cwd) : SCOPES.PROJECT);
|
|
162
231
|
const mode = flags.mode ?? (interactive ? await askMode() : MODES.DEFAULTS);
|
|
163
232
|
const picks = { families: flags.families, gates: flags.gates };
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
233
|
+
await askPicksFor(mode, registry, picks, interactive);
|
|
234
|
+
|
|
235
|
+
if (mode !== MODES.NEW || picks.gates.length > 0)
|
|
236
|
+
return { scope, mode, picks, nothingNew: false };
|
|
237
|
+
|
|
238
|
+
const fresh = await decideNewPicks(
|
|
239
|
+
io,
|
|
240
|
+
registry,
|
|
241
|
+
configPathFor(scope, { cwd }),
|
|
242
|
+
interactive,
|
|
243
|
+
);
|
|
244
|
+
picks.gates = fresh.picks;
|
|
245
|
+
return { scope, mode, picks, nothingNew: fresh.none };
|
|
171
246
|
}
|
|
172
247
|
|
|
173
248
|
function renderSummary(registry, gatesMap) {
|
|
@@ -330,12 +405,23 @@ export async function runInit(
|
|
|
330
405
|
|
|
331
406
|
if (interactive) io.intro('claude-gates');
|
|
332
407
|
|
|
333
|
-
const { scope, mode, picks } = await decide(
|
|
408
|
+
const { scope, mode, picks, nothingNew } = await decide(
|
|
334
409
|
flags,
|
|
335
410
|
registry,
|
|
336
411
|
cwd,
|
|
337
412
|
interactive,
|
|
413
|
+
io,
|
|
338
414
|
);
|
|
415
|
+
if (nothingNew) {
|
|
416
|
+
io.outro(
|
|
417
|
+
'Nothing new: this config already decides about every gate in the registry.',
|
|
418
|
+
);
|
|
419
|
+
return {
|
|
420
|
+
path: configPathFor(scope, { cwd }),
|
|
421
|
+
config: null,
|
|
422
|
+
written: false,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
339
425
|
const gates = resolveSelection(registry, mode, picks);
|
|
340
426
|
const path = configPathFor(scope, { cwd });
|
|
341
427
|
const existing = readConfig(path);
|
package/cli/install.mjs
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
8
9
|
import { SCOPES } from './config.mjs';
|
|
9
10
|
import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
|
|
11
|
+
import { compareVersions, parsePluginList } from './doctor.mjs';
|
|
10
12
|
|
|
11
13
|
const CLAUDE_BIN = 'claude';
|
|
12
14
|
|
|
15
|
+
/** The version of THIS package — what an install is expected to leave behind. */
|
|
16
|
+
function packageVersion() {
|
|
17
|
+
return JSON.parse(readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'))
|
|
18
|
+
.version;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
// Config scope decides where the plugin is installed: a project selection stays local to
|
|
14
22
|
// this project (its .claude/settings.json); a global selection installs for every project.
|
|
15
23
|
const PLUGIN_SCOPE = Object.freeze({
|
|
@@ -198,6 +206,47 @@ function removePreviousInstalls(targets, scope, runClaude) {
|
|
|
198
206
|
}
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Whether the install ACTUALLY took, described as a problem string (null when it did).
|
|
211
|
+
*
|
|
212
|
+
* `claude plugin install` exiting 0 is not evidence that anything changed: a marketplace
|
|
213
|
+
* still serving a stale path, a plugin that resolves but is never enabled, or an update
|
|
214
|
+
* that silently no-ops all exit 0 too. Reporting success on the exit code alone is what
|
|
215
|
+
* produced the "it says it worked but nothing updated" failure — the installer claimed a
|
|
216
|
+
* result it had not checked, and only `doctor`, run separately and later, ever noticed.
|
|
217
|
+
*
|
|
218
|
+
* So the post-condition is read back from Claude Code itself: the plugin must now appear
|
|
219
|
+
* in `plugin list`, at a version not older than this package's. Reuses doctor's parser
|
|
220
|
+
* rather than a second one, so what install verifies and what doctor reports can never
|
|
221
|
+
* disagree. A verification that cannot run (no `claude` on PATH, unparseable output) is
|
|
222
|
+
* NOT treated as failure — that would turn a working install into a false alarm; only a
|
|
223
|
+
* definite mismatch is reported.
|
|
224
|
+
*/
|
|
225
|
+
function installationProblem(runClaude, plugin) {
|
|
226
|
+
let listed;
|
|
227
|
+
try {
|
|
228
|
+
listed = parsePluginList(runClaude(['plugin', 'list']));
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (listed.length === 0) return null;
|
|
233
|
+
|
|
234
|
+
const found = listed.filter((entry) => entry.plugin === plugin);
|
|
235
|
+
if (found.length === 0)
|
|
236
|
+
return 'the install reported success but the plugin is not in `claude plugin list`';
|
|
237
|
+
|
|
238
|
+
const expected = packageVersion();
|
|
239
|
+
const stale = found.filter(
|
|
240
|
+
(entry) => entry.version && compareVersions(entry.version, expected) < 0,
|
|
241
|
+
);
|
|
242
|
+
if (stale.length === found.length)
|
|
243
|
+
return (
|
|
244
|
+
`still running ${stale[0].version} after installing ${expected} — the marketplace is ` +
|
|
245
|
+
'serving a stale copy. Run `claude plugin marketplace remove`, then re-run init from this package.'
|
|
246
|
+
);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
201
250
|
/**
|
|
202
251
|
* Registers the marketplace (idempotent: a second add just reports it already exists, which
|
|
203
252
|
* is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
|
|
@@ -259,10 +308,13 @@ export function installPlugin(
|
|
|
259
308
|
'--scope',
|
|
260
309
|
scope,
|
|
261
310
|
]);
|
|
262
|
-
return { plugin, installed: true };
|
|
263
311
|
} catch (error) {
|
|
264
312
|
return { plugin, installed: false, reason: reasonFor(error) };
|
|
265
313
|
}
|
|
314
|
+
const problem = installationProblem(runClaude, plugin);
|
|
315
|
+
return problem
|
|
316
|
+
? { plugin, installed: false, reason: problem }
|
|
317
|
+
: { plugin, installed: true };
|
|
266
318
|
});
|
|
267
319
|
|
|
268
320
|
const installed = results.every((result) => result.installed);
|
package/cli/registry.mjs
CHANGED
package/cli/selection.mjs
CHANGED
|
@@ -10,8 +10,25 @@ export const MODES = Object.freeze({
|
|
|
10
10
|
GRANULAR: 'granular',
|
|
11
11
|
DEFAULTS: 'defaults',
|
|
12
12
|
NONE: 'none',
|
|
13
|
+
/**
|
|
14
|
+
* Only what this config has never decided about. Resolves exactly like GRANULAR — by the
|
|
15
|
+
* time picks arrive the user has already chosen from a list narrowed to the new gates —
|
|
16
|
+
* but the caller builds that list with `newGatesFor`, so an upgrade can adopt what a
|
|
17
|
+
* release added without re-answering, or silently flipping, anything already in the file.
|
|
18
|
+
*/
|
|
19
|
+
NEW: 'new',
|
|
13
20
|
});
|
|
14
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The gates a config has never decided about: no entry under `gates` for their configKey.
|
|
24
|
+
* An entry set to `false` counts as DECIDED — the user turned it off on purpose, and
|
|
25
|
+
* offering it again as "new" would be how a deliberate opt-out gets undone by an upgrade.
|
|
26
|
+
*/
|
|
27
|
+
export function newGatesFor(registry, existingGates = {}) {
|
|
28
|
+
const decided = new Set(Object.keys(existingGates ?? {}));
|
|
29
|
+
return allGates(registry).filter((gate) => !decided.has(gate.configKey));
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
function assertKnown(chosen, known, kind) {
|
|
16
33
|
const unknown = [...chosen].filter((id) => !known.has(id));
|
|
17
34
|
if (unknown.length > 0)
|
|
@@ -35,6 +52,11 @@ const STRATEGIES = {
|
|
|
35
52
|
gates.filter((gate) => chosen.has(gate.family)).map((gate) => gate.id),
|
|
36
53
|
);
|
|
37
54
|
},
|
|
55
|
+
[MODES.NEW]: (gates, _registry, picks) => {
|
|
56
|
+
const chosen = new Set(picks.gates ?? []);
|
|
57
|
+
assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
|
|
58
|
+
return chosen;
|
|
59
|
+
},
|
|
38
60
|
[MODES.GRANULAR]: (gates, _registry, picks) => {
|
|
39
61
|
const chosen = new Set(picks.gates ?? []);
|
|
40
62
|
assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
|
|
@@ -93,7 +115,7 @@ export function namedGatesFor(registry, mode, picks = {}) {
|
|
|
93
115
|
const chosen = new Set(picks.families ?? []);
|
|
94
116
|
return keysOf((gate) => chosen.has(gate.family));
|
|
95
117
|
}
|
|
96
|
-
if (mode === MODES.GRANULAR) {
|
|
118
|
+
if (mode === MODES.GRANULAR || mode === MODES.NEW) {
|
|
97
119
|
const chosen = new Set(picks.gates ?? []);
|
|
98
120
|
return keysOf((gate) => chosen.has(gate.id));
|
|
99
121
|
}
|