@loicngr/kobo 1.5.2 → 1.5.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.
Files changed (53) hide show
  1. package/README.md +47 -0
  2. package/dist/server/index.js +2 -0
  3. package/dist/server/routes/sentry.js +20 -0
  4. package/dist/server/routes/settings.js +11 -0
  5. package/dist/server/routes/workspaces.js +93 -3
  6. package/dist/server/services/notion-service.js +45 -189
  7. package/dist/server/services/sentry-service.js +135 -0
  8. package/dist/server/services/settings-service.js +23 -0
  9. package/dist/server/utils/mcp-client.js +211 -0
  10. package/package.json +1 -1
  11. package/src/client/dist/spa/assets/{ActivityFeed-CyCjnWpd.js → ActivityFeed-DwLZQtds.js} +1 -1
  12. package/src/client/dist/spa/assets/CreatePage-DnV87-Ej.js +2 -0
  13. package/src/client/dist/spa/assets/CreatePage-y7wOGccu.css +1 -0
  14. package/src/client/dist/spa/assets/{DiffViewer-CODOs-nD.js → DiffViewer-DGMt53xq.js} +2 -2
  15. package/src/client/dist/spa/assets/{MainLayout-gJB92Uex.js → MainLayout-liO7poC9.js} +2 -2
  16. package/src/client/dist/spa/assets/{QExpansionItem-GHHBo3vG.js → QExpansionItem-DG5mKIcR.js} +1 -1
  17. package/src/client/dist/spa/assets/{QList-D4El-L0w.js → QList-B7DWhM2q.js} +1 -1
  18. package/src/client/dist/spa/assets/{QMenu-DPjNAi13.js → QMenu-hku3VAGZ.js} +1 -1
  19. package/src/client/dist/spa/assets/{QPage-DyGpMmRB.js → QPage-BhzgHYFd.js} +1 -1
  20. package/src/client/dist/spa/assets/SettingsPage-C26BwQT7.css +1 -0
  21. package/src/client/dist/spa/assets/SettingsPage-LzfsgdZ9.js +1 -0
  22. package/src/client/dist/spa/assets/{WorkspacePage-DCIpeMKc.js → WorkspacePage-BaOtXfiF.js} +3 -3
  23. package/src/client/dist/spa/assets/{cssMode-Bs7Ir5HZ.js → cssMode-DiBSKuoC.js} +1 -1
  24. package/src/client/dist/spa/assets/{editor.api-Bx7Ah6pf.js → editor.api-hOnUFdOA.js} +1 -1
  25. package/src/client/dist/spa/assets/{editor.main-tO5BzTPC.js → editor.main-DNa8GpeG.js} +3 -3
  26. package/src/client/dist/spa/assets/{freemarker2-CDfily2B.js → freemarker2-Dj7P1Jpi.js} +1 -1
  27. package/src/client/dist/spa/assets/{handlebars-AwDxxENn.js → handlebars-DEMCvy8L.js} +1 -1
  28. package/src/client/dist/spa/assets/{html-BY9SWE1C.js → html-CPhzMOby.js} +1 -1
  29. package/src/client/dist/spa/assets/{htmlMode-C6FDyX-w.js → htmlMode-DneB4Mlv.js} +1 -1
  30. package/src/client/dist/spa/assets/i18n-BYjjZp-H.js +1 -0
  31. package/src/client/dist/spa/assets/i18n-Dho6KmW_.js +1 -0
  32. package/src/client/dist/spa/assets/index-CFlp44pN.js +5 -0
  33. package/src/client/dist/spa/assets/{javascript-Blavvi4I.js → javascript-xA20aQhN.js} +1 -1
  34. package/src/client/dist/spa/assets/{jsonMode-CsgNfmDe.js → jsonMode-DB1q7_RO.js} +1 -1
  35. package/src/client/dist/spa/assets/{liquid-CeX-oFi-.js → liquid-DUaXf8Ca.js} +1 -1
  36. package/src/client/dist/spa/assets/{marked.esm-DqTghqFg.js → marked.esm-hG3KqOZk.js} +1 -1
  37. package/src/client/dist/spa/assets/{mdx-CzdoKooW.js → mdx-DhSPjkRN.js} +1 -1
  38. package/src/client/dist/spa/assets/{monaco.contribution-2VdGUl2G.js → monaco.contribution-BRz6c5Oe.js} +2 -2
  39. package/src/client/dist/spa/assets/{python-DfvLV8-T.js → python-Cf6SIZRF.js} +1 -1
  40. package/src/client/dist/spa/assets/{razor-CLVHHld4.js → razor-N0lXOpYt.js} +1 -1
  41. package/src/client/dist/spa/assets/{settings-CuK-S6HH.js → settings-CrHkitxT.js} +1 -1
  42. package/src/client/dist/spa/assets/{tsMode-B8UnweFF.js → tsMode-rGOAcVzO.js} +1 -1
  43. package/src/client/dist/spa/assets/{typescript-BtDEyXr-.js → typescript-BDFRwi4p.js} +1 -1
  44. package/src/client/dist/spa/assets/{xml-BPbtYZUS.js → xml-Bu77_PI9.js} +1 -1
  45. package/src/client/dist/spa/assets/{yaml-Ddz4CrJo.js → yaml-wD3nav-2.js} +1 -1
  46. package/src/client/dist/spa/index.html +3 -3
  47. package/src/client/dist/spa/assets/CreatePage-BKe7LN7v.js +0 -2
  48. package/src/client/dist/spa/assets/CreatePage-BTFc1WXO.css +0 -1
  49. package/src/client/dist/spa/assets/SettingsPage-BG_7gLtm.js +0 -1
  50. package/src/client/dist/spa/assets/SettingsPage-ai7Q_1KB.css +0 -1
  51. package/src/client/dist/spa/assets/i18n-Ck0cyale.js +0 -1
  52. package/src/client/dist/spa/assets/i18n-NYEh-R2v.js +0 -1
  53. package/src/client/dist/spa/assets/index-C0rZED_M.js +0 -5
package/README.md CHANGED
@@ -15,6 +15,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
15
15
  - **Live agent output** — stream `stdout`/`stderr` from Claude Code to the browser via WebSocket, with persisted event replay on reconnect
16
16
  - **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
17
17
  - **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria
18
+ - **Sentry integration** — paste a Sentry issue URL to spin up a dedicated "fix workspace" with the stacktrace, tags, and offending spans written to `.ai/thoughts/SENTRY-<id>.md`; the agent is primed with a TDD fix workflow and has access to the Sentry MCP tools for deeper digging
18
19
  - **Per-workspace dev servers** — start/stop Docker or Node dev servers scoped to each branch, with log streaming
19
20
  - **Conventional-commit enforcement** — project-level git conventions are written to `.ai/.git-conventions.md` inside every workspace so Claude follows them during commits
20
21
  - **Pull request automation** — one-click `push`, `pull`, and `open-pr` endpoints integrate with the GitHub CLI, using a configurable prompt template
@@ -41,6 +42,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
41
42
  - Optional: Docker (if you configure per-workspace dev servers)
42
43
  - Optional: `gh` CLI (if you use the PR automation)
43
44
  - Optional: a Notion integration token (only if you want to import workspace missions from Notion pages — see [Notion integration](#notion-integration))
45
+ - Optional: a Sentry auth token (only if you want to create fix workspaces from Sentry issue URLs — see [Sentry integration](#sentry-integration))
44
46
 
45
47
  ### Run via `npx` (recommended)
46
48
 
@@ -137,6 +139,51 @@ If you need to pin a specific version of the Notion MCP server, use a fork, or a
137
139
 
138
140
  Without a valid token configured, the Notion import field in the workspace creation form will return an error when you click **Refresh** or submit a Notion URL — the rest of Kōbō (workspaces, agents, tasks, Git integration) keeps working independently.
139
141
 
142
+ ## Sentry integration
143
+
144
+ Kōbō can turn a Sentry issue into a dedicated "fix workspace" — you paste the issue URL at workspace creation and Kōbō extracts the stacktrace, culprit, tags, offending spans and extra context, writes them as a local markdown file inside the worktree (`.ai/thoughts/SENTRY-<id>.md`), and primes the Claude agent with a TDD fix workflow that points at that file. The agent also keeps access to the Sentry MCP tools (`search_issue_events`, `get_issue_tag_values`, `get_sentry_resource`) so it can dig deeper on its own. **This feature is opt-in and reuses the Sentry MCP configuration you already have for Claude Code** — Kōbō does not manage a Sentry token separately.
145
+
146
+ Under the hood, Kōbō spawns the official [`@sentry/mcp-server`](https://www.npmjs.com/package/@sentry/mcp-server) as a child process using the exact `command`, `args`, and `env` from your `~/.claude.json`, then calls `get_sentry_resource` over stdio. No token handling inside Kōbō — if you change the token or the host in your Claude Code config, Kōbō follows automatically.
147
+
148
+ ### Getting a Sentry auth token
149
+
150
+ 1. In Sentry, go to **Settings → Developer Settings → Custom Integrations** (or **User Auth Tokens** for personal use)
151
+ 2. Create a token with at least these scopes: `project:read`, `event:read`, `org:read`
152
+ 3. Copy the token (format `sntryu_...` for user tokens)
153
+
154
+ ### Configuring the Sentry MCP in Claude Code
155
+
156
+ The recommended setup is to register the Sentry MCP once in Claude Code — Kōbō picks it up automatically:
157
+
158
+ ```bash
159
+ claude mcp add sentry -s user \
160
+ -e SENTRY_ACCESS_TOKEN=sntryu_your_token_here \
161
+ -e SENTRY_HOST=your-org.sentry.io \
162
+ -- npx -y @sentry/mcp-server@latest
163
+ ```
164
+
165
+ For self-hosted Sentry, set `SENTRY_HOST` to your Sentry hostname (e.g. `sentry.mycompany.com`).
166
+
167
+ ### How Kōbō picks the entry
168
+
169
+ Kōbō reads `~/.claude.json` and uses the first entry under `mcpServers` whose key contains `sentry` (case-insensitive) **and is not disabled**. This means:
170
+
171
+ - A single `sentry` entry → used as-is
172
+ - Multiple entries whose key contains `sentry` → the first matching non-disabled key wins
173
+ - Toggle `"disabled": true` on an entry to make Kōbō skip it
174
+
175
+ ### Usage
176
+
177
+ 1. In the workspace creation form, click **Import Sentry**
178
+ 2. Paste the issue URL (e.g. `https://your-org.sentry.io/issues/112081699`)
179
+ 3. Submit — Kōbō extracts the issue, writes `.ai/thoughts/SENTRY-<numericId>.md`, creates a `Fix: <title>` task, and boots the agent with the fix workflow
180
+
181
+ The Sentry issue Short-ID (e.g. `ACME-API-3` — the canonical identifier Sentry assigns to each issue) is used as the ticket prefix for the working branch (e.g. `fix/ACME-API-3--slow-db-query` or `bugfix/ACME-API-3--slow-db-query`, depending on the branch prefix you chose at creation). The Short-ID is also what Sentry recognises in commit messages like `Fixes ACME-API-3` to auto-close the issue on merge. The local copy of the issue is written to `.ai/thoughts/SENTRY-<shortId>.md` (e.g. `SENTRY-ACME-API-3.md`). When Sentry is active, the description field becomes optional — the extracted context is enough to start work.
182
+
183
+ If the MCP server is slow to initialize (e.g. cold `npx` fetch, self-hosted host validation), bump the handshake timeout with `KOBO_MCP_INIT_TIMEOUT_MS` (default: `30000`).
184
+
185
+ Without a valid Sentry MCP configured in `~/.claude.json`, the Sentry import field returns a clear error when you submit — the rest of Kōbō keeps working.
186
+
140
187
  ## Recommended: Superpowers plugin for Claude Code
141
188
 
142
189
  For the best experience, we recommend installing the [**superpowers**](https://github.com/obra/superpowers) plugin in Claude Code. Kōbō is designed to work well with it out of the box:
@@ -12,6 +12,7 @@ import gitRouter from './routes/git.js';
12
12
  import imagesRouter from './routes/images.js';
13
13
  import notionRouter from './routes/notion.js';
14
14
  import plansRouter from './routes/plans.js';
15
+ import sentryRouter from './routes/sentry.js';
15
16
  import settingsRouter from './routes/settings.js';
16
17
  import templatesRouter from './routes/templates.js';
17
18
  import workspacesRouter from './routes/workspaces.js';
@@ -47,6 +48,7 @@ app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion(
47
48
  app.route('/api/workspaces', workspacesRouter);
48
49
  app.route('/api/workspaces', imagesRouter);
49
50
  app.route('/api/notion', notionRouter);
51
+ app.route('/api/sentry', sentryRouter);
50
52
  app.route('/api/git', gitRouter);
51
53
  app.route('/api/settings', settingsRouter);
52
54
  app.route('/api/dev-server', devServerRouter);
@@ -0,0 +1,20 @@
1
+ import { Hono } from 'hono';
2
+ import { extractSentryIssue } from '../services/sentry-service.js';
3
+ /** Hono sub-router for Sentry issue extraction (preflight). */
4
+ const app = new Hono();
5
+ // POST /api/sentry/extract — extract a Sentry issue by URL
6
+ app.post('/extract', async (c) => {
7
+ try {
8
+ const body = await c.req.json();
9
+ if (!body.url) {
10
+ return c.json({ error: 'Missing required field: url' }, 400);
11
+ }
12
+ const content = await extractSentryIssue(body.url);
13
+ return c.json(content);
14
+ }
15
+ catch (err) {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ return c.json({ error: message }, 500);
18
+ }
19
+ });
20
+ export default app;
@@ -24,6 +24,17 @@ app.get('/global', (c) => {
24
24
  return c.json({ error: message }, 500);
25
25
  }
26
26
  });
27
+ // GET /api/settings/mcp-servers — list active MCP servers from Claude config
28
+ app.get('/mcp-servers', (c) => {
29
+ try {
30
+ const servers = settingsService.listActiveClaudeMcpServers();
31
+ return c.json(servers);
32
+ }
33
+ catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ return c.json({ error: message }, 500);
36
+ }
37
+ });
27
38
  // PUT /api/settings/global — update global settings
28
39
  app.put('/global', async (c) => {
29
40
  try {
@@ -9,6 +9,7 @@ import * as agentManager from '../services/agent-manager.js';
9
9
  import * as devServerService from '../services/dev-server-service.js';
10
10
  import * as notionService from '../services/notion-service.js';
11
11
  import { renderPrTemplate } from '../services/pr-template-service.js';
12
+ import * as sentryService from '../services/sentry-service.js';
12
13
  import * as settingsService from '../services/settings-service.js';
13
14
  import { runSetupScript } from '../services/setup-script-service.js';
14
15
  import * as terminalService from '../services/terminal-service.js';
@@ -52,6 +53,7 @@ app.post('/', async (c) => {
52
53
  permissionMode: body.permissionMode || globalSettings.defaultPermissionMode || 'plan',
53
54
  });
54
55
  let notionContent = null;
56
+ let sentryContent = null;
55
57
  // Extract Notion page content if a URL was provided
56
58
  if (body.notionUrl) {
57
59
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
@@ -63,6 +65,23 @@ app.post('/', async (c) => {
63
65
  console.error(`[workspaces] Failed to extract Notion page: ${message}`);
64
66
  }
65
67
  }
68
+ // Extract Sentry issue content if a URL was provided. Done early (before
69
+ // worktree creation) so the issue ID can be injected into the branch name.
70
+ if (body.sentryUrl) {
71
+ workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
72
+ try {
73
+ sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
74
+ }
75
+ catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ console.error(`[workspaces] Failed to extract Sentry issue: ${message}`);
78
+ }
79
+ }
80
+ // Update workspace name with Sentry issue title if the user did not provide
81
+ // a custom name and Notion hasn't already filled it.
82
+ if (sentryContent?.title && !notionContent?.title && workspace.name === 'workspace') {
83
+ workspace = workspaceService.updateWorkspaceName(workspace.id, sentryContent.title);
84
+ }
66
85
  // Create tasks from extracted Notion data
67
86
  if (notionContent) {
68
87
  let sortOrder = 0;
@@ -116,14 +135,16 @@ app.post('/', async (c) => {
116
135
  // then falls back to a TK-XXXX pattern anywhere in the workspace name.
117
136
  // The worktree has not been created yet, so a DB update is sufficient.
118
137
  {
119
- const detectedTicketId = notionContent?.ticketId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
138
+ // Sentry's canonical identifier is the issue short-ID (e.g. "ACME-API-3"),
139
+ // which is what Sentry auto-close recognises in commit messages.
140
+ const detectedTicketId = notionContent?.ticketId || sentryContent?.issueId || workspace.name.match(/[A-Z]+-\d+/i)?.[0];
120
141
  if (detectedTicketId && !workingBranch.toLowerCase().includes(detectedTicketId.toLowerCase())) {
121
142
  const ticketPrefix = detectedTicketId.toUpperCase();
122
143
  const slashIdx = workingBranch.indexOf('/');
123
144
  const typePrefix = slashIdx >= 0 ? workingBranch.slice(0, slashIdx + 1) : 'feature/';
124
- // Use Notion title or workspace name for the slug — both have proper accented
145
+ // Use Notion/Sentry title or workspace name for the slug — all have proper accented
125
146
  // characters that NFD normalization can transliterate (é→e, ç→c, etc.)
126
- const titleSource = notionContent?.title || workspace.name;
147
+ const titleSource = notionContent?.title || sentryContent?.title || workspace.name;
127
148
  const titleSlug = titleSource
128
149
  .normalize('NFD')
129
150
  .replace(/[\u0300-\u036f]/g, '')
@@ -262,6 +283,57 @@ app.post('/', async (c) => {
262
283
  console.error('[workspaces] Failed to save Notion content:', err);
263
284
  }
264
285
  }
286
+ // --- Sentry file + task (extraction already done before worktree creation) --
287
+ let sentryFilePath = null;
288
+ if (sentryContent) {
289
+ try {
290
+ const thoughtsDir = path.join(worktreePath, '.ai', 'thoughts');
291
+ fs.mkdirSync(thoughtsDir, { recursive: true });
292
+ // File is named SENTRY-<shortId>.md (e.g. SENTRY-ACME-API-3.md) — the
293
+ // Short-ID is the canonical Sentry identifier. Falls back to the numeric
294
+ // ID if the Short-ID could not be parsed from the MCP response.
295
+ const idForFile = sentryContent.issueId || sentryContent.issueNumericId;
296
+ sentryFilePath = path.join(thoughtsDir, `SENTRY-${idForFile}.md`);
297
+ const today = new Date().toISOString().split('T')[0];
298
+ const tags = sentryContent.tags;
299
+ const env = tags.environment ?? 'unknown';
300
+ const tagsBlock = Object.entries(tags)
301
+ .map(([k, v]) => `- ${k}: ${v}`)
302
+ .join('\n') || '- (none)';
303
+ const spansBlock = sentryContent.offendingSpans.length > 0 ? sentryContent.offendingSpans.map((s) => `- ${s}`).join('\n') : 'N/A';
304
+ const extra = sentryContent.extraContext || 'N/A';
305
+ const md = `# Fix: ${sentryContent.title || sentryContent.issueId || sentryContent.issueNumericId}\n\n` +
306
+ `## Source\n` +
307
+ `- Sentry: ${body.sentryUrl}\n` +
308
+ `- Issue Short-ID: ${sentryContent.issueId} (use in commit messages for auto-close)\n` +
309
+ `- Issue numeric ID: ${sentryContent.issueNumericId}\n` +
310
+ `- Retrieved: ${today}\n\n` +
311
+ `## Summary\n` +
312
+ `- **Culprit**: ${sentryContent.culprit}\n` +
313
+ `- **Platform**: ${sentryContent.platform}\n` +
314
+ `- **Environment**: ${env}\n` +
315
+ `- **Occurrences**: ${sentryContent.occurrences} (first: ${sentryContent.firstSeen}, last: ${sentryContent.lastSeen})\n\n` +
316
+ `## Tags\n${tagsBlock}\n\n` +
317
+ `## Error Detail / Offending Spans\n${spansBlock}\n\n` +
318
+ `## Additional Context\n${extra}\n\n` +
319
+ `## MCP Tools for deeper analysis\n` +
320
+ `If you need more context, the following Sentry MCP tools are available:\n` +
321
+ `- \`mcp__sentry__get_sentry_resource(url, resourceType)\` — fetch the issue, breadcrumbs, replay, or trace\n` +
322
+ `- \`mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}')\` — recent events for this issue\n` +
323
+ `- \`mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key)\` — filter by tag (environment, user, browser, …)\n`;
324
+ fs.writeFileSync(sentryFilePath, md, 'utf-8');
325
+ workspaceService.createTask(workspace.id, {
326
+ title: `Fix: ${sentryContent.title || sentryContent.issueId || `Sentry #${sentryContent.issueNumericId}`}`,
327
+ isAcceptanceCriterion: false,
328
+ sortOrder: 9999,
329
+ });
330
+ }
331
+ catch (err) {
332
+ console.error('[workspaces] Failed to save Sentry content:', err);
333
+ sentryFilePath = null;
334
+ }
335
+ }
336
+ // ------------------------------------------------------------------------
265
337
  // Update Notion status if both property name and value are configured
266
338
  const notionStatusProp = effectiveSettings.notionStatusProperty;
267
339
  const notionTargetStatus = effectiveSettings.notionInProgressStatus;
@@ -299,6 +371,24 @@ app.post('/', async (c) => {
299
371
  brainstormPrompt += `\nNotion ticket: ${body.notionUrl}`;
300
372
  brainstormPrompt += `\nLocal copy: ${notionFilePath}\n`;
301
373
  }
374
+ if (sentryFilePath && sentryContent) {
375
+ brainstormPrompt += `\nSentry issue: ${body.sentryUrl}`;
376
+ brainstormPrompt += `\nIssue Short-ID: ${sentryContent.issueId} (canonical, use in commit messages for auto-close)`;
377
+ brainstormPrompt += `\nIssue numeric ID: ${sentryContent.issueNumericId}`;
378
+ brainstormPrompt += `\nLocal copy: ${sentryFilePath}\n`;
379
+ brainstormPrompt +=
380
+ `\nFix workflow:\n` +
381
+ `1. Read the local Sentry file above for full context\n` +
382
+ `2. Locate the bug from the stacktrace / culprit\n` +
383
+ `3. Write a failing test that reproduces the bug (TDD)\n` +
384
+ `4. Implement the minimal fix\n` +
385
+ `5. Confirm the test passes, run related tests\n` +
386
+ `6. Commit referencing the Sentry Short-ID (e.g. "fix(scope): description (${sentryContent.issueId})") — Sentry auto-closes the issue when the commit is merged\n` +
387
+ `\nIf you need more context, Sentry MCP tools are available:\n` +
388
+ `- mcp__sentry__get_sentry_resource(url, resourceType) — fetch the issue, breadcrumbs, replay or trace\n` +
389
+ `- mcp__sentry__search_issue_events(organizationSlug, issueId='${sentryContent.issueId}') — recent events\n` +
390
+ `- mcp__sentry__get_issue_tag_values(organizationSlug, issueId='${sentryContent.issueId}', key) — filter by tag\n`;
391
+ }
302
392
  if (todos.length > 0) {
303
393
  brainstormPrompt += `\nTasks:\n${todos.map((t) => `- [${t.status === 'done' ? 'x' : ' '}] ${t.title}`).join('\n')}\n`;
304
394
  }
@@ -1,16 +1,11 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
3
- import { getPackageVersion } from '../utils/paths.js';
1
+ import { callMcpTool, initializeMcp, readClaudeMcpEntry, spawnMcpProcess, unwrapMcpResult, } from '../utils/mcp-client.js';
2
+ import { getGlobalSettings } from './settings-service.js';
4
3
  // Gherkin keywords (French and English)
5
4
  const GHERKIN_PATTERN = /^(Scénario|Étant donné|Quand|Alors|Scenario|Given|When|Then|Feature|Fonctionnalité|And|Et|But|Mais)/i;
6
5
  // Keywords that start a NEW scenario and must flush the current block.
7
6
  // NOTE: `Feature`/`Fonctionnalité` are top-level containers, not a new scenario,
8
7
  // so they stay attached to the first scenario rather than triggering a split.
9
8
  const SCENARIO_START_PATTERN = /^(Scénario|Scenario)/i;
10
- const nextRpcId = (() => {
11
- let counter = 1;
12
- return () => counter++;
13
- })();
14
9
  /**
15
10
  * Parse a Notion URL and extract the page_id in UUID format (with dashes).
16
11
  * Handles:
@@ -35,192 +30,51 @@ export function parseNotionUrl(url) {
35
30
  // Convert 32 hex chars to UUID format: 8-4-4-4-12
36
31
  return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`;
37
32
  }
38
- /** Send a JSON-RPC request to the MCP process and read the response (30s timeout). */
39
- export async function callMcpTool(mcpProcess, toolName, args) {
40
- const id = nextRpcId();
41
- const request = JSON.stringify({
42
- jsonrpc: '2.0',
43
- id,
44
- method: 'tools/call',
45
- params: {
46
- name: toolName,
47
- arguments: args,
48
- },
49
- });
50
- return new Promise((resolve, reject) => {
51
- if (!mcpProcess.stdin || !mcpProcess.stdout) {
52
- reject(new Error('MCP process stdin/stdout not available'));
53
- return;
54
- }
55
- let buffer = '';
56
- const timeout = setTimeout(() => {
57
- mcpProcess.stdout?.removeListener('data', onData);
58
- mcpProcess.stdout?.removeListener('error', onError);
59
- mcpProcess.kill();
60
- reject(new Error(`callMcpTool('${toolName}') timed out after 30s`));
61
- }, 30_000);
62
- const onData = (chunk) => {
63
- buffer += chunk.toString();
64
- // Try to parse complete JSON lines
65
- const lines = buffer.split('\n');
66
- // Keep the last (potentially incomplete) line in the buffer
67
- buffer = lines.pop() ?? '';
68
- for (const line of lines) {
69
- const trimmed = line.trim();
70
- if (!trimmed)
71
- continue;
72
- try {
73
- const parsed = JSON.parse(trimmed);
74
- if (parsed.id === id) {
75
- clearTimeout(timeout);
76
- mcpProcess.stdout?.removeListener('data', onData);
77
- mcpProcess.stdout?.removeListener('error', onError);
78
- if (parsed.error) {
79
- reject(new Error(`MCP tool '${toolName}' error: ${parsed.error.message} (code: ${parsed.error.code})`));
80
- }
81
- else {
82
- resolve(parsed.result);
83
- }
84
- }
85
- }
86
- catch {
87
- // Ignore JSON parse errors for partial lines
88
- }
89
- }
90
- };
91
- const onError = (err) => {
92
- clearTimeout(timeout);
93
- mcpProcess.stdout?.removeListener('data', onData);
94
- reject(err);
95
- };
96
- mcpProcess.stdout.on('data', onData);
97
- mcpProcess.stdout.once('error', onError);
98
- mcpProcess.stdin.write(`${request}\n`);
99
- });
100
- }
101
33
  /**
102
- * Read the Notion token from Claude Code's config file as a fallback.
34
+ * Read the Notion token from the user's Claude Code config as a fallback.
35
+ * Picks the first enabled `mcpServers` entry named `notion` (disabled entries
36
+ * are skipped). Returns an empty string when none is found.
103
37
  */
104
- function readNotionTokenFromClaudeConfig() {
105
- try {
106
- const homedir = process.env.HOME ?? process.env.USERPROFILE ?? '';
107
- const configPath = `${homedir}/.claude.json`;
108
- const raw = readFileSync(configPath, 'utf-8');
109
- const config = JSON.parse(raw);
110
- const mcpServers = config.mcpServers;
111
- const notionServer = mcpServers?.notion;
112
- return notionServer?.env?.NOTION_TOKEN ?? notionServer?.env?.NOTION_API_TOKEN ?? '';
113
- }
114
- catch {
115
- return '';
38
+ function readNotionMcpEntryFromClaudeConfig(preferredKey) {
39
+ const normalizedPreferred = preferredKey?.trim();
40
+ const match = normalizedPreferred
41
+ ? readClaudeMcpEntry((k) => k === normalizedPreferred)
42
+ : readClaudeMcpEntry((k) => k === 'notion');
43
+ if (!match) {
44
+ if (normalizedPreferred) {
45
+ throw new Error(`Notion MCP key '${normalizedPreferred}' not found or disabled in ~/.claude.json (mcpServers section)`);
46
+ }
47
+ return null;
116
48
  }
49
+ return match;
117
50
  }
118
- function spawnMcpProcess() {
119
- const notionToken = process.env.NOTION_API_TOKEN ?? process.env.NOTION_TOKEN ?? readNotionTokenFromClaudeConfig();
120
- const mcpCommand = process.env.NOTION_MCP_COMMAND ?? 'npx';
121
- const mcpArgs = process.env.NOTION_MCP_ARGS
51
+ export function buildNotionMcpConfig(preferredKey) {
52
+ const configEntry = readNotionMcpEntryFromClaudeConfig(preferredKey);
53
+ const configEnv = configEntry?.entry.env ?? {};
54
+ const notionToken = process.env.NOTION_API_TOKEN ??
55
+ process.env.NOTION_TOKEN ??
56
+ configEnv.NOTION_TOKEN ??
57
+ configEnv.NOTION_API_TOKEN ??
58
+ '';
59
+ const command = process.env.NOTION_MCP_COMMAND ?? configEntry?.entry.command ?? 'npx';
60
+ const args = process.env.NOTION_MCP_ARGS
122
61
  ? process.env.NOTION_MCP_ARGS.split(' ')
123
- : ['-y', '@notionhq/notion-mcp-server'];
124
- const mcpProcess = spawn(mcpCommand, mcpArgs, {
125
- stdio: ['pipe', 'pipe', 'pipe'],
126
- env: {
127
- ...process.env,
128
- OPENAPI_MCP_HEADERS: JSON.stringify({
129
- Authorization: `Bearer ${notionToken}`,
130
- 'Notion-Version': '2022-06-28',
131
- }),
132
- },
133
- });
134
- mcpProcess.stderr?.on('data', (data) => {
135
- // Silently consume stderr to avoid cluttering logs
136
- const text = data.toString();
137
- if (process.env.DEBUG_NOTION_MCP) {
138
- console.error('[notion-mcp stderr]', text);
139
- }
140
- });
141
- return mcpProcess;
142
- }
143
- /** Initialize the MCP server by sending an initialize handshake (10s timeout). */
144
- async function initializeMcp(mcpProcess) {
145
- const id = nextRpcId();
146
- const request = JSON.stringify({
147
- jsonrpc: '2.0',
148
- id,
149
- method: 'initialize',
150
- params: {
151
- protocolVersion: '2024-11-05',
152
- capabilities: {},
153
- clientInfo: { name: 'kobo', version: getPackageVersion() },
154
- },
155
- });
156
- await new Promise((resolve, reject) => {
157
- if (!mcpProcess.stdin || !mcpProcess.stdout) {
158
- reject(new Error('MCP process not ready'));
159
- return;
160
- }
161
- let buffer = '';
162
- const timeout = setTimeout(() => {
163
- mcpProcess.stdout?.removeListener('data', onData);
164
- mcpProcess.kill();
165
- reject(new Error('initializeMcp timed out after 10s'));
166
- }, 10_000);
167
- const onData = (chunk) => {
168
- buffer += chunk.toString();
169
- const lines = buffer.split('\n');
170
- buffer = lines.pop() ?? '';
171
- for (const line of lines) {
172
- const trimmed = line.trim();
173
- if (!trimmed)
174
- continue;
175
- try {
176
- const parsed = JSON.parse(trimmed);
177
- if (parsed.id === id) {
178
- clearTimeout(timeout);
179
- mcpProcess.stdout?.removeListener('data', onData);
180
- const initialized = JSON.stringify({
181
- jsonrpc: '2.0',
182
- method: 'notifications/initialized',
183
- });
184
- mcpProcess.stdin?.write(`${initialized}\n`);
185
- resolve();
186
- }
187
- }
188
- catch {
189
- // ignore
190
- }
191
- }
192
- };
193
- const onError = (err) => {
194
- clearTimeout(timeout);
195
- mcpProcess.stdout?.removeListener('data', onData);
196
- reject(err);
197
- };
198
- mcpProcess.stdout.on('data', onData);
199
- mcpProcess.stdout.once('error', onError);
200
- mcpProcess.stdin.write(`${request}\n`);
201
- });
202
- }
203
- /**
204
- * Unwrap MCP tool response.
205
- * MCP returns { content: [{ type: "text", text: "..." }] }
206
- * where text is a JSON-stringified API response.
207
- */
208
- function unwrapMcpResult(result) {
209
- if (result && typeof result === 'object') {
210
- const obj = result;
211
- if (Array.isArray(obj.content)) {
212
- const first = obj.content[0];
213
- if (first?.type === 'text' && first.text) {
214
- try {
215
- return JSON.parse(first.text);
216
- }
217
- catch {
218
- return first.text;
219
- }
220
- }
221
- }
62
+ : (configEntry?.entry.args ?? ['-y', '@notionhq/notion-mcp-server']);
63
+ const env = {
64
+ ...process.env,
65
+ ...configEnv,
66
+ };
67
+ if (!env.OPENAPI_MCP_HEADERS && notionToken) {
68
+ env.OPENAPI_MCP_HEADERS = JSON.stringify({
69
+ Authorization: `Bearer ${notionToken}`,
70
+ 'Notion-Version': '2022-06-28',
71
+ });
222
72
  }
223
- return result;
73
+ return { command, args, env };
74
+ }
75
+ function spawnNotionMcp(preferredKey) {
76
+ const { command, args, env } = buildNotionMcpConfig(preferredKey);
77
+ return spawnMcpProcess(command, args, env);
224
78
  }
225
79
  function extractTextFromRichText(richText) {
226
80
  if (!Array.isArray(richText))
@@ -340,7 +194,8 @@ export function parseBlocks(blocks) {
340
194
  */
341
195
  export async function extractNotionPage(notionUrl) {
342
196
  const pageId = parseNotionUrl(notionUrl);
343
- const mcpProcess = spawnMcpProcess();
197
+ const global = getGlobalSettings();
198
+ const mcpProcess = spawnNotionMcp(global.notionMcpKey);
344
199
  // Give the process a moment to start
345
200
  await new Promise((resolve, reject) => {
346
201
  const timeout = setTimeout(() => resolve(), 1000);
@@ -405,7 +260,8 @@ export async function extractNotionPage(notionUrl) {
405
260
  /** Update a status property on a Notion page. Best-effort, does not throw. */
406
261
  export async function updateNotionStatus(notionUrl, propertyName, statusValue) {
407
262
  const pageId = parseNotionUrl(notionUrl);
408
- const mcpProcess = spawnMcpProcess();
263
+ const global = getGlobalSettings();
264
+ const mcpProcess = spawnNotionMcp(global.notionMcpKey);
409
265
  try {
410
266
  await new Promise((resolve, reject) => {
411
267
  const timeout = setTimeout(() => resolve(), 1000);