Skip to content

Commit 73487dc

Browse files
committed
Update project from 'agents-common' Copier template (HEAD).
1 parent 1bd90c8 commit 73487dc

File tree

6 files changed

+604
-17
lines changed

6 files changed

+604
-17
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Opencode Plugins for Quality Assurance
2+
3+
This directory contains Opencode plugins that provide quality assurance and development workflow enforcement, ported from Claude Code hooks.
4+
5+
## Plugins
6+
7+
### ✅ 1. `post-edit-linter.js` (WORKING)
8+
**Purpose**: Runs linters after file updates
9+
**Event**: `tool.execute.after` (for `edit` tool)
10+
**Behavior**:
11+
- Checks if `hatch` command is available
12+
- Checks if `develop` Hatch environment exists
13+
- Runs `hatch --env develop run linters`
14+
- Throws error with truncated output (50 lines max) if linters fail
15+
- Early exit if conditions not met (hatch not available)
16+
- **Note**: Uses `tool.execute.after` not `file.edited` (LLM-initiated edits don't trigger `file.edited`)
17+
18+
### ⚠️ 2. `git-commit-guard.js-disabled` (DISABLED - Opencode bash tool limitation)
19+
**Purpose**: Would prevent git commits when linters or tests fail
20+
**Status**: **DISABLED** - Opencode's bash tool doesn't pass command in `input.args.command`
21+
**Issue**: Plugin intercepts `tool.execute.before` but `input.args` is empty for bash tool
22+
**Original intent**: Port of Claude Code hook `pre-bash-git-commit-check`
23+
24+
### ⚠️ 3. `python-environment-guard.js-disabled` (DISABLED - Opencode bash tool limitation)
25+
**Purpose**: Would detect improper Python usage in Bash commands
26+
**Status**: **DISABLED** - Opencode's bash tool doesn't pass command in `input.args.command`
27+
**Issue**: Plugin intercepts `tool.execute.before` but `input.args` is empty for bash tool
28+
**Original intent**: Port of Claude Code hook `pre-bash-python-check`
29+
30+
## Installation for Downstream Projects
31+
32+
When this template is copied to a downstream project:
33+
34+
1. **Navigate to the plugin directory**:
35+
```bash
36+
cd .auxiliary/configuration/coders/opencode/plugin
37+
```
38+
39+
2. **Install dependencies**:
40+
```bash
41+
npm install
42+
```
43+
44+
3. **Ensure symlink exists**:
45+
```bash
46+
# From project root
47+
ln -sf .auxiliary/configuration/coders/opencode .opencode
48+
```
49+
50+
4. **Verify plugin loading**:
51+
Opencode should automatically load plugins from `.opencode/plugin/`
52+
53+
## Dependencies
54+
55+
- `shlex`: Shell command parsing (port of Python's shlex module) - used in disabled plugins
56+
- `bun`: Runtime (provided by Opencode)
57+
58+
## Porting Notes
59+
60+
These plugins are ports of Claude Code hooks with varying success:
61+
62+
| Claude Code Hook | Opencode Plugin | Status | Key Changes |
63+
|-----------------|----------------|--------|-------------|
64+
| `post-edit-linter` | `post-edit-linter.js` |**WORKING** | Python → JavaScript, `subprocess` → Bun shell API, uses `tool.execute.after` not `file.edited` |
65+
| `pre-bash-git-commit-check` | `git-commit-guard.js-disabled` | ⚠️ **DISABLED** | Tool name: `Bash``bash`, uses npm `shlex` package. **Issue**: Opencode bash tool doesn't pass command in `input.args.command` |
66+
| `pre-bash-python-check` | `python-environment-guard.js-disabled` | ⚠️ **DISABLED** | Same parsing logic with `shlex`, exact error messages. **Issue**: Opencode bash tool doesn't pass command in `input.args.command` |
67+
68+
## Critical Discovery
69+
70+
**Opencode's bash tool limitation**: During testing, we discovered that Opencode's bash tool doesn't pass the command string in `input.args.command` (or any `input.args` field). The `input.args` object is empty `{}` when the bash tool is invoked. This prevents plugins from intercepting and analyzing bash commands.
71+
72+
**Working solution**: Only `post-edit-linter.js` works because it uses `tool.execute.after` for the `edit` tool, where file information is available in `output.metadata.filediff.file`.
73+
74+
## Error Messages
75+
76+
All error messages match the original Claude Code hooks exactly, including:
77+
- Linter output truncation to 50 lines
78+
- "Divine admonition" for git commit blocking
79+
- Warning messages for Python usage
80+
81+
## Testing
82+
83+
To test the plugins:
84+
85+
1. **File edit test**: Edit a Python file and verify linters run
86+
2. **Git commit test**: Try `git commit -m "test"` and verify checks run
87+
3. **Python usage test**: Try `python -c "print('test')"` and verify warning
88+
89+
## Troubleshooting
90+
91+
**Plugins not loading**:
92+
- Verify `.opencode` symlink points to `.auxiliary/configuration/coders/opencode`
93+
- Check Opencode version supports plugin API
94+
- Ensure dependencies are installed (`npm install`)
95+
96+
**Command not found errors**:
97+
- Verify `hatch` is installed and in PATH
98+
- Check `develop` Hatch environment exists: `hatch env show`
99+
100+
**Timeout issues**:
101+
- Timeouts match Python hooks (60s, 120s, 300s)
102+
- Uses `Promise.race` with `setTimeout` since Bun shell lacks native timeout
103+
104+
## Source Code
105+
106+
Original Claude Code hooks in `template/.auxiliary/configuration/coders/claude/scripts/`:
107+
- `post-edit-linter`
108+
- `pre-bash-git-commit-check`
109+
- `pre-bash-python-check`
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Opencode plugin to prevent git commits when linters or tests fail.
3+
* Port of Claude Code hook: template/.auxiliary/configuration/coders/claude/scripts/pre-bash-git-commit-check
4+
*/
5+
import { split } from 'shlex';
6+
7+
export const GitCommitGuard = async ({ project, client, $, directory, worktree }) => {
8+
const GIT_COMMIT_MIN_TOKENS = 2;
9+
const SPLITTERS = new Set([';', '&', '|', '&&', '||']);
10+
11+
/**
12+
* Checks if a command is available in PATH.
13+
*/
14+
async function isCommandAvailable(command) {
15+
try {
16+
const result = await $`which ${command}`.nothrow().quiet();
17+
return result.exitCode === 0;
18+
} catch {
19+
return false;
20+
}
21+
}
22+
23+
/**
24+
* Checks if a specific Hatch environment exists.
25+
*/
26+
async function isHatchEnvAvailable(envName) {
27+
try {
28+
const result = await $`hatch env show`.nothrow().quiet();
29+
if (result.exitCode !== 0) return false;
30+
return result.stdout.toString().includes(envName);
31+
} catch {
32+
return false;
33+
}
34+
}
35+
36+
/**
37+
* Runs a command with timeout using Promise.race.
38+
*/
39+
async function runCommandWithTimeout(command, timeoutMs) {
40+
const timeoutPromise = new Promise((_, reject) => {
41+
setTimeout(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)), timeoutMs);
42+
});
43+
44+
try {
45+
const commandPromise = (async () => {
46+
try {
47+
const result = await $`sh -c "${command}"`.nothrow().quiet();
48+
return {
49+
exitCode: result.exitCode,
50+
stdout: result.stdout?.toString() || '',
51+
stderr: result.stderr?.toString() || ''
52+
};
53+
} catch (error) {
54+
return {
55+
exitCode: error.exitCode || 1,
56+
stdout: error.stdout?.toString() || '',
57+
stderr: error.stderr?.toString() || error.message || ''
58+
};
59+
}
60+
})();
61+
62+
return await Promise.race([commandPromise, timeoutPromise]);
63+
} catch (error) {
64+
return {
65+
exitCode: 1,
66+
stdout: '',
67+
stderr: error.message || 'Command execution failed'
68+
};
69+
}
70+
}
71+
72+
/**
73+
* Displays divine admonition and exits.
74+
*/
75+
function errorWithDivineMessage() {
76+
const message = (
77+
"The Large Language Divinity 🌩️🤖🌩️ in the Celestial Data Center hath " +
78+
"commanded that:\n" +
79+
"* Thy code shalt pass all lints before thy commit.\n" +
80+
" Run: hatch --env develop run linters\n" +
81+
" Run: hatch --env develop run vulture\n" +
82+
"* Thy code shalt pass all tests before thy commit.\n" +
83+
" Run: hatch --env develop run testers\n\n" +
84+
"(If you are in the middle of a large refactor, consider commenting " +
85+
"out tests and adding a reminder note in the .auxiliary/notes " +
86+
"directory.)"
87+
);
88+
throw new Error(message);
89+
}
90+
91+
/**
92+
* Checks if tokens represent a git commit command.
93+
*/
94+
function isGitCommitCommand(tokens) {
95+
if (tokens.length < GIT_COMMIT_MIN_TOKENS) {
96+
return false;
97+
}
98+
return tokens[0] === 'git' && tokens[1] === 'commit';
99+
}
100+
101+
/**
102+
* Partitions command line into separate commands using shell splitters.
103+
*/
104+
function partitionCommandLine(commandLine) {
105+
// Use shlex.split for proper shell parsing (matches Python hook)
106+
const tokens = split(commandLine);
107+
108+
// Now partition by shell splitters
109+
const commands = [];
110+
let commandTokens = [];
111+
112+
for (const token of tokens) {
113+
if (SPLITTERS.has(token)) {
114+
if (commandTokens.length > 0) {
115+
commands.push(commandTokens);
116+
commandTokens = [];
117+
}
118+
continue;
119+
}
120+
commandTokens.push(token);
121+
}
122+
123+
if (commandTokens.length > 0) {
124+
commands.push(commandTokens);
125+
}
126+
127+
return commands;
128+
}
129+
130+
/**
131+
* Checks for git commit commands and validates linters/tests.
132+
*/
133+
async function checkGitCommitCommand(tokens) {
134+
if (!isGitCommitCommand(tokens)) return;
135+
136+
// Check if hatch command is available
137+
if (!(await isCommandAvailable('hatch'))) {
138+
return; // Early exit if hatch not available
139+
}
140+
141+
// Check if develop Hatch environment exists
142+
if (!(await isHatchEnvAvailable('develop'))) {
143+
return; // Early exit if develop environment not available
144+
}
145+
146+
// Run linters with 120 second timeout
147+
try {
148+
const result = await runCommandWithTimeout('hatch --env develop run linters', 120000);
149+
if (result.exitCode !== 0) {
150+
errorWithDivineMessage();
151+
}
152+
} catch {
153+
errorWithDivineMessage();
154+
}
155+
156+
// Run tests with 300 second timeout
157+
try {
158+
const result = await runCommandWithTimeout('hatch --env develop run testers', 300000);
159+
if (result.exitCode !== 0) {
160+
errorWithDivineMessage();
161+
}
162+
} catch {
163+
errorWithDivineMessage();
164+
}
165+
166+
// Run vulture with 120 second timeout
167+
try {
168+
const result = await runCommandWithTimeout('hatch --env develop run vulture', 120000);
169+
if (result.exitCode !== 0) {
170+
errorWithDivineMessage();
171+
}
172+
} catch {
173+
errorWithDivineMessage();
174+
}
175+
}
176+
177+
return {
178+
"tool.execute.before": async (input, output) => {
179+
// Only run for bash tool
180+
if (input.tool !== "bash") return;
181+
182+
// Extract command from input
183+
const command = input.args?.command || '';
184+
if (!command) return;
185+
186+
// Partition command line into separate commands
187+
const commands = partitionCommandLine(command);
188+
189+
// Check each command for git commit
190+
for (const commandTokens of commands) {
191+
await checkGitCommitCommand(commandTokens);
192+
}
193+
}
194+
};
195+
};

0 commit comments

Comments
 (0)