@henryavila/mdprobe 0.1.0 → 0.2.1

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.
@@ -0,0 +1,392 @@
1
+ ![mdProbe](header.png)
2
+
3
+ # mdProbe
4
+
5
+ Visualizador e revisor de markdown com live reload, anotações persistentes e integração com agentes de IA.
6
+
7
+ Abra arquivos `.md` no browser, anote inline, aprove seções e exporte feedback estruturado em YAML — tudo pelo terminal.
8
+
9
+ ---
10
+
11
+ ## O que o mdProbe é
12
+
13
+ - Uma **ferramenta CLI** que renderiza markdown no browser com live reload
14
+ - Um **sistema de anotações** onde você seleciona texto e adiciona comentários com tags (bug, question, suggestion, nitpick)
15
+ - Um **workflow de revisão** com aprovação por seção (aprovar/rejeitar por heading)
16
+ - Um **servidor MCP** que permite agentes de IA (Claude Code, Cursor, etc.) abrir arquivos, ler anotações e resolver feedback programaticamente
17
+
18
+ ## O que o mdProbe não é
19
+
20
+ - Não é um editor de markdown — você edita no seu editor, o mdprobe renderiza e anota
21
+ - Não é um gerador de sites estáticos — ele roda um servidor local para preview ao vivo
22
+ - Não é exclusivo para IA — funciona perfeitamente como ferramenta standalone de revisão
23
+
24
+ ---
25
+
26
+ ## Instalação
27
+
28
+ ```bash
29
+ npm install -g @henryavila/mdprobe
30
+ mdprobe setup
31
+ ```
32
+
33
+ O wizard de setup configura seu nome de autor, instala a skill de IA nas IDEs detectadas (Claude Code, Cursor, Gemini), registra o servidor MCP e adiciona um hook PostToolUse.
34
+
35
+ Para ambientes não-interativos: `mdprobe setup --yes --author "Seu Nome"`
36
+
37
+ Ou execute sem instalar:
38
+
39
+ ```bash
40
+ npx @henryavila/mdprobe README.md
41
+ ```
42
+
43
+ **Requisitos:** Node.js 20+, um browser.
44
+
45
+ ---
46
+
47
+ ## Início Rápido
48
+
49
+ ### Visualizar e editar
50
+
51
+ ```bash
52
+ mdprobe README.md
53
+ ```
54
+
55
+ Abre o markdown renderizado no browser. Edite o arquivo fonte — o browser atualiza instantaneamente via WebSocket.
56
+
57
+ ```bash
58
+ mdprobe docs/
59
+ ```
60
+
61
+ Descobre todos os `.md` recursivamente e mostra um seletor de arquivos.
62
+
63
+ ### Anotar
64
+
65
+ Selecione qualquer texto no browser → escolha uma tag → escreva um comentário → salve.
66
+
67
+ | Tag | Significado |
68
+ |-----|-------------|
69
+ | `bug` | Algo está errado |
70
+ | `question` | Precisa de esclarecimento |
71
+ | `suggestion` | Ideia de melhoria |
72
+ | `nitpick` | Detalhe menor de estilo/texto |
73
+
74
+ Anotações são armazenadas em arquivos sidecar `.annotations.yaml` — legíveis por humanos, amigáveis para git.
75
+
76
+ ---
77
+
78
+ ## Servidor Singleton
79
+
80
+ O mdProbe roda uma **única instância do servidor**. Múltiplas invocações compartilham o mesmo servidor ao invés de iniciar duplicatas:
81
+
82
+ ```bash
83
+ mdprobe README.md # Inicia servidor na porta 3000, abre browser
84
+ mdprobe CHANGELOG.md # Detecta servidor rodando, adiciona arquivo, abre browser, sai
85
+ ```
86
+
87
+ A segunda invocação adiciona seus arquivos ao servidor existente e sai imediatamente. O browser mostra todos os arquivos na sidebar.
88
+
89
+ **Como funciona:** Um lock file em `/tmp/mdprobe.lock` registra o PID, porta e URL do servidor rodando. Novas invocações leem o lock file, verificam se o servidor está vivo via health check HTTP, e entram via `POST /api/add-files`. Ao desligar (`Ctrl+C`), o lock file é removido automaticamente.
90
+
91
+ **Recuperação de lock stale:** Se uma instância anterior crashou, a próxima invocação detecta o processo morto e inicia normalmente.
92
+
93
+ ---
94
+
95
+ ## Dois Workflows de Revisão
96
+
97
+ O mdProbe suporta dois workflows de revisão distintos para contextos diferentes:
98
+
99
+ ### 1. Revisão bloqueante (`--once`) — para CI/CD e scripts
100
+
101
+ ```bash
102
+ mdprobe spec.md --once
103
+ ```
104
+
105
+ Bloqueia o processo até você clicar **"Finish Review"** na UI. Ao finalizar, as anotações são salvas em `spec.annotations.yaml` e o processo sai com a lista de arquivos criados. Útil para pipelines que precisam de aprovação humana antes de continuar.
106
+
107
+ O modo `--once` sempre cria uma **instância isolada** — não participa do singleton. Isso garante que sessões de revisão tenham ciclo de vida independente.
108
+
109
+ ### 2. Revisão assistida por IA (MCP) — para agentes de código
110
+
111
+ Ao trabalhar com agentes de IA (Claude Code, Cursor, etc.), o workflow é diferente. O agente **não usa `--once`**. Em vez disso:
112
+
113
+ ```
114
+ Agente escreve spec.md
115
+
116
+ Agente chama mdprobe_view → browser abre, servidor continua rodando
117
+
118
+ Humano lê, anota, aprova/rejeita seções
119
+
120
+ Humano diz ao agente via chat: "terminei de revisar"
121
+
122
+ Agente chama mdprobe_annotations → lê todo o feedback
123
+
124
+ Agente corrige bugs, responde perguntas, avalia sugestões
125
+
126
+ Agente reporta mudanças, pede confirmação
127
+
128
+ Agente chama mdprobe_update → resolve anotações
129
+
130
+ Humano vê itens resolvidos em tempo real (esmaecidos)
131
+ ```
132
+
133
+ O servidor continua rodando durante toda a conversa. O agente lê anotações sob demanda — sem bloqueio, sem sair do processo. Múltiplos arquivos podem ser revisados na mesma sessão via servidor singleton.
134
+
135
+ ---
136
+
137
+ ## Funcionalidades
138
+
139
+ ### Renderização
140
+
141
+ Tabelas GFM, syntax highlighting (highlight.js), diagramas Mermaid, math/LaTeX (KaTeX), frontmatter YAML/TOML, HTML raw, imagens do diretório fonte.
142
+
143
+ ### Live Reload
144
+
145
+ Mudanças detectadas via chokidar, enviadas por WebSocket. Debounce de 100ms. Posição de scroll preservada.
146
+
147
+ ### Aprovação de Seções
148
+
149
+ Cada heading ganha botões de aprovar/rejeitar. Aprovar um pai cascateia para todos os filhos. Barra de progresso mostra seções revisadas vs total.
150
+
151
+ ### Detecção de Drift
152
+
153
+ Banner de aviso quando o arquivo fonte muda após as anotações terem sido criadas.
154
+
155
+ ### Temas
156
+
157
+ Cinco temas baseados no Catppuccin: Mocha (escuro, padrão), Macchiato, Frappe, Latte, Light.
158
+
159
+ ### Atalhos de Teclado
160
+
161
+ | Tecla | Ação |
162
+ |-------|------|
163
+ | `[` | Toggle painel esquerdo (arquivos + TOC) |
164
+ | `]` | Toggle painel direito (anotações) |
165
+ | `\` | Modo foco (esconde ambos os painéis) |
166
+ | `j` / `k` | Próxima / anterior anotação |
167
+ | `?` | Overlay de ajuda |
168
+ | `Ctrl+Enter` | Salvar anotação |
169
+
170
+ ### Exportação
171
+
172
+ ```bash
173
+ mdprobe export spec.md --report # Relatório de revisão em markdown
174
+ mdprobe export spec.md --inline # Anotações inseridas no fonte
175
+ mdprobe export spec.md --json # JSON puro
176
+ mdprobe export spec.md --sarif # SARIF 2.1.0 (integração CI/CD)
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Integração com Agentes de IA
182
+
183
+ O mdProbe inclui um servidor MCP (Model Context Protocol) e um arquivo de skill (`SKILL.md`) que ensina agentes de IA a usar o workflow de revisão. Isso habilita um loop bidirecional: o agente escreve markdown, o humano anota, o agente lê o feedback e resolve.
184
+
185
+ ### Setup
186
+
187
+ ```bash
188
+ mdprobe setup
189
+ ```
190
+
191
+ Wizard interativo que:
192
+ 1. Instala o `SKILL.md` nas IDEs detectadas (Claude Code, Cursor, Gemini)
193
+ 2. Registra o servidor MCP (`mdprobe mcp`) na config do Claude Code
194
+ 3. Adiciona um hook PostToolUse que lembra o agente de usar mdprobe ao editar `.md`
195
+ 4. Configura seu nome de autor
196
+
197
+ Não-interativo: `mdprobe setup --yes --author "Seu Nome"`
198
+ Remover tudo: `mdprobe setup --remove`
199
+
200
+ ### Ferramentas MCP
201
+
202
+ Após o setup, agentes de IA podem chamar estas ferramentas:
203
+
204
+ | Ferramenta | Propósito |
205
+ |------------|-----------|
206
+ | `mdprobe_view` | Abrir `.md` no browser |
207
+ | `mdprobe_annotations` | Ler anotações e status das seções |
208
+ | `mdprobe_update` | Resolver, responder, adicionar ou deletar anotações |
209
+ | `mdprobe_status` | Verificar se o servidor está rodando |
210
+
211
+ O servidor MCP participa do singleton — se um servidor iniciado via CLI já estiver rodando, o agente o reutiliza.
212
+
213
+ ### Registro Manual do MCP
214
+
215
+ Se preferir não usar `mdprobe setup`:
216
+
217
+ ```bash
218
+ claude mcp add --scope user --transport stdio mdprobe -- mdprobe mcp
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Referência CLI
224
+
225
+ ```
226
+ mdprobe [arquivos...] [opções]
227
+
228
+ Opções:
229
+ --port <n> Porta (padrão: 3000, auto-incrementa se ocupada)
230
+ --once Revisão bloqueante — servidor isolado, sai ao "Finish Review"
231
+ --no-open Não abrir browser automaticamente
232
+ --help, -h Mostrar ajuda
233
+ --version, -v Mostrar versão
234
+
235
+ Subcomandos:
236
+ setup Setup interativo (skill + MCP + hook)
237
+ setup --remove Desinstalar tudo
238
+ setup --yes [--author] Setup não-interativo
239
+ mcp Iniciar servidor MCP (stdio, para agentes de IA)
240
+ config [key] [value] Gerenciar configuração
241
+ export <path> [flags] Exportar anotações (--report, --inline, --json, --sarif)
242
+ ```
243
+
244
+ ---
245
+
246
+ ## API como Biblioteca
247
+
248
+ ### Embutir no seu próprio servidor
249
+
250
+ ```javascript
251
+ import { createHandler } from '@henryavila/mdprobe'
252
+
253
+ const handler = createHandler({
254
+ resolveFile: (req) => '/path/to/file.md',
255
+ listFiles: () => [
256
+ { id: 'spec', path: '/docs/spec.md', label: 'Especificação' },
257
+ ],
258
+ basePath: '/review',
259
+ author: 'Review Bot',
260
+ onComplete: (result) => {
261
+ console.log(`Revisão concluída: ${result.annotations} anotações`)
262
+ },
263
+ })
264
+
265
+ import http from 'node:http'
266
+ http.createServer(handler).listen(3000)
267
+ ```
268
+
269
+ ### Trabalhando com anotações programaticamente
270
+
271
+ ```javascript
272
+ import { AnnotationFile } from '@henryavila/mdprobe/annotations'
273
+
274
+ const af = await AnnotationFile.load('spec.annotations.yaml')
275
+
276
+ // Consultar
277
+ const open = af.getOpen()
278
+ const bugs = af.getByTag('bug')
279
+
280
+ // Modificar
281
+ af.add({
282
+ selectors: {
283
+ position: { startLine: 10, startColumn: 1, endLine: 10, endColumn: 40 },
284
+ quote: { exact: 'texto selecionado', prefix: '', suffix: '' },
285
+ },
286
+ comment: 'Isso precisa de esclarecimento',
287
+ tag: 'question',
288
+ author: 'Henry',
289
+ })
290
+ af.resolve(bugs[0].id)
291
+ await af.save('spec.annotations.yaml')
292
+
293
+ // Exportar
294
+ import { exportJSON, exportSARIF } from '@henryavila/mdprobe/export'
295
+ const sarif = exportSARIF(af, 'spec.md')
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Schema de Anotações
301
+
302
+ Formato do arquivo sidecar (`<arquivo>.annotations.yaml`):
303
+
304
+ ```yaml
305
+ version: 1
306
+ source: spec.md
307
+ source_hash: "sha256:abc123..."
308
+ sections:
309
+ - heading: Introdução
310
+ level: 2
311
+ status: approved
312
+ annotations:
313
+ - id: "a1b2c3d4"
314
+ selectors:
315
+ position: { startLine: 15, startColumn: 1, endLine: 15, endColumn: 42 }
316
+ quote: { exact: "O sistema deve suportar usuários concorrentes" }
317
+ comment: "Quantos usuários concorrentes?"
318
+ tag: question
319
+ status: open
320
+ author: Henry
321
+ created_at: "2026-04-08T10:30:00.000Z"
322
+ replies:
323
+ - author: Agente
324
+ comment: "Meta é 500 concorrentes."
325
+ created_at: "2026-04-08T11:00:00.000Z"
326
+ ```
327
+
328
+ JSON Schema disponível em `@henryavila/mdprobe/schema.json`.
329
+
330
+ ---
331
+
332
+ ## API HTTP
333
+
334
+ Disponível quando o servidor está rodando:
335
+
336
+ | Método | Endpoint | Descrição |
337
+ |--------|----------|-----------|
338
+ | `GET` | `/api/files` | Listar arquivos markdown |
339
+ | `GET` | `/api/file?path=<arquivo>` | HTML renderizado + TOC + frontmatter |
340
+ | `GET` | `/api/annotations?path=<arquivo>` | Anotações + seções + status de drift |
341
+ | `POST` | `/api/annotations` | Criar/atualizar/deletar anotações |
342
+ | `POST` | `/api/sections` | Aprovar/rejeitar/resetar seções |
343
+ | `GET` | `/api/export?path=<arquivo>&format=<fmt>` | Exportar (json, report, inline, sarif) |
344
+ | `GET` | `/api/status` | Identidade do servidor, PID, porta, lista de arquivos |
345
+ | `POST` | `/api/add-files` | Adicionar arquivos a servidor rodando (singleton join) |
346
+
347
+ WebSocket em `/ws` para atualizações em tempo real.
348
+
349
+ ---
350
+
351
+ ## Desenvolvimento
352
+
353
+ ```bash
354
+ git clone https://github.com/henryavila/mdprobe.git
355
+ cd mdprobe
356
+ npm install
357
+ npm run build:ui
358
+ npm test
359
+ ```
360
+
361
+ ### Estrutura do Projeto
362
+
363
+ ```
364
+ bin/cli.js Entry point da CLI
365
+ src/
366
+ server.js Servidor HTTP + WebSocket
367
+ singleton.js Lock file + coordenação singleton cross-process
368
+ mcp.js Servidor MCP (4 tools, transporte stdio)
369
+ renderer.js Markdown → HTML (unified/remark/rehype)
370
+ annotations.js CRUD de anotações + aprovação de seções
371
+ export.js Exportação: report, inline, JSON, SARIF
372
+ setup.js Registro de skill + MCP + hook nas IDEs
373
+ setup-ui.js Wizard interativo de setup
374
+ handler.js API de biblioteca para embedding
375
+ config.js Config do usuário (~/.mdprobe.json)
376
+ open-browser.js Abertura de browser cross-platform
377
+ hash.js Detecção de drift via SHA-256
378
+ anchoring.js Matching de posição de texto
379
+ ui/
380
+ components/ Componentes Preact
381
+ hooks/ WebSocket, keyboard, tema, anotações
382
+ state/store.js Estado via Preact Signals
383
+ styles/themes.css Temas Catppuccin
384
+ schema.json JSON Schema para YAML de anotações
385
+ skills/mdprobe/ Skill para agentes de IA (SKILL.md)
386
+ ```
387
+
388
+ ---
389
+
390
+ ## Licença
391
+
392
+ MIT © [Henry Avila](https://github.com/henryavila)
package/bin/cli.js CHANGED
@@ -9,6 +9,8 @@ import { AnnotationFile } from '../src/annotations.js'
9
9
  import { exportReport, exportInline, exportJSON, exportSARIF } from '../src/export.js'
10
10
  import { createServer as createMdprobeServer } from '../src/server.js'
11
11
  import { findMarkdownFiles, extractFlag, hasFlag } from '../src/cli-utils.js'
12
+ import { openBrowser } from '../src/open-browser.js'
13
+ import { discoverExistingServer, joinExistingServer, writeLockFile, registerShutdownHandlers } from '../src/singleton.js'
12
14
 
13
15
  // ---------------------------------------------------------------------------
14
16
  // Resolve paths
@@ -29,18 +31,22 @@ const rawArgs = process.argv.slice(2)
29
31
  function printUsage() {
30
32
  console.log(`Usage: mdprobe [files...] [options]
31
33
 
32
- Markdown viewer + reviewer with live reload and persistent annotations.
34
+ mdProbe — Markdown viewer + reviewer with live reload and persistent annotations.
33
35
 
34
36
  Options:
35
37
  --port <n> Port number (default: 3000)
36
38
  --once Review mode (single pass, then exit)
39
+ --no-open Don't auto-open browser
37
40
  --help, -h Show help
38
41
  --version, -v Show version
39
42
 
40
43
  Subcommands:
44
+ setup Interactive setup (skill + MCP + hook)
45
+ setup --remove Uninstall everything
46
+ setup --yes [--author] Non-interactive setup
47
+ mcp Start MCP server (stdio, used by Claude Code)
41
48
  config [key] [value] Manage configuration
42
49
  export <path> [flags] Export annotations (--report, --inline, --json, --sarif)
43
- install --plugin Install Claude Code skill/plugin
44
50
  `)
45
51
  }
46
52
 
@@ -112,30 +118,21 @@ async function main() {
112
118
  process.exit(0)
113
119
  }
114
120
 
115
- // ---- install subcommand ----
116
- if (subcommand === 'install') {
117
- const target = args[1]
118
- if (target === '--plugin') {
119
- const os = await import('node:os')
120
- const fs = await import('node:fs/promises')
121
- const destDir = join(os.homedir(), '.claude', 'skills', 'mdprobe')
122
- const srcFile = join(PROJECT_ROOT, 'skills', 'mdprobe', 'SKILL.md')
123
-
124
- try {
125
- await fs.mkdir(destDir, { recursive: true })
126
- const content = await fs.readFile(srcFile, 'utf-8')
127
- await fs.writeFile(join(destDir, 'SKILL.md'), content, 'utf-8')
128
- console.log(`Plugin installed to ${destDir}/SKILL.md`)
129
- console.log('Claude Code will now suggest mdprobe for rich markdown output.')
130
- } catch (err) {
131
- fatal(`Error installing plugin: ${err.message}`)
132
- }
133
- } else {
134
- fatal('Usage: mdprobe install --plugin')
135
- }
121
+ // ---- setup subcommand ----
122
+ if (subcommand === 'setup') {
123
+ const { runSetup } = await import('../src/setup-ui.js')
124
+ await runSetup(args.slice(1))
136
125
  process.exit(0)
137
126
  }
138
127
 
128
+ // ---- mcp subcommand ----
129
+ if (subcommand === 'mcp') {
130
+ const { startMcpServer } = await import('../src/mcp.js')
131
+ await startMcpServer()
132
+ // MCP server runs until parent process terminates
133
+ return
134
+ }
135
+
139
136
  // ---- export subcommand ----
140
137
  if (subcommand === 'export') {
141
138
  const mdPath = args[1]
@@ -200,9 +197,9 @@ async function main() {
200
197
 
201
198
  // ---- Serve mode (default) ----
202
199
 
203
- // Check if author is configured; prompt if missing
200
+ // Check if author is configured; prompt if missing (only in interactive terminals)
204
201
  let currentAuthor = await getAuthor()
205
- if (currentAuthor === 'anonymous') {
202
+ if (currentAuthor === 'anonymous' && process.stdin.isTTY) {
206
203
  const { createInterface } = await import('node:readline')
207
204
  const rl = createInterface({ input: process.stdin, output: process.stdout })
208
205
  const name = await new Promise(resolve => {
@@ -274,23 +271,23 @@ async function main() {
274
271
  }
275
272
  }
276
273
 
277
- // Start the mdprobe server (HTTP + WebSocket + file watcher + Preact UI)
278
- try {
279
- const server = await createMdprobeServer({
280
- files: mdFiles,
281
- port,
282
- open: false,
283
- once: onceFlag,
284
- author: currentAuthor,
285
- })
286
-
287
- console.log(`Server listening at ${server.url}`)
288
-
289
- if (onceFlag) {
274
+ // --once mode: always isolated (never singleton)
275
+ if (onceFlag) {
276
+ try {
277
+ const server = await createMdprobeServer({
278
+ files: mdFiles,
279
+ port,
280
+ open: false,
281
+ once: true,
282
+ author: currentAuthor,
283
+ })
284
+ console.log(`Server listening at ${server.url}`)
285
+ if (!noOpenFlag) {
286
+ try { await openBrowser(server.url) } catch { /* ignore */ }
287
+ }
290
288
  console.log(`Review mode: ${mdFiles.length} file(s)`)
291
289
  mdFiles.forEach(f => console.log(` - ${basename(f)}`))
292
290
 
293
- // Block until user clicks "Finish Review" in the UI
294
291
  const result = await server.finishPromise
295
292
  console.log('\nReview complete.')
296
293
  if (result.yamlPaths?.length > 0) {
@@ -300,30 +297,51 @@ async function main() {
300
297
  }
301
298
  await server.close()
302
299
  process.exit(0)
300
+ } catch (err) {
301
+ fatal(`Error: ${err.message}`)
303
302
  }
303
+ return
304
+ }
304
305
 
305
- // Try to open browser (skip with --no-open)
306
- if (noOpenFlag) { /* skip */ } else try {
307
- const { execFile: execFileFn } = await import('node:child_process')
308
- const isWSL = await readFile('/proc/version', 'utf-8').then(v => /microsoft/i.test(v)).catch(() => false)
309
-
310
- let cmd, args
311
- if (process.platform === 'darwin') {
312
- cmd = 'open'
313
- args = [server.url]
314
- } else if (process.platform === 'win32') {
315
- cmd = 'cmd'
316
- args = ['/c', 'start', server.url]
317
- } else if (isWSL) {
318
- cmd = '/mnt/c/Windows/System32/cmd.exe'
319
- args = ['/c', 'start', server.url]
320
- } else {
321
- cmd = 'xdg-open'
322
- args = [server.url]
306
+ // --- Singleton mode: reuse existing server if running ---
307
+ try {
308
+ const existing = await discoverExistingServer()
309
+
310
+ if (existing) {
311
+ console.log(`Found running mdprobe at ${existing.url}`)
312
+ const result = await joinExistingServer(existing.url, mdFiles)
313
+ if (result.ok) {
314
+ console.log(`Added ${mdFiles.length} file(s) to existing server`)
315
+ if (!noOpenFlag) {
316
+ const fileUrl = mdFiles.length === 1
317
+ ? `${existing.url}/${basename(mdFiles[0])}`
318
+ : existing.url
319
+ try { await openBrowser(fileUrl) } catch { /* ignore */ }
320
+ }
321
+ process.exit(0)
323
322
  }
324
- execFileFn(cmd, args, { stdio: 'ignore' })
325
- } catch {
326
- // Browser open failed — user can navigate manually
323
+ console.log('Could not join existing server, starting new instance...')
324
+ }
325
+
326
+ // Start new server
327
+ const server = await createMdprobeServer({
328
+ files: mdFiles,
329
+ port,
330
+ open: false,
331
+ author: currentAuthor,
332
+ })
333
+
334
+ await writeLockFile({
335
+ pid: process.pid,
336
+ port: server.port,
337
+ url: server.url,
338
+ startedAt: new Date().toISOString(),
339
+ })
340
+ registerShutdownHandlers(server)
341
+
342
+ console.log(`Server listening at ${server.url}`)
343
+ if (!noOpenFlag) {
344
+ try { await openBrowser(server.url) } catch { /* ignore */ }
327
345
  }
328
346
  } catch (err) {
329
347
  fatal(`Error: ${err.message}`)