@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/mcp.js ADDED
@@ -0,0 +1,258 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import { z } from 'zod'
4
+ import { resolve, basename, dirname } from 'node:path'
5
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
6
+ import { createServer } from './server.js'
7
+ import { openBrowser } from './open-browser.js'
8
+ import { AnnotationFile } from './annotations.js'
9
+ import { getConfig } from './config.js'
10
+ import { hashContent } from './hash.js'
11
+ import { discoverExistingServer, joinExistingServer, writeLockFile, computeBuildHash } from './singleton.js'
12
+
13
+ let httpServerPromise = null
14
+
15
+ async function getOrCreateServer(port = 3000) {
16
+ if (!httpServerPromise) {
17
+ const buildHash = await computeBuildHash()
18
+
19
+ const existing = await discoverExistingServer(undefined, buildHash)
20
+ if (existing) {
21
+ httpServerPromise = Promise.resolve({
22
+ url: existing.url,
23
+ port: existing.port,
24
+ addFiles: (paths) => joinExistingServer(existing.url, paths),
25
+ getFiles: () => [],
26
+ broadcast: () => {},
27
+ close: async () => {},
28
+ _remote: true,
29
+ })
30
+ } else {
31
+ httpServerPromise = createServer({ files: [], port, open: false, buildHash }).then(async (srv) => {
32
+ await writeLockFile({
33
+ pid: process.pid,
34
+ port: srv.port,
35
+ url: srv.url,
36
+ startedAt: new Date().toISOString(),
37
+ buildHash,
38
+ })
39
+ return srv
40
+ })
41
+ }
42
+ }
43
+ return httpServerPromise
44
+ }
45
+
46
+ function buildUrl(port, urlStyle, filePath) {
47
+ const host = urlStyle === 'mdprobe.localhost' ? 'mdprobe.localhost' : 'localhost'
48
+ const suffix = filePath ? '/' + filePath : ''
49
+ return `http://${host}:${port}${suffix}`
50
+ }
51
+
52
+ export function validateViewParams(params) {
53
+ const hasPaths = Array.isArray(params.paths) && params.paths.length > 0
54
+ const hasContent = typeof params.content === 'string' && params.content.length > 0
55
+
56
+ if (hasPaths && hasContent) {
57
+ return { error: 'Cannot provide both paths and content. Use one or the other.' }
58
+ }
59
+ if (!hasPaths && !hasContent) {
60
+ return { error: 'Either paths or content must be provided.' }
61
+ }
62
+ if (hasContent && !params.filename) {
63
+ return { error: 'filename is required when content is provided.' }
64
+ }
65
+ return { mode: hasPaths ? 'paths' : 'content' }
66
+ }
67
+
68
+ export async function saveContentToFile(content, filename) {
69
+ const absPath = resolve(filename)
70
+ await mkdir(dirname(absPath), { recursive: true })
71
+ await writeFile(absPath, content, 'utf-8')
72
+ return { savedTo: absPath }
73
+ }
74
+
75
+ export async function startMcpServer() {
76
+ const config = await getConfig()
77
+ const author = config.author || 'Agent'
78
+ const urlStyle = config.urlStyle || 'localhost'
79
+
80
+ const server = new McpServer({
81
+ name: 'mdProbe',
82
+ version: '0.2.0',
83
+ })
84
+
85
+ server.registerTool('mdprobe_view', {
86
+ description: 'Open content for human review in the browser. Call this BEFORE asking for feedback on any content >20 lines — findings, specs, plans, analysis, or any long output.',
87
+ inputSchema: z.object({
88
+ paths: z.array(z.string()).optional().describe('Paths to .md files (relative or absolute)'),
89
+ content: z.string().optional().describe('Raw markdown content to save and open'),
90
+ filename: z.string().optional().describe('Filename to save content to (required with content)'),
91
+ open: z.boolean().optional().default(true).describe('Auto-open browser'),
92
+ }),
93
+ }, async (params) => {
94
+ const validation = validateViewParams(params)
95
+ if (validation.error) {
96
+ return {
97
+ content: [{ type: 'text', text: JSON.stringify({ error: validation.error }) }],
98
+ isError: true,
99
+ }
100
+ }
101
+
102
+ const srv = await getOrCreateServer()
103
+ let resolved
104
+ let savedTo
105
+
106
+ if (validation.mode === 'content') {
107
+ const result = await saveContentToFile(params.content, params.filename)
108
+ savedTo = result.savedTo
109
+ resolved = [savedTo]
110
+ } else {
111
+ resolved = params.paths.map(p => resolve(p))
112
+ }
113
+
114
+ srv.addFiles(resolved)
115
+
116
+ const url = resolved.length === 1
117
+ ? buildUrl(srv.port, urlStyle, basename(resolved[0]))
118
+ : buildUrl(srv.port, urlStyle)
119
+ if (params.open) await openBrowser(url).catch(() => {})
120
+
121
+ const response = { url, files: resolved.map(p => basename(p)) }
122
+ if (savedTo) response.savedTo = savedTo
123
+
124
+ return {
125
+ content: [{ type: 'text', text: JSON.stringify(response) }],
126
+ }
127
+ })
128
+
129
+ server.registerTool('mdprobe_annotations', {
130
+ description: 'Read annotations for a file after human review',
131
+ inputSchema: z.object({
132
+ path: z.string().describe('Path to .md file'),
133
+ }),
134
+ }, async ({ path }) => {
135
+ const resolved = resolve(path)
136
+ const sidecarPath = resolved.replace(/\.md$/, '.annotations.yaml')
137
+
138
+ let af
139
+ try {
140
+ af = await AnnotationFile.load(sidecarPath)
141
+ } catch {
142
+ return {
143
+ content: [{ type: 'text', text: JSON.stringify({
144
+ source: basename(resolved),
145
+ sections: [],
146
+ annotations: [],
147
+ summary: { total: 0, open: 0, resolved: 0, bugs: 0, questions: 0, suggestions: 0, nitpicks: 0 },
148
+ }) }],
149
+ }
150
+ }
151
+
152
+ const json = af.toJSON()
153
+ const summary = {
154
+ total: json.annotations.length,
155
+ open: af.getOpen().length,
156
+ resolved: af.getResolved().length,
157
+ bugs: af.getByTag('bug').length,
158
+ questions: af.getByTag('question').length,
159
+ suggestions: af.getByTag('suggestion').length,
160
+ nitpicks: af.getByTag('nitpick').length,
161
+ }
162
+
163
+ return {
164
+ content: [{ type: 'text', text: JSON.stringify({ ...json, summary }) }],
165
+ }
166
+ })
167
+
168
+ server.registerTool('mdprobe_update', {
169
+ description: 'Update annotations — resolve, reopen, reply, create, delete',
170
+ inputSchema: z.object({
171
+ path: z.string().describe('Path to .md file'),
172
+ actions: z.array(z.object({
173
+ action: z.enum(['resolve', 'reopen', 'reply', 'add', 'delete']),
174
+ id: z.string().optional(),
175
+ comment: z.string().optional(),
176
+ tag: z.enum(['bug', 'question', 'suggestion', 'nitpick']).optional(),
177
+ selectors: z.any().optional(),
178
+ })).describe('Batch operations'),
179
+ }),
180
+ }, async ({ path, actions }) => {
181
+ const resolved = resolve(path)
182
+ const sidecarPath = resolved.replace(/\.md$/, '.annotations.yaml')
183
+
184
+ let af
185
+ try {
186
+ af = await AnnotationFile.load(sidecarPath)
187
+ } catch {
188
+ // Fix #2: wrap readFile in try/catch for missing source files
189
+ let content
190
+ try {
191
+ content = await readFile(resolved, 'utf-8')
192
+ } catch {
193
+ return {
194
+ content: [{ type: 'text', text: JSON.stringify({ error: `File not found: ${path}` }) }],
195
+ isError: true,
196
+ }
197
+ }
198
+ af = AnnotationFile.create(basename(resolved), `sha256:${hashContent(content)}`)
199
+ }
200
+
201
+ for (const act of actions) {
202
+ switch (act.action) {
203
+ case 'resolve': af.resolve(act.id); break
204
+ case 'reopen': af.reopen(act.id); break
205
+ case 'reply': af.addReply(act.id, { author, comment: act.comment }); break
206
+ case 'add': af.add({ selectors: act.selectors, comment: act.comment, tag: act.tag, author }); break
207
+ case 'delete': af.delete(act.id); break
208
+ }
209
+ }
210
+
211
+ await af.save(sidecarPath)
212
+
213
+ // Fix #6: broadcast via WebSocket if HTTP server is running
214
+ const srv = await httpServerPromise
215
+ if (srv?.broadcast) {
216
+ srv.broadcast({
217
+ type: 'annotations',
218
+ file: basename(resolved),
219
+ annotations: af.toJSON().annotations,
220
+ sections: af.toJSON().sections,
221
+ })
222
+ }
223
+
224
+ const json = af.toJSON()
225
+ return {
226
+ content: [{ type: 'text', text: JSON.stringify({
227
+ updated: actions.length,
228
+ annotations: json.annotations,
229
+ summary: { total: json.annotations.length, open: af.getOpen().length, resolved: af.getResolved().length },
230
+ }) }],
231
+ }
232
+ })
233
+
234
+ // Fix #5: include files field in status response
235
+ server.registerTool('mdprobe_status', {
236
+ description: 'Returns current MCP server state',
237
+ inputSchema: z.object({}),
238
+ }, async () => {
239
+ if (!httpServerPromise) {
240
+ return { content: [{ type: 'text', text: JSON.stringify({ running: false }) }] }
241
+ }
242
+ const srv = await httpServerPromise
243
+ return {
244
+ content: [{ type: 'text', text: JSON.stringify({
245
+ running: true,
246
+ url: srv.url,
247
+ files: srv.getFiles?.() ?? [],
248
+ }) }],
249
+ }
250
+ })
251
+
252
+ const transport = new StdioServerTransport()
253
+ await server.connect(transport)
254
+ }
255
+
256
+ // For testing: expose internals
257
+ export { getOrCreateServer, buildUrl }
258
+ export function _resetServer() { httpServerPromise = null }
@@ -0,0 +1,27 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { readFile } from 'node:fs/promises'
3
+
4
+ export async function openBrowser(url) {
5
+ const isWSL = process.platform === 'linux'
6
+ ? await readFile('/proc/version', 'utf-8').then(v => /microsoft/i.test(v)).catch(() => false)
7
+ : false
8
+
9
+ let cmd, args
10
+ if (process.platform === 'darwin') {
11
+ cmd = 'open'
12
+ args = [url]
13
+ } else if (process.platform === 'win32') {
14
+ cmd = 'cmd'
15
+ args = ['/c', 'start', url]
16
+ } else if (isWSL) {
17
+ cmd = '/mnt/c/Windows/System32/cmd.exe'
18
+ args = ['/c', 'start', url]
19
+ } else {
20
+ cmd = 'xdg-open'
21
+ args = [url]
22
+ }
23
+
24
+ return new Promise((resolve) => {
25
+ execFile(cmd, args, { stdio: 'ignore' }, () => resolve())
26
+ })
27
+ }
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ import { watch } from 'chokidar'
8
8
  import { render } from './renderer.js'
9
9
  import { AnnotationFile, computeSectionStatus } from './annotations.js'
10
10
  import { detectDrift } from './hash.js'
11
+ import { reanchorAll } from './anchoring.js'
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // File resolution
@@ -236,10 +237,10 @@ if (!SHELL_HTML) {
236
237
  <head>
237
238
  <meta charset="utf-8">
238
239
  <meta name="viewport" content="width=device-width, initial-scale=1">
239
- <title>mdprobe</title>
240
+ <title>mdProbe</title>
240
241
  </head>
241
242
  <body>
242
- <div id="app"><p style="padding:40px;font-family:sans-serif">mdprobe — run <code>npm run build:ui</code> to build the UI</p></div>
243
+ <div id="app"><p style="padding:40px;font-family:sans-serif">mdProbe — run <code>npm run build:ui</code> to build the UI</p></div>
243
244
  </body>
244
245
  </html>`
245
246
  }
@@ -268,28 +269,35 @@ export async function createServer(options) {
268
269
  once = false,
269
270
  author,
270
271
  onDisconnect,
272
+ buildHash,
271
273
  } = options
272
274
 
273
- // 1. Resolve files
274
- const resolvedFiles = await resolveFiles(files)
275
+ // 1. Resolve files (allow empty array for lazy MCP mode)
276
+ const resolvedFiles = (files && files.length > 0) ? await resolveFiles(files) : []
275
277
 
276
- // Base directory for static asset serving — use the directory of the first file
277
- const assetBaseDir = node_path.dirname(resolvedFiles[0])
278
+ // Base directory for static asset serving — use the directory of the first file or cwd
279
+ const assetBaseDir = resolvedFiles.length > 0
280
+ ? node_path.dirname(resolvedFiles[0])
281
+ : process.cwd()
278
282
 
279
283
  // 2. Find an available port
280
284
  const actualPort = await findAvailablePort(preferredPort)
281
285
 
282
286
  // 3. Build route handler (onFinish is set below for --once mode)
283
287
  let onFinishCallback = null
284
- // broadcastToAll is defined below after wss; forward-ref via closure
288
+ // broadcastToAll and addFiles are defined below; forward-ref via closure
285
289
  let broadcastFn = () => {}
290
+ let addFilesFn = () => {}
286
291
  const handleRequest = createRequestHandler({
287
292
  resolvedFiles,
288
293
  assetBaseDir,
289
294
  once,
290
295
  author,
296
+ port: actualPort,
297
+ buildHash: buildHash || null,
291
298
  getOnFinish: () => onFinishCallback,
292
299
  broadcast: (msg) => broadcastFn(msg),
300
+ addFiles: (paths) => addFilesFn(paths),
293
301
  })
294
302
 
295
303
  // 4. Create HTTP server
@@ -354,6 +362,16 @@ export async function createServer(options) {
354
362
  }
355
363
  }
356
364
  broadcastFn = broadcastToAll
365
+ addFilesFn = (newPaths) => {
366
+ for (const p of newPaths) {
367
+ const abs = node_path.resolve(p)
368
+ if (!resolvedFiles.includes(abs)) {
369
+ resolvedFiles.push(abs)
370
+ watcher.add(node_path.dirname(abs))
371
+ broadcastToAll({ type: 'file-added', file: node_path.basename(abs) })
372
+ }
373
+ }
374
+ }
357
375
 
358
376
  watcher.on('change', (filePath) => {
359
377
  if (!filePath.endsWith('.md')) return
@@ -375,6 +393,25 @@ export async function createServer(options) {
375
393
  html: rendered.html,
376
394
  toc: rendered.toc,
377
395
  })
396
+
397
+ // Check for drift and broadcast anchor status
398
+ const sidecarPath = filePath.replace(/\.md$/, '.annotations.yaml')
399
+ try {
400
+ const drift = await detectDrift(sidecarPath, filePath)
401
+ if (drift.drifted) {
402
+ const af = await AnnotationFile.load(sidecarPath)
403
+ const anns = af.toJSON().annotations
404
+ const anchorResults = reanchorAll(anns, content)
405
+ broadcastToAll({
406
+ type: 'drift',
407
+ warning: true,
408
+ file: fileName,
409
+ anchorStatus: Object.fromEntries(
410
+ [...anchorResults].map(([id, r]) => [id, r.status === 'orphan' ? 'orphan' : 'anchored'])
411
+ ),
412
+ })
413
+ }
414
+ } catch { /* no sidecar or drift check failed — skip */ }
378
415
  } catch (err) {
379
416
  broadcastToAll({
380
417
  type: 'error',
@@ -404,6 +441,9 @@ export async function createServer(options) {
404
441
  url: `http://127.0.0.1:${actualPort}`,
405
442
  port: actualPort,
406
443
  address: () => httpServer.address(),
444
+ addFiles: addFilesFn,
445
+ getFiles: () => resolvedFiles.map(f => node_path.basename(f)),
446
+ broadcast: (msg) => broadcastToAll(msg),
407
447
  close: (cb) => {
408
448
  // Clear all debounce timers
409
449
  for (const timer of debounceTimers.values()) clearTimeout(timer)
@@ -450,7 +490,7 @@ export async function createServer(options) {
450
490
  * @param {string} [ctx.author]
451
491
  * @returns {(req: node_http.IncomingMessage, res: node_http.ServerResponse) => void}
452
492
  */
453
- function createRequestHandler({ resolvedFiles, assetBaseDir, once, author, getOnFinish, broadcast }) {
493
+ function createRequestHandler({ resolvedFiles, assetBaseDir, once, author, port, buildHash, getOnFinish, broadcast, addFiles }) {
454
494
  return async (req, res) => {
455
495
  try {
456
496
  const parsedUrl = new URL(req.url, `http://${req.headers.host}`)
@@ -508,10 +548,18 @@ function createRequestHandler({ resolvedFiles, assetBaseDir, once, author, getOn
508
548
  const af = await AnnotationFile.load(sidecarPath)
509
549
  json = af.toJSON()
510
550
  savedSections = json.sections || []
511
- // Check for drift
551
+ // Check for drift and re-anchor annotations
512
552
  try {
513
553
  const drift = await detectDrift(sidecarPath, match)
514
- if (drift.drifted) json.drift = true
554
+ if (drift.drifted) {
555
+ const content = await node_fs.readFile(match, 'utf8')
556
+ const anchorResults = reanchorAll(json.annotations, content)
557
+ json.drift = {
558
+ anchorStatus: Object.fromEntries(
559
+ [...anchorResults].map(([id, r]) => [id, r.status === 'orphan' ? 'orphan' : 'anchored'])
560
+ )
561
+ }
562
+ }
515
563
  } catch { /* no drift info available */ }
516
564
  } catch {
517
565
  // No sidecar or unreadable
@@ -690,6 +738,35 @@ function createRequestHandler({ resolvedFiles, assetBaseDir, once, author, getOn
690
738
  return sendJSON(res, 200, { author: author || 'anonymous' })
691
739
  }
692
740
 
741
+ // GET /api/status — identity check for singleton discovery
742
+ if (req.method === 'GET' && pathname === '/api/status') {
743
+ return sendJSON(res, 200, {
744
+ identity: 'mdprobe',
745
+ pid: process.pid,
746
+ port,
747
+ files: resolvedFiles.map(f => node_path.basename(f)),
748
+ uptime: process.uptime(),
749
+ buildHash,
750
+ })
751
+ }
752
+
753
+ // POST /api/add-files — add files from another process (singleton join)
754
+ if (req.method === 'POST' && pathname === '/api/add-files') {
755
+ const body = await readBody(req)
756
+ const { files: newFiles } = body
757
+ if (!Array.isArray(newFiles) || newFiles.length === 0) {
758
+ return sendJSON(res, 400, { error: 'Missing or empty files array' })
759
+ }
760
+ const before = resolvedFiles.length
761
+ addFiles(newFiles)
762
+ const added = resolvedFiles.slice(before).map(f => node_path.basename(f))
763
+ return sendJSON(res, 200, {
764
+ ok: true,
765
+ files: resolvedFiles.map(f => node_path.basename(f)),
766
+ added,
767
+ })
768
+ }
769
+
693
770
  // GET /api/review/status (only in once mode)
694
771
  if (req.method === 'GET' && pathname === '/api/review/status') {
695
772
  if (once) {
@@ -756,6 +833,12 @@ function createRequestHandler({ resolvedFiles, assetBaseDir, once, author, getOn
756
833
  }
757
834
  }
758
835
 
836
+ // SPA catch-all: serve HTML shell for any unmatched GET path
837
+ // This enables deep linking — client reads pathname to auto-select file
838
+ if (req.method === 'GET') {
839
+ return sendHTML(res, 200, SHELL_HTML)
840
+ }
841
+
759
842
  // Fallback — 404
760
843
  send404(res)
761
844
  } catch (err) {
@@ -0,0 +1,108 @@
1
+ import { intro, outro, text, select, confirm, spinner, isCancel } from '@clack/prompts'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join, dirname } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import {
6
+ detectIDEs, installSkill, registerMCP,
7
+ registerHook, saveConfig, removeAll,
8
+ } from './setup.js'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const PROJECT_ROOT = join(dirname(__filename), '..')
12
+ const pkg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf-8'))
13
+
14
+ function bail(value) {
15
+ if (isCancel(value)) {
16
+ outro('Setup cancelado.')
17
+ process.exit(0)
18
+ }
19
+ return value
20
+ }
21
+
22
+ export async function runSetup(args) {
23
+ const isRemove = args.includes('--remove')
24
+ const isNonInteractive = args.includes('--yes')
25
+ const authorIdx = args.indexOf('--author')
26
+ const authorFlag = (authorIdx !== -1 && args[authorIdx + 1] && !args[authorIdx + 1].startsWith('-'))
27
+ ? args[authorIdx + 1]
28
+ : undefined
29
+
30
+ if (isRemove) {
31
+ const s = spinner()
32
+ s.start('Removendo mdProbe...')
33
+ const removed = await removeAll()
34
+ s.stop(`Removido: ${removed.length > 0 ? removed.join(', ') : 'nada encontrado'}`)
35
+ return
36
+ }
37
+
38
+ if (isNonInteractive) {
39
+ const author = authorFlag || 'anonymous'
40
+ const urlStyle = 'localhost'
41
+ const ides = await detectIDEs()
42
+
43
+ const s = spinner()
44
+ s.start('Instalando...')
45
+
46
+ for (const ide of ides) {
47
+ await installSkill(ide)
48
+ }
49
+ await registerMCP()
50
+ await registerHook()
51
+ await saveConfig({ author, urlStyle })
52
+
53
+ s.stop('Instalado com sucesso')
54
+ console.log(` IDEs: ${ides.length > 0 ? ides.join(', ') : 'nenhum detectado'}`)
55
+ console.log(` Author: ${author}`)
56
+ return
57
+ }
58
+
59
+ // Interactive mode
60
+ intro(`mdProbe v${pkg.version} — setup`)
61
+
62
+ const author = bail(await text({
63
+ message: 'Seu nome para anotacoes:',
64
+ placeholder: 'anonymous',
65
+ validate: () => undefined,
66
+ })) || 'anonymous'
67
+
68
+ const urlStyle = bail(await select({
69
+ message: 'Estilo de URL:',
70
+ options: [
71
+ { value: 'mdprobe.localhost', label: 'mdprobe.localhost', hint: 'Chrome/Firefox/Edge' },
72
+ { value: 'localhost', label: 'localhost', hint: 'compativel com todos' },
73
+ ],
74
+ }))
75
+
76
+ const ides = await detectIDEs()
77
+ if (ides.length > 0) {
78
+ console.log(` IDEs detectados: ${ides.map(i => `✓ ${i}`).join(', ')}`)
79
+ } else {
80
+ console.log(' Nenhum IDE detectado.')
81
+ }
82
+
83
+ const s = spinner()
84
+ s.start('Instalando...')
85
+
86
+ const installed = []
87
+
88
+ for (const ide of ides) {
89
+ const path = await installSkill(ide)
90
+ installed.push(` ${ide}: ${path}`)
91
+ }
92
+
93
+ const mcpResult = await registerMCP()
94
+ const hookResult = await registerHook()
95
+ await saveConfig({ author, urlStyle })
96
+
97
+ s.stop('Instalado com sucesso!')
98
+
99
+ if (installed.length > 0) {
100
+ console.log('\n Skills instaladas:')
101
+ installed.forEach(p => console.log(` ${p}`))
102
+ }
103
+ console.log(`\n MCP server registrado (${mcpResult.method})`)
104
+ if (hookResult.added) console.log(' Hook PostToolUse registrado')
105
+ console.log(` Config salva em ~/.mdprobe.json`)
106
+
107
+ outro('Reinicie o Claude Code para ativar.')
108
+ }