@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/lib/security.mjs
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Security boundaries for file operations.
|
|
3
|
-
*
|
|
4
|
-
* Claude Code provides its own sandbox (permissions, project scope).
|
|
5
|
-
* This module handles: path canonicalization, symlink resolution,
|
|
6
|
-
* binary file detection, and size limits.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { realpathSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
10
|
-
import { resolve, isAbsolute, dirname } from "node:path";
|
|
11
|
-
import { listDirectory } from "./format.mjs";
|
|
12
|
-
|
|
13
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Convert Git Bash /c/Users/... → c:/Users/... on Windows.
|
|
17
|
-
* Node.js resolve() treats /c/ as absolute from current drive root, producing D:\c\Users.
|
|
18
|
-
*/
|
|
19
|
-
export function normalizePath(p) {
|
|
20
|
-
if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(p)) {
|
|
21
|
-
p = p[1] + ":" + p.slice(2);
|
|
22
|
-
}
|
|
23
|
-
return p.replace(/\\/g, "/");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Validate a file path against security boundaries.
|
|
28
|
-
* Returns the canonicalized absolute path.
|
|
29
|
-
* Throws on violation.
|
|
30
|
-
*/
|
|
31
|
-
export function validatePath(filePath) {
|
|
32
|
-
if (!filePath) throw new Error("Empty file path");
|
|
33
|
-
|
|
34
|
-
const normalized = normalizePath(filePath);
|
|
35
|
-
const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
|
|
36
|
-
|
|
37
|
-
// Check existence — show parent directory contents as fallback
|
|
38
|
-
if (!existsSync(abs)) {
|
|
39
|
-
let hint = "";
|
|
40
|
-
try {
|
|
41
|
-
const parent = dirname(abs);
|
|
42
|
-
if (existsSync(parent)) {
|
|
43
|
-
const { text, total } = listDirectory(parent, { limit: 20, metadata: true });
|
|
44
|
-
hint = `\n\nParent directory ${parent} contains:\n${text}`;
|
|
45
|
-
if (total > 20) hint += `\n ... (${total - 20} more)`;
|
|
46
|
-
}
|
|
47
|
-
} catch {}
|
|
48
|
-
throw new Error(`FILE_NOT_FOUND: ${abs}${hint}`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Canonicalize (resolves symlinks)
|
|
52
|
-
let real;
|
|
53
|
-
try {
|
|
54
|
-
real = realpathSync(abs);
|
|
55
|
-
} catch (e) {
|
|
56
|
-
throw new Error(`Cannot resolve path: ${abs} (${e.message})`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Check file type
|
|
60
|
-
const stat = statSync(real);
|
|
61
|
-
if (stat.isDirectory()) return real; // directories allowed for listing
|
|
62
|
-
if (!stat.isFile()) {
|
|
63
|
-
const type = stat.isSymbolicLink() ? "symlink" : "special";
|
|
64
|
-
throw new Error(`NOT_REGULAR_FILE: ${real} (${type}). Cannot read special files.`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Size check
|
|
68
|
-
if (stat.size > MAX_FILE_SIZE) {
|
|
69
|
-
throw new Error(`FILE_TOO_LARGE: ${real} (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${MAX_FILE_SIZE / 1024 / 1024}MB). Use offset/limit to read a range.`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Binary detection (check first 8KB for null bytes — only read 8KB, not whole file)
|
|
73
|
-
const bfd = openSync(real, "r");
|
|
74
|
-
const probe = Buffer.alloc(8192);
|
|
75
|
-
const bytesRead = readSync(bfd, probe, 0, 8192, 0);
|
|
76
|
-
closeSync(bfd);
|
|
77
|
-
for (let i = 0; i < bytesRead; i++) {
|
|
78
|
-
if (probe[i] === 0) {
|
|
79
|
-
throw new Error(`BINARY_FILE: ${real}. Use built-in Read tool (supports images, PDFs, notebooks).`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return real.replace(/\\/g, "/");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Validate path for write (does NOT require file to exist).
|
|
88
|
-
* Resolves to absolute path, validates parent exists or can be created.
|
|
89
|
-
*/
|
|
90
|
-
export function validateWritePath(filePath) {
|
|
91
|
-
if (!filePath) throw new Error("Empty file path");
|
|
92
|
-
|
|
93
|
-
const normalized = normalizePath(filePath);
|
|
94
|
-
const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
|
|
95
|
-
|
|
96
|
-
// For write, the file might not exist yet — validate the parent directory
|
|
97
|
-
if (!existsSync(abs)) {
|
|
98
|
-
const parent = resolve(abs, "..");
|
|
99
|
-
if (!existsSync(parent)) {
|
|
100
|
-
// Walk up to find an existing ancestor (parent dirs will be created by write_file)
|
|
101
|
-
let ancestor = resolve(parent, "..");
|
|
102
|
-
while (!existsSync(ancestor) && ancestor !== resolve(ancestor, "..")) {
|
|
103
|
-
ancestor = resolve(ancestor, "..");
|
|
104
|
-
}
|
|
105
|
-
if (!existsSync(ancestor)) {
|
|
106
|
-
throw new Error(`No existing ancestor directory for: ${abs}`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return abs.replace(/\\/g, "/");
|
|
112
|
-
}
|
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
|
-
}
|
package/lib/update-check.mjs
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
|
|
5
|
-
const CACHE_FILE = join(tmpdir(), "hex-line-mcp-update.json");
|
|
6
|
-
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
|
7
|
-
const TIMEOUT = 3000;
|
|
8
|
-
|
|
9
|
-
async function readCache() {
|
|
10
|
-
try {
|
|
11
|
-
return JSON.parse(await readFile(CACHE_FILE, "utf-8"));
|
|
12
|
-
} catch { return null; }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function writeCache(entry) {
|
|
16
|
-
await writeFile(CACHE_FILE, JSON.stringify(entry)).catch(() => {});
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function fetchLatest(packageName) {
|
|
20
|
-
try {
|
|
21
|
-
const ctrl = new AbortController();
|
|
22
|
-
const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
|
|
23
|
-
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, { signal: ctrl.signal });
|
|
24
|
-
clearTimeout(timer);
|
|
25
|
-
if (!res.ok) return null;
|
|
26
|
-
const data = await res.json();
|
|
27
|
-
return data.version ?? null;
|
|
28
|
-
} catch { return null; }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function compareVersions(a, b) {
|
|
32
|
-
const pa = a.split(".").map(Number);
|
|
33
|
-
const pb = b.split(".").map(Number);
|
|
34
|
-
for (let i = 0; i < 3; i++) {
|
|
35
|
-
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
36
|
-
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
37
|
-
}
|
|
38
|
-
return 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function checkForUpdates(packageName, currentVersion) {
|
|
42
|
-
const cached = await readCache();
|
|
43
|
-
if (cached && Date.now() - cached.timestamp < CHECK_INTERVAL) {
|
|
44
|
-
if (cached.latest && compareVersions(currentVersion, cached.latest) < 0) {
|
|
45
|
-
process.stderr.write(`${packageName} update: ${currentVersion} → ${cached.latest}. Run: npm install -g ${packageName}\n`);
|
|
46
|
-
}
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
const latest = await fetchLatest(packageName);
|
|
50
|
-
if (latest) {
|
|
51
|
-
await writeCache({ timestamp: Date.now(), latest });
|
|
52
|
-
if (compareVersions(currentVersion, latest) < 0) {
|
|
53
|
-
process.stderr.write(`${packageName} update: ${currentVersion} → ${latest}. Run: npm install -g ${packageName}\n`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|