@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/README.md +173 -185
- package/README.pt-BR.md +392 -0
- package/bin/cli.js +78 -60
- package/dist/assets/index-Cp_TccJA.css +1 -0
- package/dist/assets/index-DuVgC81Y.js +2 -0
- package/dist/index.html +3 -3
- package/package.json +5 -2
- package/schema.json +2 -2
- package/skills/mdprobe/SKILL.md +143 -278
- package/src/annotations.js +1 -1
- package/src/export.js +1 -1
- package/src/mcp.js +258 -0
- package/src/open-browser.js +27 -0
- package/src/server.js +93 -10
- package/src/setup-ui.js +108 -0
- package/src/setup.js +203 -0
- package/src/singleton.js +236 -0
- package/src/ui/app.jsx +21 -12
- package/src/ui/components/RightPanel.jsx +128 -69
- package/src/ui/hooks/useAnnotations.js +6 -1
- package/src/ui/hooks/useClientLibs.js +2 -2
- package/src/ui/hooks/useWebSocket.js +7 -3
- package/src/ui/index.html +1 -1
- package/src/ui/state/store.js +9 -0
- package/src/ui/styles/themes.css +43 -3
- package/dist/assets/index-DPysqH1p.js +0 -2
- package/dist/assets/index-nl9v2RuJ.css +0 -1
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>
|
|
240
|
+
<title>mdProbe</title>
|
|
240
241
|
</head>
|
|
241
242
|
<body>
|
|
242
|
-
<div id="app"><p style="padding:40px;font-family:sans-serif">
|
|
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 =
|
|
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
|
|
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)
|
|
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) {
|
package/src/setup-ui.js
ADDED
|
@@ -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
|
+
}
|