@theronap/cortex-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cortex-mcp.mjs +38 -85
- package/lib/capture.mjs +62 -0
- package/lib/server.mjs +75 -0
- package/lib/setup.mjs +112 -0
- package/package.json +3 -2
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -2,103 +2,56 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* cortex-mcp — connect your AI assistant to Cortex.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* (none) run the MCP server (stdio) — used by your Claude config
|
|
7
|
+
* setup <TOKEN> wire BOTH the MCP server + capture hook into your Claude config
|
|
8
|
+
* capture the Stop-hook capturer (invoked by Claude Code, not by hand)
|
|
9
|
+
* --version | -v
|
|
10
|
+
* --help | -h
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* "args": ["-y", "cortex-mcp"],
|
|
10
|
-
* "env": { "CORTEX_TOKEN": "<your-personal-token>" }
|
|
11
|
-
* }
|
|
12
|
+
* Zero-install onboarding:
|
|
13
|
+
* npx -y @theronap/cortex-mcp setup <your-token>
|
|
12
14
|
*
|
|
13
|
-
* Get your
|
|
14
|
-
*
|
|
15
|
-
* Env:
|
|
16
|
-
* CORTEX_TOKEN (required) your personal token — identifies you + your org
|
|
17
|
-
* CORTEX_URL (optional) defaults to https://cortex-console.vercel.app
|
|
15
|
+
* Get your token from the Cortex console → Connect your AI.
|
|
18
16
|
*/
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const VERSION = '0.2.0'
|
|
19
|
+
const cmd = process.argv[2]
|
|
20
|
+
const rest = process.argv.slice(3)
|
|
21
|
+
|
|
22
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
23
|
+
process.stdout.write(`cortex-mcp ${VERSION}\n`)
|
|
24
|
+
process.exit(0)
|
|
25
|
+
}
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
const arg = process.argv[2]
|
|
26
|
-
if (arg === '--version' || arg === '-v') { process.stdout.write(`cortex-mcp ${VERSION}\n`); process.exit(0) }
|
|
27
|
-
if (arg === '--help' || arg === '-h') {
|
|
27
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
28
28
|
process.stdout.write(
|
|
29
29
|
`cortex-mcp ${VERSION} — connect your AI assistant to Cortex\n\n` +
|
|
30
|
-
`
|
|
31
|
-
`
|
|
32
|
-
`Claude
|
|
33
|
-
`
|
|
34
|
-
`
|
|
30
|
+
`Onboard (one command):\n` +
|
|
31
|
+
` npx -y @theronap/cortex-mcp setup <YOUR_TOKEN>\n\n` +
|
|
32
|
+
`This wires your Claude config so your AI sees your Cortex context and your\n` +
|
|
33
|
+
`sessions flow into the org automatically. Restart Claude Code after.\n\n` +
|
|
34
|
+
`Subcommands:\n` +
|
|
35
|
+
` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
|
|
36
|
+
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
37
|
+
` (no args) run the MCP server (used by your Claude config)\n\n` +
|
|
38
|
+
`Get your token from the Cortex console → Connect your AI.\n`,
|
|
35
39
|
)
|
|
36
40
|
process.exit(0)
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Cortex console → Connect your AI.\n')
|
|
44
|
-
process.exit(1)
|
|
43
|
+
if (cmd === 'setup') {
|
|
44
|
+
const { runSetup } = await import('../lib/setup.mjs')
|
|
45
|
+
runSetup(rest)
|
|
46
|
+
process.exit(0)
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
|
|
52
|
-
const res = await fetch(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
53
|
-
if (!res.ok) {
|
|
54
|
-
let detail = 'unknown'
|
|
55
|
-
try { detail = (await res.json()).error ?? detail } catch { /* ignore */ }
|
|
56
|
-
throw new Error(`Cortex API ${res.status}: ${detail}`)
|
|
57
|
-
}
|
|
58
|
-
const { context } = await res.json()
|
|
59
|
-
cache = { text: context, ts: now }
|
|
60
|
-
return context
|
|
49
|
+
if (cmd === 'capture') {
|
|
50
|
+
const { runCapture } = await import('../lib/capture.mjs')
|
|
51
|
+
await runCapture()
|
|
52
|
+
process.exit(0)
|
|
61
53
|
}
|
|
62
54
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'my_context',
|
|
67
|
-
{
|
|
68
|
-
title: 'My Cortex context',
|
|
69
|
-
description: 'Your current work context from the org — your projects, recent activity, gaps, and any directives from leadership. Scoped to what you are permitted to see.',
|
|
70
|
-
inputSchema: {},
|
|
71
|
-
},
|
|
72
|
-
async () => ({ content: [{ type: 'text', text: await fetchContext() }] }),
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
server.registerTool(
|
|
76
|
-
'search_org',
|
|
77
|
-
{
|
|
78
|
-
title: 'Search the org',
|
|
79
|
-
description: 'Search your visible work activity and projects by keyword.',
|
|
80
|
-
inputSchema: { query: z.string().describe('keyword to search for') },
|
|
81
|
-
},
|
|
82
|
-
async ({ query }) => {
|
|
83
|
-
const text = await fetchContext()
|
|
84
|
-
const q = query.toLowerCase()
|
|
85
|
-
const lines = text.split('\n').filter((l) => l.toLowerCase().includes(q))
|
|
86
|
-
return { content: [{ type: 'text', text: lines.length ? `Matches for "${query}":\n${lines.join('\n')}` : `No visible results for "${query}".` }] }
|
|
87
|
-
},
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
server.registerTool(
|
|
91
|
-
'project_status',
|
|
92
|
-
{
|
|
93
|
-
title: 'Project status',
|
|
94
|
-
description: 'Status of a specific project by key (e.g. checkout-v2). Returns only what you can see.',
|
|
95
|
-
inputSchema: { key: z.string().describe('project key, e.g. checkout-v2') },
|
|
96
|
-
},
|
|
97
|
-
async ({ key }) => {
|
|
98
|
-
const text = await fetchContext()
|
|
99
|
-
const line = text.split('\n').find((l) => l.includes(`**${key}**`) || l.includes(key))
|
|
100
|
-
return { content: [{ type: 'text', text: line ? `Project ${key}:\n${line.trim()}` : `No visible project "${key}".` }] }
|
|
101
|
-
},
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
await server.connect(new StdioServerTransport())
|
|
55
|
+
// Default: run the MCP server.
|
|
56
|
+
const { runServer } = await import('../lib/server.mjs')
|
|
57
|
+
await runServer(VERSION)
|
package/lib/capture.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
|
|
3
|
+
// Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
|
|
4
|
+
// summarizes server-side and upserts ONE record per session. Node-native
|
|
5
|
+
// (no bun, no repo clone). Always exits 0 — capture must never break a session.
|
|
6
|
+
|
|
7
|
+
function readStdin() {
|
|
8
|
+
try { return readFileSync(0, 'utf8') } catch { return '' }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Claude transcripts are JSONL; pull human-readable text from the tail so the
|
|
12
|
+
// server has real content to summarize (not raw tool JSON).
|
|
13
|
+
function transcriptTail(path) {
|
|
14
|
+
let raw = ''
|
|
15
|
+
try { raw = readFileSync(path, 'utf8') } catch { return '' }
|
|
16
|
+
const lines = raw.split('\n').filter(Boolean).slice(-60)
|
|
17
|
+
const texts = []
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
try {
|
|
20
|
+
const obj = JSON.parse(line)
|
|
21
|
+
const content = obj?.message?.content ?? obj?.content
|
|
22
|
+
if (typeof content === 'string') texts.push(content)
|
|
23
|
+
else if (Array.isArray(content)) {
|
|
24
|
+
for (const c of content) if (c?.type === 'text' && c.text) texts.push(c.text)
|
|
25
|
+
}
|
|
26
|
+
} catch { /* skip non-JSON */ }
|
|
27
|
+
}
|
|
28
|
+
return texts.join('\n').slice(-6000)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runCapture() {
|
|
32
|
+
const token = process.env.CORTEX_TOKEN
|
|
33
|
+
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
34
|
+
const base = (process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')
|
|
35
|
+
|
|
36
|
+
let hook = {}
|
|
37
|
+
try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
|
|
38
|
+
const repo = hook.cwd?.split('/').filter(Boolean).pop() ?? 'general'
|
|
39
|
+
const transcript = hook.transcript_path ? transcriptTail(hook.transcript_path) : ''
|
|
40
|
+
|
|
41
|
+
if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
|
|
42
|
+
|
|
43
|
+
const res = await fetch(`${base}/api/ingest`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
source: 'claude-code',
|
|
48
|
+
project: repo,
|
|
49
|
+
sessionId: hook.session_id,
|
|
50
|
+
transcript,
|
|
51
|
+
title: `Worked in ${repo}`,
|
|
52
|
+
payload: { session_id: hook.session_id, cwd: hook.cwd },
|
|
53
|
+
}),
|
|
54
|
+
}).catch((e) => { process.stderr.write(`cortex: ingest failed: ${e}\n`); return null })
|
|
55
|
+
|
|
56
|
+
if (res && res.ok) {
|
|
57
|
+
const j = await res.json().catch(() => ({}))
|
|
58
|
+
process.stderr.write(`cortex: ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
|
|
59
|
+
} else if (res) {
|
|
60
|
+
process.stderr.write(`cortex: ingest ${res.status}\n`)
|
|
61
|
+
}
|
|
62
|
+
}
|
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
// The Cortex MCP server (stdio). Serves the signed-in employee's scoped org
|
|
6
|
+
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
7
|
+
|
|
8
|
+
export async function runServer(version) {
|
|
9
|
+
const TOKEN = process.env.CORTEX_TOKEN
|
|
10
|
+
const BASE = (process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')
|
|
11
|
+
|
|
12
|
+
if (!TOKEN) {
|
|
13
|
+
process.stderr.write('cortex-mcp: CORTEX_TOKEN is required. Get yours from the Cortex console → Connect your AI.\n')
|
|
14
|
+
process.exit(1)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Cache context for 5 minutes so repeated tool calls don't re-fetch.
|
|
18
|
+
let cache = null
|
|
19
|
+
async function fetchContext() {
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
if (cache && now - cache.ts < 5 * 60 * 1000) return cache.text
|
|
22
|
+
const res = await fetch(`${BASE}/api/mcp-context`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
let detail = 'unknown'
|
|
25
|
+
try { detail = (await res.json()).error ?? detail } catch { /* ignore */ }
|
|
26
|
+
throw new Error(`Cortex API ${res.status}: ${detail}`)
|
|
27
|
+
}
|
|
28
|
+
const { context } = await res.json()
|
|
29
|
+
cache = { text: context, ts: now }
|
|
30
|
+
return context
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const server = new McpServer({ name: 'cortex', version })
|
|
34
|
+
|
|
35
|
+
server.registerTool(
|
|
36
|
+
'my_context',
|
|
37
|
+
{
|
|
38
|
+
title: 'My Cortex context',
|
|
39
|
+
description: 'Your current work context from the org — your projects, recent activity, gaps, and any directives from leadership. Scoped to what you are permitted to see.',
|
|
40
|
+
inputSchema: {},
|
|
41
|
+
},
|
|
42
|
+
async () => ({ content: [{ type: 'text', text: await fetchContext() }] }),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
server.registerTool(
|
|
46
|
+
'search_org',
|
|
47
|
+
{
|
|
48
|
+
title: 'Search the org',
|
|
49
|
+
description: 'Search your visible work activity and projects by keyword.',
|
|
50
|
+
inputSchema: { query: z.string().describe('keyword to search for') },
|
|
51
|
+
},
|
|
52
|
+
async ({ query }) => {
|
|
53
|
+
const text = await fetchContext()
|
|
54
|
+
const q = query.toLowerCase()
|
|
55
|
+
const lines = text.split('\n').filter((l) => l.toLowerCase().includes(q))
|
|
56
|
+
return { content: [{ type: 'text', text: lines.length ? `Matches for "${query}":\n${lines.join('\n')}` : `No visible results for "${query}".` }] }
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
server.registerTool(
|
|
61
|
+
'project_status',
|
|
62
|
+
{
|
|
63
|
+
title: 'Project status',
|
|
64
|
+
description: 'Status of a specific project by key (e.g. checkout-v2). Returns only what you can see.',
|
|
65
|
+
inputSchema: { key: z.string().describe('project key, e.g. checkout-v2') },
|
|
66
|
+
},
|
|
67
|
+
async ({ key }) => {
|
|
68
|
+
const text = await fetchContext()
|
|
69
|
+
const line = text.split('\n').find((l) => l.includes(`**${key}**`) || l.includes(key))
|
|
70
|
+
return { content: [{ type: 'text', text: line ? `Project ${key}:\n${line.trim()}` : `No visible project "${key}".` }] }
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
await server.connect(new StdioServerTransport())
|
|
75
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join, dirname } from 'path'
|
|
4
|
+
|
|
5
|
+
// One-command employee onboarding. Wires both:
|
|
6
|
+
// 1. ~/.claude.json → the cortex MCP server (context-serving)
|
|
7
|
+
// 2. ~/.claude/settings.json → the capture Stop hook (activity ingest)
|
|
8
|
+
//
|
|
9
|
+
// Safe by construction: backs up each file before touching it, validates JSON,
|
|
10
|
+
// merges into existing structures (never clobbers other MCP servers / hooks),
|
|
11
|
+
// and is idempotent (re-running just updates the cortex entries in place).
|
|
12
|
+
|
|
13
|
+
const PKG = '@theronap/cortex-mcp'
|
|
14
|
+
|
|
15
|
+
function readJson(path) {
|
|
16
|
+
if (!existsSync(path)) return {}
|
|
17
|
+
const raw = readFileSync(path, 'utf8').trim()
|
|
18
|
+
if (!raw) return {}
|
|
19
|
+
return JSON.parse(raw) // throws on malformed — caller handles
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function backup(path) {
|
|
23
|
+
if (!existsSync(path)) return null
|
|
24
|
+
const bak = `${path}.cortex-bak`
|
|
25
|
+
copyFileSync(path, bak)
|
|
26
|
+
return bak
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureDir(path) {
|
|
30
|
+
const dir = dirname(path)
|
|
31
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runSetup(argv) {
|
|
35
|
+
const token = argv[0]
|
|
36
|
+
if (!token || token.startsWith('-')) {
|
|
37
|
+
process.stderr.write(
|
|
38
|
+
'Usage: npx @theronap/cortex-mcp setup <CORTEX_TOKEN>\n\n' +
|
|
39
|
+
'Get your token from the Cortex console → Connect your AI.\n'
|
|
40
|
+
)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
const base = process.env.CORTEX_URL ?? 'https://cortex-console.vercel.app'
|
|
44
|
+
const home = homedir()
|
|
45
|
+
const claudeJson = join(home, '.claude.json')
|
|
46
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
47
|
+
|
|
48
|
+
const log = (m) => process.stdout.write(m + '\n')
|
|
49
|
+
log('')
|
|
50
|
+
log('Cortex setup — wiring your AI assistant…')
|
|
51
|
+
|
|
52
|
+
// ── 1. MCP server in ~/.claude.json ──────────────────────────────────────
|
|
53
|
+
try {
|
|
54
|
+
let cfg
|
|
55
|
+
try { cfg = readJson(claudeJson) } catch (e) {
|
|
56
|
+
process.stderr.write(`\n✗ ${claudeJson} is not valid JSON — fix or remove it, then re-run.\n`)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
const bak = backup(claudeJson)
|
|
60
|
+
cfg.mcpServers = cfg.mcpServers ?? {}
|
|
61
|
+
cfg.mcpServers.cortex = {
|
|
62
|
+
type: 'stdio',
|
|
63
|
+
command: 'npx',
|
|
64
|
+
args: ['-y', PKG],
|
|
65
|
+
env: { CORTEX_TOKEN: token },
|
|
66
|
+
}
|
|
67
|
+
ensureDir(claudeJson)
|
|
68
|
+
writeFileSync(claudeJson, JSON.stringify(cfg, null, 2))
|
|
69
|
+
log(` ✓ MCP server → ${claudeJson}${bak ? ' (backup saved)' : ''}`)
|
|
70
|
+
} catch (e) {
|
|
71
|
+
process.stderr.write(` ✗ failed to update ${claudeJson}: ${e.message}\n`)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 2. Capture Stop hook in ~/.claude/settings.json ──────────────────────
|
|
76
|
+
try {
|
|
77
|
+
let s
|
|
78
|
+
try { s = readJson(settingsJson) } catch (e) {
|
|
79
|
+
process.stderr.write(`\n✗ ${settingsJson} is not valid JSON — fix or remove it, then re-run.\n`)
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
const bak = backup(settingsJson)
|
|
83
|
+
s.hooks = s.hooks ?? {}
|
|
84
|
+
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
85
|
+
|
|
86
|
+
const captureCmd = `CORTEX_TOKEN=${token} npx -y ${PKG} capture`
|
|
87
|
+
// Remove any prior cortex capture hook (idempotent: drop old token / old path forms).
|
|
88
|
+
for (const grp of s.hooks.Stop) {
|
|
89
|
+
if (Array.isArray(grp.hooks)) {
|
|
90
|
+
grp.hooks = grp.hooks.filter((h) => !/cortex-mcp.*capture|capture-session-cloud/.test(h.command ?? ''))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Find or create a matcher:"" group and append.
|
|
94
|
+
let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
95
|
+
if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
|
|
96
|
+
grp.hooks = grp.hooks ?? []
|
|
97
|
+
grp.hooks.push({ type: 'command', command: captureCmd })
|
|
98
|
+
|
|
99
|
+
ensureDir(settingsJson)
|
|
100
|
+
writeFileSync(settingsJson, JSON.stringify(s, null, 2))
|
|
101
|
+
log(` ✓ Capture hook → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
102
|
+
} catch (e) {
|
|
103
|
+
process.stderr.write(` ✗ failed to update ${settingsJson}: ${e.message}\n`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
log('')
|
|
108
|
+
log('Done. Restart Claude Code, then your AI will see your Cortex context')
|
|
109
|
+
log('and your sessions will flow into the org automatically.')
|
|
110
|
+
log(`Console: ${base}`)
|
|
111
|
+
log('')
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theronap/cortex-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cortex-mcp": "bin/cortex-mcp.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"bin"
|
|
10
|
+
"bin",
|
|
11
|
+
"lib"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=18"
|