@fcamblor/on-changes-run 0.0.1

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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # on-changes-run
2
+
3
+ Run commands when files matching a glob pattern change between executions. Designed as a [Claude Code](https://docs.anthropic.com/en/docs/claude-code) lifecycle hook (`Stop` / `SubagentStop`).
4
+
5
+ ## How it works
6
+
7
+ 1. On each run, `on-changes-run` snapshots the SHA-256 hashes of files that are in the git diff (unstaged + staged) and match the provided glob pattern.
8
+ 2. It compares this snapshot with the one stored from the previous execution.
9
+ 3. If any files changed between the two runs, it executes the specified commands in parallel.
10
+ 4. On the **first run**, it stores a baseline and exits successfully without running any commands.
11
+
12
+ State is persisted in `<git-root>/.claude/on-changes-run.state.json`.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npx on-changes-run --on '<glob>' --exec '<command>'
18
+ ```
19
+
20
+ No installation needed when using `npx`.
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ npx on-changes-run \
26
+ --on "frontend/**/*.ts" \
27
+ --exec "cd frontend && npm run lint" \
28
+ --exec "cd frontend && npm run typecheck"
29
+ ```
30
+
31
+ ### Options
32
+
33
+ | Option | Description | Required |
34
+ |--------|-------------|----------|
35
+ | `--on <glob>` | Glob pattern to match changed files | Yes |
36
+ | `--exec <command>` | Shell command to run (repeatable) | Yes |
37
+ | `--exec-timeout <seconds>` | Timeout per command (default: 300) | No |
38
+
39
+ ### Exit codes
40
+
41
+ - `0` — All commands succeeded (or no changes detected)
42
+ - `1` — At least one command failed (error output is printed to stderr)
43
+
44
+ ## Claude Code Hook Configuration
45
+
46
+ Add to your project's `.claude/settings.json`:
47
+
48
+ ```json
49
+ {
50
+ "hooks": {
51
+ "Stop": [
52
+ {
53
+ "hooks": [
54
+ {
55
+ "type": "command",
56
+ "command": "npx on-changes-run --on 'frontend/**/*.ts' --exec 'cd frontend && npm run lint' --exec 'cd frontend && npm run typecheck'"
57
+ }
58
+ ]
59
+ }
60
+ ],
61
+ "SubagentStop": [
62
+ {
63
+ "hooks": [
64
+ {
65
+ "type": "command",
66
+ "command": "npx on-changes-run --on 'backend/**/*.kt' --exec 'cd backend && ./gradlew lint' --exec 'cd backend && ./gradlew build'"
67
+ }
68
+ ]
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ ```
74
+
75
+ When a command fails, its stdout/stderr is printed to stderr so the LLM can interpret the errors and fix the issues.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import picomatch from 'picomatch';
4
+ import { getGitRoot, getHeadSha, getDiffFiles } from './git.js';
5
+ import { computeHashes, loadState, saveState, findChangedFiles } from './state.js';
6
+ import { executeAll, printFailures } from './executor.js';
7
+ function collect(value, previous) {
8
+ return previous.concat([value]);
9
+ }
10
+ function parseCliArgs() {
11
+ const program = new Command();
12
+ program
13
+ .name('on-changes-run')
14
+ .description('Run commands when files matching a glob pattern change between executions')
15
+ .requiredOption('--on <glob>', 'Glob pattern to match changed files against')
16
+ .requiredOption('--exec <command>', 'Command to execute (repeatable, run in parallel)', collect, [])
17
+ .option('--exec-timeout <seconds>', 'Timeout per command in seconds', '300')
18
+ .parse(process.argv);
19
+ const opts = program.opts();
20
+ return {
21
+ on: opts.on,
22
+ exec: opts.exec,
23
+ execTimeout: parseInt(opts.execTimeout, 10),
24
+ };
25
+ }
26
+ async function readStdin() {
27
+ if (process.stdin.isTTY) {
28
+ return '';
29
+ }
30
+ return new Promise((resolve) => {
31
+ const chunks = [];
32
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
33
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
34
+ process.stdin.on('error', () => resolve(''));
35
+ setTimeout(() => {
36
+ process.stdin.destroy();
37
+ resolve(Buffer.concat(chunks).toString('utf-8'));
38
+ }, 1000);
39
+ });
40
+ }
41
+ async function main() {
42
+ const args = parseCliArgs();
43
+ // Consume stdin (Claude Code hook context) without blocking
44
+ await readStdin();
45
+ const gitRoot = await getGitRoot();
46
+ const headSha = await getHeadSha();
47
+ // Get files in git diff (unstaged + staged)
48
+ const diffFiles = await getDiffFiles();
49
+ // Filter diff files by glob pattern
50
+ const isMatch = picomatch(args.on);
51
+ const matchingFiles = diffFiles.filter((f) => isMatch(f));
52
+ // Compute hashes for matching diff files
53
+ const currentHashes = await computeHashes(gitRoot, matchingFiles);
54
+ const currentState = { headSha, fileHashes: currentHashes };
55
+ // Load previous state
56
+ const previousState = loadState(gitRoot, args.on);
57
+ if (!previousState) {
58
+ // First run: store baseline, exit successfully
59
+ process.stderr.write(`on-changes-run: first run for pattern "${args.on}", storing baseline (${matchingFiles.length} files tracked)\n`);
60
+ await saveState(gitRoot, args.on, currentState);
61
+ process.exit(0);
62
+ }
63
+ // Detect changes between previous and current snapshots
64
+ const changedFiles = findChangedFiles(previousState.fileHashes, currentHashes);
65
+ // Always save new state
66
+ await saveState(gitRoot, args.on, currentState);
67
+ if (changedFiles.length === 0) {
68
+ process.exit(0);
69
+ }
70
+ process.stderr.write(`on-changes-run: ${changedFiles.length} file(s) changed matching "${args.on}", running ${args.exec.length} command(s)\n`);
71
+ // Run all commands in parallel
72
+ const timeoutMs = args.execTimeout * 1000;
73
+ const results = await executeAll(args.exec, timeoutMs);
74
+ const failures = results.filter((r) => r.exitCode !== 0);
75
+ if (failures.length > 0) {
76
+ printFailures(failures);
77
+ process.exit(1);
78
+ }
79
+ process.exit(0);
80
+ }
81
+ main().catch((err) => {
82
+ process.stderr.write(`on-changes-run: fatal error: ${err}\n`);
83
+ process.exit(1);
84
+ });
@@ -0,0 +1,7 @@
1
+ import type { CommandResult } from './types.js';
2
+ /** Execute a single shell command and capture its output */
3
+ export declare function executeCommand(command: string, timeoutMs: number): Promise<CommandResult>;
4
+ /** Execute multiple commands in parallel */
5
+ export declare function executeAll(commands: string[], timeoutMs: number): Promise<CommandResult[]>;
6
+ /** Print details of failed commands to stderr */
7
+ export declare function printFailures(failures: CommandResult[]): void;
@@ -0,0 +1,38 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execAsync = promisify(exec);
4
+ /** Execute a single shell command and capture its output */
5
+ export async function executeCommand(command, timeoutMs) {
6
+ try {
7
+ const { stdout, stderr } = await execAsync(command, {
8
+ maxBuffer: 10 * 1024 * 1024,
9
+ timeout: timeoutMs,
10
+ });
11
+ return { command, exitCode: 0, stdout, stderr };
12
+ }
13
+ catch (error) {
14
+ const err = error;
15
+ return {
16
+ command,
17
+ exitCode: err.code ?? 1,
18
+ stdout: err.stdout ?? '',
19
+ stderr: err.stderr ?? '',
20
+ };
21
+ }
22
+ }
23
+ /** Execute multiple commands in parallel */
24
+ export async function executeAll(commands, timeoutMs) {
25
+ return Promise.all(commands.map((cmd) => executeCommand(cmd, timeoutMs)));
26
+ }
27
+ /** Print details of failed commands to stderr */
28
+ export function printFailures(failures) {
29
+ for (const f of failures) {
30
+ process.stderr.write(`\n--- FAILED: ${f.command} (exit code ${f.exitCode}) ---\n`);
31
+ if (f.stdout) {
32
+ process.stderr.write(`[stdout]\n${f.stdout}\n`);
33
+ }
34
+ if (f.stderr) {
35
+ process.stderr.write(`[stderr]\n${f.stderr}\n`);
36
+ }
37
+ }
38
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Returns absolute path to git repository root */
2
+ export declare function getGitRoot(): Promise<string>;
3
+ /** Returns current HEAD commit SHA */
4
+ export declare function getHeadSha(): Promise<string>;
5
+ /** Returns deduplicated list of files in the git diff (unstaged + staged), relative to git root */
6
+ export declare function getDiffFiles(): Promise<string[]>;
package/dist/git.js ADDED
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /** Returns absolute path to git repository root */
5
+ export async function getGitRoot() {
6
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel']);
7
+ return stdout.trim();
8
+ }
9
+ /** Returns current HEAD commit SHA */
10
+ export async function getHeadSha() {
11
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD']);
12
+ return stdout.trim();
13
+ }
14
+ /** Returns deduplicated list of files in the git diff (unstaged + staged), relative to git root */
15
+ export async function getDiffFiles() {
16
+ const [unstaged, staged] = await Promise.all([
17
+ execFileAsync('git', ['diff', '--name-only', 'HEAD']).catch(() => ({ stdout: '' })),
18
+ execFileAsync('git', ['diff', '--cached', '--name-only']).catch(() => ({ stdout: '' })),
19
+ ]);
20
+ const files = new Set();
21
+ for (const line of unstaged.stdout.trim().split('\n')) {
22
+ if (line)
23
+ files.add(line);
24
+ }
25
+ for (const line of staged.stdout.trim().split('\n')) {
26
+ if (line)
27
+ files.add(line);
28
+ }
29
+ return [...files];
30
+ }
@@ -0,0 +1,11 @@
1
+ import type { PatternState } from './types.js';
2
+ /** Compute SHA-256 hash of a file's content on disk */
3
+ export declare function computeFileHash(absolutePath: string): Promise<string>;
4
+ /** Compute hashes for multiple files in parallel */
5
+ export declare function computeHashes(gitRoot: string, relativePaths: string[]): Promise<Record<string, string>>;
6
+ /** Load previous state for a given pattern from the state file */
7
+ export declare function loadState(gitRoot: string, pattern: string): PatternState | null;
8
+ /** Save state for a given pattern to the state file */
9
+ export declare function saveState(gitRoot: string, pattern: string, state: PatternState): Promise<void>;
10
+ /** Find files that changed between two snapshots */
11
+ export declare function findChangedFiles(previous: Record<string, string>, current: Record<string, string>): string[];
package/dist/state.js ADDED
@@ -0,0 +1,68 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ const STATE_FILENAME = '.claude/on-changes-run.state.json';
6
+ /** Compute SHA-256 hash of a file's content on disk */
7
+ export async function computeFileHash(absolutePath) {
8
+ const content = await readFile(absolutePath);
9
+ return createHash('sha256').update(content).digest('hex');
10
+ }
11
+ /** Compute hashes for multiple files in parallel */
12
+ export async function computeHashes(gitRoot, relativePaths) {
13
+ const entries = await Promise.all(relativePaths.map(async (rel) => {
14
+ try {
15
+ const hash = await computeFileHash(join(gitRoot, rel));
16
+ return [rel, hash];
17
+ }
18
+ catch {
19
+ // File may have been deleted since git diff was run
20
+ return null;
21
+ }
22
+ }));
23
+ return Object.fromEntries(entries.filter((e) => e !== null));
24
+ }
25
+ /** Load previous state for a given pattern from the state file */
26
+ export function loadState(gitRoot, pattern) {
27
+ const statePath = join(gitRoot, STATE_FILENAME);
28
+ try {
29
+ const raw = readFileSync(statePath, 'utf-8');
30
+ const stateFile = JSON.parse(raw);
31
+ return stateFile[pattern] ?? null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Save state for a given pattern to the state file */
38
+ export async function saveState(gitRoot, pattern, state) {
39
+ const statePath = join(gitRoot, STATE_FILENAME);
40
+ let stateFile = {};
41
+ try {
42
+ const raw = await readFile(statePath, 'utf-8');
43
+ stateFile = JSON.parse(raw);
44
+ }
45
+ catch {
46
+ // File doesn't exist yet, start fresh
47
+ }
48
+ stateFile[pattern] = state;
49
+ await mkdir(join(gitRoot, '.claude'), { recursive: true });
50
+ await writeFile(statePath, JSON.stringify(stateFile, null, 2) + '\n');
51
+ }
52
+ /** Find files that changed between two snapshots */
53
+ export function findChangedFiles(previous, current) {
54
+ const changed = [];
55
+ // Files that are new or have different hashes
56
+ for (const [path, hash] of Object.entries(current)) {
57
+ if (previous[path] !== hash) {
58
+ changed.push(path);
59
+ }
60
+ }
61
+ // Files that were in the previous snapshot but not in the current one
62
+ for (const path of Object.keys(previous)) {
63
+ if (!(path in current)) {
64
+ changed.push(path);
65
+ }
66
+ }
67
+ return changed;
68
+ }
@@ -0,0 +1,24 @@
1
+ /** Per-glob-pattern persisted state */
2
+ export interface PatternState {
3
+ /** HEAD commit SHA at time of last run */
4
+ headSha: string;
5
+ /** Map of relative file path -> content hash (SHA-256 hex) */
6
+ fileHashes: Record<string, string>;
7
+ }
8
+ /** Root state file shape, keyed by glob pattern */
9
+ export interface StateFile {
10
+ [globPattern: string]: PatternState;
11
+ }
12
+ /** Parsed CLI arguments */
13
+ export interface CliArgs {
14
+ on: string;
15
+ exec: string[];
16
+ execTimeout: number;
17
+ }
18
+ /** Result of running a single command */
19
+ export interface CommandResult {
20
+ command: string;
21
+ exitCode: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@fcamblor/on-changes-run",
3
+ "version": "0.0.1",
4
+ "description": "Claude Code lifecycle hook: run commands when file changes are detected",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "fcamblor"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "on-changes-run": "./dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "dependencies": {
24
+ "commander": "^13.0.0",
25
+ "picomatch": "^4.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "~5.8.0",
29
+ "@types/node": "^22.0.0",
30
+ "@types/picomatch": "^3.0.0"
31
+ }
32
+ }