@levnikolaevich/hex-line-mcp 1.3.3 → 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/lib/setup.mjs DELETED
@@ -1,275 +0,0 @@
1
- /**
2
- * Setup hex-line hooks for CLI agents.
3
- *
4
- * Idempotent: re-running with same config produces no changes.
5
- * Supports: claude (hooks in ~/.claude/settings.json global), gemini, codex (info only).
6
- * Cleanup: removes old per-project hooks from .claude/settings.local.json.
7
- */
8
-
9
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
10
- import { resolve, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- import { homedir } from "node:os";
13
-
14
- // Resolve absolute path to hook.mjs at module load time.
15
- // setup.mjs is in lib/, hook.mjs is one level up (sibling of lib/).
16
- const __filename = fileURLToPath(import.meta.url);
17
- const __dirname = dirname(__filename);
18
- const HOOK_SCRIPT = resolve(__dirname, "..", "hook.mjs").replace(/\\/g, "/");
19
- const HOOK_COMMAND = `node ${HOOK_SCRIPT}`;
20
-
21
- // Substring that identifies any hex-line hook command (old relative or new absolute).
22
- const HOOK_SIGNATURE = "hex-line-mcp/hook.mjs";
23
-
24
- const NPX_MARKERS = ["_npx", "npx-cache", ".npm/_npx"];
25
-
26
- function isEphemeralInstall(scriptPath) {
27
- return NPX_MARKERS.some((m) => scriptPath.includes(m));
28
- }
29
-
30
- const CLAUDE_HOOKS = {
31
- SessionStart: {
32
- matcher: "*",
33
- hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }],
34
- },
35
- PreToolUse: {
36
- matcher: "Read|Edit|Write|Grep|Bash|mcp__hex-line__.*",
37
- hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 5 }],
38
- },
39
- PostToolUse: {
40
- matcher: "Bash",
41
- hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 10 }],
42
- },
43
- };
44
-
45
- // ---- Helpers ----
46
-
47
- function readJson(filePath) {
48
- if (!existsSync(filePath)) return null;
49
- return JSON.parse(readFileSync(filePath, "utf-8"));
50
- }
51
-
52
- function writeJson(filePath, data) {
53
- mkdirSync(dirname(filePath), { recursive: true });
54
- writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
55
- }
56
-
57
- /**
58
- * Find existing hook entry index by hex-line signature substring.
59
- * Catches both old relative ("node mcp/hex-line-mcp/hook.mjs") and
60
- * new absolute ("node d:/.../hex-line-mcp/hook.mjs") commands.
61
- */
62
- function findEntryByCommand(entries) {
63
- return entries.findIndex(
64
- (e) => Array.isArray(e.hooks) && e.hooks.some((h) =>
65
- typeof h.command === "string" && h.command.includes(HOOK_SIGNATURE)
66
- )
67
- );
68
- }
69
-
70
- // ---- Core: write hooks to a settings file ----
71
-
72
- function writeHooksToFile(settingsPath, label) {
73
- const config = readJson(settingsPath) || {};
74
-
75
- if (!config.hooks || typeof config.hooks !== "object") {
76
- config.hooks = {};
77
- }
78
-
79
- let changed = false;
80
-
81
- for (const [event, desired] of Object.entries(CLAUDE_HOOKS)) {
82
- if (!Array.isArray(config.hooks[event])) {
83
- config.hooks[event] = [];
84
- }
85
-
86
- const entries = config.hooks[event];
87
- const idx = findEntryByCommand(entries);
88
-
89
- if (idx >= 0) {
90
- const existing = entries[idx];
91
- if (existing.matcher === desired.matcher &&
92
- existing.hooks.length === desired.hooks.length &&
93
- existing.hooks[0].command === HOOK_COMMAND &&
94
- existing.hooks[0].timeout === desired.hooks[0].timeout) {
95
- continue; // Already configured exactly
96
- }
97
- // Update in place (path changed or config updated)
98
- entries[idx] = { matcher: desired.matcher, hooks: [...desired.hooks] };
99
- changed = true;
100
- } else {
101
- entries.push({ matcher: desired.matcher, hooks: [...desired.hooks] });
102
- changed = true;
103
- }
104
- }
105
-
106
- if (config.disableAllHooks !== false) {
107
- config.disableAllHooks = false;
108
- changed = true;
109
- }
110
-
111
- if (!changed) {
112
- return `Claude (${label}): already configured`;
113
- }
114
-
115
- writeJson(settingsPath, config);
116
- return `Claude (${label}): hooks -> ${HOOK_SCRIPT} OK`;
117
- }
118
-
119
- // ---- Cleanup: remove hex-line hooks from per-project file ----
120
-
121
- function cleanLocalHooks() {
122
- const localPath = resolve(process.cwd(), ".claude/settings.local.json");
123
- const config = readJson(localPath);
124
-
125
- if (!config || !config.hooks || typeof config.hooks !== "object") {
126
- return "local: clean";
127
- }
128
-
129
- let changed = false;
130
-
131
- for (const event of Object.keys(CLAUDE_HOOKS)) {
132
- if (!Array.isArray(config.hooks[event])) continue;
133
-
134
- const entries = config.hooks[event];
135
- const idx = findEntryByCommand(entries);
136
-
137
- if (idx >= 0) {
138
- entries.splice(idx, 1);
139
- changed = true;
140
- }
141
-
142
- // Remove empty arrays
143
- if (entries.length === 0) {
144
- delete config.hooks[event];
145
- }
146
- }
147
-
148
- // Remove empty hooks object
149
- if (Object.keys(config.hooks).length === 0) {
150
- delete config.hooks;
151
- }
152
-
153
- if (!changed) {
154
- return "local: clean";
155
- }
156
-
157
- writeJson(localPath, config);
158
- return "local: removed old hex-line hooks";
159
- }
160
-
161
- // ---- Output Style installer ----
162
-
163
- function installOutputStyle() {
164
- const source = resolve(dirname(fileURLToPath(import.meta.url)), "..", "output-style.md");
165
- const target = resolve(homedir(), ".claude", "output-styles", "hex-line.md");
166
-
167
- // Copy output-style.md to ~/.claude/output-styles/
168
- mkdirSync(dirname(target), { recursive: true });
169
- writeFileSync(target, readFileSync(source, "utf-8"), "utf-8");
170
-
171
- // Set hex-line only if no explicit style is already active
172
- const userSettings = resolve(homedir(), ".claude/settings.json");
173
- const config = readJson(userSettings) || {};
174
- const prev = config.outputStyle;
175
- if (!prev) {
176
- config.outputStyle = "hex-line";
177
- writeJson(userSettings, config);
178
- }
179
-
180
- const msg = prev
181
- ? `Output style file installed. Existing style '${prev}' preserved (not overridden)`
182
- : "Output style 'hex-line' installed and activated globally";
183
- return msg;
184
- }
185
-
186
- // ---- Agent configurators ----
187
-
188
- function setupClaude() {
189
- if (isEphemeralInstall(HOOK_SCRIPT)) {
190
- return "Claude: SKIPPED — hook.mjs is in npx cache (ephemeral). " +
191
- "Install permanently: npm i -g @levnikolaevich/hex-line-mcp, then re-run setup_hooks.";
192
- }
193
-
194
- const results = [];
195
-
196
- // Phase A: write hooks to global ~/.claude/settings.json
197
- const globalPath = resolve(homedir(), ".claude/settings.json");
198
- results.push(writeHooksToFile(globalPath, "global"));
199
-
200
- // Phase B: remove hex-line hooks from per-project settings.local.json
201
- results.push(cleanLocalHooks());
202
-
203
- // Phase C: install Output Style
204
- results.push(installOutputStyle());
205
-
206
- return results.join(" | ");
207
- }
208
-
209
- function setupGemini() {
210
- return "Gemini: Not supported (Gemini CLI does not support hooks. Add MCP Tool Preferences to GEMINI.md instead)";
211
- }
212
-
213
- function setupCodex() {
214
- return "Codex: Not supported (Codex CLI does not support hooks. Add MCP Tool Preferences to AGENTS.md instead)";
215
- }
216
-
217
- // ---- Uninstall: remove hex-line hooks ----
218
-
219
- function uninstallClaude() {
220
- const globalPath = resolve(homedir(), ".claude/settings.json");
221
- const config = readJson(globalPath);
222
- if (!config || !config.hooks || typeof config.hooks !== "object") {
223
- return "Claude: no hooks to remove";
224
- }
225
-
226
- let changed = false;
227
- for (const event of Object.keys(CLAUDE_HOOKS)) {
228
- if (!Array.isArray(config.hooks[event])) continue;
229
- const idx = findEntryByCommand(config.hooks[event]);
230
- if (idx >= 0) {
231
- config.hooks[event].splice(idx, 1);
232
- if (config.hooks[event].length === 0) delete config.hooks[event];
233
- changed = true;
234
- }
235
- }
236
-
237
- if (Object.keys(config.hooks).length === 0) delete config.hooks;
238
-
239
- if (!changed) return "Claude: no hex-line hooks found";
240
-
241
- writeJson(globalPath, config);
242
- return "Claude: hex-line hooks removed from global settings";
243
- }
244
-
245
- // ---- Public API ----
246
-
247
- const AGENTS = { claude: setupClaude, gemini: setupGemini, codex: setupCodex };
248
-
249
- /**
250
- * Configure hex-line hooks for one or all supported agents.
251
- * Claude: writes to ~/.claude/settings.json (global), cleans per-project hooks.
252
- * @param {string} [agent="all"] - "claude", "gemini", "codex", or "all"
253
- * @param {string} [action="install"] - "install" or "uninstall"
254
- * @returns {string} Status report
255
- */
256
- export function setupHooks(agent = "all", action = "install") {
257
- const target = (agent || "all").toLowerCase();
258
- const act = (action || "install").toLowerCase();
259
-
260
- if (act === "uninstall") {
261
- const result = uninstallClaude();
262
- return `Hooks uninstalled:\n ${result}\n\nRestart Claude Code to apply changes.`;
263
- }
264
-
265
- if (target !== "all" && !AGENTS[target]) {
266
- throw new Error(`UNKNOWN_AGENT: '${agent}'. Supported: claude, gemini, codex, all`);
267
- }
268
-
269
- const targets = target === "all" ? Object.keys(AGENTS) : [target];
270
- const results = targets.map((name) => " " + AGENTS[name]());
271
-
272
- const header = `Hooks configured for ${target}:`;
273
- const footer = "\nRestart Claude Code to apply hook changes.";
274
- return [header, ...results, footer].join("\n");
275
- }
package/lib/tree.mjs DELETED
@@ -1,236 +0,0 @@
1
- /**
2
- * Compact directory tree with root .gitignore support.
3
- *
4
- * Skips common build/cache dirs by default.
5
- * Uses `ignore` package for spec-compliant .gitignore matching (path-based, negation, dir-only).
6
- * Only root .gitignore is loaded — nested .gitignore files are not supported.
7
- */
8
-
9
- import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
10
- import { resolve, basename, join, relative } from "node:path";
11
- import { formatSize, relativeTime, countFileLines } from "./format.mjs";
12
- import { normalizePath } from "./security.mjs";
13
- import ignore from "ignore";
14
-
15
- const SKIP_DIRS = new Set([
16
- "node_modules", ".git", "dist", "build", "__pycache__", ".next", "coverage",
17
- ]);
18
-
19
- /**
20
- * Convert a simple glob pattern to a RegExp for name matching.
21
- * Used by pattern-mode to match entry names.
22
- */
23
- function globToRegex(pat) {
24
- return new RegExp(
25
- "^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&")
26
- .replace(/\*\*/g, "\0")
27
- .replace(/\*/g, "[^/]*")
28
- .replace(/\0/g, ".*")
29
- .replace(/\?/g, ".") + "$"
30
- );
31
- }
32
-
33
- /**
34
- * Load root .gitignore into an `ignore` instance.
35
- * @param {string} rootDir - absolute path to tree root
36
- * @returns {ReturnType<typeof ignore>|null}
37
- */
38
- function loadGitignore(rootDir) {
39
- const gi = join(rootDir, ".gitignore");
40
- if (!existsSync(gi)) return null;
41
- try {
42
- const content = readFileSync(gi, "utf-8");
43
- return ignore().add(content);
44
- } catch { return null; }
45
- }
46
-
47
- /**
48
- * Check if a relative path should be ignored.
49
- * @param {ReturnType<typeof ignore>|null} ig - ignore instance (null = no gitignore)
50
- * @param {string} relPath - POSIX relative path from tree root
51
- * @param {boolean} isDir - true if directory
52
- * @returns {boolean}
53
- */
54
- function isIgnored(ig, relPath, isDir) {
55
- if (!ig) return false;
56
- // ignore package expects dir paths to end with /
57
- return ig.ignores(isDir ? relPath + "/" : relPath);
58
- }
59
-
60
- /**
61
- * Find files/dirs by glob pattern. Returns flat list of relative paths.
62
- * @param {string} dirPath - Root directory to search
63
- * @param {object} opts - { pattern, type, max_depth, gitignore }
64
- * @returns {string} Formatted match list
65
- */
66
- function findByPattern(dirPath, opts) {
67
- const re = globToRegex(opts.pattern);
68
- const filterType = opts.type || "all";
69
- const maxDepth = opts.max_depth ?? 20;
70
-
71
- const abs = resolve(normalizePath(dirPath));
72
- if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}`);
73
- if (!statSync(abs).isDirectory()) throw new Error(`Not a directory: ${abs}`);
74
-
75
- const ig = (opts.gitignore ?? true) ? loadGitignore(abs) : null;
76
- const matches = [];
77
-
78
- function walk(dir, depth) {
79
- if (depth > maxDepth) return;
80
- let entries;
81
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
82
-
83
- for (const entry of entries) {
84
- const isDir = entry.isDirectory();
85
- if (SKIP_DIRS.has(entry.name) && isDir) continue;
86
-
87
- const full = join(dir, entry.name);
88
- const rel = relative(abs, full).replace(/\\/g, "/");
89
- if (isIgnored(ig, rel, isDir)) continue;
90
-
91
- if (re.test(entry.name)) {
92
- if (filterType === "all" ||
93
- (filterType === "dir" && isDir) ||
94
- (filterType === "file" && !isDir)) {
95
- matches.push(isDir ? rel + "/" : rel);
96
- }
97
- }
98
-
99
- if (isDir) walk(full, depth + 1);
100
- }
101
- }
102
-
103
- walk(abs, 1);
104
- matches.sort();
105
-
106
- const rootName = basename(abs);
107
- if (matches.length === 0) {
108
- return `No matches for "${opts.pattern}" in ${rootName}/`;
109
- }
110
- return `Found ${matches.length} match${matches.length === 1 ? "" : "es"} for "${opts.pattern}" in ${rootName}/\n\n${matches.join("\n")}`;
111
- }
112
-
113
- /**
114
- * Build directory tree recursively, or find by pattern.
115
- * @param {string} dirPath - Absolute directory path
116
- * @param {object} opts - { max_depth, gitignore, format, pattern, type }
117
- * @returns {string} Formatted tree or match list
118
- */
119
- export function directoryTree(dirPath, opts = {}) {
120
- if (opts.pattern) return findByPattern(dirPath, opts);
121
-
122
- const compact = opts.format === "compact";
123
- const maxDepth = compact ? 1 : (opts.max_depth ?? 3);
124
-
125
- const abs = resolve(normalizePath(dirPath));
126
- if (!existsSync(abs)) throw new Error(`DIRECTORY_NOT_FOUND: ${abs}. Check path or use directory_tree on parent directory.`);
127
- const rootStat = statSync(abs);
128
- if (!rootStat.isDirectory()) throw new Error(`Not a directory: ${abs}`);
129
-
130
- const ig = (opts.gitignore ?? true) ? loadGitignore(abs) : null;
131
-
132
- let totalFiles = 0;
133
- let totalSize = 0;
134
- const lines = [];
135
-
136
- /**
137
- * Recursive walk. Returns total file count for entire subtree
138
- * (including beyond maxDepth — count is always full, display is depth-limited).
139
- * Output order: pre-order (dir line before children).
140
- */
141
- function walk(dir, prefix, depth) {
142
- let entries;
143
- try {
144
- entries = readdirSync(dir, { withFileTypes: true });
145
- } catch { return 0; }
146
-
147
- // Sort: directories first, then files, alphabetical
148
- entries.sort((a, b) => {
149
- const aDir = a.isDirectory() ? 0 : 1;
150
- const bDir = b.isDirectory() ? 0 : 1;
151
- if (aDir !== bDir) return aDir - bDir;
152
- return a.name.localeCompare(b.name);
153
- });
154
-
155
- let subTotal = 0;
156
-
157
- for (const entry of entries) {
158
- const name = entry.name;
159
- const isDir = entry.isDirectory();
160
-
161
- if (SKIP_DIRS.has(name) && isDir) continue;
162
-
163
- const full = join(dir, name);
164
- const rel = relative(abs, full).replace(/\\/g, "/");
165
- if (isIgnored(ig, rel, isDir)) continue;
166
-
167
- if (isDir) {
168
- if (compact) {
169
- lines.push(`${prefix}${name}/`);
170
- } else {
171
- // Pre-order: placeholder for dir line, patch after recursion
172
- const lineIdx = lines.length;
173
- lines.push("");
174
- const count = depth < maxDepth
175
- ? walk(full, prefix + " ", depth + 1)
176
- : countSubtreeFiles(full, ig, abs);
177
- lines[lineIdx] = `${prefix}${name}/ (${count} files)`;
178
- subTotal += count;
179
- }
180
- if (compact) walk(full, prefix + " ", depth + 1);
181
- } else {
182
- totalFiles++;
183
- subTotal++;
184
- if (compact) {
185
- lines.push(`${prefix}${name}`);
186
- } else {
187
- let size = 0, mtime = null, lineCount = null;
188
- try {
189
- const st = statSync(full);
190
- size = st.size;
191
- mtime = st.mtime;
192
- } catch { /* skip */ }
193
- totalSize += size;
194
- lineCount = countFileLines(full, size);
195
- const parts = [];
196
- if (lineCount !== null) parts.push(`${lineCount}L`);
197
- parts.push(formatSize(size));
198
- if (mtime) parts.push(relativeTime(mtime, true));
199
- lines.push(`${prefix}${name} (${parts.join(", ")})`);
200
- }
201
- }
202
- }
203
-
204
- return subTotal;
205
- }
206
-
207
- /**
208
- * Count files in subtree without emitting lines (for dirs beyond maxDepth).
209
- */
210
- function countSubtreeFiles(dir, ig, rootAbs, depth = 0) {
211
- if (depth > 10) return 0;
212
- let entries;
213
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
214
- let count = 0;
215
- for (const entry of entries) {
216
- if (SKIP_DIRS.has(entry.name) && entry.isDirectory()) continue;
217
- const full = join(dir, entry.name);
218
- const rel = relative(rootAbs, full).replace(/\\/g, "/");
219
- if (isIgnored(ig, rel, entry.isDirectory())) continue;
220
- if (entry.isDirectory()) {
221
- count += countSubtreeFiles(full, ig, rootAbs, depth + 1);
222
- } else {
223
- count++;
224
- }
225
- }
226
- return count;
227
- }
228
-
229
- const rootName = basename(abs);
230
- walk(abs, " ", 1);
231
-
232
- const header = compact
233
- ? `Directory: ${rootName}/ (${totalFiles} files)`
234
- : `Directory: ${rootName}/ (${totalFiles} files, ${formatSize(totalSize)})`;
235
- return `${header}\n\n${rootName}/\n${lines.join("\n")}`;
236
- }
@@ -1 +0,0 @@
1
- export * from "@levnikolaevich/hex-common/runtime/update-check";
package/lib/verify.mjs DELETED
@@ -1,70 +0,0 @@
1
- /**
2
- * Checksum verification without re-reading full file.
3
- * Validates range checksums from prior reads.
4
- */
5
-
6
- import { parseChecksum } from "@levnikolaevich/hex-common/text-protocol/hash";
7
- import { validatePath, normalizePath } from "./security.mjs";
8
- import {
9
- buildRangeChecksum,
10
- computeChangedRanges,
11
- describeChangedRanges,
12
- getSnapshotByRevision,
13
- readSnapshot,
14
- } from "./revisions.mjs";
15
-
16
- /**
17
- * Verify checksums against current file state.
18
- *
19
- * @param {string} filePath
20
- * @param {string[]} checksums - array of "start-end:8hex" strings
21
- * @param {object} opts
22
- * @returns {string} verification result
23
- */
24
- export function verifyChecksums(filePath, checksums, opts = {}) {
25
- filePath = normalizePath(filePath);
26
- const real = validatePath(filePath);
27
- const current = readSnapshot(real);
28
- const baseSnapshot = opts.baseRevision ? getSnapshotByRevision(opts.baseRevision) : null;
29
-
30
- const results = [];
31
- let allValid = true;
32
-
33
- for (const cs of checksums) {
34
- const parsed = parseChecksum(cs);
35
-
36
- if (parsed.start < 1 || parsed.end > current.lines.length) {
37
- results.push(`${cs}: INVALID (range ${parsed.start}-${parsed.end} exceeds file length ${current.lines.length})`);
38
- allValid = false;
39
- continue;
40
- }
41
-
42
- const actual = buildRangeChecksum(current, parsed.start, parsed.end);
43
- const currentHex = actual.split(":")[1];
44
-
45
- if (currentHex === parsed.hex) {
46
- results.push(`${cs}: valid`);
47
- } else {
48
- const staleBits = [`${cs}: STALE → current: ${actual}`];
49
- if (baseSnapshot?.path === real) {
50
- const changedRanges = computeChangedRanges(baseSnapshot.lines, current.lines);
51
- staleBits.push(`revision: ${current.revision}`);
52
- staleBits.push(`changed_ranges: ${describeChangedRanges(changedRanges)}`);
53
- } else if (opts.baseRevision) {
54
- staleBits.push(`revision: ${current.revision}`);
55
- staleBits.push(`changed_ranges: unavailable (base revision evicted)`);
56
- }
57
- results.push(staleBits.join("\n"));
58
- allValid = false;
59
- }
60
- }
61
-
62
- if (allValid && checksums.length > 0) {
63
- let msg = `All ${checksums.length} checksum(s) valid for ${filePath}`;
64
- msg += `\nrevision: ${current.revision}`;
65
- msg += `\nfile: ${current.fileChecksum}`;
66
- return msg;
67
- }
68
-
69
- return results.join("\n");
70
- }