Skip to content

Commit 0de251e

Browse files
committed
chore(repo): add version-sync pre-commit hook + adapter round-trip tests
- scripts/check-version-sync.sh validates plugin.json's `version` matches the marketplace.json plugin entry. Exits 1 on drift. - .githooks/pre-commit runs the version-sync check whenever either manifest is staged. Activate per-clone with `git config core.hooksPath .githooks`. - DEVELOPMENT.md adds the activation step to Clone and Setup. - OpenCodeAdapter round-trip tests cover fromOAC → toOAC stability for subagent + primary agents and verify that no extra fields are introduced on a minimal-input round trip. Vitest: 607/607 passing (was 604).
1 parent 0e29d42 commit 0de251e

4 files changed

Lines changed: 197 additions & 0 deletions

File tree

.githooks/pre-commit

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Repo-tracked pre-commit hook.
4+
#
5+
# Activate once per clone:
6+
# git config core.hooksPath .githooks
7+
#
8+
# Skip with: git commit --no-verify
9+
#
10+
11+
set -e
12+
13+
REPO_ROOT="$(git rev-parse --show-toplevel)"
14+
15+
# Only run version-sync when either manifest file is staged.
16+
if git diff --cached --name-only | grep -qE '(^plugins/claude-code/\.claude-plugin/plugin\.json$|^\.claude-plugin/marketplace\.json$)'; then
17+
bash "$REPO_ROOT/scripts/check-version-sync.sh"
18+
fi

docs/contributing/DEVELOPMENT.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,16 @@ cd OpenAgentsControl
3737
cd evals/framework
3838
npm install
3939
cd ../..
40+
41+
# Activate repo-tracked git hooks (one-time, per clone)
42+
git config core.hooksPath .githooks
4043
```
4144

45+
The `.githooks/pre-commit` hook runs `scripts/check-version-sync.sh`
46+
whenever `plugin.json` or `marketplace.json` is staged, and blocks
47+
commits where the plugin version drifts between the two manifests.
48+
Skip with `git commit --no-verify` if you have a reason.
49+
4250
### Verify Setup
4351

4452
```bash

packages/compatibility-layer/tests/unit/adapters/OpenCodeAdapter.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,4 +285,93 @@ System prompt body.`;
285285
expect(adapter.validateConversion(agent).join(" ")).toMatch(/maxSteps/);
286286
});
287287
});
288+
289+
// ============================================================================
290+
// ROUND-TRIP
291+
// ============================================================================
292+
293+
describe("round-trip (fromOAC → toOAC)", () => {
294+
it("preserves subagent frontmatter through a full round trip", async () => {
295+
const original: OpenAgent = {
296+
frontmatter: {
297+
name: "code-reviewer",
298+
description: "reviews code",
299+
mode: "subagent",
300+
model: "opus",
301+
temperature: 0.2,
302+
permission: { edit: "deny", bash: "deny", webfetch: "deny" },
303+
skills: ["code-review", "verification-before-completion"],
304+
} as AgentFrontmatter,
305+
metadata: { name: "code-reviewer" },
306+
systemPrompt: "Review code carefully.",
307+
contexts: [],
308+
};
309+
310+
const converted = await adapter.fromOAC(original);
311+
const agentMd = converted.configs.find((c) => c.fileName.endsWith(".md"))!;
312+
const reparsed = await adapter.toOAC(agentMd.content);
313+
314+
expect(reparsed.frontmatter.name).toBe(original.frontmatter.name);
315+
expect(reparsed.frontmatter.description).toBe(original.frontmatter.description);
316+
expect(reparsed.frontmatter.mode).toBe(original.frontmatter.mode);
317+
expect(reparsed.frontmatter.model).toBe(original.frontmatter.model);
318+
expect(reparsed.frontmatter.temperature).toBe(original.frontmatter.temperature);
319+
expect(reparsed.frontmatter.permission).toEqual(original.frontmatter.permission);
320+
expect(reparsed.frontmatter.skills).toEqual(original.frontmatter.skills);
321+
expect(reparsed.systemPrompt).toBe(original.systemPrompt);
322+
});
323+
324+
it("preserves primary agent identity through a round trip (mode + name)", async () => {
325+
const original: OpenAgent = {
326+
frontmatter: {
327+
name: "main",
328+
description: "primary loop",
329+
mode: "primary",
330+
model: "sonnet",
331+
tools: { read: true, write: true, edit: true, bash: false },
332+
} as AgentFrontmatter,
333+
metadata: { name: "main" },
334+
systemPrompt: "Primary loop body.",
335+
contexts: [],
336+
};
337+
338+
const converted = await adapter.fromOAC(original);
339+
const agentMd = converted.configs.find((c) =>
340+
c.fileName === ".opencode/agents/main.md"
341+
)!;
342+
const reparsed = await adapter.toOAC(agentMd.content);
343+
344+
expect(reparsed.frontmatter.name).toBe("main");
345+
expect(reparsed.frontmatter.mode).toBe("primary");
346+
expect(reparsed.frontmatter.model).toBe("sonnet");
347+
// tools→permission fallback materialised as a permission block in the MD,
348+
// and toOAC parses it back as permission (not tools) — assert that lift.
349+
expect(reparsed.frontmatter.permission).toBeDefined();
350+
expect(reparsed.frontmatter.permission!.edit).toBe("allow");
351+
expect(reparsed.frontmatter.permission!.bash).toBe("deny");
352+
expect(reparsed.systemPrompt).toBe("Primary loop body.");
353+
});
354+
355+
it("does not introduce extra fields on round trip", async () => {
356+
const original: OpenAgent = {
357+
frontmatter: {
358+
name: "minimal",
359+
description: "no extras",
360+
mode: "subagent",
361+
} as AgentFrontmatter,
362+
metadata: { name: "minimal" },
363+
systemPrompt: "Body.",
364+
contexts: [],
365+
};
366+
367+
const converted = await adapter.fromOAC(original);
368+
const agentMd = converted.configs.find((c) => c.fileName.endsWith(".md"))!;
369+
const reparsed = await adapter.toOAC(agentMd.content);
370+
371+
// Optional fields should remain undefined (not coerced to empty objects/arrays).
372+
expect(reparsed.frontmatter.temperature).toBeUndefined();
373+
expect(reparsed.frontmatter.permission).toBeUndefined();
374+
expect(reparsed.frontmatter.skills).toBeUndefined();
375+
});
376+
});
288377
});

scripts/check-version-sync.sh

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env bash
2+
#
3+
# check-version-sync.sh
4+
#
5+
# Verifies the plugin version is consistent across:
6+
# - plugins/claude-code/.claude-plugin/plugin.json (top-level "version")
7+
# - .claude-plugin/marketplace.json (plugins[].version where name == "oac")
8+
#
9+
# Exits non-zero on mismatch so a pre-commit hook can block the commit.
10+
#
11+
# Usage:
12+
# bash scripts/check-version-sync.sh
13+
14+
set -e
15+
16+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
17+
PLUGIN_JSON="$REPO_ROOT/plugins/claude-code/.claude-plugin/plugin.json"
18+
MARKETPLACE_JSON="$REPO_ROOT/.claude-plugin/marketplace.json"
19+
20+
if [ ! -f "$PLUGIN_JSON" ]; then
21+
echo "version-sync: $PLUGIN_JSON not found — skipping"
22+
exit 0
23+
fi
24+
25+
if [ ! -f "$MARKETPLACE_JSON" ]; then
26+
echo "version-sync: $MARKETPLACE_JSON not found — skipping"
27+
exit 0
28+
fi
29+
30+
# Prefer node (already a project dependency); fall back to python3 or jq.
31+
read_version() {
32+
local file="$1"
33+
local jq_filter="$2"
34+
local node_filter="$3"
35+
36+
if command -v node >/dev/null 2>&1; then
37+
node -e "
38+
const m = require('$file');
39+
const v = $node_filter;
40+
if (!v) process.exit(2);
41+
console.log(v);
42+
"
43+
elif command -v jq >/dev/null 2>&1; then
44+
jq -er "$jq_filter" "$file"
45+
elif command -v python3 >/dev/null 2>&1; then
46+
python3 - <<PY
47+
import json, sys
48+
with open("$file") as f:
49+
data = json.load(f)
50+
$node_filter
51+
PY
52+
else
53+
echo "version-sync: need node, jq, or python3 — none found" >&2
54+
exit 2
55+
fi
56+
}
57+
58+
PLUGIN_VERSION=$(node -e "
59+
const m = require('$PLUGIN_JSON');
60+
if (!m.version) process.exit(2);
61+
console.log(m.version);
62+
") || { echo "version-sync: failed to read plugin.json version" >&2; exit 2; }
63+
64+
MARKETPLACE_VERSION=$(node -e "
65+
const m = require('$MARKETPLACE_JSON');
66+
const oac = (m.plugins || []).find(p => p.name === 'oac');
67+
if (!oac || !oac.version) process.exit(2);
68+
console.log(oac.version);
69+
") || { echo "version-sync: failed to read marketplace.json oac version" >&2; exit 2; }
70+
71+
if [ "$PLUGIN_VERSION" != "$MARKETPLACE_VERSION" ]; then
72+
echo
73+
echo "✗ version-sync: plugin version drift" >&2
74+
echo " plugins/claude-code/.claude-plugin/plugin.json → $PLUGIN_VERSION" >&2
75+
echo " .claude-plugin/marketplace.json (oac entry) → $MARKETPLACE_VERSION" >&2
76+
echo
77+
echo "Bump both files together. Run with --no-verify only if you know why." >&2
78+
echo
79+
exit 1
80+
fi
81+
82+
echo "version-sync: $PLUGIN_VERSION (plugin.json) == $MARKETPLACE_VERSION (marketplace.json) ✓"

0 commit comments

Comments
 (0)