@onlooker-community/ecosystem 0.9.0 → 0.14.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 +39 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.github/copilot-instructions.md +46 -0
- package/.github/workflows/coverage.yml +78 -0
- package/.github/workflows/release.yml +24 -8
- package/.github/workflows/test.yml +3 -0
- package/.markdownlintignore +3 -0
- package/.release-please-manifest.json +4 -1
- package/CHANGELOG.md +44 -0
- package/README.md +57 -13
- package/config.json +6 -1
- package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
- package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
- package/docs/adr/003-ulid-over-uuid.md +40 -0
- package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
- package/docs/architecture.md +117 -0
- package/hooks/hooks.json +4 -0
- package/package.json +13 -7
- package/plugins/archivist/.claude-plugin/plugin.json +14 -0
- package/plugins/archivist/CHANGELOG.md +8 -0
- package/plugins/archivist/README.md +105 -0
- package/plugins/archivist/config.json +18 -0
- package/plugins/archivist/hooks/hooks.json +35 -0
- package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
- package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
- package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
- package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
- package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
- package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
- package/plugins/echo/.claude-plugin/plugin.json +14 -0
- package/plugins/echo/CHANGELOG.md +24 -0
- package/plugins/echo/README.md +110 -0
- package/plugins/echo/config.json +15 -0
- package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
- package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
- package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
- package/plugins/echo/hooks/hooks.json +15 -0
- package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
- package/plugins/echo/scripts/lib/echo-config.sh +108 -0
- package/plugins/echo/scripts/lib/echo-events.sh +74 -0
- package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
- package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
- package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
- package/plugins/tribunal/CHANGELOG.md +10 -0
- package/plugins/tribunal/README.md +134 -0
- package/plugins/tribunal/agents/tribunal-actor.md +35 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
- package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
- package/plugins/tribunal/config.json +50 -0
- package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
- package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
- package/plugins/tribunal/hooks/hooks.json +15 -0
- package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
- package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
- package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
- package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
- package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
- package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
- package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
- package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
- package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
- package/release-please-config.json +43 -5
- package/scripts/coverage/bash-coverage.mjs +169 -0
- package/scripts/coverage/format-comment.mjs +120 -0
- package/scripts/coverage/run-coverage.mjs +151 -0
- package/scripts/hooks/agent-spawn-tracker.sh +4 -4
- package/scripts/hooks/prompt-rule-injector.sh +122 -0
- package/scripts/lib/onlooker-event.mjs +82 -10
- package/scripts/lib/portable-lock.sh +48 -0
- package/scripts/lib/prompt-rules.sh +207 -0
- package/scripts/lib/tool-history.sh +7 -8
- package/scripts/lib/validate-path.sh +4 -0
- package/scripts/lint/check-manifests.mjs +314 -0
- package/scripts/lint/check-references.mjs +311 -0
- package/skills/list-prompt-rules/SKILL.md +15 -0
- package/test/bats/archivist-config-files.bats +60 -0
- package/test/bats/archivist-config.bats +54 -0
- package/test/bats/archivist-inject.bats +73 -0
- package/test/bats/archivist-project-key.bats +75 -0
- package/test/bats/archivist-storage.bats +119 -0
- package/test/bats/archivist-ulid.bats +36 -0
- package/test/bats/config.bats +10 -10
- package/test/bats/echo-config.bats +90 -0
- package/test/bats/echo-events.bats +121 -0
- package/test/bats/echo-project-key.bats +115 -0
- package/test/bats/echo-stop-hook.bats +101 -0
- package/test/bats/echo-ulid.bats +38 -0
- package/test/bats/portable-lock.bats +62 -0
- package/test/bats/prompt-rules.bats +269 -0
- package/test/bats/read-chunk-tracking.bats +73 -0
- package/test/bats/tool-history-tracker.bats +1 -0
- package/test/bats/tribunal-aggregate.bats +77 -0
- package/test/bats/tribunal-config.bats +86 -0
- package/test/bats/tribunal-events.bats +209 -0
- package/test/bats/tribunal-gate.bats +95 -0
- package/test/bats/tribunal-jury.bats +80 -0
- package/test/bats/tribunal-rubric.bats +119 -0
- package/test/bats/tribunal-stop-hook.bats +73 -0
- package/test/bats/tribunal-verdict.bats +71 -0
- package/test/bats/validate-path.bats +1 -1
- package/test/fixtures/hook-inputs/post-tool-use-read-chunked.json +15 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
- package/test/helpers/setup.bash +9 -0
- package/test/node/check-manifests.test.mjs +173 -0
- package/test/node/check-references.test.mjs +279 -0
- package/test/node/coverage.test.mjs +143 -0
- package/test/node/schema-events.test.mjs +41 -1
|
@@ -22,6 +22,10 @@ export ONLOOKER_EVENTS_LOG="$ONLOOKER_DIR/logs/onlooker-events.jsonl"
|
|
|
22
22
|
export ONLOOKER_HOOK_HEALTH_LOG="$ONLOOKER_DIR/logs/hook-health.jsonl"
|
|
23
23
|
_VALIDATE_PATH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
24
|
export ONLOOKER_EMIT="$_VALIDATE_PATH_DIR/onlooker-emit.sh"
|
|
25
|
+
# Portable mutex (flock substitute) — every hook script that needs to write
|
|
26
|
+
# shared state can call lock_acquire/lock_release after sourcing this file.
|
|
27
|
+
# shellcheck source=portable-lock.sh
|
|
28
|
+
source "$_VALIDATE_PATH_DIR/portable-lock.sh"
|
|
25
29
|
unset _VALIDATE_PATH_DIR
|
|
26
30
|
|
|
27
31
|
# ==============================================================================
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Manifest validator for the Onlooker marketplace.
|
|
3
|
+
//
|
|
4
|
+
// Asserts that .claude-plugin/marketplace.json and every plugin's
|
|
5
|
+
// .claude-plugin/plugin.json match the Claude Code plugin schema, and that
|
|
6
|
+
// our own conventions hold:
|
|
7
|
+
//
|
|
8
|
+
// * marketplace.json plugins[] entries MUST NOT carry a `version` field
|
|
9
|
+
// (the drift trap — plugin.json's version silently wins at runtime, so
|
|
10
|
+
// setting both is documented as harmful in plugins-reference).
|
|
11
|
+
// * plugin.json `name` matches its marketplace entry name (no surprise
|
|
12
|
+
// renames).
|
|
13
|
+
// * plugin.json `name` is kebab-case (matches plugin loader conventions).
|
|
14
|
+
// * plugin.json `version` is semver-shaped.
|
|
15
|
+
// * resource arrays (skills/commands/agents) and mcpServers/hooks fields
|
|
16
|
+
// have the expected types.
|
|
17
|
+
//
|
|
18
|
+
// Exit codes:
|
|
19
|
+
// 0 ok
|
|
20
|
+
// 1 one or more schema violations
|
|
21
|
+
// 2 setup/usage error
|
|
22
|
+
//
|
|
23
|
+
// Flags:
|
|
24
|
+
// --strict treat warnings (unknown fields, missing optional
|
|
25
|
+
// recommended fields) as errors
|
|
26
|
+
// --root <path> override the repo root
|
|
27
|
+
// --plugin <name> only validate the named plugin (repeatable)
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
30
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
34
|
+
const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
35
|
+
|
|
36
|
+
// Fields we recognize on each manifest. Unknown fields trigger a warning
|
|
37
|
+
// (typo detection). New fields should be added here as Claude Code's schema
|
|
38
|
+
// evolves.
|
|
39
|
+
const KNOWN_MARKETPLACE_FIELDS = new Set(['name', 'owner', 'metadata', 'plugins', '$schema']);
|
|
40
|
+
const KNOWN_MARKETPLACE_PLUGIN_FIELDS = new Set([
|
|
41
|
+
'name',
|
|
42
|
+
'source',
|
|
43
|
+
'description',
|
|
44
|
+
'author',
|
|
45
|
+
'homepage',
|
|
46
|
+
'repository',
|
|
47
|
+
'license',
|
|
48
|
+
'keywords',
|
|
49
|
+
'tags',
|
|
50
|
+
]);
|
|
51
|
+
const KNOWN_PLUGIN_JSON_FIELDS = new Set([
|
|
52
|
+
'name',
|
|
53
|
+
'version',
|
|
54
|
+
'description',
|
|
55
|
+
'author',
|
|
56
|
+
'homepage',
|
|
57
|
+
'repository',
|
|
58
|
+
'license',
|
|
59
|
+
'skills',
|
|
60
|
+
'commands',
|
|
61
|
+
'agents',
|
|
62
|
+
'mcpServers',
|
|
63
|
+
'hooks',
|
|
64
|
+
'keywords',
|
|
65
|
+
'$schema',
|
|
66
|
+
]);
|
|
67
|
+
const KNOWN_HOOK_EVENTS = new Set([
|
|
68
|
+
'PreToolUse',
|
|
69
|
+
'PostToolUse',
|
|
70
|
+
'PostToolUseFailure',
|
|
71
|
+
'PermissionRequest',
|
|
72
|
+
'PermissionDenied',
|
|
73
|
+
'SessionStart',
|
|
74
|
+
'SessionEnd',
|
|
75
|
+
'Notification',
|
|
76
|
+
'SubagentStart',
|
|
77
|
+
'PreCompact',
|
|
78
|
+
'PostCompact',
|
|
79
|
+
'SubagentStop',
|
|
80
|
+
'ConfigChange',
|
|
81
|
+
'CwdChanged',
|
|
82
|
+
'FileChanged',
|
|
83
|
+
'StopFailure',
|
|
84
|
+
'InstructionsLoaded',
|
|
85
|
+
'Elicitation',
|
|
86
|
+
'ElicitationResult',
|
|
87
|
+
'UserPromptSubmit',
|
|
88
|
+
'UserPromptExpansion',
|
|
89
|
+
'Stop',
|
|
90
|
+
'TeammateIdle',
|
|
91
|
+
'TaskCreated',
|
|
92
|
+
'TaskCompleted',
|
|
93
|
+
'WorktreeCreate',
|
|
94
|
+
'WorktreeRemove',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function findRepoRoot(start) {
|
|
98
|
+
let cur = resolve(start);
|
|
99
|
+
while (cur !== '/') {
|
|
100
|
+
if (existsSync(join(cur, '.claude-plugin', 'marketplace.json'))) return cur;
|
|
101
|
+
cur = dirname(cur);
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`could not find .claude-plugin/marketplace.json above ${start}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
const out = { strict: false, plugins: [], root: null };
|
|
108
|
+
for (let i = 2; i < argv.length; i++) {
|
|
109
|
+
const a = argv[i];
|
|
110
|
+
if (a === '--strict') out.strict = true;
|
|
111
|
+
else if (a === '--plugin') out.plugins.push(argv[++i]);
|
|
112
|
+
else if (a === '--root') out.root = argv[++i];
|
|
113
|
+
else if (a === '-h' || a === '--help') {
|
|
114
|
+
process.stderr.write('Usage: check-manifests.mjs [--strict] [--plugin name]... [--root path]\n');
|
|
115
|
+
process.exit(0);
|
|
116
|
+
} else {
|
|
117
|
+
process.stderr.write(`unknown argument: ${a}\n`);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isPlainObject(v) {
|
|
125
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readJson(path, label) {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { __readError: `cannot read ${label} at ${path}: ${err.message}` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateMarketplace(marketplace, errors, warnings) {
|
|
137
|
+
if (!isPlainObject(marketplace)) {
|
|
138
|
+
errors.push('marketplace.json must be a JSON object');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (typeof marketplace.name !== 'string' || marketplace.name.length === 0) {
|
|
142
|
+
errors.push('marketplace.json: `name` is required and must be a non-empty string');
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(marketplace.plugins)) {
|
|
145
|
+
errors.push('marketplace.json: `plugins` is required and must be an array');
|
|
146
|
+
}
|
|
147
|
+
if (marketplace.owner !== undefined && !isPlainObject(marketplace.owner)) {
|
|
148
|
+
errors.push('marketplace.json: `owner` must be an object when present');
|
|
149
|
+
}
|
|
150
|
+
if (marketplace.owner && typeof marketplace.owner.name !== 'string') {
|
|
151
|
+
errors.push('marketplace.json: `owner.name` is required when `owner` is present');
|
|
152
|
+
}
|
|
153
|
+
for (const key of Object.keys(marketplace)) {
|
|
154
|
+
if (!KNOWN_MARKETPLACE_FIELDS.has(key)) {
|
|
155
|
+
warnings.push(`marketplace.json: unknown top-level field "${key}"`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateMarketplacePluginEntry(entry, index, errors, warnings) {
|
|
161
|
+
const label = `marketplace.json plugins[${index}]`;
|
|
162
|
+
if (!isPlainObject(entry)) {
|
|
163
|
+
errors.push(`${label}: must be an object`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (typeof entry.name !== 'string' || !KEBAB_CASE.test(entry.name)) {
|
|
167
|
+
errors.push(`${label}: \`name\` is required and must be kebab-case (got ${JSON.stringify(entry.name)})`);
|
|
168
|
+
}
|
|
169
|
+
if (typeof entry.source !== 'string' || entry.source.length === 0) {
|
|
170
|
+
errors.push(`${label}: \`source\` is required and must be a string`);
|
|
171
|
+
}
|
|
172
|
+
if ('version' in entry) {
|
|
173
|
+
errors.push(
|
|
174
|
+
`${label}: \`version\` MUST NOT be set in marketplace.json — Claude Code reads version from each plugin's own plugin.json, and setting both is a documented drift hazard.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
for (const key of Object.keys(entry)) {
|
|
178
|
+
if (!KNOWN_MARKETPLACE_PLUGIN_FIELDS.has(key)) {
|
|
179
|
+
warnings.push(`${label}: unknown field "${key}"`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function validatePluginJson(pluginJson, marketplaceName, label, errors, warnings) {
|
|
185
|
+
if (!isPlainObject(pluginJson)) {
|
|
186
|
+
errors.push(`${label}: plugin.json must be a JSON object`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (typeof pluginJson.name !== 'string' || !KEBAB_CASE.test(pluginJson.name)) {
|
|
190
|
+
errors.push(`${label}: \`name\` is required and must be kebab-case (got ${JSON.stringify(pluginJson.name)})`);
|
|
191
|
+
} else if (pluginJson.name !== marketplaceName) {
|
|
192
|
+
errors.push(
|
|
193
|
+
`${label}: plugin.json \`name\` "${pluginJson.name}" does not match marketplace entry name "${marketplaceName}"`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (typeof pluginJson.version !== 'string' || pluginJson.version.length === 0) {
|
|
197
|
+
errors.push(`${label}: \`version\` is required and must be a non-empty string`);
|
|
198
|
+
} else if (!SEMVER.test(pluginJson.version)) {
|
|
199
|
+
errors.push(`${label}: \`version\` is not semver-shaped (got ${JSON.stringify(pluginJson.version)})`);
|
|
200
|
+
}
|
|
201
|
+
if (typeof pluginJson.description !== 'string' || pluginJson.description.length === 0) {
|
|
202
|
+
errors.push(`${label}: \`description\` is required and must be a non-empty string`);
|
|
203
|
+
}
|
|
204
|
+
for (const field of ['skills', 'commands', 'agents']) {
|
|
205
|
+
if (pluginJson[field] !== undefined && !Array.isArray(pluginJson[field])) {
|
|
206
|
+
errors.push(`${label}: \`${field}\` must be an array when present`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (pluginJson.mcpServers !== undefined && !isPlainObject(pluginJson.mcpServers)) {
|
|
210
|
+
errors.push(`${label}: \`mcpServers\` must be an object when present`);
|
|
211
|
+
}
|
|
212
|
+
for (const key of Object.keys(pluginJson)) {
|
|
213
|
+
if (!KNOWN_PLUGIN_JSON_FIELDS.has(key)) {
|
|
214
|
+
warnings.push(`${label}: unknown field "${key}"`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateHooksJson(hooksJson, label, errors, warnings) {
|
|
220
|
+
if (!isPlainObject(hooksJson)) {
|
|
221
|
+
errors.push(`${label}: hooks.json must be a JSON object`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!isPlainObject(hooksJson.hooks)) {
|
|
225
|
+
errors.push(`${label}: hooks.json must contain a \`hooks\` object`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const [event, value] of Object.entries(hooksJson.hooks)) {
|
|
229
|
+
if (!KNOWN_HOOK_EVENTS.has(event)) {
|
|
230
|
+
warnings.push(`${label}: hooks.json declares unknown event "${event}"`);
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(value)) {
|
|
233
|
+
errors.push(`${label}: hooks.json event "${event}" must be an array`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (let i = 0; i < value.length; i++) {
|
|
237
|
+
const matcher = value[i];
|
|
238
|
+
if (!isPlainObject(matcher)) {
|
|
239
|
+
errors.push(`${label}: hooks.json ${event}[${i}] must be an object`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!Array.isArray(matcher.hooks)) {
|
|
243
|
+
errors.push(`${label}: hooks.json ${event}[${i}].hooks must be an array`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main() {
|
|
250
|
+
const args = parseArgs(process.argv);
|
|
251
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
252
|
+
const root = args.root ? resolve(args.root) : findRepoRoot(here);
|
|
253
|
+
|
|
254
|
+
const marketplacePath = join(root, '.claude-plugin', 'marketplace.json');
|
|
255
|
+
const marketplace = readJson(marketplacePath, 'marketplace.json');
|
|
256
|
+
if (marketplace.__readError) {
|
|
257
|
+
process.stderr.write(`error: ${marketplace.__readError}\n`);
|
|
258
|
+
process.exit(2);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const errors = [];
|
|
262
|
+
const warnings = [];
|
|
263
|
+
|
|
264
|
+
validateMarketplace(marketplace, errors, warnings);
|
|
265
|
+
|
|
266
|
+
const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
|
|
267
|
+
const filter = args.plugins.length === 0 ? null : new Set(args.plugins);
|
|
268
|
+
|
|
269
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
270
|
+
const entry = plugins[i];
|
|
271
|
+
validateMarketplacePluginEntry(entry, i, errors, warnings);
|
|
272
|
+
if (!isPlainObject(entry) || typeof entry.name !== 'string' || typeof entry.source !== 'string') {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (filter && !filter.has(entry.name)) continue;
|
|
276
|
+
|
|
277
|
+
const pluginRoot = resolve(root, entry.source);
|
|
278
|
+
const pluginJsonPath = join(pluginRoot, '.claude-plugin', 'plugin.json');
|
|
279
|
+
const label = `plugin "${entry.name}"`;
|
|
280
|
+
const pluginJson = readJson(pluginJsonPath, `${label} plugin.json`);
|
|
281
|
+
if (pluginJson.__readError) {
|
|
282
|
+
errors.push(pluginJson.__readError);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
validatePluginJson(pluginJson, entry.name, label, errors, warnings);
|
|
286
|
+
|
|
287
|
+
// Validate hooks/hooks.json if the plugin appears to ship hooks.
|
|
288
|
+
const hooksJsonPath = join(pluginRoot, 'hooks', 'hooks.json');
|
|
289
|
+
if (existsSync(hooksJsonPath)) {
|
|
290
|
+
const stat = statSync(hooksJsonPath);
|
|
291
|
+
if (stat.isFile()) {
|
|
292
|
+
const hooksJson = readJson(hooksJsonPath, `${label} hooks.json`);
|
|
293
|
+
if (hooksJson.__readError) {
|
|
294
|
+
errors.push(hooksJson.__readError);
|
|
295
|
+
} else {
|
|
296
|
+
validateHooksJson(hooksJson, `${label} (${relative(root, hooksJsonPath)})`, errors, warnings);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const e of errors) process.stderr.write(`error: ${e}\n`);
|
|
303
|
+
for (const w of warnings) process.stderr.write(`warn: ${w}\n`);
|
|
304
|
+
|
|
305
|
+
const failing = errors.length > 0 || (args.strict && warnings.length > 0);
|
|
306
|
+
if (failing) {
|
|
307
|
+
process.stderr.write(`check-manifests: ${errors.length} error(s), ${warnings.length} warning(s)\n`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
process.stdout.write(`check-manifests: ok (${plugins.length} plugin(s), ${warnings.length} warning(s))\n`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
main();
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cross-reference linter for the Onlooker marketplace.
|
|
3
|
+
//
|
|
4
|
+
// Walks every plugin declared in `.claude-plugin/marketplace.json` and:
|
|
5
|
+
// 1. asserts every path under plugin.json's `skills`, `commands`, `agents`,
|
|
6
|
+
// and `hooks` fields resolves to a real file or directory inside the
|
|
7
|
+
// plugin's source tree;
|
|
8
|
+
// 2. parses YAML frontmatter from each markdown skill/command/agent and
|
|
9
|
+
// asserts the required fields (`name`, `description`) are present;
|
|
10
|
+
// 3. builds a cross-marketplace registry of declared commands/skills/agents
|
|
11
|
+
// and scans every markdown body for slash-command references
|
|
12
|
+
// (`/foo`) — anything that is not a known built-in or a declared
|
|
13
|
+
// command is reported as a warning.
|
|
14
|
+
//
|
|
15
|
+
// Exit codes:
|
|
16
|
+
// 0 no problems
|
|
17
|
+
// 1 at least one error (broken path or invalid frontmatter)
|
|
18
|
+
// 2 setup/usage error
|
|
19
|
+
//
|
|
20
|
+
// Flags:
|
|
21
|
+
// --strict treat warnings as errors
|
|
22
|
+
// --plugin <name> only check the named plugin (repeatable)
|
|
23
|
+
// --root <path> override the repo root (defaults to git toplevel)
|
|
24
|
+
// --print-registry dump the resolved registry to stderr (debugging)
|
|
25
|
+
|
|
26
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
27
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
// Slash commands shipped by Claude Code itself. References to these never
|
|
31
|
+
// fail; they are valid even though we cannot resolve them in the
|
|
32
|
+
// marketplace. Kept short on purpose — additions should be deliberate.
|
|
33
|
+
const BUILTIN_COMMANDS = new Set([
|
|
34
|
+
'clear',
|
|
35
|
+
'compact',
|
|
36
|
+
'config',
|
|
37
|
+
'help',
|
|
38
|
+
'loop',
|
|
39
|
+
'plugin',
|
|
40
|
+
'reload-plugins',
|
|
41
|
+
'effort',
|
|
42
|
+
'commit',
|
|
43
|
+
'fast',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Required frontmatter fields per resource kind. Skills/commands/agents all
|
|
47
|
+
// need at least name+description; everything else is optional metadata.
|
|
48
|
+
const REQUIRED_FRONTMATTER = ['name', 'description'];
|
|
49
|
+
|
|
50
|
+
function findRepoRoot(start) {
|
|
51
|
+
let cur = resolve(start);
|
|
52
|
+
while (cur !== '/') {
|
|
53
|
+
try {
|
|
54
|
+
statSync(join(cur, '.claude-plugin', 'marketplace.json'));
|
|
55
|
+
return cur;
|
|
56
|
+
} catch {}
|
|
57
|
+
cur = dirname(cur);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`could not find .claude-plugin/marketplace.json above ${start}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseArgs(argv) {
|
|
63
|
+
const out = { strict: false, plugins: [], printRegistry: false, root: null };
|
|
64
|
+
for (let i = 2; i < argv.length; i++) {
|
|
65
|
+
const a = argv[i];
|
|
66
|
+
if (a === '--strict') out.strict = true;
|
|
67
|
+
else if (a === '--print-registry') out.printRegistry = true;
|
|
68
|
+
else if (a === '--plugin') out.plugins.push(argv[++i]);
|
|
69
|
+
else if (a === '--root') out.root = argv[++i];
|
|
70
|
+
else if (a === '-h' || a === '--help') {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
'Usage: check-references.mjs [--strict] [--plugin name]... [--root path] [--print-registry]\n',
|
|
73
|
+
);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
} else {
|
|
76
|
+
process.stderr.write(`unknown argument: ${a}\n`);
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Minimal YAML frontmatter parser. We only need `key: value` and quoted
|
|
84
|
+
// strings — no nesting, no lists. Sufficient for plugin resource frontmatter,
|
|
85
|
+
// and avoids pulling in a YAML dependency.
|
|
86
|
+
function parseFrontmatter(source) {
|
|
87
|
+
const lines = source.split(/\r?\n/);
|
|
88
|
+
if (lines[0] !== '---') return { frontmatter: null, body: source };
|
|
89
|
+
let end = -1;
|
|
90
|
+
for (let i = 1; i < lines.length; i++) {
|
|
91
|
+
if (lines[i] === '---') {
|
|
92
|
+
end = i;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (end === -1) return { frontmatter: null, body: source };
|
|
97
|
+
const fm = {};
|
|
98
|
+
for (let i = 1; i < end; i++) {
|
|
99
|
+
const m = lines[i].match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
100
|
+
if (!m) continue;
|
|
101
|
+
let v = m[2].trim();
|
|
102
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
103
|
+
v = v.slice(1, -1);
|
|
104
|
+
}
|
|
105
|
+
fm[m[1]] = v;
|
|
106
|
+
}
|
|
107
|
+
return { frontmatter: fm, body: lines.slice(end + 1).join('\n') };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Resolve a plugin manifest entry that may be either a bare string or
|
|
111
|
+
// `{ source, name? }` to an absolute path inside the plugin tree.
|
|
112
|
+
function entryPath(pluginRoot, entry) {
|
|
113
|
+
if (typeof entry === 'string') return resolve(pluginRoot, entry);
|
|
114
|
+
if (entry && typeof entry === 'object' && typeof entry.source === 'string') {
|
|
115
|
+
return resolve(pluginRoot, entry.source);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Extract a friendly identifier from an entry for error messages.
|
|
121
|
+
function entryLabel(entry) {
|
|
122
|
+
if (typeof entry === 'string') return entry;
|
|
123
|
+
if (entry && typeof entry === 'object') {
|
|
124
|
+
return entry.name ?? entry.source ?? JSON.stringify(entry);
|
|
125
|
+
}
|
|
126
|
+
return String(entry);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Walk a single resource kind (skills/commands/agents) for a plugin.
|
|
130
|
+
// Returns { errors[], records[] } where records are { kind, name, file, plugin }.
|
|
131
|
+
function checkResourceKind(plugin, pluginJson, kind, pluginRoot) {
|
|
132
|
+
const errors = [];
|
|
133
|
+
const records = [];
|
|
134
|
+
const entries = pluginJson[kind];
|
|
135
|
+
if (!Array.isArray(entries)) return { errors, records };
|
|
136
|
+
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
const abs = entryPath(pluginRoot, entry);
|
|
139
|
+
if (!abs) {
|
|
140
|
+
errors.push(`[${plugin}] ${kind} entry has neither a string path nor a {source} field: ${entryLabel(entry)}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let stat;
|
|
145
|
+
try {
|
|
146
|
+
stat = statSync(abs);
|
|
147
|
+
} catch {
|
|
148
|
+
errors.push(`[${plugin}] ${kind} entry points to a missing file: ${entryLabel(entry)} (${abs})`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// If it's a directory, look for either SKILL.md / COMMAND.md / AGENT.md
|
|
153
|
+
// or any single .md file inside. We treat each found .md as one record.
|
|
154
|
+
const files = stat.isDirectory() ? collectMarkdownInDir(abs) : [abs];
|
|
155
|
+
if (files.length === 0) {
|
|
156
|
+
errors.push(`[${plugin}] ${kind} entry directory contains no markdown: ${abs}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
const raw = readFileSync(file, 'utf8');
|
|
162
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
163
|
+
if (!frontmatter) {
|
|
164
|
+
errors.push(`[${plugin}] ${kind} file is missing YAML frontmatter: ${relative(pluginRoot, file)}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const required of REQUIRED_FRONTMATTER) {
|
|
168
|
+
if (!frontmatter[required] || frontmatter[required].trim() === '') {
|
|
169
|
+
errors.push(
|
|
170
|
+
`[${plugin}] ${kind} file is missing required frontmatter field "${required}": ${relative(pluginRoot, file)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
records.push({
|
|
175
|
+
kind,
|
|
176
|
+
plugin,
|
|
177
|
+
name: frontmatter.name ?? entryLabel(entry),
|
|
178
|
+
file,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { errors, records };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function collectMarkdownInDir(dir) {
|
|
187
|
+
const out = [];
|
|
188
|
+
const stack = [dir];
|
|
189
|
+
while (stack.length) {
|
|
190
|
+
const cur = stack.pop();
|
|
191
|
+
let items;
|
|
192
|
+
try {
|
|
193
|
+
items = readdirSync(cur, { withFileTypes: true });
|
|
194
|
+
} catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
const p = join(cur, item.name);
|
|
199
|
+
if (item.isDirectory()) stack.push(p);
|
|
200
|
+
else if (item.isFile() && p.toLowerCase().endsWith('.md')) out.push(p);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out.sort();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Scan a markdown body for slash-command references. Returns an array of
|
|
207
|
+
// `{ name, line }` for everything that looks like a slash command.
|
|
208
|
+
function findSlashCommands(body) {
|
|
209
|
+
const refs = [];
|
|
210
|
+
const lines = body.split(/\r?\n/);
|
|
211
|
+
// A slash command is /name where name is lowercase + digits + hyphen.
|
|
212
|
+
// We require a non-word char (or start-of-string) before the `/` so URLs,
|
|
213
|
+
// regex literals, and option flags don't match.
|
|
214
|
+
const rx = /(^|[\s(`'"])\/([a-z][a-z0-9-]{1,40})\b/g;
|
|
215
|
+
for (let i = 0; i < lines.length; i++) {
|
|
216
|
+
const line = lines[i];
|
|
217
|
+
// Skip fenced code blocks and inline code: an exhaustive parser is
|
|
218
|
+
// overkill, but we strip backtick-enclosed runs first to cut down on
|
|
219
|
+
// the most common false positives.
|
|
220
|
+
const stripped = line.replace(/`[^`]*`/g, '');
|
|
221
|
+
rx.lastIndex = 0;
|
|
222
|
+
let m = rx.exec(stripped);
|
|
223
|
+
while (m !== null) {
|
|
224
|
+
refs.push({ name: m[2], line: i + 1 });
|
|
225
|
+
m = rx.exec(stripped);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return refs;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function main() {
|
|
232
|
+
const args = parseArgs(process.argv);
|
|
233
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
234
|
+
const root = args.root ? resolve(args.root) : findRepoRoot(here);
|
|
235
|
+
|
|
236
|
+
const marketplacePath = join(root, '.claude-plugin', 'marketplace.json');
|
|
237
|
+
let marketplace;
|
|
238
|
+
try {
|
|
239
|
+
marketplace = JSON.parse(readFileSync(marketplacePath, 'utf8'));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
process.stderr.write(`could not read ${marketplacePath}: ${err.message}\n`);
|
|
242
|
+
process.exit(2);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const plugins = (marketplace.plugins ?? []).filter((p) => args.plugins.length === 0 || args.plugins.includes(p.name));
|
|
246
|
+
if (plugins.length === 0) {
|
|
247
|
+
process.stderr.write('no plugins matched\n');
|
|
248
|
+
process.exit(2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const errors = [];
|
|
252
|
+
const warnings = [];
|
|
253
|
+
const allRecords = [];
|
|
254
|
+
const commandRegistry = new Set(BUILTIN_COMMANDS);
|
|
255
|
+
|
|
256
|
+
for (const plugin of plugins) {
|
|
257
|
+
const pluginRoot = resolve(root, plugin.source ?? '.');
|
|
258
|
+
const pluginJsonPath = join(pluginRoot, '.claude-plugin', 'plugin.json');
|
|
259
|
+
let pluginJson;
|
|
260
|
+
try {
|
|
261
|
+
pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8'));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
errors.push(`[${plugin.name}] cannot read plugin.json: ${err.message}`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const kind of ['skills', 'commands', 'agents']) {
|
|
268
|
+
const { errors: kindErrors, records } = checkResourceKind(plugin.name, pluginJson, kind, pluginRoot);
|
|
269
|
+
errors.push(...kindErrors);
|
|
270
|
+
allRecords.push(...records);
|
|
271
|
+
if (kind === 'commands') {
|
|
272
|
+
for (const rec of records) commandRegistry.add(rec.name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Pass 2: cross-reference body slash commands.
|
|
278
|
+
for (const rec of allRecords) {
|
|
279
|
+
const raw = readFileSync(rec.file, 'utf8');
|
|
280
|
+
const { body } = parseFrontmatter(raw);
|
|
281
|
+
const refs = findSlashCommands(body ?? raw);
|
|
282
|
+
for (const r of refs) {
|
|
283
|
+
if (!commandRegistry.has(r.name)) {
|
|
284
|
+
warnings.push(
|
|
285
|
+
`[${rec.plugin}] ${rec.kind}/${rec.name} references unknown command "/${r.name}" at line ${r.line} of ${relative(root, rec.file)}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (args.printRegistry) {
|
|
292
|
+
process.stderr.write(`registry: ${JSON.stringify([...commandRegistry].sort())}\n`);
|
|
293
|
+
process.stderr.write(`records:\n`);
|
|
294
|
+
for (const r of allRecords) {
|
|
295
|
+
process.stderr.write(` [${r.plugin}] ${r.kind}/${r.name} -> ${relative(root, r.file)}\n`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const e of errors) process.stderr.write(`error: ${e}\n`);
|
|
300
|
+
for (const w of warnings) process.stderr.write(`warn: ${w}\n`);
|
|
301
|
+
|
|
302
|
+
const failing = errors.length > 0 || (args.strict && warnings.length > 0);
|
|
303
|
+
if (failing) {
|
|
304
|
+
process.stderr.write(`check-references: ${errors.length} error(s), ${warnings.length} warning(s)\n`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
process.stdout.write(`check-references: ok (${allRecords.length} records, ${warnings.length} warning(s))\n`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
main();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: List all prompt rules (global + project) with their pattern, guidance, and per-session fire status. Use when asked which prompt rules are active, why a rule did or didn't fire, or to debug the prompt-rule-injector hook.
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
allowed-tools: Bash(bash *)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# List prompt rules
|
|
8
|
+
|
|
9
|
+
```!
|
|
10
|
+
bash -c '
|
|
11
|
+
source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/validate-path.sh"
|
|
12
|
+
source "${CLAUDE_PLUGIN_ROOT}/scripts/lib/prompt-rules.sh"
|
|
13
|
+
prompt_rules_list_table "${CLAUDE_SESSION_ID}" "$(pwd)"
|
|
14
|
+
'
|
|
15
|
+
```
|