@henryavila/mdprobe 0.1.0 → 0.2.0

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/src/setup.js ADDED
@@ -0,0 +1,203 @@
1
+ import { readFile, writeFile, mkdir, rm, access } from 'node:fs/promises'
2
+ import { join, dirname } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { execFile } from 'node:child_process'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const PROJECT_ROOT = join(dirname(__filename), '..')
9
+ const SKILL_SOURCE = join(PROJECT_ROOT, 'skills', 'mdprobe', 'SKILL.md')
10
+ const DEFAULT_CONFIG_PATH = join(homedir(), '.mdprobe.json')
11
+
12
+ // IDE skill directory mappings
13
+ const IDE_CONFIGS = {
14
+ 'Claude Code': { skillsDir: join(homedir(), '.claude', 'skills') },
15
+ 'Cursor': { skillsDir: join(homedir(), '.cursor', 'skills') },
16
+ 'Gemini': { skillsDir: join(homedir(), '.gemini', 'skills') },
17
+ }
18
+
19
+ /**
20
+ * Detect which IDEs have skill directories.
21
+ * @returns {Promise<string[]>} List of IDE names with skills dirs
22
+ */
23
+ export async function detectIDEs() {
24
+ const detected = []
25
+ for (const [name, config] of Object.entries(IDE_CONFIGS)) {
26
+ try {
27
+ await access(config.skillsDir)
28
+ detected.push(name)
29
+ } catch {
30
+ // Directory doesn't exist
31
+ }
32
+ }
33
+ return detected
34
+ }
35
+
36
+ /**
37
+ * Install the SKILL.md file to an IDE's skill directory.
38
+ * @param {string} ide - IDE name (e.g., 'Claude Code')
39
+ * @param {string} [content] - Skill content (reads from source if omitted)
40
+ * @returns {Promise<string>} Path where skill was installed
41
+ */
42
+ export async function installSkill(ide, content) {
43
+ const config = IDE_CONFIGS[ide]
44
+ if (!config) throw new Error(`Unknown IDE: ${ide}`)
45
+
46
+ if (!content) {
47
+ content = await readFile(SKILL_SOURCE, 'utf-8')
48
+ }
49
+
50
+ const destDir = join(config.skillsDir, 'mdprobe')
51
+ await mkdir(destDir, { recursive: true })
52
+ const destPath = join(destDir, 'SKILL.md')
53
+ await writeFile(destPath, content, 'utf-8')
54
+ return destPath
55
+ }
56
+
57
+ /**
58
+ * Register the MCP server via claude CLI.
59
+ * Falls back to direct ~/.claude.json write if CLI not available.
60
+ * @returns {Promise<{method: string}>}
61
+ */
62
+ export async function registerMCP() {
63
+ try {
64
+ await execFileAsync('claude', [
65
+ 'mcp', 'add', '--scope', 'user', '--transport', 'stdio',
66
+ 'mdprobe', '--', 'mdprobe', 'mcp',
67
+ ])
68
+ return { method: 'cli' }
69
+ } catch {
70
+ // Fallback: write directly to ~/.claude.json
71
+ const claudeJsonPath = join(homedir(), '.claude.json')
72
+ let config = {}
73
+ try {
74
+ config = JSON.parse(await readFile(claudeJsonPath, 'utf-8'))
75
+ } catch { /* start fresh */ }
76
+
77
+ if (!config.mcpServers) config.mcpServers = {}
78
+ config.mcpServers.mdprobe = {
79
+ command: 'mdprobe',
80
+ args: ['mcp'],
81
+ type: 'stdio',
82
+ }
83
+
84
+ await writeFile(claudeJsonPath, JSON.stringify(config, null, 2), 'utf-8')
85
+ return { method: 'file' }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Register PostToolUse hook in settings.json (safe merge).
91
+ * @param {string} [settingsPath] - Override path for testing
92
+ * @returns {Promise<{added: boolean}>}
93
+ */
94
+ export async function registerHook(settingsPath) {
95
+ if (!settingsPath) {
96
+ settingsPath = join(homedir(), '.claude', 'settings.json')
97
+ }
98
+
99
+ let settings = {}
100
+ try {
101
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
102
+ } catch { /* start fresh */ }
103
+
104
+ if (!settings.hooks) settings.hooks = {}
105
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = []
106
+
107
+ // Check if mdprobe hook already exists
108
+ const existing = settings.hooks.PostToolUse.find(h =>
109
+ h.hooks?.some(hh => typeof hh.command === 'string' && hh.command.includes('[mdprobe]'))
110
+ )
111
+ if (existing) return { added: false }
112
+
113
+ const hookCommand = `node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const p=d.tool_input?.file_path||''; if(p.endsWith('.md')){const j={decision:'allow',reason:'[mdprobe] .md file modified: '+require('path').basename(p)+'. Offer to open with mdProbe.'}; process.stdout.write(JSON.stringify(j))}"`
114
+
115
+ settings.hooks.PostToolUse.push({
116
+ matcher: 'Write|Edit',
117
+ hooks: [{ type: 'command', command: hookCommand }],
118
+ })
119
+
120
+ await mkdir(dirname(settingsPath), { recursive: true })
121
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8')
122
+ return { added: true }
123
+ }
124
+
125
+ /**
126
+ * Save user config to ~/.mdprobe.json.
127
+ * @param {object} config - { author, urlStyle }
128
+ * @param {string} [configPath]
129
+ */
130
+ export async function saveConfig(config, configPath = DEFAULT_CONFIG_PATH) {
131
+ await mkdir(dirname(configPath), { recursive: true })
132
+ await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8')
133
+ }
134
+
135
+ /**
136
+ * Remove all mdprobe installations.
137
+ * @param {object} [opts]
138
+ * @param {string} [opts.configPath]
139
+ * @param {string} [opts.settingsPath]
140
+ */
141
+ export async function removeAll(opts = {}) {
142
+ const configPath = opts.configPath || DEFAULT_CONFIG_PATH
143
+ const settingsPath = opts.settingsPath || join(homedir(), '.claude', 'settings.json')
144
+ const removed = []
145
+
146
+ // Remove skill from all IDEs
147
+ for (const [name, config] of Object.entries(IDE_CONFIGS)) {
148
+ const skillDir = join(config.skillsDir, 'mdprobe')
149
+ try {
150
+ await rm(skillDir, { recursive: true })
151
+ removed.push(`skill:${name}`)
152
+ } catch { /* not installed */ }
153
+ }
154
+
155
+ // Remove MCP registration (both CLI and file-based)
156
+ try {
157
+ await execFileAsync('claude', ['mcp', 'remove', 'mdprobe'])
158
+ removed.push('mcp:cli')
159
+ } catch { /* CLI not available or entry not found */ }
160
+
161
+ // Always clean up file-based entry too (may exist from fallback registration)
162
+ try {
163
+ const claudeJsonPath = join(homedir(), '.claude.json')
164
+ const config = JSON.parse(await readFile(claudeJsonPath, 'utf-8'))
165
+ if (config.mcpServers?.mdprobe) {
166
+ delete config.mcpServers.mdprobe
167
+ await writeFile(claudeJsonPath, JSON.stringify(config, null, 2), 'utf-8')
168
+ removed.push('mcp:file')
169
+ }
170
+ } catch { /* ignore */ }
171
+
172
+ // Remove hook from settings.json
173
+ try {
174
+ const settings = JSON.parse(await readFile(settingsPath, 'utf-8'))
175
+ if (settings.hooks?.PostToolUse) {
176
+ const before = settings.hooks.PostToolUse.length
177
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(h =>
178
+ !h.hooks?.some(hh => typeof hh.command === 'string' && hh.command.includes('[mdprobe]'))
179
+ )
180
+ if (settings.hooks.PostToolUse.length < before) {
181
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8')
182
+ removed.push('hook')
183
+ }
184
+ }
185
+ } catch { /* ignore */ }
186
+
187
+ // Remove config file
188
+ try {
189
+ await rm(configPath)
190
+ removed.push('config')
191
+ } catch { /* ignore */ }
192
+
193
+ return removed
194
+ }
195
+
196
+ function execFileAsync(cmd, args) {
197
+ return new Promise((resolve, reject) => {
198
+ execFile(cmd, args, (err, stdout) => {
199
+ if (err) reject(err)
200
+ else resolve(stdout)
201
+ })
202
+ })
203
+ }
@@ -0,0 +1,236 @@
1
+ import { readFile, writeFile, unlink } from 'node:fs/promises'
2
+ import { unlinkSync } from 'node:fs'
3
+ import { join, dirname } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import node_http from 'node:http'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { hashContent } from './hash.js'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const DIST_INDEX = join(dirname(__filename), '..', 'dist', 'index.html')
11
+
12
+ const DEFAULT_LOCK_PATH = join(tmpdir(), 'mdprobe.lock')
13
+
14
+ /**
15
+ * Compute a build hash from dist/index.html content.
16
+ * Falls back to package version if dist doesn't exist (dev mode).
17
+ * @returns {Promise<string>}
18
+ */
19
+ export async function computeBuildHash() {
20
+ try {
21
+ const content = await readFile(DIST_INDEX, 'utf-8')
22
+ return hashContent(content)
23
+ } catch {
24
+ try {
25
+ const pkg = JSON.parse(await readFile(join(dirname(__filename), '..', 'package.json'), 'utf-8'))
26
+ return `pkg:${pkg.version}`
27
+ } catch {
28
+ return 'unknown'
29
+ }
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Read and parse the lock file.
35
+ * @param {string} [lockPath]
36
+ * @returns {Promise<{pid: number, port: number, url: string, startedAt: string} | null>}
37
+ */
38
+ export async function readLockFile(lockPath = DEFAULT_LOCK_PATH) {
39
+ try {
40
+ const raw = await readFile(lockPath, 'utf-8')
41
+ return JSON.parse(raw)
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Write lock file with server instance data.
49
+ * @param {{pid: number, port: number, url: string, startedAt: string}} data
50
+ * @param {string} [lockPath]
51
+ */
52
+ export async function writeLockFile(data, lockPath = DEFAULT_LOCK_PATH) {
53
+ await writeFile(lockPath, JSON.stringify(data), 'utf-8')
54
+ }
55
+
56
+ /**
57
+ * Remove lock file. Silently ignores ENOENT.
58
+ * @param {string} [lockPath]
59
+ */
60
+ export async function removeLockFile(lockPath = DEFAULT_LOCK_PATH) {
61
+ try {
62
+ await unlink(lockPath)
63
+ } catch (err) {
64
+ if (err.code !== 'ENOENT') throw err
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Synchronous lock file removal — last resort for process.on('exit').
70
+ * @param {string} [lockPath]
71
+ */
72
+ export function removeLockFileSync(lockPath = DEFAULT_LOCK_PATH) {
73
+ try {
74
+ unlinkSync(lockPath)
75
+ } catch { /* ignore */ }
76
+ }
77
+
78
+ /**
79
+ * Check if a process with the given PID is alive.
80
+ * @param {number} pid
81
+ * @returns {boolean}
82
+ */
83
+ export function isProcessAlive(pid) {
84
+ try {
85
+ process.kill(pid, 0)
86
+ return true
87
+ } catch (err) {
88
+ if (err.code === 'EPERM') return true
89
+ return false
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Ping a server's /api/status endpoint to verify it's a running mdprobe instance.
95
+ * @param {string} url - Base URL (e.g. "http://127.0.0.1:3000")
96
+ * @param {number} [timeout=2000]
97
+ * @returns {Promise<{alive: boolean}>}
98
+ */
99
+ export function pingServer(url, timeout = 2000) {
100
+ return new Promise((resolve) => {
101
+ const req = node_http.get(`${url}/api/status`, { timeout }, (res) => {
102
+ let data = ''
103
+ res.on('data', (chunk) => { data += chunk })
104
+ res.on('end', () => {
105
+ try {
106
+ const json = JSON.parse(data)
107
+ resolve({ alive: json.identity === 'mdprobe' })
108
+ } catch {
109
+ resolve({ alive: false })
110
+ }
111
+ })
112
+ })
113
+ req.on('error', () => resolve({ alive: false }))
114
+ req.on('timeout', () => {
115
+ req.destroy()
116
+ resolve({ alive: false })
117
+ })
118
+ })
119
+ }
120
+
121
+ /**
122
+ * Discover an existing running mdprobe server via lock file + HTTP verification.
123
+ * Cleans up stale lock files automatically.
124
+ * @param {string} [lockPath]
125
+ * @param {string} [currentBuildHash] - If provided, reject servers with different buildHash
126
+ * @returns {Promise<{url: string, port: number} | null>}
127
+ */
128
+ export async function discoverExistingServer(lockPath = DEFAULT_LOCK_PATH, currentBuildHash) {
129
+ const lock = await readLockFile(lockPath)
130
+ if (!lock) return null
131
+
132
+ // Reject lock files without buildHash when we have one (backward compat)
133
+ if (currentBuildHash && !lock.buildHash) {
134
+ await removeLockFile(lockPath)
135
+ return null
136
+ }
137
+
138
+ // Reject lock files with different buildHash (stale server)
139
+ if (currentBuildHash && lock.buildHash !== currentBuildHash) {
140
+ await removeLockFile(lockPath)
141
+ return null
142
+ }
143
+
144
+ if (!isProcessAlive(lock.pid)) {
145
+ await removeLockFile(lockPath)
146
+ return null
147
+ }
148
+
149
+ const { alive } = await pingServer(lock.url)
150
+ if (!alive) {
151
+ await removeLockFile(lockPath)
152
+ return null
153
+ }
154
+
155
+ return { url: lock.url, port: lock.port }
156
+ }
157
+
158
+ /**
159
+ * Join an existing server by adding files via its HTTP API.
160
+ * @param {string} url - Base URL of the running server
161
+ * @param {string[]} files - Absolute file paths to add
162
+ * @returns {Promise<{ok: boolean, files?: string[], added?: string[]}>}
163
+ */
164
+ export function joinExistingServer(url, files) {
165
+ return new Promise((resolve) => {
166
+ const body = JSON.stringify({ files })
167
+ const parsed = new URL(`${url}/api/add-files`)
168
+
169
+ const req = node_http.request({
170
+ hostname: parsed.hostname,
171
+ port: parsed.port,
172
+ path: parsed.pathname,
173
+ method: 'POST',
174
+ headers: {
175
+ 'Content-Type': 'application/json',
176
+ 'Content-Length': Buffer.byteLength(body),
177
+ },
178
+ timeout: 5000,
179
+ }, (res) => {
180
+ let data = ''
181
+ res.on('data', (chunk) => { data += chunk })
182
+ res.on('end', () => {
183
+ try {
184
+ const json = JSON.parse(data)
185
+ resolve({ ok: json.ok === true, files: json.files, added: json.added })
186
+ } catch {
187
+ resolve({ ok: false })
188
+ }
189
+ })
190
+ })
191
+
192
+ req.on('error', () => resolve({ ok: false }))
193
+ req.on('timeout', () => {
194
+ req.destroy()
195
+ resolve({ ok: false })
196
+ })
197
+ req.end(body)
198
+ })
199
+ }
200
+
201
+ let shuttingDown = false
202
+
203
+ /**
204
+ * Register signal handlers to clean up lock file and close server on exit.
205
+ * @param {{close: Function}} serverObj
206
+ * @param {string} [lockPath]
207
+ */
208
+ export function registerShutdownHandlers(serverObj, lockPath = DEFAULT_LOCK_PATH) {
209
+ shuttingDown = false
210
+
211
+ async function shutdown() {
212
+ if (shuttingDown) return
213
+ shuttingDown = true
214
+
215
+ await removeLockFile(lockPath)
216
+
217
+ try {
218
+ await serverObj.close()
219
+ } catch { /* ignore */ }
220
+
221
+ process.exit(0)
222
+ }
223
+
224
+ process.on('SIGINT', shutdown)
225
+ process.on('SIGTERM', shutdown)
226
+
227
+ // Synchronous last-resort cleanup on exit
228
+ process.on('exit', () => {
229
+ removeLockFileSync(lockPath)
230
+ })
231
+ }
232
+
233
+ export { DEFAULT_LOCK_PATH }
234
+
235
+ // For testing
236
+ export function _resetShutdownFlag() { shuttingDown = false }
package/src/ui/app.jsx CHANGED
@@ -6,7 +6,8 @@ import { useTheme } from './hooks/useTheme.js'
6
6
  import { useAnnotations } from './hooks/useAnnotations.js'
7
7
  import { useClientLibs } from './hooks/useClientLibs.js'
8
8
  import { files, currentFile, currentHtml, currentToc, author, reviewMode,
9
- leftPanelOpen, rightPanelOpen, openAnnotations, sectionStats, driftWarning } from './state/store.js'
9
+ leftPanelOpen, rightPanelOpen, openAnnotations, sectionStats, driftWarning,
10
+ orphanedAnnotations } from './state/store.js'
10
11
  import { LeftPanel } from './components/LeftPanel.jsx'
11
12
  import { RightPanel } from './components/RightPanel.jsx'
12
13
  import { Content } from './components/Content.jsx'
@@ -40,17 +41,23 @@ function App() {
40
41
  useEffect(() => {
41
42
  fetch('/api/files').then(r => r.json()).then(data => {
42
43
  files.value = data
43
- if (data.length > 0) {
44
- const first = data[0].path || data[0]
45
- currentFile.value = first
46
- // Fetch rendered content
47
- fetch(`/api/file?path=${encodeURIComponent(first)}`).then(r => r.json()).then(d => {
48
- currentHtml.value = d.html
49
- currentToc.value = d.toc || []
44
+ if (data.length === 0) return
45
+
46
+ // Deep link: check if URL pathname matches a file
47
+ const pathname = window.location.pathname
48
+ let target = null
49
+ if (pathname && pathname !== '/') {
50
+ const cleaned = pathname.replace(/^\//, '')
51
+ target = data.find(f => {
52
+ const fp = f.path || f
53
+ return fp === cleaned ||
54
+ fp === cleaned.split('/').pop() ||
55
+ (f.absPath && f.absPath.endsWith('/' + cleaned))
50
56
  })
51
- // Fetch annotations
52
- annotationOps.fetchAnnotations(first)
53
57
  }
58
+
59
+ const selected = target ? (target.path || target) : (data[0].path || data[0])
60
+ handleFileSelect(selected)
54
61
  })
55
62
 
56
63
  fetch('/api/config').then(r => r.json()).then(data => {
@@ -76,7 +83,7 @@ function App() {
76
83
  <>
77
84
  {/* Header */}
78
85
  <header class="header">
79
- <h1>mdprobe</h1>
86
+ <h1>mdProbe</h1>
80
87
  <span class="header-file">{currentFile.value || 'No file selected'}</span>
81
88
  <div style="flex: 1" />
82
89
  {sectionStats.value.total > 0 && (reviewMode.value || sectionStats.value.reviewed > 0) && (
@@ -99,7 +106,9 @@ function App() {
99
106
  {/* Drift warning banner */}
100
107
  {driftWarning.value && (
101
108
  <div class="drift-banner">
102
- Arquivo modificado desde a ultima revisao. Algumas anotacoes podem estar desalinhadas.
109
+ {orphanedAnnotations.value.length > 0
110
+ ? `Arquivo modificado — ${orphanedAnnotations.value.length} anotação(ões) não encontrada(s)`
111
+ : 'Arquivo modificado desde a ultima revisao. Algumas anotacoes podem estar desalinhadas.'}
103
112
  <button class="btn btn-sm" style="margin-left: 8px" onClick={() => driftWarning.value = false}>Dismiss</button>
104
113
  </div>
105
114
  )}