@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/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
- }
@@ -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
- }