@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Exercises the Stop hook's gating behavior. Does not run `claude -p` (the
|
|
4
|
+
# script bails when claude is not on PATH or when conditions don't apply), so
|
|
5
|
+
# these tests verify the SHORT-CIRCUIT branches: disabled, no-git, no-changes.
|
|
6
|
+
|
|
7
|
+
setup() {
|
|
8
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
9
|
+
setup_test_env
|
|
10
|
+
|
|
11
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
|
|
12
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
13
|
+
HOOK="${PLUGIN_ROOT}/scripts/hooks/tribunal-stop-gate.sh"
|
|
14
|
+
|
|
15
|
+
REPO="${BATS_TEST_TMPDIR}/repo"
|
|
16
|
+
mkdir -p "$REPO"
|
|
17
|
+
git -C "$REPO" init -q
|
|
18
|
+
git -C "$REPO" config user.email test@example.com
|
|
19
|
+
git -C "$REPO" config user.name test
|
|
20
|
+
(cd "$REPO" && printf 'initial\n' > README.md && git add README.md && git commit -q -m init)
|
|
21
|
+
|
|
22
|
+
TRANSCRIPT="${BATS_TEST_TMPDIR}/transcript.jsonl"
|
|
23
|
+
printf '{"role":"user","content":"hi"}\n' > "$TRANSCRIPT"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_make_input() {
|
|
27
|
+
local cwd="$1" tp="$2" sid="${3:-test-session}"
|
|
28
|
+
jq -n --arg cwd "$cwd" --arg tp "$tp" --arg sid "$sid" \
|
|
29
|
+
'{cwd: $cwd, transcript_path: $tp, session_id: $sid}'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@test "hook exits 0 silently when stop_hook.enabled is false (default)" {
|
|
33
|
+
local input
|
|
34
|
+
input=$(_make_input "$REPO" "$TRANSCRIPT")
|
|
35
|
+
run bash -c "printf '%s' '$input' | '$HOOK'"
|
|
36
|
+
[ "$status" -eq 0 ]
|
|
37
|
+
[ -z "$output" ]
|
|
38
|
+
# No verdict files written
|
|
39
|
+
! find "${ONLOOKER_DIR}/tribunal" -name 'stop-*.json' 2>/dev/null | grep -q .
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@test "hook exits 0 when enabled but no git context" {
|
|
43
|
+
mkdir -p "${REPO}/.claude"
|
|
44
|
+
printf '%s\n' '{"tribunal":{"stop_hook":{"enabled":true,"skip_if_no_file_changes":false}}}' \
|
|
45
|
+
> "${REPO}/.claude/settings.json"
|
|
46
|
+
# cwd outside any repo
|
|
47
|
+
local non_repo="${BATS_TEST_TMPDIR}/not-a-repo"
|
|
48
|
+
mkdir -p "$non_repo"
|
|
49
|
+
local input
|
|
50
|
+
input=$(_make_input "$non_repo" "$TRANSCRIPT")
|
|
51
|
+
run bash -c "printf '%s' '$input' | '$HOOK'"
|
|
52
|
+
[ "$status" -eq 0 ]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@test "hook skips when enabled + skip_if_no_file_changes + clean tree" {
|
|
56
|
+
mkdir -p "${REPO}/.claude"
|
|
57
|
+
printf '%s\n' '{"tribunal":{"stop_hook":{"enabled":true,"skip_if_no_file_changes":true}}}' \
|
|
58
|
+
> "${REPO}/.claude/settings.json"
|
|
59
|
+
local input
|
|
60
|
+
input=$(_make_input "$REPO" "$TRANSCRIPT")
|
|
61
|
+
run bash -c "printf '%s' '$input' | '$HOOK'"
|
|
62
|
+
[ "$status" -eq 0 ]
|
|
63
|
+
# No verdict files written (no changes to evaluate)
|
|
64
|
+
! find "${ONLOOKER_DIR}/tribunal" -name 'stop-*.json' 2>/dev/null | grep -q .
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@test "hook never prints to stdout (Stop must not break the contract)" {
|
|
68
|
+
local input
|
|
69
|
+
input=$(_make_input "$REPO" "$TRANSCRIPT")
|
|
70
|
+
run bash -c "printf '%s' '$input' | '$HOOK'"
|
|
71
|
+
[ "$status" -eq 0 ]
|
|
72
|
+
[ -z "$output" ]
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
setup() {
|
|
4
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
5
|
+
setup_test_env
|
|
6
|
+
|
|
7
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/tribunal"
|
|
8
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
9
|
+
# shellcheck disable=SC1091
|
|
10
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-ulid.sh"
|
|
11
|
+
# shellcheck disable=SC1091
|
|
12
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-verdict.sh"
|
|
13
|
+
|
|
14
|
+
KEY="abc123def456"
|
|
15
|
+
TASK_ID=$(tribunal_ulid)
|
|
16
|
+
ITER_ID=$(tribunal_ulid)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@test "ulid is 26 chars" {
|
|
20
|
+
local u
|
|
21
|
+
u=$(tribunal_ulid)
|
|
22
|
+
[ "${#u}" -eq 26 ]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@test "init_task creates task directory" {
|
|
26
|
+
tribunal_init_task "$KEY" "$TASK_ID"
|
|
27
|
+
[ -d "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}" ]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@test "init_iteration creates iteration + verdicts dirs" {
|
|
31
|
+
tribunal_init_iteration "$KEY" "$TASK_ID" "$ITER_ID"
|
|
32
|
+
[ -d "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/verdicts" ]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@test "write_project_manifest stores remote + repo_root" {
|
|
36
|
+
tribunal_write_project_manifest "$KEY" "https://example.com/r.git" "/tmp/repo"
|
|
37
|
+
local m
|
|
38
|
+
m=$(jq -r '.remote_url' "${ONLOOKER_DIR}/tribunal/${KEY}/manifest.json")
|
|
39
|
+
[ "$m" = "https://example.com/r.git" ]
|
|
40
|
+
[ "$(jq -r '.source' "${ONLOOKER_DIR}/tribunal/${KEY}/manifest.json")" = "local" ]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@test "write_task_manifest stores rubric snapshot" {
|
|
44
|
+
local rubric='{"id":"default","criteria":[{"name":"a","weight":1.0,"min_pass":0.5}],"score_threshold":0.75,"max_iterations":3,"judge_types":["standard"],"gate_policy":"majority","aggregation_method":"mean"}'
|
|
45
|
+
tribunal_write_task_manifest "$KEY" "$TASK_ID" "do the thing" "default" "$rubric"
|
|
46
|
+
local path="${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/manifest.json"
|
|
47
|
+
[ -f "$path" ]
|
|
48
|
+
[ "$(jq -r '.task_summary' "$path")" = "do the thing" ]
|
|
49
|
+
[ "$(jq -r '.rubric.gate_policy' "$path")" = "majority" ]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@test "write_actor_output writes actor.md" {
|
|
53
|
+
tribunal_write_actor_output "$KEY" "$TASK_ID" "$ITER_ID" "# work"
|
|
54
|
+
local path="${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/actor.md"
|
|
55
|
+
[ -f "$path" ]
|
|
56
|
+
[[ "$(cat "$path")" == "# work" ]]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@test "write_judge_verdict writes one file per judge_id" {
|
|
60
|
+
local v='{"score":0.8,"passed":true,"judge_type":"standard"}'
|
|
61
|
+
tribunal_write_judge_verdict "$KEY" "$TASK_ID" "$ITER_ID" "judge-1" "$v"
|
|
62
|
+
tribunal_write_judge_verdict "$KEY" "$TASK_ID" "$ITER_ID" "judge-2" "$v"
|
|
63
|
+
local count
|
|
64
|
+
count=$(find "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/verdicts" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
65
|
+
[ "$count" = "2" ]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@test "write_iteration_artifact persists named JSON files" {
|
|
69
|
+
tribunal_write_iteration_artifact "$KEY" "$TASK_ID" "$ITER_ID" "consensus" '{"aggregated_score":0.8,"passed":true}'
|
|
70
|
+
[ -f "${ONLOOKER_DIR}/tribunal/${KEY}/${TASK_ID}/iteration-${ITER_ID}/consensus.json" ]
|
|
71
|
+
}
|
|
@@ -96,7 +96,7 @@ setup() {
|
|
|
96
96
|
export _HOOK_SESSION_ID="emit-session"
|
|
97
97
|
export ONLOOKER_HOOK_TYPE="PreToolUse"
|
|
98
98
|
export ONLOOKER_TOOL_NAME="Read"
|
|
99
|
-
local payload='{"path":"/tmp/example.txt"}'
|
|
99
|
+
local payload='{"path":"/tmp/example.txt","read_mode":"full"}'
|
|
100
100
|
safe_emit "tool.file.read" "$payload"
|
|
101
101
|
[ "$?" -eq 0 ]
|
|
102
102
|
[ -f "$ONLOOKER_EVENTS_LOG" ]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"session_id": "history-session-001",
|
|
3
|
+
"hook_event_name": "PostToolUse",
|
|
4
|
+
"tool_name": "Read",
|
|
5
|
+
"tool_use_id": "toolu_read_002",
|
|
6
|
+
"duration_ms": 18,
|
|
7
|
+
"tool_input": {
|
|
8
|
+
"file_path": "/project/src/large-module.ts",
|
|
9
|
+
"offset": 400,
|
|
10
|
+
"limit": 80
|
|
11
|
+
},
|
|
12
|
+
"tool_response": {
|
|
13
|
+
"content": "// chunk line 1\n// chunk line 2\n"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/test/helpers/setup.bash
CHANGED
|
@@ -20,6 +20,15 @@ setup_test_env() {
|
|
|
20
20
|
export ONLOOKER_DIR="${TEST_HOME}/.onlooker"
|
|
21
21
|
export CLAUDE_HOME="${TEST_HOME}/.claude"
|
|
22
22
|
export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
|
|
23
|
+
|
|
24
|
+
# Sever git from the developer's global config. Otherwise XDG_CONFIG_HOME
|
|
25
|
+
# (which is exported by the parent shell and not affected by reassigning
|
|
26
|
+
# HOME) leaks `commit.gpgsign = true` and the per-test signingkey path
|
|
27
|
+
# into git-driven tests like worktree-tracker, where there's no SSH key
|
|
28
|
+
# in the isolated $TEST_HOME and `git worktree add` fails to sign.
|
|
29
|
+
export GIT_CONFIG_GLOBAL=/dev/null
|
|
30
|
+
export GIT_CONFIG_SYSTEM=/dev/null
|
|
31
|
+
unset XDG_CONFIG_HOME
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
# Source validate-path.sh with test env vars already set.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const REPO_ROOT = resolve(HERE, '..', '..');
|
|
11
|
+
const LINTER = join(REPO_ROOT, 'scripts', 'lint', 'check-manifests.mjs');
|
|
12
|
+
|
|
13
|
+
function scaffold() {
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), 'check-manifests-'));
|
|
15
|
+
mkdirSync(join(root, '.claude-plugin'), { recursive: true });
|
|
16
|
+
return root;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeJson(p, data) {
|
|
20
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
21
|
+
writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run(root, ...args) {
|
|
25
|
+
const r = spawnSync('node', [LINTER, '--root', root, ...args], { encoding: 'utf8' });
|
|
26
|
+
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const VALID_PLUGIN_JSON = (overrides = {}) => ({
|
|
30
|
+
name: 'sample',
|
|
31
|
+
version: '0.1.0',
|
|
32
|
+
description: 'A sample plugin used by tests.',
|
|
33
|
+
...overrides,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const VALID_MARKETPLACE = (overrides = {}) => ({
|
|
37
|
+
name: 'tm',
|
|
38
|
+
owner: { name: 'Onlooker' },
|
|
39
|
+
plugins: [{ name: 'sample', source: './' }],
|
|
40
|
+
...overrides,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('check-manifests', () => {
|
|
44
|
+
it('passes a minimally valid marketplace + plugin', () => {
|
|
45
|
+
const root = scaffold();
|
|
46
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
47
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
|
|
48
|
+
const r = run(root);
|
|
49
|
+
assert.equal(r.code, 0, r.stderr);
|
|
50
|
+
assert.match(r.stdout, /ok \(1 plugin/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('errors if marketplace plugin entry carries a version field (drift hazard)', () => {
|
|
54
|
+
const root = scaffold();
|
|
55
|
+
writeJson(
|
|
56
|
+
join(root, '.claude-plugin', 'marketplace.json'),
|
|
57
|
+
VALID_MARKETPLACE({
|
|
58
|
+
plugins: [{ name: 'sample', source: './', version: '0.1.0' }],
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
|
|
62
|
+
const r = run(root);
|
|
63
|
+
assert.equal(r.code, 1);
|
|
64
|
+
assert.match(r.stderr, /MUST NOT be set in marketplace\.json/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('errors when plugin.json name does not match marketplace name', () => {
|
|
68
|
+
const root = scaffold();
|
|
69
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
70
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ name: 'mismatched' }));
|
|
71
|
+
const r = run(root);
|
|
72
|
+
assert.equal(r.code, 1);
|
|
73
|
+
assert.match(r.stderr, /does not match marketplace entry name/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('errors on non-kebab-case names', () => {
|
|
77
|
+
const root = scaffold();
|
|
78
|
+
writeJson(
|
|
79
|
+
join(root, '.claude-plugin', 'marketplace.json'),
|
|
80
|
+
VALID_MARKETPLACE({ plugins: [{ name: 'Bad_Name', source: './' }] }),
|
|
81
|
+
);
|
|
82
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ name: 'Bad_Name' }));
|
|
83
|
+
const r = run(root);
|
|
84
|
+
assert.equal(r.code, 1);
|
|
85
|
+
assert.match(r.stderr, /must be kebab-case/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('errors on a non-semver version', () => {
|
|
89
|
+
const root = scaffold();
|
|
90
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
91
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ version: 'v1' }));
|
|
92
|
+
const r = run(root);
|
|
93
|
+
assert.equal(r.code, 1);
|
|
94
|
+
assert.match(r.stderr, /not semver-shaped/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('errors when required plugin.json fields are missing', () => {
|
|
98
|
+
const root = scaffold();
|
|
99
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
100
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), { name: 'sample' });
|
|
101
|
+
const r = run(root);
|
|
102
|
+
assert.equal(r.code, 1);
|
|
103
|
+
assert.match(r.stderr, /`version` is required/);
|
|
104
|
+
assert.match(r.stderr, /`description` is required/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('warns on unknown fields (typo detection)', () => {
|
|
108
|
+
const root = scaffold();
|
|
109
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
110
|
+
writeJson(
|
|
111
|
+
join(root, '.claude-plugin', 'plugin.json'),
|
|
112
|
+
VALID_PLUGIN_JSON({ descripton: 'typo' }), // misspelled
|
|
113
|
+
);
|
|
114
|
+
const r = run(root);
|
|
115
|
+
// Warnings alone do not fail.
|
|
116
|
+
assert.equal(r.code, 0);
|
|
117
|
+
assert.match(r.stderr, /unknown field "descripton"/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('--strict turns warnings into errors', () => {
|
|
121
|
+
const root = scaffold();
|
|
122
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
123
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ descripton: 'typo' }));
|
|
124
|
+
const r = run(root, '--strict');
|
|
125
|
+
assert.equal(r.code, 1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('errors when skills field is not an array', () => {
|
|
129
|
+
const root = scaffold();
|
|
130
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
131
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ skills: 'oops' }));
|
|
132
|
+
const r = run(root);
|
|
133
|
+
assert.equal(r.code, 1);
|
|
134
|
+
assert.match(r.stderr, /`skills` must be an array/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('validates hooks.json shape when present', () => {
|
|
138
|
+
const root = scaffold();
|
|
139
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
140
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
|
|
141
|
+
writeJson(join(root, 'hooks', 'hooks.json'), { wrong: true });
|
|
142
|
+
const r = run(root);
|
|
143
|
+
assert.equal(r.code, 1);
|
|
144
|
+
assert.match(r.stderr, /must contain a `hooks` object/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('warns on hook events not recognized by claude code', () => {
|
|
148
|
+
const root = scaffold();
|
|
149
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), VALID_MARKETPLACE());
|
|
150
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON());
|
|
151
|
+
writeJson(join(root, 'hooks', 'hooks.json'), {
|
|
152
|
+
hooks: { GibberishEvent: [{ hooks: [] }] },
|
|
153
|
+
});
|
|
154
|
+
const r = run(root);
|
|
155
|
+
assert.equal(r.code, 0);
|
|
156
|
+
assert.match(r.stderr, /declares unknown event "GibberishEvent"/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('--plugin filters validation to a single plugin', () => {
|
|
160
|
+
const root = scaffold();
|
|
161
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
162
|
+
name: 'tm',
|
|
163
|
+
plugins: [
|
|
164
|
+
{ name: 'good', source: './good' },
|
|
165
|
+
{ name: 'bad', source: './bad' },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
writeJson(join(root, 'good', '.claude-plugin', 'plugin.json'), VALID_PLUGIN_JSON({ name: 'good' }));
|
|
169
|
+
writeJson(join(root, 'bad', '.claude-plugin', 'plugin.json'), { name: 'bad' }); // missing version + description
|
|
170
|
+
const r = run(root, '--plugin', 'good');
|
|
171
|
+
assert.equal(r.code, 0, r.stderr);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Tests for scripts/lint/check-references.mjs. Each test stands up a
|
|
2
|
+
// scratch marketplace under BATS_TEST_TMPDIR-style isolation, runs the
|
|
3
|
+
// linter as a subprocess, and asserts on exit code + emitted output.
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const REPO_ROOT = resolve(HERE, '..', '..');
|
|
15
|
+
const LINTER = join(REPO_ROOT, 'scripts', 'lint', 'check-references.mjs');
|
|
16
|
+
|
|
17
|
+
function scaffold() {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), 'check-refs-'));
|
|
19
|
+
mkdirSync(join(root, '.claude-plugin'), { recursive: true });
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeJson(p, data) {
|
|
24
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
25
|
+
writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeFile(p, text) {
|
|
29
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
30
|
+
writeFileSync(p, text);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(root, ...args) {
|
|
34
|
+
const r = spawnSync('node', [LINTER, '--root', root, ...args], { encoding: 'utf8' });
|
|
35
|
+
return { code: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeSkill(root, pluginDir, fileRelPath, frontmatter, body = '') {
|
|
39
|
+
const fmLines = ['---', ...Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`), '---', body];
|
|
40
|
+
writeFile(join(root, pluginDir, fileRelPath), fmLines.join('\n'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('check-references', () => {
|
|
44
|
+
it('passes on an empty marketplace', () => {
|
|
45
|
+
const root = scaffold();
|
|
46
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
47
|
+
name: 'tm',
|
|
48
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
49
|
+
});
|
|
50
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
51
|
+
name: 'ecosystem',
|
|
52
|
+
version: '0.0.1',
|
|
53
|
+
});
|
|
54
|
+
const r = run(root);
|
|
55
|
+
assert.equal(r.code, 0, r.stderr);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('passes when skills and commands resolve and have frontmatter', () => {
|
|
59
|
+
const root = scaffold();
|
|
60
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
61
|
+
name: 'tm',
|
|
62
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
63
|
+
});
|
|
64
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
65
|
+
name: 'ecosystem',
|
|
66
|
+
version: '0.0.1',
|
|
67
|
+
skills: ['./skills/think.md'],
|
|
68
|
+
commands: ['./commands/commit.md'],
|
|
69
|
+
});
|
|
70
|
+
writeSkill(root, '.', 'skills/think.md', { name: 'think', description: 'muse' });
|
|
71
|
+
writeSkill(root, '.', 'commands/commit.md', { name: 'commit', description: 'git commit' });
|
|
72
|
+
const r = run(root);
|
|
73
|
+
assert.equal(r.code, 0, r.stderr);
|
|
74
|
+
assert.match(r.stdout, /ok \(2 records/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('fails when a referenced path does not exist', () => {
|
|
78
|
+
const root = scaffold();
|
|
79
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
80
|
+
name: 'tm',
|
|
81
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
82
|
+
});
|
|
83
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
84
|
+
name: 'ecosystem',
|
|
85
|
+
version: '0.0.1',
|
|
86
|
+
skills: ['./skills/missing.md'],
|
|
87
|
+
});
|
|
88
|
+
const r = run(root);
|
|
89
|
+
assert.equal(r.code, 1);
|
|
90
|
+
assert.match(r.stderr, /points to a missing file/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('fails when a markdown file has no frontmatter', () => {
|
|
94
|
+
const root = scaffold();
|
|
95
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
96
|
+
name: 'tm',
|
|
97
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
98
|
+
});
|
|
99
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
100
|
+
name: 'ecosystem',
|
|
101
|
+
version: '0.0.1',
|
|
102
|
+
skills: ['./skills/bare.md'],
|
|
103
|
+
});
|
|
104
|
+
writeFile(join(root, 'skills/bare.md'), 'no frontmatter here\n');
|
|
105
|
+
const r = run(root);
|
|
106
|
+
assert.equal(r.code, 1);
|
|
107
|
+
assert.match(r.stderr, /missing YAML frontmatter/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('fails when required frontmatter fields are missing', () => {
|
|
111
|
+
const root = scaffold();
|
|
112
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
113
|
+
name: 'tm',
|
|
114
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
115
|
+
});
|
|
116
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
117
|
+
name: 'ecosystem',
|
|
118
|
+
version: '0.0.1',
|
|
119
|
+
skills: ['./skills/half.md'],
|
|
120
|
+
});
|
|
121
|
+
writeSkill(root, '.', 'skills/half.md', { name: 'half' });
|
|
122
|
+
const r = run(root);
|
|
123
|
+
assert.equal(r.code, 1);
|
|
124
|
+
assert.match(r.stderr, /missing required frontmatter field "description"/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('warns on body references to unknown slash commands', () => {
|
|
128
|
+
const root = scaffold();
|
|
129
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
130
|
+
name: 'tm',
|
|
131
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
132
|
+
});
|
|
133
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
134
|
+
name: 'ecosystem',
|
|
135
|
+
version: '0.0.1',
|
|
136
|
+
skills: ['./skills/refs.md'],
|
|
137
|
+
});
|
|
138
|
+
writeSkill(
|
|
139
|
+
root,
|
|
140
|
+
'.',
|
|
141
|
+
'skills/refs.md',
|
|
142
|
+
{ name: 'refs', description: 'x' },
|
|
143
|
+
'Use the /nonexistent-command to bootstrap.',
|
|
144
|
+
);
|
|
145
|
+
const r = run(root);
|
|
146
|
+
// Warnings alone do not fail by default.
|
|
147
|
+
assert.equal(r.code, 0);
|
|
148
|
+
assert.match(r.stderr, /unknown command "\/nonexistent-command"/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not warn on built-in slash commands', () => {
|
|
152
|
+
const root = scaffold();
|
|
153
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
154
|
+
name: 'tm',
|
|
155
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
156
|
+
});
|
|
157
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
158
|
+
name: 'ecosystem',
|
|
159
|
+
version: '0.0.1',
|
|
160
|
+
skills: ['./skills/refs.md'],
|
|
161
|
+
});
|
|
162
|
+
writeSkill(root, '.', 'skills/refs.md', { name: 'refs', description: 'x' }, 'Run /help and /clear to reset.');
|
|
163
|
+
const r = run(root);
|
|
164
|
+
assert.equal(r.code, 0);
|
|
165
|
+
assert.doesNotMatch(r.stderr, /unknown command/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not warn on commands declared elsewhere in the same marketplace', () => {
|
|
169
|
+
const root = scaffold();
|
|
170
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
171
|
+
name: 'tm',
|
|
172
|
+
plugins: [
|
|
173
|
+
{ name: 'ecosystem', source: './' },
|
|
174
|
+
{ name: 'archivist', source: './plugins/archivist' },
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
178
|
+
name: 'ecosystem',
|
|
179
|
+
version: '0.0.1',
|
|
180
|
+
commands: ['./commands/pin.md'],
|
|
181
|
+
skills: ['./skills/uses-pin.md'],
|
|
182
|
+
});
|
|
183
|
+
writeSkill(root, '.', 'commands/pin.md', { name: 'pin', description: 'pin a memory' });
|
|
184
|
+
writeSkill(
|
|
185
|
+
root,
|
|
186
|
+
'.',
|
|
187
|
+
'skills/uses-pin.md',
|
|
188
|
+
{ name: 'uses-pin', description: 'x' },
|
|
189
|
+
'Call /pin to mark an item as important.',
|
|
190
|
+
);
|
|
191
|
+
writeJson(join(root, 'plugins/archivist/.claude-plugin/plugin.json'), {
|
|
192
|
+
name: 'archivist',
|
|
193
|
+
version: '0.0.1',
|
|
194
|
+
});
|
|
195
|
+
const r = run(root);
|
|
196
|
+
assert.equal(r.code, 0, r.stderr);
|
|
197
|
+
assert.doesNotMatch(r.stderr, /unknown command/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('--strict turns warnings into errors', () => {
|
|
201
|
+
const root = scaffold();
|
|
202
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
203
|
+
name: 'tm',
|
|
204
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
205
|
+
});
|
|
206
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
207
|
+
name: 'ecosystem',
|
|
208
|
+
version: '0.0.1',
|
|
209
|
+
skills: ['./skills/refs.md'],
|
|
210
|
+
});
|
|
211
|
+
writeSkill(root, '.', 'skills/refs.md', { name: 'refs', description: 'x' }, 'Run /nothing-here.');
|
|
212
|
+
const r = run(root, '--strict');
|
|
213
|
+
assert.equal(r.code, 1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('--plugin filters to a single plugin', () => {
|
|
217
|
+
const root = scaffold();
|
|
218
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
219
|
+
name: 'tm',
|
|
220
|
+
plugins: [
|
|
221
|
+
{ name: 'ecosystem', source: './' },
|
|
222
|
+
{ name: 'archivist', source: './plugins/archivist' },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
226
|
+
name: 'ecosystem',
|
|
227
|
+
version: '0.0.1',
|
|
228
|
+
skills: ['./skills/missing.md'],
|
|
229
|
+
});
|
|
230
|
+
writeJson(join(root, 'plugins/archivist/.claude-plugin/plugin.json'), {
|
|
231
|
+
name: 'archivist',
|
|
232
|
+
version: '0.0.1',
|
|
233
|
+
});
|
|
234
|
+
const r = run(root, '--plugin', 'archivist');
|
|
235
|
+
// ecosystem has a broken path, but we filtered it out, so this passes.
|
|
236
|
+
assert.equal(r.code, 0, r.stderr);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('treats a directory entry as a tree of markdown files', () => {
|
|
240
|
+
const root = scaffold();
|
|
241
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
242
|
+
name: 'tm',
|
|
243
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
244
|
+
});
|
|
245
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
246
|
+
name: 'ecosystem',
|
|
247
|
+
version: '0.0.1',
|
|
248
|
+
skills: ['./skills'],
|
|
249
|
+
});
|
|
250
|
+
writeSkill(root, '.', 'skills/a.md', { name: 'a', description: 'x' });
|
|
251
|
+
writeSkill(root, '.', 'skills/nested/b.md', { name: 'b', description: 'y' });
|
|
252
|
+
const r = run(root);
|
|
253
|
+
assert.equal(r.code, 0, r.stderr);
|
|
254
|
+
assert.match(r.stdout, /ok \(2 records/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('ignores slash-command-like strings inside backtick spans', () => {
|
|
258
|
+
const root = scaffold();
|
|
259
|
+
writeJson(join(root, '.claude-plugin', 'marketplace.json'), {
|
|
260
|
+
name: 'tm',
|
|
261
|
+
plugins: [{ name: 'ecosystem', source: './' }],
|
|
262
|
+
});
|
|
263
|
+
writeJson(join(root, '.claude-plugin', 'plugin.json'), {
|
|
264
|
+
name: 'ecosystem',
|
|
265
|
+
version: '0.0.1',
|
|
266
|
+
skills: ['./skills/code.md'],
|
|
267
|
+
});
|
|
268
|
+
writeSkill(
|
|
269
|
+
root,
|
|
270
|
+
'.',
|
|
271
|
+
'skills/code.md',
|
|
272
|
+
{ name: 'code', description: 'x' },
|
|
273
|
+
'Inline `/should-not-warn` should be ignored.',
|
|
274
|
+
);
|
|
275
|
+
const r = run(root);
|
|
276
|
+
assert.equal(r.code, 0);
|
|
277
|
+
assert.doesNotMatch(r.stderr, /unknown command/);
|
|
278
|
+
});
|
|
279
|
+
});
|