From 6baead1d7f4376e6fbefe0ea3b168644e883c280 Mon Sep 17 00:00:00 2001 From: oritwoen <18102267+oritwoen@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:58:15 +0100 Subject: [PATCH] feat(claude-code): add MCP server and hook handler --- build.config.ts | 9 +- claude-code/hooks.mjs | 7 + claude-code/index.d.mts | 9 + claude-code/index.mjs | 7 + claude-code/package.json | 35 ++ package.json | 17 +- pnpm-lock.yaml | 721 ++++++++++++++++++++++++++++++++++++++- src/claude-code-hooks.ts | 211 ++++++++++++ src/claude-code.ts | 509 +++++++++++++++++++++++++++ src/opencode.ts | 37 +- src/shared.ts | 36 ++ test/claude-code.test.ts | 329 ++++++++++++++++++ 12 files changed, 1884 insertions(+), 43 deletions(-) create mode 100644 claude-code/hooks.mjs create mode 100644 claude-code/index.d.mts create mode 100644 claude-code/index.mjs create mode 100644 claude-code/package.json create mode 100644 src/claude-code-hooks.ts create mode 100644 src/claude-code.ts create mode 100644 src/shared.ts create mode 100644 test/claude-code.test.ts diff --git a/build.config.ts b/build.config.ts index b993907..5178eec 100644 --- a/build.config.ts +++ b/build.config.ts @@ -8,7 +8,14 @@ export default defineBuildConfig({ entries: [ { type: "bundle", - input: ["./src/index.ts", "./src/cli.ts", "./src/ai.ts", "./src/opencode.ts"], + input: [ + "./src/index.ts", + "./src/cli.ts", + "./src/ai.ts", + "./src/opencode.ts", + "./src/claude-code.ts", + "./src/claude-code-hooks.ts", + ], }, ], }); diff --git a/claude-code/hooks.mjs b/claude-code/hooks.mjs new file mode 100644 index 0000000..cdf93b6 --- /dev/null +++ b/claude-code/hooks.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runHookCli } from "obsxa/claude-code-hooks"; + +runHookCli(process.argv.slice(2)).catch((err) => { + console.error("[obsxa] Hook error:", err); + process.exit(1); +}); diff --git a/claude-code/index.d.mts b/claude-code/index.d.mts new file mode 100644 index 0000000..36ac2ab --- /dev/null +++ b/claude-code/index.d.mts @@ -0,0 +1,9 @@ +export { registerTools, startMcpServer } from "../src/claude-code.ts"; +export { + handleHookEvent, + handlePostToolUse, + handleSessionStart, + handleStop, + runHookCli, +} from "../src/claude-code-hooks.ts"; +export type { HookInput } from "../src/claude-code-hooks.ts"; diff --git a/claude-code/index.mjs b/claude-code/index.mjs new file mode 100644 index 0000000..d997a36 --- /dev/null +++ b/claude-code/index.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { startMcpServer } from "obsxa/claude-code"; + +startMcpServer(process.argv.slice(2)).catch((err) => { + console.error("[obsxa] Fatal:", err); + process.exit(1); +}); diff --git a/claude-code/package.json b/claude-code/package.json new file mode 100644 index 0000000..c22ea05 --- /dev/null +++ b/claude-code/package.json @@ -0,0 +1,35 @@ +{ + "name": "obsxa-claude-code", + "version": "0.0.3", + "description": "Claude Code plugin wrapper package for obsxa (MCP server + hooks)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/oritwoen/obsxa.git" + }, + "bin": { + "obsxa-claude-code": "./index.mjs", + "obsxa-hooks": "./hooks.mjs", + "obsxa-mcp": "./index.mjs" + }, + "files": [ + "index.mjs", + "hooks.mjs", + "index.d.mts" + ], + "type": "module", + "main": "./index.mjs", + "types": "./index.d.mts", + "exports": { + ".": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "./hooks": { + "default": "./hooks.mjs" + } + }, + "dependencies": { + "obsxa": "0.0.3" + } +} diff --git a/package.json b/package.json index 362f2bf..f1cf944 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "url": "git+https://github.com/oritwoen/obsxa.git" }, "bin": { - "obsxa": "./dist/cli.mjs" + "obsxa": "./dist/cli.mjs", + "obsxa-hooks": "./dist/claude-code-hooks.mjs", + "obsxa-mcp": "./dist/claude-code.mjs" }, "files": [ "dist", @@ -33,6 +35,14 @@ "./opencode": { "types": "./dist/opencode.d.mts", "import": "./dist/opencode.mjs" + }, + "./claude-code": { + "types": "./dist/claude-code.d.mts", + "import": "./dist/claude-code.mjs" + }, + "./claude-code-hooks": { + "types": "./dist/claude-code-hooks.d.mts", + "import": "./dist/claude-code-hooks.mjs" } }, "scripts": { @@ -55,6 +65,7 @@ "drizzle-orm": "^0.44.0" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", "@opencode-ai/plugin": "1.2.24", "@types/node": "^25.3.0", "@typescript/native-preview": "7.0.0-dev.20260310.1", @@ -69,11 +80,15 @@ "zod": "^4.3.6" }, "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0", "@opencode-ai/plugin": "*", "ai": ">=6.0.0", "zod": ">=4.0.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, "@opencode-ai/plugin": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65827d5..0be9998 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: specifier: ^0.44.0 version: 0.44.7(@libsql/client@0.17.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) '@opencode-ai/plugin': specifier: 1.2.24 version: 1.2.24 @@ -564,6 +567,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -634,6 +643,16 @@ packages: cpu: [x64] os: [win32] + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -1235,12 +1254,27 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + ai@6.0.116: resolution: {integrity: sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + array-find-index@1.0.2: resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} engines: {node: '>=0.10.0'} @@ -1268,6 +1302,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1278,6 +1316,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} peerDependencies: @@ -1306,6 +1348,14 @@ packages: magicast: optional: true + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1337,12 +1387,36 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-gitmoji@0.1.5: resolution: {integrity: sha512-4wqOafJdk2tqZC++cjcbGcaJ13BZ3kwldf06PTiAQRAB76Z1KJwZNL1SaRZMi2w1FM9RYTgZ6QErS8NUl/GBmQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -1379,6 +1453,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -1499,12 +1577,35 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -1525,13 +1626,24 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1540,9 +1652,25 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1559,10 +1687,22 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -1571,6 +1711,17 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} @@ -1581,6 +1732,30 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1590,6 +1765,14 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1600,14 +1783,23 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -1616,6 +1808,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -1630,6 +1828,26 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -1658,6 +1876,10 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-abi@3.87.0: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} @@ -1688,6 +1910,14 @@ packages: engines: {node: '>=18'} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1701,6 +1931,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1727,6 +1961,17 @@ packages: resolution: {integrity: sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA==} engines: {node: '>=12'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1740,6 +1985,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -1760,9 +2009,25 @@ packages: promise-limit@2.7.0: resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -1781,6 +2046,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1819,6 +2088,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -1826,6 +2099,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -1834,6 +2110,41 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1878,6 +2189,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1914,6 +2229,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -1923,6 +2242,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1934,9 +2257,17 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2021,6 +2352,11 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2045,6 +2381,11 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} @@ -2343,6 +2684,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2419,6 +2764,28 @@ snapshots: '@libsql/win32-x64-msvc@0.5.22': optional: true + '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.7) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 + jose: 6.2.1 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -2782,6 +3149,11 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + ai@6.0.116(zod@4.3.6): dependencies: '@ai-sdk/gateway': 3.0.66(zod@4.3.6) @@ -2790,6 +3162,17 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 4.3.6 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + array-find-index@1.0.2: {} assertion-error@2.0.1: {} @@ -2823,6 +3206,20 @@ snapshots: readable-stream: 3.6.2 optional: true + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + buffer-from@1.1.2: {} buffer@5.7.1: @@ -2835,6 +3232,8 @@ snapshots: dependencies: run-applescript: 7.1.0 + bytes@3.1.2: {} + c12@3.3.3: dependencies: chokidar: 5.0.0 @@ -2864,6 +3263,16 @@ snapshots: giget: 2.0.0 jiti: 2.6.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@6.2.2: {} changelogen@0.6.2: @@ -2903,14 +3312,33 @@ snapshots: consola@3.4.2: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-gitmoji@0.1.5: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-fetch@4.1.0: dependencies: node-fetch: 2.7.0 transitivePeerDependencies: - encoding + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + data-uri-to-buffer@4.0.1: {} debug@4.4.3: @@ -2936,6 +3364,8 @@ snapshots: defu@6.1.4: {} + depd@2.0.0: {} + destr@2.0.5: {} detect-libc@2.0.2: {} @@ -2963,13 +3393,31 @@ snapshots: dts-resolver@2.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 optional: true + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild-register@3.6.0(esbuild@0.25.12): dependencies: debug: 4.4.3 @@ -3060,19 +3508,69 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + eventsource-parser@3.0.6: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + expand-template@2.0.3: optional: true expect-type@1.3.0: {} + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -3085,16 +3583,51 @@ snapshots: file-uri-to-path@1.0.0: optional: true + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-constants@1.0.0: optional: true fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -3111,31 +3644,66 @@ snapshots: github-from-package@0.0.0: optional: true + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.7: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: optional: true - inherits@2.0.4: - optional: true + inherits@2.0.4: {} ini@1.3.8: optional: true + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + is-docker@3.0.0: {} is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 + is-promise@4.0.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 + isexe@2.0.0: {} + jiti@2.6.1: {} + jose@6.2.1: {} + js-base64@3.7.8: {} jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} libsql@0.5.22: @@ -3159,6 +3727,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-response@3.1.0: optional: true @@ -3179,6 +3759,8 @@ snapshots: napi-build-utils@2.0.0: optional: true + negotiator@1.0.0: {} + node-abi@3.87.0: dependencies: semver: 7.7.4 @@ -3204,6 +3786,10 @@ snapshots: pathe: 2.0.3 tinyexec: 1.0.2 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.1: {} obuild@0.4.32(@typescript/native-preview@7.0.0-dev.20260310.1)(chokidar@5.0.0)(dotenv@17.3.1)(giget@2.0.0)(jiti@2.6.1)(picomatch@4.0.3)(rollup@4.59.0)(typescript@5.9.3): @@ -3241,10 +3827,13 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 - optional: true open@10.2.0: dependencies: @@ -3301,6 +3890,12 @@ snapshots: package-name-regex@2.0.6: {} + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -3309,6 +3904,8 @@ snapshots: picomatch@4.0.3: {} + pkce-challenge@5.0.1: {} + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -3341,12 +3938,30 @@ snapshots: promise-limit@2.7.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 optional: true + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -3374,6 +3989,8 @@ snapshots: readdirp@5.0.0: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.22.4(@typescript/native-preview@7.0.0-dev.20260310.1)(rolldown@1.0.0-rc.8)(typescript@5.9.3): @@ -3460,15 +4077,88 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} safe-buffer@5.2.1: optional: true + safer-buffer@2.1.2: {} + scule@1.3.0: {} semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} simple-concat@1.0.1: @@ -3519,6 +4209,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} string_decoder@1.3.0: @@ -3559,6 +4251,8 @@ snapshots: tinyrainbow@3.0.3: {} + toidentifier@1.0.1: {} + tr46@0.0.3: {} tslib@2.8.1: @@ -3569,15 +4263,25 @@ snapshots: safe-buffer: 5.2.1 optional: true + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} undici-types@7.18.2: {} + unpipe@1.0.0: {} + util-deprecate@1.0.2: optional: true + vary@1.1.2: {} + vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1): dependencies: esbuild: 0.27.3 @@ -3638,13 +4342,16 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - wrappy@1.0.2: - optional: true + wrappy@1.0.2: {} ws@8.19.0: {} @@ -3652,6 +4359,10 @@ snapshots: dependencies: is-wsl: 3.1.1 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.1.8: {} zod@4.3.6: {} diff --git a/src/claude-code-hooks.ts b/src/claude-code-hooks.ts new file mode 100644 index 0000000..42ed7f1 --- /dev/null +++ b/src/claude-code-hooks.ts @@ -0,0 +1,211 @@ +import { parseArgs } from "node:util"; +import { getDefaultDbPath } from "./core/db-path.ts"; +import { createObsxa } from "./index.ts"; +import type { ObsxaInstance } from "./index.ts"; +import { computeInputHash, isSqliteConstraintError } from "./shared.ts"; + +const SKIP_TOOLS = new Set(["Read", "Grep", "Glob", "LSP", "ToolSearch"]); + +export interface HookInput { + tool_name?: string; + tool_input?: unknown; + session_id?: string; + [key: string]: unknown; +} + +async function findByInputHash( + obsxa: ObsxaInstance, + projectId: string, + hash: string, +): Promise { + const found = await obsxa.observation.getByInputHash(projectId, hash); + return found?.id; +} + +export async function handlePostToolUse( + obsxa: ObsxaInstance, + projectId: string, + input: HookInput, +): Promise { + const toolName = input.tool_name; + if (typeof toolName !== "string" || toolName.length === 0) return; + if (SKIP_TOOLS.has(toolName)) return; + + const toolInput = input.tool_input; + const title = `Tool: ${toolName}`.slice(0, 200); + const collector = "claude-code:PostToolUse"; + const dedupPayload = `${toolName}\n${JSON.stringify(toolInput ?? "")}`; + const hash = computeInputHash(dedupPayload, collector, projectId); + + const existingId = await findByInputHash(obsxa, projectId, hash); + if (existingId !== undefined) { + await obsxa.observation.incrementFrequency(existingId); + return; + } + + const sessionId = typeof input.session_id === "string" ? input.session_id : "unknown"; + await obsxa.observation.add({ + projectId, + title, + description: toolInput ? JSON.stringify(toolInput).slice(0, 500) : undefined, + type: "measurement", + source: toolName, + sourceType: "computation", + collector, + sourceRef: `session:${sessionId}:tool:${toolName}`, + inputHash: hash, + context: JSON.stringify({ + tool_name: toolName, + session_id: sessionId, + }), + }); +} + +export async function handleSessionStart( + obsxa: ObsxaInstance, + projectId: string, + input: HookInput, +): Promise { + const sessionId = typeof input.session_id === "string" ? input.session_id : "unknown"; + const collector = "claude-code:SessionStart"; + const title = `Session started: ${sessionId}`.slice(0, 200); + const hash = computeInputHash(`session:${sessionId}`, collector, projectId); + + const existingId = await findByInputHash(obsxa, projectId, hash); + if (existingId !== undefined) { + await obsxa.observation.incrementFrequency(existingId); + return; + } + + await obsxa.observation.add({ + projectId, + title, + type: "pattern", + source: sessionId, + sourceType: "external", + collector, + sourceRef: `session:${sessionId}`, + inputHash: hash, + context: JSON.stringify({ session_id: sessionId }), + }); +} + +export async function handleStop( + obsxa: ObsxaInstance, + projectId: string, + input: HookInput, +): Promise { + const sessionId = typeof input.session_id === "string" ? input.session_id : "unknown"; + const collector = "claude-code:Stop"; + + const stdinText = JSON.stringify(input); + const title = `Response completed: ${sessionId}`.slice(0, 200); + const hash = computeInputHash(stdinText, collector, projectId); + + const existingId = await findByInputHash(obsxa, projectId, hash); + if (existingId !== undefined) { + await obsxa.observation.incrementFrequency(existingId); + return; + } + + await obsxa.observation.add({ + projectId, + title, + type: "pattern", + source: sessionId, + sourceType: "external", + collector, + sourceRef: `session:${sessionId}`, + inputHash: hash, + context: JSON.stringify({ session_id: sessionId }), + }); +} + +export async function handleHookEvent( + event: string, + input: HookInput, + dbPath: string, + projectId: string, +): Promise { + const obsxa = await createObsxa({ db: dbPath }); + try { + try { + await obsxa.project.add({ id: projectId, name: projectId }); + } catch (error) { + if (!isSqliteConstraintError(error)) throw error; + } + + switch (event) { + case "PostToolUse": + await handlePostToolUse(obsxa, projectId, input); + break; + case "SessionStart": + await handleSessionStart(obsxa, projectId, input); + break; + case "Stop": + await handleStop(obsxa, projectId, input); + break; + default: + console.error(`[obsxa] Unknown hook event: ${event}`); + } + } finally { + await obsxa.close(); + } +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk: string) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", reject); + }); +} + +export async function runHookCli(args: string[]): Promise { + const { values } = parseArgs({ + args, + options: { + db: { type: "string" }, + project: { type: "string", default: "default" }, + event: { type: "string" }, + }, + strict: false, + }); + + const event = values.event as string | undefined; + if (!event) { + console.error("[obsxa] --event is required"); + process.exit(1); + } + + const dbPath = (values.db as string | undefined) ?? getDefaultDbPath(); + const projectId = (values.project as string | undefined) ?? "default"; + + let input: HookInput = {}; + try { + const raw = await readStdin(); + if (raw.trim().length > 0) { + input = JSON.parse(raw); + } + } catch { + // stdin may be empty or non-JSON + } + + await handleHookEvent(event, input, dbPath, projectId); +} + +const isMain = + typeof process !== "undefined" && + process.argv[1] && + import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")); + +if (isMain) { + runHookCli(process.argv.slice(2)).catch((err) => { + console.error("[obsxa] Hook error:", err); + process.exit(1); + }); +} diff --git a/src/claude-code.ts b/src/claude-code.ts new file mode 100644 index 0000000..e5f354e --- /dev/null +++ b/src/claude-code.ts @@ -0,0 +1,509 @@ +import { parseArgs } from "node:util"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod/v4"; +import { getDefaultDbPath } from "./core/db-path.ts"; +import { createObsxa } from "./index.ts"; +import type { ObsxaInstance } from "./index.ts"; +import { isSqliteConstraintError } from "./shared.ts"; + +const OBSERVATION_TYPES = ["pattern", "anomaly", "measurement", "correlation", "artifact"] as const; +const SOURCE_TYPES = ["experiment", "manual", "scan", "computation", "external"] as const; +const STATUS_VALUES = ["active", "promoted", "dismissed", "archived"] as const; +const REASON_CODES = [ + "noise", + "duplicate", + "merged", + "irrelevant", + "invalid", + "manual_review", +] as const; +const RELATION_TYPES = [ + "similar_to", + "contradicts", + "supports", + "derived_from", + "duplicate_of", + "refines", + "same_signal_as", +] as const; + +function textResult(data: unknown): { content: Array<{ type: "text"; text: string }> } { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +function errorResult(message: string): { + content: Array<{ type: "text"; text: string }>; + isError: true; +} { + return { content: [{ type: "text", text: message }], isError: true }; +} + +export function registerTools( + server: McpServer, + obsxa: ObsxaInstance, + defaultProjectId: string, +): void { + const projectOr = (input: string | undefined): string => + input && input.length > 0 ? input : defaultProjectId; + + server.tool( + "obsxa_observation", + "Manage observations: add/get/list/update/dismiss/archive/bump/transitions/edits/import/batchUpdate", + { + operation: z.enum([ + "add", + "get", + "list", + "update", + "dismiss", + "archive", + "bump", + "transitions", + "edits", + "import", + "batchUpdate", + ]), + projectId: z.string().optional(), + id: z.number().optional(), + title: z.string().optional(), + description: z.string().optional(), + type: z.enum(OBSERVATION_TYPES).optional(), + source: z.string().optional(), + sourceType: z.enum(SOURCE_TYPES).optional(), + confidence: z.number().optional(), + tags: z.string().optional(), + data: z.string().optional(), + context: z.string().optional(), + capturedAt: z.string().optional(), + sourceRef: z.string().optional(), + collector: z.string().optional(), + inputHash: z.string().optional(), + evidenceStrength: z.number().optional(), + novelty: z.number().optional(), + uncertainty: z.number().optional(), + reproducibilityHint: z.string().optional(), + status: z.enum(STATUS_VALUES).optional(), + reasonCode: z.enum(REASON_CODES).optional(), + reasonNote: z.string().optional(), + records: z.string().optional(), + }, + async (args) => { + const pid = projectOr(args.projectId); + const parseTags = (s?: string): string[] | undefined => { + if (!s) return undefined; + try { + const parsed = JSON.parse(s); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } + }; + + switch (args.operation) { + case "add": { + if (!args.title) return errorResult("'title' is required for add"); + if (!args.source) return errorResult("'source' is required for add"); + const result = await obsxa.observation.add({ + projectId: pid, + title: args.title, + description: args.description, + type: args.type, + source: args.source, + sourceType: args.sourceType, + confidence: args.confidence, + tags: parseTags(args.tags), + data: args.data, + context: args.context, + capturedAt: args.capturedAt, + sourceRef: args.sourceRef, + collector: args.collector, + inputHash: args.inputHash, + evidenceStrength: args.evidenceStrength, + novelty: args.novelty, + uncertainty: args.uncertainty, + reproducibilityHint: args.reproducibilityHint, + }); + return textResult(result); + } + case "get": { + if (args.id === undefined) return errorResult("'id' is required for get"); + const obs = await obsxa.observation.get(args.id); + return textResult(obs); + } + case "list": { + const result = await obsxa.observation.list(pid, { + status: args.status, + type: args.type, + sourceType: args.sourceType, + }); + return textResult(result); + } + case "update": { + if (args.id === undefined) return errorResult("'id' is required for update"); + const result = await obsxa.observation.update(args.id, { + title: args.title, + description: args.description, + type: args.type, + source: args.source, + sourceType: args.sourceType, + confidence: args.confidence, + tags: parseTags(args.tags), + data: args.data, + context: args.context, + capturedAt: args.capturedAt, + sourceRef: args.sourceRef, + collector: args.collector, + inputHash: args.inputHash, + evidenceStrength: args.evidenceStrength, + novelty: args.novelty, + uncertainty: args.uncertainty, + reproducibilityHint: args.reproducibilityHint, + }); + return textResult(result); + } + case "dismiss": { + if (args.id === undefined) return errorResult("'id' is required for dismiss"); + if (!args.reasonCode) return errorResult("'reasonCode' is required for dismiss"); + const result = await obsxa.observation.dismiss(args.id, { + reasonCode: args.reasonCode, + reasonNote: args.reasonNote, + }); + return textResult(result); + } + case "archive": { + if (args.id === undefined) return errorResult("'id' is required for archive"); + if (!args.reasonCode) return errorResult("'reasonCode' is required for archive"); + const result = await obsxa.observation.archive(args.id, { + reasonCode: args.reasonCode, + reasonNote: args.reasonNote, + }); + return textResult(result); + } + case "bump": { + if (args.id === undefined) return errorResult("'id' is required for bump"); + const result = await obsxa.observation.incrementFrequency(args.id); + return textResult(result); + } + case "transitions": { + if (args.id === undefined) return errorResult("'id' is required for transitions"); + const result = await obsxa.observation.transitions(args.id); + return textResult(result); + } + case "edits": { + if (args.id === undefined) return errorResult("'id' is required for edits"); + const result = await obsxa.observation.edits(args.id); + return textResult(result); + } + case "import": { + if (!args.records) return errorResult("'records' (JSON string) is required for import"); + let parsed: unknown[]; + try { + parsed = JSON.parse(args.records); + } catch { + return errorResult("'records' must be a valid JSON array"); + } + if (!Array.isArray(parsed)) return errorResult("'records' must be a JSON array"); + const result = await obsxa.observation.addMany( + parsed as Parameters[0], + ); + return textResult(result); + } + case "batchUpdate": { + if (!args.records) + return errorResult("'records' (JSON string) is required for batchUpdate"); + let parsed: unknown[]; + try { + parsed = JSON.parse(args.records); + } catch { + return errorResult("'records' must be a valid JSON array"); + } + if (!Array.isArray(parsed)) return errorResult("'records' must be a JSON array"); + const result = await obsxa.observation.updateMany( + parsed as Parameters[0], + ); + return textResult(result); + } + default: + return errorResult(`Unknown operation: ${args.operation}`); + } + }, + ); + + server.tool( + "obsxa_relation", + "Manage observation relations: add and list", + { + operation: z.enum(["add", "list"]), + fromObservationId: z.number().optional(), + toObservationId: z.number().optional(), + observationId: z.number().optional(), + type: z.enum(RELATION_TYPES).optional(), + confidence: z.number().optional(), + notes: z.string().optional(), + }, + async (args) => { + switch (args.operation) { + case "add": { + if (args.fromObservationId === undefined) + return errorResult("'fromObservationId' is required for add"); + if (args.toObservationId === undefined) + return errorResult("'toObservationId' is required for add"); + if (!args.type) return errorResult("'type' is required for add"); + const result = await obsxa.relation.add({ + fromObservationId: args.fromObservationId, + toObservationId: args.toObservationId, + type: args.type, + confidence: args.confidence, + notes: args.notes, + }); + return textResult(result); + } + case "list": { + if (args.observationId === undefined) + return errorResult("'observationId' is required for list"); + const result = await obsxa.relation.list(args.observationId); + return textResult(result); + } + default: + return errorResult(`Unknown operation: ${args.operation}`); + } + }, + ); + + server.tool( + "obsxa_cluster", + "Manage observation clusters: add, list, addMember, listMembers", + { + operation: z.enum(["add", "list", "addMember", "listMembers"]), + projectId: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + clusterId: z.number().optional(), + observationId: z.number().optional(), + }, + async (args) => { + const pid = projectOr(args.projectId); + switch (args.operation) { + case "add": { + if (!args.name) return errorResult("'name' is required for add"); + const result = await obsxa.cluster.add({ + projectId: pid, + name: args.name, + description: args.description, + }); + return textResult(result); + } + case "list": { + const result = await obsxa.cluster.list(pid); + return textResult(result); + } + case "addMember": { + if (args.clusterId === undefined) return errorResult("'clusterId' is required"); + if (args.observationId === undefined) return errorResult("'observationId' is required"); + const result = await obsxa.cluster.addMember(args.clusterId, args.observationId); + return textResult(result); + } + case "listMembers": { + if (args.clusterId === undefined) return errorResult("'clusterId' is required"); + const result = await obsxa.cluster.listMembers(args.clusterId); + return textResult(result); + } + default: + return errorResult(`Unknown operation: ${args.operation}`); + } + }, + ); + + server.tool( + "obsxa_search", + "Search observations via FTS or LIKE fallback", + { + query: z.string(), + projectId: z.string().optional(), + limit: z.number().optional(), + }, + async (args) => { + const result = await obsxa.search.search(args.query, args.projectId, args.limit); + return textResult(result); + }, + ); + + server.tool( + "obsxa_analysis", + "Run observation analyses: stats, frequent, isolated, convergent, promoted, unpromoted, triage", + { + operation: z.enum([ + "stats", + "frequent", + "isolated", + "convergent", + "promoted", + "unpromoted", + "triage", + ]), + projectId: z.string().optional(), + limit: z.number().optional(), + sort: z.enum(["triage", "recent"]).optional(), + }, + async (args) => { + const pid = projectOr(args.projectId); + switch (args.operation) { + case "stats": + return textResult(await obsxa.analysis.stats(pid)); + case "frequent": + return textResult(await obsxa.analysis.frequent(pid)); + case "isolated": + return textResult(await obsxa.analysis.isolated(pid)); + case "convergent": + return textResult(await obsxa.analysis.convergent(pid)); + case "promoted": + return textResult(await obsxa.analysis.promoted(pid)); + case "unpromoted": + return textResult(await obsxa.analysis.unpromoted(pid)); + case "triage": + return textResult(await obsxa.analysis.triage(pid, args.limit, args.sort)); + default: + return errorResult(`Unknown operation: ${args.operation}`); + } + }, + ); + + server.tool( + "obsxa_promote", + "Promote active observation to hypothesis candidate", + { + observationId: z.number(), + hypothesisRef: z.string(), + }, + async (args) => { + const result = await obsxa.observation.promote(args.observationId, args.hypothesisRef); + return textResult(result); + }, + ); + + server.tool( + "obsxa_dedup", + "Dedup workflow: scan/list/review candidates and merge duplicates", + { + operation: z.enum(["scan", "candidates", "review", "merge"]), + projectId: z.string().optional(), + threshold: z.number().optional(), + status: z.enum(["open", "resolved", "dismissed", "all"]).optional(), + candidateId: z.number().optional(), + reason: z.string().optional(), + primaryObservationId: z.number().optional(), + duplicateObservationId: z.number().optional(), + confidenceStrategy: z.enum(["primary", "max", "average"]).optional(), + relationType: z.enum(RELATION_TYPES).optional(), + relationConfidence: z.number().optional(), + relationNotes: z.string().optional(), + mergeDescription: z.enum(["primary", "duplicate", "concat"]).optional(), + }, + async (args) => { + const pid = projectOr(args.projectId); + switch (args.operation) { + case "scan": + return textResult(await obsxa.dedup.scan(pid, args.threshold)); + case "candidates": + return textResult( + await obsxa.dedup.candidates( + pid, + args.status as "open" | "resolved" | "dismissed" | "all" | undefined, + ), + ); + case "review": { + if (args.candidateId === undefined) + return errorResult("'candidateId' is required for review"); + if (!args.status || !["open", "resolved", "dismissed"].includes(args.status)) + return errorResult("'status' must be open/resolved/dismissed for review"); + if (!args.reason) return errorResult("'reason' is required for review"); + return textResult( + await obsxa.dedup.review( + args.candidateId, + args.status as "open" | "resolved" | "dismissed", + args.reason, + ), + ); + } + case "merge": { + if (args.primaryObservationId === undefined) + return errorResult("'primaryObservationId' is required for merge"); + if (args.duplicateObservationId === undefined) + return errorResult("'duplicateObservationId' is required for merge"); + return textResult( + await obsxa.dedup.merge(args.primaryObservationId, args.duplicateObservationId, { + confidenceStrategy: args.confidenceStrategy, + relationType: args.relationType, + relationConfidence: args.relationConfidence, + relationNotes: args.relationNotes, + mergeDescription: args.mergeDescription, + }), + ); + } + default: + return errorResult(`Unknown operation: ${args.operation}`); + } + }, + ); +} + +export async function startMcpServer(args: string[]): Promise { + const { values } = parseArgs({ + args, + options: { + db: { type: "string" }, + project: { type: "string", default: "default" }, + }, + strict: false, + }); + + const dbPath = (values.db as string | undefined) ?? getDefaultDbPath(); + const projectId = (values.project as string | undefined) ?? "default"; + + const obsxa = await createObsxa({ db: dbPath }); + + try { + await obsxa.project.add({ id: projectId, name: projectId }); + } catch (error) { + if (!isSqliteConstraintError(error)) { + await obsxa.close(); + throw error; + } + } + + const server = new McpServer({ + name: "obsxa", + version: "0.0.3", + }); + + registerTools(server, obsxa, projectId); + + const transport = new StdioServerTransport(); + const shutdown = async () => { + try { + await server.close(); + } catch {} + try { + await obsxa.close(); + } catch {} + }; + + process.on("SIGINT", () => void shutdown().then(() => process.exit(0))); + process.on("SIGTERM", () => void shutdown().then(() => process.exit(0))); + + await server.connect(transport); + console.error("[obsxa] MCP server started"); +} + +const isMain = + typeof process !== "undefined" && + process.argv[1] && + import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/")); + +if (isMain) { + startMcpServer(process.argv.slice(2)).catch((err) => { + console.error("[obsxa] Fatal:", err); + process.exit(1); + }); +} diff --git a/src/opencode.ts b/src/opencode.ts index 90d003e..2cf4bdb 100644 --- a/src/opencode.ts +++ b/src/opencode.ts @@ -1,7 +1,7 @@ -import { createHash } from "node:crypto"; import { getDefaultDbPath } from "./core/db-path.ts"; import { createObsxa } from "./index.ts"; import type { ObsxaInstance } from "./index.ts"; +import { computeInputHash, isSqliteConstraintError } from "./shared.ts"; export interface ObsxaPluginOptions { db?: string; @@ -158,12 +158,6 @@ function setCacheValue(cache: Map, key: string, value: T, maxSize: } } -function computeInputHash(payload: string, collector: string, projectId: string): string { - return createHash("sha256") - .update(JSON.stringify({ payload, collector, projectId })) - .digest("hex"); -} - async function findByHash( obsxa: ObsxaInstance, projectId: string, @@ -362,35 +356,6 @@ function logHookError(scope: string, err: unknown): void { console.warn(`[obsxa] ${scope} hook error`, err); } -function isSqliteConstraintError(error: unknown): boolean { - let current: unknown = error; - while (current) { - const obj = current as { - message?: unknown; - code?: unknown; - rawCode?: unknown; - extendedCode?: unknown; - cause?: unknown; - }; - const message = typeof obj.message === "string" ? obj.message : String(obj.message ?? ""); - const code = typeof obj.code === "string" ? obj.code : String(obj.code ?? ""); - const rawCode = String(obj.rawCode ?? ""); - const extendedCode = - typeof obj.extendedCode === "string" ? obj.extendedCode : String(obj.extendedCode ?? ""); - if ( - message.includes("UNIQUE constraint") || - message.includes("SQLITE_CONSTRAINT") || - code.includes("SQLITE_CONSTRAINT") || - extendedCode.includes("SQLITE_CONSTRAINT") || - rawCode === "1555" - ) { - return true; - } - current = obj.cause; - } - return false; -} - export function createObsxaPlugin(options?: ObsxaPluginOptions): Plugin { return async (input: PluginInput): Promise => { const db = options?.db ?? getDefaultDbPath(); diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..12c7743 --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,36 @@ +import { createHash } from "node:crypto"; + +export function computeInputHash(payload: string, collector: string, projectId: string): string { + return createHash("sha256") + .update(JSON.stringify({ payload, collector, projectId })) + .digest("hex"); +} + +export function isSqliteConstraintError(error: unknown): boolean { + let current: unknown = error; + while (current) { + const obj = current as { + message?: unknown; + code?: unknown; + rawCode?: unknown; + extendedCode?: unknown; + cause?: unknown; + }; + const message = typeof obj.message === "string" ? obj.message : String(obj.message ?? ""); + const code = typeof obj.code === "string" ? obj.code : String(obj.code ?? ""); + const rawCode = String(obj.rawCode ?? ""); + const extendedCode = + typeof obj.extendedCode === "string" ? obj.extendedCode : String(obj.extendedCode ?? ""); + if ( + message.includes("UNIQUE constraint") || + message.includes("SQLITE_CONSTRAINT") || + code.includes("SQLITE_CONSTRAINT") || + extendedCode.includes("SQLITE_CONSTRAINT") || + rawCode === "1555" + ) { + return true; + } + current = obj.cause; + } + return false; +} diff --git a/test/claude-code.test.ts b/test/claude-code.test.ts new file mode 100644 index 0000000..75fecc0 --- /dev/null +++ b/test/claude-code.test.ts @@ -0,0 +1,329 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { registerTools } from "../src/claude-code.ts"; +import { + handlePostToolUse, + handleSessionStart, + handleStop, + handleHookEvent, +} from "../src/claude-code-hooks.ts"; +import { createObsxa } from "../src/index.ts"; +import type { ObsxaInstance } from "../src/index.ts"; + +describe("MCP server tool registration", () => { + let dbDir: string; + let dbPath: string; + let obsxa: ObsxaInstance; + + beforeEach(async () => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-mcp-")); + dbPath = join(dbDir, "test.db"); + obsxa = await createObsxa({ db: dbPath }); + await obsxa.project.add({ id: "test-project", name: "test-project" }); + }); + + afterEach(async () => { + await obsxa.close(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("registers 7 tools on McpServer", () => { + const server = new McpServer({ name: "obsxa-test", version: "0.0.1" }); + registerTools(server, obsxa, "test-project"); + + const registered = (server as unknown as { _registeredTools: Record }) + ._registeredTools; + const toolNames = Object.keys(registered); + expect(toolNames).toHaveLength(7); + expect(toolNames).toContain("obsxa_observation"); + expect(toolNames).toContain("obsxa_relation"); + expect(toolNames).toContain("obsxa_cluster"); + expect(toolNames).toContain("obsxa_search"); + expect(toolNames).toContain("obsxa_analysis"); + expect(toolNames).toContain("obsxa_promote"); + expect(toolNames).toContain("obsxa_dedup"); + }); +}); + +describe("hook handler: PostToolUse", () => { + let dbDir: string; + let dbPath: string; + let obsxa: ObsxaInstance; + + beforeEach(async () => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-hook-post-")); + dbPath = join(dbDir, "test.db"); + obsxa = await createObsxa({ db: dbPath }); + await obsxa.project.add({ id: "test-project", name: "test-project" }); + }); + + afterEach(async () => { + await obsxa.close(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("creates measurement observation for Bash tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "Bash", + tool_input: { command: "ls -la" }, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].type).toBe("measurement"); + expect(obs[0].collector).toBe("claude-code:PostToolUse"); + expect(obs[0].title).toContain("Bash"); + }); + + it("creates measurement observation for Edit tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "Edit", + tool_input: { file: "test.ts" }, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].source).toBe("Edit"); + }); + + it("creates measurement observation for Write tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "Write", + tool_input: { file: "new.ts" }, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + }); + + it("skips Read tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "Read", + tool_input: {}, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(0); + }); + + it("skips Grep tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "Grep", + tool_input: {}, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(0); + }); + + it("skips Glob tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "Glob", + tool_input: {}, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(0); + }); + + it("skips LSP tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "LSP", + tool_input: {}, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(0); + }); + + it("skips ToolSearch tool", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "ToolSearch", + tool_input: {}, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(0); + }); + + it("dedup: same tool call twice bumps frequency", async () => { + const input = { + tool_name: "Bash", + tool_input: { command: "npm test" }, + session_id: "s1", + }; + + await handlePostToolUse(obsxa, "test-project", input); + await handlePostToolUse(obsxa, "test-project", input); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].frequency).toBe(2); + }); + + it("does not create observation for empty tool_name", async () => { + await handlePostToolUse(obsxa, "test-project", { + tool_name: "", + tool_input: {}, + session_id: "s1", + }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(0); + }); +}); + +describe("hook handler: SessionStart", () => { + let dbDir: string; + let dbPath: string; + let obsxa: ObsxaInstance; + + beforeEach(async () => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-hook-session-")); + dbPath = join(dbDir, "test.db"); + obsxa = await createObsxa({ db: dbPath }); + await obsxa.project.add({ id: "test-project", name: "test-project" }); + }); + + afterEach(async () => { + await obsxa.close(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("creates pattern observation", async () => { + await handleSessionStart(obsxa, "test-project", { session_id: "sess-1" }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].type).toBe("pattern"); + expect(obs[0].collector).toBe("claude-code:SessionStart"); + expect(obs[0].title).toContain("sess-1"); + }); + + it("dedup: same session start twice bumps frequency", async () => { + await handleSessionStart(obsxa, "test-project", { session_id: "sess-1" }); + await handleSessionStart(obsxa, "test-project", { session_id: "sess-1" }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].frequency).toBe(2); + }); +}); + +describe("hook handler: Stop", () => { + let dbDir: string; + let dbPath: string; + let obsxa: ObsxaInstance; + + beforeEach(async () => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-hook-stop-")); + dbPath = join(dbDir, "test.db"); + obsxa = await createObsxa({ db: dbPath }); + await obsxa.project.add({ id: "test-project", name: "test-project" }); + }); + + afterEach(async () => { + await obsxa.close(); + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("creates pattern observation", async () => { + await handleStop(obsxa, "test-project", { session_id: "sess-1" }); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].type).toBe("pattern"); + expect(obs[0].collector).toBe("claude-code:Stop"); + expect(obs[0].title).toContain("sess-1"); + }); + + it("dedup: same stop event bumps frequency", async () => { + const input = { session_id: "sess-1" }; + await handleStop(obsxa, "test-project", input); + await handleStop(obsxa, "test-project", input); + + const obs = await obsxa.observation.list("test-project"); + expect(obs).toHaveLength(1); + expect(obs[0].frequency).toBe(2); + }); +}); + +describe("handleHookEvent integration", () => { + let dbDir: string; + let dbPath: string; + + beforeEach(() => { + dbDir = mkdtempSync(join(tmpdir(), "obsxa-hook-int-")); + dbPath = join(dbDir, "test.db"); + }); + + afterEach(() => { + rmSync(dbDir, { recursive: true, force: true }); + }); + + it("PostToolUse creates observation via handleHookEvent", async () => { + await handleHookEvent( + "PostToolUse", + { tool_name: "Bash", tool_input: { command: "echo hi" }, session_id: "s1" }, + dbPath, + "test-project", + ); + + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].collector).toBe("claude-code:PostToolUse"); + }); + + it("SessionStart creates observation via handleHookEvent", async () => { + await handleHookEvent("SessionStart", { session_id: "sess-42" }, dbPath, "test-project"); + + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].collector).toBe("claude-code:SessionStart"); + }); + + it("Stop creates observation via handleHookEvent", async () => { + await handleHookEvent("Stop", { session_id: "sess-42" }, dbPath, "test-project"); + + const obsxa = await createObsxa({ db: dbPath }); + const obs = await obsxa.observation.list("test-project"); + await obsxa.close(); + expect(obs).toHaveLength(1); + expect(obs[0].collector).toBe("claude-code:Stop"); + }); + + it("creates project automatically", async () => { + await handleHookEvent("SessionStart", { session_id: "s1" }, dbPath, "auto-project"); + + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("auto-project"); + await obsxa.close(); + expect(project).not.toBeNull(); + expect(project?.id).toBe("auto-project"); + }); + + it("is idempotent for project creation", async () => { + await handleHookEvent("SessionStart", { session_id: "s1" }, dbPath, "test-project"); + await handleHookEvent("SessionStart", { session_id: "s2" }, dbPath, "test-project"); + + const obsxa = await createObsxa({ db: dbPath }); + const project = await obsxa.project.get("test-project"); + await obsxa.close(); + expect(project?.id).toBe("test-project"); + }); +});