Skip to content

Commit d09fe86

Browse files
kmelveclaude
andcommitted
feat: add --hook flag for AI agent integration
Add --hook flag that reads JSON from stdin, extracts file path from common field patterns, and fixes markdown files in-place. Designed for seamless integration with AI coding agent hook systems. Supported JSON formats: - tool_input.file_path (Claude Code) - file_path (Cursor, Windsurf) - filePath (camelCase variant) - path (minimal) Key behaviors: - Silently skips non-.md files - Always exits 0 to never break agentic workflows - Works with Claude Code, Cursor, Windsurf, OpenCode Includes: - extractFilePath() function with 11 unit tests - Updated README with docs for all supported agents - Local .claude/settings.json hook config for testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7fc7d28 commit d09fe86

File tree

4 files changed

+251
-10
lines changed

4 files changed

+251
-10
lines changed

.claude/settings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"hooks": {
3+
"PostToolUse": [
4+
{
5+
"matcher": "Write|Edit",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "node \"$CLAUDE_PROJECT_DIR/dist/cli.js\" --hook"
10+
}
11+
]
12+
}
13+
]
14+
}
15+
}

README.md

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ Fix misaligned ASCII diagram borders in markdown files.
55
```
66
Before After
77
┌─────────────────────┐ ┌─────────────────────┐
8-
│ Component A │ → │ Component A │
9-
│ with content │ │ with content │
8+
│ Component A │ → │ Component A │
9+
│ with content │ │ with content │
1010
└─────────────────────┘ └─────────────────────┘
1111
```
1212

@@ -71,6 +71,7 @@ boxfix doc1.md doc2.md --in-place
7171
| `--json` | `-j` | Output results as JSON |
7272
| `--dry-run` | `-d` | Preview changes without modifying |
7373
| `--quiet` | `-q` | Suppress output except errors |
74+
| `--hook` | | Read JSON from stdin, extract file path, fix in-place (for AI agents) |
7475

7576
### JSON Output
7677

@@ -233,11 +234,31 @@ jobs:
233234
- run: npx @sanity-labs/boxfix --check **/*.md
234235
```
235236
236-
### Claude Code
237+
### AI Agent Hooks
237238
238-
Automatically fix diagrams as Claude writes them using [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks).
239+
The `--hook` flag enables seamless integration with AI coding agents. It reads JSON from stdin, extracts the file path from common field patterns, and silently processes markdown files.
239240

240-
Add this to your project's `.claude/settings.json`:
241+
**Key features:**
242+
- Reads JSON payload from stdin (as provided by agent hooks)
243+
- Extracts file path from common JSON structures
244+
- Silently skips non-markdown files
245+
- Always exits 0 to never break agentic workflows
246+
- Works with Claude Code, Cursor, Windsurf, and other agents
247+
248+
**Supported JSON formats:**
249+
250+
| Format | Example | Used by |
251+
|--------|---------|---------|
252+
| `tool_input.file_path` | `{"tool_input":{"file_path":"..."}}` | Claude Code |
253+
| `file_path` | `{"file_path":"..."}` | Cursor, Windsurf |
254+
| `filePath` | `{"filePath":"..."}` | Generic (camelCase) |
255+
| `path` | `{"path":"..."}` | Minimal |
256+
257+
#### Claude Code
258+
259+
Automatically fix diagrams as Claude writes them using [hooks](https://code.claude.com/docs/en/hooks).
260+
261+
Add to `.claude/settings.json`:
241262

242263
```json
243264
{
@@ -248,7 +269,7 @@ Add this to your project's `.claude/settings.json`:
248269
"hooks": [
249270
{
250271
"type": "command",
251-
"command": "npx @sanity-labs/boxfix \"$(jq -r '.tool_input.file_path // empty')\" --in-place 2>/dev/null || true"
272+
"command": "npx @sanity-labs/boxfix --hook"
252273
}
253274
]
254275
}
@@ -257,7 +278,45 @@ Add this to your project's `.claude/settings.json`:
257278
}
258279
```
259280

260-
This runs boxfix on any markdown file Claude creates or edits, silently fixing diagram borders in the background.
281+
#### Cursor
282+
283+
Cursor 1.7+ supports [hooks](https://cursor.com/docs/agent/hooks) for agent lifecycle control.
284+
285+
Add to `.cursor/hooks.json`:
286+
287+
```json
288+
{
289+
"afterFileEdit": [
290+
{
291+
"command": "npx @sanity-labs/boxfix --hook"
292+
}
293+
]
294+
}
295+
```
296+
297+
#### Windsurf
298+
299+
Windsurf (Codeium) supports [Cascade Hooks](https://docs.windsurf.com/windsurf/cascade/hooks) for automation.
300+
301+
Add to `.windsurf/hooks.json`:
302+
303+
```json
304+
{
305+
"post_write_code": [
306+
{
307+
"command": "npx @sanity-labs/boxfix --hook"
308+
}
309+
]
310+
}
311+
```
312+
313+
#### OpenCode
314+
315+
OpenCode supports plugins for extensibility. You can use the [oh-my-opencode](https://www.npmjs.com/package/oh-my-opencode) package which provides Claude Code hook compatibility, or create a custom plugin.
316+
317+
#### Other Agents
318+
319+
For any agent that pipes JSON with a file path to stdin on file edit events, the `--hook` flag should work out of the box. The tool checks for file paths in common locations (see table above) and silently exits 0 if no valid markdown path is found.
261320

262321
## Why "boxfix"?
263322

src/cli.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,78 @@
11
#!/usr/bin/env node
22
import { program } from "commander";
33
import { readFileSync, writeFileSync, existsSync } from "node:fs";
4+
import { fileURLToPath } from "node:url";
45
import { glob } from "glob";
56
import { boxfixMarkdown } from "./markdown.js";
67

8+
/**
9+
* Extract file path from common JSON field patterns used by AI agents.
10+
* Supports multiple formats for agent-agnostic integration.
11+
*/
12+
export function extractFilePath(json: unknown): string | null {
13+
if (!json || typeof json !== "object") return null;
14+
const obj = json as Record<string, unknown>;
15+
16+
// Check common path locations (order of precedence)
17+
const candidates = [
18+
(obj.tool_input as Record<string, unknown>)?.file_path, // Claude Code, Cursor
19+
obj.file_path, // Generic
20+
obj.filePath, // camelCase variant
21+
(obj.input as Record<string, unknown>)?.file_path, // Nested input object
22+
obj.path, // Minimal
23+
];
24+
25+
for (const candidate of candidates) {
26+
if (typeof candidate === "string" && candidate.length > 0) {
27+
return candidate;
28+
}
29+
}
30+
return null;
31+
}
32+
33+
/**
34+
* Read JSON from stdin and extract file path for hook processing.
35+
* Returns null if stdin is TTY, JSON is invalid, or path is not a markdown file.
36+
*/
37+
async function readHookInput(): Promise<string | null> {
38+
// Return null if stdin is TTY (no piped input)
39+
if (process.stdin.isTTY) {
40+
return null;
41+
}
42+
43+
// Read all stdin
44+
const chunks: Buffer[] = [];
45+
for await (const chunk of process.stdin) {
46+
chunks.push(chunk);
47+
}
48+
const input = Buffer.concat(chunks).toString("utf-8").trim();
49+
50+
if (!input) {
51+
return null;
52+
}
53+
54+
// Parse JSON
55+
let json: unknown;
56+
try {
57+
json = JSON.parse(input);
58+
} catch {
59+
return null;
60+
}
61+
62+
// Extract file path
63+
const filePath = extractFilePath(json);
64+
if (!filePath) {
65+
return null;
66+
}
67+
68+
// Only process markdown files
69+
if (!filePath.endsWith(".md") && !filePath.endsWith(".markdown")) {
70+
return null;
71+
}
72+
73+
return filePath;
74+
}
75+
776
interface FileResult {
877
file: string;
978
linesFixed: number;
@@ -25,15 +94,44 @@ program
2594
.name("boxfix")
2695
.description("Fix misaligned ASCII diagram borders in markdown files")
2796
.version("1.0.0")
28-
.argument("<patterns...>", "File path(s) or glob pattern(s) to process")
97+
.argument("[patterns...]", "File path(s) or glob pattern(s) to process")
2998
.option("-o, --output <file>", "Output to file (only valid with single input)")
3099
.option("-i, --in-place", "Modify files in place")
31100
.option("-d, --dry-run", "Show what would be changed without modifying files")
32101
.option("-q, --quiet", "Suppress output except errors")
33102
.option("-c, --check", "Check if files need fixing (exit code 1 if fixes needed)")
34103
.option("-j, --json", "Output results as JSON")
104+
.option(
105+
"--hook",
106+
"Read JSON from stdin, extract file path, and fix in-place (for agent integration)"
107+
)
35108
.action(async (patterns: string[], options) => {
36-
const { output, inPlace, dryRun, quiet, check, json } = options;
109+
const { output, inPlace, dryRun, quiet, check, json, hook } = options;
110+
111+
// Hook mode: read JSON from stdin and process file
112+
if (hook) {
113+
const filePath = await readHookInput();
114+
if (!filePath || !existsSync(filePath)) {
115+
process.exit(0); // Silent success
116+
}
117+
118+
try {
119+
const content = readFileSync(filePath, "utf-8");
120+
const result = boxfixMarkdown(content);
121+
if (result.stats.linesFixed > 0) {
122+
writeFileSync(filePath, result.fixed, "utf-8");
123+
}
124+
} catch {
125+
// Ignore errors in hook mode
126+
}
127+
process.exit(0);
128+
}
129+
130+
// Normal mode requires patterns
131+
if (!patterns || patterns.length === 0) {
132+
console.error("Error: No files specified");
133+
process.exit(1);
134+
}
37135

38136
// Expand glob patterns
39137
const files: string[] = [];
@@ -183,4 +281,12 @@ program
183281
}
184282
});
185283

186-
program.parse();
284+
// Only run when executed directly, not when imported
285+
const isMain =
286+
typeof import.meta.url !== "undefined" &&
287+
process.argv[1] &&
288+
fileURLToPath(import.meta.url) === process.argv[1];
289+
290+
if (isMain) {
291+
program.parse();
292+
}

test/boxfix.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { boxfix, boxfixDiagram } from "../src/boxfix.js";
33
import { boxfixMarkdown } from "../src/markdown.js";
44
import { getDisplayWidth, expandTabs } from "../src/width.js";
55
import { isDiagram, isBoundaryLine, isContentLine, isTreeLine } from "../src/diagram-detector.js";
6+
import { extractFilePath } from "../src/cli.js";
67

78
describe("getDisplayWidth", () => {
89
it("calculates width of ASCII string", () => {
@@ -254,3 +255,63 @@ Text between.
254255
expect(result.stats.blocksProcessed).toBe(2);
255256
});
256257
});
258+
259+
describe("extractFilePath", () => {
260+
it("extracts path from Claude Code format (tool_input.file_path)", () => {
261+
const json = { tool_input: { file_path: "docs/readme.md" } };
262+
expect(extractFilePath(json)).toBe("docs/readme.md");
263+
});
264+
265+
it("extracts path from generic format (file_path)", () => {
266+
const json = { file_path: "test.md" };
267+
expect(extractFilePath(json)).toBe("test.md");
268+
});
269+
270+
it("extracts path from camelCase format (filePath)", () => {
271+
const json = { filePath: "example.md" };
272+
expect(extractFilePath(json)).toBe("example.md");
273+
});
274+
275+
it("extracts path from nested input format (input.file_path)", () => {
276+
const json = { input: { file_path: "nested/file.md" } };
277+
expect(extractFilePath(json)).toBe("nested/file.md");
278+
});
279+
280+
it("extracts path from minimal format (path)", () => {
281+
const json = { path: "simple.md" };
282+
expect(extractFilePath(json)).toBe("simple.md");
283+
});
284+
285+
it("prefers tool_input.file_path over other fields", () => {
286+
const json = {
287+
tool_input: { file_path: "priority.md" },
288+
file_path: "fallback.md",
289+
path: "last.md",
290+
};
291+
expect(extractFilePath(json)).toBe("priority.md");
292+
});
293+
294+
it("returns null for empty object", () => {
295+
expect(extractFilePath({})).toBe(null);
296+
});
297+
298+
it("returns null for null input", () => {
299+
expect(extractFilePath(null)).toBe(null);
300+
});
301+
302+
it("returns null for non-object input", () => {
303+
expect(extractFilePath("string")).toBe(null);
304+
expect(extractFilePath(123)).toBe(null);
305+
expect(extractFilePath(undefined)).toBe(null);
306+
});
307+
308+
it("returns null for empty string path", () => {
309+
const json = { file_path: "" };
310+
expect(extractFilePath(json)).toBe(null);
311+
});
312+
313+
it("returns null when no recognizable path field exists", () => {
314+
const json = { something_else: "value.md" };
315+
expect(extractFilePath(json)).toBe(null);
316+
});
317+
});

0 commit comments

Comments
 (0)