Run the project's tests after any turn that edited files, print pass or fail under Claude's answer, and send the failure back to Claude with one /fix command.

Run the project's tests after any turn in which Claude edited a file, print pass or fail under Claude's answer, and send a failure back to Claude with one /fix command. It closes the gap between "done" and "tests pass" without you running anything.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install red-green@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/red-green
Set the command once per project:
/red-green detect
That picks npm test, pnpm test, or yarn test from package.json, or pytest, cargo test, go test ./..., or make test from the files it finds. Or name it yourself: /red-green npm test -- --run.
From then on, after a turn in which Claude edited a file, a line appears under the answer:
red-green: tests passed (npm test, 4.2s)
red-green: tests FAILED, exit 1 (npm test, 6.8s). /fix sends the failure to Claude.
/fix starts a new turn with the last 30 lines of output and asks Claude to fix the failures and run the tests again. The turn starts right after the command returns.
| Command | What it does |
|---|---|
/red-green <command> | Set the test command for this project. Saved. |
/red-green detect | Pick one from the project's files. |
/red-green | Show the command and the last result. |
/red-green off, /red-green on | Pause or resume. |
/fix | Send the last failure to Claude. |
Nothing runs when nothing was edited, after an interrupted turn, or for a subagent's turn.
When Claude changed what the tests run. The command runs outside Claude Code's permission prompts, so if Claude edited package.json, a Makefile, pyproject.toml, a test runner's config, or a file named in the command itself during the turn, running it would execute code Claude just wrote. In that case a question asks first, and the default is to skip:
red-green: skipped npm test because Claude changed package.json this turn. Review it, then run the tests yourself or ask Claude to.
From claude plugin validate ./mods/red-green:
hooks: session.start, command.run{command=red-green}, command.run{command=fix}, tool.call{tool=Edit|Write|MultiEdit|NotebookEdit}, turn.complete
calls: $.clock.after, $.command.register, $.fs.exists (via detect), $.fs.read (via detect), $.process.run (via run), $.prompt.submit, $.session.root (via load), $.store.get (via load), $.store.set (via save), $.ui.ask, $.ui.log
$.process.run runs the test command you set, through sh -c, in the project root, with a 3-minute timeout. It runs only after a turn that edited files, and not without asking when that turn also changed a file that defines the tests. Nothing else is run.$.prompt.submit starts a turn only when you run /fix, in the mod's own name, never as you. It's scheduled with a zero-delay $.clock.after because a command can't submit a prompt while it holds the turn.tool.call observes edits and passes every call through unchanged.$.store holds the command and the on/off flag, per project.claude plugin validate --strict and claude plugin test pass. /red-green answered from a live claude -p session. A real test run after a real edit hasn't been exercised on screen by the author yet.claude -p nobody can answer the question above, so a turn that changed a test-defining file skips the run.MIT, see the repository root.
hooks/register.ts 192 lines1// red-green: run the tests after any turn that edited files.
2//
3// /red-green npm test set the test command for this project
4// /red-green detect pick one from package.json, pyproject.toml, Makefile, Cargo.toml, or go.mod
5// /red-green show the command and the last result
6// /red-green off | on pause or resume
7// /fix send the last failure to Claude and ask it to fix it
8//
9// After a turn in which Claude edited a file, the command runs and a line under
10// the answer says pass or fail. Nothing runs when nothing was edited.
11
12const TIMEOUT_MS = 180_000
13const TAIL_LINES = 30
14
15type Settings = { command: string; enabled: boolean }
16type Result = { command: string; exitCode: number; ms: number; tail: string; at: number }
17
18let root = ''
19let settings: Settings = { command: '', enabled: true }
20let dirty = false
21let running = false
22let last: Result | null = null
23// Files Claude edited this turn, to notice when it changed what the test command runs.
24let edited = new Set<string>()
25
26// Files that define what a test command does. An edit to one of these this turn
27// means running the command would execute code Claude just wrote, without a
28// permission prompt, so the user is asked first.
29const DEFINES_TESTS = /(?:^|\/)(?:package\.json|Makefile|GNUmakefile|pyproject\.toml|setup\.cfg|setup\.py|conftest\.py|pytest\.ini|tox\.ini|Cargo\.toml|build\.rs|go\.mod|Rakefile|Gemfile|build\.gradle(?:\.kts)?|pom\.xml|\.mocharc[^/]*|(?:jest|vitest|karma|playwright|cypress|ava)\.config\.[^/]+|\.npmrc|\.yarnrc[^/]*)$/
30
31function changedTestDefinition(): string[] {
32 const named = settings.command.split(/\s+/).filter((w) => /[./]/.test(w) && !w.startsWith('-'))
33 return [...edited].filter((f) => DEFINES_TESTS.test(f) || named.some((n) => f === n || f.endsWith('/' + n)))
34}
35
36async function load($) {
37 try {
38 root = await $.session.root()
39 const saved = await $.store.get('red-green:' + root)
40 if (saved && typeof saved === 'object') settings = { command: String(saved.command ?? ''), enabled: saved.enabled !== false }
41 } catch {
42 settings = { command: '', enabled: true }
43 }
44}
45
46async function save($) {
47 await $.store.set('red-green:' + root, settings)
48}
49
50async function detect($): Promise<string | null> {
51 const has = async (file: string) => {
52 try {
53 return await $.fs.exists(root + '/' + file)
54 } catch {
55 return false
56 }
57 }
58 if (await has('package.json')) {
59 try {
60 const pkg = JSON.parse(await $.fs.read(root + '/package.json'))
61 if (pkg.scripts && pkg.scripts.test) return (await has('pnpm-lock.yaml')) ? 'pnpm test' : (await has('yarn.lock')) ? 'yarn test' : 'npm test'
62 } catch {
63 // Unreadable package.json: keep looking.
64 }
65 }
66 if (await has('pyproject.toml')) return 'pytest'
67 if (await has('Cargo.toml')) return 'cargo test'
68 if (await has('go.mod')) return 'go test ./...'
69 if (await has('Makefile')) {
70 try {
71 if (/^test:/m.test(await $.fs.read(root + '/Makefile'))) return 'make test'
72 } catch {
73 // Unreadable Makefile.
74 }
75 }
76 return null
77}
78
79function tail(text: string): string {
80 const lines = text.split('\n').filter((l) => l.trim())
81 return lines.slice(-TAIL_LINES).join('\n')
82}
83
84async function run($): Promise<Result> {
85 const started = Date.now()
86 try {
87 const r = await $.process.run(['sh', '-c', settings.command], { cwd: root, timeoutMs: TIMEOUT_MS })
88 return { command: settings.command, exitCode: r.exitCode, ms: Date.now() - started, tail: tail(r.stdout + '\n' + r.stderr), at: started }
89 } catch (error) {
90 return { command: settings.command, exitCode: -1, ms: Date.now() - started, tail: String(error), at: started }
91 }
92}
93
94function verdict(r: Result): string {
95 const secs = (r.ms / 1000).toFixed(1) + 's'
96 if (r.exitCode === 0) return 'red-green: tests passed (' + r.command + ', ' + secs + ')'
97 if (r.exitCode === -1) return 'red-green: could not run ' + r.command + ' (' + r.tail.slice(0, 120) + ')'
98 return 'red-green: tests FAILED, exit ' + r.exitCode + ' (' + r.command + ', ' + secs + '). /fix sends the failure to Claude.'
99}
100
101export function register(on) {
102 on('session.start', async ($, e, next) => {
103 await load($)
104 for (const c of [
105 { name: 'red-green', description: 'Set or show the test command that runs after edits', argumentHint: '[<command> | detect | on | off]' },
106 { name: 'fix', description: 'Ask Claude to fix the last failing test run' },
107 ]) {
108 try {
109 await $.command.register(c)
110 } catch (error) {
111 $.ui.log('could not register /' + c.name + ': ' + error)
112 }
113 }
114 return next(e)
115 })
116
117 on('command.run', { command: 'red-green' }, async ($, e) => {
118 const args = e.args.trim()
119 if (args === 'on' || args === 'off') {
120 settings.enabled = args === 'on'
121 await save($)
122 return { text: settings.enabled ? 'red-green on.' : 'red-green off. Tests are not run after edits.' }
123 }
124 if (args === 'detect') {
125 const found = await detect($)
126 if (!found) return { text: 'Nothing recognized. Set one with /red-green <command>.' }
127 settings.command = found
128 await save($)
129 return { text: 'Test command for this project: ' + found }
130 }
131 if (args) {
132 settings.command = args
133 await save($)
134 return { text: 'Test command for this project: ' + args }
135 }
136 return {
137 text: (settings.command ? 'Test command: ' + settings.command : 'No test command. /red-green detect or /red-green <command>.') +
138 (settings.enabled ? '' : ' (off)') + (last ? '\nLast run: ' + verdict(last) : ''),
139 }
140 }).catch(async () => ({ text: 'red-green: the command failed, so nothing changed.' }))
141
142 on('command.run', { command: 'fix' }, async ($) => {
143 if (!last || last.exitCode === 0) return { text: 'No failing test run to fix.' }
144 const failure = last
145 // A prompt can't be submitted from inside a command, which holds the turn it
146 // would wait on. A zero-delay timer runs outside the command and submits it.
147 $.clock.after(0, async () => {
148 try {
149 await $.prompt.submit({
150 text: 'The test command `' + failure.command + '` failed with exit code ' + failure.exitCode + ' after your last changes. The end of its output:\n\n```\n' + failure.tail + '\n```\n\nFix the failures, then run the tests again.',
151 })
152 } catch (error) {
153 $.ui.log('red-green could not send the failure to Claude: ' + error)
154 }
155 })
156 return { text: 'Sending the failure to Claude.' }
157 }).catch(async () => ({ text: 'red-green: could not send the failure to Claude.' }))
158
159 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
160 const result = await next(e)
161 if (typeof e.agentId !== 'string' && !result.deny && !result.isError) {
162 dirty = true
163 const file = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
164 if (typeof file === 'string') edited.add(root && file.startsWith(root + '/') ? file.slice(root.length + 1) : file)
165 }
166 return result
167 }).catch(async ($, e, next) => next(e))
168
169 on('turn.complete', async ($, e, next) => {
170 if (typeof e.agentId === 'string' || e.isAborted || !dirty || !settings.enabled || !settings.command || running) return next(e)
171 dirty = false
172 const risky = changedTestDefinition()
173 edited = new Set()
174 if (risky.length) {
175 let answer = 'Skip'
176 try {
177 answer = await $.ui.ask('red-green: Claude changed ' + risky.join(', ') + ' this turn, which can change what `' + settings.command + '` runs. Run it anyway?', ['Skip', 'Run'])
178 } catch {
179 // Nobody to ask: skip.
180 }
181 if (answer !== 'Run') return { text: 'red-green: skipped ' + settings.command + ' because Claude changed ' + risky.join(', ') + ' this turn. Review it, then run the tests yourself or ask Claude to.' }
182 }
183 running = true
184 try {
185 last = await run($)
186 } finally {
187 running = false
188 }
189 return { text: verdict(last) }
190 }).catch(async ($, e, next) => next(e))
191}
192