@levnikolaevich/hex-line-mcp 1.3.2 → 1.3.4
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 +62 -7
- package/dist/hook.mjs +428 -0
- package/dist/server.mjs +6615 -0
- package/output-style.md +16 -2
- package/package.json +16 -12
- package/benchmark/atomic.mjs +0 -502
- package/benchmark/graph.mjs +0 -80
- package/benchmark/index.mjs +0 -144
- package/benchmark/workflows.mjs +0 -259
- package/hook.mjs +0 -466
- package/lib/benchmark-helpers.mjs +0 -541
- package/lib/bulk-replace.mjs +0 -65
- package/lib/changes.mjs +0 -176
- package/lib/coerce.mjs +0 -2
- package/lib/edit.mjs +0 -400
- package/lib/format.mjs +0 -138
- package/lib/graph-enrich.mjs +0 -226
- package/lib/hash.mjs +0 -109
- package/lib/info.mjs +0 -91
- package/lib/normalize.mjs +0 -106
- package/lib/outline.mjs +0 -201
- package/lib/read.mjs +0 -136
- package/lib/search.mjs +0 -269
- package/lib/security.mjs +0 -112
- package/lib/setup.mjs +0 -275
- package/lib/tree.mjs +0 -236
- package/lib/update-check.mjs +0 -56
- package/lib/verify.mjs +0 -55
- package/server.mjs +0 -381
package/README.md
CHANGED
|
@@ -13,14 +13,31 @@ Every line carries an FNV-1a content hash. Every edit must present those hashes
|
|
|
13
13
|
|
|
14
14
|
### 11 MCP Tools
|
|
15
15
|
|
|
16
|
+
Core day-to-day tools:
|
|
17
|
+
|
|
18
|
+
- `read_file`
|
|
19
|
+
- `edit_file`
|
|
20
|
+
- `grep_search`
|
|
21
|
+
- `outline`
|
|
22
|
+
- `verify`
|
|
23
|
+
- `bulk_replace`
|
|
24
|
+
|
|
25
|
+
Advanced / occasional:
|
|
26
|
+
|
|
27
|
+
- `write_file`
|
|
28
|
+
- `directory_tree`
|
|
29
|
+
- `get_file_info`
|
|
30
|
+
- `changes`
|
|
31
|
+
- `setup_hooks`
|
|
32
|
+
|
|
16
33
|
| Tool | Description | Key Feature |
|
|
17
34
|
|------|-------------|-------------|
|
|
18
|
-
| `read_file` | Read file with hash-annotated lines and
|
|
19
|
-
| `edit_file` |
|
|
35
|
+
| `read_file` | Read file with hash-annotated lines, checksums, and revision | Partial reads via `offset`/`limit` |
|
|
36
|
+
| `edit_file` | Revision-aware anchor edits (`set_line`, `replace_lines`, `insert_after`, `replace_between`) | Batched same-file edits + conservative auto-rebase |
|
|
20
37
|
| `write_file` | Create new file or overwrite, auto-creates parent dirs | Path validation, no hash overhead |
|
|
21
38
|
| `grep_search` | Search with ripgrep, 3 output modes, per-group checksums | Edit-ready: grep -> edit directly with checksums |
|
|
22
39
|
| `outline` | AST-based structural overview via tree-sitter WASM | 95% token reduction (10 lines instead of 500) |
|
|
23
|
-
| `verify` | Check if held
|
|
40
|
+
| `verify` | Check if held checksums / revision are still current | Staleness check without full re-read |
|
|
24
41
|
| `directory_tree` | Compact directory tree with root .gitignore support | Skips node_modules/.git, shows file sizes |
|
|
25
42
|
| `get_file_info` | File metadata without reading content | Size, lines, mtime, type, binary detection |
|
|
26
43
|
| `setup_hooks` | Configure Claude hooks + install output style | Gemini/Codex get guidance only; no hooks |
|
|
@@ -49,6 +66,8 @@ npm i -g @levnikolaevich/hex-line-mcp
|
|
|
49
66
|
claude mcp add -s user hex-line -- hex-line-mcp
|
|
50
67
|
```
|
|
51
68
|
|
|
69
|
+
ripgrep is bundled via `@vscode/ripgrep` — no manual install needed for `grep_search`.
|
|
70
|
+
|
|
52
71
|
### Hooks
|
|
53
72
|
|
|
54
73
|
Automatic setup (run once after MCP install):
|
|
@@ -121,9 +140,31 @@ If a project already has `.codegraph/index.db`, `hex-line` can add lightweight g
|
|
|
121
140
|
|
|
122
141
|
## Tools Reference
|
|
123
142
|
|
|
143
|
+
## Common Workflows
|
|
144
|
+
|
|
145
|
+
### Local code edit in one file
|
|
146
|
+
|
|
147
|
+
1. `outline` for large code files
|
|
148
|
+
2. `read_file` for the exact range you need
|
|
149
|
+
3. `edit_file` with all known hunks in one call
|
|
150
|
+
|
|
151
|
+
### Follow-up edit on the same file
|
|
152
|
+
|
|
153
|
+
1. Carry `revision` from the earlier `read_file` or `edit_file`
|
|
154
|
+
2. Pass it back as `base_revision`
|
|
155
|
+
3. Use `verify` before rereading the file
|
|
156
|
+
|
|
157
|
+
### Rewrite a long block
|
|
158
|
+
|
|
159
|
+
Use `replace_between` inside `edit_file` when you know stable start/end anchors and want to replace a large function, class, or config block without reciting the old body.
|
|
160
|
+
|
|
161
|
+
### Literal rename / refactor
|
|
162
|
+
|
|
163
|
+
Use `bulk_replace` for text rename patterns across one or more files. Do not use it as a substitute for structured block rewrites.
|
|
164
|
+
|
|
124
165
|
### read_file
|
|
125
166
|
|
|
126
|
-
Read a file with FNV-1a hash-annotated lines
|
|
167
|
+
Read a file with FNV-1a hash-annotated lines, range checksums, file checksum, and revision. Supports directory listing.
|
|
127
168
|
|
|
128
169
|
| Parameter | Type | Required | Description |
|
|
129
170
|
|-----------|------|----------|-------------|
|
|
@@ -140,11 +181,13 @@ ab.1 import { resolve } from "node:path";
|
|
|
140
181
|
cd.2 import { readFileSync } from "node:fs";
|
|
141
182
|
...
|
|
142
183
|
checksum: 1-50:f7e2a1b0
|
|
184
|
+
revision: rev-12-a1b2c3d4
|
|
185
|
+
file: 1-120:beefcafe
|
|
143
186
|
```
|
|
144
187
|
|
|
145
188
|
### edit_file
|
|
146
189
|
|
|
147
|
-
Edit using hash-verified anchors. For text rename use bulk_replace.
|
|
190
|
+
Edit using revision-aware hash-verified anchors. Prefer one batched call per file. For text rename use bulk_replace.
|
|
148
191
|
|
|
149
192
|
| Parameter | Type | Required | Description |
|
|
150
193
|
|-----------|------|----------|-------------|
|
|
@@ -152,17 +195,28 @@ Edit using hash-verified anchors. For text rename use bulk_replace.
|
|
|
152
195
|
| `edits` | string | yes | JSON array of edit operations (see below) |
|
|
153
196
|
| `dry_run` | boolean | no | Preview changes without writing |
|
|
154
197
|
| `restore_indent` | boolean | no | Auto-fix indentation to match anchor context (default: false) |
|
|
198
|
+
| `base_revision` | string | no | Prior revision from `read_file` / `edit_file` for same-file follow-up edits |
|
|
199
|
+
| `conflict_policy` | enum | no | `conservative` or `strict` (default: `conservative`) |
|
|
155
200
|
|
|
156
201
|
Edit operations (JSON array):
|
|
157
202
|
|
|
158
203
|
```json
|
|
159
204
|
[
|
|
160
205
|
{"set_line": {"anchor": "ab.12", "new_text": "replacement line"}},
|
|
161
|
-
{"replace_lines": {"start_anchor": "ab.10", "end_anchor": "cd.15", "new_text": "..."}},
|
|
206
|
+
{"replace_lines": {"start_anchor": "ab.10", "end_anchor": "cd.15", "new_text": "...", "range_checksum": "10-15:a1b2c3d4"}},
|
|
162
207
|
{"insert_after": {"anchor": "ab.20", "text": "inserted line"}},
|
|
208
|
+
{"replace_between": {"start_anchor": "ab.30", "end_anchor": "cd.80", "new_text": "...", "boundary_mode": "inclusive"}}
|
|
163
209
|
]
|
|
164
210
|
```
|
|
165
211
|
|
|
212
|
+
Result footer includes:
|
|
213
|
+
|
|
214
|
+
- `status: OK | AUTO_REBASED | CONFLICT`
|
|
215
|
+
- `revision: ...`
|
|
216
|
+
- `file: ...`
|
|
217
|
+
- `changed_ranges: ...` when relevant
|
|
218
|
+
- `retry_checksum: ...` on local conflicts
|
|
219
|
+
|
|
166
220
|
### write_file
|
|
167
221
|
|
|
168
222
|
Create a new file or overwrite an existing one. Creates parent directories automatically.
|
|
@@ -210,12 +264,13 @@ Not for `.md`, `.json`, `.yaml`, `.txt` -- use `read_file` directly for those.
|
|
|
210
264
|
|
|
211
265
|
### verify
|
|
212
266
|
|
|
213
|
-
Check if range checksums from a prior read are still valid
|
|
267
|
+
Check if range checksums from a prior read are still valid, optionally relative to a prior `base_revision`.
|
|
214
268
|
|
|
215
269
|
| Parameter | Type | Required | Description |
|
|
216
270
|
|-----------|------|----------|-------------|
|
|
217
271
|
| `path` | string | yes | File path |
|
|
218
272
|
| `checksums` | string | yes | JSON array of checksum strings, e.g. `["1-50:f7e2a1b0"]` |
|
|
273
|
+
| `base_revision` | string | no | Prior revision to compare against latest state |
|
|
219
274
|
|
|
220
275
|
Returns a single-line confirmation or lists changed ranges.
|
|
221
276
|
|
package/dist/hook.mjs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../hex-common/src/output/normalize.mjs
|
|
4
|
+
var NORM_RULES = [
|
|
5
|
+
[/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>"],
|
|
6
|
+
[/\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/g, "<TS>"],
|
|
7
|
+
[/\d{2}-\d{2}-\d{4}\s\d{2}:\d{2}:\d{2}/g, "<TS>"],
|
|
8
|
+
[/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?/g, "<IP>"],
|
|
9
|
+
[/\/[0-9a-f]{8,}/gi, "/<ID>"],
|
|
10
|
+
[/\b\d{3,}(?=\b|[a-zA-Z])/g, "<N>"],
|
|
11
|
+
[/trace_id=[0-9a-fA-F]{1,8}/g, "trace_id=<TRACE>"]
|
|
12
|
+
];
|
|
13
|
+
function normalizeLine(line) {
|
|
14
|
+
let result = line;
|
|
15
|
+
for (const [rx, repl] of NORM_RULES) {
|
|
16
|
+
result = result.replace(rx, repl);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
function deduplicateLines(lines) {
|
|
21
|
+
const groups = /* @__PURE__ */ new Map();
|
|
22
|
+
const order = [];
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const norm = normalizeLine(line);
|
|
25
|
+
if (groups.has(norm)) groups.get(norm).count++;
|
|
26
|
+
else {
|
|
27
|
+
groups.set(norm, { representative: line, count: 1 });
|
|
28
|
+
order.push(norm);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
order.sort((a, b) => groups.get(b).count - groups.get(a).count);
|
|
32
|
+
return order.map((norm) => {
|
|
33
|
+
const { representative, count } = groups.get(norm);
|
|
34
|
+
return count > 1 ? `${representative} (x${count})` : representative;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function smartTruncate(text, headLines = 40, tailLines = 20) {
|
|
38
|
+
const lines = text.split("\n");
|
|
39
|
+
const total = lines.length;
|
|
40
|
+
const maxLines = headLines + tailLines;
|
|
41
|
+
if (total <= maxLines) return text;
|
|
42
|
+
const head = lines.slice(0, headLines);
|
|
43
|
+
const tail = lines.slice(total - tailLines);
|
|
44
|
+
const skipped = total - maxLines;
|
|
45
|
+
return [...head, `
|
|
46
|
+
--- ${skipped} lines omitted ---
|
|
47
|
+
`, ...tail].join("\n");
|
|
48
|
+
}
|
|
49
|
+
function normalizeOutput(text, opts = {}) {
|
|
50
|
+
const { deduplicate = true, headLines = 40, tailLines = 20 } = opts;
|
|
51
|
+
const lines = text.split("\n");
|
|
52
|
+
const processed = deduplicate ? deduplicateLines(lines) : lines;
|
|
53
|
+
return smartTruncate(processed.join("\n"), headLines, tailLines);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// hook.mjs
|
|
57
|
+
import { readFileSync } from "node:fs";
|
|
58
|
+
import { resolve } from "node:path";
|
|
59
|
+
import { homedir } from "node:os";
|
|
60
|
+
import { fileURLToPath } from "node:url";
|
|
61
|
+
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
62
|
+
".png",
|
|
63
|
+
".jpg",
|
|
64
|
+
".jpeg",
|
|
65
|
+
".gif",
|
|
66
|
+
".bmp",
|
|
67
|
+
".webp",
|
|
68
|
+
".svg",
|
|
69
|
+
".ico",
|
|
70
|
+
".pdf",
|
|
71
|
+
".ipynb",
|
|
72
|
+
".zip",
|
|
73
|
+
".tar",
|
|
74
|
+
".gz",
|
|
75
|
+
".7z",
|
|
76
|
+
".rar",
|
|
77
|
+
".exe",
|
|
78
|
+
".dll",
|
|
79
|
+
".so",
|
|
80
|
+
".dylib",
|
|
81
|
+
".wasm",
|
|
82
|
+
".mp3",
|
|
83
|
+
".mp4",
|
|
84
|
+
".wav",
|
|
85
|
+
".avi",
|
|
86
|
+
".mkv",
|
|
87
|
+
".ttf",
|
|
88
|
+
".otf",
|
|
89
|
+
".woff",
|
|
90
|
+
".woff2"
|
|
91
|
+
]);
|
|
92
|
+
var REVERSE_TOOL_HINTS = {
|
|
93
|
+
"mcp__hex-line__read_file": "Read (file_path, offset, limit)",
|
|
94
|
+
"mcp__hex-line__edit_file": "Edit (revision-aware hash edits, block rewrite, auto-rebase)",
|
|
95
|
+
"mcp__hex-line__write_file": "Write (file_path, content)",
|
|
96
|
+
"mcp__hex-line__grep_search": "Grep (pattern, path)",
|
|
97
|
+
"mcp__hex-line__directory_tree": "Glob (pattern) or Bash(ls)",
|
|
98
|
+
"mcp__hex-line__get_file_info": "Bash(stat/wc)",
|
|
99
|
+
"mcp__hex-line__outline": "Read with offset/limit",
|
|
100
|
+
"mcp__hex-line__verify": "Verify held checksums / revision without reread",
|
|
101
|
+
"mcp__hex-line__changes": "Bash(git diff)",
|
|
102
|
+
"mcp__hex-line__bulk_replace": "Edit (text rename/refactor across files)",
|
|
103
|
+
"mcp__hex-line__setup_hooks": "Not available (hex-line disabled)"
|
|
104
|
+
};
|
|
105
|
+
var TOOL_HINTS = {
|
|
106
|
+
Read: "mcp__hex-line__read_file (not Read). For writing: write_file (no prior Read needed)",
|
|
107
|
+
Edit: "mcp__hex-line__edit_file for revision-aware hash edits. Batch same-file hunks, carry base_revision, use replace_between for block rewrites",
|
|
108
|
+
Write: "mcp__hex-line__write_file (not Write). No prior Read needed",
|
|
109
|
+
Grep: "mcp__hex-line__grep_search (not Grep). Params: output, literal, context_before, context_after, multiline",
|
|
110
|
+
cat: "mcp__hex-line__read_file (not cat/head/tail/less/more)",
|
|
111
|
+
head: "mcp__hex-line__read_file with limit param (not head)",
|
|
112
|
+
tail: "mcp__hex-line__read_file with offset param (not tail)",
|
|
113
|
+
ls: "mcp__hex-line__directory_tree with pattern param (not ls/find/tree). E.g. pattern='*-mcp' type='dir'",
|
|
114
|
+
stat: "mcp__hex-line__get_file_info (not stat/wc/file)",
|
|
115
|
+
grep: "mcp__hex-line__grep_search (not grep/rg). Params: output, literal, context_before, context_after, multiline",
|
|
116
|
+
sed: "mcp__hex-line__edit_file for hash edits, or mcp__hex-line__bulk_replace for text rename (not sed -i)",
|
|
117
|
+
diff: "mcp__hex-line__changes (not diff). Git-based semantic diff",
|
|
118
|
+
outline: "mcp__hex-line__outline (before reading large code files)",
|
|
119
|
+
verify: "mcp__hex-line__verify (staleness / revision check without re-read)",
|
|
120
|
+
changes: "mcp__hex-line__changes (semantic AST diff)",
|
|
121
|
+
bulk: "mcp__hex-line__bulk_replace (multi-file search-replace)",
|
|
122
|
+
setup: "mcp__hex-line__setup_hooks (configure hooks for agents)"
|
|
123
|
+
};
|
|
124
|
+
var BASH_REDIRECTS = [
|
|
125
|
+
{ regex: /^cat\s+\S+/, key: "cat" },
|
|
126
|
+
{ regex: /^head\s+/, key: "head" },
|
|
127
|
+
{ regex: /^tail\s+(?!-[fF])/, key: "tail" },
|
|
128
|
+
{ regex: /^(less|more)\s+/, key: "cat" },
|
|
129
|
+
{ regex: /^ls\s+-\S*R(\s|$)/, key: "ls" },
|
|
130
|
+
// ls -R, ls -laR (recursive only)
|
|
131
|
+
{ regex: /^dir\s+\/[sS](\s|$)/, key: "ls" },
|
|
132
|
+
// dir /s, dir /S (recursive only)
|
|
133
|
+
{ regex: /^tree\s+/, key: "ls" },
|
|
134
|
+
{ regex: /^find\s+/, key: "ls" },
|
|
135
|
+
{ regex: /^(stat|wc)\s+/, key: "stat" },
|
|
136
|
+
{ regex: /^(grep|rg)\s+/, key: "grep" },
|
|
137
|
+
{ regex: /^sed\s+-i/, key: "sed" }
|
|
138
|
+
];
|
|
139
|
+
var TOOL_REDIRECT_MAP = {
|
|
140
|
+
Read: "Read",
|
|
141
|
+
Edit: "Edit",
|
|
142
|
+
Write: "Write",
|
|
143
|
+
Grep: "Grep"
|
|
144
|
+
};
|
|
145
|
+
var DANGEROUS_PATTERNS = [
|
|
146
|
+
{
|
|
147
|
+
regex: /rm\s+(-[rf]+\s+)*[/~]/,
|
|
148
|
+
reason: "rm -rf on root/home directory"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
regex: /git\s+push\s+(-f|--force)/,
|
|
152
|
+
reason: "force push can overwrite remote history"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
regex: /git\s+reset\s+--hard/,
|
|
156
|
+
reason: "hard reset discards uncommitted changes"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
regex: /DROP\s+(TABLE|DATABASE)/i,
|
|
160
|
+
reason: "DROP destroys data permanently"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
regex: /chmod\s+777/,
|
|
164
|
+
reason: "chmod 777 removes all access restrictions"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
regex: /mkfs/,
|
|
168
|
+
reason: "filesystem format destroys all data"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
regex: /dd\s+if=\/dev\/zero/,
|
|
172
|
+
reason: "direct disk write destroys data"
|
|
173
|
+
}
|
|
174
|
+
];
|
|
175
|
+
var COMPOUND_OPERATORS = /[|]|>>?|&&|\|\||;/;
|
|
176
|
+
var CMD_PATTERNS = [
|
|
177
|
+
[/npm (install|ci|update|add)/i, "npm-install"],
|
|
178
|
+
[/npm test|jest|vitest|mocha|pytest|cargo test/i, "test"],
|
|
179
|
+
[/npm run build|tsc|webpack|vite build|cargo build/i, "build"],
|
|
180
|
+
[/pip install/i, "pip-install"],
|
|
181
|
+
[/git (log|diff|status)/i, "git"]
|
|
182
|
+
];
|
|
183
|
+
var LINE_THRESHOLD = 50;
|
|
184
|
+
var HEAD_LINES = 15;
|
|
185
|
+
var TAIL_LINES = 15;
|
|
186
|
+
function extOf(filePath) {
|
|
187
|
+
const dot = filePath.lastIndexOf(".");
|
|
188
|
+
return dot !== -1 ? filePath.slice(dot).toLowerCase() : "";
|
|
189
|
+
}
|
|
190
|
+
function detectCommandType(cmd) {
|
|
191
|
+
for (const [re, type] of CMD_PATTERNS) {
|
|
192
|
+
if (re.test(cmd)) return type;
|
|
193
|
+
}
|
|
194
|
+
return "generic";
|
|
195
|
+
}
|
|
196
|
+
function extractBashText(response) {
|
|
197
|
+
if (typeof response === "string") return response;
|
|
198
|
+
if (response && typeof response === "object") {
|
|
199
|
+
const parts = [];
|
|
200
|
+
if (response.stdout) parts.push(response.stdout);
|
|
201
|
+
if (response.stderr) parts.push(response.stderr);
|
|
202
|
+
return parts.join("\n") || "";
|
|
203
|
+
}
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
var _hexLineDisabled = null;
|
|
207
|
+
function isHexLineDisabled(configPath) {
|
|
208
|
+
if (_hexLineDisabled !== null) return _hexLineDisabled;
|
|
209
|
+
_hexLineDisabled = false;
|
|
210
|
+
try {
|
|
211
|
+
const p = configPath || resolve(homedir(), ".claude.json");
|
|
212
|
+
const claudeJson = JSON.parse(readFileSync(p, "utf-8"));
|
|
213
|
+
const projects = claudeJson.projects;
|
|
214
|
+
if (!projects || typeof projects !== "object") return _hexLineDisabled;
|
|
215
|
+
const cwd = process.cwd().replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
|
|
216
|
+
for (const [path, config] of Object.entries(projects)) {
|
|
217
|
+
if (path.replace(/\\/g, "/").replace(/\/$/, "").toLowerCase() === cwd) {
|
|
218
|
+
const disabled = config.disabledMcpServers;
|
|
219
|
+
if (Array.isArray(disabled) && disabled.includes("hex-line")) {
|
|
220
|
+
_hexLineDisabled = true;
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
return _hexLineDisabled;
|
|
228
|
+
}
|
|
229
|
+
function _resetHexLineDisabledCache() {
|
|
230
|
+
_hexLineDisabled = null;
|
|
231
|
+
}
|
|
232
|
+
function block(reason, context) {
|
|
233
|
+
const output = {
|
|
234
|
+
hookSpecificOutput: {
|
|
235
|
+
hookEventName: "PreToolUse",
|
|
236
|
+
permissionDecision: "deny",
|
|
237
|
+
permissionDecisionReason: reason
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
if (context) output.hookSpecificOutput.additionalContext = context;
|
|
241
|
+
process.stdout.write(JSON.stringify(output));
|
|
242
|
+
process.exit(2);
|
|
243
|
+
}
|
|
244
|
+
function handlePreToolUse(data) {
|
|
245
|
+
const toolName = data.tool_name || "";
|
|
246
|
+
const toolInput = data.tool_input || {};
|
|
247
|
+
if (toolName.startsWith("mcp__hex-line__")) {
|
|
248
|
+
process.exit(0);
|
|
249
|
+
}
|
|
250
|
+
const hintKey = TOOL_REDIRECT_MAP[toolName];
|
|
251
|
+
if (hintKey) {
|
|
252
|
+
const filePath = toolInput.file_path || toolInput.path || "";
|
|
253
|
+
if (BINARY_EXT.has(extOf(filePath))) {
|
|
254
|
+
process.exit(0);
|
|
255
|
+
}
|
|
256
|
+
const normalPath = filePath.replace(/\\/g, "/");
|
|
257
|
+
if (normalPath.includes(".claude/plans/") || normalPath.includes("AppData")) {
|
|
258
|
+
process.exit(0);
|
|
259
|
+
}
|
|
260
|
+
const ALLOWED_CONFIGS = /* @__PURE__ */ new Set(["settings.json", "settings.local.json"]);
|
|
261
|
+
const fileName = normalPath.split("/").pop();
|
|
262
|
+
if (ALLOWED_CONFIGS.has(fileName)) {
|
|
263
|
+
let candidate = filePath;
|
|
264
|
+
if (candidate.startsWith("~/")) {
|
|
265
|
+
candidate = homedir().replace(/\\/g, "/") + candidate.slice(1);
|
|
266
|
+
}
|
|
267
|
+
const absPath = resolve(process.cwd(), candidate).replace(/\\/g, "/");
|
|
268
|
+
const projectClaude = resolve(process.cwd(), ".claude").replace(/\\/g, "/") + "/";
|
|
269
|
+
const globalClaude = resolve(homedir(), ".claude").replace(/\\/g, "/") + "/";
|
|
270
|
+
const cmp = process.platform === "win32" ? (a, b) => a.toLowerCase().startsWith(b.toLowerCase()) : (a, b) => a.startsWith(b);
|
|
271
|
+
if (cmp(absPath, projectClaude) || cmp(absPath, globalClaude)) {
|
|
272
|
+
process.exit(0);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const hint = TOOL_HINTS[hintKey];
|
|
276
|
+
const toolName2 = hint.split(" (")[0];
|
|
277
|
+
const pathNote = filePath ? ` with path="${filePath}"` : "";
|
|
278
|
+
block(`Use ${toolName2}${pathNote}`, hint);
|
|
279
|
+
}
|
|
280
|
+
if (toolName === "Bash") {
|
|
281
|
+
const command = (toolInput.command || "").trim();
|
|
282
|
+
if (command.includes("# hex-confirmed")) {
|
|
283
|
+
process.exit(0);
|
|
284
|
+
}
|
|
285
|
+
const cmdCheck = command.replace(/<<['"]?(\w+)['"]?\s*\n[\s\S]*?\n\1/g, "");
|
|
286
|
+
for (const { regex, reason } of DANGEROUS_PATTERNS) {
|
|
287
|
+
if (regex.test(cmdCheck)) {
|
|
288
|
+
block(
|
|
289
|
+
`DANGEROUS: ${reason}. Ask user to confirm, then retry with: # hex-confirmed`,
|
|
290
|
+
`Original command: ${command.slice(0, 100)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (COMPOUND_OPERATORS.test(command)) {
|
|
295
|
+
const firstCmd = command.split(/\s*[|;&>]\s*/)[0].trim();
|
|
296
|
+
for (const { regex, key } of BASH_REDIRECTS) {
|
|
297
|
+
if (regex.test(firstCmd)) {
|
|
298
|
+
const hint = TOOL_HINTS[key];
|
|
299
|
+
const toolName2 = hint.split(" (")[0];
|
|
300
|
+
block(`Use ${toolName2} instead of piped command`, hint);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
for (const { regex, key } of BASH_REDIRECTS) {
|
|
306
|
+
if (regex.test(command)) {
|
|
307
|
+
const hint = TOOL_HINTS[key];
|
|
308
|
+
const toolName2 = hint.split(" (")[0];
|
|
309
|
+
const args = command.split(/\s+/).slice(1).join(" ");
|
|
310
|
+
const argsNote = args ? ` \u2014 args: "${args}"` : "";
|
|
311
|
+
block(`Use ${toolName2}${argsNote}`, hint);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
process.exit(0);
|
|
316
|
+
}
|
|
317
|
+
function handlePreToolUseReverse(data) {
|
|
318
|
+
const toolName = data.tool_name || "";
|
|
319
|
+
if (toolName.startsWith("mcp__hex-line__")) {
|
|
320
|
+
const builtIn = REVERSE_TOOL_HINTS[toolName];
|
|
321
|
+
if (builtIn) {
|
|
322
|
+
const target = builtIn.split(" ")[0];
|
|
323
|
+
block(
|
|
324
|
+
`hex-line is disabled in this project. Use ${target}`,
|
|
325
|
+
`hex-line disabled. Use built-in: ${builtIn}`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
block("hex-line is disabled in this project", "Disabled via project settings");
|
|
329
|
+
}
|
|
330
|
+
process.exit(0);
|
|
331
|
+
}
|
|
332
|
+
function handlePostToolUse(data) {
|
|
333
|
+
const toolName = data.tool_name || "";
|
|
334
|
+
if (toolName !== "Bash") {
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
const toolInput = data.tool_input || {};
|
|
338
|
+
const rawText = extractBashText(data.tool_response);
|
|
339
|
+
const command = toolInput.command || "";
|
|
340
|
+
if (!rawText) {
|
|
341
|
+
process.exit(0);
|
|
342
|
+
}
|
|
343
|
+
const lines = rawText.split("\n");
|
|
344
|
+
const originalCount = lines.length;
|
|
345
|
+
if (originalCount < LINE_THRESHOLD) {
|
|
346
|
+
process.exit(0);
|
|
347
|
+
}
|
|
348
|
+
const type = detectCommandType(command);
|
|
349
|
+
const filtered = normalizeOutput(lines.join("\n"), { headLines: HEAD_LINES, tailLines: TAIL_LINES });
|
|
350
|
+
const filteredCount = filtered.split("\n").length;
|
|
351
|
+
const header = `RTK FILTERED: ${type} (${originalCount} lines -> ${filteredCount} lines)`;
|
|
352
|
+
const output = [
|
|
353
|
+
"=".repeat(50),
|
|
354
|
+
header,
|
|
355
|
+
"=".repeat(50),
|
|
356
|
+
"",
|
|
357
|
+
filtered,
|
|
358
|
+
"",
|
|
359
|
+
"-".repeat(50),
|
|
360
|
+
`Original: ${originalCount} lines | Filtered: ${filteredCount} lines`,
|
|
361
|
+
"=".repeat(50)
|
|
362
|
+
].join("\n");
|
|
363
|
+
process.stderr.write(output);
|
|
364
|
+
process.exit(2);
|
|
365
|
+
}
|
|
366
|
+
function handleSessionStart() {
|
|
367
|
+
const settingsFiles = [
|
|
368
|
+
resolve(process.cwd(), ".claude/settings.local.json"),
|
|
369
|
+
resolve(process.cwd(), ".claude/settings.json"),
|
|
370
|
+
resolve(homedir(), ".claude/settings.json")
|
|
371
|
+
];
|
|
372
|
+
let styleActive = false;
|
|
373
|
+
for (const f of settingsFiles) {
|
|
374
|
+
try {
|
|
375
|
+
const config = JSON.parse(readFileSync(f, "utf-8"));
|
|
376
|
+
if (config.outputStyle === "hex-line") {
|
|
377
|
+
styleActive = true;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (config.outputStyle) break;
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (styleActive) {
|
|
385
|
+
process.stdout.write(JSON.stringify({ systemMessage: "hex-line Output Style active." }));
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
const seen = /* @__PURE__ */ new Set();
|
|
389
|
+
const lines = [];
|
|
390
|
+
for (const hint of Object.values(TOOL_HINTS)) {
|
|
391
|
+
const tool = hint.split(" ")[0];
|
|
392
|
+
if (!seen.has(tool)) {
|
|
393
|
+
seen.add(tool);
|
|
394
|
+
lines.push(`- ${hint}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
lines.push("Exceptions: images, PDFs, notebooks, .claude/settings.json, .claude/settings.local.json \u2192 built-in Read; Glob always OK");
|
|
398
|
+
lines.push("Bash OK for: npm/node/git/docker/curl, pipes, scripts");
|
|
399
|
+
const msg = "Hex-line MCP available. Workflow:\n- Discovery: read_file, grep_search, outline, directory_tree\n- Same-file edits: prefer ONE edit_file call per file, carry revision/base_revision\n- Hash edits: edit_file (set_line, replace_lines, insert_after, replace_between)\n- Large rewrites: replace_between instead of reciting old blocks\n- Text rename: bulk_replace (multi-file search-replace)\n- Verify staleness: verify before considering reread\n- Write new: write_file\n" + lines.join("\n");
|
|
400
|
+
process.stdout.write(JSON.stringify({ systemMessage: msg }));
|
|
401
|
+
process.exit(0);
|
|
402
|
+
}
|
|
403
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
404
|
+
let input = "";
|
|
405
|
+
process.stdin.on("data", (chunk) => {
|
|
406
|
+
input += chunk;
|
|
407
|
+
});
|
|
408
|
+
process.stdin.on("end", () => {
|
|
409
|
+
try {
|
|
410
|
+
const data = JSON.parse(input);
|
|
411
|
+
const event = data.hook_event_name || "";
|
|
412
|
+
if (isHexLineDisabled()) {
|
|
413
|
+
if (event === "PreToolUse") handlePreToolUseReverse(data);
|
|
414
|
+
process.exit(0);
|
|
415
|
+
}
|
|
416
|
+
if (event === "SessionStart") handleSessionStart();
|
|
417
|
+
else if (event === "PreToolUse") handlePreToolUse(data);
|
|
418
|
+
else if (event === "PostToolUse") handlePostToolUse(data);
|
|
419
|
+
else process.exit(0);
|
|
420
|
+
} catch {
|
|
421
|
+
process.exit(0);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
export {
|
|
426
|
+
_resetHexLineDisabledCache,
|
|
427
|
+
isHexLineDisabled
|
|
428
|
+
};
|