feat: read-only inventory scanners for Claude Code and Codex, local API server and launcher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9c75087 parent: 772fb24 added
packages/cli/bin/skilly.js +6 -0 | new file mode 100644 | ||
| @@ -0,0 +1,6 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +// Development entry: runs the TypeScript sources through tsx. Bundling for npm comes later. | |
| 3 | +import { register } from 'tsx/esm/api' | |
| 4 | + | |
| 5 | +register() | |
| 6 | +await import('../src/main.ts') | |
| new file mode 100644 | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | +#!/usr/bin/env node | ||
| 2 | +// Development entry: runs the TypeScript sources through tsx. Bundling for npm comes later. | ||
| 3 | +import { register } from 'tsx/esm/api' | ||
| 4 | + | ||
| 5 | +register() | ||
| 6 | +await import('../src/main.ts') | ||
added
packages/cli/src/main.ts +133 -0 | new file mode 100644 | ||
| @@ -0,0 +1,133 @@ | ||
| 1 | +import { randomBytes } from 'node:crypto' | |
| 2 | +import { existsSync } from 'node:fs' | |
| 3 | +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' | |
| 4 | +import os from 'node:os' | |
| 5 | +import path from 'node:path' | |
| 6 | +import { fileURLToPath } from 'node:url' | |
| 7 | +import { startServer } from '@skilly/server' | |
| 8 | +import { openWindow } from './window.ts' | |
| 9 | + | |
| 10 | +const IDLE_EXIT_MS = 20_000 | |
| 11 | + | |
| 12 | +interface Args { | |
| 13 | + open: boolean | |
| 14 | + port: number | |
| 15 | + help: boolean | |
| 16 | +} | |
| 17 | + | |
| 18 | +interface InstanceInfo { | |
| 19 | + pid: number | |
| 20 | + port: number | |
| 21 | + token: string | |
| 22 | + startedAt: string | |
| 23 | +} | |
| 24 | + | |
| 25 | +function parseArgs(argv: string[]): Args { | |
| 26 | + const args: Args = { open: true, port: 0, help: false } | |
| 27 | + for (let i = 0; i < argv.length; i++) { | |
| 28 | + const a = argv[i] | |
| 29 | + if (a === '--no-open') args.open = false | |
| 30 | + else if (a === '--help' || a === '-h') args.help = true | |
| 31 | + else if (a === '--port' || a?.startsWith('--port=')) { | |
| 32 | + const raw = a === '--port' ? argv[++i] : a.slice('--port='.length) | |
| 33 | + const n = Number(raw) | |
| 34 | + if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`Invalid port: ${raw}`) | |
| 35 | + args.port = n | |
| 36 | + } else throw new Error(`Unknown argument: ${a}`) | |
| 37 | + } | |
| 38 | + return args | |
| 39 | +} | |
| 40 | + | |
| 41 | +async function readInstance(file: string): Promise<InstanceInfo | undefined> { | |
| 42 | + try { | |
| 43 | + const data = JSON.parse(await readFile(file, 'utf8')) as Partial<InstanceInfo> | |
| 44 | + if (typeof data.port === 'number' && typeof data.token === 'string') return data as InstanceInfo | |
| 45 | + } catch { | |
| 46 | + // missing or corrupt: treat as no instance | |
| 47 | + } | |
| 48 | + return undefined | |
| 49 | +} | |
| 50 | + | |
| 51 | +async function isAlive(info: InstanceInfo): Promise<boolean> { | |
| 52 | + try { | |
| 53 | + const res = await fetch(`http://127.0.0.1:${info.port}/api/health`, { | |
| 54 | + headers: { 'x-skilly-token': info.token }, | |
| 55 | + signal: AbortSignal.timeout(1500), | |
| 56 | + }) | |
| 57 | + return res.ok | |
| 58 | + } catch { | |
| 59 | + return false | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +function windowUrl(port: number, token: string): string { | |
| 64 | + return `http://127.0.0.1:${port}/?t=${token}` | |
| 65 | +} | |
| 66 | + | |
| 67 | +async function launch(url: string, profileDir: string): Promise<void> { | |
| 68 | + if (!(await openWindow(url, profileDir))) { | |
| 69 | + console.error(`Could not open a browser window automatically. Open this URL yourself: ${url}`) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +async function main(): Promise<void> { | |
| 74 | + const args = parseArgs(process.argv.slice(2)) | |
| 75 | + if (args.help) { | |
| 76 | + console.log('Usage: skilly [--no-open] [--port N]') | |
| 77 | + return | |
| 78 | + } | |
| 79 | + | |
| 80 | + const configDir = process.env.SKILLY_CONFIG_DIR || path.join(os.homedir(), '.skilly') | |
| 81 | + const instanceFile = path.join(configDir, 'instance.json') | |
| 82 | + const profileDir = path.join(configDir, 'browser-profile') | |
| 83 | + await mkdir(configDir, { recursive: true }) | |
| 84 | + | |
| 85 | + // Single instance: reuse a running server. | |
| 86 | + const existing = await readInstance(instanceFile) | |
| 87 | + if (existing && (await isAlive(existing))) { | |
| 88 | + const url = windowUrl(existing.port, existing.token) | |
| 89 | + if (args.open) await launch(url, profileDir) | |
| 90 | + console.log(args.open ? `skilly is already running: ${url}` : url) | |
| 91 | + return | |
| 92 | + } | |
| 93 | + | |
| 94 | + const staticDir = process.env.SKILLY_STATIC_DIR || fileURLToPath(new URL('../../ui/.output/public', import.meta.url)) | |
| 95 | + if (!existsSync(path.join(staticDir, 'index.html')) && !existsSync(path.join(staticDir, '200.html'))) { | |
| 96 | + console.error(`The UI is not built (missing ${staticDir}).\nRun "pnpm build" from the repository root first.`) | |
| 97 | + process.exitCode = 1 | |
| 98 | + return | |
| 99 | + } | |
| 100 | + | |
| 101 | + const token = randomBytes(32).toString('hex') | |
| 102 | + let shuttingDown = false | |
| 103 | + const cleanup = async (): Promise<void> => { | |
| 104 | + if (shuttingDown) return | |
| 105 | + shuttingDown = true | |
| 106 | + const current = await readInstance(instanceFile) | |
| 107 | + if (current?.pid === process.pid) await rm(instanceFile, { force: true }) | |
| 108 | + await server.close().catch(() => undefined) | |
| 109 | + process.exit(0) | |
| 110 | + } | |
| 111 | + | |
| 112 | + const server = await startServer({ | |
| 113 | + token, | |
| 114 | + port: args.port, | |
| 115 | + configDir, | |
| 116 | + staticDir, | |
| 117 | + exitOnIdleMs: IDLE_EXIT_MS, | |
| 118 | + onIdle: () => void cleanup(), | |
| 119 | + }) | |
| 120 | + const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() } | |
| 121 | + await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8') | |
| 122 | + process.on('SIGINT', () => void cleanup()) | |
| 123 | + process.on('SIGTERM', () => void cleanup()) | |
| 124 | + | |
| 125 | + const url = windowUrl(server.port, token) | |
| 126 | + console.log(`skilly: ${url} (close the window to stop)`) | |
| 127 | + if (args.open) await launch(url, profileDir) | |
| 128 | +} | |
| 129 | + | |
| 130 | +main().catch((err: unknown) => { | |
| 131 | + console.error(err instanceof Error ? err.message : String(err)) | |
| 132 | + process.exit(1) | |
| 133 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,133 @@ | |||
| 1 | +import { randomBytes } from 'node:crypto' | ||
| 2 | +import { existsSync } from 'node:fs' | ||
| 3 | +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' | ||
| 4 | +import os from 'node:os' | ||
| 5 | +import path from 'node:path' | ||
| 6 | +import { fileURLToPath } from 'node:url' | ||
| 7 | +import { startServer } from '@skilly/server' | ||
| 8 | +import { openWindow } from './window.ts' | ||
| 9 | + | ||
| 10 | +const IDLE_EXIT_MS = 20_000 | ||
| 11 | + | ||
| 12 | +interface Args { | ||
| 13 | + open: boolean | ||
| 14 | + port: number | ||
| 15 | + help: boolean | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +interface InstanceInfo { | ||
| 19 | + pid: number | ||
| 20 | + port: number | ||
| 21 | + token: string | ||
| 22 | + startedAt: string | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +function parseArgs(argv: string[]): Args { | ||
| 26 | + const args: Args = { open: true, port: 0, help: false } | ||
| 27 | + for (let i = 0; i < argv.length; i++) { | ||
| 28 | + const a = argv[i] | ||
| 29 | + if (a === '--no-open') args.open = false | ||
| 30 | + else if (a === '--help' || a === '-h') args.help = true | ||
| 31 | + else if (a === '--port' || a?.startsWith('--port=')) { | ||
| 32 | + const raw = a === '--port' ? argv[++i] : a.slice('--port='.length) | ||
| 33 | + const n = Number(raw) | ||
| 34 | + if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`Invalid port: ${raw}`) | ||
| 35 | + args.port = n | ||
| 36 | + } else throw new Error(`Unknown argument: ${a}`) | ||
| 37 | + } | ||
| 38 | + return args | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +async function readInstance(file: string): Promise<InstanceInfo | undefined> { | ||
| 42 | + try { | ||
| 43 | + const data = JSON.parse(await readFile(file, 'utf8')) as Partial<InstanceInfo> | ||
| 44 | + if (typeof data.port === 'number' && typeof data.token === 'string') return data as InstanceInfo | ||
| 45 | + } catch { | ||
| 46 | + // missing or corrupt: treat as no instance | ||
| 47 | + } | ||
| 48 | + return undefined | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +async function isAlive(info: InstanceInfo): Promise<boolean> { | ||
| 52 | + try { | ||
| 53 | + const res = await fetch(`http://127.0.0.1:${info.port}/api/health`, { | ||
| 54 | + headers: { 'x-skilly-token': info.token }, | ||
| 55 | + signal: AbortSignal.timeout(1500), | ||
| 56 | + }) | ||
| 57 | + return res.ok | ||
| 58 | + } catch { | ||
| 59 | + return false | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function windowUrl(port: number, token: string): string { | ||
| 64 | + return `http://127.0.0.1:${port}/?t=${token}` | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +async function launch(url: string, profileDir: string): Promise<void> { | ||
| 68 | + if (!(await openWindow(url, profileDir))) { | ||
| 69 | + console.error(`Could not open a browser window automatically. Open this URL yourself: ${url}`) | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +async function main(): Promise<void> { | ||
| 74 | + const args = parseArgs(process.argv.slice(2)) | ||
| 75 | + if (args.help) { | ||
| 76 | + console.log('Usage: skilly [--no-open] [--port N]') | ||
| 77 | + return | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + const configDir = process.env.SKILLY_CONFIG_DIR || path.join(os.homedir(), '.skilly') | ||
| 81 | + const instanceFile = path.join(configDir, 'instance.json') | ||
| 82 | + const profileDir = path.join(configDir, 'browser-profile') | ||
| 83 | + await mkdir(configDir, { recursive: true }) | ||
| 84 | + | ||
| 85 | + // Single instance: reuse a running server. | ||
| 86 | + const existing = await readInstance(instanceFile) | ||
| 87 | + if (existing && (await isAlive(existing))) { | ||
| 88 | + const url = windowUrl(existing.port, existing.token) | ||
| 89 | + if (args.open) await launch(url, profileDir) | ||
| 90 | + console.log(args.open ? `skilly is already running: ${url}` : url) | ||
| 91 | + return | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + const staticDir = process.env.SKILLY_STATIC_DIR || fileURLToPath(new URL('../../ui/.output/public', import.meta.url)) | ||
| 95 | + if (!existsSync(path.join(staticDir, 'index.html')) && !existsSync(path.join(staticDir, '200.html'))) { | ||
| 96 | + console.error(`The UI is not built (missing ${staticDir}).\nRun "pnpm build" from the repository root first.`) | ||
| 97 | + process.exitCode = 1 | ||
| 98 | + return | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + const token = randomBytes(32).toString('hex') | ||
| 102 | + let shuttingDown = false | ||
| 103 | + const cleanup = async (): Promise<void> => { | ||
| 104 | + if (shuttingDown) return | ||
| 105 | + shuttingDown = true | ||
| 106 | + const current = await readInstance(instanceFile) | ||
| 107 | + if (current?.pid === process.pid) await rm(instanceFile, { force: true }) | ||
| 108 | + await server.close().catch(() => undefined) | ||
| 109 | + process.exit(0) | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + const server = await startServer({ | ||
| 113 | + token, | ||
| 114 | + port: args.port, | ||
| 115 | + configDir, | ||
| 116 | + staticDir, | ||
| 117 | + exitOnIdleMs: IDLE_EXIT_MS, | ||
| 118 | + onIdle: () => void cleanup(), | ||
| 119 | + }) | ||
| 120 | + const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() } | ||
| 121 | + await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8') | ||
| 122 | + process.on('SIGINT', () => void cleanup()) | ||
| 123 | + process.on('SIGTERM', () => void cleanup()) | ||
| 124 | + | ||
| 125 | + const url = windowUrl(server.port, token) | ||
| 126 | + console.log(`skilly: ${url} (close the window to stop)`) | ||
| 127 | + if (args.open) await launch(url, profileDir) | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +main().catch((err: unknown) => { | ||
| 131 | + console.error(err instanceof Error ? err.message : String(err)) | ||
| 132 | + process.exit(1) | ||
| 133 | +}) | ||
added
packages/cli/src/window.ts +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { spawn } from 'node:child_process' | |
| 2 | +import { existsSync } from 'node:fs' | |
| 3 | +import path from 'node:path' | |
| 4 | + | |
| 5 | +interface Launch { | |
| 6 | + command: string | |
| 7 | + args: string[] | |
| 8 | +} | |
| 9 | + | |
| 10 | +function spawnDetached(l: Launch): Promise<boolean> { | |
| 11 | + return new Promise((resolve) => { | |
| 12 | + try { | |
| 13 | + const child = spawn(l.command, l.args, { detached: true, stdio: 'ignore', windowsHide: true }) | |
| 14 | + child.once('error', () => resolve(false)) | |
| 15 | + child.once('spawn', () => { | |
| 16 | + child.unref() | |
| 17 | + resolve(true) | |
| 18 | + }) | |
| 19 | + } catch { | |
| 20 | + resolve(false) | |
| 21 | + } | |
| 22 | + }) | |
| 23 | +} | |
| 24 | + | |
| 25 | +function findOnPath(names: string[]): string | undefined { | |
| 26 | + const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean) | |
| 27 | + for (const name of names) { | |
| 28 | + for (const dir of dirs) { | |
| 29 | + const full = path.join(dir, name) | |
| 30 | + if (existsSync(full)) return full | |
| 31 | + } | |
| 32 | + } | |
| 33 | + return undefined | |
| 34 | +} | |
| 35 | + | |
| 36 | +function windowsBrowsers(): string[] { | |
| 37 | + const roots = [process.env['ProgramFiles(x86)'], process.env.ProgramFiles, process.env.LOCALAPPDATA].filter( | |
| 38 | + (r): r is string => Boolean(r), | |
| 39 | + ) | |
| 40 | + const rel = [ | |
| 41 | + ['Microsoft', 'Edge', 'Application', 'msedge.exe'], | |
| 42 | + ['Google', 'Chrome', 'Application', 'chrome.exe'], | |
| 43 | + ] | |
| 44 | + const out: string[] = [] | |
| 45 | + for (const r of rel) for (const root of roots) out.push(path.join(root, ...r)) | |
| 46 | + return out.filter((p) => existsSync(p)) | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Builds the launch attempts in order of preference. */ | |
| 50 | +function candidates(url: string, profileDir: string): Launch[] { | |
| 51 | + const appArgs = [`--app=${url}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--no-default-browser-check'] | |
| 52 | + const list: Launch[] = [] | |
| 53 | + if (process.platform === 'win32') { | |
| 54 | + for (const exe of windowsBrowsers()) list.push({ command: exe, args: appArgs }) | |
| 55 | + list.push({ command: 'cmd', args: ['/c', 'start', '', url] }) | |
| 56 | + } else if (process.platform === 'darwin') { | |
| 57 | + for (const app of ['Google Chrome', 'Microsoft Edge']) { | |
| 58 | + if (existsSync(`/Applications/${app}.app`)) { | |
| 59 | + list.push({ command: 'open', args: ['-na', app, '--args', ...appArgs] }) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + list.push({ command: 'open', args: [url] }) | |
| 63 | + } else { | |
| 64 | + for (const name of ['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge']) { | |
| 65 | + const exe = findOnPath([name]) | |
| 66 | + if (exe) list.push({ command: exe, args: appArgs }) | |
| 67 | + } | |
| 68 | + list.push({ command: 'xdg-open', args: [url] }) | |
| 69 | + } | |
| 70 | + return list | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** Opens `url` in an app-mode browser window, falling back to the default browser. */ | |
| 74 | +export async function openWindow(url: string, profileDir: string): Promise<boolean> { | |
| 75 | + for (const launch of candidates(url, profileDir)) { | |
| 76 | + if (await spawnDetached(launch)) return true | |
| 77 | + } | |
| 78 | + return false | |
| 79 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +import { spawn } from 'node:child_process' | ||
| 2 | +import { existsSync } from 'node:fs' | ||
| 3 | +import path from 'node:path' | ||
| 4 | + | ||
| 5 | +interface Launch { | ||
| 6 | + command: string | ||
| 7 | + args: string[] | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +function spawnDetached(l: Launch): Promise<boolean> { | ||
| 11 | + return new Promise((resolve) => { | ||
| 12 | + try { | ||
| 13 | + const child = spawn(l.command, l.args, { detached: true, stdio: 'ignore', windowsHide: true }) | ||
| 14 | + child.once('error', () => resolve(false)) | ||
| 15 | + child.once('spawn', () => { | ||
| 16 | + child.unref() | ||
| 17 | + resolve(true) | ||
| 18 | + }) | ||
| 19 | + } catch { | ||
| 20 | + resolve(false) | ||
| 21 | + } | ||
| 22 | + }) | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +function findOnPath(names: string[]): string | undefined { | ||
| 26 | + const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean) | ||
| 27 | + for (const name of names) { | ||
| 28 | + for (const dir of dirs) { | ||
| 29 | + const full = path.join(dir, name) | ||
| 30 | + if (existsSync(full)) return full | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | + return undefined | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function windowsBrowsers(): string[] { | ||
| 37 | + const roots = [process.env['ProgramFiles(x86)'], process.env.ProgramFiles, process.env.LOCALAPPDATA].filter( | ||
| 38 | + (r): r is string => Boolean(r), | ||
| 39 | + ) | ||
| 40 | + const rel = [ | ||
| 41 | + ['Microsoft', 'Edge', 'Application', 'msedge.exe'], | ||
| 42 | + ['Google', 'Chrome', 'Application', 'chrome.exe'], | ||
| 43 | + ] | ||
| 44 | + const out: string[] = [] | ||
| 45 | + for (const r of rel) for (const root of roots) out.push(path.join(root, ...r)) | ||
| 46 | + return out.filter((p) => existsSync(p)) | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** Builds the launch attempts in order of preference. */ | ||
| 50 | +function candidates(url: string, profileDir: string): Launch[] { | ||
| 51 | + const appArgs = [`--app=${url}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--no-default-browser-check'] | ||
| 52 | + const list: Launch[] = [] | ||
| 53 | + if (process.platform === 'win32') { | ||
| 54 | + for (const exe of windowsBrowsers()) list.push({ command: exe, args: appArgs }) | ||
| 55 | + list.push({ command: 'cmd', args: ['/c', 'start', '', url] }) | ||
| 56 | + } else if (process.platform === 'darwin') { | ||
| 57 | + for (const app of ['Google Chrome', 'Microsoft Edge']) { | ||
| 58 | + if (existsSync(`/Applications/${app}.app`)) { | ||
| 59 | + list.push({ command: 'open', args: ['-na', app, '--args', ...appArgs] }) | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | + list.push({ command: 'open', args: [url] }) | ||
| 63 | + } else { | ||
| 64 | + for (const name of ['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge']) { | ||
| 65 | + const exe = findOnPath([name]) | ||
| 66 | + if (exe) list.push({ command: exe, args: appArgs }) | ||
| 67 | + } | ||
| 68 | + list.push({ command: 'xdg-open', args: [url] }) | ||
| 69 | + } | ||
| 70 | + return list | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +/** Opens `url` in an app-mode browser window, falling back to the default browser. */ | ||
| 74 | +export async function openWindow(url: string, profileDir: string): Promise<boolean> { | ||
| 75 | + for (const launch of candidates(url, profileDir)) { | ||
| 76 | + if (await spawnDetached(launch)) return true | ||
| 77 | + } | ||
| 78 | + return false | ||
| 79 | +} | ||
added
packages/core/src/scanners/claude/acc.ts +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +import type { InventoryItem, ItemDetails, ItemSource, ItemType, ScanError } from '../../model.ts' | |
| 2 | +import { uniqueId } from '../../util.ts' | |
| 3 | + | |
| 4 | +/** Collects items and errors of one scan, and keeps ids unique. */ | |
| 5 | +export class Acc { | |
| 6 | + items: InventoryItem[] = [] | |
| 7 | + errors: ScanError[] = [] | |
| 8 | + private taken = new Set<string>() | |
| 9 | + | |
| 10 | + error(p: string, err: unknown, message?: string): void { | |
| 11 | + this.errors.push({ agent: 'claude', path: p, message: message ?? (err instanceof Error ? err.message : String(err)) }) | |
| 12 | + } | |
| 13 | + | |
| 14 | + add( | |
| 15 | + type: ItemType, | |
| 16 | + name: string, | |
| 17 | + fields: { description?: string; source: ItemSource; details: ItemDetails; hash?: string; warnings?: string[] }, | |
| 18 | + ): InventoryItem { | |
| 19 | + const item: InventoryItem = { | |
| 20 | + id: uniqueId(`claude:${type}:${name}`, this.taken), | |
| 21 | + type, | |
| 22 | + name, | |
| 23 | + agent: 'claude', | |
| 24 | + source: fields.source, | |
| 25 | + details: fields.details, | |
| 26 | + warnings: fields.warnings ?? [], | |
| 27 | + } | |
| 28 | + if (fields.description !== undefined) item.description = fields.description | |
| 29 | + if (fields.hash !== undefined) item.hash = fields.hash | |
| 30 | + this.items.push(item) | |
| 31 | + return item | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function str(v: unknown): string | undefined { | |
| 36 | + return typeof v === 'string' && v.trim() ? v : undefined | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function isRecord(v: unknown): v is Record<string, unknown> { | |
| 40 | + return typeof v === 'object' && v !== null && !Array.isArray(v) | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** Comma-separated string or array of strings to a list. */ | |
| 44 | +export function toList(v: unknown): string[] | undefined { | |
| 45 | + if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean) | |
| 46 | + if (typeof v === 'string') return v.split(',').map((x) => x.trim()).filter(Boolean) | |
| 47 | + return undefined | |
| 48 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +import type { InventoryItem, ItemDetails, ItemSource, ItemType, ScanError } from '../../model.ts' | ||
| 2 | +import { uniqueId } from '../../util.ts' | ||
| 3 | + | ||
| 4 | +/** Collects items and errors of one scan, and keeps ids unique. */ | ||
| 5 | +export class Acc { | ||
| 6 | + items: InventoryItem[] = [] | ||
| 7 | + errors: ScanError[] = [] | ||
| 8 | + private taken = new Set<string>() | ||
| 9 | + | ||
| 10 | + error(p: string, err: unknown, message?: string): void { | ||
| 11 | + this.errors.push({ agent: 'claude', path: p, message: message ?? (err instanceof Error ? err.message : String(err)) }) | ||
| 12 | + } | ||
| 13 | + | ||
| 14 | + add( | ||
| 15 | + type: ItemType, | ||
| 16 | + name: string, | ||
| 17 | + fields: { description?: string; source: ItemSource; details: ItemDetails; hash?: string; warnings?: string[] }, | ||
| 18 | + ): InventoryItem { | ||
| 19 | + const item: InventoryItem = { | ||
| 20 | + id: uniqueId(`claude:${type}:${name}`, this.taken), | ||
| 21 | + type, | ||
| 22 | + name, | ||
| 23 | + agent: 'claude', | ||
| 24 | + source: fields.source, | ||
| 25 | + details: fields.details, | ||
| 26 | + warnings: fields.warnings ?? [], | ||
| 27 | + } | ||
| 28 | + if (fields.description !== undefined) item.description = fields.description | ||
| 29 | + if (fields.hash !== undefined) item.hash = fields.hash | ||
| 30 | + this.items.push(item) | ||
| 31 | + return item | ||
| 32 | + } | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +export function str(v: unknown): string | undefined { | ||
| 36 | + return typeof v === 'string' && v.trim() ? v : undefined | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +export function isRecord(v: unknown): v is Record<string, unknown> { | ||
| 40 | + return typeof v === 'object' && v !== null && !Array.isArray(v) | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +/** Comma-separated string or array of strings to a list. */ | ||
| 44 | +export function toList(v: unknown): string[] | undefined { | ||
| 45 | + if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean) | ||
| 46 | + if (typeof v === 'string') return v.split(',').map((x) => x.trim()).filter(Boolean) | ||
| 47 | + return undefined | ||
| 48 | +} | ||
added
packages/core/src/scanners/claude/content.ts +245 -0 | new file mode 100644 | ||
| @@ -0,0 +1,245 @@ | ||
| 1 | +import { stat } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { AgentDetails, InstructionsDetails, ItemSource, RuleDetails, SkillDetails } from '../../model.ts' | |
| 4 | +import { | |
| 5 | + hashPath, | |
| 6 | + listDirs, | |
| 7 | + listFilesRecursive, | |
| 8 | + parseFrontmatter, | |
| 9 | + readText, | |
| 10 | + symlinkTarget, | |
| 11 | +} from '../../util.ts' | |
| 12 | +import { Acc, str, toList } from './acc.ts' | |
| 13 | + | |
| 14 | +export type SkillOverrides = Record<string, string> | |
| 15 | + | |
| 16 | +export interface SkillOptions { | |
| 17 | + /** `skillOverrides` of the settings that apply (not applied to plugin skills). */ | |
| 18 | + overrides?: SkillOverrides | |
| 19 | + plugin?: string | |
| 20 | + warnings?: string[] | |
| 21 | + /** Displayed name when it must differ from the folder/file name (plugin skills are namespaced). */ | |
| 22 | + displayName?: string | |
| 23 | +} | |
| 24 | + | |
| 25 | +function isTrue(v: unknown): boolean { | |
| 26 | + return v === true || (typeof v === 'string' && v.trim().toLowerCase() === 'true') | |
| 27 | +} | |
| 28 | + | |
| 29 | +function resolveVisibility( | |
| 30 | + name: string, | |
| 31 | + fm: Record<string, unknown>, | |
| 32 | + overrides: SkillOverrides | undefined, | |
| 33 | +): Pick<SkillDetails, 'visibility' | 'visibilitySource'> { | |
| 34 | + let visibility: SkillDetails['visibility'] = 'auto' | |
| 35 | + let visibilitySource: string | undefined | |
| 36 | + if (isTrue(fm['disable-model-invocation'])) { | |
| 37 | + visibility = 'on-demand' | |
| 38 | + visibilitySource = 'frontmatter:disable-model-invocation' | |
| 39 | + } | |
| 40 | + const o = overrides?.[name] | |
| 41 | + if (o === 'on' || o === 'name-only' || o === 'off' || o === 'user-invocable-only') { | |
| 42 | + visibility = o === 'user-invocable-only' ? 'on-demand' : o === 'on' ? 'auto' : o | |
| 43 | + visibilitySource = 'settings:skillOverrides' | |
| 44 | + } | |
| 45 | + const out: Pick<SkillDetails, 'visibility' | 'visibilitySource'> = { visibility } | |
| 46 | + if (visibilitySource) out.visibilitySource = visibilitySource | |
| 47 | + return out | |
| 48 | +} | |
| 49 | + | |
| 50 | +function source(p: string, kind: ItemSource['kind'], link: string | undefined, plugin?: string): ItemSource { | |
| 51 | + const s: ItemSource = { path: p, kind } | |
| 52 | + if (link) s.symlinkTarget = link | |
| 53 | + if (plugin) s.plugin = plugin | |
| 54 | + return s | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** Adds a skill folder if it holds a SKILL.md. Returns true when an item was added. */ | |
| 58 | +export async function addSkillDir(acc: Acc, dir: string, opts: SkillOptions = {}): Promise<boolean> { | |
| 59 | + const skillFile = path.join(dir, 'SKILL.md') | |
| 60 | + try { | |
| 61 | + const text = await readText(skillFile) | |
| 62 | + if (text === undefined) return false | |
| 63 | + const { frontmatter: fm } = parseFrontmatter(text) | |
| 64 | + const folder = path.basename(dir) | |
| 65 | + const name = str(fm.name) ?? folder | |
| 66 | + const shown = opts.displayName ?? name | |
| 67 | + const details: SkillDetails = { | |
| 68 | + type: 'skill', | |
| 69 | + frontmatter: fm, | |
| 70 | + files: await listFilesRecursive(dir, 200), | |
| 71 | + ...resolveVisibility(name, fm, opts.plugin ? undefined : opts.overrides), | |
| 72 | + } | |
| 73 | + acc.add('skill', shown, { | |
| 74 | + description: str(fm.description), | |
| 75 | + source: source(dir, 'dir', await symlinkTarget(dir), opts.plugin), | |
| 76 | + details, | |
| 77 | + hash: await hashPath(dir), | |
| 78 | + warnings: opts.warnings ? [...opts.warnings] : [], | |
| 79 | + }) | |
| 80 | + return true | |
| 81 | + } catch (err) { | |
| 82 | + acc.error(skillFile, err) | |
| 83 | + return false | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * `<base>/skills`: one folder per skill. `synced/` holds skills synced from claude.ai; on disk they sit one level | |
| 89 | + * deeper (`synced/<id>/<skill>/SKILL.md`), so folders without a SKILL.md are searched one level down. | |
| 90 | + */ | |
| 91 | +export async function scanSkillsDir(acc: Acc, skillsDir: string, overrides: SkillOverrides): Promise<void> { | |
| 92 | + for (const name of await listDirs(skillsDir)) { | |
| 93 | + const dir = path.join(skillsDir, name) | |
| 94 | + if (name === 'synced') { | |
| 95 | + const warnings = ['Synced from claude.ai (managed by Claude, not by files you edit)'] | |
| 96 | + for (const child of await listDirs(dir)) { | |
| 97 | + const childDir = path.join(dir, child) | |
| 98 | + if (await addSkillDir(acc, childDir, { overrides, warnings })) continue | |
| 99 | + for (const grand of await listDirs(childDir)) { | |
| 100 | + await addSkillDir(acc, path.join(childDir, grand), { overrides, warnings }) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + continue | |
| 104 | + } | |
| 105 | + await addSkillDir(acc, dir, { overrides }) | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** Legacy custom commands (`commands/**\/*.md`) are skills. */ | |
| 110 | +export async function scanCommandsDir( | |
| 111 | + acc: Acc, | |
| 112 | + commandsDir: string, | |
| 113 | + opts: { overrides?: SkillOverrides; plugin?: string; prefix?: string } = {}, | |
| 114 | +): Promise<void> { | |
| 115 | + for (const rel of await listFilesRecursive(commandsDir, 5000)) { | |
| 116 | + if (!rel.toLowerCase().endsWith('.md')) continue | |
| 117 | + const file = path.join(commandsDir, rel) | |
| 118 | + try { | |
| 119 | + const text = await readText(file) | |
| 120 | + if (text === undefined) continue | |
| 121 | + const { frontmatter: fm } = parseFrontmatter(text) | |
| 122 | + const name = rel.slice(0, -3) | |
| 123 | + const details: SkillDetails = { | |
| 124 | + type: 'skill', | |
| 125 | + frontmatter: fm, | |
| 126 | + files: [path.basename(file)], | |
| 127 | + ...resolveVisibility(name, fm, opts.plugin ? undefined : opts.overrides), | |
| 128 | + } | |
| 129 | + acc.add('skill', opts.prefix ? `${opts.prefix}${name}` : name, { | |
| 130 | + description: str(fm.description), | |
| 131 | + source: source(file, 'file', await symlinkTarget(file), opts.plugin), | |
| 132 | + details, | |
| 133 | + hash: await hashPath(file), | |
| 134 | + warnings: ['Legacy custom command (merged into skills)'], | |
| 135 | + }) | |
| 136 | + } catch (err) { | |
| 137 | + acc.error(file, err) | |
| 138 | + } | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +export async function scanRulesDir(acc: Acc, rulesDir: string): Promise<void> { | |
| 143 | + for (const rel of await listFilesRecursive(rulesDir, 5000)) { | |
| 144 | + if (!rel.toLowerCase().endsWith('.md')) continue | |
| 145 | + const file = path.join(rulesDir, rel) | |
| 146 | + try { | |
| 147 | + const text = await readText(file) | |
| 148 | + if (text === undefined) continue | |
| 149 | + const { frontmatter: fm } = parseFrontmatter(text) | |
| 150 | + const details: RuleDetails = { type: 'rule', frontmatter: fm, paths: toList2(fm.paths) } | |
| 151 | + acc.add('rule', rel.slice(0, -3), { | |
| 152 | + description: str(fm.description), | |
| 153 | + source: source(file, 'file', await symlinkTarget(file)), | |
| 154 | + details, | |
| 155 | + hash: await hashPath(file), | |
| 156 | + }) | |
| 157 | + } catch (err) { | |
| 158 | + acc.error(file, err) | |
| 159 | + } | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +/** `paths` is a list of globs (a single string is one glob: globs may contain commas inside braces). */ | |
| 164 | +function toList2(v: unknown): string[] { | |
| 165 | + if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean) | |
| 166 | + if (typeof v === 'string' && v.trim()) return [v.trim()] | |
| 167 | + return [] | |
| 168 | +} | |
| 169 | + | |
| 170 | +/** `agents/*.md`, optionally namespaced (plugin agents). */ | |
| 171 | +export async function scanAgentsDir( | |
| 172 | + acc: Acc, | |
| 173 | + agentsDir: string, | |
| 174 | + opts: { plugin?: string; prefix?: string } = {}, | |
| 175 | +): Promise<void> { | |
| 176 | + for (const rel of await listFilesRecursive(agentsDir, 1000)) { | |
| 177 | + if (!rel.toLowerCase().endsWith('.md') || rel.includes('/')) continue | |
| 178 | + const file = path.join(agentsDir, rel) | |
| 179 | + try { | |
| 180 | + const text = await readText(file) | |
| 181 | + if (text === undefined) continue | |
| 182 | + const { frontmatter: fm } = parseFrontmatter(text) | |
| 183 | + const name = str(fm.name) ?? rel.slice(0, -3) | |
| 184 | + const details: AgentDetails = { type: 'agent', frontmatter: fm } | |
| 185 | + const model = str(fm.model) | |
| 186 | + if (model) details.model = model | |
| 187 | + const tools = toList(fm.tools) | |
| 188 | + if (tools) details.tools = tools | |
| 189 | + acc.add('agent', opts.prefix ? `${opts.prefix}${name}` : name, { | |
| 190 | + description: str(fm.description), | |
| 191 | + source: source(file, 'file', await symlinkTarget(file), opts.plugin), | |
| 192 | + details, | |
| 193 | + hash: await hashPath(file), | |
| 194 | + }) | |
| 195 | + } catch (err) { | |
| 196 | + acc.error(file, err) | |
| 197 | + } | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +/** Extracts `@path` imports, ignoring fenced code blocks and inline code spans. */ | |
| 202 | +export function extractImports(text: string): string[] { | |
| 203 | + const stripped = text | |
| 204 | + .replace(/^(\s*)(```|~~~)[^\n]*\n[\s\S]*?(?:\n\s*\2[^\n]*|$)/gm, '') | |
| 205 | + .replace(/(`+)[^`\n]*?\1/g, '') | |
| 206 | + const out: string[] = [] | |
| 207 | + for (const m of stripped.matchAll(/(?:^|[\s(])@((?:\\ |[^\s])+)/g)) { | |
| 208 | + let p = m[1]!.replace(/\\ /g, ' ').replace(/[.,;:!?)\]}>"']+$/, '') | |
| 209 | + if (!p || !/[/.~\\]/.test(p)) continue | |
| 210 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) continue | |
| 211 | + if (!out.includes(p)) out.push(p) | |
| 212 | + } | |
| 213 | + return out | |
| 214 | +} | |
| 215 | + | |
| 216 | +export async function addInstructions( | |
| 217 | + acc: Acc, | |
| 218 | + file: string, | |
| 219 | + extra: { name?: string; loaded?: boolean; notLoadedReason?: string; warnings?: string[] } = {}, | |
| 220 | +): Promise<boolean> { | |
| 221 | + try { | |
| 222 | + const text = await readText(file) | |
| 223 | + if (text === undefined) return false | |
| 224 | + const st = await stat(file) | |
| 225 | + const fileName = path.basename(file) | |
| 226 | + const details: InstructionsDetails = { | |
| 227 | + type: 'instructions', | |
| 228 | + fileName, | |
| 229 | + sizeBytes: st.size, | |
| 230 | + imports: extractImports(text), | |
| 231 | + loaded: extra.loaded ?? true, | |
| 232 | + } | |
| 233 | + if (extra.notLoadedReason) details.notLoadedReason = extra.notLoadedReason | |
| 234 | + acc.add('instructions', extra.name ?? fileName, { | |
| 235 | + source: source(file, 'file', await symlinkTarget(file)), | |
| 236 | + details, | |
| 237 | + hash: await hashPath(file), | |
| 238 | + warnings: extra.warnings, | |
| 239 | + }) | |
| 240 | + return true | |
| 241 | + } catch (err) { | |
| 242 | + acc.error(file, err) | |
| 243 | + return false | |
| 244 | + } | |
| 245 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,245 @@ | |||
| 1 | +import { stat } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import type { AgentDetails, InstructionsDetails, ItemSource, RuleDetails, SkillDetails } from '../../model.ts' | ||
| 4 | +import { | ||
| 5 | + hashPath, | ||
| 6 | + listDirs, | ||
| 7 | + listFilesRecursive, | ||
| 8 | + parseFrontmatter, | ||
| 9 | + readText, | ||
| 10 | + symlinkTarget, | ||
| 11 | +} from '../../util.ts' | ||
| 12 | +import { Acc, str, toList } from './acc.ts' | ||
| 13 | + | ||
| 14 | +export type SkillOverrides = Record<string, string> | ||
| 15 | + | ||
| 16 | +export interface SkillOptions { | ||
| 17 | + /** `skillOverrides` of the settings that apply (not applied to plugin skills). */ | ||
| 18 | + overrides?: SkillOverrides | ||
| 19 | + plugin?: string | ||
| 20 | + warnings?: string[] | ||
| 21 | + /** Displayed name when it must differ from the folder/file name (plugin skills are namespaced). */ | ||
| 22 | + displayName?: string | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +function isTrue(v: unknown): boolean { | ||
| 26 | + return v === true || (typeof v === 'string' && v.trim().toLowerCase() === 'true') | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +function resolveVisibility( | ||
| 30 | + name: string, | ||
| 31 | + fm: Record<string, unknown>, | ||
| 32 | + overrides: SkillOverrides | undefined, | ||
| 33 | +): Pick<SkillDetails, 'visibility' | 'visibilitySource'> { | ||
| 34 | + let visibility: SkillDetails['visibility'] = 'auto' | ||
| 35 | + let visibilitySource: string | undefined | ||
| 36 | + if (isTrue(fm['disable-model-invocation'])) { | ||
| 37 | + visibility = 'on-demand' | ||
| 38 | + visibilitySource = 'frontmatter:disable-model-invocation' | ||
| 39 | + } | ||
| 40 | + const o = overrides?.[name] | ||
| 41 | + if (o === 'on' || o === 'name-only' || o === 'off' || o === 'user-invocable-only') { | ||
| 42 | + visibility = o === 'user-invocable-only' ? 'on-demand' : o === 'on' ? 'auto' : o | ||
| 43 | + visibilitySource = 'settings:skillOverrides' | ||
| 44 | + } | ||
| 45 | + const out: Pick<SkillDetails, 'visibility' | 'visibilitySource'> = { visibility } | ||
| 46 | + if (visibilitySource) out.visibilitySource = visibilitySource | ||
| 47 | + return out | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +function source(p: string, kind: ItemSource['kind'], link: string | undefined, plugin?: string): ItemSource { | ||
| 51 | + const s: ItemSource = { path: p, kind } | ||
| 52 | + if (link) s.symlinkTarget = link | ||
| 53 | + if (plugin) s.plugin = plugin | ||
| 54 | + return s | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +/** Adds a skill folder if it holds a SKILL.md. Returns true when an item was added. */ | ||
| 58 | +export async function addSkillDir(acc: Acc, dir: string, opts: SkillOptions = {}): Promise<boolean> { | ||
| 59 | + const skillFile = path.join(dir, 'SKILL.md') | ||
| 60 | + try { | ||
| 61 | + const text = await readText(skillFile) | ||
| 62 | + if (text === undefined) return false | ||
| 63 | + const { frontmatter: fm } = parseFrontmatter(text) | ||
| 64 | + const folder = path.basename(dir) | ||
| 65 | + const name = str(fm.name) ?? folder | ||
| 66 | + const shown = opts.displayName ?? name | ||
| 67 | + const details: SkillDetails = { | ||
| 68 | + type: 'skill', | ||
| 69 | + frontmatter: fm, | ||
| 70 | + files: await listFilesRecursive(dir, 200), | ||
| 71 | + ...resolveVisibility(name, fm, opts.plugin ? undefined : opts.overrides), | ||
| 72 | + } | ||
| 73 | + acc.add('skill', shown, { | ||
| 74 | + description: str(fm.description), | ||
| 75 | + source: source(dir, 'dir', await symlinkTarget(dir), opts.plugin), | ||
| 76 | + details, | ||
| 77 | + hash: await hashPath(dir), | ||
| 78 | + warnings: opts.warnings ? [...opts.warnings] : [], | ||
| 79 | + }) | ||
| 80 | + return true | ||
| 81 | + } catch (err) { | ||
| 82 | + acc.error(skillFile, err) | ||
| 83 | + return false | ||
| 84 | + } | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +/** | ||
| 88 | + * `<base>/skills`: one folder per skill. `synced/` holds skills synced from claude.ai; on disk they sit one level | ||
| 89 | + * deeper (`synced/<id>/<skill>/SKILL.md`), so folders without a SKILL.md are searched one level down. | ||
| 90 | + */ | ||
| 91 | +export async function scanSkillsDir(acc: Acc, skillsDir: string, overrides: SkillOverrides): Promise<void> { | ||
| 92 | + for (const name of await listDirs(skillsDir)) { | ||
| 93 | + const dir = path.join(skillsDir, name) | ||
| 94 | + if (name === 'synced') { | ||
| 95 | + const warnings = ['Synced from claude.ai (managed by Claude, not by files you edit)'] | ||
| 96 | + for (const child of await listDirs(dir)) { | ||
| 97 | + const childDir = path.join(dir, child) | ||
| 98 | + if (await addSkillDir(acc, childDir, { overrides, warnings })) continue | ||
| 99 | + for (const grand of await listDirs(childDir)) { | ||
| 100 | + await addSkillDir(acc, path.join(childDir, grand), { overrides, warnings }) | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | + continue | ||
| 104 | + } | ||
| 105 | + await addSkillDir(acc, dir, { overrides }) | ||
| 106 | + } | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +/** Legacy custom commands (`commands/**\/*.md`) are skills. */ | ||
| 110 | +export async function scanCommandsDir( | ||
| 111 | + acc: Acc, | ||
| 112 | + commandsDir: string, | ||
| 113 | + opts: { overrides?: SkillOverrides; plugin?: string; prefix?: string } = {}, | ||
| 114 | +): Promise<void> { | ||
| 115 | + for (const rel of await listFilesRecursive(commandsDir, 5000)) { | ||
| 116 | + if (!rel.toLowerCase().endsWith('.md')) continue | ||
| 117 | + const file = path.join(commandsDir, rel) | ||
| 118 | + try { | ||
| 119 | + const text = await readText(file) | ||
| 120 | + if (text === undefined) continue | ||
| 121 | + const { frontmatter: fm } = parseFrontmatter(text) | ||
| 122 | + const name = rel.slice(0, -3) | ||
| 123 | + const details: SkillDetails = { | ||
| 124 | + type: 'skill', | ||
| 125 | + frontmatter: fm, | ||
| 126 | + files: [path.basename(file)], | ||
| 127 | + ...resolveVisibility(name, fm, opts.plugin ? undefined : opts.overrides), | ||
| 128 | + } | ||
| 129 | + acc.add('skill', opts.prefix ? `${opts.prefix}${name}` : name, { | ||
| 130 | + description: str(fm.description), | ||
| 131 | + source: source(file, 'file', await symlinkTarget(file), opts.plugin), | ||
| 132 | + details, | ||
| 133 | + hash: await hashPath(file), | ||
| 134 | + warnings: ['Legacy custom command (merged into skills)'], | ||
| 135 | + }) | ||
| 136 | + } catch (err) { | ||
| 137 | + acc.error(file, err) | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +export async function scanRulesDir(acc: Acc, rulesDir: string): Promise<void> { | ||
| 143 | + for (const rel of await listFilesRecursive(rulesDir, 5000)) { | ||
| 144 | + if (!rel.toLowerCase().endsWith('.md')) continue | ||
| 145 | + const file = path.join(rulesDir, rel) | ||
| 146 | + try { | ||
| 147 | + const text = await readText(file) | ||
| 148 | + if (text === undefined) continue | ||
| 149 | + const { frontmatter: fm } = parseFrontmatter(text) | ||
| 150 | + const details: RuleDetails = { type: 'rule', frontmatter: fm, paths: toList2(fm.paths) } | ||
| 151 | + acc.add('rule', rel.slice(0, -3), { | ||
| 152 | + description: str(fm.description), | ||
| 153 | + source: source(file, 'file', await symlinkTarget(file)), | ||
| 154 | + details, | ||
| 155 | + hash: await hashPath(file), | ||
| 156 | + }) | ||
| 157 | + } catch (err) { | ||
| 158 | + acc.error(file, err) | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +/** `paths` is a list of globs (a single string is one glob: globs may contain commas inside braces). */ | ||
| 164 | +function toList2(v: unknown): string[] { | ||
| 165 | + if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean) | ||
| 166 | + if (typeof v === 'string' && v.trim()) return [v.trim()] | ||
| 167 | + return [] | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +/** `agents/*.md`, optionally namespaced (plugin agents). */ | ||
| 171 | +export async function scanAgentsDir( | ||
| 172 | + acc: Acc, | ||
| 173 | + agentsDir: string, | ||
| 174 | + opts: { plugin?: string; prefix?: string } = {}, | ||
| 175 | +): Promise<void> { | ||
| 176 | + for (const rel of await listFilesRecursive(agentsDir, 1000)) { | ||
| 177 | + if (!rel.toLowerCase().endsWith('.md') || rel.includes('/')) continue | ||
| 178 | + const file = path.join(agentsDir, rel) | ||
| 179 | + try { | ||
| 180 | + const text = await readText(file) | ||
| 181 | + if (text === undefined) continue | ||
| 182 | + const { frontmatter: fm } = parseFrontmatter(text) | ||
| 183 | + const name = str(fm.name) ?? rel.slice(0, -3) | ||
| 184 | + const details: AgentDetails = { type: 'agent', frontmatter: fm } | ||
| 185 | + const model = str(fm.model) | ||
| 186 | + if (model) details.model = model | ||
| 187 | + const tools = toList(fm.tools) | ||
| 188 | + if (tools) details.tools = tools | ||
| 189 | + acc.add('agent', opts.prefix ? `${opts.prefix}${name}` : name, { | ||
| 190 | + description: str(fm.description), | ||
| 191 | + source: source(file, 'file', await symlinkTarget(file), opts.plugin), | ||
| 192 | + details, | ||
| 193 | + hash: await hashPath(file), | ||
| 194 | + }) | ||
| 195 | + } catch (err) { | ||
| 196 | + acc.error(file, err) | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +/** Extracts `@path` imports, ignoring fenced code blocks and inline code spans. */ | ||
| 202 | +export function extractImports(text: string): string[] { | ||
| 203 | + const stripped = text | ||
| 204 | + .replace(/^(\s*)(```|~~~)[^\n]*\n[\s\S]*?(?:\n\s*\2[^\n]*|$)/gm, '') | ||
| 205 | + .replace(/(`+)[^`\n]*?\1/g, '') | ||
| 206 | + const out: string[] = [] | ||
| 207 | + for (const m of stripped.matchAll(/(?:^|[\s(])@((?:\\ |[^\s])+)/g)) { | ||
| 208 | + let p = m[1]!.replace(/\\ /g, ' ').replace(/[.,;:!?)\]}>"']+$/, '') | ||
| 209 | + if (!p || !/[/.~\\]/.test(p)) continue | ||
| 210 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(p)) continue | ||
| 211 | + if (!out.includes(p)) out.push(p) | ||
| 212 | + } | ||
| 213 | + return out | ||
| 214 | +} | ||
| 215 | + | ||
| 216 | +export async function addInstructions( | ||
| 217 | + acc: Acc, | ||
| 218 | + file: string, | ||
| 219 | + extra: { name?: string; loaded?: boolean; notLoadedReason?: string; warnings?: string[] } = {}, | ||
| 220 | +): Promise<boolean> { | ||
| 221 | + try { | ||
| 222 | + const text = await readText(file) | ||
| 223 | + if (text === undefined) return false | ||
| 224 | + const st = await stat(file) | ||
| 225 | + const fileName = path.basename(file) | ||
| 226 | + const details: InstructionsDetails = { | ||
| 227 | + type: 'instructions', | ||
| 228 | + fileName, | ||
| 229 | + sizeBytes: st.size, | ||
| 230 | + imports: extractImports(text), | ||
| 231 | + loaded: extra.loaded ?? true, | ||
| 232 | + } | ||
| 233 | + if (extra.notLoadedReason) details.notLoadedReason = extra.notLoadedReason | ||
| 234 | + acc.add('instructions', extra.name ?? fileName, { | ||
| 235 | + source: source(file, 'file', await symlinkTarget(file)), | ||
| 236 | + details, | ||
| 237 | + hash: await hashPath(file), | ||
| 238 | + warnings: extra.warnings, | ||
| 239 | + }) | ||
| 240 | + return true | ||
| 241 | + } catch (err) { | ||
| 242 | + acc.error(file, err) | ||
| 243 | + return false | ||
| 244 | + } | ||
| 245 | +} | ||
added
packages/core/src/scanners/claude/hooks.ts +49 -0 | new file mode 100644 | ||
| @@ -0,0 +1,49 @@ | ||
| 1 | +import type { HookDetails, ItemSource } from '../../model.ts' | |
| 2 | +import { Acc, isRecord, str } from './acc.ts' | |
| 3 | +import { sanitizeUrl } from './redact.ts' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Adds one item per hook handler from an event map `{ Event: [{ matcher?, hooks: [{ type, command }] }] }`. | |
| 7 | + * Names are `${event}:${matcher || '*'}:${index}` where index counts handlers within the event. | |
| 8 | + */ | |
| 9 | +export function addHooks( | |
| 10 | + acc: Acc, | |
| 11 | + hooks: unknown, | |
| 12 | + file: string, | |
| 13 | + opts: { configPrefix?: string; plugin?: string } = {}, | |
| 14 | +): void { | |
| 15 | + if (hooks === undefined || hooks === null) return | |
| 16 | + if (!isRecord(hooks)) { | |
| 17 | + acc.error(file, undefined, '`hooks` is not an object') | |
| 18 | + return | |
| 19 | + } | |
| 20 | + const prefix = opts.configPrefix ?? 'hooks' | |
| 21 | + for (const [event, groups] of Object.entries(hooks)) { | |
| 22 | + if (!Array.isArray(groups)) continue | |
| 23 | + let index = 0 | |
| 24 | + groups.forEach((group, gi) => { | |
| 25 | + if (!isRecord(group) || !Array.isArray(group.hooks)) return | |
| 26 | + const matcher = str(group.matcher) | |
| 27 | + group.hooks.forEach((h, hi) => { | |
| 28 | + if (!isRecord(h)) return | |
| 29 | + const handlerType = str(h.type) ?? 'command' | |
| 30 | + const details: HookDetails = { type: 'hook', event, handlerType } | |
| 31 | + if (matcher) details.matcher = matcher | |
| 32 | + const rawUrl = str(h.url) | |
| 33 | + const command = str(h.command) ?? (rawUrl ? sanitizeUrl(rawUrl).url : undefined) | |
| 34 | + if (command) details.command = command | |
| 35 | + const source: ItemSource = { | |
| 36 | + path: file, | |
| 37 | + kind: 'config-entry', | |
| 38 | + configKey: `${prefix}.${event}[${gi}].hooks[${hi}]`, | |
| 39 | + } | |
| 40 | + if (opts.plugin) source.plugin = opts.plugin | |
| 41 | + acc.add('hook', `${event}:${matcher || '*'}:${index++}`, { | |
| 42 | + description: str(h.statusMessage), | |
| 43 | + source, | |
| 44 | + details, | |
| 45 | + }) | |
| 46 | + }) | |
| 47 | + }) | |
| 48 | + } | |
| 49 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,49 @@ | |||
| 1 | +import type { HookDetails, ItemSource } from '../../model.ts' | ||
| 2 | +import { Acc, isRecord, str } from './acc.ts' | ||
| 3 | +import { sanitizeUrl } from './redact.ts' | ||
| 4 | + | ||
| 5 | +/** | ||
| 6 | + * Adds one item per hook handler from an event map `{ Event: [{ matcher?, hooks: [{ type, command }] }] }`. | ||
| 7 | + * Names are `${event}:${matcher || '*'}:${index}` where index counts handlers within the event. | ||
| 8 | + */ | ||
| 9 | +export function addHooks( | ||
| 10 | + acc: Acc, | ||
| 11 | + hooks: unknown, | ||
| 12 | + file: string, | ||
| 13 | + opts: { configPrefix?: string; plugin?: string } = {}, | ||
| 14 | +): void { | ||
| 15 | + if (hooks === undefined || hooks === null) return | ||
| 16 | + if (!isRecord(hooks)) { | ||
| 17 | + acc.error(file, undefined, '`hooks` is not an object') | ||
| 18 | + return | ||
| 19 | + } | ||
| 20 | + const prefix = opts.configPrefix ?? 'hooks' | ||
| 21 | + for (const [event, groups] of Object.entries(hooks)) { | ||
| 22 | + if (!Array.isArray(groups)) continue | ||
| 23 | + let index = 0 | ||
| 24 | + groups.forEach((group, gi) => { | ||
| 25 | + if (!isRecord(group) || !Array.isArray(group.hooks)) return | ||
| 26 | + const matcher = str(group.matcher) | ||
| 27 | + group.hooks.forEach((h, hi) => { | ||
| 28 | + if (!isRecord(h)) return | ||
| 29 | + const handlerType = str(h.type) ?? 'command' | ||
| 30 | + const details: HookDetails = { type: 'hook', event, handlerType } | ||
| 31 | + if (matcher) details.matcher = matcher | ||
| 32 | + const rawUrl = str(h.url) | ||
| 33 | + const command = str(h.command) ?? (rawUrl ? sanitizeUrl(rawUrl).url : undefined) | ||
| 34 | + if (command) details.command = command | ||
| 35 | + const source: ItemSource = { | ||
| 36 | + path: file, | ||
| 37 | + kind: 'config-entry', | ||
| 38 | + configKey: `${prefix}.${event}[${gi}].hooks[${hi}]`, | ||
| 39 | + } | ||
| 40 | + if (opts.plugin) source.plugin = opts.plugin | ||
| 41 | + acc.add('hook', `${event}:${matcher || '*'}:${index++}`, { | ||
| 42 | + description: str(h.statusMessage), | ||
| 43 | + source, | ||
| 44 | + details, | ||
| 45 | + }) | ||
| 46 | + }) | ||
| 47 | + }) | ||
| 48 | + } | ||
| 49 | +} | ||
modified
packages/core/src/scanners/claude/index.ts +226 -8 | @@ -1,12 +1,230 @@ | ||
| 1 | -import type { AgentScanner } from '../../model.ts' | |
| 1 | +import path from 'node:path' | |
| 2 | +import type { AgentScanner, InstructionsDetails, ScanContext, ScanResult } from '../../model.ts' | |
| 3 | +import { isDirectory, normalizePath, pathExists, readJson, readText, stripBom } from '../../util.ts' | |
| 4 | +import { Acc, isRecord } from './acc.ts' | |
| 5 | +import { | |
| 6 | + addInstructions, | |
| 7 | + scanAgentsDir, | |
| 8 | + scanCommandsDir, | |
| 9 | + scanRulesDir, | |
| 10 | + scanSkillsDir, | |
| 11 | + type SkillOverrides, | |
| 12 | +} from './content.ts' | |
| 13 | +import { addHooks } from './hooks.ts' | |
| 14 | +import { addMcpServer } from './mcp.ts' | |
| 15 | +import { scanPlugins } from './plugins.ts' | |
| 16 | + | |
| 17 | +type Json = Record<string, unknown> | |
| 18 | + | |
| 19 | +/** Reads a settings file. Missing file yields {}; a broken file is reported and yields {}. */ | |
| 20 | +async function loadSettings(acc: Acc, file: string): Promise<Json> { | |
| 21 | + try { | |
| 22 | + const j = await readJson(file) | |
| 23 | + return isRecord(j) ? j : {} | |
| 24 | + } catch (err) { | |
| 25 | + acc.error(file, err) | |
| 26 | + return {} | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * Reads `~/.claude.json`. It holds OAuth and account data: callers must only pick `mcpServers`, | |
| 32 | + * `disabledMcpjsonServers` and per-project entries of the same keys. Parse errors are reported without any content. | |
| 33 | + */ | |
| 34 | +async function loadClaudeJson(acc: Acc, homeDir: string): Promise<Json> { | |
| 35 | + const file = path.join(homeDir, '.claude.json') | |
| 36 | + const text = await readText(file) | |
| 37 | + if (text === undefined) return {} | |
| 38 | + try { | |
| 39 | + const j: unknown = JSON.parse(stripBom(text)) | |
| 40 | + return isRecord(j) ? j : {} | |
| 41 | + } catch { | |
| 42 | + acc.error(file, undefined, 'Invalid JSON') | |
| 43 | + return {} | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +function overridesOf(settings: Json): SkillOverrides { | |
| 48 | + const out: SkillOverrides = {} | |
| 49 | + if (isRecord(settings.skillOverrides)) { | |
| 50 | + for (const [k, v] of Object.entries(settings.skillOverrides)) if (typeof v === 'string') out[k] = v | |
| 51 | + } | |
| 52 | + return out | |
| 53 | +} | |
| 54 | + | |
| 55 | +function enabledPluginsOf(settings: Json): Record<string, boolean> { | |
| 56 | + const out: Record<string, boolean> = {} | |
| 57 | + if (isRecord(settings.enabledPlugins)) { | |
| 58 | + for (const [k, v] of Object.entries(settings.enabledPlugins)) if (typeof v === 'boolean') out[k] = v | |
| 59 | + } | |
| 60 | + return out | |
| 61 | +} | |
| 62 | + | |
| 63 | +function stringList(v: unknown): string[] { | |
| 64 | + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] | |
| 65 | +} | |
| 66 | + | |
| 67 | +function samePath(a: string, b: string): boolean { | |
| 68 | + const [x, y] = [normalizePath(a), normalizePath(b)] | |
| 69 | + return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y | |
| 70 | +} | |
| 71 | + | |
| 72 | +async function scanHome(ctx: ScanContext): Promise<ScanResult> { | |
| 73 | + const acc = new Acc() | |
| 74 | + const claudeDir = path.join(ctx.homeDir, '.claude') | |
| 75 | + const detected = (await pathExists(claudeDir)) || (await pathExists(path.join(ctx.homeDir, '.claude.json'))) | |
| 76 | + | |
| 77 | + const settingsFile = path.join(claudeDir, 'settings.json') | |
| 78 | + const settings = await loadSettings(acc, settingsFile) | |
| 79 | + const overrides = overridesOf(settings) | |
| 80 | + | |
| 81 | + await scanSkillsDir(acc, path.join(claudeDir, 'skills'), overrides) | |
| 82 | + await scanCommandsDir(acc, path.join(claudeDir, 'commands'), { overrides }) | |
| 83 | + await scanRulesDir(acc, path.join(claudeDir, 'rules')) | |
| 84 | + await scanAgentsDir(acc, path.join(claudeDir, 'agents')) | |
| 85 | + await addInstructions(acc, path.join(claudeDir, 'CLAUDE.md')) | |
| 86 | + addHooks(acc, settings.hooks, settingsFile) | |
| 87 | + | |
| 88 | + // User-scope MCP servers: only `mcpServers` is read from ~/.claude.json. | |
| 89 | + const claudeJson = await loadClaudeJson(acc, ctx.homeDir) | |
| 90 | + if (isRecord(claudeJson.mcpServers)) { | |
| 91 | + const file = path.join(ctx.homeDir, '.claude.json') | |
| 92 | + for (const [name, cfg] of Object.entries(claudeJson.mcpServers)) { | |
| 93 | + addMcpServer(acc, name, cfg, { | |
| 94 | + agentScope: 'user', | |
| 95 | + enabled: true, | |
| 96 | + source: { path: file, kind: 'config-entry', configKey: `mcpServers.${name}` }, | |
| 97 | + }) | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + await scanPlugins(acc, { homeDir: ctx.homeDir, enabled: enabledPluginsOf(settings) }) | |
| 102 | + | |
| 103 | + return { detected: detected || acc.items.length > 0, items: acc.items, errors: acc.errors } | |
| 104 | +} | |
| 105 | + | |
| 106 | +/** True when a CLAUDE.md-like file exists in `root` or in a directory above it (they shadow AGENTS.md). */ | |
| 107 | +async function findClaudeMdAbove(root: string): Promise<string | undefined> { | |
| 108 | + let dir = path.resolve(root) | |
| 109 | + for (;;) { | |
| 110 | + // Only CLAUDE.md and CLAUDE.local.md: a .claude/CLAUDE.md in a parent could be the user memory (~/.claude). | |
| 111 | + const candidates = ['CLAUDE.md', 'CLAUDE.local.md'] | |
| 112 | + for (const c of candidates) { | |
| 113 | + if (await pathExists(path.join(dir, c))) return path.join(dir, c) | |
| 114 | + } | |
| 115 | + const parent = path.dirname(dir) | |
| 116 | + if (parent === dir) return undefined | |
| 117 | + dir = parent | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 121 | +async function scanProject(root: string, ctx: ScanContext): Promise<ScanResult> { | |
| 122 | + const acc = new Acc() | |
| 123 | + const claudeDir = path.join(root, '.claude') | |
| 124 | + const mcpFile = path.join(root, '.mcp.json') | |
| 125 | + const detected = | |
| 126 | + (await isDirectory(claudeDir)) || | |
| 127 | + (await pathExists(path.join(root, 'CLAUDE.md'))) || | |
| 128 | + (await pathExists(path.join(root, 'CLAUDE.local.md'))) || | |
| 129 | + (await pathExists(mcpFile)) | |
| 130 | + | |
| 131 | + const userSettings = await loadSettings(acc, path.join(ctx.homeDir, '.claude', 'settings.json')) | |
| 132 | + const projectSettingsFile = path.join(claudeDir, 'settings.json') | |
| 133 | + const localSettingsFile = path.join(claudeDir, 'settings.local.json') | |
| 134 | + const projectSettings = await loadSettings(acc, projectSettingsFile) | |
| 135 | + const localSettings = await loadSettings(acc, localSettingsFile) | |
| 136 | + | |
| 137 | + // Later wins: user, then project, then local. | |
| 138 | + const overrides: SkillOverrides = { | |
| 139 | + ...overridesOf(userSettings), | |
| 140 | + ...overridesOf(projectSettings), | |
| 141 | + ...overridesOf(localSettings), | |
| 142 | + } | |
| 143 | + | |
| 144 | + await scanSkillsDir(acc, path.join(claudeDir, 'skills'), overrides) | |
| 145 | + await scanCommandsDir(acc, path.join(claudeDir, 'commands'), { overrides }) | |
| 146 | + await scanRulesDir(acc, path.join(claudeDir, 'rules')) | |
| 147 | + await scanAgentsDir(acc, path.join(claudeDir, 'agents')) | |
| 148 | + | |
| 149 | + // Instructions. AGENTS.md is read only when no CLAUDE.md-like file exists in the working directory or above. | |
| 150 | + await addInstructions(acc, path.join(root, 'CLAUDE.md')) | |
| 151 | + await addInstructions(acc, path.join(claudeDir, 'CLAUDE.md'), { name: '.claude/CLAUDE.md' }) | |
| 152 | + await addInstructions(acc, path.join(root, 'CLAUDE.local.md')) | |
| 153 | + const agentsFile = path.join(root, 'AGENTS.md') | |
| 154 | + const claudeMdItems = acc.items.filter((i) => i.type === 'instructions') | |
| 155 | + const importedBy = claudeMdItems.find((i) => | |
| 156 | + (i.details as InstructionsDetails).imports.some((imp) => | |
| 157 | + samePath(path.resolve(path.dirname(i.source.path), imp.replace(/^~(?=[/\\])/, ctx.homeDir)), agentsFile), | |
| 158 | + ), | |
| 159 | + ) | |
| 160 | + const shadow = claudeMdItems.length > 0 ? claudeMdItems[0]!.source.path : await findClaudeMdAbove(path.dirname(root)) | |
| 161 | + if (importedBy) { | |
| 162 | + await addInstructions(acc, agentsFile, { loaded: true }) | |
| 163 | + } else if (shadow) { | |
| 164 | + await addInstructions(acc, agentsFile, { | |
| 165 | + loaded: false, | |
| 166 | + notLoadedReason: `Claude Code reads AGENTS.md only when there is no CLAUDE.md or CLAUDE.local.md in the working directory or above (found ${normalizePath(shadow)}); import it with @AGENTS.md to load it`, | |
| 167 | + }) | |
| 168 | + } else { | |
| 169 | + await addInstructions(acc, agentsFile, { loaded: true }) | |
| 170 | + } | |
| 171 | + | |
| 172 | + addHooks(acc, projectSettings.hooks, projectSettingsFile) | |
| 173 | + addHooks(acc, localSettings.hooks, localSettingsFile) | |
| 174 | + | |
| 175 | + // MCP. `disabledMcpjsonServers` may come from any settings layer or from the project entry of ~/.claude.json. | |
| 176 | + const claudeJson = await loadClaudeJson(acc, ctx.homeDir) | |
| 177 | + const projects = isRecord(claudeJson.projects) ? claudeJson.projects : {} | |
| 178 | + const projectKey = Object.keys(projects).find((k) => samePath(k, root)) | |
| 179 | + const entry = projectKey !== undefined && isRecord(projects[projectKey]) ? (projects[projectKey] as Json) : {} | |
| 180 | + const disabled = new Set<string>([ | |
| 181 | + ...stringList(userSettings.disabledMcpjsonServers), | |
| 182 | + ...stringList(projectSettings.disabledMcpjsonServers), | |
| 183 | + ...stringList(localSettings.disabledMcpjsonServers), | |
| 184 | + ...stringList(entry.disabledMcpjsonServers), | |
| 185 | + ]) | |
| 186 | + | |
| 187 | + try { | |
| 188 | + const mcp = await readJson(mcpFile) | |
| 189 | + if (isRecord(mcp) && isRecord(mcp.mcpServers)) { | |
| 190 | + for (const [name, cfg] of Object.entries(mcp.mcpServers)) { | |
| 191 | + addMcpServer(acc, name, cfg, { | |
| 192 | + agentScope: 'project', | |
| 193 | + enabled: !disabled.has(name), | |
| 194 | + source: { path: mcpFile, kind: 'config-entry', configKey: `mcpServers.${name}` }, | |
| 195 | + }) | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } catch (err) { | |
| 199 | + acc.error(mcpFile, err) | |
| 200 | + } | |
| 201 | + | |
| 202 | + if (projectKey !== undefined && isRecord(entry.mcpServers)) { | |
| 203 | + const file = path.join(ctx.homeDir, '.claude.json') | |
| 204 | + for (const [name, cfg] of Object.entries(entry.mcpServers)) { | |
| 205 | + addMcpServer(acc, name, cfg, { | |
| 206 | + agentScope: 'local', | |
| 207 | + enabled: true, | |
| 208 | + source: { | |
| 209 | + path: file, | |
| 210 | + kind: 'config-entry', | |
| 211 | + configKey: `projects[${JSON.stringify(projectKey)}].mcpServers.${name}`, | |
| 212 | + }, | |
| 213 | + }) | |
| 214 | + } | |
| 215 | + } | |
| 216 | + | |
| 217 | + await scanPlugins(acc, { | |
| 218 | + homeDir: ctx.homeDir, | |
| 219 | + enabled: { ...enabledPluginsOf(projectSettings), ...enabledPluginsOf(localSettings) }, | |
| 220 | + projectRoot: root, | |
| 221 | + }) | |
| 222 | + | |
| 223 | + return { detected: detected || acc.items.length > 0, items: acc.items, errors: acc.errors } | |
| 224 | +} | |
| 2 | 225 | |
| 3 | -// Placeholder: replaced by the real scanner implementation. | |
| 4 | 226 | export const claudeScanner: AgentScanner = { |
| 5 | 227 | agent: 'claude', |
| 6 | - async scanHome() { | |
| 7 | - return { detected: false, items: [], errors: [] } | |
| 8 | - }, | |
| 9 | - async scanProject() { | |
| 10 | - return { detected: false, items: [], errors: [] } | |
| 11 | - }, | |
| 228 | + scanHome, | |
| 229 | + scanProject, | |
| 12 | 230 | } |
| @@ -1,12 +1,230 @@ | |||
| 1 | -import type { AgentScanner } from '../../model.ts' | 1 | +import path from 'node:path' |
| 2 | +import type { AgentScanner, InstructionsDetails, ScanContext, ScanResult } from '../../model.ts' | ||
| 3 | +import { isDirectory, normalizePath, pathExists, readJson, readText, stripBom } from '../../util.ts' | ||
| 4 | +import { Acc, isRecord } from './acc.ts' | ||
| 5 | +import { | ||
| 6 | + addInstructions, | ||
| 7 | + scanAgentsDir, | ||
| 8 | + scanCommandsDir, | ||
| 9 | + scanRulesDir, | ||
| 10 | + scanSkillsDir, | ||
| 11 | + type SkillOverrides, | ||
| 12 | +} from './content.ts' | ||
| 13 | +import { addHooks } from './hooks.ts' | ||
| 14 | +import { addMcpServer } from './mcp.ts' | ||
| 15 | +import { scanPlugins } from './plugins.ts' | ||
| 16 | + | ||
| 17 | +type Json = Record<string, unknown> | ||
| 18 | + | ||
| 19 | +/** Reads a settings file. Missing file yields {}; a broken file is reported and yields {}. */ | ||
| 20 | +async function loadSettings(acc: Acc, file: string): Promise<Json> { | ||
| 21 | + try { | ||
| 22 | + const j = await readJson(file) | ||
| 23 | + return isRecord(j) ? j : {} | ||
| 24 | + } catch (err) { | ||
| 25 | + acc.error(file, err) | ||
| 26 | + return {} | ||
| 27 | + } | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +/** | ||
| 31 | + * Reads `~/.claude.json`. It holds OAuth and account data: callers must only pick `mcpServers`, | ||
| 32 | + * `disabledMcpjsonServers` and per-project entries of the same keys. Parse errors are reported without any content. | ||
| 33 | + */ | ||
| 34 | +async function loadClaudeJson(acc: Acc, homeDir: string): Promise<Json> { | ||
| 35 | + const file = path.join(homeDir, '.claude.json') | ||
| 36 | + const text = await readText(file) | ||
| 37 | + if (text === undefined) return {} | ||
| 38 | + try { | ||
| 39 | + const j: unknown = JSON.parse(stripBom(text)) | ||
| 40 | + return isRecord(j) ? j : {} | ||
| 41 | + } catch { | ||
| 42 | + acc.error(file, undefined, 'Invalid JSON') | ||
| 43 | + return {} | ||
| 44 | + } | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +function overridesOf(settings: Json): SkillOverrides { | ||
| 48 | + const out: SkillOverrides = {} | ||
| 49 | + if (isRecord(settings.skillOverrides)) { | ||
| 50 | + for (const [k, v] of Object.entries(settings.skillOverrides)) if (typeof v === 'string') out[k] = v | ||
| 51 | + } | ||
| 52 | + return out | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +function enabledPluginsOf(settings: Json): Record<string, boolean> { | ||
| 56 | + const out: Record<string, boolean> = {} | ||
| 57 | + if (isRecord(settings.enabledPlugins)) { | ||
| 58 | + for (const [k, v] of Object.entries(settings.enabledPlugins)) if (typeof v === 'boolean') out[k] = v | ||
| 59 | + } | ||
| 60 | + return out | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function stringList(v: unknown): string[] { | ||
| 64 | + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +function samePath(a: string, b: string): boolean { | ||
| 68 | + const [x, y] = [normalizePath(a), normalizePath(b)] | ||
| 69 | + return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +async function scanHome(ctx: ScanContext): Promise<ScanResult> { | ||
| 73 | + const acc = new Acc() | ||
| 74 | + const claudeDir = path.join(ctx.homeDir, '.claude') | ||
| 75 | + const detected = (await pathExists(claudeDir)) || (await pathExists(path.join(ctx.homeDir, '.claude.json'))) | ||
| 76 | + | ||
| 77 | + const settingsFile = path.join(claudeDir, 'settings.json') | ||
| 78 | + const settings = await loadSettings(acc, settingsFile) | ||
| 79 | + const overrides = overridesOf(settings) | ||
| 80 | + | ||
| 81 | + await scanSkillsDir(acc, path.join(claudeDir, 'skills'), overrides) | ||
| 82 | + await scanCommandsDir(acc, path.join(claudeDir, 'commands'), { overrides }) | ||
| 83 | + await scanRulesDir(acc, path.join(claudeDir, 'rules')) | ||
| 84 | + await scanAgentsDir(acc, path.join(claudeDir, 'agents')) | ||
| 85 | + await addInstructions(acc, path.join(claudeDir, 'CLAUDE.md')) | ||
| 86 | + addHooks(acc, settings.hooks, settingsFile) | ||
| 87 | + | ||
| 88 | + // User-scope MCP servers: only `mcpServers` is read from ~/.claude.json. | ||
| 89 | + const claudeJson = await loadClaudeJson(acc, ctx.homeDir) | ||
| 90 | + if (isRecord(claudeJson.mcpServers)) { | ||
| 91 | + const file = path.join(ctx.homeDir, '.claude.json') | ||
| 92 | + for (const [name, cfg] of Object.entries(claudeJson.mcpServers)) { | ||
| 93 | + addMcpServer(acc, name, cfg, { | ||
| 94 | + agentScope: 'user', | ||
| 95 | + enabled: true, | ||
| 96 | + source: { path: file, kind: 'config-entry', configKey: `mcpServers.${name}` }, | ||
| 97 | + }) | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + await scanPlugins(acc, { homeDir: ctx.homeDir, enabled: enabledPluginsOf(settings) }) | ||
| 102 | + | ||
| 103 | + return { detected: detected || acc.items.length > 0, items: acc.items, errors: acc.errors } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +/** True when a CLAUDE.md-like file exists in `root` or in a directory above it (they shadow AGENTS.md). */ | ||
| 107 | +async function findClaudeMdAbove(root: string): Promise<string | undefined> { | ||
| 108 | + let dir = path.resolve(root) | ||
| 109 | + for (;;) { | ||
| 110 | + // Only CLAUDE.md and CLAUDE.local.md: a .claude/CLAUDE.md in a parent could be the user memory (~/.claude). | ||
| 111 | + const candidates = ['CLAUDE.md', 'CLAUDE.local.md'] | ||
| 112 | + for (const c of candidates) { | ||
| 113 | + if (await pathExists(path.join(dir, c))) return path.join(dir, c) | ||
| 114 | + } | ||
| 115 | + const parent = path.dirname(dir) | ||
| 116 | + if (parent === dir) return undefined | ||
| 117 | + dir = parent | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +async function scanProject(root: string, ctx: ScanContext): Promise<ScanResult> { | ||
| 122 | + const acc = new Acc() | ||
| 123 | + const claudeDir = path.join(root, '.claude') | ||
| 124 | + const mcpFile = path.join(root, '.mcp.json') | ||
| 125 | + const detected = | ||
| 126 | + (await isDirectory(claudeDir)) || | ||
| 127 | + (await pathExists(path.join(root, 'CLAUDE.md'))) || | ||
| 128 | + (await pathExists(path.join(root, 'CLAUDE.local.md'))) || | ||
| 129 | + (await pathExists(mcpFile)) | ||
| 130 | + | ||
| 131 | + const userSettings = await loadSettings(acc, path.join(ctx.homeDir, '.claude', 'settings.json')) | ||
| 132 | + const projectSettingsFile = path.join(claudeDir, 'settings.json') | ||
| 133 | + const localSettingsFile = path.join(claudeDir, 'settings.local.json') | ||
| 134 | + const projectSettings = await loadSettings(acc, projectSettingsFile) | ||
| 135 | + const localSettings = await loadSettings(acc, localSettingsFile) | ||
| 136 | + | ||
| 137 | + // Later wins: user, then project, then local. | ||
| 138 | + const overrides: SkillOverrides = { | ||
| 139 | + ...overridesOf(userSettings), | ||
| 140 | + ...overridesOf(projectSettings), | ||
| 141 | + ...overridesOf(localSettings), | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + await scanSkillsDir(acc, path.join(claudeDir, 'skills'), overrides) | ||
| 145 | + await scanCommandsDir(acc, path.join(claudeDir, 'commands'), { overrides }) | ||
| 146 | + await scanRulesDir(acc, path.join(claudeDir, 'rules')) | ||
| 147 | + await scanAgentsDir(acc, path.join(claudeDir, 'agents')) | ||
| 148 | + | ||
| 149 | + // Instructions. AGENTS.md is read only when no CLAUDE.md-like file exists in the working directory or above. | ||
| 150 | + await addInstructions(acc, path.join(root, 'CLAUDE.md')) | ||
| 151 | + await addInstructions(acc, path.join(claudeDir, 'CLAUDE.md'), { name: '.claude/CLAUDE.md' }) | ||
| 152 | + await addInstructions(acc, path.join(root, 'CLAUDE.local.md')) | ||
| 153 | + const agentsFile = path.join(root, 'AGENTS.md') | ||
| 154 | + const claudeMdItems = acc.items.filter((i) => i.type === 'instructions') | ||
| 155 | + const importedBy = claudeMdItems.find((i) => | ||
| 156 | + (i.details as InstructionsDetails).imports.some((imp) => | ||
| 157 | + samePath(path.resolve(path.dirname(i.source.path), imp.replace(/^~(?=[/\\])/, ctx.homeDir)), agentsFile), | ||
| 158 | + ), | ||
| 159 | + ) | ||
| 160 | + const shadow = claudeMdItems.length > 0 ? claudeMdItems[0]!.source.path : await findClaudeMdAbove(path.dirname(root)) | ||
| 161 | + if (importedBy) { | ||
| 162 | + await addInstructions(acc, agentsFile, { loaded: true }) | ||
| 163 | + } else if (shadow) { | ||
| 164 | + await addInstructions(acc, agentsFile, { | ||
| 165 | + loaded: false, | ||
| 166 | + notLoadedReason: `Claude Code reads AGENTS.md only when there is no CLAUDE.md or CLAUDE.local.md in the working directory or above (found ${normalizePath(shadow)}); import it with @AGENTS.md to load it`, | ||
| 167 | + }) | ||
| 168 | + } else { | ||
| 169 | + await addInstructions(acc, agentsFile, { loaded: true }) | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + addHooks(acc, projectSettings.hooks, projectSettingsFile) | ||
| 173 | + addHooks(acc, localSettings.hooks, localSettingsFile) | ||
| 174 | + | ||
| 175 | + // MCP. `disabledMcpjsonServers` may come from any settings layer or from the project entry of ~/.claude.json. | ||
| 176 | + const claudeJson = await loadClaudeJson(acc, ctx.homeDir) | ||
| 177 | + const projects = isRecord(claudeJson.projects) ? claudeJson.projects : {} | ||
| 178 | + const projectKey = Object.keys(projects).find((k) => samePath(k, root)) | ||
| 179 | + const entry = projectKey !== undefined && isRecord(projects[projectKey]) ? (projects[projectKey] as Json) : {} | ||
| 180 | + const disabled = new Set<string>([ | ||
| 181 | + ...stringList(userSettings.disabledMcpjsonServers), | ||
| 182 | + ...stringList(projectSettings.disabledMcpjsonServers), | ||
| 183 | + ...stringList(localSettings.disabledMcpjsonServers), | ||
| 184 | + ...stringList(entry.disabledMcpjsonServers), | ||
| 185 | + ]) | ||
| 186 | + | ||
| 187 | + try { | ||
| 188 | + const mcp = await readJson(mcpFile) | ||
| 189 | + if (isRecord(mcp) && isRecord(mcp.mcpServers)) { | ||
| 190 | + for (const [name, cfg] of Object.entries(mcp.mcpServers)) { | ||
| 191 | + addMcpServer(acc, name, cfg, { | ||
| 192 | + agentScope: 'project', | ||
| 193 | + enabled: !disabled.has(name), | ||
| 194 | + source: { path: mcpFile, kind: 'config-entry', configKey: `mcpServers.${name}` }, | ||
| 195 | + }) | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + } catch (err) { | ||
| 199 | + acc.error(mcpFile, err) | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + if (projectKey !== undefined && isRecord(entry.mcpServers)) { | ||
| 203 | + const file = path.join(ctx.homeDir, '.claude.json') | ||
| 204 | + for (const [name, cfg] of Object.entries(entry.mcpServers)) { | ||
| 205 | + addMcpServer(acc, name, cfg, { | ||
| 206 | + agentScope: 'local', | ||
| 207 | + enabled: true, | ||
| 208 | + source: { | ||
| 209 | + path: file, | ||
| 210 | + kind: 'config-entry', | ||
| 211 | + configKey: `projects[${JSON.stringify(projectKey)}].mcpServers.${name}`, | ||
| 212 | + }, | ||
| 213 | + }) | ||
| 214 | + } | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + await scanPlugins(acc, { | ||
| 218 | + homeDir: ctx.homeDir, | ||
| 219 | + enabled: { ...enabledPluginsOf(projectSettings), ...enabledPluginsOf(localSettings) }, | ||
| 220 | + projectRoot: root, | ||
| 221 | + }) | ||
| 222 | + | ||
| 223 | + return { detected: detected || acc.items.length > 0, items: acc.items, errors: acc.errors } | ||
| 224 | +} | ||
| 2 | 225 | ||
| 3 | -// Placeholder: replaced by the real scanner implementation. | ||
| 4 | export const claudeScanner: AgentScanner = { | 226 | export const claudeScanner: AgentScanner = { |
| 5 | agent: 'claude', | 227 | agent: 'claude', |
| 6 | - async scanHome() { | 228 | + scanHome, |
| 7 | - return { detected: false, items: [], errors: [] } | 229 | + scanProject, |
| 8 | - }, | ||
| 9 | - async scanProject() { | ||
| 10 | - return { detected: false, items: [], errors: [] } | ||
| 11 | - }, | ||
| 12 | } | 230 | } |
added
packages/core/src/scanners/claude/mcp.ts +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +import type { ItemSource, McpDetails } from '../../model.ts' | |
| 2 | +import { envRefs } from '../../util.ts' | |
| 3 | +import { Acc, isRecord, str } from './acc.ts' | |
| 4 | +import { REDACTED, redactArgs, sanitizeUrl } from './redact.ts' | |
| 5 | + | |
| 6 | +export interface McpOptions { | |
| 7 | + agentScope: string | |
| 8 | + enabled: boolean | |
| 9 | + source: ItemSource | |
| 10 | + /** Overrides the displayed name (plugin servers). */ | |
| 11 | + displayName?: string | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** Turns one `mcpServers` entry into an item. Env and header values are never copied. */ | |
| 15 | +export function addMcpServer(acc: Acc, name: string, cfg: unknown, opts: McpOptions): void { | |
| 16 | + if (!isRecord(cfg)) { | |
| 17 | + acc.error(opts.source.path, undefined, `MCP server "${name}" is not an object`) | |
| 18 | + return | |
| 19 | + } | |
| 20 | + const warnings: string[] = [] | |
| 21 | + const refs = new Set<string>() | |
| 22 | + const scan = (v: unknown): void => { | |
| 23 | + if (typeof v === 'string') for (const r of envRefs(v)) refs.add(r) | |
| 24 | + } | |
| 25 | + | |
| 26 | + const command = str(cfg.command) | |
| 27 | + scan(command) | |
| 28 | + let url = str(cfg.url) | |
| 29 | + scan(url) | |
| 30 | + let redacted = false | |
| 31 | + | |
| 32 | + let args: string[] | undefined | |
| 33 | + if (Array.isArray(cfg.args)) { | |
| 34 | + const raw = cfg.args.map((a) => String(a)) | |
| 35 | + raw.forEach(scan) | |
| 36 | + const r = redactArgs(raw) | |
| 37 | + args = r.args | |
| 38 | + redacted ||= r.redacted | |
| 39 | + } | |
| 40 | + if (url) { | |
| 41 | + const r = sanitizeUrl(url) | |
| 42 | + url = r.url | |
| 43 | + redacted ||= r.redacted | |
| 44 | + } | |
| 45 | + | |
| 46 | + const literalEnvKeys: string[] = [] | |
| 47 | + if (isRecord(cfg.env)) { | |
| 48 | + for (const [k, v] of Object.entries(cfg.env)) { | |
| 49 | + if (typeof v === 'string') { | |
| 50 | + scan(v) | |
| 51 | + if (envRefs(v).length === 0) literalEnvKeys.push(k) | |
| 52 | + } else if (v !== undefined && v !== null) literalEnvKeys.push(k) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + if (isRecord(cfg.headers)) for (const v of Object.values(cfg.headers)) scan(v) | |
| 56 | + | |
| 57 | + const type = str(cfg.type)?.toLowerCase() | |
| 58 | + let transport: McpDetails['transport'] = 'unknown' | |
| 59 | + if (type === 'stdio' || type === 'http' || type === 'sse') transport = type | |
| 60 | + else if (!type) transport = command ? 'stdio' : url ? 'http' : 'unknown' | |
| 61 | + | |
| 62 | + if (redacted) warnings.push(`Credential-looking values were replaced by ${REDACTED}`) | |
| 63 | + | |
| 64 | + const details: McpDetails = { | |
| 65 | + type: 'mcp', | |
| 66 | + transport, | |
| 67 | + envVarNames: [...refs].sort(), | |
| 68 | + literalEnvKeys: literalEnvKeys.sort(), | |
| 69 | + agentScope: opts.agentScope, | |
| 70 | + enabled: opts.enabled, | |
| 71 | + } | |
| 72 | + if (command) details.command = command | |
| 73 | + if (args) details.args = args | |
| 74 | + if (url) details.url = url | |
| 75 | + | |
| 76 | + acc.add('mcp', opts.displayName ?? name, { source: opts.source, details, warnings }) | |
| 77 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +import type { ItemSource, McpDetails } from '../../model.ts' | ||
| 2 | +import { envRefs } from '../../util.ts' | ||
| 3 | +import { Acc, isRecord, str } from './acc.ts' | ||
| 4 | +import { REDACTED, redactArgs, sanitizeUrl } from './redact.ts' | ||
| 5 | + | ||
| 6 | +export interface McpOptions { | ||
| 7 | + agentScope: string | ||
| 8 | + enabled: boolean | ||
| 9 | + source: ItemSource | ||
| 10 | + /** Overrides the displayed name (plugin servers). */ | ||
| 11 | + displayName?: string | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +/** Turns one `mcpServers` entry into an item. Env and header values are never copied. */ | ||
| 15 | +export function addMcpServer(acc: Acc, name: string, cfg: unknown, opts: McpOptions): void { | ||
| 16 | + if (!isRecord(cfg)) { | ||
| 17 | + acc.error(opts.source.path, undefined, `MCP server "${name}" is not an object`) | ||
| 18 | + return | ||
| 19 | + } | ||
| 20 | + const warnings: string[] = [] | ||
| 21 | + const refs = new Set<string>() | ||
| 22 | + const scan = (v: unknown): void => { | ||
| 23 | + if (typeof v === 'string') for (const r of envRefs(v)) refs.add(r) | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + const command = str(cfg.command) | ||
| 27 | + scan(command) | ||
| 28 | + let url = str(cfg.url) | ||
| 29 | + scan(url) | ||
| 30 | + let redacted = false | ||
| 31 | + | ||
| 32 | + let args: string[] | undefined | ||
| 33 | + if (Array.isArray(cfg.args)) { | ||
| 34 | + const raw = cfg.args.map((a) => String(a)) | ||
| 35 | + raw.forEach(scan) | ||
| 36 | + const r = redactArgs(raw) | ||
| 37 | + args = r.args | ||
| 38 | + redacted ||= r.redacted | ||
| 39 | + } | ||
| 40 | + if (url) { | ||
| 41 | + const r = sanitizeUrl(url) | ||
| 42 | + url = r.url | ||
| 43 | + redacted ||= r.redacted | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + const literalEnvKeys: string[] = [] | ||
| 47 | + if (isRecord(cfg.env)) { | ||
| 48 | + for (const [k, v] of Object.entries(cfg.env)) { | ||
| 49 | + if (typeof v === 'string') { | ||
| 50 | + scan(v) | ||
| 51 | + if (envRefs(v).length === 0) literalEnvKeys.push(k) | ||
| 52 | + } else if (v !== undefined && v !== null) literalEnvKeys.push(k) | ||
| 53 | + } | ||
| 54 | + } | ||
| 55 | + if (isRecord(cfg.headers)) for (const v of Object.values(cfg.headers)) scan(v) | ||
| 56 | + | ||
| 57 | + const type = str(cfg.type)?.toLowerCase() | ||
| 58 | + let transport: McpDetails['transport'] = 'unknown' | ||
| 59 | + if (type === 'stdio' || type === 'http' || type === 'sse') transport = type | ||
| 60 | + else if (!type) transport = command ? 'stdio' : url ? 'http' : 'unknown' | ||
| 61 | + | ||
| 62 | + if (redacted) warnings.push(`Credential-looking values were replaced by ${REDACTED}`) | ||
| 63 | + | ||
| 64 | + const details: McpDetails = { | ||
| 65 | + type: 'mcp', | ||
| 66 | + transport, | ||
| 67 | + envVarNames: [...refs].sort(), | ||
| 68 | + literalEnvKeys: literalEnvKeys.sort(), | ||
| 69 | + agentScope: opts.agentScope, | ||
| 70 | + enabled: opts.enabled, | ||
| 71 | + } | ||
| 72 | + if (command) details.command = command | ||
| 73 | + if (args) details.args = args | ||
| 74 | + if (url) details.url = url | ||
| 75 | + | ||
| 76 | + acc.add('mcp', opts.displayName ?? name, { source: opts.source, details, warnings }) | ||
| 77 | +} | ||
added
packages/core/src/scanners/claude/plugins.ts +145 -0 | new file mode 100644 | ||
| @@ -0,0 +1,145 @@ | ||
| 1 | +import path from 'node:path' | |
| 2 | +import { isDirectory, listDirs, normalizePath, pathExists, readJson } from '../../util.ts' | |
| 3 | +import { Acc, isRecord, str } from './acc.ts' | |
| 4 | +import { addSkillDir, scanAgentsDir, scanCommandsDir } from './content.ts' | |
| 5 | +import { addHooks } from './hooks.ts' | |
| 6 | +import { addMcpServer } from './mcp.ts' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Items contributed by installed and enabled plugins (best effort, read-only). | |
| 10 | + * Layout (see plugins-reference): `~/.claude/plugins/installed_plugins.json` maps `<plugin>@<marketplace>` to | |
| 11 | + * installs with an `installPath`; a plugin folder holds `skills/<name>/SKILL.md`, `commands/*.md`, `agents/*.md`, | |
| 12 | + * `hooks/hooks.json` (event map under a `hooks` key), `.mcp.json` and `.claude-plugin/plugin.json`. | |
| 13 | + */ | |
| 14 | +export async function scanPlugins( | |
| 15 | + acc: Acc, | |
| 16 | + opts: { homeDir: string; enabled: Record<string, boolean>; projectRoot?: string }, | |
| 17 | +): Promise<void> { | |
| 18 | + const enabledKeys = Object.entries(opts.enabled) | |
| 19 | + .filter(([, on]) => on === true) | |
| 20 | + .map(([k]) => k) | |
| 21 | + if (enabledKeys.length === 0) return | |
| 22 | + | |
| 23 | + const registry = path.join(opts.homeDir, '.claude', 'plugins', 'installed_plugins.json') | |
| 24 | + let installed: unknown | |
| 25 | + try { | |
| 26 | + installed = await readJson(registry) | |
| 27 | + } catch (err) { | |
| 28 | + acc.error(registry, err) | |
| 29 | + return | |
| 30 | + } | |
| 31 | + if (!isRecord(installed) || !isRecord(installed.plugins)) return | |
| 32 | + | |
| 33 | + for (const key of enabledKeys) { | |
| 34 | + try { | |
| 35 | + const installPath = pickInstall(installed.plugins[key], opts.projectRoot) | |
| 36 | + if (!installPath || !(await isDirectory(installPath))) continue | |
| 37 | + await scanPluginDir(acc, key, installPath) | |
| 38 | + } catch (err) { | |
| 39 | + acc.error(registry, err, `Plugin ${key}: ${err instanceof Error ? err.message : String(err)}`) | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** Installs are a list (registry v2) or a single object (v1). Prefer a match for the project, then user scope. */ | |
| 45 | +function pickInstall(entry: unknown, projectRoot: string | undefined): string | undefined { | |
| 46 | + const list = Array.isArray(entry) ? entry : entry ? [entry] : [] | |
| 47 | + const installs = list.filter(isRecord).filter((i) => str(i.installPath)) | |
| 48 | + const forProject = projectRoot | |
| 49 | + ? installs.find((i) => str(i.projectPath) && normalizePath(str(i.projectPath)!) === normalizePath(projectRoot)) | |
| 50 | + : undefined | |
| 51 | + const pick = forProject ?? installs.find((i) => i.scope === 'user') ?? installs[0] | |
| 52 | + return pick ? str(pick.installPath) : undefined | |
| 53 | +} | |
| 54 | + | |
| 55 | +async function scanPluginDir(acc: Acc, key: string, root: string): Promise<void> { | |
| 56 | + const pluginName = key.split('@')[0] || key | |
| 57 | + const manifestPath = path.join(root, '.claude-plugin', 'plugin.json') | |
| 58 | + let manifest: Record<string, unknown> = {} | |
| 59 | + try { | |
| 60 | + const m = await readJson(manifestPath) | |
| 61 | + if (isRecord(m)) manifest = m | |
| 62 | + } catch (err) { | |
| 63 | + acc.error(manifestPath, err) | |
| 64 | + } | |
| 65 | + | |
| 66 | + // Skills: default `skills/` plus custom paths from the manifest. | |
| 67 | + for (const dir of customDirs(root, manifest.skills, 'skills')) { | |
| 68 | + if (await pathExists(path.join(dir, 'SKILL.md'))) { | |
| 69 | + await addSkillDir(acc, dir, { plugin: key, displayName: `${pluginName}:${path.basename(dir)}` }) | |
| 70 | + continue | |
| 71 | + } | |
| 72 | + for (const name of await listDirs(dir)) { | |
| 73 | + await addSkillDir(acc, path.join(dir, name), { plugin: key, displayName: `${pluginName}:${name}` }) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + for (const dir of customDirs(root, manifest.commands, 'commands')) { | |
| 77 | + await scanCommandsDir(acc, dir, { plugin: key, prefix: `${pluginName}:` }) | |
| 78 | + } | |
| 79 | + for (const dir of customDirs(root, manifest.agents, 'agents')) { | |
| 80 | + await scanAgentsDir(acc, dir, { plugin: key, prefix: `${pluginName}:` }) | |
| 81 | + } | |
| 82 | + | |
| 83 | + // Hooks: `hooks/hooks.json` (wrapped in `hooks`), plus inline or file references in the manifest. | |
| 84 | + const hookSources: Array<{ file: string; map: unknown }> = [] | |
| 85 | + const defaultHooks = path.join(root, 'hooks', 'hooks.json') | |
| 86 | + await collectJson(acc, defaultHooks, (j) => hookSources.push({ file: defaultHooks, map: isRecord(j) ? j.hooks : undefined })) | |
| 87 | + for (const ref of asArray(manifest.hooks)) { | |
| 88 | + if (typeof ref === 'string') { | |
| 89 | + const file = path.resolve(root, ref) | |
| 90 | + await collectJson(acc, file, (j) => hookSources.push({ file, map: isRecord(j) ? j.hooks : undefined })) | |
| 91 | + } else if (isRecord(ref)) hookSources.push({ file: manifestPath, map: ref }) | |
| 92 | + } | |
| 93 | + for (const h of hookSources) addHooks(acc, h.map, h.file, { plugin: key }) | |
| 94 | + | |
| 95 | + // MCP servers: `.mcp.json` (wrapped in `mcpServers`, or a flat map), then manifest entries; later wins by name. | |
| 96 | + const servers = new Map<string, { file: string; cfg: unknown; configKey: string }>() | |
| 97 | + const addMap = (file: string, map: unknown, prefix: string): void => { | |
| 98 | + if (!isRecord(map)) return | |
| 99 | + for (const [name, cfg] of Object.entries(map)) servers.set(name, { file, cfg, configKey: `${prefix}${name}` }) | |
| 100 | + } | |
| 101 | + const dotMcp = path.join(root, '.mcp.json') | |
| 102 | + await collectJson(acc, dotMcp, (j) => { | |
| 103 | + if (!isRecord(j)) return | |
| 104 | + if (isRecord(j.mcpServers)) addMap(dotMcp, j.mcpServers, 'mcpServers.') | |
| 105 | + else addMap(dotMcp, j, '') | |
| 106 | + }) | |
| 107 | + for (const ref of asArray(manifest.mcpServers)) { | |
| 108 | + if (typeof ref === 'string' && ref.toLowerCase().endsWith('.json')) { | |
| 109 | + const file = path.resolve(root, ref) | |
| 110 | + await collectJson(acc, file, (j) => addMap(file, isRecord(j) && isRecord(j.mcpServers) ? j.mcpServers : j, 'mcpServers.')) | |
| 111 | + } else if (isRecord(ref)) addMap(manifestPath, ref, 'mcpServers.') | |
| 112 | + } | |
| 113 | + for (const [name, s] of servers) { | |
| 114 | + addMcpServer(acc, name, s.cfg, { | |
| 115 | + agentScope: 'plugin', | |
| 116 | + enabled: true, | |
| 117 | + displayName: `plugin:${pluginName}:${name}`, | |
| 118 | + source: { path: s.file, kind: 'config-entry', configKey: s.configKey, plugin: key }, | |
| 119 | + }) | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +function asArray(v: unknown): unknown[] { | |
| 124 | + return Array.isArray(v) ? v : v === undefined || v === null ? [] : [v] | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** The default folder plus string paths declared in the manifest (relative to the plugin root, kept inside it). */ | |
| 128 | +function customDirs(root: string, declared: unknown, fallback: string): string[] { | |
| 129 | + const dirs = [path.join(root, fallback)] | |
| 130 | + for (const d of asArray(declared)) { | |
| 131 | + if (typeof d !== 'string') continue | |
| 132 | + const abs = path.resolve(root, d) | |
| 133 | + if (abs.startsWith(path.resolve(root) + path.sep) && !dirs.includes(abs)) dirs.push(abs) | |
| 134 | + } | |
| 135 | + return dirs | |
| 136 | +} | |
| 137 | + | |
| 138 | +async function collectJson(acc: Acc, file: string, use: (json: unknown) => void): Promise<void> { | |
| 139 | + try { | |
| 140 | + const j = await readJson(file) | |
| 141 | + if (j !== undefined) use(j) | |
| 142 | + } catch (err) { | |
| 143 | + acc.error(file, err) | |
| 144 | + } | |
| 145 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,145 @@ | |||
| 1 | +import path from 'node:path' | ||
| 2 | +import { isDirectory, listDirs, normalizePath, pathExists, readJson } from '../../util.ts' | ||
| 3 | +import { Acc, isRecord, str } from './acc.ts' | ||
| 4 | +import { addSkillDir, scanAgentsDir, scanCommandsDir } from './content.ts' | ||
| 5 | +import { addHooks } from './hooks.ts' | ||
| 6 | +import { addMcpServer } from './mcp.ts' | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * Items contributed by installed and enabled plugins (best effort, read-only). | ||
| 10 | + * Layout (see plugins-reference): `~/.claude/plugins/installed_plugins.json` maps `<plugin>@<marketplace>` to | ||
| 11 | + * installs with an `installPath`; a plugin folder holds `skills/<name>/SKILL.md`, `commands/*.md`, `agents/*.md`, | ||
| 12 | + * `hooks/hooks.json` (event map under a `hooks` key), `.mcp.json` and `.claude-plugin/plugin.json`. | ||
| 13 | + */ | ||
| 14 | +export async function scanPlugins( | ||
| 15 | + acc: Acc, | ||
| 16 | + opts: { homeDir: string; enabled: Record<string, boolean>; projectRoot?: string }, | ||
| 17 | +): Promise<void> { | ||
| 18 | + const enabledKeys = Object.entries(opts.enabled) | ||
| 19 | + .filter(([, on]) => on === true) | ||
| 20 | + .map(([k]) => k) | ||
| 21 | + if (enabledKeys.length === 0) return | ||
| 22 | + | ||
| 23 | + const registry = path.join(opts.homeDir, '.claude', 'plugins', 'installed_plugins.json') | ||
| 24 | + let installed: unknown | ||
| 25 | + try { | ||
| 26 | + installed = await readJson(registry) | ||
| 27 | + } catch (err) { | ||
| 28 | + acc.error(registry, err) | ||
| 29 | + return | ||
| 30 | + } | ||
| 31 | + if (!isRecord(installed) || !isRecord(installed.plugins)) return | ||
| 32 | + | ||
| 33 | + for (const key of enabledKeys) { | ||
| 34 | + try { | ||
| 35 | + const installPath = pickInstall(installed.plugins[key], opts.projectRoot) | ||
| 36 | + if (!installPath || !(await isDirectory(installPath))) continue | ||
| 37 | + await scanPluginDir(acc, key, installPath) | ||
| 38 | + } catch (err) { | ||
| 39 | + acc.error(registry, err, `Plugin ${key}: ${err instanceof Error ? err.message : String(err)}`) | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +/** Installs are a list (registry v2) or a single object (v1). Prefer a match for the project, then user scope. */ | ||
| 45 | +function pickInstall(entry: unknown, projectRoot: string | undefined): string | undefined { | ||
| 46 | + const list = Array.isArray(entry) ? entry : entry ? [entry] : [] | ||
| 47 | + const installs = list.filter(isRecord).filter((i) => str(i.installPath)) | ||
| 48 | + const forProject = projectRoot | ||
| 49 | + ? installs.find((i) => str(i.projectPath) && normalizePath(str(i.projectPath)!) === normalizePath(projectRoot)) | ||
| 50 | + : undefined | ||
| 51 | + const pick = forProject ?? installs.find((i) => i.scope === 'user') ?? installs[0] | ||
| 52 | + return pick ? str(pick.installPath) : undefined | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +async function scanPluginDir(acc: Acc, key: string, root: string): Promise<void> { | ||
| 56 | + const pluginName = key.split('@')[0] || key | ||
| 57 | + const manifestPath = path.join(root, '.claude-plugin', 'plugin.json') | ||
| 58 | + let manifest: Record<string, unknown> = {} | ||
| 59 | + try { | ||
| 60 | + const m = await readJson(manifestPath) | ||
| 61 | + if (isRecord(m)) manifest = m | ||
| 62 | + } catch (err) { | ||
| 63 | + acc.error(manifestPath, err) | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + // Skills: default `skills/` plus custom paths from the manifest. | ||
| 67 | + for (const dir of customDirs(root, manifest.skills, 'skills')) { | ||
| 68 | + if (await pathExists(path.join(dir, 'SKILL.md'))) { | ||
| 69 | + await addSkillDir(acc, dir, { plugin: key, displayName: `${pluginName}:${path.basename(dir)}` }) | ||
| 70 | + continue | ||
| 71 | + } | ||
| 72 | + for (const name of await listDirs(dir)) { | ||
| 73 | + await addSkillDir(acc, path.join(dir, name), { plugin: key, displayName: `${pluginName}:${name}` }) | ||
| 74 | + } | ||
| 75 | + } | ||
| 76 | + for (const dir of customDirs(root, manifest.commands, 'commands')) { | ||
| 77 | + await scanCommandsDir(acc, dir, { plugin: key, prefix: `${pluginName}:` }) | ||
| 78 | + } | ||
| 79 | + for (const dir of customDirs(root, manifest.agents, 'agents')) { | ||
| 80 | + await scanAgentsDir(acc, dir, { plugin: key, prefix: `${pluginName}:` }) | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + // Hooks: `hooks/hooks.json` (wrapped in `hooks`), plus inline or file references in the manifest. | ||
| 84 | + const hookSources: Array<{ file: string; map: unknown }> = [] | ||
| 85 | + const defaultHooks = path.join(root, 'hooks', 'hooks.json') | ||
| 86 | + await collectJson(acc, defaultHooks, (j) => hookSources.push({ file: defaultHooks, map: isRecord(j) ? j.hooks : undefined })) | ||
| 87 | + for (const ref of asArray(manifest.hooks)) { | ||
| 88 | + if (typeof ref === 'string') { | ||
| 89 | + const file = path.resolve(root, ref) | ||
| 90 | + await collectJson(acc, file, (j) => hookSources.push({ file, map: isRecord(j) ? j.hooks : undefined })) | ||
| 91 | + } else if (isRecord(ref)) hookSources.push({ file: manifestPath, map: ref }) | ||
| 92 | + } | ||
| 93 | + for (const h of hookSources) addHooks(acc, h.map, h.file, { plugin: key }) | ||
| 94 | + | ||
| 95 | + // MCP servers: `.mcp.json` (wrapped in `mcpServers`, or a flat map), then manifest entries; later wins by name. | ||
| 96 | + const servers = new Map<string, { file: string; cfg: unknown; configKey: string }>() | ||
| 97 | + const addMap = (file: string, map: unknown, prefix: string): void => { | ||
| 98 | + if (!isRecord(map)) return | ||
| 99 | + for (const [name, cfg] of Object.entries(map)) servers.set(name, { file, cfg, configKey: `${prefix}${name}` }) | ||
| 100 | + } | ||
| 101 | + const dotMcp = path.join(root, '.mcp.json') | ||
| 102 | + await collectJson(acc, dotMcp, (j) => { | ||
| 103 | + if (!isRecord(j)) return | ||
| 104 | + if (isRecord(j.mcpServers)) addMap(dotMcp, j.mcpServers, 'mcpServers.') | ||
| 105 | + else addMap(dotMcp, j, '') | ||
| 106 | + }) | ||
| 107 | + for (const ref of asArray(manifest.mcpServers)) { | ||
| 108 | + if (typeof ref === 'string' && ref.toLowerCase().endsWith('.json')) { | ||
| 109 | + const file = path.resolve(root, ref) | ||
| 110 | + await collectJson(acc, file, (j) => addMap(file, isRecord(j) && isRecord(j.mcpServers) ? j.mcpServers : j, 'mcpServers.')) | ||
| 111 | + } else if (isRecord(ref)) addMap(manifestPath, ref, 'mcpServers.') | ||
| 112 | + } | ||
| 113 | + for (const [name, s] of servers) { | ||
| 114 | + addMcpServer(acc, name, s.cfg, { | ||
| 115 | + agentScope: 'plugin', | ||
| 116 | + enabled: true, | ||
| 117 | + displayName: `plugin:${pluginName}:${name}`, | ||
| 118 | + source: { path: s.file, kind: 'config-entry', configKey: s.configKey, plugin: key }, | ||
| 119 | + }) | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +function asArray(v: unknown): unknown[] { | ||
| 124 | + return Array.isArray(v) ? v : v === undefined || v === null ? [] : [v] | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +/** The default folder plus string paths declared in the manifest (relative to the plugin root, kept inside it). */ | ||
| 128 | +function customDirs(root: string, declared: unknown, fallback: string): string[] { | ||
| 129 | + const dirs = [path.join(root, fallback)] | ||
| 130 | + for (const d of asArray(declared)) { | ||
| 131 | + if (typeof d !== 'string') continue | ||
| 132 | + const abs = path.resolve(root, d) | ||
| 133 | + if (abs.startsWith(path.resolve(root) + path.sep) && !dirs.includes(abs)) dirs.push(abs) | ||
| 134 | + } | ||
| 135 | + return dirs | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +async function collectJson(acc: Acc, file: string, use: (json: unknown) => void): Promise<void> { | ||
| 139 | + try { | ||
| 140 | + const j = await readJson(file) | ||
| 141 | + if (j !== undefined) use(j) | ||
| 142 | + } catch (err) { | ||
| 143 | + acc.error(file, err) | ||
| 144 | + } | ||
| 145 | +} | ||
added
packages/core/src/scanners/claude/redact.ts +102 -0 | new file mode 100644 | ||
| @@ -0,0 +1,102 @@ | ||
| 1 | +/** Redaction helpers: MCP configs may embed credentials in args or urls. Values are never exposed. */ | |
| 2 | + | |
| 3 | +export const REDACTED = '<redacted>' | |
| 4 | + | |
| 5 | +const SECRET_FLAG = | |
| 6 | + /^--?(?:[\w-]*(?:token|api-?key|apikey|secret|password|passwd|credentials?|bearer)[\w-]*|key|auth[\w-]*|pass|pwd)$/i | |
| 7 | +const HEADER_FLAG = /^--?(?:headers?|H)$/ | |
| 8 | +const ENV_FLAG = /^--?(?:e|env)$/ | |
| 9 | +const SECRET_NAME = /(?:token|api-?key|apikey|secret|password|passwd|credential|bearer|auth|cookie|key|pwd)/i | |
| 10 | +const SECRET_PARAM = /(?:key|token|secret|auth|password|passwd|credential|sig|signature|bearer)/i | |
| 11 | +const KNOWN_PREFIX = | |
| 12 | + /^(?:sk-|sk_|pk_|rk_|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[abprs]-|AKIA[0-9A-Z]{12,}|glpat-|npm_|AIza|hf_|shpat_)/ | |
| 13 | +const JWT = /^eyJ[\w-]+\.[\w-]+\.[\w-]*$/ | |
| 14 | +const PURE_REF = /^\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}$/ | |
| 15 | + | |
| 16 | +function entropy(s: string): number { | |
| 17 | + const counts = new Map<string, number>() | |
| 18 | + for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1) | |
| 19 | + let h = 0 | |
| 20 | + for (const n of counts.values()) { | |
| 21 | + const p = n / s.length | |
| 22 | + h -= p * Math.log2(p) | |
| 23 | + } | |
| 24 | + return h | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function looksLikeCredential(value: string): boolean { | |
| 28 | + if (PURE_REF.test(value)) return false | |
| 29 | + if (KNOWN_PREFIX.test(value) || JWT.test(value)) return true | |
| 30 | + return ( | |
| 31 | + value.length >= 24 && | |
| 32 | + /^[A-Za-z0-9_\-+=]+$/.test(value) && | |
| 33 | + /\d/.test(value) && | |
| 34 | + /[A-Za-z]/.test(value) && | |
| 35 | + entropy(value) >= 3 | |
| 36 | + ) | |
| 37 | +} | |
| 38 | + | |
| 39 | +function decodeSafe(s: string): string { | |
| 40 | + try { | |
| 41 | + return decodeURIComponent(s) | |
| 42 | + } catch { | |
| 43 | + return s | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** Drops credentials from a url: user info, and the whole query string when it holds a secret-looking param. */ | |
| 48 | +export function sanitizeUrl(url: string): { url: string; redacted: boolean } { | |
| 49 | + let out = url | |
| 50 | + let redacted = false | |
| 51 | + const userInfo = out.match(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i) | |
| 52 | + if (userInfo) { | |
| 53 | + out = userInfo[1] + out.slice(userInfo[0].length) | |
| 54 | + redacted = true | |
| 55 | + } | |
| 56 | + const q = out.indexOf('?') | |
| 57 | + if (q >= 0) { | |
| 58 | + const hashAt = out.indexOf('#', q) | |
| 59 | + const query = out.slice(q + 1, hashAt >= 0 ? hashAt : undefined) | |
| 60 | + const suspicious = query.split('&').some((pair) => { | |
| 61 | + const [k = '', v = ''] = pair.split('=') | |
| 62 | + return SECRET_PARAM.test(decodeSafe(k)) || looksLikeCredential(decodeSafe(v)) | |
| 63 | + }) | |
| 64 | + if (suspicious) { | |
| 65 | + out = out.slice(0, q) + (hashAt >= 0 ? out.slice(hashAt) : '') | |
| 66 | + redacted = true | |
| 67 | + } | |
| 68 | + } | |
| 69 | + return { url: out, redacted } | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** Redacts credential-looking args. Returns the new args and whether anything changed. */ | |
| 73 | +export function redactArgs(args: string[]): { args: string[]; redacted: boolean } { | |
| 74 | + const out: string[] = [] | |
| 75 | + let redacted = false | |
| 76 | + let prev = '' | |
| 77 | + for (const arg of args) { | |
| 78 | + let next = arg | |
| 79 | + if (SECRET_FLAG.test(prev) && !PURE_REF.test(arg) && !arg.startsWith('-')) { | |
| 80 | + next = REDACTED | |
| 81 | + } else if (HEADER_FLAG.test(prev)) { | |
| 82 | + const m = arg.match(/^([^:=]+)([:=])\s*(.*)$/) | |
| 83 | + if (m && m[3] && !PURE_REF.test(m[3]) && SECRET_NAME.test(m[1]!)) next = `${m[1]}${m[2]} ${REDACTED}` | |
| 84 | + } else { | |
| 85 | + const flagEq = arg.match(/^(--?[\w-]+)=(.*)$/) | |
| 86 | + const kv = arg.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/) | |
| 87 | + if (flagEq && SECRET_FLAG.test(flagEq[1]!) && flagEq[2] && !PURE_REF.test(flagEq[2])) { | |
| 88 | + next = `${flagEq[1]}=${REDACTED}` | |
| 89 | + } else if (kv && kv[2] && !PURE_REF.test(kv[2]) && (ENV_FLAG.test(prev) || SECRET_NAME.test(kv[1]!))) { | |
| 90 | + next = `${kv[1]}=${REDACTED}` | |
| 91 | + } else if (/^https?:\/\//i.test(arg)) { | |
| 92 | + next = sanitizeUrl(arg).url | |
| 93 | + } else if (looksLikeCredential(arg)) { | |
| 94 | + next = REDACTED | |
| 95 | + } | |
| 96 | + } | |
| 97 | + if (next !== arg) redacted = true | |
| 98 | + out.push(next) | |
| 99 | + prev = arg | |
| 100 | + } | |
| 101 | + return { args: out, redacted } | |
| 102 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | +/** Redaction helpers: MCP configs may embed credentials in args or urls. Values are never exposed. */ | ||
| 2 | + | ||
| 3 | +export const REDACTED = '<redacted>' | ||
| 4 | + | ||
| 5 | +const SECRET_FLAG = | ||
| 6 | + /^--?(?:[\w-]*(?:token|api-?key|apikey|secret|password|passwd|credentials?|bearer)[\w-]*|key|auth[\w-]*|pass|pwd)$/i | ||
| 7 | +const HEADER_FLAG = /^--?(?:headers?|H)$/ | ||
| 8 | +const ENV_FLAG = /^--?(?:e|env)$/ | ||
| 9 | +const SECRET_NAME = /(?:token|api-?key|apikey|secret|password|passwd|credential|bearer|auth|cookie|key|pwd)/i | ||
| 10 | +const SECRET_PARAM = /(?:key|token|secret|auth|password|passwd|credential|sig|signature|bearer)/i | ||
| 11 | +const KNOWN_PREFIX = | ||
| 12 | + /^(?:sk-|sk_|pk_|rk_|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[abprs]-|AKIA[0-9A-Z]{12,}|glpat-|npm_|AIza|hf_|shpat_)/ | ||
| 13 | +const JWT = /^eyJ[\w-]+\.[\w-]+\.[\w-]*$/ | ||
| 14 | +const PURE_REF = /^\$\{[A-Za-z_][A-Za-z0-9_]*(?::-[^}]*)?\}$/ | ||
| 15 | + | ||
| 16 | +function entropy(s: string): number { | ||
| 17 | + const counts = new Map<string, number>() | ||
| 18 | + for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1) | ||
| 19 | + let h = 0 | ||
| 20 | + for (const n of counts.values()) { | ||
| 21 | + const p = n / s.length | ||
| 22 | + h -= p * Math.log2(p) | ||
| 23 | + } | ||
| 24 | + return h | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +export function looksLikeCredential(value: string): boolean { | ||
| 28 | + if (PURE_REF.test(value)) return false | ||
| 29 | + if (KNOWN_PREFIX.test(value) || JWT.test(value)) return true | ||
| 30 | + return ( | ||
| 31 | + value.length >= 24 && | ||
| 32 | + /^[A-Za-z0-9_\-+=]+$/.test(value) && | ||
| 33 | + /\d/.test(value) && | ||
| 34 | + /[A-Za-z]/.test(value) && | ||
| 35 | + entropy(value) >= 3 | ||
| 36 | + ) | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +function decodeSafe(s: string): string { | ||
| 40 | + try { | ||
| 41 | + return decodeURIComponent(s) | ||
| 42 | + } catch { | ||
| 43 | + return s | ||
| 44 | + } | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +/** Drops credentials from a url: user info, and the whole query string when it holds a secret-looking param. */ | ||
| 48 | +export function sanitizeUrl(url: string): { url: string; redacted: boolean } { | ||
| 49 | + let out = url | ||
| 50 | + let redacted = false | ||
| 51 | + const userInfo = out.match(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i) | ||
| 52 | + if (userInfo) { | ||
| 53 | + out = userInfo[1] + out.slice(userInfo[0].length) | ||
| 54 | + redacted = true | ||
| 55 | + } | ||
| 56 | + const q = out.indexOf('?') | ||
| 57 | + if (q >= 0) { | ||
| 58 | + const hashAt = out.indexOf('#', q) | ||
| 59 | + const query = out.slice(q + 1, hashAt >= 0 ? hashAt : undefined) | ||
| 60 | + const suspicious = query.split('&').some((pair) => { | ||
| 61 | + const [k = '', v = ''] = pair.split('=') | ||
| 62 | + return SECRET_PARAM.test(decodeSafe(k)) || looksLikeCredential(decodeSafe(v)) | ||
| 63 | + }) | ||
| 64 | + if (suspicious) { | ||
| 65 | + out = out.slice(0, q) + (hashAt >= 0 ? out.slice(hashAt) : '') | ||
| 66 | + redacted = true | ||
| 67 | + } | ||
| 68 | + } | ||
| 69 | + return { url: out, redacted } | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +/** Redacts credential-looking args. Returns the new args and whether anything changed. */ | ||
| 73 | +export function redactArgs(args: string[]): { args: string[]; redacted: boolean } { | ||
| 74 | + const out: string[] = [] | ||
| 75 | + let redacted = false | ||
| 76 | + let prev = '' | ||
| 77 | + for (const arg of args) { | ||
| 78 | + let next = arg | ||
| 79 | + if (SECRET_FLAG.test(prev) && !PURE_REF.test(arg) && !arg.startsWith('-')) { | ||
| 80 | + next = REDACTED | ||
| 81 | + } else if (HEADER_FLAG.test(prev)) { | ||
| 82 | + const m = arg.match(/^([^:=]+)([:=])\s*(.*)$/) | ||
| 83 | + if (m && m[3] && !PURE_REF.test(m[3]) && SECRET_NAME.test(m[1]!)) next = `${m[1]}${m[2]} ${REDACTED}` | ||
| 84 | + } else { | ||
| 85 | + const flagEq = arg.match(/^(--?[\w-]+)=(.*)$/) | ||
| 86 | + const kv = arg.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/) | ||
| 87 | + if (flagEq && SECRET_FLAG.test(flagEq[1]!) && flagEq[2] && !PURE_REF.test(flagEq[2])) { | ||
| 88 | + next = `${flagEq[1]}=${REDACTED}` | ||
| 89 | + } else if (kv && kv[2] && !PURE_REF.test(kv[2]) && (ENV_FLAG.test(prev) || SECRET_NAME.test(kv[1]!))) { | ||
| 90 | + next = `${kv[1]}=${REDACTED}` | ||
| 91 | + } else if (/^https?:\/\//i.test(arg)) { | ||
| 92 | + next = sanitizeUrl(arg).url | ||
| 93 | + } else if (looksLikeCredential(arg)) { | ||
| 94 | + next = REDACTED | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + if (next !== arg) redacted = true | ||
| 98 | + out.push(next) | ||
| 99 | + prev = arg | ||
| 100 | + } | ||
| 101 | + return { args: out, redacted } | ||
| 102 | +} | ||
added
packages/core/src/scanners/codex/agents.ts +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +import path from 'node:path' | |
| 2 | +import { parse as parseToml } from 'smol-toml' | |
| 3 | +import type { InventoryItem } from '../../model.ts' | |
| 4 | +import { hashPath, isDirectory, listFilesRecursive, readText, stripBom, uniqueId } from '../../util.ts' | |
| 5 | +import { asString, isRecord, pushError, type Collector, type Json } from './common.ts' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Custom agents: standalone TOML files in `~/.codex/agents` or `<root>/.codex/agents` | |
| 9 | + * (required fields: name, description, developer_instructions). | |
| 10 | + * Returns true when the directory exists. | |
| 11 | + */ | |
| 12 | +export async function scanAgentsDir(dir: string, c: Collector, extraWarning?: string): Promise<boolean> { | |
| 13 | + if (!(await isDirectory(dir))) return false | |
| 14 | + for (const rel of await listFilesRecursive(dir)) { | |
| 15 | + if (!rel.toLowerCase().endsWith('.toml')) continue | |
| 16 | + const file = path.join(dir, rel) | |
| 17 | + try { | |
| 18 | + const text = await readText(file) | |
| 19 | + if (text === undefined) continue | |
| 20 | + let data: Json | |
| 21 | + try { | |
| 22 | + data = parseToml(stripBom(text)) as Json | |
| 23 | + } catch (err) { | |
| 24 | + pushError(c, file, `Invalid TOML: ${err instanceof Error ? err.message : String(err)}`) | |
| 25 | + continue | |
| 26 | + } | |
| 27 | + const warnings: string[] = [] | |
| 28 | + if (extraWarning) warnings.push(extraWarning) | |
| 29 | + for (const field of ['name', 'description', 'developer_instructions']) { | |
| 30 | + if (!asString(data[field])) warnings.push(`missing required field \`${field}\``) | |
| 31 | + } | |
| 32 | + const name = asString(data.name) || path.basename(rel, path.extname(rel)) | |
| 33 | + const item: InventoryItem = { | |
| 34 | + id: uniqueId(`codex:agent:${name}`, c.taken), | |
| 35 | + type: 'agent', | |
| 36 | + name, | |
| 37 | + description: asString(data.description), | |
| 38 | + agent: 'codex', | |
| 39 | + source: { path: file, kind: 'file' }, | |
| 40 | + details: { type: 'agent', frontmatter: data, model: asString(data.model), tools: toolsOf(data) }, | |
| 41 | + hash: await hashPath(file), | |
| 42 | + warnings, | |
| 43 | + } | |
| 44 | + c.items.push(item) | |
| 45 | + } catch (err) { | |
| 46 | + pushError(c, file, err) | |
| 47 | + } | |
| 48 | + } | |
| 49 | + return true | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** Codex custom agent files have no tool list; only an explicit `tools` key is reported. */ | |
| 53 | +function toolsOf(data: Json): string[] | undefined { | |
| 54 | + const t = data.tools | |
| 55 | + if (Array.isArray(t)) return t.filter((x): x is string => typeof x === 'string') | |
| 56 | + if (isRecord(t)) return Object.keys(t) | |
| 57 | + return undefined | |
| 58 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +import path from 'node:path' | ||
| 2 | +import { parse as parseToml } from 'smol-toml' | ||
| 3 | +import type { InventoryItem } from '../../model.ts' | ||
| 4 | +import { hashPath, isDirectory, listFilesRecursive, readText, stripBom, uniqueId } from '../../util.ts' | ||
| 5 | +import { asString, isRecord, pushError, type Collector, type Json } from './common.ts' | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * Custom agents: standalone TOML files in `~/.codex/agents` or `<root>/.codex/agents` | ||
| 9 | + * (required fields: name, description, developer_instructions). | ||
| 10 | + * Returns true when the directory exists. | ||
| 11 | + */ | ||
| 12 | +export async function scanAgentsDir(dir: string, c: Collector, extraWarning?: string): Promise<boolean> { | ||
| 13 | + if (!(await isDirectory(dir))) return false | ||
| 14 | + for (const rel of await listFilesRecursive(dir)) { | ||
| 15 | + if (!rel.toLowerCase().endsWith('.toml')) continue | ||
| 16 | + const file = path.join(dir, rel) | ||
| 17 | + try { | ||
| 18 | + const text = await readText(file) | ||
| 19 | + if (text === undefined) continue | ||
| 20 | + let data: Json | ||
| 21 | + try { | ||
| 22 | + data = parseToml(stripBom(text)) as Json | ||
| 23 | + } catch (err) { | ||
| 24 | + pushError(c, file, `Invalid TOML: ${err instanceof Error ? err.message : String(err)}`) | ||
| 25 | + continue | ||
| 26 | + } | ||
| 27 | + const warnings: string[] = [] | ||
| 28 | + if (extraWarning) warnings.push(extraWarning) | ||
| 29 | + for (const field of ['name', 'description', 'developer_instructions']) { | ||
| 30 | + if (!asString(data[field])) warnings.push(`missing required field \`${field}\``) | ||
| 31 | + } | ||
| 32 | + const name = asString(data.name) || path.basename(rel, path.extname(rel)) | ||
| 33 | + const item: InventoryItem = { | ||
| 34 | + id: uniqueId(`codex:agent:${name}`, c.taken), | ||
| 35 | + type: 'agent', | ||
| 36 | + name, | ||
| 37 | + description: asString(data.description), | ||
| 38 | + agent: 'codex', | ||
| 39 | + source: { path: file, kind: 'file' }, | ||
| 40 | + details: { type: 'agent', frontmatter: data, model: asString(data.model), tools: toolsOf(data) }, | ||
| 41 | + hash: await hashPath(file), | ||
| 42 | + warnings, | ||
| 43 | + } | ||
| 44 | + c.items.push(item) | ||
| 45 | + } catch (err) { | ||
| 46 | + pushError(c, file, err) | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + return true | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +/** Codex custom agent files have no tool list; only an explicit `tools` key is reported. */ | ||
| 53 | +function toolsOf(data: Json): string[] | undefined { | ||
| 54 | + const t = data.tools | ||
| 55 | + if (Array.isArray(t)) return t.filter((x): x is string => typeof x === 'string') | ||
| 56 | + if (isRecord(t)) return Object.keys(t) | ||
| 57 | + return undefined | ||
| 58 | +} | ||
added
packages/core/src/scanners/codex/common.ts +74 -0 | new file mode 100644 | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +import path from 'node:path' | |
| 2 | +import { parse as parseToml } from 'smol-toml' | |
| 3 | +import type { InventoryItem, ScanError } from '../../model.ts' | |
| 4 | +import { normalizePath, readText, stripBom } from '../../util.ts' | |
| 5 | + | |
| 6 | +export type Json = Record<string, unknown> | |
| 7 | + | |
| 8 | +/** Mutable accumulator shared by the sub-scanners of one scan. */ | |
| 9 | +export interface Collector { | |
| 10 | + items: InventoryItem[] | |
| 11 | + errors: ScanError[] | |
| 12 | + taken: Set<string> | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function newCollector(): Collector { | |
| 16 | + return { items: [], errors: [], taken: new Set() } | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function pushError(c: Collector, p: string, err: unknown): void { | |
| 20 | + c.errors.push({ agent: 'codex', path: p, message: err instanceof Error ? err.message : String(err) }) | |
| 21 | +} | |
| 22 | + | |
| 23 | +export const UNTRUSTED_WARNING = 'project not trusted: Codex ignores this config' | |
| 24 | + | |
| 25 | +export function isRecord(v: unknown): v is Json { | |
| 26 | + return typeof v === 'object' && v !== null && !Array.isArray(v) | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function asString(v: unknown): string | undefined { | |
| 30 | + return typeof v === 'string' ? v : undefined | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function asStringArray(v: unknown): string[] { | |
| 34 | + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Comparison key for paths: normalized, case-insensitive on Windows. */ | |
| 38 | +export function pathKey(p: string): string { | |
| 39 | + const n = normalizePath(p.replace(/^\\\\\?\\/, '')) | |
| 40 | + return process.platform === 'win32' ? n.toLowerCase() : n | |
| 41 | +} | |
| 42 | + | |
| 43 | +export interface LoadedConfig { | |
| 44 | + /** True when the file exists and could be read. */ | |
| 45 | + exists: boolean | |
| 46 | + data: Json | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** Reads a TOML file. Parse errors are reported in the collector and yield an empty config. */ | |
| 50 | +export async function loadToml(file: string, c: Collector): Promise<LoadedConfig> { | |
| 51 | + const text = await readText(file) | |
| 52 | + if (text === undefined) return { exists: false, data: {} } | |
| 53 | + try { | |
| 54 | + return { exists: true, data: parseToml(stripBom(text)) as Json } | |
| 55 | + } catch (err) { | |
| 56 | + pushError(c, file, `Invalid TOML: ${err instanceof Error ? err.message : String(err)}`) | |
| 57 | + return { exists: true, data: {} } | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** True when the user config marks `root` as a trusted project (`[projects."<path>"] trust_level = "trusted"`). */ | |
| 62 | +export function isProjectTrusted(userConfig: Json, root: string): boolean { | |
| 63 | + const projects = userConfig.projects | |
| 64 | + if (!isRecord(projects)) return false | |
| 65 | + const key = pathKey(root) | |
| 66 | + for (const [p, entry] of Object.entries(projects)) { | |
| 67 | + if (pathKey(p) === key && isRecord(entry)) return entry.trust_level === 'trusted' | |
| 68 | + } | |
| 69 | + return false | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function codexHomeOf(ctx: { homeDir: string; codexHome?: string }): string { | |
| 73 | + return ctx.codexHome ?? path.join(ctx.homeDir, '.codex') | |
| 74 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | +import path from 'node:path' | ||
| 2 | +import { parse as parseToml } from 'smol-toml' | ||
| 3 | +import type { InventoryItem, ScanError } from '../../model.ts' | ||
| 4 | +import { normalizePath, readText, stripBom } from '../../util.ts' | ||
| 5 | + | ||
| 6 | +export type Json = Record<string, unknown> | ||
| 7 | + | ||
| 8 | +/** Mutable accumulator shared by the sub-scanners of one scan. */ | ||
| 9 | +export interface Collector { | ||
| 10 | + items: InventoryItem[] | ||
| 11 | + errors: ScanError[] | ||
| 12 | + taken: Set<string> | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +export function newCollector(): Collector { | ||
| 16 | + return { items: [], errors: [], taken: new Set() } | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +export function pushError(c: Collector, p: string, err: unknown): void { | ||
| 20 | + c.errors.push({ agent: 'codex', path: p, message: err instanceof Error ? err.message : String(err) }) | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +export const UNTRUSTED_WARNING = 'project not trusted: Codex ignores this config' | ||
| 24 | + | ||
| 25 | +export function isRecord(v: unknown): v is Json { | ||
| 26 | + return typeof v === 'object' && v !== null && !Array.isArray(v) | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +export function asString(v: unknown): string | undefined { | ||
| 30 | + return typeof v === 'string' ? v : undefined | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +export function asStringArray(v: unknown): string[] { | ||
| 34 | + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +/** Comparison key for paths: normalized, case-insensitive on Windows. */ | ||
| 38 | +export function pathKey(p: string): string { | ||
| 39 | + const n = normalizePath(p.replace(/^\\\\\?\\/, '')) | ||
| 40 | + return process.platform === 'win32' ? n.toLowerCase() : n | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +export interface LoadedConfig { | ||
| 44 | + /** True when the file exists and could be read. */ | ||
| 45 | + exists: boolean | ||
| 46 | + data: Json | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +/** Reads a TOML file. Parse errors are reported in the collector and yield an empty config. */ | ||
| 50 | +export async function loadToml(file: string, c: Collector): Promise<LoadedConfig> { | ||
| 51 | + const text = await readText(file) | ||
| 52 | + if (text === undefined) return { exists: false, data: {} } | ||
| 53 | + try { | ||
| 54 | + return { exists: true, data: parseToml(stripBom(text)) as Json } | ||
| 55 | + } catch (err) { | ||
| 56 | + pushError(c, file, `Invalid TOML: ${err instanceof Error ? err.message : String(err)}`) | ||
| 57 | + return { exists: true, data: {} } | ||
| 58 | + } | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +/** True when the user config marks `root` as a trusted project (`[projects."<path>"] trust_level = "trusted"`). */ | ||
| 62 | +export function isProjectTrusted(userConfig: Json, root: string): boolean { | ||
| 63 | + const projects = userConfig.projects | ||
| 64 | + if (!isRecord(projects)) return false | ||
| 65 | + const key = pathKey(root) | ||
| 66 | + for (const [p, entry] of Object.entries(projects)) { | ||
| 67 | + if (pathKey(p) === key && isRecord(entry)) return entry.trust_level === 'trusted' | ||
| 68 | + } | ||
| 69 | + return false | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +export function codexHomeOf(ctx: { homeDir: string; codexHome?: string }): string { | ||
| 73 | + return ctx.codexHome ?? path.join(ctx.homeDir, '.codex') | ||
| 74 | +} | ||
added
packages/core/src/scanners/codex/hooks.ts +88 -0 | new file mode 100644 | ||
| @@ -0,0 +1,88 @@ | ||
| 1 | +import { readJson, uniqueId } from '../../util.ts' | |
| 2 | +import { asString, isRecord, pushError, type Collector, type Json } from './common.ts' | |
| 3 | + | |
| 4 | +export interface HooksOptions { | |
| 5 | + /** Warning added to each hook (e.g. untrusted project). */ | |
| 6 | + warning?: string | |
| 7 | + /** Plugin key (`name@marketplace`) when the hooks come from an installed plugin. */ | |
| 8 | + plugin?: string | |
| 9 | + /** Prefix of the config key, e.g. `hooks` (config.toml / hooks.json). */ | |
| 10 | + keyPrefix?: string | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** Reads a `hooks.json` file and emits its handlers. Returns true when the file exists. */ | |
| 14 | +export async function scanHooksJson(file: string, c: Collector, opts: HooksOptions = {}, config?: Json): Promise<boolean> { | |
| 15 | + let data: unknown | |
| 16 | + try { | |
| 17 | + data = await readJson(file) | |
| 18 | + } catch (err) { | |
| 19 | + pushError(c, file, `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`) | |
| 20 | + return true | |
| 21 | + } | |
| 22 | + if (data === undefined) return false | |
| 23 | + if (!isRecord(data)) { | |
| 24 | + pushError(c, file, 'hooks.json is not an object') | |
| 25 | + return true | |
| 26 | + } | |
| 27 | + scanHooksTable(data.hooks, file, c, { ...opts, config }) | |
| 28 | + return true | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * Emits one `hook` item per handler of an `event -> matcher groups -> handlers` table | |
| 33 | + * (the `hooks` object of hooks.json, or the `[hooks]` table of config.toml). | |
| 34 | + */ | |
| 35 | +export function scanHooksTable(table: unknown, file: string, c: Collector, opts: HooksOptions & { config?: Json } = {}): number { | |
| 36 | + if (!isRecord(table)) return 0 | |
| 37 | + const prefix = opts.keyPrefix ?? 'hooks' | |
| 38 | + const features = opts.config?.features | |
| 39 | + const hooksOff = isRecord(features) && (features.hooks === false || features.codex_hooks === false) | |
| 40 | + let count = 0 | |
| 41 | + for (const [event, groups] of Object.entries(table)) { | |
| 42 | + if (!Array.isArray(groups)) continue // e.g. non-event keys of the [hooks] table | |
| 43 | + let index = 0 | |
| 44 | + groups.forEach((group, gi) => { | |
| 45 | + try { | |
| 46 | + if (!isRecord(group) || !Array.isArray(group.hooks)) return | |
| 47 | + const matcher = asString(group.matcher) | |
| 48 | + group.hooks.forEach((handler: unknown, hi: number) => { | |
| 49 | + if (!isRecord(handler)) return | |
| 50 | + const handlerType = asString(handler.type) ?? 'command' | |
| 51 | + let command: string | undefined | |
| 52 | + if (handlerType === 'mcp_tool') { | |
| 53 | + const server = asString(handler.server) | |
| 54 | + const tool = asString(handler.tool) | |
| 55 | + command = server || tool ? `${server ?? '?'}.${tool ?? '?'}` : undefined | |
| 56 | + } else { | |
| 57 | + command = asString(handler.command) | |
| 58 | + } | |
| 59 | + const warnings: string[] = [] | |
| 60 | + if (opts.warning) warnings.push(opts.warning) | |
| 61 | + if (hooksOff) warnings.push('hooks are disabled in config.toml ([features] hooks = false)') | |
| 62 | + if (handlerType === 'prompt' || handlerType === 'agent') { | |
| 63 | + warnings.push(`handler type "${handlerType}" is parsed but skipped by Codex`) | |
| 64 | + } | |
| 65 | + const name = `${event}:${matcher || '*'}:${index++}` | |
| 66 | + c.items.push({ | |
| 67 | + id: uniqueId(`codex:hook:${name}`, c.taken), | |
| 68 | + type: 'hook', | |
| 69 | + name, | |
| 70 | + agent: 'codex', | |
| 71 | + source: { | |
| 72 | + path: file, | |
| 73 | + kind: 'config-entry', | |
| 74 | + configKey: `${prefix}.${event}[${gi}].hooks[${hi}]`, | |
| 75 | + plugin: opts.plugin, | |
| 76 | + }, | |
| 77 | + details: { type: 'hook', event, matcher, handlerType, command }, | |
| 78 | + warnings, | |
| 79 | + }) | |
| 80 | + count++ | |
| 81 | + }) | |
| 82 | + } catch (err) { | |
| 83 | + pushError(c, file, err) | |
| 84 | + } | |
| 85 | + }) | |
| 86 | + } | |
| 87 | + return count | |
| 88 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | +import { readJson, uniqueId } from '../../util.ts' | ||
| 2 | +import { asString, isRecord, pushError, type Collector, type Json } from './common.ts' | ||
| 3 | + | ||
| 4 | +export interface HooksOptions { | ||
| 5 | + /** Warning added to each hook (e.g. untrusted project). */ | ||
| 6 | + warning?: string | ||
| 7 | + /** Plugin key (`name@marketplace`) when the hooks come from an installed plugin. */ | ||
| 8 | + plugin?: string | ||
| 9 | + /** Prefix of the config key, e.g. `hooks` (config.toml / hooks.json). */ | ||
| 10 | + keyPrefix?: string | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +/** Reads a `hooks.json` file and emits its handlers. Returns true when the file exists. */ | ||
| 14 | +export async function scanHooksJson(file: string, c: Collector, opts: HooksOptions = {}, config?: Json): Promise<boolean> { | ||
| 15 | + let data: unknown | ||
| 16 | + try { | ||
| 17 | + data = await readJson(file) | ||
| 18 | + } catch (err) { | ||
| 19 | + pushError(c, file, `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`) | ||
| 20 | + return true | ||
| 21 | + } | ||
| 22 | + if (data === undefined) return false | ||
| 23 | + if (!isRecord(data)) { | ||
| 24 | + pushError(c, file, 'hooks.json is not an object') | ||
| 25 | + return true | ||
| 26 | + } | ||
| 27 | + scanHooksTable(data.hooks, file, c, { ...opts, config }) | ||
| 28 | + return true | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +/** | ||
| 32 | + * Emits one `hook` item per handler of an `event -> matcher groups -> handlers` table | ||
| 33 | + * (the `hooks` object of hooks.json, or the `[hooks]` table of config.toml). | ||
| 34 | + */ | ||
| 35 | +export function scanHooksTable(table: unknown, file: string, c: Collector, opts: HooksOptions & { config?: Json } = {}): number { | ||
| 36 | + if (!isRecord(table)) return 0 | ||
| 37 | + const prefix = opts.keyPrefix ?? 'hooks' | ||
| 38 | + const features = opts.config?.features | ||
| 39 | + const hooksOff = isRecord(features) && (features.hooks === false || features.codex_hooks === false) | ||
| 40 | + let count = 0 | ||
| 41 | + for (const [event, groups] of Object.entries(table)) { | ||
| 42 | + if (!Array.isArray(groups)) continue // e.g. non-event keys of the [hooks] table | ||
| 43 | + let index = 0 | ||
| 44 | + groups.forEach((group, gi) => { | ||
| 45 | + try { | ||
| 46 | + if (!isRecord(group) || !Array.isArray(group.hooks)) return | ||
| 47 | + const matcher = asString(group.matcher) | ||
| 48 | + group.hooks.forEach((handler: unknown, hi: number) => { | ||
| 49 | + if (!isRecord(handler)) return | ||
| 50 | + const handlerType = asString(handler.type) ?? 'command' | ||
| 51 | + let command: string | undefined | ||
| 52 | + if (handlerType === 'mcp_tool') { | ||
| 53 | + const server = asString(handler.server) | ||
| 54 | + const tool = asString(handler.tool) | ||
| 55 | + command = server || tool ? `${server ?? '?'}.${tool ?? '?'}` : undefined | ||
| 56 | + } else { | ||
| 57 | + command = asString(handler.command) | ||
| 58 | + } | ||
| 59 | + const warnings: string[] = [] | ||
| 60 | + if (opts.warning) warnings.push(opts.warning) | ||
| 61 | + if (hooksOff) warnings.push('hooks are disabled in config.toml ([features] hooks = false)') | ||
| 62 | + if (handlerType === 'prompt' || handlerType === 'agent') { | ||
| 63 | + warnings.push(`handler type "${handlerType}" is parsed but skipped by Codex`) | ||
| 64 | + } | ||
| 65 | + const name = `${event}:${matcher || '*'}:${index++}` | ||
| 66 | + c.items.push({ | ||
| 67 | + id: uniqueId(`codex:hook:${name}`, c.taken), | ||
| 68 | + type: 'hook', | ||
| 69 | + name, | ||
| 70 | + agent: 'codex', | ||
| 71 | + source: { | ||
| 72 | + path: file, | ||
| 73 | + kind: 'config-entry', | ||
| 74 | + configKey: `${prefix}.${event}[${gi}].hooks[${hi}]`, | ||
| 75 | + plugin: opts.plugin, | ||
| 76 | + }, | ||
| 77 | + details: { type: 'hook', event, matcher, handlerType, command }, | ||
| 78 | + warnings, | ||
| 79 | + }) | ||
| 80 | + count++ | ||
| 81 | + }) | ||
| 82 | + } catch (err) { | ||
| 83 | + pushError(c, file, err) | ||
| 84 | + } | ||
| 85 | + }) | ||
| 86 | + } | ||
| 87 | + return count | ||
| 88 | +} | ||
modified
packages/core/src/scanners/codex/index.ts +100 -8 | @@ -1,12 +1,104 @@ | ||
| 1 | -import type { AgentScanner } from '../../model.ts' | |
| 1 | +import path from 'node:path' | |
| 2 | +import type { AgentScanner, ScanContext, ScanResult } from '../../model.ts' | |
| 3 | +import { isDirectory } from '../../util.ts' | |
| 4 | +import { scanAgentsDir } from './agents.ts' | |
| 5 | +import { | |
| 6 | + UNTRUSTED_WARNING, | |
| 7 | + codexHomeOf, | |
| 8 | + isProjectTrusted, | |
| 9 | + loadToml, | |
| 10 | + newCollector, | |
| 11 | + pushError, | |
| 12 | + type Collector, | |
| 13 | +} from './common.ts' | |
| 14 | +import { scanHooksJson, scanHooksTable } from './hooks.ts' | |
| 15 | +import { candidateNames, docMaxBytes, fallbackNames, scanInstructionsDir } from './instructions.ts' | |
| 16 | +import { scanMcpServers } from './mcp.ts' | |
| 17 | +import { scanPlugins } from './plugins.ts' | |
| 18 | +import { disabledSkillPaths, scanSkillRoot } from './skills.ts' | |
| 19 | + | |
| 20 | +const LEGACY_SKILLS_WARNING = | |
| 21 | + 'legacy location: Codex docs list ~/.agents/skills for user skills; <codexHome>/skills is deprecated and may not be read' | |
| 22 | + | |
| 23 | +function result(c: Collector, detected: boolean): ScanResult { | |
| 24 | + return { detected, items: c.items, errors: c.errors } | |
| 25 | +} | |
| 26 | + | |
| 27 | +async function scanHome(ctx: ScanContext): Promise<ScanResult> { | |
| 28 | + const c = newCollector() | |
| 29 | + const codexHome = codexHomeOf(ctx) | |
| 30 | + let detected = await isDirectory(codexHome) | |
| 31 | + try { | |
| 32 | + const configFile = path.join(codexHome, 'config.toml') | |
| 33 | + const config = await loadToml(configFile, c) | |
| 34 | + if (config.exists) detected = true | |
| 35 | + const disabled = disabledSkillPaths(config.data) | |
| 36 | + | |
| 37 | + // Instructions: AGENTS.override.md then AGENTS.md (no fallback names at global level) | |
| 38 | + await scanInstructionsDir(codexHome, candidateNames([]), c) | |
| 39 | + | |
| 40 | + // Skills | |
| 41 | + if (await scanSkillRoot(path.join(ctx.homeDir, '.agents', 'skills'), c, { disabled })) detected = true | |
| 42 | + if (await scanSkillRoot(path.join(codexHome, 'skills'), c, { disabled, warning: LEGACY_SKILLS_WARNING })) { | |
| 43 | + detected = true | |
| 44 | + } | |
| 45 | + | |
| 46 | + // MCP servers and inline hooks | |
| 47 | + scanMcpServers(config.data, configFile, 'user', c) | |
| 48 | + scanHooksTable(config.data.hooks, configFile, c, { config: config.data }) | |
| 49 | + if (await scanHooksJson(path.join(codexHome, 'hooks.json'), c, {}, config.data)) detected = true | |
| 50 | + | |
| 51 | + // Custom agents | |
| 52 | + await scanAgentsDir(path.join(codexHome, 'agents'), c) | |
| 53 | + | |
| 54 | + // Plugins (best effort) | |
| 55 | + await scanPlugins(codexHome, config.data, disabled, c) | |
| 56 | + } catch (err) { | |
| 57 | + pushError(c, codexHome, err) | |
| 58 | + } | |
| 59 | + return result(c, detected) | |
| 60 | +} | |
| 61 | + | |
| 62 | +async function scanProject(root: string, ctx: ScanContext): Promise<ScanResult> { | |
| 63 | + const c = newCollector() | |
| 64 | + let detected = false | |
| 65 | + try { | |
| 66 | + const codexHome = codexHomeOf(ctx) | |
| 67 | + const user = await loadToml(path.join(codexHome, 'config.toml'), c) | |
| 68 | + const trusted = isProjectTrusted(user.data, root) | |
| 69 | + const projectDir = path.join(root, '.codex') | |
| 70 | + const projectConfigFile = path.join(projectDir, 'config.toml') | |
| 71 | + const project = await loadToml(projectConfigFile, c) | |
| 72 | + const gate = trusted ? undefined : UNTRUSTED_WARNING | |
| 73 | + // Project config only applies when the project is trusted. | |
| 74 | + const effectiveProject = trusted ? project.data : {} | |
| 75 | + const disabled = disabledSkillPaths(user.data, effectiveProject) | |
| 76 | + | |
| 77 | + if (await isDirectory(projectDir)) detected = true | |
| 78 | + | |
| 79 | + // Instructions (v1: project root only) | |
| 80 | + const names = candidateNames(fallbackNames(user.data, effectiveProject)) | |
| 81 | + const maxBytes = docMaxBytes(user.data, effectiveProject) | |
| 82 | + if (await scanInstructionsDir(root, names, c, { maxBytes })) detected = true | |
| 83 | + | |
| 84 | + // Skills (v1: project root only) | |
| 85 | + if (await scanSkillRoot(path.join(root, '.agents', 'skills'), c, { disabled })) detected = true | |
| 86 | + | |
| 87 | + // MCP servers and hooks from the project .codex layer (trust-gated) | |
| 88 | + scanMcpServers(project.data, projectConfigFile, 'project', c, gate) | |
| 89 | + scanHooksTable(project.data.hooks, projectConfigFile, c, { warning: gate, config: project.data }) | |
| 90 | + await scanHooksJson(path.join(projectDir, 'hooks.json'), c, { warning: gate }, project.data) | |
| 91 | + | |
| 92 | + // Custom agents (trust-gated) | |
| 93 | + await scanAgentsDir(path.join(projectDir, 'agents'), c, gate) | |
| 94 | + } catch (err) { | |
| 95 | + pushError(c, root, err) | |
| 96 | + } | |
| 97 | + return result(c, detected) | |
| 98 | +} | |
| 2 | 99 | |
| 3 | -// Placeholder: replaced by the real scanner implementation. | |
| 4 | 100 | export const codexScanner: AgentScanner = { |
| 5 | 101 | agent: 'codex', |
| 6 | - async scanHome() { | |
| 7 | - return { detected: false, items: [], errors: [] } | |
| 8 | - }, | |
| 9 | - async scanProject() { | |
| 10 | - return { detected: false, items: [], errors: [] } | |
| 11 | - }, | |
| 102 | + scanHome, | |
| 103 | + scanProject, | |
| 12 | 104 | } |
| @@ -1,12 +1,104 @@ | |||
| 1 | -import type { AgentScanner } from '../../model.ts' | 1 | +import path from 'node:path' |
| 2 | +import type { AgentScanner, ScanContext, ScanResult } from '../../model.ts' | ||
| 3 | +import { isDirectory } from '../../util.ts' | ||
| 4 | +import { scanAgentsDir } from './agents.ts' | ||
| 5 | +import { | ||
| 6 | + UNTRUSTED_WARNING, | ||
| 7 | + codexHomeOf, | ||
| 8 | + isProjectTrusted, | ||
| 9 | + loadToml, | ||
| 10 | + newCollector, | ||
| 11 | + pushError, | ||
| 12 | + type Collector, | ||
| 13 | +} from './common.ts' | ||
| 14 | +import { scanHooksJson, scanHooksTable } from './hooks.ts' | ||
| 15 | +import { candidateNames, docMaxBytes, fallbackNames, scanInstructionsDir } from './instructions.ts' | ||
| 16 | +import { scanMcpServers } from './mcp.ts' | ||
| 17 | +import { scanPlugins } from './plugins.ts' | ||
| 18 | +import { disabledSkillPaths, scanSkillRoot } from './skills.ts' | ||
| 19 | + | ||
| 20 | +const LEGACY_SKILLS_WARNING = | ||
| 21 | + 'legacy location: Codex docs list ~/.agents/skills for user skills; <codexHome>/skills is deprecated and may not be read' | ||
| 22 | + | ||
| 23 | +function result(c: Collector, detected: boolean): ScanResult { | ||
| 24 | + return { detected, items: c.items, errors: c.errors } | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +async function scanHome(ctx: ScanContext): Promise<ScanResult> { | ||
| 28 | + const c = newCollector() | ||
| 29 | + const codexHome = codexHomeOf(ctx) | ||
| 30 | + let detected = await isDirectory(codexHome) | ||
| 31 | + try { | ||
| 32 | + const configFile = path.join(codexHome, 'config.toml') | ||
| 33 | + const config = await loadToml(configFile, c) | ||
| 34 | + if (config.exists) detected = true | ||
| 35 | + const disabled = disabledSkillPaths(config.data) | ||
| 36 | + | ||
| 37 | + // Instructions: AGENTS.override.md then AGENTS.md (no fallback names at global level) | ||
| 38 | + await scanInstructionsDir(codexHome, candidateNames([]), c) | ||
| 39 | + | ||
| 40 | + // Skills | ||
| 41 | + if (await scanSkillRoot(path.join(ctx.homeDir, '.agents', 'skills'), c, { disabled })) detected = true | ||
| 42 | + if (await scanSkillRoot(path.join(codexHome, 'skills'), c, { disabled, warning: LEGACY_SKILLS_WARNING })) { | ||
| 43 | + detected = true | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + // MCP servers and inline hooks | ||
| 47 | + scanMcpServers(config.data, configFile, 'user', c) | ||
| 48 | + scanHooksTable(config.data.hooks, configFile, c, { config: config.data }) | ||
| 49 | + if (await scanHooksJson(path.join(codexHome, 'hooks.json'), c, {}, config.data)) detected = true | ||
| 50 | + | ||
| 51 | + // Custom agents | ||
| 52 | + await scanAgentsDir(path.join(codexHome, 'agents'), c) | ||
| 53 | + | ||
| 54 | + // Plugins (best effort) | ||
| 55 | + await scanPlugins(codexHome, config.data, disabled, c) | ||
| 56 | + } catch (err) { | ||
| 57 | + pushError(c, codexHome, err) | ||
| 58 | + } | ||
| 59 | + return result(c, detected) | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +async function scanProject(root: string, ctx: ScanContext): Promise<ScanResult> { | ||
| 63 | + const c = newCollector() | ||
| 64 | + let detected = false | ||
| 65 | + try { | ||
| 66 | + const codexHome = codexHomeOf(ctx) | ||
| 67 | + const user = await loadToml(path.join(codexHome, 'config.toml'), c) | ||
| 68 | + const trusted = isProjectTrusted(user.data, root) | ||
| 69 | + const projectDir = path.join(root, '.codex') | ||
| 70 | + const projectConfigFile = path.join(projectDir, 'config.toml') | ||
| 71 | + const project = await loadToml(projectConfigFile, c) | ||
| 72 | + const gate = trusted ? undefined : UNTRUSTED_WARNING | ||
| 73 | + // Project config only applies when the project is trusted. | ||
| 74 | + const effectiveProject = trusted ? project.data : {} | ||
| 75 | + const disabled = disabledSkillPaths(user.data, effectiveProject) | ||
| 76 | + | ||
| 77 | + if (await isDirectory(projectDir)) detected = true | ||
| 78 | + | ||
| 79 | + // Instructions (v1: project root only) | ||
| 80 | + const names = candidateNames(fallbackNames(user.data, effectiveProject)) | ||
| 81 | + const maxBytes = docMaxBytes(user.data, effectiveProject) | ||
| 82 | + if (await scanInstructionsDir(root, names, c, { maxBytes })) detected = true | ||
| 83 | + | ||
| 84 | + // Skills (v1: project root only) | ||
| 85 | + if (await scanSkillRoot(path.join(root, '.agents', 'skills'), c, { disabled })) detected = true | ||
| 86 | + | ||
| 87 | + // MCP servers and hooks from the project .codex layer (trust-gated) | ||
| 88 | + scanMcpServers(project.data, projectConfigFile, 'project', c, gate) | ||
| 89 | + scanHooksTable(project.data.hooks, projectConfigFile, c, { warning: gate, config: project.data }) | ||
| 90 | + await scanHooksJson(path.join(projectDir, 'hooks.json'), c, { warning: gate }, project.data) | ||
| 91 | + | ||
| 92 | + // Custom agents (trust-gated) | ||
| 93 | + await scanAgentsDir(path.join(projectDir, 'agents'), c, gate) | ||
| 94 | + } catch (err) { | ||
| 95 | + pushError(c, root, err) | ||
| 96 | + } | ||
| 97 | + return result(c, detected) | ||
| 98 | +} | ||
| 2 | 99 | ||
| 3 | -// Placeholder: replaced by the real scanner implementation. | ||
| 4 | export const codexScanner: AgentScanner = { | 100 | export const codexScanner: AgentScanner = { |
| 5 | agent: 'codex', | 101 | agent: 'codex', |
| 6 | - async scanHome() { | 102 | + scanHome, |
| 7 | - return { detected: false, items: [], errors: [] } | 103 | + scanProject, |
| 8 | - }, | ||
| 9 | - async scanProject() { | ||
| 10 | - return { detected: false, items: [], errors: [] } | ||
| 11 | - }, | ||
| 12 | } | 104 | } |
added
packages/core/src/scanners/codex/instructions.ts +87 -0 | new file mode 100644 | ||
| @@ -0,0 +1,87 @@ | ||
| 1 | +import { stat } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { InventoryItem } from '../../model.ts' | |
| 4 | +import { hashPath, readText, uniqueId } from '../../util.ts' | |
| 5 | +import { asStringArray, pushError, type Collector, type Json } from './common.ts' | |
| 6 | + | |
| 7 | +export const DEFAULT_DOC_MAX_BYTES = 32 * 1024 | |
| 8 | + | |
| 9 | +/** Candidate file names for one directory, in Codex precedence order. */ | |
| 10 | +export function candidateNames(fallbacks: string[]): string[] { | |
| 11 | + return [...new Set(['AGENTS.override.md', 'AGENTS.md', ...fallbacks])] | |
| 12 | +} | |
| 13 | + | |
| 14 | +/** `project_doc_fallback_filenames`; later configs (project) take precedence over earlier ones (user). */ | |
| 15 | +export function fallbackNames(...configs: Json[]): string[] { | |
| 16 | + let out: string[] = [] | |
| 17 | + for (const cfg of configs) { | |
| 18 | + if (!Array.isArray(cfg.project_doc_fallback_filenames)) continue | |
| 19 | + out = asStringArray(cfg.project_doc_fallback_filenames).filter((n) => n && !/[\\/]/.test(n)) | |
| 20 | + } | |
| 21 | + return out | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function docMaxBytes(...configs: Json[]): number { | |
| 25 | + let max = DEFAULT_DOC_MAX_BYTES | |
| 26 | + for (const cfg of configs) { | |
| 27 | + const v = cfg.project_doc_max_bytes | |
| 28 | + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) max = v | |
| 29 | + } | |
| 30 | + return max | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Emits one `instructions` item per candidate file found in `dir`. | |
| 35 | + * Codex uses only the first non-empty one; the others are reported as not loaded. | |
| 36 | + * Returns true when at least one candidate exists. | |
| 37 | + */ | |
| 38 | +export async function scanInstructionsDir( | |
| 39 | + dir: string, | |
| 40 | + names: string[], | |
| 41 | + c: Collector, | |
| 42 | + opts: { maxBytes?: number } = {}, | |
| 43 | +): Promise<boolean> { | |
| 44 | + let found = false | |
| 45 | + let winner: string | undefined | |
| 46 | + for (const fileName of names) { | |
| 47 | + const file = path.join(dir, fileName) | |
| 48 | + try { | |
| 49 | + let size: number | |
| 50 | + try { | |
| 51 | + const st = await stat(file) | |
| 52 | + if (!st.isFile()) continue | |
| 53 | + size = st.size | |
| 54 | + } catch { | |
| 55 | + continue | |
| 56 | + } | |
| 57 | + found = true | |
| 58 | + const text = (await readText(file)) ?? '' | |
| 59 | + let loaded = false | |
| 60 | + let notLoadedReason: string | undefined | |
| 61 | + if (text.trim().length === 0) notLoadedReason = 'empty file: Codex skips it' | |
| 62 | + else if (winner) notLoadedReason = `shadowed by ${winner}: Codex uses at most one file per directory` | |
| 63 | + else { | |
| 64 | + loaded = true | |
| 65 | + winner = fileName | |
| 66 | + } | |
| 67 | + const warnings: string[] = [] | |
| 68 | + if (loaded && opts.maxBytes !== undefined && size > opts.maxBytes) { | |
| 69 | + warnings.push(`file is ${size} bytes, above project_doc_max_bytes (${opts.maxBytes}): Codex truncates it`) | |
| 70 | + } | |
| 71 | + const item: InventoryItem = { | |
| 72 | + id: uniqueId(`codex:instructions:${fileName}`, c.taken), | |
| 73 | + type: 'instructions', | |
| 74 | + name: fileName, | |
| 75 | + agent: 'codex', | |
| 76 | + source: { path: file, kind: 'file' }, | |
| 77 | + details: { type: 'instructions', fileName, sizeBytes: size, imports: [], loaded, notLoadedReason }, | |
| 78 | + hash: await hashPath(file), | |
| 79 | + warnings, | |
| 80 | + } | |
| 81 | + c.items.push(item) | |
| 82 | + } catch (err) { | |
| 83 | + pushError(c, file, err) | |
| 84 | + } | |
| 85 | + } | |
| 86 | + return found | |
| 87 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | +import { stat } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import type { InventoryItem } from '../../model.ts' | ||
| 4 | +import { hashPath, readText, uniqueId } from '../../util.ts' | ||
| 5 | +import { asStringArray, pushError, type Collector, type Json } from './common.ts' | ||
| 6 | + | ||
| 7 | +export const DEFAULT_DOC_MAX_BYTES = 32 * 1024 | ||
| 8 | + | ||
| 9 | +/** Candidate file names for one directory, in Codex precedence order. */ | ||
| 10 | +export function candidateNames(fallbacks: string[]): string[] { | ||
| 11 | + return [...new Set(['AGENTS.override.md', 'AGENTS.md', ...fallbacks])] | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +/** `project_doc_fallback_filenames`; later configs (project) take precedence over earlier ones (user). */ | ||
| 15 | +export function fallbackNames(...configs: Json[]): string[] { | ||
| 16 | + let out: string[] = [] | ||
| 17 | + for (const cfg of configs) { | ||
| 18 | + if (!Array.isArray(cfg.project_doc_fallback_filenames)) continue | ||
| 19 | + out = asStringArray(cfg.project_doc_fallback_filenames).filter((n) => n && !/[\\/]/.test(n)) | ||
| 20 | + } | ||
| 21 | + return out | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +export function docMaxBytes(...configs: Json[]): number { | ||
| 25 | + let max = DEFAULT_DOC_MAX_BYTES | ||
| 26 | + for (const cfg of configs) { | ||
| 27 | + const v = cfg.project_doc_max_bytes | ||
| 28 | + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) max = v | ||
| 29 | + } | ||
| 30 | + return max | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/** | ||
| 34 | + * Emits one `instructions` item per candidate file found in `dir`. | ||
| 35 | + * Codex uses only the first non-empty one; the others are reported as not loaded. | ||
| 36 | + * Returns true when at least one candidate exists. | ||
| 37 | + */ | ||
| 38 | +export async function scanInstructionsDir( | ||
| 39 | + dir: string, | ||
| 40 | + names: string[], | ||
| 41 | + c: Collector, | ||
| 42 | + opts: { maxBytes?: number } = {}, | ||
| 43 | +): Promise<boolean> { | ||
| 44 | + let found = false | ||
| 45 | + let winner: string | undefined | ||
| 46 | + for (const fileName of names) { | ||
| 47 | + const file = path.join(dir, fileName) | ||
| 48 | + try { | ||
| 49 | + let size: number | ||
| 50 | + try { | ||
| 51 | + const st = await stat(file) | ||
| 52 | + if (!st.isFile()) continue | ||
| 53 | + size = st.size | ||
| 54 | + } catch { | ||
| 55 | + continue | ||
| 56 | + } | ||
| 57 | + found = true | ||
| 58 | + const text = (await readText(file)) ?? '' | ||
| 59 | + let loaded = false | ||
| 60 | + let notLoadedReason: string | undefined | ||
| 61 | + if (text.trim().length === 0) notLoadedReason = 'empty file: Codex skips it' | ||
| 62 | + else if (winner) notLoadedReason = `shadowed by ${winner}: Codex uses at most one file per directory` | ||
| 63 | + else { | ||
| 64 | + loaded = true | ||
| 65 | + winner = fileName | ||
| 66 | + } | ||
| 67 | + const warnings: string[] = [] | ||
| 68 | + if (loaded && opts.maxBytes !== undefined && size > opts.maxBytes) { | ||
| 69 | + warnings.push(`file is ${size} bytes, above project_doc_max_bytes (${opts.maxBytes}): Codex truncates it`) | ||
| 70 | + } | ||
| 71 | + const item: InventoryItem = { | ||
| 72 | + id: uniqueId(`codex:instructions:${fileName}`, c.taken), | ||
| 73 | + type: 'instructions', | ||
| 74 | + name: fileName, | ||
| 75 | + agent: 'codex', | ||
| 76 | + source: { path: file, kind: 'file' }, | ||
| 77 | + details: { type: 'instructions', fileName, sizeBytes: size, imports: [], loaded, notLoadedReason }, | ||
| 78 | + hash: await hashPath(file), | ||
| 79 | + warnings, | ||
| 80 | + } | ||
| 81 | + c.items.push(item) | ||
| 82 | + } catch (err) { | ||
| 83 | + pushError(c, file, err) | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + return found | ||
| 87 | +} | ||
added
packages/core/src/scanners/codex/mcp.ts +104 -0 | new file mode 100644 | ||
| @@ -0,0 +1,104 @@ | ||
| 1 | +import type { InventoryItem, McpDetails } from '../../model.ts' | |
| 2 | +import { envRefs, uniqueId } from '../../util.ts' | |
| 3 | +import { asString, asStringArray, isRecord, pushError, type Collector, type Json } from './common.ts' | |
| 4 | +import { redactArgs, sanitizeUrl } from './redact.ts' | |
| 5 | + | |
| 6 | +/** Emits one `mcp` item per `[mcp_servers.<name>]` table of a Codex config. */ | |
| 7 | +export function scanMcpServers( | |
| 8 | + config: Json, | |
| 9 | + file: string, | |
| 10 | + agentScope: 'user' | 'project', | |
| 11 | + c: Collector, | |
| 12 | + extraWarning?: string, | |
| 13 | +): number { | |
| 14 | + const servers = config.mcp_servers | |
| 15 | + if (!isRecord(servers)) return 0 | |
| 16 | + let count = 0 | |
| 17 | + for (const [name, def] of Object.entries(servers)) { | |
| 18 | + try { | |
| 19 | + if (!isRecord(def)) { | |
| 20 | + pushError(c, file, `mcp_servers.${name} is not a table`) | |
| 21 | + continue | |
| 22 | + } | |
| 23 | + const warnings: string[] = [] | |
| 24 | + if (extraWarning) warnings.push(extraWarning) | |
| 25 | + let redacted = false | |
| 26 | + const names = new Set<string>() | |
| 27 | + | |
| 28 | + const command = asString(def.command) | |
| 29 | + let args: string[] | undefined | |
| 30 | + if (Array.isArray(def.args)) { | |
| 31 | + const raw = asStringArray(def.args) | |
| 32 | + for (const a of raw) for (const r of envRefs(a)) names.add(r) | |
| 33 | + const r = redactArgs(raw) | |
| 34 | + args = r.value | |
| 35 | + redacted ||= r.redacted | |
| 36 | + } | |
| 37 | + | |
| 38 | + let url: string | undefined | |
| 39 | + const rawUrl = asString(def.url) | |
| 40 | + if (rawUrl !== undefined) { | |
| 41 | + for (const r of envRefs(rawUrl)) names.add(r) | |
| 42 | + const s = sanitizeUrl(rawUrl) | |
| 43 | + url = s.value | |
| 44 | + redacted ||= s.redacted | |
| 45 | + } | |
| 46 | + | |
| 47 | + const literalEnvKeys: string[] = [] | |
| 48 | + if (isRecord(def.env)) { | |
| 49 | + for (const [k, v] of Object.entries(def.env)) { | |
| 50 | + literalEnvKeys.push(k) | |
| 51 | + if (typeof v === 'string') for (const r of envRefs(v)) names.add(r) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + if (Array.isArray(def.env_vars)) { | |
| 55 | + for (const v of def.env_vars) { | |
| 56 | + if (typeof v === 'string') names.add(v) | |
| 57 | + else if (isRecord(v) && typeof v.name === 'string') names.add(v.name) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + const bearer = asString(def.bearer_token_env_var) | |
| 61 | + if (bearer) names.add(bearer) | |
| 62 | + if (isRecord(def.env_http_headers)) { | |
| 63 | + for (const v of Object.values(def.env_http_headers)) if (typeof v === 'string') names.add(v) | |
| 64 | + } | |
| 65 | + if (isRecord(def.http_headers)) { | |
| 66 | + for (const v of Object.values(def.http_headers)) if (typeof v === 'string') for (const r of envRefs(v)) names.add(r) | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (redacted) warnings.push('secret-looking values were redacted from the arguments or url') | |
| 70 | + if (isRecord(def.env) && literalEnvKeys.length > 0) { | |
| 71 | + warnings.push(`env values are set literally in the config file (${literalEnvKeys.join(', ')})`) | |
| 72 | + } | |
| 73 | + if (isRecord(def.http_headers) && Object.keys(def.http_headers).length > 0) { | |
| 74 | + warnings.push('static HTTP header values are set in the config file (values hidden)') | |
| 75 | + } | |
| 76 | + | |
| 77 | + const transport: McpDetails['transport'] = url !== undefined ? 'http' : command !== undefined ? 'stdio' : 'unknown' | |
| 78 | + const item: InventoryItem = { | |
| 79 | + id: uniqueId(`codex:mcp:${name}`, c.taken), | |
| 80 | + type: 'mcp', | |
| 81 | + name, | |
| 82 | + agent: 'codex', | |
| 83 | + source: { path: file, kind: 'config-entry', configKey: `mcp_servers.${name}` }, | |
| 84 | + details: { | |
| 85 | + type: 'mcp', | |
| 86 | + transport, | |
| 87 | + command, | |
| 88 | + args, | |
| 89 | + url, | |
| 90 | + envVarNames: [...names].sort(), | |
| 91 | + literalEnvKeys, | |
| 92 | + agentScope, | |
| 93 | + enabled: def.enabled !== false, | |
| 94 | + }, | |
| 95 | + warnings, | |
| 96 | + } | |
| 97 | + c.items.push(item) | |
| 98 | + count++ | |
| 99 | + } catch (err) { | |
| 100 | + pushError(c, file, err) | |
| 101 | + } | |
| 102 | + } | |
| 103 | + return count | |
| 104 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,104 @@ | |||
| 1 | +import type { InventoryItem, McpDetails } from '../../model.ts' | ||
| 2 | +import { envRefs, uniqueId } from '../../util.ts' | ||
| 3 | +import { asString, asStringArray, isRecord, pushError, type Collector, type Json } from './common.ts' | ||
| 4 | +import { redactArgs, sanitizeUrl } from './redact.ts' | ||
| 5 | + | ||
| 6 | +/** Emits one `mcp` item per `[mcp_servers.<name>]` table of a Codex config. */ | ||
| 7 | +export function scanMcpServers( | ||
| 8 | + config: Json, | ||
| 9 | + file: string, | ||
| 10 | + agentScope: 'user' | 'project', | ||
| 11 | + c: Collector, | ||
| 12 | + extraWarning?: string, | ||
| 13 | +): number { | ||
| 14 | + const servers = config.mcp_servers | ||
| 15 | + if (!isRecord(servers)) return 0 | ||
| 16 | + let count = 0 | ||
| 17 | + for (const [name, def] of Object.entries(servers)) { | ||
| 18 | + try { | ||
| 19 | + if (!isRecord(def)) { | ||
| 20 | + pushError(c, file, `mcp_servers.${name} is not a table`) | ||
| 21 | + continue | ||
| 22 | + } | ||
| 23 | + const warnings: string[] = [] | ||
| 24 | + if (extraWarning) warnings.push(extraWarning) | ||
| 25 | + let redacted = false | ||
| 26 | + const names = new Set<string>() | ||
| 27 | + | ||
| 28 | + const command = asString(def.command) | ||
| 29 | + let args: string[] | undefined | ||
| 30 | + if (Array.isArray(def.args)) { | ||
| 31 | + const raw = asStringArray(def.args) | ||
| 32 | + for (const a of raw) for (const r of envRefs(a)) names.add(r) | ||
| 33 | + const r = redactArgs(raw) | ||
| 34 | + args = r.value | ||
| 35 | + redacted ||= r.redacted | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + let url: string | undefined | ||
| 39 | + const rawUrl = asString(def.url) | ||
| 40 | + if (rawUrl !== undefined) { | ||
| 41 | + for (const r of envRefs(rawUrl)) names.add(r) | ||
| 42 | + const s = sanitizeUrl(rawUrl) | ||
| 43 | + url = s.value | ||
| 44 | + redacted ||= s.redacted | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + const literalEnvKeys: string[] = [] | ||
| 48 | + if (isRecord(def.env)) { | ||
| 49 | + for (const [k, v] of Object.entries(def.env)) { | ||
| 50 | + literalEnvKeys.push(k) | ||
| 51 | + if (typeof v === 'string') for (const r of envRefs(v)) names.add(r) | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | + if (Array.isArray(def.env_vars)) { | ||
| 55 | + for (const v of def.env_vars) { | ||
| 56 | + if (typeof v === 'string') names.add(v) | ||
| 57 | + else if (isRecord(v) && typeof v.name === 'string') names.add(v.name) | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + const bearer = asString(def.bearer_token_env_var) | ||
| 61 | + if (bearer) names.add(bearer) | ||
| 62 | + if (isRecord(def.env_http_headers)) { | ||
| 63 | + for (const v of Object.values(def.env_http_headers)) if (typeof v === 'string') names.add(v) | ||
| 64 | + } | ||
| 65 | + if (isRecord(def.http_headers)) { | ||
| 66 | + for (const v of Object.values(def.http_headers)) if (typeof v === 'string') for (const r of envRefs(v)) names.add(r) | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + if (redacted) warnings.push('secret-looking values were redacted from the arguments or url') | ||
| 70 | + if (isRecord(def.env) && literalEnvKeys.length > 0) { | ||
| 71 | + warnings.push(`env values are set literally in the config file (${literalEnvKeys.join(', ')})`) | ||
| 72 | + } | ||
| 73 | + if (isRecord(def.http_headers) && Object.keys(def.http_headers).length > 0) { | ||
| 74 | + warnings.push('static HTTP header values are set in the config file (values hidden)') | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + const transport: McpDetails['transport'] = url !== undefined ? 'http' : command !== undefined ? 'stdio' : 'unknown' | ||
| 78 | + const item: InventoryItem = { | ||
| 79 | + id: uniqueId(`codex:mcp:${name}`, c.taken), | ||
| 80 | + type: 'mcp', | ||
| 81 | + name, | ||
| 82 | + agent: 'codex', | ||
| 83 | + source: { path: file, kind: 'config-entry', configKey: `mcp_servers.${name}` }, | ||
| 84 | + details: { | ||
| 85 | + type: 'mcp', | ||
| 86 | + transport, | ||
| 87 | + command, | ||
| 88 | + args, | ||
| 89 | + url, | ||
| 90 | + envVarNames: [...names].sort(), | ||
| 91 | + literalEnvKeys, | ||
| 92 | + agentScope, | ||
| 93 | + enabled: def.enabled !== false, | ||
| 94 | + }, | ||
| 95 | + warnings, | ||
| 96 | + } | ||
| 97 | + c.items.push(item) | ||
| 98 | + count++ | ||
| 99 | + } catch (err) { | ||
| 100 | + pushError(c, file, err) | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | + return count | ||
| 104 | +} | ||
added
packages/core/src/scanners/codex/plugins.ts +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +import path from 'node:path' | |
| 2 | +import { isDirectory, listDirs } from '../../util.ts' | |
| 3 | +import { isRecord, type Collector, type Json } from './common.ts' | |
| 4 | +import { scanHooksJson } from './hooks.ts' | |
| 5 | +import { scanSkillRoot } from './skills.ts' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Best effort: skills and hooks bundled by ENABLED plugins. | |
| 9 | + * | |
| 10 | + * Plugins are enabled with `[plugins."<name>@<marketplace>"] enabled = true` in config.toml. | |
| 11 | + * The install location is not described in the public docs; the layout below | |
| 12 | + * (`<codexHome>/plugins/cache/<marketplace>/<name>/<version>/`) is what Codex writes in practice | |
| 13 | + * and is UNVERIFIED against the docs. Missing directories are silently ignored. | |
| 14 | + */ | |
| 15 | +export async function scanPlugins( | |
| 16 | + codexHome: string, | |
| 17 | + userConfig: Json, | |
| 18 | + disabledSkills: Set<string>, | |
| 19 | + c: Collector, | |
| 20 | +): Promise<boolean> { | |
| 21 | + const plugins = userConfig.plugins | |
| 22 | + if (!isRecord(plugins)) return false | |
| 23 | + let found = false | |
| 24 | + for (const [key, entry] of Object.entries(plugins)) { | |
| 25 | + if (!isRecord(entry) || entry.enabled !== true) continue | |
| 26 | + const at = key.lastIndexOf('@') | |
| 27 | + const pluginName = at > 0 ? key.slice(0, at) : key | |
| 28 | + const marketplace = at > 0 ? key.slice(at + 1) : undefined | |
| 29 | + if (!marketplace) continue | |
| 30 | + const pluginDir = path.join(codexHome, 'plugins', 'cache', marketplace, pluginName) | |
| 31 | + if (!(await isDirectory(pluginDir))) continue | |
| 32 | + const versions = await listDirs(pluginDir) | |
| 33 | + const version = versions[versions.length - 1] | |
| 34 | + const root = version ? path.join(pluginDir, version) : pluginDir | |
| 35 | + found = true | |
| 36 | + await scanSkillRoot(path.join(root, 'skills'), c, { disabled: disabledSkills, plugin: key }) | |
| 37 | + await scanHooksJson(path.join(root, 'hooks', 'hooks.json'), c, { | |
| 38 | + plugin: key, | |
| 39 | + warning: 'plugin-bundled hook: Codex skips it until you review and trust it', | |
| 40 | + }) | |
| 41 | + } | |
| 42 | + return found | |
| 43 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +import path from 'node:path' | ||
| 2 | +import { isDirectory, listDirs } from '../../util.ts' | ||
| 3 | +import { isRecord, type Collector, type Json } from './common.ts' | ||
| 4 | +import { scanHooksJson } from './hooks.ts' | ||
| 5 | +import { scanSkillRoot } from './skills.ts' | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * Best effort: skills and hooks bundled by ENABLED plugins. | ||
| 9 | + * | ||
| 10 | + * Plugins are enabled with `[plugins."<name>@<marketplace>"] enabled = true` in config.toml. | ||
| 11 | + * The install location is not described in the public docs; the layout below | ||
| 12 | + * (`<codexHome>/plugins/cache/<marketplace>/<name>/<version>/`) is what Codex writes in practice | ||
| 13 | + * and is UNVERIFIED against the docs. Missing directories are silently ignored. | ||
| 14 | + */ | ||
| 15 | +export async function scanPlugins( | ||
| 16 | + codexHome: string, | ||
| 17 | + userConfig: Json, | ||
| 18 | + disabledSkills: Set<string>, | ||
| 19 | + c: Collector, | ||
| 20 | +): Promise<boolean> { | ||
| 21 | + const plugins = userConfig.plugins | ||
| 22 | + if (!isRecord(plugins)) return false | ||
| 23 | + let found = false | ||
| 24 | + for (const [key, entry] of Object.entries(plugins)) { | ||
| 25 | + if (!isRecord(entry) || entry.enabled !== true) continue | ||
| 26 | + const at = key.lastIndexOf('@') | ||
| 27 | + const pluginName = at > 0 ? key.slice(0, at) : key | ||
| 28 | + const marketplace = at > 0 ? key.slice(at + 1) : undefined | ||
| 29 | + if (!marketplace) continue | ||
| 30 | + const pluginDir = path.join(codexHome, 'plugins', 'cache', marketplace, pluginName) | ||
| 31 | + if (!(await isDirectory(pluginDir))) continue | ||
| 32 | + const versions = await listDirs(pluginDir) | ||
| 33 | + const version = versions[versions.length - 1] | ||
| 34 | + const root = version ? path.join(pluginDir, version) : pluginDir | ||
| 35 | + found = true | ||
| 36 | + await scanSkillRoot(path.join(root, 'skills'), c, { disabled: disabledSkills, plugin: key }) | ||
| 37 | + await scanHooksJson(path.join(root, 'hooks', 'hooks.json'), c, { | ||
| 38 | + plugin: key, | ||
| 39 | + warning: 'plugin-bundled hook: Codex skips it until you review and trust it', | ||
| 40 | + }) | ||
| 41 | + } | ||
| 42 | + return found | ||
| 43 | +} | ||
added
packages/core/src/scanners/codex/redact.ts +101 -0 | new file mode 100644 | ||
| @@ -0,0 +1,101 @@ | ||
| 1 | +/** Secret redaction for MCP definitions. Values are never exposed; only a `<redacted>` marker. */ | |
| 2 | + | |
| 3 | +export const REDACTED = '<redacted>' | |
| 4 | + | |
| 5 | +const SECRET_FLAG = /^--?(?:[\w-]*(?:token|api-?key|secret|password|passwd|credential)[\w-]*|key|auth[\w-]*|authorization|bearer|pat)$/i | |
| 6 | +const SECRET_NAME = /token|api[-_]?key|secret|passw|credential|auth|bearer|signature|\bkey\b|^key$|_key$/i | |
| 7 | +const SECRET_QUERY = /key|token|secret|passw|auth|sig|credential|bearer/i | |
| 8 | + | |
| 9 | +function entropy(s: string): number { | |
| 10 | + const counts = new Map<string, number>() | |
| 11 | + for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1) | |
| 12 | + let h = 0 | |
| 13 | + for (const n of counts.values()) { | |
| 14 | + const p = n / s.length | |
| 15 | + h -= p * Math.log2(p) | |
| 16 | + } | |
| 17 | + return h | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** True when a standalone string looks like a credential. */ | |
| 21 | +export function looksLikeSecret(value: string): boolean { | |
| 22 | + if (/^sk-[A-Za-z0-9_-]{16,}/.test(value)) return true | |
| 23 | + if (/^gh[pousr]_[A-Za-z0-9]{20,}/.test(value) || /^github_pat_[A-Za-z0-9_]{20,}/.test(value)) return true | |
| 24 | + if (/^xox[abprs]-[A-Za-z0-9-]{10,}/.test(value)) return true | |
| 25 | + if (/^AKIA[0-9A-Z]{16}$/.test(value)) return true | |
| 26 | + if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/.test(value)) return true | |
| 27 | + if (value.length >= 24 && /^[A-Za-z0-9_+=.-]+$/.test(value)) { | |
| 28 | + return /[A-Za-z]/.test(value) && /\d/.test(value) && entropy(value) >= 3.5 | |
| 29 | + } | |
| 30 | + return false | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface Sanitized<T> { | |
| 34 | + value: T | |
| 35 | + redacted: boolean | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Removes credentials from a URL: userinfo, and the whole query string when a param looks sensitive. */ | |
| 39 | +export function sanitizeUrl(raw: string): Sanitized<string> { | |
| 40 | + let redacted = false | |
| 41 | + try { | |
| 42 | + const u = new URL(raw) | |
| 43 | + if (u.username || u.password) { | |
| 44 | + u.username = '' | |
| 45 | + u.password = '' | |
| 46 | + redacted = true | |
| 47 | + } | |
| 48 | + let sensitive = false | |
| 49 | + for (const [k, v] of u.searchParams) if (SECRET_QUERY.test(k) || looksLikeSecret(v)) sensitive = true | |
| 50 | + if (sensitive) { | |
| 51 | + u.search = '' | |
| 52 | + u.hash = '' | |
| 53 | + redacted = true | |
| 54 | + } | |
| 55 | + return { value: redacted ? u.toString() : raw, redacted } | |
| 56 | + } catch { | |
| 57 | + const q = raw.indexOf('?') | |
| 58 | + if (q >= 0 && SECRET_QUERY.test(raw.slice(q))) return { value: raw.slice(0, q), redacted: true } | |
| 59 | + return { value: raw, redacted: false } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +function sanitizeValue(arg: string): Sanitized<string> { | |
| 64 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg)) return sanitizeUrl(arg) | |
| 65 | + // NAME=value with a sensitive name | |
| 66 | + const kv = /^(-{0,2}[A-Za-z0-9_.-]+)=(.*)$/.exec(arg) | |
| 67 | + if (kv && kv[2] !== '' && (SECRET_NAME.test(kv[1]!) || looksLikeSecret(kv[2]!))) { | |
| 68 | + return { value: `${kv[1]}=${REDACTED}`, redacted: true } | |
| 69 | + } | |
| 70 | + // Header-like "Authorization: Bearer xxx" | |
| 71 | + const hdr = /^([A-Za-z][\w-]*)\s*:\s*(.+)$/.exec(arg) | |
| 72 | + if (hdr && SECRET_NAME.test(hdr[1]!)) { | |
| 73 | + return { value: `${hdr[1]}: ${REDACTED}`, redacted: true } | |
| 74 | + } | |
| 75 | + if (looksLikeSecret(arg)) return { value: REDACTED, redacted: true } | |
| 76 | + return { value: arg, redacted: false } | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** Redacts secrets from command-line args. */ | |
| 80 | +export function redactArgs(args: string[]): Sanitized<string[]> { | |
| 81 | + const out: string[] = [] | |
| 82 | + let redacted = false | |
| 83 | + let redactNext = false | |
| 84 | + for (const arg of args) { | |
| 85 | + if (redactNext) { | |
| 86 | + redactNext = false | |
| 87 | + out.push(REDACTED) | |
| 88 | + redacted = true | |
| 89 | + continue | |
| 90 | + } | |
| 91 | + if (SECRET_FLAG.test(arg)) { | |
| 92 | + out.push(arg) | |
| 93 | + redactNext = true | |
| 94 | + continue | |
| 95 | + } | |
| 96 | + const s = sanitizeValue(arg) | |
| 97 | + out.push(s.value) | |
| 98 | + if (s.redacted) redacted = true | |
| 99 | + } | |
| 100 | + return { value: out, redacted } | |
| 101 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,101 @@ | |||
| 1 | +/** Secret redaction for MCP definitions. Values are never exposed; only a `<redacted>` marker. */ | ||
| 2 | + | ||
| 3 | +export const REDACTED = '<redacted>' | ||
| 4 | + | ||
| 5 | +const SECRET_FLAG = /^--?(?:[\w-]*(?:token|api-?key|secret|password|passwd|credential)[\w-]*|key|auth[\w-]*|authorization|bearer|pat)$/i | ||
| 6 | +const SECRET_NAME = /token|api[-_]?key|secret|passw|credential|auth|bearer|signature|\bkey\b|^key$|_key$/i | ||
| 7 | +const SECRET_QUERY = /key|token|secret|passw|auth|sig|credential|bearer/i | ||
| 8 | + | ||
| 9 | +function entropy(s: string): number { | ||
| 10 | + const counts = new Map<string, number>() | ||
| 11 | + for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1) | ||
| 12 | + let h = 0 | ||
| 13 | + for (const n of counts.values()) { | ||
| 14 | + const p = n / s.length | ||
| 15 | + h -= p * Math.log2(p) | ||
| 16 | + } | ||
| 17 | + return h | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +/** True when a standalone string looks like a credential. */ | ||
| 21 | +export function looksLikeSecret(value: string): boolean { | ||
| 22 | + if (/^sk-[A-Za-z0-9_-]{16,}/.test(value)) return true | ||
| 23 | + if (/^gh[pousr]_[A-Za-z0-9]{20,}/.test(value) || /^github_pat_[A-Za-z0-9_]{20,}/.test(value)) return true | ||
| 24 | + if (/^xox[abprs]-[A-Za-z0-9-]{10,}/.test(value)) return true | ||
| 25 | + if (/^AKIA[0-9A-Z]{16}$/.test(value)) return true | ||
| 26 | + if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/.test(value)) return true | ||
| 27 | + if (value.length >= 24 && /^[A-Za-z0-9_+=.-]+$/.test(value)) { | ||
| 28 | + return /[A-Za-z]/.test(value) && /\d/.test(value) && entropy(value) >= 3.5 | ||
| 29 | + } | ||
| 30 | + return false | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +export interface Sanitized<T> { | ||
| 34 | + value: T | ||
| 35 | + redacted: boolean | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +/** Removes credentials from a URL: userinfo, and the whole query string when a param looks sensitive. */ | ||
| 39 | +export function sanitizeUrl(raw: string): Sanitized<string> { | ||
| 40 | + let redacted = false | ||
| 41 | + try { | ||
| 42 | + const u = new URL(raw) | ||
| 43 | + if (u.username || u.password) { | ||
| 44 | + u.username = '' | ||
| 45 | + u.password = '' | ||
| 46 | + redacted = true | ||
| 47 | + } | ||
| 48 | + let sensitive = false | ||
| 49 | + for (const [k, v] of u.searchParams) if (SECRET_QUERY.test(k) || looksLikeSecret(v)) sensitive = true | ||
| 50 | + if (sensitive) { | ||
| 51 | + u.search = '' | ||
| 52 | + u.hash = '' | ||
| 53 | + redacted = true | ||
| 54 | + } | ||
| 55 | + return { value: redacted ? u.toString() : raw, redacted } | ||
| 56 | + } catch { | ||
| 57 | + const q = raw.indexOf('?') | ||
| 58 | + if (q >= 0 && SECRET_QUERY.test(raw.slice(q))) return { value: raw.slice(0, q), redacted: true } | ||
| 59 | + return { value: raw, redacted: false } | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +function sanitizeValue(arg: string): Sanitized<string> { | ||
| 64 | + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg)) return sanitizeUrl(arg) | ||
| 65 | + // NAME=value with a sensitive name | ||
| 66 | + const kv = /^(-{0,2}[A-Za-z0-9_.-]+)=(.*)$/.exec(arg) | ||
| 67 | + if (kv && kv[2] !== '' && (SECRET_NAME.test(kv[1]!) || looksLikeSecret(kv[2]!))) { | ||
| 68 | + return { value: `${kv[1]}=${REDACTED}`, redacted: true } | ||
| 69 | + } | ||
| 70 | + // Header-like "Authorization: Bearer xxx" | ||
| 71 | + const hdr = /^([A-Za-z][\w-]*)\s*:\s*(.+)$/.exec(arg) | ||
| 72 | + if (hdr && SECRET_NAME.test(hdr[1]!)) { | ||
| 73 | + return { value: `${hdr[1]}: ${REDACTED}`, redacted: true } | ||
| 74 | + } | ||
| 75 | + if (looksLikeSecret(arg)) return { value: REDACTED, redacted: true } | ||
| 76 | + return { value: arg, redacted: false } | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +/** Redacts secrets from command-line args. */ | ||
| 80 | +export function redactArgs(args: string[]): Sanitized<string[]> { | ||
| 81 | + const out: string[] = [] | ||
| 82 | + let redacted = false | ||
| 83 | + let redactNext = false | ||
| 84 | + for (const arg of args) { | ||
| 85 | + if (redactNext) { | ||
| 86 | + redactNext = false | ||
| 87 | + out.push(REDACTED) | ||
| 88 | + redacted = true | ||
| 89 | + continue | ||
| 90 | + } | ||
| 91 | + if (SECRET_FLAG.test(arg)) { | ||
| 92 | + out.push(arg) | ||
| 93 | + redactNext = true | ||
| 94 | + continue | ||
| 95 | + } | ||
| 96 | + const s = sanitizeValue(arg) | ||
| 97 | + out.push(s.value) | ||
| 98 | + if (s.redacted) redacted = true | ||
| 99 | + } | ||
| 100 | + return { value: out, redacted } | ||
| 101 | +} | ||
added
packages/core/src/scanners/codex/skills.ts +105 -0 | new file mode 100644 | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +import path from 'node:path' | |
| 2 | +import { parse as parseYaml } from 'yaml' | |
| 3 | +import type { InventoryItem, SkillDetails } from '../../model.ts' | |
| 4 | +import { | |
| 5 | + hashPath, | |
| 6 | + isDirectory, | |
| 7 | + listDirs, | |
| 8 | + listFilesRecursive, | |
| 9 | + parseFrontmatter, | |
| 10 | + readText, | |
| 11 | + stripBom, | |
| 12 | + symlinkTarget, | |
| 13 | + uniqueId, | |
| 14 | +} from '../../util.ts' | |
| 15 | +import { asString, isRecord, pathKey, pushError, type Collector, type Json } from './common.ts' | |
| 16 | + | |
| 17 | +/** Set of path keys (skill folders) disabled through `[[skills.config]]`. Entries may point to SKILL.md or the folder. */ | |
| 18 | +export function disabledSkillPaths(...configs: Json[]): Set<string> { | |
| 19 | + const out = new Set<string>() | |
| 20 | + for (const cfg of configs) { | |
| 21 | + const skills = cfg.skills | |
| 22 | + if (!isRecord(skills) || !Array.isArray(skills.config)) continue | |
| 23 | + for (const entry of skills.config) { | |
| 24 | + if (!isRecord(entry) || typeof entry.path !== 'string') continue | |
| 25 | + const folder = pathKey(entry.path).replace(/\/skill\.md$/i, '') | |
| 26 | + if (entry.enabled === false) out.add(folder) | |
| 27 | + else out.delete(folder) | |
| 28 | + } | |
| 29 | + } | |
| 30 | + return out | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface SkillRootOptions { | |
| 34 | + disabled: Set<string> | |
| 35 | + /** Warning added to every skill found in this root. */ | |
| 36 | + warning?: string | |
| 37 | + /** Plugin key (`name@marketplace`) when the root belongs to an installed plugin. */ | |
| 38 | + plugin?: string | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** Scans `<root>/<name>/SKILL.md` folders. `.system` (bundled skills) is skipped. Returns true when the root exists. */ | |
| 42 | +export async function scanSkillRoot(root: string, c: Collector, opts: SkillRootOptions): Promise<boolean> { | |
| 43 | + if (!(await isDirectory(root))) return false | |
| 44 | + for (const dirName of await listDirs(root)) { | |
| 45 | + if (dirName === '.system') continue | |
| 46 | + const dir = path.join(root, dirName) | |
| 47 | + try { | |
| 48 | + const text = await readText(path.join(dir, 'SKILL.md')) | |
| 49 | + if (text === undefined) continue | |
| 50 | + const warnings: string[] = [] | |
| 51 | + if (opts.warning) warnings.push(opts.warning) | |
| 52 | + const { frontmatter } = parseFrontmatter(text) | |
| 53 | + const declared = asString(frontmatter.name)?.trim() | |
| 54 | + const name = declared || dirName | |
| 55 | + if (!declared) warnings.push('SKILL.md has no `name` in its frontmatter') | |
| 56 | + if (!asString(frontmatter.description)) warnings.push('SKILL.md has no `description` in its frontmatter') | |
| 57 | + const link = await symlinkTarget(dir) | |
| 58 | + | |
| 59 | + let visibility: SkillDetails['visibility'] = 'auto' | |
| 60 | + let visibilitySource: string | undefined | |
| 61 | + const yamlFile = path.join(dir, 'agents', 'openai.yaml') | |
| 62 | + const yamlText = await readText(yamlFile) | |
| 63 | + if (yamlText !== undefined) { | |
| 64 | + try { | |
| 65 | + const meta = parseYaml(stripBom(yamlText)) as unknown | |
| 66 | + const policy = isRecord(meta) ? meta.policy : undefined | |
| 67 | + if (isRecord(policy) && policy.allow_implicit_invocation === false) { | |
| 68 | + visibility = 'on-demand' | |
| 69 | + visibilitySource = 'agents/openai.yaml' | |
| 70 | + } | |
| 71 | + } catch (err) { | |
| 72 | + pushError(c, yamlFile, `Invalid YAML: ${err instanceof Error ? err.message : String(err)}`) | |
| 73 | + } | |
| 74 | + } | |
| 75 | + const keys = [pathKey(dir)] | |
| 76 | + if (link) keys.push(pathKey(link)) | |
| 77 | + if (keys.some((k) => opts.disabled.has(k))) { | |
| 78 | + visibility = 'off' | |
| 79 | + visibilitySource = 'config.toml:skills.config' | |
| 80 | + } | |
| 81 | + | |
| 82 | + const item: InventoryItem = { | |
| 83 | + id: uniqueId(`codex:skill:${name}`, c.taken), | |
| 84 | + type: 'skill', | |
| 85 | + name, | |
| 86 | + description: asString(frontmatter.description), | |
| 87 | + agent: 'codex', | |
| 88 | + source: { path: dir, kind: 'dir', symlinkTarget: link, plugin: opts.plugin }, | |
| 89 | + details: { | |
| 90 | + type: 'skill', | |
| 91 | + frontmatter, | |
| 92 | + files: await listFilesRecursive(dir, 200), | |
| 93 | + visibility, | |
| 94 | + visibilitySource, | |
| 95 | + }, | |
| 96 | + hash: await hashPath(dir), | |
| 97 | + warnings, | |
| 98 | + } | |
| 99 | + c.items.push(item) | |
| 100 | + } catch (err) { | |
| 101 | + pushError(c, dir, err) | |
| 102 | + } | |
| 103 | + } | |
| 104 | + return true | |
| 105 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,105 @@ | |||
| 1 | +import path from 'node:path' | ||
| 2 | +import { parse as parseYaml } from 'yaml' | ||
| 3 | +import type { InventoryItem, SkillDetails } from '../../model.ts' | ||
| 4 | +import { | ||
| 5 | + hashPath, | ||
| 6 | + isDirectory, | ||
| 7 | + listDirs, | ||
| 8 | + listFilesRecursive, | ||
| 9 | + parseFrontmatter, | ||
| 10 | + readText, | ||
| 11 | + stripBom, | ||
| 12 | + symlinkTarget, | ||
| 13 | + uniqueId, | ||
| 14 | +} from '../../util.ts' | ||
| 15 | +import { asString, isRecord, pathKey, pushError, type Collector, type Json } from './common.ts' | ||
| 16 | + | ||
| 17 | +/** Set of path keys (skill folders) disabled through `[[skills.config]]`. Entries may point to SKILL.md or the folder. */ | ||
| 18 | +export function disabledSkillPaths(...configs: Json[]): Set<string> { | ||
| 19 | + const out = new Set<string>() | ||
| 20 | + for (const cfg of configs) { | ||
| 21 | + const skills = cfg.skills | ||
| 22 | + if (!isRecord(skills) || !Array.isArray(skills.config)) continue | ||
| 23 | + for (const entry of skills.config) { | ||
| 24 | + if (!isRecord(entry) || typeof entry.path !== 'string') continue | ||
| 25 | + const folder = pathKey(entry.path).replace(/\/skill\.md$/i, '') | ||
| 26 | + if (entry.enabled === false) out.add(folder) | ||
| 27 | + else out.delete(folder) | ||
| 28 | + } | ||
| 29 | + } | ||
| 30 | + return out | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +export interface SkillRootOptions { | ||
| 34 | + disabled: Set<string> | ||
| 35 | + /** Warning added to every skill found in this root. */ | ||
| 36 | + warning?: string | ||
| 37 | + /** Plugin key (`name@marketplace`) when the root belongs to an installed plugin. */ | ||
| 38 | + plugin?: string | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +/** Scans `<root>/<name>/SKILL.md` folders. `.system` (bundled skills) is skipped. Returns true when the root exists. */ | ||
| 42 | +export async function scanSkillRoot(root: string, c: Collector, opts: SkillRootOptions): Promise<boolean> { | ||
| 43 | + if (!(await isDirectory(root))) return false | ||
| 44 | + for (const dirName of await listDirs(root)) { | ||
| 45 | + if (dirName === '.system') continue | ||
| 46 | + const dir = path.join(root, dirName) | ||
| 47 | + try { | ||
| 48 | + const text = await readText(path.join(dir, 'SKILL.md')) | ||
| 49 | + if (text === undefined) continue | ||
| 50 | + const warnings: string[] = [] | ||
| 51 | + if (opts.warning) warnings.push(opts.warning) | ||
| 52 | + const { frontmatter } = parseFrontmatter(text) | ||
| 53 | + const declared = asString(frontmatter.name)?.trim() | ||
| 54 | + const name = declared || dirName | ||
| 55 | + if (!declared) warnings.push('SKILL.md has no `name` in its frontmatter') | ||
| 56 | + if (!asString(frontmatter.description)) warnings.push('SKILL.md has no `description` in its frontmatter') | ||
| 57 | + const link = await symlinkTarget(dir) | ||
| 58 | + | ||
| 59 | + let visibility: SkillDetails['visibility'] = 'auto' | ||
| 60 | + let visibilitySource: string | undefined | ||
| 61 | + const yamlFile = path.join(dir, 'agents', 'openai.yaml') | ||
| 62 | + const yamlText = await readText(yamlFile) | ||
| 63 | + if (yamlText !== undefined) { | ||
| 64 | + try { | ||
| 65 | + const meta = parseYaml(stripBom(yamlText)) as unknown | ||
| 66 | + const policy = isRecord(meta) ? meta.policy : undefined | ||
| 67 | + if (isRecord(policy) && policy.allow_implicit_invocation === false) { | ||
| 68 | + visibility = 'on-demand' | ||
| 69 | + visibilitySource = 'agents/openai.yaml' | ||
| 70 | + } | ||
| 71 | + } catch (err) { | ||
| 72 | + pushError(c, yamlFile, `Invalid YAML: ${err instanceof Error ? err.message : String(err)}`) | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + const keys = [pathKey(dir)] | ||
| 76 | + if (link) keys.push(pathKey(link)) | ||
| 77 | + if (keys.some((k) => opts.disabled.has(k))) { | ||
| 78 | + visibility = 'off' | ||
| 79 | + visibilitySource = 'config.toml:skills.config' | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + const item: InventoryItem = { | ||
| 83 | + id: uniqueId(`codex:skill:${name}`, c.taken), | ||
| 84 | + type: 'skill', | ||
| 85 | + name, | ||
| 86 | + description: asString(frontmatter.description), | ||
| 87 | + agent: 'codex', | ||
| 88 | + source: { path: dir, kind: 'dir', symlinkTarget: link, plugin: opts.plugin }, | ||
| 89 | + details: { | ||
| 90 | + type: 'skill', | ||
| 91 | + frontmatter, | ||
| 92 | + files: await listFilesRecursive(dir, 200), | ||
| 93 | + visibility, | ||
| 94 | + visibilitySource, | ||
| 95 | + }, | ||
| 96 | + hash: await hashPath(dir), | ||
| 97 | + warnings, | ||
| 98 | + } | ||
| 99 | + c.items.push(item) | ||
| 100 | + } catch (err) { | ||
| 101 | + pushError(c, dir, err) | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | + return true | ||
| 105 | +} | ||
added
packages/core/test/claude.test.ts +374 -0 | new file mode 100644 | ||
| @@ -0,0 +1,374 @@ | ||
| 1 | +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' | |
| 2 | +import os from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | |
| 5 | +import type { InventoryItem, McpDetails, ScanContext, ScanResult } from '../src/model.ts' | |
| 6 | +import { claudeScanner } from '../src/scanners/claude/index.ts' | |
| 7 | +import { redactArgs, sanitizeUrl } from '../src/scanners/claude/redact.ts' | |
| 8 | + | |
| 9 | +let tmp: string | |
| 10 | +let home: string | |
| 11 | +let project: string | |
| 12 | +let ctx: ScanContext | |
| 13 | +let linked = false | |
| 14 | + | |
| 15 | +async function put(file: string, content: string): Promise<void> { | |
| 16 | + await mkdir(path.dirname(file), { recursive: true }) | |
| 17 | + await writeFile(file, content) | |
| 18 | +} | |
| 19 | + | |
| 20 | +const json = (v: unknown): string => JSON.stringify(v, null, 2) | |
| 21 | + | |
| 22 | +function find(r: ScanResult, type: string, name: string): InventoryItem { | |
| 23 | + const item = r.items.find((i) => i.type === type && i.name === name) | |
| 24 | + if (!item) throw new Error(`missing ${type}:${name} in ${r.items.map((i) => `${i.type}:${i.name}`).join(', ')}`) | |
| 25 | + return item | |
| 26 | +} | |
| 27 | + | |
| 28 | +const SECRET = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA' | |
| 29 | + | |
| 30 | +beforeAll(async () => { | |
| 31 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-claude-')) | |
| 32 | + home = path.join(tmp, 'home') | |
| 33 | + project = path.join(tmp, 'proj') | |
| 34 | + ctx = { homeDir: home } | |
| 35 | + const c = path.join(home, '.claude') | |
| 36 | + | |
| 37 | + // Home skills | |
| 38 | + await put(path.join(c, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: Alpha skill\n---\nBody') | |
| 39 | + await put(path.join(c, 'skills', 'alpha', 'ref.md'), 'ref') | |
| 40 | + await put( | |
| 41 | + path.join(c, 'skills', 'quiet', 'SKILL.md'), | |
| 42 | + '---\nname: quiet\ndescription: Quiet\ndisable-model-invocation: true\n---\n', | |
| 43 | + ) | |
| 44 | + await put(path.join(c, 'skills', 'noisy', 'SKILL.md'), '---\ndescription: Overridden\n---\n') | |
| 45 | + await put(path.join(c, 'skills', 'synced', 'abc_def', 'docx', 'SKILL.md'), '---\nname: docx\ndescription: Docs\n---\n') | |
| 46 | + await put(path.join(c, 'skills', 'not-a-skill', 'README.md'), 'nothing') | |
| 47 | + await put(path.join(c, 'commands', 'team', 'deploy.md'), '---\ndescription: Deploy\n---\nrun') | |
| 48 | + // A broken skill (bad frontmatter) must not throw | |
| 49 | + await put(path.join(c, 'skills', 'broken', 'SKILL.md'), '---\nname: [unclosed\n---\n') | |
| 50 | + | |
| 51 | + // Real skill linked through a junction | |
| 52 | + const outside = path.join(tmp, 'outside', 'linked-skill') | |
| 53 | + await put(path.join(outside, 'SKILL.md'), '---\nname: linked-skill\ndescription: Linked\n---\n') | |
| 54 | + try { | |
| 55 | + await symlink(outside, path.join(c, 'skills', 'linked-skill'), 'junction') | |
| 56 | + linked = true | |
| 57 | + } catch { | |
| 58 | + linked = false | |
| 59 | + } | |
| 60 | + | |
| 61 | + // Rules, agents, instructions | |
| 62 | + await put(path.join(c, 'rules', 'style.md'), '---\npaths:\n - "src/**/*.ts"\n - "lib/**"\n---\nUse tabs') | |
| 63 | + await put(path.join(c, 'rules', 'sub', 'always.md'), 'Always') | |
| 64 | + await put(path.join(c, 'agents', 'reviewer.md'), '---\nname: reviewer\ndescription: Reviews\nmodel: sonnet\ntools: Read, Grep\n---\n') | |
| 65 | + await put(path.join(c, 'agents', 'planner.md'), '---\ndescription: Plans\ntools:\n - Read\n - Glob\n---\n') | |
| 66 | + await put( | |
| 67 | + path.join(c, 'CLAUDE.md'), | |
| 68 | + 'See @docs/guide.md and @~/notes.md.\n\n```\n@in/fence.md\n```\nInline `@in/code.md` and mail me@example.com\n', | |
| 69 | + ) | |
| 70 | + | |
| 71 | + // Settings with hooks + enabled plugin | |
| 72 | + await put( | |
| 73 | + path.join(c, 'settings.json'), | |
| 74 | + json({ | |
| 75 | + hooks: { | |
| 76 | + PreToolUse: [ | |
| 77 | + { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo pre' }] }, | |
| 78 | + { hooks: [{ type: 'command', command: 'echo any' }, { type: 'command', command: 'echo any2' }] }, | |
| 79 | + ], | |
| 80 | + }, | |
| 81 | + skillOverrides: { noisy: 'off', alpha: 'name-only' }, | |
| 82 | + enabledPlugins: { 'demo@market': true, 'off@market': false }, | |
| 83 | + }), | |
| 84 | + ) | |
| 85 | + | |
| 86 | + // ~/.claude.json: secrets must never leak | |
| 87 | + await put( | |
| 88 | + path.join(home, '.claude.json'), | |
| 89 | + json({ | |
| 90 | + oauthAccount: { emailAddress: 'secret@example.com', token: 'TOPSECRET' }, | |
| 91 | + mcpServers: { | |
| 92 | + github: { | |
| 93 | + command: 'npx', | |
| 94 | + args: ['-y', '@modelcontextprotocol/server-github', '--token', 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', '--verbose'], | |
| 95 | + env: { GITHUB_TOKEN: 'literal-value', FROM_ENV: '${HOME_TOKEN}' }, | |
| 96 | + }, | |
| 97 | + remote: { | |
| 98 | + type: 'http', | |
| 99 | + url: 'https://example.com/mcp?api_key=abc123&x=1', | |
| 100 | + headers: { Authorization: 'Bearer ${REMOTE_TOKEN}' }, | |
| 101 | + }, | |
| 102 | + }, | |
| 103 | + projects: { | |
| 104 | + [project.replace(/\\/g, '/').replace(/^([a-z]):/i, (_m, d: string) => `${d.toUpperCase()}:`)]: { | |
| 105 | + mcpServers: { localone: { command: 'node', args: ['server.js', SECRET] } }, | |
| 106 | + disabledMcpjsonServers: ['disabled-in-claude-json'], | |
| 107 | + }, | |
| 108 | + }, | |
| 109 | + }), | |
| 110 | + ) | |
| 111 | + | |
| 112 | + // Plugin | |
| 113 | + const plug = path.join(c, 'plugins', 'cache', 'market', 'demo', '1.0.0') | |
| 114 | + await put( | |
| 115 | + path.join(c, 'plugins', 'installed_plugins.json'), | |
| 116 | + json({ version: 2, plugins: { 'demo@market': [{ scope: 'user', installPath: plug }], 'off@market': [{ scope: 'user', installPath: plug }] } }), | |
| 117 | + ) | |
| 118 | + await put(path.join(plug, '.claude-plugin', 'plugin.json'), json({ name: 'demo' })) | |
| 119 | + await put(path.join(plug, 'skills', 'plugskill', 'SKILL.md'), '---\nname: plugskill\ndescription: From plugin\n---\n') | |
| 120 | + await put(path.join(plug, 'agents', 'pagent.md'), '---\ndescription: Plugin agent\n---\n') | |
| 121 | + await put(path.join(plug, 'hooks', 'hooks.json'), json({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo plug' }] }] } })) | |
| 122 | + await put(path.join(plug, '.mcp.json'), json({ mcpServers: { psrv: { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/s.js'] } } })) | |
| 123 | + | |
| 124 | + // Project | |
| 125 | + await put(path.join(project, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '---\nname: proj-skill\ndescription: P\n---\n') | |
| 126 | + await put(path.join(project, '.claude', 'skills', 'hidden', 'SKILL.md'), '---\nname: hidden\ndescription: H\n---\n') | |
| 127 | + await put(path.join(project, '.claude', 'skills', 'local-off', 'SKILL.md'), '---\nname: local-off\n---\n') | |
| 128 | + await put(path.join(project, '.claude', 'rules', 'r.md'), 'rule') | |
| 129 | + await put(path.join(project, '.claude', 'agents', 'a.md'), '---\ndescription: A\n---\n') | |
| 130 | + await put(path.join(project, '.claude', 'settings.json'), json({ | |
| 131 | + skillOverrides: { hidden: 'user-invocable-only', 'local-off': 'name-only' }, | |
| 132 | + disabledMcpjsonServers: ['disabled-one'], | |
| 133 | + hooks: { PostToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'prettier' }] }] }, | |
| 134 | + })) | |
| 135 | + await put(path.join(project, '.claude', 'settings.local.json'), json({ | |
| 136 | + skillOverrides: { 'local-off': 'off' }, | |
| 137 | + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'say done' }] }] }, | |
| 138 | + })) | |
| 139 | + await put(path.join(project, '.mcp.json'), json({ | |
| 140 | + mcpServers: { | |
| 141 | + 'disabled-one': { command: 'a' }, | |
| 142 | + 'disabled-in-claude-json': { command: 'b' }, | |
| 143 | + fine: { command: 'c', env: { K: '${K_VAR}' } }, | |
| 144 | + }, | |
| 145 | + })) | |
| 146 | + await put(path.join(project, 'CLAUDE.md'), '# Project\n') | |
| 147 | + await put(path.join(project, 'AGENTS.md'), '# Agents\n') | |
| 148 | + | |
| 149 | + // Project with only AGENTS.md | |
| 150 | + await put(path.join(tmp, 'agents-only', 'AGENTS.md'), '# Agents\n') | |
| 151 | + // Project whose CLAUDE.md imports AGENTS.md | |
| 152 | + await put(path.join(tmp, 'imports-agents', 'AGENTS.md'), '# Agents\n') | |
| 153 | + await put(path.join(tmp, 'imports-agents', 'CLAUDE.md'), '@AGENTS.md\n') | |
| 154 | +}) | |
| 155 | + | |
| 156 | +afterAll(async () => { | |
| 157 | + await rm(tmp, { recursive: true, force: true }) | |
| 158 | +}) | |
| 159 | + | |
| 160 | +describe('claude scanner: home', () => { | |
| 161 | + let r: ScanResult | |
| 162 | + beforeAll(async () => { | |
| 163 | + r = await claudeScanner.scanHome(ctx) | |
| 164 | + }) | |
| 165 | + | |
| 166 | + it('detects claude and tags every item', () => { | |
| 167 | + expect(r.detected).toBe(true) | |
| 168 | + expect(r.items.every((i) => i.agent === 'claude')).toBe(true) | |
| 169 | + expect(new Set(r.items.map((i) => i.id)).size).toBe(r.items.length) | |
| 170 | + }) | |
| 171 | + | |
| 172 | + it('lists skills with visibility', () => { | |
| 173 | + const alpha = find(r, 'skill', 'alpha') | |
| 174 | + expect(alpha.description).toBe('Alpha skill') | |
| 175 | + expect(alpha.hash).toMatch(/^[0-9a-f]{64}$/) | |
| 176 | + expect(alpha.details).toMatchObject({ visibility: 'name-only', visibilitySource: 'settings:skillOverrides' }) | |
| 177 | + expect((alpha.details as { files: string[] }).files.sort()).toEqual(['SKILL.md', 'ref.md']) | |
| 178 | + expect(find(r, 'skill', 'quiet').details).toMatchObject({ | |
| 179 | + visibility: 'on-demand', | |
| 180 | + visibilitySource: 'frontmatter:disable-model-invocation', | |
| 181 | + }) | |
| 182 | + expect(find(r, 'skill', 'noisy').details).toMatchObject({ visibility: 'off' }) | |
| 183 | + expect(r.items.find((i) => i.name === 'not-a-skill')).toBeUndefined() | |
| 184 | + }) | |
| 185 | + | |
| 186 | + it('lists synced skills with a note, and legacy commands as skills', () => { | |
| 187 | + const docx = find(r, 'skill', 'docx') | |
| 188 | + expect(docx.warnings.join(' ')).toMatch(/claude\.ai/) | |
| 189 | + expect(r.items.find((i) => i.name === 'synced')).toBeUndefined() | |
| 190 | + const cmd = find(r, 'skill', 'team/deploy') | |
| 191 | + expect(cmd.source.kind).toBe('file') | |
| 192 | + }) | |
| 193 | + | |
| 194 | + it('does not throw on a skill with bad frontmatter', () => { | |
| 195 | + expect(r.items.find((i) => i.name === 'broken')).toBeDefined() | |
| 196 | + }) | |
| 197 | + | |
| 198 | + it.runIf(process.platform === 'win32' || true)('reports junctions as symlinks', () => { | |
| 199 | + if (!linked) return | |
| 200 | + const s = find(r, 'skill', 'linked-skill') | |
| 201 | + expect(s.source.symlinkTarget).toBeDefined() | |
| 202 | + expect(s.source.symlinkTarget!.toLowerCase()).toContain('linked-skill') | |
| 203 | + }) | |
| 204 | + | |
| 205 | + it('lists rules with paths', () => { | |
| 206 | + expect(find(r, 'rule', 'style').details).toMatchObject({ paths: ['src/**/*.ts', 'lib/**'] }) | |
| 207 | + expect(find(r, 'rule', 'sub/always').details).toMatchObject({ paths: [] }) | |
| 208 | + }) | |
| 209 | + | |
| 210 | + it('lists agents', () => { | |
| 211 | + expect(find(r, 'agent', 'reviewer').details).toMatchObject({ model: 'sonnet', tools: ['Read', 'Grep'] }) | |
| 212 | + expect(find(r, 'agent', 'planner').details).toMatchObject({ tools: ['Read', 'Glob'] }) | |
| 213 | + }) | |
| 214 | + | |
| 215 | + it('collects imports outside code', () => { | |
| 216 | + const d = find(r, 'instructions', 'CLAUDE.md').details | |
| 217 | + expect(d).toMatchObject({ loaded: true, imports: ['docs/guide.md', '~/notes.md'] }) | |
| 218 | + }) | |
| 219 | + | |
| 220 | + it('lists hooks per handler', () => { | |
| 221 | + const names = r.items.filter((i) => i.type === 'hook' && !i.source.plugin).map((i) => i.name) | |
| 222 | + expect(names).toEqual(['PreToolUse:Bash:0', 'PreToolUse:*:1', 'PreToolUse:*:2']) | |
| 223 | + expect(find(r, 'hook', 'PreToolUse:Bash:0').details).toMatchObject({ event: 'PreToolUse', matcher: 'Bash', command: 'echo pre' }) | |
| 224 | + }) | |
| 225 | + | |
| 226 | + it('lists user MCP servers without secrets', () => { | |
| 227 | + const gh = find(r, 'mcp', 'github') | |
| 228 | + const d = gh.details as McpDetails | |
| 229 | + expect(d.transport).toBe('stdio') | |
| 230 | + expect(d.agentScope).toBe('user') | |
| 231 | + expect(d.args).toEqual(['-y', '@modelcontextprotocol/server-github', '--token', '<redacted>', '--verbose']) | |
| 232 | + expect(d.literalEnvKeys).toEqual(['GITHUB_TOKEN']) | |
| 233 | + expect(d.envVarNames).toEqual(['HOME_TOKEN']) | |
| 234 | + expect(gh.warnings.length).toBe(1) | |
| 235 | + expect(gh.hash).toBeUndefined() | |
| 236 | + const remote = find(r, 'mcp', 'remote').details as McpDetails | |
| 237 | + expect(remote.transport).toBe('http') | |
| 238 | + expect(remote.url).toBe('https://example.com/mcp') | |
| 239 | + expect(remote.envVarNames).toEqual(['REMOTE_TOKEN']) | |
| 240 | + }) | |
| 241 | + | |
| 242 | + it('never leaks account data or secrets', () => { | |
| 243 | + const dump = JSON.stringify(r) | |
| 244 | + for (const leak of ['TOPSECRET', 'secret@example.com', 'literal-value', 'ghp_abcdef', 'abc123', 'localone']) { | |
| 245 | + expect(dump).not.toContain(leak) | |
| 246 | + } | |
| 247 | + }) | |
| 248 | + | |
| 249 | + it('lists enabled plugin items only', () => { | |
| 250 | + const s = find(r, 'skill', 'demo:plugskill') | |
| 251 | + expect(s.source.plugin).toBe('demo@market') | |
| 252 | + expect(find(r, 'agent', 'demo:pagent').source.plugin).toBe('demo@market') | |
| 253 | + expect(find(r, 'hook', 'SessionStart:*:0').source.plugin).toBe('demo@market') | |
| 254 | + const mcp = find(r, 'mcp', 'plugin:demo:psrv') | |
| 255 | + expect(mcp.source.plugin).toBe('demo@market') | |
| 256 | + expect(r.items.filter((i) => i.source.plugin).length).toBe(4) | |
| 257 | + }) | |
| 258 | + | |
| 259 | + it('reports an empty home as not detected', async () => { | |
| 260 | + const empty = path.join(tmp, 'empty-home') | |
| 261 | + await mkdir(empty, { recursive: true }) | |
| 262 | + const res = await claudeScanner.scanHome({ homeDir: empty }) | |
| 263 | + expect(res).toEqual({ detected: false, items: [], errors: [] }) | |
| 264 | + }) | |
| 265 | + | |
| 266 | + it('reports invalid json without throwing and without content', async () => { | |
| 267 | + const bad = path.join(tmp, 'bad-home') | |
| 268 | + await put(path.join(bad, '.claude.json'), '{ "oauth": "TOPSECRET" ,, }') | |
| 269 | + await put(path.join(bad, '.claude', 'settings.json'), '{ nope') | |
| 270 | + const res = await claudeScanner.scanHome({ homeDir: bad }) | |
| 271 | + expect(res.detected).toBe(true) | |
| 272 | + expect(res.errors.length).toBe(2) | |
| 273 | + expect(JSON.stringify(res.errors)).not.toContain('TOPSECRET') | |
| 274 | + }) | |
| 275 | +}) | |
| 276 | + | |
| 277 | +describe('claude scanner: project', () => { | |
| 278 | + let r: ScanResult | |
| 279 | + beforeAll(async () => { | |
| 280 | + r = await claudeScanner.scanProject(project, ctx) | |
| 281 | + }) | |
| 282 | + | |
| 283 | + it('detects the project and lists items', () => { | |
| 284 | + expect(r.detected).toBe(true) | |
| 285 | + expect(find(r, 'skill', 'proj-skill').details).toMatchObject({ visibility: 'auto' }) | |
| 286 | + expect(find(r, 'rule', 'r')).toBeDefined() | |
| 287 | + expect(find(r, 'agent', 'a')).toBeDefined() | |
| 288 | + }) | |
| 289 | + | |
| 290 | + it('applies skillOverrides in order user < project < local', () => { | |
| 291 | + expect(find(r, 'skill', 'hidden').details).toMatchObject({ visibility: 'on-demand', visibilitySource: 'settings:skillOverrides' }) | |
| 292 | + expect(find(r, 'skill', 'local-off').details).toMatchObject({ visibility: 'off' }) | |
| 293 | + }) | |
| 294 | + | |
| 295 | + it('does not take home skills', () => { | |
| 296 | + expect(r.items.find((i) => i.name === 'alpha')).toBeUndefined() | |
| 297 | + }) | |
| 298 | + | |
| 299 | + it('lists hooks of project and local settings', () => { | |
| 300 | + expect(find(r, 'hook', 'PostToolUse:Edit|Write:0').source.path).toContain('settings.json') | |
| 301 | + expect(find(r, 'hook', 'Stop:*:0').source.path).toContain('settings.local.json') | |
| 302 | + }) | |
| 303 | + | |
| 304 | + it('merges project and local MCP servers with enabled state', () => { | |
| 305 | + expect(find(r, 'mcp', 'disabled-one').details).toMatchObject({ enabled: false, agentScope: 'project' }) | |
| 306 | + expect(find(r, 'mcp', 'disabled-in-claude-json').details).toMatchObject({ enabled: false }) | |
| 307 | + expect(find(r, 'mcp', 'fine').details).toMatchObject({ enabled: true, envVarNames: ['K_VAR'], literalEnvKeys: [] }) | |
| 308 | + const local = find(r, 'mcp', 'localone') | |
| 309 | + expect(local.details).toMatchObject({ agentScope: 'local', args: ['server.js', '<redacted>'] }) | |
| 310 | + expect(JSON.stringify(r)).not.toContain(SECRET) | |
| 311 | + expect(r.items.find((i) => i.name === 'github')).toBeUndefined() | |
| 312 | + }) | |
| 313 | + | |
| 314 | + it('marks AGENTS.md as not loaded when CLAUDE.md exists', () => { | |
| 315 | + expect(find(r, 'instructions', 'CLAUDE.md').details).toMatchObject({ loaded: true }) | |
| 316 | + const a = find(r, 'instructions', 'AGENTS.md') | |
| 317 | + expect(a.details).toMatchObject({ loaded: false }) | |
| 318 | + expect((a.details as { notLoadedReason?: string }).notLoadedReason).toMatch(/CLAUDE\.md/) | |
| 319 | + expect(a.hash).toBeDefined() | |
| 320 | + }) | |
| 321 | + | |
| 322 | + it('loads AGENTS.md when alone, or when CLAUDE.md imports it', async () => { | |
| 323 | + const alone = await claudeScanner.scanProject(path.join(tmp, 'agents-only'), ctx) | |
| 324 | + expect(find(alone, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true }) | |
| 325 | + const imp = await claudeScanner.scanProject(path.join(tmp, 'imports-agents'), ctx) | |
| 326 | + expect(find(imp, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true }) | |
| 327 | + }) | |
| 328 | + | |
| 329 | + it('does not detect an empty folder', async () => { | |
| 330 | + const empty = path.join(tmp, 'empty-proj') | |
| 331 | + await mkdir(empty, { recursive: true }) | |
| 332 | + expect(await claudeScanner.scanProject(empty, ctx)).toEqual({ detected: false, items: [], errors: [] }) | |
| 333 | + }) | |
| 334 | +}) | |
| 335 | + | |
| 336 | +describe('redaction', () => { | |
| 337 | + it('redacts flags, key=value, headers and credential-looking args', () => { | |
| 338 | + const { args, redacted } = redactArgs([ | |
| 339 | + '--api-key=abc', | |
| 340 | + '--password', | |
| 341 | + 'hunter2', | |
| 342 | + 'API_SECRET=zzz', | |
| 343 | + '-H', | |
| 344 | + 'Authorization: Bearer xyz', | |
| 345 | + 'sk-abcdef', | |
| 346 | + 'eyJhbGciOi.eyJzdWIiOi.sig', | |
| 347 | + 'Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA', | |
| 348 | + 'safe-package-name', | |
| 349 | + '@scope/server-filesystem', | |
| 350 | + '${TOKEN}', | |
| 351 | + ]) | |
| 352 | + expect(redacted).toBe(true) | |
| 353 | + expect(args).toEqual([ | |
| 354 | + '--api-key=<redacted>', | |
| 355 | + '--password', | |
| 356 | + '<redacted>', | |
| 357 | + 'API_SECRET=<redacted>', | |
| 358 | + '-H', | |
| 359 | + 'Authorization: <redacted>', | |
| 360 | + '<redacted>', | |
| 361 | + '<redacted>', | |
| 362 | + '<redacted>', | |
| 363 | + 'safe-package-name', | |
| 364 | + '@scope/server-filesystem', | |
| 365 | + '${TOKEN}', | |
| 366 | + ]) | |
| 367 | + }) | |
| 368 | + | |
| 369 | + it('leaves harmless args and urls alone', () => { | |
| 370 | + expect(redactArgs(['-y', 'mcp-server-sequential-thinking']).redacted).toBe(false) | |
| 371 | + expect(sanitizeUrl('https://x.dev/mcp?page=2')).toEqual({ url: 'https://x.dev/mcp?page=2', redacted: false }) | |
| 372 | + expect(sanitizeUrl('https://u:p@x.dev/mcp?token=1#f')).toEqual({ url: 'https://x.dev/mcp#f', redacted: true }) | |
| 373 | + }) | |
| 374 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,374 @@ | |||
| 1 | +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' | ||
| 2 | +import os from 'node:os' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
| 5 | +import type { InventoryItem, McpDetails, ScanContext, ScanResult } from '../src/model.ts' | ||
| 6 | +import { claudeScanner } from '../src/scanners/claude/index.ts' | ||
| 7 | +import { redactArgs, sanitizeUrl } from '../src/scanners/claude/redact.ts' | ||
| 8 | + | ||
| 9 | +let tmp: string | ||
| 10 | +let home: string | ||
| 11 | +let project: string | ||
| 12 | +let ctx: ScanContext | ||
| 13 | +let linked = false | ||
| 14 | + | ||
| 15 | +async function put(file: string, content: string): Promise<void> { | ||
| 16 | + await mkdir(path.dirname(file), { recursive: true }) | ||
| 17 | + await writeFile(file, content) | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +const json = (v: unknown): string => JSON.stringify(v, null, 2) | ||
| 21 | + | ||
| 22 | +function find(r: ScanResult, type: string, name: string): InventoryItem { | ||
| 23 | + const item = r.items.find((i) => i.type === type && i.name === name) | ||
| 24 | + if (!item) throw new Error(`missing ${type}:${name} in ${r.items.map((i) => `${i.type}:${i.name}`).join(', ')}`) | ||
| 25 | + return item | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +const SECRET = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA' | ||
| 29 | + | ||
| 30 | +beforeAll(async () => { | ||
| 31 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-claude-')) | ||
| 32 | + home = path.join(tmp, 'home') | ||
| 33 | + project = path.join(tmp, 'proj') | ||
| 34 | + ctx = { homeDir: home } | ||
| 35 | + const c = path.join(home, '.claude') | ||
| 36 | + | ||
| 37 | + // Home skills | ||
| 38 | + await put(path.join(c, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: Alpha skill\n---\nBody') | ||
| 39 | + await put(path.join(c, 'skills', 'alpha', 'ref.md'), 'ref') | ||
| 40 | + await put( | ||
| 41 | + path.join(c, 'skills', 'quiet', 'SKILL.md'), | ||
| 42 | + '---\nname: quiet\ndescription: Quiet\ndisable-model-invocation: true\n---\n', | ||
| 43 | + ) | ||
| 44 | + await put(path.join(c, 'skills', 'noisy', 'SKILL.md'), '---\ndescription: Overridden\n---\n') | ||
| 45 | + await put(path.join(c, 'skills', 'synced', 'abc_def', 'docx', 'SKILL.md'), '---\nname: docx\ndescription: Docs\n---\n') | ||
| 46 | + await put(path.join(c, 'skills', 'not-a-skill', 'README.md'), 'nothing') | ||
| 47 | + await put(path.join(c, 'commands', 'team', 'deploy.md'), '---\ndescription: Deploy\n---\nrun') | ||
| 48 | + // A broken skill (bad frontmatter) must not throw | ||
| 49 | + await put(path.join(c, 'skills', 'broken', 'SKILL.md'), '---\nname: [unclosed\n---\n') | ||
| 50 | + | ||
| 51 | + // Real skill linked through a junction | ||
| 52 | + const outside = path.join(tmp, 'outside', 'linked-skill') | ||
| 53 | + await put(path.join(outside, 'SKILL.md'), '---\nname: linked-skill\ndescription: Linked\n---\n') | ||
| 54 | + try { | ||
| 55 | + await symlink(outside, path.join(c, 'skills', 'linked-skill'), 'junction') | ||
| 56 | + linked = true | ||
| 57 | + } catch { | ||
| 58 | + linked = false | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + // Rules, agents, instructions | ||
| 62 | + await put(path.join(c, 'rules', 'style.md'), '---\npaths:\n - "src/**/*.ts"\n - "lib/**"\n---\nUse tabs') | ||
| 63 | + await put(path.join(c, 'rules', 'sub', 'always.md'), 'Always') | ||
| 64 | + await put(path.join(c, 'agents', 'reviewer.md'), '---\nname: reviewer\ndescription: Reviews\nmodel: sonnet\ntools: Read, Grep\n---\n') | ||
| 65 | + await put(path.join(c, 'agents', 'planner.md'), '---\ndescription: Plans\ntools:\n - Read\n - Glob\n---\n') | ||
| 66 | + await put( | ||
| 67 | + path.join(c, 'CLAUDE.md'), | ||
| 68 | + 'See @docs/guide.md and @~/notes.md.\n\n```\n@in/fence.md\n```\nInline `@in/code.md` and mail me@example.com\n', | ||
| 69 | + ) | ||
| 70 | + | ||
| 71 | + // Settings with hooks + enabled plugin | ||
| 72 | + await put( | ||
| 73 | + path.join(c, 'settings.json'), | ||
| 74 | + json({ | ||
| 75 | + hooks: { | ||
| 76 | + PreToolUse: [ | ||
| 77 | + { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo pre' }] }, | ||
| 78 | + { hooks: [{ type: 'command', command: 'echo any' }, { type: 'command', command: 'echo any2' }] }, | ||
| 79 | + ], | ||
| 80 | + }, | ||
| 81 | + skillOverrides: { noisy: 'off', alpha: 'name-only' }, | ||
| 82 | + enabledPlugins: { 'demo@market': true, 'off@market': false }, | ||
| 83 | + }), | ||
| 84 | + ) | ||
| 85 | + | ||
| 86 | + // ~/.claude.json: secrets must never leak | ||
| 87 | + await put( | ||
| 88 | + path.join(home, '.claude.json'), | ||
| 89 | + json({ | ||
| 90 | + oauthAccount: { emailAddress: 'secret@example.com', token: 'TOPSECRET' }, | ||
| 91 | + mcpServers: { | ||
| 92 | + github: { | ||
| 93 | + command: 'npx', | ||
| 94 | + args: ['-y', '@modelcontextprotocol/server-github', '--token', 'ghp_abcdefghijklmnopqrstuvwxyz0123456789', '--verbose'], | ||
| 95 | + env: { GITHUB_TOKEN: 'literal-value', FROM_ENV: '${HOME_TOKEN}' }, | ||
| 96 | + }, | ||
| 97 | + remote: { | ||
| 98 | + type: 'http', | ||
| 99 | + url: 'https://example.com/mcp?api_key=abc123&x=1', | ||
| 100 | + headers: { Authorization: 'Bearer ${REMOTE_TOKEN}' }, | ||
| 101 | + }, | ||
| 102 | + }, | ||
| 103 | + projects: { | ||
| 104 | + [project.replace(/\\/g, '/').replace(/^([a-z]):/i, (_m, d: string) => `${d.toUpperCase()}:`)]: { | ||
| 105 | + mcpServers: { localone: { command: 'node', args: ['server.js', SECRET] } }, | ||
| 106 | + disabledMcpjsonServers: ['disabled-in-claude-json'], | ||
| 107 | + }, | ||
| 108 | + }, | ||
| 109 | + }), | ||
| 110 | + ) | ||
| 111 | + | ||
| 112 | + // Plugin | ||
| 113 | + const plug = path.join(c, 'plugins', 'cache', 'market', 'demo', '1.0.0') | ||
| 114 | + await put( | ||
| 115 | + path.join(c, 'plugins', 'installed_plugins.json'), | ||
| 116 | + json({ version: 2, plugins: { 'demo@market': [{ scope: 'user', installPath: plug }], 'off@market': [{ scope: 'user', installPath: plug }] } }), | ||
| 117 | + ) | ||
| 118 | + await put(path.join(plug, '.claude-plugin', 'plugin.json'), json({ name: 'demo' })) | ||
| 119 | + await put(path.join(plug, 'skills', 'plugskill', 'SKILL.md'), '---\nname: plugskill\ndescription: From plugin\n---\n') | ||
| 120 | + await put(path.join(plug, 'agents', 'pagent.md'), '---\ndescription: Plugin agent\n---\n') | ||
| 121 | + await put(path.join(plug, 'hooks', 'hooks.json'), json({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo plug' }] }] } })) | ||
| 122 | + await put(path.join(plug, '.mcp.json'), json({ mcpServers: { psrv: { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/s.js'] } } })) | ||
| 123 | + | ||
| 124 | + // Project | ||
| 125 | + await put(path.join(project, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '---\nname: proj-skill\ndescription: P\n---\n') | ||
| 126 | + await put(path.join(project, '.claude', 'skills', 'hidden', 'SKILL.md'), '---\nname: hidden\ndescription: H\n---\n') | ||
| 127 | + await put(path.join(project, '.claude', 'skills', 'local-off', 'SKILL.md'), '---\nname: local-off\n---\n') | ||
| 128 | + await put(path.join(project, '.claude', 'rules', 'r.md'), 'rule') | ||
| 129 | + await put(path.join(project, '.claude', 'agents', 'a.md'), '---\ndescription: A\n---\n') | ||
| 130 | + await put(path.join(project, '.claude', 'settings.json'), json({ | ||
| 131 | + skillOverrides: { hidden: 'user-invocable-only', 'local-off': 'name-only' }, | ||
| 132 | + disabledMcpjsonServers: ['disabled-one'], | ||
| 133 | + hooks: { PostToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'prettier' }] }] }, | ||
| 134 | + })) | ||
| 135 | + await put(path.join(project, '.claude', 'settings.local.json'), json({ | ||
| 136 | + skillOverrides: { 'local-off': 'off' }, | ||
| 137 | + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'say done' }] }] }, | ||
| 138 | + })) | ||
| 139 | + await put(path.join(project, '.mcp.json'), json({ | ||
| 140 | + mcpServers: { | ||
| 141 | + 'disabled-one': { command: 'a' }, | ||
| 142 | + 'disabled-in-claude-json': { command: 'b' }, | ||
| 143 | + fine: { command: 'c', env: { K: '${K_VAR}' } }, | ||
| 144 | + }, | ||
| 145 | + })) | ||
| 146 | + await put(path.join(project, 'CLAUDE.md'), '# Project\n') | ||
| 147 | + await put(path.join(project, 'AGENTS.md'), '# Agents\n') | ||
| 148 | + | ||
| 149 | + // Project with only AGENTS.md | ||
| 150 | + await put(path.join(tmp, 'agents-only', 'AGENTS.md'), '# Agents\n') | ||
| 151 | + // Project whose CLAUDE.md imports AGENTS.md | ||
| 152 | + await put(path.join(tmp, 'imports-agents', 'AGENTS.md'), '# Agents\n') | ||
| 153 | + await put(path.join(tmp, 'imports-agents', 'CLAUDE.md'), '@AGENTS.md\n') | ||
| 154 | +}) | ||
| 155 | + | ||
| 156 | +afterAll(async () => { | ||
| 157 | + await rm(tmp, { recursive: true, force: true }) | ||
| 158 | +}) | ||
| 159 | + | ||
| 160 | +describe('claude scanner: home', () => { | ||
| 161 | + let r: ScanResult | ||
| 162 | + beforeAll(async () => { | ||
| 163 | + r = await claudeScanner.scanHome(ctx) | ||
| 164 | + }) | ||
| 165 | + | ||
| 166 | + it('detects claude and tags every item', () => { | ||
| 167 | + expect(r.detected).toBe(true) | ||
| 168 | + expect(r.items.every((i) => i.agent === 'claude')).toBe(true) | ||
| 169 | + expect(new Set(r.items.map((i) => i.id)).size).toBe(r.items.length) | ||
| 170 | + }) | ||
| 171 | + | ||
| 172 | + it('lists skills with visibility', () => { | ||
| 173 | + const alpha = find(r, 'skill', 'alpha') | ||
| 174 | + expect(alpha.description).toBe('Alpha skill') | ||
| 175 | + expect(alpha.hash).toMatch(/^[0-9a-f]{64}$/) | ||
| 176 | + expect(alpha.details).toMatchObject({ visibility: 'name-only', visibilitySource: 'settings:skillOverrides' }) | ||
| 177 | + expect((alpha.details as { files: string[] }).files.sort()).toEqual(['SKILL.md', 'ref.md']) | ||
| 178 | + expect(find(r, 'skill', 'quiet').details).toMatchObject({ | ||
| 179 | + visibility: 'on-demand', | ||
| 180 | + visibilitySource: 'frontmatter:disable-model-invocation', | ||
| 181 | + }) | ||
| 182 | + expect(find(r, 'skill', 'noisy').details).toMatchObject({ visibility: 'off' }) | ||
| 183 | + expect(r.items.find((i) => i.name === 'not-a-skill')).toBeUndefined() | ||
| 184 | + }) | ||
| 185 | + | ||
| 186 | + it('lists synced skills with a note, and legacy commands as skills', () => { | ||
| 187 | + const docx = find(r, 'skill', 'docx') | ||
| 188 | + expect(docx.warnings.join(' ')).toMatch(/claude\.ai/) | ||
| 189 | + expect(r.items.find((i) => i.name === 'synced')).toBeUndefined() | ||
| 190 | + const cmd = find(r, 'skill', 'team/deploy') | ||
| 191 | + expect(cmd.source.kind).toBe('file') | ||
| 192 | + }) | ||
| 193 | + | ||
| 194 | + it('does not throw on a skill with bad frontmatter', () => { | ||
| 195 | + expect(r.items.find((i) => i.name === 'broken')).toBeDefined() | ||
| 196 | + }) | ||
| 197 | + | ||
| 198 | + it.runIf(process.platform === 'win32' || true)('reports junctions as symlinks', () => { | ||
| 199 | + if (!linked) return | ||
| 200 | + const s = find(r, 'skill', 'linked-skill') | ||
| 201 | + expect(s.source.symlinkTarget).toBeDefined() | ||
| 202 | + expect(s.source.symlinkTarget!.toLowerCase()).toContain('linked-skill') | ||
| 203 | + }) | ||
| 204 | + | ||
| 205 | + it('lists rules with paths', () => { | ||
| 206 | + expect(find(r, 'rule', 'style').details).toMatchObject({ paths: ['src/**/*.ts', 'lib/**'] }) | ||
| 207 | + expect(find(r, 'rule', 'sub/always').details).toMatchObject({ paths: [] }) | ||
| 208 | + }) | ||
| 209 | + | ||
| 210 | + it('lists agents', () => { | ||
| 211 | + expect(find(r, 'agent', 'reviewer').details).toMatchObject({ model: 'sonnet', tools: ['Read', 'Grep'] }) | ||
| 212 | + expect(find(r, 'agent', 'planner').details).toMatchObject({ tools: ['Read', 'Glob'] }) | ||
| 213 | + }) | ||
| 214 | + | ||
| 215 | + it('collects imports outside code', () => { | ||
| 216 | + const d = find(r, 'instructions', 'CLAUDE.md').details | ||
| 217 | + expect(d).toMatchObject({ loaded: true, imports: ['docs/guide.md', '~/notes.md'] }) | ||
| 218 | + }) | ||
| 219 | + | ||
| 220 | + it('lists hooks per handler', () => { | ||
| 221 | + const names = r.items.filter((i) => i.type === 'hook' && !i.source.plugin).map((i) => i.name) | ||
| 222 | + expect(names).toEqual(['PreToolUse:Bash:0', 'PreToolUse:*:1', 'PreToolUse:*:2']) | ||
| 223 | + expect(find(r, 'hook', 'PreToolUse:Bash:0').details).toMatchObject({ event: 'PreToolUse', matcher: 'Bash', command: 'echo pre' }) | ||
| 224 | + }) | ||
| 225 | + | ||
| 226 | + it('lists user MCP servers without secrets', () => { | ||
| 227 | + const gh = find(r, 'mcp', 'github') | ||
| 228 | + const d = gh.details as McpDetails | ||
| 229 | + expect(d.transport).toBe('stdio') | ||
| 230 | + expect(d.agentScope).toBe('user') | ||
| 231 | + expect(d.args).toEqual(['-y', '@modelcontextprotocol/server-github', '--token', '<redacted>', '--verbose']) | ||
| 232 | + expect(d.literalEnvKeys).toEqual(['GITHUB_TOKEN']) | ||
| 233 | + expect(d.envVarNames).toEqual(['HOME_TOKEN']) | ||
| 234 | + expect(gh.warnings.length).toBe(1) | ||
| 235 | + expect(gh.hash).toBeUndefined() | ||
| 236 | + const remote = find(r, 'mcp', 'remote').details as McpDetails | ||
| 237 | + expect(remote.transport).toBe('http') | ||
| 238 | + expect(remote.url).toBe('https://example.com/mcp') | ||
| 239 | + expect(remote.envVarNames).toEqual(['REMOTE_TOKEN']) | ||
| 240 | + }) | ||
| 241 | + | ||
| 242 | + it('never leaks account data or secrets', () => { | ||
| 243 | + const dump = JSON.stringify(r) | ||
| 244 | + for (const leak of ['TOPSECRET', 'secret@example.com', 'literal-value', 'ghp_abcdef', 'abc123', 'localone']) { | ||
| 245 | + expect(dump).not.toContain(leak) | ||
| 246 | + } | ||
| 247 | + }) | ||
| 248 | + | ||
| 249 | + it('lists enabled plugin items only', () => { | ||
| 250 | + const s = find(r, 'skill', 'demo:plugskill') | ||
| 251 | + expect(s.source.plugin).toBe('demo@market') | ||
| 252 | + expect(find(r, 'agent', 'demo:pagent').source.plugin).toBe('demo@market') | ||
| 253 | + expect(find(r, 'hook', 'SessionStart:*:0').source.plugin).toBe('demo@market') | ||
| 254 | + const mcp = find(r, 'mcp', 'plugin:demo:psrv') | ||
| 255 | + expect(mcp.source.plugin).toBe('demo@market') | ||
| 256 | + expect(r.items.filter((i) => i.source.plugin).length).toBe(4) | ||
| 257 | + }) | ||
| 258 | + | ||
| 259 | + it('reports an empty home as not detected', async () => { | ||
| 260 | + const empty = path.join(tmp, 'empty-home') | ||
| 261 | + await mkdir(empty, { recursive: true }) | ||
| 262 | + const res = await claudeScanner.scanHome({ homeDir: empty }) | ||
| 263 | + expect(res).toEqual({ detected: false, items: [], errors: [] }) | ||
| 264 | + }) | ||
| 265 | + | ||
| 266 | + it('reports invalid json without throwing and without content', async () => { | ||
| 267 | + const bad = path.join(tmp, 'bad-home') | ||
| 268 | + await put(path.join(bad, '.claude.json'), '{ "oauth": "TOPSECRET" ,, }') | ||
| 269 | + await put(path.join(bad, '.claude', 'settings.json'), '{ nope') | ||
| 270 | + const res = await claudeScanner.scanHome({ homeDir: bad }) | ||
| 271 | + expect(res.detected).toBe(true) | ||
| 272 | + expect(res.errors.length).toBe(2) | ||
| 273 | + expect(JSON.stringify(res.errors)).not.toContain('TOPSECRET') | ||
| 274 | + }) | ||
| 275 | +}) | ||
| 276 | + | ||
| 277 | +describe('claude scanner: project', () => { | ||
| 278 | + let r: ScanResult | ||
| 279 | + beforeAll(async () => { | ||
| 280 | + r = await claudeScanner.scanProject(project, ctx) | ||
| 281 | + }) | ||
| 282 | + | ||
| 283 | + it('detects the project and lists items', () => { | ||
| 284 | + expect(r.detected).toBe(true) | ||
| 285 | + expect(find(r, 'skill', 'proj-skill').details).toMatchObject({ visibility: 'auto' }) | ||
| 286 | + expect(find(r, 'rule', 'r')).toBeDefined() | ||
| 287 | + expect(find(r, 'agent', 'a')).toBeDefined() | ||
| 288 | + }) | ||
| 289 | + | ||
| 290 | + it('applies skillOverrides in order user < project < local', () => { | ||
| 291 | + expect(find(r, 'skill', 'hidden').details).toMatchObject({ visibility: 'on-demand', visibilitySource: 'settings:skillOverrides' }) | ||
| 292 | + expect(find(r, 'skill', 'local-off').details).toMatchObject({ visibility: 'off' }) | ||
| 293 | + }) | ||
| 294 | + | ||
| 295 | + it('does not take home skills', () => { | ||
| 296 | + expect(r.items.find((i) => i.name === 'alpha')).toBeUndefined() | ||
| 297 | + }) | ||
| 298 | + | ||
| 299 | + it('lists hooks of project and local settings', () => { | ||
| 300 | + expect(find(r, 'hook', 'PostToolUse:Edit|Write:0').source.path).toContain('settings.json') | ||
| 301 | + expect(find(r, 'hook', 'Stop:*:0').source.path).toContain('settings.local.json') | ||
| 302 | + }) | ||
| 303 | + | ||
| 304 | + it('merges project and local MCP servers with enabled state', () => { | ||
| 305 | + expect(find(r, 'mcp', 'disabled-one').details).toMatchObject({ enabled: false, agentScope: 'project' }) | ||
| 306 | + expect(find(r, 'mcp', 'disabled-in-claude-json').details).toMatchObject({ enabled: false }) | ||
| 307 | + expect(find(r, 'mcp', 'fine').details).toMatchObject({ enabled: true, envVarNames: ['K_VAR'], literalEnvKeys: [] }) | ||
| 308 | + const local = find(r, 'mcp', 'localone') | ||
| 309 | + expect(local.details).toMatchObject({ agentScope: 'local', args: ['server.js', '<redacted>'] }) | ||
| 310 | + expect(JSON.stringify(r)).not.toContain(SECRET) | ||
| 311 | + expect(r.items.find((i) => i.name === 'github')).toBeUndefined() | ||
| 312 | + }) | ||
| 313 | + | ||
| 314 | + it('marks AGENTS.md as not loaded when CLAUDE.md exists', () => { | ||
| 315 | + expect(find(r, 'instructions', 'CLAUDE.md').details).toMatchObject({ loaded: true }) | ||
| 316 | + const a = find(r, 'instructions', 'AGENTS.md') | ||
| 317 | + expect(a.details).toMatchObject({ loaded: false }) | ||
| 318 | + expect((a.details as { notLoadedReason?: string }).notLoadedReason).toMatch(/CLAUDE\.md/) | ||
| 319 | + expect(a.hash).toBeDefined() | ||
| 320 | + }) | ||
| 321 | + | ||
| 322 | + it('loads AGENTS.md when alone, or when CLAUDE.md imports it', async () => { | ||
| 323 | + const alone = await claudeScanner.scanProject(path.join(tmp, 'agents-only'), ctx) | ||
| 324 | + expect(find(alone, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true }) | ||
| 325 | + const imp = await claudeScanner.scanProject(path.join(tmp, 'imports-agents'), ctx) | ||
| 326 | + expect(find(imp, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true }) | ||
| 327 | + }) | ||
| 328 | + | ||
| 329 | + it('does not detect an empty folder', async () => { | ||
| 330 | + const empty = path.join(tmp, 'empty-proj') | ||
| 331 | + await mkdir(empty, { recursive: true }) | ||
| 332 | + expect(await claudeScanner.scanProject(empty, ctx)).toEqual({ detected: false, items: [], errors: [] }) | ||
| 333 | + }) | ||
| 334 | +}) | ||
| 335 | + | ||
| 336 | +describe('redaction', () => { | ||
| 337 | + it('redacts flags, key=value, headers and credential-looking args', () => { | ||
| 338 | + const { args, redacted } = redactArgs([ | ||
| 339 | + '--api-key=abc', | ||
| 340 | + '--password', | ||
| 341 | + 'hunter2', | ||
| 342 | + 'API_SECRET=zzz', | ||
| 343 | + '-H', | ||
| 344 | + 'Authorization: Bearer xyz', | ||
| 345 | + 'sk-abcdef', | ||
| 346 | + 'eyJhbGciOi.eyJzdWIiOi.sig', | ||
| 347 | + 'Zm9vYmFyYmF6cXV4MTIzNDU2Nzg5MA', | ||
| 348 | + 'safe-package-name', | ||
| 349 | + '@scope/server-filesystem', | ||
| 350 | + '${TOKEN}', | ||
| 351 | + ]) | ||
| 352 | + expect(redacted).toBe(true) | ||
| 353 | + expect(args).toEqual([ | ||
| 354 | + '--api-key=<redacted>', | ||
| 355 | + '--password', | ||
| 356 | + '<redacted>', | ||
| 357 | + 'API_SECRET=<redacted>', | ||
| 358 | + '-H', | ||
| 359 | + 'Authorization: <redacted>', | ||
| 360 | + '<redacted>', | ||
| 361 | + '<redacted>', | ||
| 362 | + '<redacted>', | ||
| 363 | + 'safe-package-name', | ||
| 364 | + '@scope/server-filesystem', | ||
| 365 | + '${TOKEN}', | ||
| 366 | + ]) | ||
| 367 | + }) | ||
| 368 | + | ||
| 369 | + it('leaves harmless args and urls alone', () => { | ||
| 370 | + expect(redactArgs(['-y', 'mcp-server-sequential-thinking']).redacted).toBe(false) | ||
| 371 | + expect(sanitizeUrl('https://x.dev/mcp?page=2')).toEqual({ url: 'https://x.dev/mcp?page=2', redacted: false }) | ||
| 372 | + expect(sanitizeUrl('https://u:p@x.dev/mcp?token=1#f')).toEqual({ url: 'https://x.dev/mcp#f', redacted: true }) | ||
| 373 | + }) | ||
| 374 | +}) | ||
added
packages/core/test/codex.test.ts +311 -0 | new file mode 100644 | ||
| @@ -0,0 +1,311 @@ | ||
| 1 | +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' | |
| 2 | +import os from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | |
| 5 | +import type { InventoryItem, McpDetails, ScanContext, ScanResult } from '../src/model.ts' | |
| 6 | +import { codexScanner } from '../src/scanners/codex/index.ts' | |
| 7 | +import { redactArgs, sanitizeUrl } from '../src/scanners/codex/redact.ts' | |
| 8 | + | |
| 9 | +let tmp: string | |
| 10 | +let homeDir: string | |
| 11 | +let codexHome: string | |
| 12 | +let project: string | |
| 13 | +let ctx: ScanContext | |
| 14 | + | |
| 15 | +async function put(file: string, content: string): Promise<void> { | |
| 16 | + await mkdir(path.dirname(file), { recursive: true }) | |
| 17 | + await writeFile(file, content) | |
| 18 | +} | |
| 19 | + | |
| 20 | +function skillMd(name: string, description = 'does things'): string { | |
| 21 | + return `---\nname: ${name}\ndescription: ${description}\n---\nBody\n` | |
| 22 | +} | |
| 23 | + | |
| 24 | +function find(r: ScanResult, type: string, name: string): InventoryItem { | |
| 25 | + const item = r.items.find((i) => i.type === type && i.name === name) | |
| 26 | + if (!item) throw new Error(`missing ${type}:${name} in ${r.items.map((i) => `${i.type}:${i.name}`).join(', ')}`) | |
| 27 | + return item | |
| 28 | +} | |
| 29 | + | |
| 30 | +const TOKEN = 'abcDEF1234567890ghijKLMN0987654321xyz' | |
| 31 | + | |
| 32 | +beforeAll(async () => { | |
| 33 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-codex-')) | |
| 34 | + homeDir = path.join(tmp, 'home') | |
| 35 | + codexHome = path.join(tmp, 'codex-home') | |
| 36 | + project = path.join(tmp, 'proj') | |
| 37 | + ctx = { homeDir, codexHome } | |
| 38 | + | |
| 39 | + // ---- user config | |
| 40 | + await put( | |
| 41 | + path.join(codexHome, 'config.toml'), | |
| 42 | + ` | |
| 43 | +project_doc_fallback_filenames = ["TEAM_GUIDE.md"] | |
| 44 | +project_doc_max_bytes = 100 | |
| 45 | + | |
| 46 | +[projects."${project.replace(/\\/g, '/')}"] | |
| 47 | +trust_level = "trusted" | |
| 48 | + | |
| 49 | +[[skills.config]] | |
| 50 | +path = "${path.join(homeDir, '.agents', 'skills', 'disabled-skill', 'SKILL.md').replace(/\\/g, '/')}" | |
| 51 | +enabled = false | |
| 52 | + | |
| 53 | +[mcp_servers.github] | |
| 54 | +command = "npx" | |
| 55 | +args = ["-y", "@modelcontextprotocol/server-github", "--token", "${TOKEN}", "--api-key=${TOKEN}", "https://x.test/mcp?api_key=${TOKEN}&a=1"] | |
| 56 | +env_vars = ["GITHUB_TOKEN"] | |
| 57 | + | |
| 58 | +[mcp_servers.github.env] | |
| 59 | +FOO = "literal-value-123" | |
| 60 | +BAR = "\${FROM_ENV}" | |
| 61 | + | |
| 62 | +[mcp_servers.remote] | |
| 63 | +url = "https://api.example.com/mcp" | |
| 64 | +bearer_token_env_var = "REMOTE_TOKEN" | |
| 65 | +http_headers = { "X-Region" = "eu" } | |
| 66 | +env_http_headers = { "X-Org" = "ORG_ID" } | |
| 67 | + | |
| 68 | +[mcp_servers.off] | |
| 69 | +command = "node" | |
| 70 | +args = ["server.js"] | |
| 71 | +enabled = false | |
| 72 | + | |
| 73 | +[plugins."myplug@market"] | |
| 74 | +enabled = true | |
| 75 | + | |
| 76 | +[plugins."ghost@market"] | |
| 77 | +enabled = false | |
| 78 | + | |
| 79 | +[[hooks.PreToolUse]] | |
| 80 | +matcher = "^Bash$" | |
| 81 | + | |
| 82 | +[[hooks.PreToolUse.hooks]] | |
| 83 | +type = "command" | |
| 84 | +command = "echo inline" | |
| 85 | +`, | |
| 86 | + ) | |
| 87 | + await put(path.join(codexHome, 'AGENTS.override.md'), '# override\n') | |
| 88 | + await put(path.join(codexHome, 'AGENTS.md'), '# base\n') | |
| 89 | + await put( | |
| 90 | + path.join(codexHome, 'hooks.json'), | |
| 91 | + JSON.stringify({ | |
| 92 | + hooks: { | |
| 93 | + SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'python3 start.py' }] }], | |
| 94 | + PostToolUse: [{ hooks: [{ type: 'mcp_tool', server: 'scanner', tool: 'scan' }, { type: 'prompt' }] }], | |
| 95 | + }, | |
| 96 | + }), | |
| 97 | + ) | |
| 98 | + await put( | |
| 99 | + path.join(codexHome, 'agents', 'reviewer.toml'), | |
| 100 | + 'name = "reviewer"\ndescription = "Reviews"\nmodel = "gpt-x"\ndeveloper_instructions = "Be careful"\n', | |
| 101 | + ) | |
| 102 | + await put(path.join(codexHome, 'agents', 'broken.toml'), 'name = [') | |
| 103 | + await put(path.join(codexHome, 'rules', 'default.rules'), 'prefix_rule(pattern=["ls"], decision="allow")\n') | |
| 104 | + await put(path.join(codexHome, 'skills', 'legacy', 'SKILL.md'), skillMd('legacy')) | |
| 105 | + await put(path.join(codexHome, 'skills', '.system', 'bundled', 'SKILL.md'), skillMd('bundled')) | |
| 106 | + | |
| 107 | + // ---- user skills | |
| 108 | + const skills = path.join(homeDir, '.agents', 'skills') | |
| 109 | + await put(path.join(skills, 'alpha', 'SKILL.md'), skillMd('alpha', 'Alpha skill')) | |
| 110 | + await put(path.join(skills, 'alpha', 'references', 'a.md'), 'ref') | |
| 111 | + await put(path.join(skills, 'alpha', 'agents', 'openai.yaml'), 'policy:\n allow_implicit_invocation: false\n') | |
| 112 | + await put(path.join(skills, 'disabled-skill', 'SKILL.md'), skillMd('disabled-skill')) | |
| 113 | + await put(path.join(tmp, 'elsewhere', 'linked', 'SKILL.md'), skillMd('linked')) | |
| 114 | + await symlink(path.join(tmp, 'elsewhere', 'linked'), path.join(skills, 'linked'), 'junction') | |
| 115 | + await put(path.join(skills, 'bad-yaml', 'SKILL.md'), skillMd('bad-yaml')) | |
| 116 | + await put(path.join(skills, 'bad-yaml', 'agents', 'openai.yaml'), 'policy: [unclosed') | |
| 117 | + | |
| 118 | + // ---- plugin (best effort layout) | |
| 119 | + const pluginRoot = path.join(codexHome, 'plugins', 'cache', 'market', 'myplug', '1.0.0') | |
| 120 | + await put(path.join(pluginRoot, 'skills', 'plug-skill', 'SKILL.md'), skillMd('plug-skill')) | |
| 121 | +}, 30_000) | |
| 122 | + | |
| 123 | +afterAll(async () => { | |
| 124 | + await rm(tmp, { recursive: true, force: true }) | |
| 125 | +}) | |
| 126 | + | |
| 127 | +describe('redaction helpers', () => { | |
| 128 | + it('redacts flag values, key=value pairs, credentials and sensitive urls', () => { | |
| 129 | + const r = redactArgs(['--token', 'x', '--api-key=abc', 'sk-abcdefghijklmnopqrstuv', 'ghp_abcdefghijklmnopqrstuvwx', '-y', 'pkg']) | |
| 130 | + expect(r.value).toEqual(['--token', '<redacted>', '--api-key=<redacted>', '<redacted>', '<redacted>', '-y', 'pkg']) | |
| 131 | + expect(r.redacted).toBe(true) | |
| 132 | + expect(redactArgs(['-y', '@scope/server-name', '--port', '3000']).redacted).toBe(false) | |
| 133 | + expect(redactArgs(['--header', 'Authorization: Bearer abc']).value).toEqual(['--header', 'Authorization: <redacted>']) | |
| 134 | + expect(sanitizeUrl('https://a.test/mcp?token=abc').value).toBe('https://a.test/mcp') | |
| 135 | + expect(sanitizeUrl('https://a.test/mcp?page=2')).toEqual({ value: 'https://a.test/mcp?page=2', redacted: false }) | |
| 136 | + }) | |
| 137 | +}) | |
| 138 | + | |
| 139 | +describe('codex scanHome', () => { | |
| 140 | + let r: ScanResult | |
| 141 | + beforeAll(async () => { | |
| 142 | + r = await codexScanner.scanHome(ctx) | |
| 143 | + }) | |
| 144 | + | |
| 145 | + it('detects codex and uses agent codex everywhere', () => { | |
| 146 | + expect(r.detected).toBe(true) | |
| 147 | + expect(r.items.every((i) => i.agent === 'codex')).toBe(true) | |
| 148 | + expect(new Set(r.items.map((i) => i.id)).size).toBe(r.items.length) | |
| 149 | + }) | |
| 150 | + | |
| 151 | + it('reports AGENTS.override.md as loaded and AGENTS.md as shadowed', () => { | |
| 152 | + const o = find(r, 'instructions', 'AGENTS.override.md') | |
| 153 | + const b = find(r, 'instructions', 'AGENTS.md') | |
| 154 | + expect(o.details).toMatchObject({ loaded: true }) | |
| 155 | + expect(b.details).toMatchObject({ loaded: false }) | |
| 156 | + expect((b.details as { notLoadedReason?: string }).notLoadedReason).toContain('AGENTS.override.md') | |
| 157 | + expect(o.hash).toMatch(/^[0-9a-f]{64}$/) | |
| 158 | + }) | |
| 159 | + | |
| 160 | + it('lists skills with visibility, symlink target and hash', () => { | |
| 161 | + expect(find(r, 'skill', 'alpha').details).toMatchObject({ visibility: 'on-demand', visibilitySource: 'agents/openai.yaml' }) | |
| 162 | + expect(find(r, 'skill', 'disabled-skill').details).toMatchObject({ visibility: 'off' }) | |
| 163 | + const linked = find(r, 'skill', 'linked') | |
| 164 | + expect(linked.source.symlinkTarget).toBeTruthy() | |
| 165 | + expect(linked.hash).toBeTruthy() | |
| 166 | + const alpha = find(r, 'skill', 'alpha') | |
| 167 | + expect((alpha.details as { files: string[] }).files).toContain('references/a.md') | |
| 168 | + expect(find(r, 'skill', 'bad-yaml').details).toMatchObject({ visibility: 'auto' }) | |
| 169 | + expect(r.errors.some((e) => e.path.endsWith('openai.yaml'))).toBe(true) | |
| 170 | + }) | |
| 171 | + | |
| 172 | + it('flags the legacy skills location and skips bundled system skills', () => { | |
| 173 | + expect(find(r, 'skill', 'legacy').warnings.join(' ')).toContain('legacy location') | |
| 174 | + expect(r.items.find((i) => i.name === 'bundled')).toBeUndefined() | |
| 175 | + }) | |
| 176 | + | |
| 177 | + it('lists enabled plugin skills with source.plugin', () => { | |
| 178 | + expect(find(r, 'skill', 'plug-skill').source.plugin).toBe('myplug@market') | |
| 179 | + }) | |
| 180 | + | |
| 181 | + it('parses MCP servers and never leaks secrets', () => { | |
| 182 | + const gh = find(r, 'mcp', 'github') | |
| 183 | + const d = gh.details as McpDetails | |
| 184 | + expect(d.transport).toBe('stdio') | |
| 185 | + expect(d.agentScope).toBe('user') | |
| 186 | + expect(d.args![d.args!.indexOf('--token') + 1]).toBe('<redacted>') | |
| 187 | + expect(d.args).toContain('--api-key=<redacted>') | |
| 188 | + expect(d.args!.find((a) => a.startsWith('https://'))).toBe('https://x.test/mcp') | |
| 189 | + expect([...d.literalEnvKeys].sort()).toEqual(['BAR', 'FOO']) | |
| 190 | + expect(d.envVarNames).toEqual(expect.arrayContaining(['GITHUB_TOKEN', 'FROM_ENV'])) | |
| 191 | + expect(gh.warnings.some((w) => w.includes('redacted'))).toBe(true) | |
| 192 | + const all = JSON.stringify(r) | |
| 193 | + expect(all).not.toContain(TOKEN) | |
| 194 | + expect(all).not.toContain('literal-value-123') | |
| 195 | + | |
| 196 | + const remote = find(r, 'mcp', 'remote').details as McpDetails | |
| 197 | + expect(remote.transport).toBe('http') | |
| 198 | + expect(remote.envVarNames).toEqual(expect.arrayContaining(['REMOTE_TOKEN', 'ORG_ID'])) | |
| 199 | + expect(all).not.toContain('"eu"') | |
| 200 | + | |
| 201 | + expect((find(r, 'mcp', 'off').details as McpDetails).enabled).toBe(false) | |
| 202 | + expect(gh.hash).toBeUndefined() | |
| 203 | + }) | |
| 204 | + | |
| 205 | + it('parses hooks from hooks.json and inline config.toml', () => { | |
| 206 | + expect(find(r, 'hook', 'SessionStart:startup:0').details).toMatchObject({ | |
| 207 | + event: 'SessionStart', | |
| 208 | + matcher: 'startup', | |
| 209 | + handlerType: 'command', | |
| 210 | + command: 'python3 start.py', | |
| 211 | + }) | |
| 212 | + expect(find(r, 'hook', 'PostToolUse:*:0').details).toMatchObject({ handlerType: 'mcp_tool', command: 'scanner.scan' }) | |
| 213 | + expect(find(r, 'hook', 'PostToolUse:*:1').warnings.join(' ')).toContain('skipped') | |
| 214 | + expect(find(r, 'hook', 'PreToolUse:^Bash$:0').details).toMatchObject({ command: 'echo inline' }) | |
| 215 | + }) | |
| 216 | + | |
| 217 | + it('parses custom agents and reports bad files without throwing', () => { | |
| 218 | + const a = find(r, 'agent', 'reviewer') | |
| 219 | + expect(a.details).toMatchObject({ type: 'agent', model: 'gpt-x' }) | |
| 220 | + expect(a.hash).toBeTruthy() | |
| 221 | + expect(r.errors.some((e) => e.path.endsWith('broken.toml'))).toBe(true) | |
| 222 | + }) | |
| 223 | + | |
| 224 | + it('does not emit execution-policy rules as rule items', () => { | |
| 225 | + expect(r.items.some((i) => i.type === 'rule')).toBe(false) | |
| 226 | + }) | |
| 227 | + | |
| 228 | + it('is not detected on an empty home', async () => { | |
| 229 | + const empty = await mkdtemp(path.join(os.tmpdir(), 'skilly-codex-empty-')) | |
| 230 | + try { | |
| 231 | + const res = await codexScanner.scanHome({ homeDir: empty }) | |
| 232 | + expect(res.detected).toBe(false) | |
| 233 | + expect(res.items).toEqual([]) | |
| 234 | + } finally { | |
| 235 | + await rm(empty, { recursive: true, force: true }) | |
| 236 | + } | |
| 237 | + }) | |
| 238 | +}) | |
| 239 | + | |
| 240 | +describe('codex scanProject', () => { | |
| 241 | + let untrusted: string | |
| 242 | + | |
| 243 | + beforeAll(async () => { | |
| 244 | + await put(path.join(project, 'AGENTS.override.md'), 'x'.repeat(150)) | |
| 245 | + await put(path.join(project, 'AGENTS.md'), '# normal\n') | |
| 246 | + await put(path.join(project, 'TEAM_GUIDE.md'), '# team\n') | |
| 247 | + await put(path.join(project, '.agents', 'skills', 'proj-skill', 'SKILL.md'), skillMd('proj-skill')) | |
| 248 | + await put( | |
| 249 | + path.join(project, '.codex', 'config.toml'), | |
| 250 | + '[mcp_servers.docs]\nurl = "https://docs.test/mcp?token=abc"\n\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\ncommand = "echo bye"\n', | |
| 251 | + ) | |
| 252 | + await put( | |
| 253 | + path.join(project, '.codex', 'hooks.json'), | |
| 254 | + JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }), | |
| 255 | + ) | |
| 256 | + await put( | |
| 257 | + path.join(project, '.codex', 'agents', 'pr.toml'), | |
| 258 | + 'name = "pr"\ndescription = "d"\ndeveloper_instructions = "i"\n', | |
| 259 | + ) | |
| 260 | + | |
| 261 | + untrusted = path.join(tmp, 'untrusted') | |
| 262 | + await put(path.join(untrusted, 'AGENTS.md'), '# hi\n') | |
| 263 | + await put(path.join(untrusted, '.codex', 'config.toml'), '[mcp_servers.x]\ncommand = "node"\n') | |
| 264 | + await put( | |
| 265 | + path.join(untrusted, '.codex', 'hooks.json'), | |
| 266 | + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'c' }] }] } }), | |
| 267 | + ) | |
| 268 | + }) | |
| 269 | + | |
| 270 | + it('handles a trusted project', async () => { | |
| 271 | + const r = await codexScanner.scanProject(project, ctx) | |
| 272 | + expect(r.detected).toBe(true) | |
| 273 | + expect(r.errors).toEqual([]) | |
| 274 | + | |
| 275 | + const o = find(r, 'instructions', 'AGENTS.override.md') | |
| 276 | + expect(o.details).toMatchObject({ loaded: true }) | |
| 277 | + expect(o.warnings.join(' ')).toContain('project_doc_max_bytes') | |
| 278 | + expect(find(r, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: false }) | |
| 279 | + expect(find(r, 'instructions', 'TEAM_GUIDE.md').details).toMatchObject({ loaded: false }) | |
| 280 | + | |
| 281 | + expect(find(r, 'skill', 'proj-skill').details).toMatchObject({ visibility: 'auto' }) | |
| 282 | + | |
| 283 | + const mcp = find(r, 'mcp', 'docs') | |
| 284 | + expect((mcp.details as McpDetails).agentScope).toBe('project') | |
| 285 | + expect((mcp.details as McpDetails).url).toBe('https://docs.test/mcp') | |
| 286 | + expect(mcp.warnings.join(' ')).not.toContain('not trusted') | |
| 287 | + expect(find(r, 'hook', 'Stop:*:0').warnings).toEqual([]) | |
| 288 | + expect(find(r, 'hook', 'SessionStart:*:0').details).toMatchObject({ command: 'echo hi' }) | |
| 289 | + expect(find(r, 'agent', 'pr').warnings).toEqual([]) | |
| 290 | + }) | |
| 291 | + | |
| 292 | + it('warns that an untrusted project config is ignored', async () => { | |
| 293 | + const r = await codexScanner.scanProject(untrusted, ctx) | |
| 294 | + const msg = 'project not trusted: Codex ignores this config' | |
| 295 | + expect(find(r, 'mcp', 'x').warnings).toContain(msg) | |
| 296 | + expect(find(r, 'hook', 'Stop:*:0').warnings).toContain(msg) | |
| 297 | + expect(find(r, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true }) | |
| 298 | + }) | |
| 299 | + | |
| 300 | + it('is not detected on a folder without codex files', async () => { | |
| 301 | + const empty = await mkdtemp(path.join(os.tmpdir(), 'skilly-codex-emptyproj-')) | |
| 302 | + try { | |
| 303 | + const r = await codexScanner.scanProject(empty, ctx) | |
| 304 | + expect(r.detected).toBe(false) | |
| 305 | + expect(r.items).toEqual([]) | |
| 306 | + } finally { | |
| 307 | + await rm(empty, { recursive: true, force: true }) | |
| 308 | + } | |
| 309 | + }) | |
| 310 | +}) | |
| 311 | + | |
| new file mode 100644 | |||
| @@ -0,0 +1,311 @@ | |||
| 1 | +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' | ||
| 2 | +import os from 'node:os' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest' | ||
| 5 | +import type { InventoryItem, McpDetails, ScanContext, ScanResult } from '../src/model.ts' | ||
| 6 | +import { codexScanner } from '../src/scanners/codex/index.ts' | ||
| 7 | +import { redactArgs, sanitizeUrl } from '../src/scanners/codex/redact.ts' | ||
| 8 | + | ||
| 9 | +let tmp: string | ||
| 10 | +let homeDir: string | ||
| 11 | +let codexHome: string | ||
| 12 | +let project: string | ||
| 13 | +let ctx: ScanContext | ||
| 14 | + | ||
| 15 | +async function put(file: string, content: string): Promise<void> { | ||
| 16 | + await mkdir(path.dirname(file), { recursive: true }) | ||
| 17 | + await writeFile(file, content) | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +function skillMd(name: string, description = 'does things'): string { | ||
| 21 | + return `---\nname: ${name}\ndescription: ${description}\n---\nBody\n` | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +function find(r: ScanResult, type: string, name: string): InventoryItem { | ||
| 25 | + const item = r.items.find((i) => i.type === type && i.name === name) | ||
| 26 | + if (!item) throw new Error(`missing ${type}:${name} in ${r.items.map((i) => `${i.type}:${i.name}`).join(', ')}`) | ||
| 27 | + return item | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +const TOKEN = 'abcDEF1234567890ghijKLMN0987654321xyz' | ||
| 31 | + | ||
| 32 | +beforeAll(async () => { | ||
| 33 | + tmp = await mkdtemp(path.join(os.tmpdir(), 'skilly-codex-')) | ||
| 34 | + homeDir = path.join(tmp, 'home') | ||
| 35 | + codexHome = path.join(tmp, 'codex-home') | ||
| 36 | + project = path.join(tmp, 'proj') | ||
| 37 | + ctx = { homeDir, codexHome } | ||
| 38 | + | ||
| 39 | + // ---- user config | ||
| 40 | + await put( | ||
| 41 | + path.join(codexHome, 'config.toml'), | ||
| 42 | + ` | ||
| 43 | +project_doc_fallback_filenames = ["TEAM_GUIDE.md"] | ||
| 44 | +project_doc_max_bytes = 100 | ||
| 45 | + | ||
| 46 | +[projects."${project.replace(/\\/g, '/')}"] | ||
| 47 | +trust_level = "trusted" | ||
| 48 | + | ||
| 49 | +[[skills.config]] | ||
| 50 | +path = "${path.join(homeDir, '.agents', 'skills', 'disabled-skill', 'SKILL.md').replace(/\\/g, '/')}" | ||
| 51 | +enabled = false | ||
| 52 | + | ||
| 53 | +[mcp_servers.github] | ||
| 54 | +command = "npx" | ||
| 55 | +args = ["-y", "@modelcontextprotocol/server-github", "--token", "${TOKEN}", "--api-key=${TOKEN}", "https://x.test/mcp?api_key=${TOKEN}&a=1"] | ||
| 56 | +env_vars = ["GITHUB_TOKEN"] | ||
| 57 | + | ||
| 58 | +[mcp_servers.github.env] | ||
| 59 | +FOO = "literal-value-123" | ||
| 60 | +BAR = "\${FROM_ENV}" | ||
| 61 | + | ||
| 62 | +[mcp_servers.remote] | ||
| 63 | +url = "https://api.example.com/mcp" | ||
| 64 | +bearer_token_env_var = "REMOTE_TOKEN" | ||
| 65 | +http_headers = { "X-Region" = "eu" } | ||
| 66 | +env_http_headers = { "X-Org" = "ORG_ID" } | ||
| 67 | + | ||
| 68 | +[mcp_servers.off] | ||
| 69 | +command = "node" | ||
| 70 | +args = ["server.js"] | ||
| 71 | +enabled = false | ||
| 72 | + | ||
| 73 | +[plugins."myplug@market"] | ||
| 74 | +enabled = true | ||
| 75 | + | ||
| 76 | +[plugins."ghost@market"] | ||
| 77 | +enabled = false | ||
| 78 | + | ||
| 79 | +[[hooks.PreToolUse]] | ||
| 80 | +matcher = "^Bash$" | ||
| 81 | + | ||
| 82 | +[[hooks.PreToolUse.hooks]] | ||
| 83 | +type = "command" | ||
| 84 | +command = "echo inline" | ||
| 85 | +`, | ||
| 86 | + ) | ||
| 87 | + await put(path.join(codexHome, 'AGENTS.override.md'), '# override\n') | ||
| 88 | + await put(path.join(codexHome, 'AGENTS.md'), '# base\n') | ||
| 89 | + await put( | ||
| 90 | + path.join(codexHome, 'hooks.json'), | ||
| 91 | + JSON.stringify({ | ||
| 92 | + hooks: { | ||
| 93 | + SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: 'python3 start.py' }] }], | ||
| 94 | + PostToolUse: [{ hooks: [{ type: 'mcp_tool', server: 'scanner', tool: 'scan' }, { type: 'prompt' }] }], | ||
| 95 | + }, | ||
| 96 | + }), | ||
| 97 | + ) | ||
| 98 | + await put( | ||
| 99 | + path.join(codexHome, 'agents', 'reviewer.toml'), | ||
| 100 | + 'name = "reviewer"\ndescription = "Reviews"\nmodel = "gpt-x"\ndeveloper_instructions = "Be careful"\n', | ||
| 101 | + ) | ||
| 102 | + await put(path.join(codexHome, 'agents', 'broken.toml'), 'name = [') | ||
| 103 | + await put(path.join(codexHome, 'rules', 'default.rules'), 'prefix_rule(pattern=["ls"], decision="allow")\n') | ||
| 104 | + await put(path.join(codexHome, 'skills', 'legacy', 'SKILL.md'), skillMd('legacy')) | ||
| 105 | + await put(path.join(codexHome, 'skills', '.system', 'bundled', 'SKILL.md'), skillMd('bundled')) | ||
| 106 | + | ||
| 107 | + // ---- user skills | ||
| 108 | + const skills = path.join(homeDir, '.agents', 'skills') | ||
| 109 | + await put(path.join(skills, 'alpha', 'SKILL.md'), skillMd('alpha', 'Alpha skill')) | ||
| 110 | + await put(path.join(skills, 'alpha', 'references', 'a.md'), 'ref') | ||
| 111 | + await put(path.join(skills, 'alpha', 'agents', 'openai.yaml'), 'policy:\n allow_implicit_invocation: false\n') | ||
| 112 | + await put(path.join(skills, 'disabled-skill', 'SKILL.md'), skillMd('disabled-skill')) | ||
| 113 | + await put(path.join(tmp, 'elsewhere', 'linked', 'SKILL.md'), skillMd('linked')) | ||
| 114 | + await symlink(path.join(tmp, 'elsewhere', 'linked'), path.join(skills, 'linked'), 'junction') | ||
| 115 | + await put(path.join(skills, 'bad-yaml', 'SKILL.md'), skillMd('bad-yaml')) | ||
| 116 | + await put(path.join(skills, 'bad-yaml', 'agents', 'openai.yaml'), 'policy: [unclosed') | ||
| 117 | + | ||
| 118 | + // ---- plugin (best effort layout) | ||
| 119 | + const pluginRoot = path.join(codexHome, 'plugins', 'cache', 'market', 'myplug', '1.0.0') | ||
| 120 | + await put(path.join(pluginRoot, 'skills', 'plug-skill', 'SKILL.md'), skillMd('plug-skill')) | ||
| 121 | +}, 30_000) | ||
| 122 | + | ||
| 123 | +afterAll(async () => { | ||
| 124 | + await rm(tmp, { recursive: true, force: true }) | ||
| 125 | +}) | ||
| 126 | + | ||
| 127 | +describe('redaction helpers', () => { | ||
| 128 | + it('redacts flag values, key=value pairs, credentials and sensitive urls', () => { | ||
| 129 | + const r = redactArgs(['--token', 'x', '--api-key=abc', 'sk-abcdefghijklmnopqrstuv', 'ghp_abcdefghijklmnopqrstuvwx', '-y', 'pkg']) | ||
| 130 | + expect(r.value).toEqual(['--token', '<redacted>', '--api-key=<redacted>', '<redacted>', '<redacted>', '-y', 'pkg']) | ||
| 131 | + expect(r.redacted).toBe(true) | ||
| 132 | + expect(redactArgs(['-y', '@scope/server-name', '--port', '3000']).redacted).toBe(false) | ||
| 133 | + expect(redactArgs(['--header', 'Authorization: Bearer abc']).value).toEqual(['--header', 'Authorization: <redacted>']) | ||
| 134 | + expect(sanitizeUrl('https://a.test/mcp?token=abc').value).toBe('https://a.test/mcp') | ||
| 135 | + expect(sanitizeUrl('https://a.test/mcp?page=2')).toEqual({ value: 'https://a.test/mcp?page=2', redacted: false }) | ||
| 136 | + }) | ||
| 137 | +}) | ||
| 138 | + | ||
| 139 | +describe('codex scanHome', () => { | ||
| 140 | + let r: ScanResult | ||
| 141 | + beforeAll(async () => { | ||
| 142 | + r = await codexScanner.scanHome(ctx) | ||
| 143 | + }) | ||
| 144 | + | ||
| 145 | + it('detects codex and uses agent codex everywhere', () => { | ||
| 146 | + expect(r.detected).toBe(true) | ||
| 147 | + expect(r.items.every((i) => i.agent === 'codex')).toBe(true) | ||
| 148 | + expect(new Set(r.items.map((i) => i.id)).size).toBe(r.items.length) | ||
| 149 | + }) | ||
| 150 | + | ||
| 151 | + it('reports AGENTS.override.md as loaded and AGENTS.md as shadowed', () => { | ||
| 152 | + const o = find(r, 'instructions', 'AGENTS.override.md') | ||
| 153 | + const b = find(r, 'instructions', 'AGENTS.md') | ||
| 154 | + expect(o.details).toMatchObject({ loaded: true }) | ||
| 155 | + expect(b.details).toMatchObject({ loaded: false }) | ||
| 156 | + expect((b.details as { notLoadedReason?: string }).notLoadedReason).toContain('AGENTS.override.md') | ||
| 157 | + expect(o.hash).toMatch(/^[0-9a-f]{64}$/) | ||
| 158 | + }) | ||
| 159 | + | ||
| 160 | + it('lists skills with visibility, symlink target and hash', () => { | ||
| 161 | + expect(find(r, 'skill', 'alpha').details).toMatchObject({ visibility: 'on-demand', visibilitySource: 'agents/openai.yaml' }) | ||
| 162 | + expect(find(r, 'skill', 'disabled-skill').details).toMatchObject({ visibility: 'off' }) | ||
| 163 | + const linked = find(r, 'skill', 'linked') | ||
| 164 | + expect(linked.source.symlinkTarget).toBeTruthy() | ||
| 165 | + expect(linked.hash).toBeTruthy() | ||
| 166 | + const alpha = find(r, 'skill', 'alpha') | ||
| 167 | + expect((alpha.details as { files: string[] }).files).toContain('references/a.md') | ||
| 168 | + expect(find(r, 'skill', 'bad-yaml').details).toMatchObject({ visibility: 'auto' }) | ||
| 169 | + expect(r.errors.some((e) => e.path.endsWith('openai.yaml'))).toBe(true) | ||
| 170 | + }) | ||
| 171 | + | ||
| 172 | + it('flags the legacy skills location and skips bundled system skills', () => { | ||
| 173 | + expect(find(r, 'skill', 'legacy').warnings.join(' ')).toContain('legacy location') | ||
| 174 | + expect(r.items.find((i) => i.name === 'bundled')).toBeUndefined() | ||
| 175 | + }) | ||
| 176 | + | ||
| 177 | + it('lists enabled plugin skills with source.plugin', () => { | ||
| 178 | + expect(find(r, 'skill', 'plug-skill').source.plugin).toBe('myplug@market') | ||
| 179 | + }) | ||
| 180 | + | ||
| 181 | + it('parses MCP servers and never leaks secrets', () => { | ||
| 182 | + const gh = find(r, 'mcp', 'github') | ||
| 183 | + const d = gh.details as McpDetails | ||
| 184 | + expect(d.transport).toBe('stdio') | ||
| 185 | + expect(d.agentScope).toBe('user') | ||
| 186 | + expect(d.args![d.args!.indexOf('--token') + 1]).toBe('<redacted>') | ||
| 187 | + expect(d.args).toContain('--api-key=<redacted>') | ||
| 188 | + expect(d.args!.find((a) => a.startsWith('https://'))).toBe('https://x.test/mcp') | ||
| 189 | + expect([...d.literalEnvKeys].sort()).toEqual(['BAR', 'FOO']) | ||
| 190 | + expect(d.envVarNames).toEqual(expect.arrayContaining(['GITHUB_TOKEN', 'FROM_ENV'])) | ||
| 191 | + expect(gh.warnings.some((w) => w.includes('redacted'))).toBe(true) | ||
| 192 | + const all = JSON.stringify(r) | ||
| 193 | + expect(all).not.toContain(TOKEN) | ||
| 194 | + expect(all).not.toContain('literal-value-123') | ||
| 195 | + | ||
| 196 | + const remote = find(r, 'mcp', 'remote').details as McpDetails | ||
| 197 | + expect(remote.transport).toBe('http') | ||
| 198 | + expect(remote.envVarNames).toEqual(expect.arrayContaining(['REMOTE_TOKEN', 'ORG_ID'])) | ||
| 199 | + expect(all).not.toContain('"eu"') | ||
| 200 | + | ||
| 201 | + expect((find(r, 'mcp', 'off').details as McpDetails).enabled).toBe(false) | ||
| 202 | + expect(gh.hash).toBeUndefined() | ||
| 203 | + }) | ||
| 204 | + | ||
| 205 | + it('parses hooks from hooks.json and inline config.toml', () => { | ||
| 206 | + expect(find(r, 'hook', 'SessionStart:startup:0').details).toMatchObject({ | ||
| 207 | + event: 'SessionStart', | ||
| 208 | + matcher: 'startup', | ||
| 209 | + handlerType: 'command', | ||
| 210 | + command: 'python3 start.py', | ||
| 211 | + }) | ||
| 212 | + expect(find(r, 'hook', 'PostToolUse:*:0').details).toMatchObject({ handlerType: 'mcp_tool', command: 'scanner.scan' }) | ||
| 213 | + expect(find(r, 'hook', 'PostToolUse:*:1').warnings.join(' ')).toContain('skipped') | ||
| 214 | + expect(find(r, 'hook', 'PreToolUse:^Bash$:0').details).toMatchObject({ command: 'echo inline' }) | ||
| 215 | + }) | ||
| 216 | + | ||
| 217 | + it('parses custom agents and reports bad files without throwing', () => { | ||
| 218 | + const a = find(r, 'agent', 'reviewer') | ||
| 219 | + expect(a.details).toMatchObject({ type: 'agent', model: 'gpt-x' }) | ||
| 220 | + expect(a.hash).toBeTruthy() | ||
| 221 | + expect(r.errors.some((e) => e.path.endsWith('broken.toml'))).toBe(true) | ||
| 222 | + }) | ||
| 223 | + | ||
| 224 | + it('does not emit execution-policy rules as rule items', () => { | ||
| 225 | + expect(r.items.some((i) => i.type === 'rule')).toBe(false) | ||
| 226 | + }) | ||
| 227 | + | ||
| 228 | + it('is not detected on an empty home', async () => { | ||
| 229 | + const empty = await mkdtemp(path.join(os.tmpdir(), 'skilly-codex-empty-')) | ||
| 230 | + try { | ||
| 231 | + const res = await codexScanner.scanHome({ homeDir: empty }) | ||
| 232 | + expect(res.detected).toBe(false) | ||
| 233 | + expect(res.items).toEqual([]) | ||
| 234 | + } finally { | ||
| 235 | + await rm(empty, { recursive: true, force: true }) | ||
| 236 | + } | ||
| 237 | + }) | ||
| 238 | +}) | ||
| 239 | + | ||
| 240 | +describe('codex scanProject', () => { | ||
| 241 | + let untrusted: string | ||
| 242 | + | ||
| 243 | + beforeAll(async () => { | ||
| 244 | + await put(path.join(project, 'AGENTS.override.md'), 'x'.repeat(150)) | ||
| 245 | + await put(path.join(project, 'AGENTS.md'), '# normal\n') | ||
| 246 | + await put(path.join(project, 'TEAM_GUIDE.md'), '# team\n') | ||
| 247 | + await put(path.join(project, '.agents', 'skills', 'proj-skill', 'SKILL.md'), skillMd('proj-skill')) | ||
| 248 | + await put( | ||
| 249 | + path.join(project, '.codex', 'config.toml'), | ||
| 250 | + '[mcp_servers.docs]\nurl = "https://docs.test/mcp?token=abc"\n\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\ncommand = "echo bye"\n', | ||
| 251 | + ) | ||
| 252 | + await put( | ||
| 253 | + path.join(project, '.codex', 'hooks.json'), | ||
| 254 | + JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] } }), | ||
| 255 | + ) | ||
| 256 | + await put( | ||
| 257 | + path.join(project, '.codex', 'agents', 'pr.toml'), | ||
| 258 | + 'name = "pr"\ndescription = "d"\ndeveloper_instructions = "i"\n', | ||
| 259 | + ) | ||
| 260 | + | ||
| 261 | + untrusted = path.join(tmp, 'untrusted') | ||
| 262 | + await put(path.join(untrusted, 'AGENTS.md'), '# hi\n') | ||
| 263 | + await put(path.join(untrusted, '.codex', 'config.toml'), '[mcp_servers.x]\ncommand = "node"\n') | ||
| 264 | + await put( | ||
| 265 | + path.join(untrusted, '.codex', 'hooks.json'), | ||
| 266 | + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'c' }] }] } }), | ||
| 267 | + ) | ||
| 268 | + }) | ||
| 269 | + | ||
| 270 | + it('handles a trusted project', async () => { | ||
| 271 | + const r = await codexScanner.scanProject(project, ctx) | ||
| 272 | + expect(r.detected).toBe(true) | ||
| 273 | + expect(r.errors).toEqual([]) | ||
| 274 | + | ||
| 275 | + const o = find(r, 'instructions', 'AGENTS.override.md') | ||
| 276 | + expect(o.details).toMatchObject({ loaded: true }) | ||
| 277 | + expect(o.warnings.join(' ')).toContain('project_doc_max_bytes') | ||
| 278 | + expect(find(r, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: false }) | ||
| 279 | + expect(find(r, 'instructions', 'TEAM_GUIDE.md').details).toMatchObject({ loaded: false }) | ||
| 280 | + | ||
| 281 | + expect(find(r, 'skill', 'proj-skill').details).toMatchObject({ visibility: 'auto' }) | ||
| 282 | + | ||
| 283 | + const mcp = find(r, 'mcp', 'docs') | ||
| 284 | + expect((mcp.details as McpDetails).agentScope).toBe('project') | ||
| 285 | + expect((mcp.details as McpDetails).url).toBe('https://docs.test/mcp') | ||
| 286 | + expect(mcp.warnings.join(' ')).not.toContain('not trusted') | ||
| 287 | + expect(find(r, 'hook', 'Stop:*:0').warnings).toEqual([]) | ||
| 288 | + expect(find(r, 'hook', 'SessionStart:*:0').details).toMatchObject({ command: 'echo hi' }) | ||
| 289 | + expect(find(r, 'agent', 'pr').warnings).toEqual([]) | ||
| 290 | + }) | ||
| 291 | + | ||
| 292 | + it('warns that an untrusted project config is ignored', async () => { | ||
| 293 | + const r = await codexScanner.scanProject(untrusted, ctx) | ||
| 294 | + const msg = 'project not trusted: Codex ignores this config' | ||
| 295 | + expect(find(r, 'mcp', 'x').warnings).toContain(msg) | ||
| 296 | + expect(find(r, 'hook', 'Stop:*:0').warnings).toContain(msg) | ||
| 297 | + expect(find(r, 'instructions', 'AGENTS.md').details).toMatchObject({ loaded: true }) | ||
| 298 | + }) | ||
| 299 | + | ||
| 300 | + it('is not detected on a folder without codex files', async () => { | ||
| 301 | + const empty = await mkdtemp(path.join(os.tmpdir(), 'skilly-codex-emptyproj-')) | ||
| 302 | + try { | ||
| 303 | + const r = await codexScanner.scanProject(empty, ctx) | ||
| 304 | + expect(r.detected).toBe(false) | ||
| 305 | + expect(r.items).toEqual([]) | ||
| 306 | + } finally { | ||
| 307 | + await rm(empty, { recursive: true, force: true }) | ||
| 308 | + } | ||
| 309 | + }) | ||
| 310 | +}) | ||
| 311 | + | ||
added
packages/server/src/dev.ts +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +import { startServer } from './index.ts' | |
| 2 | + | |
| 3 | +const server = await startServer({ token: 'dev', port: 4300 }) | |
| 4 | +console.log(`skilly dev API on ${server.url} (token: dev)`) | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +import { startServer } from './index.ts' | ||
| 2 | + | ||
| 3 | +const server = await startServer({ token: 'dev', port: 4300 }) | ||
| 4 | +console.log(`skilly dev API on ${server.url} (token: dev)`) | ||
added
packages/server/src/files.ts +100 -0 | new file mode 100644 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +import { open, realpath, stat } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { FileContent } from '@skilly/core' | |
| 4 | + | |
| 5 | +export const MAX_FILE_BYTES = 512 * 1024 | |
| 6 | + | |
| 7 | +const isWin = process.platform === 'win32' | |
| 8 | + | |
| 9 | +function fold(p: string): string { | |
| 10 | + const r = path.resolve(p) | |
| 11 | + return isWin ? r.toLowerCase() : r | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function isInside(root: string, target: string): boolean { | |
| 15 | + const rel = path.relative(fold(root), fold(target)) | |
| 16 | + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function isDeniedName(p: string): boolean { | |
| 20 | + const base = path.basename(p).toLowerCase() | |
| 21 | + return ( | |
| 22 | + base === '.claude.json' || | |
| 23 | + base === 'auth.json' || | |
| 24 | + base === '.credentials.json' || | |
| 25 | + base.startsWith('.env') || | |
| 26 | + base.endsWith('.pem') || | |
| 27 | + base.endsWith('.key') | |
| 28 | + ) | |
| 29 | +} | |
| 30 | + | |
| 31 | +export class FileAccessError extends Error { | |
| 32 | + constructor( | |
| 33 | + readonly status: 400 | 403 | 404, | |
| 34 | + message: string, | |
| 35 | + ) { | |
| 36 | + super(message) | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +export interface FileGuardOptions { | |
| 41 | + homeDir: string | |
| 42 | + codexHome?: string | |
| 43 | + projectRoots: string[] | |
| 44 | +} | |
| 45 | + | |
| 46 | +async function safeRealpath(p: string): Promise<string | undefined> { | |
| 47 | + try { | |
| 48 | + return await realpath(p) | |
| 49 | + } catch { | |
| 50 | + return undefined | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +export async function readAllowedFile(requested: string, opts: FileGuardOptions): Promise<FileContent> { | |
| 55 | + if (!requested || requested.includes('\0')) throw new FileAccessError(400, 'Invalid path') | |
| 56 | + if (!path.isAbsolute(requested)) throw new FileAccessError(400, 'Path must be absolute') | |
| 57 | + const linkPath = path.resolve(requested) // collapses `..` segments | |
| 58 | + const roots = [ | |
| 59 | + path.join(opts.homeDir, '.claude'), | |
| 60 | + path.join(opts.homeDir, '.codex'), | |
| 61 | + path.join(opts.homeDir, '.agents'), | |
| 62 | + ...(opts.codexHome ? [opts.codexHome] : []), | |
| 63 | + ...opts.projectRoots, | |
| 64 | + ] | |
| 65 | + | |
| 66 | + // Decide on the lexical path first so that forbidden paths never reveal whether they exist. | |
| 67 | + if (isDeniedName(linkPath)) throw new FileAccessError(403, 'Access denied') | |
| 68 | + const real = await safeRealpath(linkPath) | |
| 69 | + let allowed = roots.some((r) => isInside(r, linkPath)) | |
| 70 | + if (!allowed && real) { | |
| 71 | + for (const r of roots) { | |
| 72 | + const rr = (await safeRealpath(r)) ?? r | |
| 73 | + if (isInside(rr, real)) { | |
| 74 | + allowed = true | |
| 75 | + break | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | + if (!allowed) throw new FileAccessError(403, 'Access denied') | |
| 80 | + if (!real) throw new FileAccessError(404, 'File not found') | |
| 81 | + if (isDeniedName(real)) throw new FileAccessError(403, 'Access denied') | |
| 82 | + | |
| 83 | + const st = await stat(real) | |
| 84 | + if (!st.isFile()) throw new FileAccessError(400, 'Not a file') | |
| 85 | + const size = st.size | |
| 86 | + const len = Math.min(size, MAX_FILE_BYTES) | |
| 87 | + const fh = await open(real, 'r') | |
| 88 | + try { | |
| 89 | + const buf = Buffer.alloc(len) | |
| 90 | + const { bytesRead } = await fh.read(buf, 0, len, 0) | |
| 91 | + return { | |
| 92 | + path: linkPath, | |
| 93 | + content: buf.subarray(0, bytesRead).toString('utf8'), | |
| 94 | + sizeBytes: size, | |
| 95 | + truncated: size > MAX_FILE_BYTES, | |
| 96 | + } | |
| 97 | + } finally { | |
| 98 | + await fh.close() | |
| 99 | + } | |
| 100 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +import { open, realpath, stat } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import type { FileContent } from '@skilly/core' | ||
| 4 | + | ||
| 5 | +export const MAX_FILE_BYTES = 512 * 1024 | ||
| 6 | + | ||
| 7 | +const isWin = process.platform === 'win32' | ||
| 8 | + | ||
| 9 | +function fold(p: string): string { | ||
| 10 | + const r = path.resolve(p) | ||
| 11 | + return isWin ? r.toLowerCase() : r | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +export function isInside(root: string, target: string): boolean { | ||
| 15 | + const rel = path.relative(fold(root), fold(target)) | ||
| 16 | + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +export function isDeniedName(p: string): boolean { | ||
| 20 | + const base = path.basename(p).toLowerCase() | ||
| 21 | + return ( | ||
| 22 | + base === '.claude.json' || | ||
| 23 | + base === 'auth.json' || | ||
| 24 | + base === '.credentials.json' || | ||
| 25 | + base.startsWith('.env') || | ||
| 26 | + base.endsWith('.pem') || | ||
| 27 | + base.endsWith('.key') | ||
| 28 | + ) | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +export class FileAccessError extends Error { | ||
| 32 | + constructor( | ||
| 33 | + readonly status: 400 | 403 | 404, | ||
| 34 | + message: string, | ||
| 35 | + ) { | ||
| 36 | + super(message) | ||
| 37 | + } | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +export interface FileGuardOptions { | ||
| 41 | + homeDir: string | ||
| 42 | + codexHome?: string | ||
| 43 | + projectRoots: string[] | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +async function safeRealpath(p: string): Promise<string | undefined> { | ||
| 47 | + try { | ||
| 48 | + return await realpath(p) | ||
| 49 | + } catch { | ||
| 50 | + return undefined | ||
| 51 | + } | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +export async function readAllowedFile(requested: string, opts: FileGuardOptions): Promise<FileContent> { | ||
| 55 | + if (!requested || requested.includes('\0')) throw new FileAccessError(400, 'Invalid path') | ||
| 56 | + if (!path.isAbsolute(requested)) throw new FileAccessError(400, 'Path must be absolute') | ||
| 57 | + const linkPath = path.resolve(requested) // collapses `..` segments | ||
| 58 | + const roots = [ | ||
| 59 | + path.join(opts.homeDir, '.claude'), | ||
| 60 | + path.join(opts.homeDir, '.codex'), | ||
| 61 | + path.join(opts.homeDir, '.agents'), | ||
| 62 | + ...(opts.codexHome ? [opts.codexHome] : []), | ||
| 63 | + ...opts.projectRoots, | ||
| 64 | + ] | ||
| 65 | + | ||
| 66 | + // Decide on the lexical path first so that forbidden paths never reveal whether they exist. | ||
| 67 | + if (isDeniedName(linkPath)) throw new FileAccessError(403, 'Access denied') | ||
| 68 | + const real = await safeRealpath(linkPath) | ||
| 69 | + let allowed = roots.some((r) => isInside(r, linkPath)) | ||
| 70 | + if (!allowed && real) { | ||
| 71 | + for (const r of roots) { | ||
| 72 | + const rr = (await safeRealpath(r)) ?? r | ||
| 73 | + if (isInside(rr, real)) { | ||
| 74 | + allowed = true | ||
| 75 | + break | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + if (!allowed) throw new FileAccessError(403, 'Access denied') | ||
| 80 | + if (!real) throw new FileAccessError(404, 'File not found') | ||
| 81 | + if (isDeniedName(real)) throw new FileAccessError(403, 'Access denied') | ||
| 82 | + | ||
| 83 | + const st = await stat(real) | ||
| 84 | + if (!st.isFile()) throw new FileAccessError(400, 'Not a file') | ||
| 85 | + const size = st.size | ||
| 86 | + const len = Math.min(size, MAX_FILE_BYTES) | ||
| 87 | + const fh = await open(real, 'r') | ||
| 88 | + try { | ||
| 89 | + const buf = Buffer.alloc(len) | ||
| 90 | + const { bytesRead } = await fh.read(buf, 0, len, 0) | ||
| 91 | + return { | ||
| 92 | + path: linkPath, | ||
| 93 | + content: buf.subarray(0, bytesRead).toString('utf8'), | ||
| 94 | + sizeBytes: size, | ||
| 95 | + truncated: size > MAX_FILE_BYTES, | ||
| 96 | + } | ||
| 97 | + } finally { | ||
| 98 | + await fh.close() | ||
| 99 | + } | ||
| 100 | +} | ||
added
packages/server/src/fslist.ts +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +import { existsSync } from 'node:fs' | |
| 2 | +import { readdir, stat } from 'node:fs/promises' | |
| 3 | +import os from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import type { FsEntry, FsListing } from '@skilly/core' | |
| 6 | + | |
| 7 | +const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information']) | |
| 8 | +const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json'] | |
| 9 | + | |
| 10 | +function hasAgentConfig(dir: string): boolean { | |
| 11 | + return AGENT_MARKERS.some((m) => existsSync(path.join(dir, m))) | |
| 12 | +} | |
| 13 | + | |
| 14 | +function entry(dir: string, name?: string): FsEntry { | |
| 15 | + return { name: name ?? (path.basename(dir) || dir), path: dir, hasAgentConfig: hasAgentConfig(dir) } | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function listRoots(homeDir: string): FsListing { | |
| 19 | + const entries: FsEntry[] = [] | |
| 20 | + if (process.platform === 'win32') { | |
| 21 | + for (let c = 65; c <= 90; c++) { | |
| 22 | + const drive = `${String.fromCharCode(c)}:\\` | |
| 23 | + if (existsSync(drive)) entries.push(entry(drive, drive)) | |
| 24 | + } | |
| 25 | + } else { | |
| 26 | + entries.push(entry('/', '/')) | |
| 27 | + } | |
| 28 | + const home = path.resolve(homeDir || os.homedir()) | |
| 29 | + entries.push(entry(home, home)) | |
| 30 | + return { path: null, parent: null, entries } | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** Lists subdirectories of `dir`. Throws (code ENOENT / ENOTDIR) when it is not a listable directory. */ | |
| 34 | +export async function listDirectory(dir: string): Promise<FsListing> { | |
| 35 | + const abs = path.resolve(dir) | |
| 36 | + const st = await stat(abs) | |
| 37 | + if (!st.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'ENOTDIR' }) | |
| 38 | + const dirents = await readdir(abs, { withFileTypes: true }) | |
| 39 | + const candidates = dirents.filter( | |
| 40 | + (d) => !d.name.startsWith('.') && !EXCLUDED.has(d.name.toLowerCase()) && (d.isDirectory() || d.isSymbolicLink()), | |
| 41 | + ) | |
| 42 | + const checked = await Promise.all( | |
| 43 | + candidates.map(async (d): Promise<FsEntry | undefined> => { | |
| 44 | + const full = path.join(abs, d.name) | |
| 45 | + try { | |
| 46 | + if (d.isSymbolicLink() && !(await stat(full)).isDirectory()) return undefined | |
| 47 | + await readdir(full) // unreadable directories are skipped | |
| 48 | + return entry(full, d.name) | |
| 49 | + } catch { | |
| 50 | + return undefined | |
| 51 | + } | |
| 52 | + }), | |
| 53 | + ) | |
| 54 | + const entries = checked | |
| 55 | + .filter((e): e is FsEntry => e !== undefined) | |
| 56 | + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) | |
| 57 | + const parentDir = path.dirname(abs) | |
| 58 | + return { path: abs, parent: parentDir === abs ? null : parentDir, entries } | |
| 59 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +import { existsSync } from 'node:fs' | ||
| 2 | +import { readdir, stat } from 'node:fs/promises' | ||
| 3 | +import os from 'node:os' | ||
| 4 | +import path from 'node:path' | ||
| 5 | +import type { FsEntry, FsListing } from '@skilly/core' | ||
| 6 | + | ||
| 7 | +const EXCLUDED = new Set(['node_modules', '$recycle.bin', 'system volume information']) | ||
| 8 | +const AGENT_MARKERS = ['.claude', '.agents', '.codex', 'AGENTS.md', 'CLAUDE.md', '.mcp.json'] | ||
| 9 | + | ||
| 10 | +function hasAgentConfig(dir: string): boolean { | ||
| 11 | + return AGENT_MARKERS.some((m) => existsSync(path.join(dir, m))) | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +function entry(dir: string, name?: string): FsEntry { | ||
| 15 | + return { name: name ?? (path.basename(dir) || dir), path: dir, hasAgentConfig: hasAgentConfig(dir) } | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +export function listRoots(homeDir: string): FsListing { | ||
| 19 | + const entries: FsEntry[] = [] | ||
| 20 | + if (process.platform === 'win32') { | ||
| 21 | + for (let c = 65; c <= 90; c++) { | ||
| 22 | + const drive = `${String.fromCharCode(c)}:\\` | ||
| 23 | + if (existsSync(drive)) entries.push(entry(drive, drive)) | ||
| 24 | + } | ||
| 25 | + } else { | ||
| 26 | + entries.push(entry('/', '/')) | ||
| 27 | + } | ||
| 28 | + const home = path.resolve(homeDir || os.homedir()) | ||
| 29 | + entries.push(entry(home, home)) | ||
| 30 | + return { path: null, parent: null, entries } | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +/** Lists subdirectories of `dir`. Throws (code ENOENT / ENOTDIR) when it is not a listable directory. */ | ||
| 34 | +export async function listDirectory(dir: string): Promise<FsListing> { | ||
| 35 | + const abs = path.resolve(dir) | ||
| 36 | + const st = await stat(abs) | ||
| 37 | + if (!st.isDirectory()) throw Object.assign(new Error('Not a directory'), { code: 'ENOTDIR' }) | ||
| 38 | + const dirents = await readdir(abs, { withFileTypes: true }) | ||
| 39 | + const candidates = dirents.filter( | ||
| 40 | + (d) => !d.name.startsWith('.') && !EXCLUDED.has(d.name.toLowerCase()) && (d.isDirectory() || d.isSymbolicLink()), | ||
| 41 | + ) | ||
| 42 | + const checked = await Promise.all( | ||
| 43 | + candidates.map(async (d): Promise<FsEntry | undefined> => { | ||
| 44 | + const full = path.join(abs, d.name) | ||
| 45 | + try { | ||
| 46 | + if (d.isSymbolicLink() && !(await stat(full)).isDirectory()) return undefined | ||
| 47 | + await readdir(full) // unreadable directories are skipped | ||
| 48 | + return entry(full, d.name) | ||
| 49 | + } catch { | ||
| 50 | + return undefined | ||
| 51 | + } | ||
| 52 | + }), | ||
| 53 | + ) | ||
| 54 | + const entries = checked | ||
| 55 | + .filter((e): e is FsEntry => e !== undefined) | ||
| 56 | + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) | ||
| 57 | + const parentDir = path.dirname(abs) | ||
| 58 | + return { path: abs, parent: parentDir === abs ? null : parentDir, entries } | ||
| 59 | +} | ||
added
packages/server/src/index.ts +287 -0 | new file mode 100644 | ||
| @@ -0,0 +1,287 @@ | ||
| 1 | +import { timingSafeEqual } from 'node:crypto' | |
| 2 | +import { readFile, stat } from 'node:fs/promises' | |
| 3 | +import type { Server } from 'node:http' | |
| 4 | +import type { AddressInfo } from 'node:net' | |
| 5 | +import os from 'node:os' | |
| 6 | +import path from 'node:path' | |
| 7 | +import { serve } from '@hono/node-server' | |
| 8 | +import { | |
| 9 | + ITEM_TYPES, | |
| 10 | + defaultScanContext, | |
| 11 | + scanHome, | |
| 12 | + scanProject, | |
| 13 | + type AddProjectBody, | |
| 14 | + type ApiError, | |
| 15 | + type ItemType, | |
| 16 | + type ProjectSummary, | |
| 17 | + type ScanContext, | |
| 18 | + type ScopeInventory, | |
| 19 | +} from '@skilly/core' | |
| 20 | +import { Hono } from 'hono' | |
| 21 | +import { FileAccessError, isInside, readAllowedFile } from './files.ts' | |
| 22 | +import { listDirectory, listRoots } from './fslist.ts' | |
| 23 | +import { ProjectStore, type StoredProject } from './store.ts' | |
| 24 | + | |
| 25 | +export const SERVER_VERSION = '0.0.0' | |
| 26 | + | |
| 27 | +const SCAN_CACHE_MS = 10_000 | |
| 28 | +const STARTUP_GRACE_MS = 60_000 | |
| 29 | + | |
| 30 | +export interface AppOptions { | |
| 31 | + token: string | |
| 32 | + /** Defaults to `~/.skilly`. */ | |
| 33 | + configDir?: string | |
| 34 | + scanContext?: ScanContext | |
| 35 | + /** Built UI folder served at `/`. */ | |
| 36 | + staticDir?: string | |
| 37 | + /** Called on every `POST /api/heartbeat`. */ | |
| 38 | + onHeartbeat?: () => void | |
| 39 | +} | |
| 40 | + | |
| 41 | +export interface ServerOptions extends Omit<AppOptions, 'onHeartbeat'> { | |
| 42 | + /** 0 or undefined = free port. The host is always 127.0.0.1. */ | |
| 43 | + port?: number | |
| 44 | + /** Heartbeat watchdog: `onIdle` fires when no heartbeat arrives within this delay. Undefined = never. */ | |
| 45 | + exitOnIdleMs?: number | |
| 46 | + onIdle?: () => void | |
| 47 | +} | |
| 48 | + | |
| 49 | +export interface RunningServer { | |
| 50 | + url: string | |
| 51 | + port: number | |
| 52 | + token: string | |
| 53 | + close(): Promise<void> | |
| 54 | +} | |
| 55 | + | |
| 56 | +const MIME: Record<string, string> = { | |
| 57 | + '.html': 'text/html; charset=utf-8', | |
| 58 | + '.js': 'text/javascript; charset=utf-8', | |
| 59 | + '.mjs': 'text/javascript; charset=utf-8', | |
| 60 | + '.css': 'text/css; charset=utf-8', | |
| 61 | + '.json': 'application/json; charset=utf-8', | |
| 62 | + '.svg': 'image/svg+xml', | |
| 63 | + '.png': 'image/png', | |
| 64 | + '.jpg': 'image/jpeg', | |
| 65 | + '.jpeg': 'image/jpeg', | |
| 66 | + '.gif': 'image/gif', | |
| 67 | + '.webp': 'image/webp', | |
| 68 | + '.ico': 'image/x-icon', | |
| 69 | + '.woff': 'font/woff', | |
| 70 | + '.woff2': 'font/woff2', | |
| 71 | + '.ttf': 'font/ttf', | |
| 72 | + '.txt': 'text/plain; charset=utf-8', | |
| 73 | + '.map': 'application/json; charset=utf-8', | |
| 74 | +} | |
| 75 | + | |
| 76 | +function emptyCounts(): Record<ItemType, number> { | |
| 77 | + return Object.fromEntries(ITEM_TYPES.map((t) => [t, 0])) as Record<ItemType, number> | |
| 78 | +} | |
| 79 | + | |
| 80 | +function hostnameOf(hostHeader: string | undefined): string | undefined { | |
| 81 | + if (!hostHeader) return undefined | |
| 82 | + const m = /^(\[[^\]]*\]|[^:]*)(?::(\d+))?$/.exec(hostHeader.trim()) | |
| 83 | + return m?.[1]?.toLowerCase() | |
| 84 | +} | |
| 85 | + | |
| 86 | +function tokenMatches(given: string | undefined, expected: string): boolean { | |
| 87 | + if (!given) return false | |
| 88 | + const a = Buffer.from(given) | |
| 89 | + const b = Buffer.from(expected) | |
| 90 | + return a.length === b.length && timingSafeEqual(a, b) | |
| 91 | +} | |
| 92 | + | |
| 93 | +async function isDir(p: string): Promise<boolean> { | |
| 94 | + try { | |
| 95 | + return (await stat(p)).isDirectory() | |
| 96 | + } catch { | |
| 97 | + return false | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> { | |
| 102 | + let rel: string | |
| 103 | + try { | |
| 104 | + rel = decodeURIComponent(urlPath) | |
| 105 | + } catch { | |
| 106 | + return undefined | |
| 107 | + } | |
| 108 | + if (rel.includes('\0')) return undefined | |
| 109 | + const full = path.resolve(root, '.' + path.posix.normalize('/' + rel.replace(/\\/g, '/'))) | |
| 110 | + if (!isInside(root, full)) return undefined | |
| 111 | + try { | |
| 112 | + const st = await stat(full) | |
| 113 | + const file = st.isDirectory() ? path.join(full, 'index.html') : full | |
| 114 | + return { body: await readFile(file), type: MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream' } | |
| 115 | + } catch { | |
| 116 | + return undefined | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function createApp(options: AppOptions): Hono { | |
| 121 | + const { token } = options | |
| 122 | + const configDir = options.configDir ?? path.join(os.homedir(), '.skilly') | |
| 123 | + const ctx = options.scanContext ?? defaultScanContext() | |
| 124 | + const store = new ProjectStore(configDir) | |
| 125 | + const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>() | |
| 126 | + const app = new Hono() | |
| 127 | + | |
| 128 | + const err = (message: string): ApiError => ({ error: message }) | |
| 129 | + | |
| 130 | + // DNS rebinding protection: only loopback host names are served. | |
| 131 | + app.use('*', async (c, next) => { | |
| 132 | + const host = hostnameOf(c.req.header('host') ?? new URL(c.req.url).host) | |
| 133 | + if (host !== '127.0.0.1' && host !== 'localhost') return c.json(err('Forbidden host'), 403) | |
| 134 | + await next() | |
| 135 | + }) | |
| 136 | + | |
| 137 | + app.use('/api/*', async (c, next) => { | |
| 138 | + if (!tokenMatches(c.req.header('x-skilly-token'), token)) return c.json(err('Unauthorized'), 401) | |
| 139 | + await next() | |
| 140 | + }) | |
| 141 | + | |
| 142 | + async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> { | |
| 143 | + const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt } | |
| 144 | + if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() } | |
| 145 | + const cached = scanCache.get(p.id) | |
| 146 | + let inventory: ScopeInventory | |
| 147 | + if (useCache && cached && Date.now() - cached.at < SCAN_CACHE_MS) { | |
| 148 | + inventory = cached.inventory | |
| 149 | + } else { | |
| 150 | + inventory = await scanProject(p.path, ctx) | |
| 151 | + scanCache.set(p.id, { at: Date.now(), inventory }) | |
| 152 | + } | |
| 153 | + const counts = emptyCounts() | |
| 154 | + for (const item of inventory.items) counts[item.type]++ | |
| 155 | + return { ...base, exists: true, agentsDetected: inventory.agentsDetected, counts } | |
| 156 | + } | |
| 157 | + | |
| 158 | + app.get('/api/health', (c) => c.json({ ok: true, version: SERVER_VERSION })) | |
| 159 | + | |
| 160 | + app.post('/api/heartbeat', (c) => { | |
| 161 | + options.onHeartbeat?.() | |
| 162 | + return c.body(null, 204) | |
| 163 | + }) | |
| 164 | + | |
| 165 | + app.get('/api/home', async (c) => c.json(await scanHome(ctx))) | |
| 166 | + | |
| 167 | + app.get('/api/projects', async (c) => { | |
| 168 | + const projects = await store.list() | |
| 169 | + return c.json(await Promise.all(projects.map((p) => summarize(p, true)))) | |
| 170 | + }) | |
| 171 | + | |
| 172 | + app.post('/api/projects', async (c) => { | |
| 173 | + let body: Partial<AddProjectBody> | undefined | |
| 174 | + try { | |
| 175 | + body = (await c.req.json()) as Partial<AddProjectBody> | |
| 176 | + } catch { | |
| 177 | + return c.json(err('Invalid JSON body'), 400) | |
| 178 | + } | |
| 179 | + if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) { | |
| 180 | + return c.json(err('An absolute "path" is required'), 400) | |
| 181 | + } | |
| 182 | + if (!(await isDir(body.path))) return c.json(err('Not a directory'), 400) | |
| 183 | + const project = await store.add(body.path) | |
| 184 | + if (!project) return c.json(err('Project already added'), 409) | |
| 185 | + return c.json(await summarize(project, false), 201) | |
| 186 | + }) | |
| 187 | + | |
| 188 | + app.delete('/api/projects/:id', async (c) => { | |
| 189 | + const id = c.req.param('id') | |
| 190 | + if (!(await store.remove(id))) return c.json(err('Unknown project'), 404) | |
| 191 | + scanCache.delete(id) | |
| 192 | + return c.body(null, 204) | |
| 193 | + }) | |
| 194 | + | |
| 195 | + app.get('/api/projects/:id/inventory', async (c) => { | |
| 196 | + const project = (await store.list()).find((p) => p.id === c.req.param('id')) | |
| 197 | + if (!project) return c.json(err('Unknown project'), 404) | |
| 198 | + if (!(await isDir(project.path))) return c.json(err('Project folder no longer exists'), 404) | |
| 199 | + return c.json(await scanProject(project.path, ctx)) | |
| 200 | + }) | |
| 201 | + | |
| 202 | + app.get('/api/fs/list', async (c) => { | |
| 203 | + const p = c.req.query('path') | |
| 204 | + if (!p) return c.json(listRoots(ctx.homeDir)) | |
| 205 | + if (!path.isAbsolute(p)) return c.json(err('Path must be absolute'), 400) | |
| 206 | + try { | |
| 207 | + return c.json(await listDirectory(p)) | |
| 208 | + } catch (e) { | |
| 209 | + const code = (e as NodeJS.ErrnoException).code | |
| 210 | + if (code === 'ENOENT' || code === 'ENOTDIR') return c.json(err('Not a directory'), 404) | |
| 211 | + return c.json(err('Cannot read directory'), 403) | |
| 212 | + } | |
| 213 | + }) | |
| 214 | + | |
| 215 | + app.get('/api/files', async (c) => { | |
| 216 | + const p = c.req.query('path') | |
| 217 | + if (!p) return c.json(err('Missing "path"'), 400) | |
| 218 | + try { | |
| 219 | + const projects = await store.list() | |
| 220 | + const file = await readAllowedFile(p, { | |
| 221 | + homeDir: ctx.homeDir, | |
| 222 | + codexHome: ctx.codexHome, | |
| 223 | + projectRoots: projects.map((x) => x.path), | |
| 224 | + }) | |
| 225 | + return c.json(file) | |
| 226 | + } catch (e) { | |
| 227 | + if (e instanceof FileAccessError) return c.json(err(e.message), e.status) | |
| 228 | + return c.json(err('Cannot read file'), 500) | |
| 229 | + } | |
| 230 | + }) | |
| 231 | + | |
| 232 | + app.all('/api/*', (c) => c.json(err('Not found'), 404)) | |
| 233 | + | |
| 234 | + const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined | |
| 235 | + if (staticDir) { | |
| 236 | + app.get('*', async (c) => { | |
| 237 | + const urlPath = new URL(c.req.url).pathname | |
| 238 | + let found = await readStatic(staticDir, urlPath) | |
| 239 | + // SPA fallback for routes (not for missing assets). | |
| 240 | + if (!found && !path.extname(urlPath)) { | |
| 241 | + found = (await readStatic(staticDir, '/200.html')) ?? (await readStatic(staticDir, '/index.html')) | |
| 242 | + } | |
| 243 | + if (!found) return c.json(err('Not found'), 404) | |
| 244 | + return c.body(new Uint8Array(found.body), 200, { 'content-type': found.type }) | |
| 245 | + }) | |
| 246 | + } | |
| 247 | + | |
| 248 | + return app | |
| 249 | +} | |
| 250 | + | |
| 251 | +export async function startServer(options: ServerOptions): Promise<RunningServer> { | |
| 252 | + let idleTimer: NodeJS.Timeout | undefined | |
| 253 | + let closed = false | |
| 254 | + const armWatchdog = (ms: number): void => { | |
| 255 | + if (options.exitOnIdleMs === undefined) return | |
| 256 | + if (idleTimer) clearTimeout(idleTimer) | |
| 257 | + idleTimer = setTimeout(() => { | |
| 258 | + if (!closed) options.onIdle?.() | |
| 259 | + }, ms) | |
| 260 | + idleTimer.unref() | |
| 261 | + } | |
| 262 | + | |
| 263 | + const app = createApp({ | |
| 264 | + ...options, | |
| 265 | + onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0), | |
| 266 | + }) | |
| 267 | + | |
| 268 | + const server = await new Promise<ReturnType<typeof serve>>((resolve, reject) => { | |
| 269 | + const s = serve({ fetch: app.fetch, port: options.port ?? 0, hostname: '127.0.0.1' }, () => resolve(s)) | |
| 270 | + s.once('error', reject) | |
| 271 | + }) | |
| 272 | + const port = (server.address() as AddressInfo).port | |
| 273 | + armWatchdog(Math.max(STARTUP_GRACE_MS, options.exitOnIdleMs ?? 0)) | |
| 274 | + | |
| 275 | + return { | |
| 276 | + url: `http://127.0.0.1:${port}`, | |
| 277 | + port, | |
| 278 | + token: options.token, | |
| 279 | + close: () => | |
| 280 | + new Promise<void>((resolve) => { | |
| 281 | + closed = true | |
| 282 | + if (idleTimer) clearTimeout(idleTimer) | |
| 283 | + server.close(() => resolve()) | |
| 284 | + ;(server as unknown as Server).closeAllConnections?.() | |
| 285 | + }), | |
| 286 | + } | |
| 287 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,287 @@ | |||
| 1 | +import { timingSafeEqual } from 'node:crypto' | ||
| 2 | +import { readFile, stat } from 'node:fs/promises' | ||
| 3 | +import type { Server } from 'node:http' | ||
| 4 | +import type { AddressInfo } from 'node:net' | ||
| 5 | +import os from 'node:os' | ||
| 6 | +import path from 'node:path' | ||
| 7 | +import { serve } from '@hono/node-server' | ||
| 8 | +import { | ||
| 9 | + ITEM_TYPES, | ||
| 10 | + defaultScanContext, | ||
| 11 | + scanHome, | ||
| 12 | + scanProject, | ||
| 13 | + type AddProjectBody, | ||
| 14 | + type ApiError, | ||
| 15 | + type ItemType, | ||
| 16 | + type ProjectSummary, | ||
| 17 | + type ScanContext, | ||
| 18 | + type ScopeInventory, | ||
| 19 | +} from '@skilly/core' | ||
| 20 | +import { Hono } from 'hono' | ||
| 21 | +import { FileAccessError, isInside, readAllowedFile } from './files.ts' | ||
| 22 | +import { listDirectory, listRoots } from './fslist.ts' | ||
| 23 | +import { ProjectStore, type StoredProject } from './store.ts' | ||
| 24 | + | ||
| 25 | +export const SERVER_VERSION = '0.0.0' | ||
| 26 | + | ||
| 27 | +const SCAN_CACHE_MS = 10_000 | ||
| 28 | +const STARTUP_GRACE_MS = 60_000 | ||
| 29 | + | ||
| 30 | +export interface AppOptions { | ||
| 31 | + token: string | ||
| 32 | + /** Defaults to `~/.skilly`. */ | ||
| 33 | + configDir?: string | ||
| 34 | + scanContext?: ScanContext | ||
| 35 | + /** Built UI folder served at `/`. */ | ||
| 36 | + staticDir?: string | ||
| 37 | + /** Called on every `POST /api/heartbeat`. */ | ||
| 38 | + onHeartbeat?: () => void | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +export interface ServerOptions extends Omit<AppOptions, 'onHeartbeat'> { | ||
| 42 | + /** 0 or undefined = free port. The host is always 127.0.0.1. */ | ||
| 43 | + port?: number | ||
| 44 | + /** Heartbeat watchdog: `onIdle` fires when no heartbeat arrives within this delay. Undefined = never. */ | ||
| 45 | + exitOnIdleMs?: number | ||
| 46 | + onIdle?: () => void | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +export interface RunningServer { | ||
| 50 | + url: string | ||
| 51 | + port: number | ||
| 52 | + token: string | ||
| 53 | + close(): Promise<void> | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +const MIME: Record<string, string> = { | ||
| 57 | + '.html': 'text/html; charset=utf-8', | ||
| 58 | + '.js': 'text/javascript; charset=utf-8', | ||
| 59 | + '.mjs': 'text/javascript; charset=utf-8', | ||
| 60 | + '.css': 'text/css; charset=utf-8', | ||
| 61 | + '.json': 'application/json; charset=utf-8', | ||
| 62 | + '.svg': 'image/svg+xml', | ||
| 63 | + '.png': 'image/png', | ||
| 64 | + '.jpg': 'image/jpeg', | ||
| 65 | + '.jpeg': 'image/jpeg', | ||
| 66 | + '.gif': 'image/gif', | ||
| 67 | + '.webp': 'image/webp', | ||
| 68 | + '.ico': 'image/x-icon', | ||
| 69 | + '.woff': 'font/woff', | ||
| 70 | + '.woff2': 'font/woff2', | ||
| 71 | + '.ttf': 'font/ttf', | ||
| 72 | + '.txt': 'text/plain; charset=utf-8', | ||
| 73 | + '.map': 'application/json; charset=utf-8', | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +function emptyCounts(): Record<ItemType, number> { | ||
| 77 | + return Object.fromEntries(ITEM_TYPES.map((t) => [t, 0])) as Record<ItemType, number> | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +function hostnameOf(hostHeader: string | undefined): string | undefined { | ||
| 81 | + if (!hostHeader) return undefined | ||
| 82 | + const m = /^(\[[^\]]*\]|[^:]*)(?::(\d+))?$/.exec(hostHeader.trim()) | ||
| 83 | + return m?.[1]?.toLowerCase() | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +function tokenMatches(given: string | undefined, expected: string): boolean { | ||
| 87 | + if (!given) return false | ||
| 88 | + const a = Buffer.from(given) | ||
| 89 | + const b = Buffer.from(expected) | ||
| 90 | + return a.length === b.length && timingSafeEqual(a, b) | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +async function isDir(p: string): Promise<boolean> { | ||
| 94 | + try { | ||
| 95 | + return (await stat(p)).isDirectory() | ||
| 96 | + } catch { | ||
| 97 | + return false | ||
| 98 | + } | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> { | ||
| 102 | + let rel: string | ||
| 103 | + try { | ||
| 104 | + rel = decodeURIComponent(urlPath) | ||
| 105 | + } catch { | ||
| 106 | + return undefined | ||
| 107 | + } | ||
| 108 | + if (rel.includes('\0')) return undefined | ||
| 109 | + const full = path.resolve(root, '.' + path.posix.normalize('/' + rel.replace(/\\/g, '/'))) | ||
| 110 | + if (!isInside(root, full)) return undefined | ||
| 111 | + try { | ||
| 112 | + const st = await stat(full) | ||
| 113 | + const file = st.isDirectory() ? path.join(full, 'index.html') : full | ||
| 114 | + return { body: await readFile(file), type: MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream' } | ||
| 115 | + } catch { | ||
| 116 | + return undefined | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +export function createApp(options: AppOptions): Hono { | ||
| 121 | + const { token } = options | ||
| 122 | + const configDir = options.configDir ?? path.join(os.homedir(), '.skilly') | ||
| 123 | + const ctx = options.scanContext ?? defaultScanContext() | ||
| 124 | + const store = new ProjectStore(configDir) | ||
| 125 | + const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>() | ||
| 126 | + const app = new Hono() | ||
| 127 | + | ||
| 128 | + const err = (message: string): ApiError => ({ error: message }) | ||
| 129 | + | ||
| 130 | + // DNS rebinding protection: only loopback host names are served. | ||
| 131 | + app.use('*', async (c, next) => { | ||
| 132 | + const host = hostnameOf(c.req.header('host') ?? new URL(c.req.url).host) | ||
| 133 | + if (host !== '127.0.0.1' && host !== 'localhost') return c.json(err('Forbidden host'), 403) | ||
| 134 | + await next() | ||
| 135 | + }) | ||
| 136 | + | ||
| 137 | + app.use('/api/*', async (c, next) => { | ||
| 138 | + if (!tokenMatches(c.req.header('x-skilly-token'), token)) return c.json(err('Unauthorized'), 401) | ||
| 139 | + await next() | ||
| 140 | + }) | ||
| 141 | + | ||
| 142 | + async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> { | ||
| 143 | + const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt } | ||
| 144 | + if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() } | ||
| 145 | + const cached = scanCache.get(p.id) | ||
| 146 | + let inventory: ScopeInventory | ||
| 147 | + if (useCache && cached && Date.now() - cached.at < SCAN_CACHE_MS) { | ||
| 148 | + inventory = cached.inventory | ||
| 149 | + } else { | ||
| 150 | + inventory = await scanProject(p.path, ctx) | ||
| 151 | + scanCache.set(p.id, { at: Date.now(), inventory }) | ||
| 152 | + } | ||
| 153 | + const counts = emptyCounts() | ||
| 154 | + for (const item of inventory.items) counts[item.type]++ | ||
| 155 | + return { ...base, exists: true, agentsDetected: inventory.agentsDetected, counts } | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + app.get('/api/health', (c) => c.json({ ok: true, version: SERVER_VERSION })) | ||
| 159 | + | ||
| 160 | + app.post('/api/heartbeat', (c) => { | ||
| 161 | + options.onHeartbeat?.() | ||
| 162 | + return c.body(null, 204) | ||
| 163 | + }) | ||
| 164 | + | ||
| 165 | + app.get('/api/home', async (c) => c.json(await scanHome(ctx))) | ||
| 166 | + | ||
| 167 | + app.get('/api/projects', async (c) => { | ||
| 168 | + const projects = await store.list() | ||
| 169 | + return c.json(await Promise.all(projects.map((p) => summarize(p, true)))) | ||
| 170 | + }) | ||
| 171 | + | ||
| 172 | + app.post('/api/projects', async (c) => { | ||
| 173 | + let body: Partial<AddProjectBody> | undefined | ||
| 174 | + try { | ||
| 175 | + body = (await c.req.json()) as Partial<AddProjectBody> | ||
| 176 | + } catch { | ||
| 177 | + return c.json(err('Invalid JSON body'), 400) | ||
| 178 | + } | ||
| 179 | + if (!body || typeof body.path !== 'string' || !body.path.trim() || !path.isAbsolute(body.path)) { | ||
| 180 | + return c.json(err('An absolute "path" is required'), 400) | ||
| 181 | + } | ||
| 182 | + if (!(await isDir(body.path))) return c.json(err('Not a directory'), 400) | ||
| 183 | + const project = await store.add(body.path) | ||
| 184 | + if (!project) return c.json(err('Project already added'), 409) | ||
| 185 | + return c.json(await summarize(project, false), 201) | ||
| 186 | + }) | ||
| 187 | + | ||
| 188 | + app.delete('/api/projects/:id', async (c) => { | ||
| 189 | + const id = c.req.param('id') | ||
| 190 | + if (!(await store.remove(id))) return c.json(err('Unknown project'), 404) | ||
| 191 | + scanCache.delete(id) | ||
| 192 | + return c.body(null, 204) | ||
| 193 | + }) | ||
| 194 | + | ||
| 195 | + app.get('/api/projects/:id/inventory', async (c) => { | ||
| 196 | + const project = (await store.list()).find((p) => p.id === c.req.param('id')) | ||
| 197 | + if (!project) return c.json(err('Unknown project'), 404) | ||
| 198 | + if (!(await isDir(project.path))) return c.json(err('Project folder no longer exists'), 404) | ||
| 199 | + return c.json(await scanProject(project.path, ctx)) | ||
| 200 | + }) | ||
| 201 | + | ||
| 202 | + app.get('/api/fs/list', async (c) => { | ||
| 203 | + const p = c.req.query('path') | ||
| 204 | + if (!p) return c.json(listRoots(ctx.homeDir)) | ||
| 205 | + if (!path.isAbsolute(p)) return c.json(err('Path must be absolute'), 400) | ||
| 206 | + try { | ||
| 207 | + return c.json(await listDirectory(p)) | ||
| 208 | + } catch (e) { | ||
| 209 | + const code = (e as NodeJS.ErrnoException).code | ||
| 210 | + if (code === 'ENOENT' || code === 'ENOTDIR') return c.json(err('Not a directory'), 404) | ||
| 211 | + return c.json(err('Cannot read directory'), 403) | ||
| 212 | + } | ||
| 213 | + }) | ||
| 214 | + | ||
| 215 | + app.get('/api/files', async (c) => { | ||
| 216 | + const p = c.req.query('path') | ||
| 217 | + if (!p) return c.json(err('Missing "path"'), 400) | ||
| 218 | + try { | ||
| 219 | + const projects = await store.list() | ||
| 220 | + const file = await readAllowedFile(p, { | ||
| 221 | + homeDir: ctx.homeDir, | ||
| 222 | + codexHome: ctx.codexHome, | ||
| 223 | + projectRoots: projects.map((x) => x.path), | ||
| 224 | + }) | ||
| 225 | + return c.json(file) | ||
| 226 | + } catch (e) { | ||
| 227 | + if (e instanceof FileAccessError) return c.json(err(e.message), e.status) | ||
| 228 | + return c.json(err('Cannot read file'), 500) | ||
| 229 | + } | ||
| 230 | + }) | ||
| 231 | + | ||
| 232 | + app.all('/api/*', (c) => c.json(err('Not found'), 404)) | ||
| 233 | + | ||
| 234 | + const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined | ||
| 235 | + if (staticDir) { | ||
| 236 | + app.get('*', async (c) => { | ||
| 237 | + const urlPath = new URL(c.req.url).pathname | ||
| 238 | + let found = await readStatic(staticDir, urlPath) | ||
| 239 | + // SPA fallback for routes (not for missing assets). | ||
| 240 | + if (!found && !path.extname(urlPath)) { | ||
| 241 | + found = (await readStatic(staticDir, '/200.html')) ?? (await readStatic(staticDir, '/index.html')) | ||
| 242 | + } | ||
| 243 | + if (!found) return c.json(err('Not found'), 404) | ||
| 244 | + return c.body(new Uint8Array(found.body), 200, { 'content-type': found.type }) | ||
| 245 | + }) | ||
| 246 | + } | ||
| 247 | + | ||
| 248 | + return app | ||
| 249 | +} | ||
| 250 | + | ||
| 251 | +export async function startServer(options: ServerOptions): Promise<RunningServer> { | ||
| 252 | + let idleTimer: NodeJS.Timeout | undefined | ||
| 253 | + let closed = false | ||
| 254 | + const armWatchdog = (ms: number): void => { | ||
| 255 | + if (options.exitOnIdleMs === undefined) return | ||
| 256 | + if (idleTimer) clearTimeout(idleTimer) | ||
| 257 | + idleTimer = setTimeout(() => { | ||
| 258 | + if (!closed) options.onIdle?.() | ||
| 259 | + }, ms) | ||
| 260 | + idleTimer.unref() | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + const app = createApp({ | ||
| 264 | + ...options, | ||
| 265 | + onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0), | ||
| 266 | + }) | ||
| 267 | + | ||
| 268 | + const server = await new Promise<ReturnType<typeof serve>>((resolve, reject) => { | ||
| 269 | + const s = serve({ fetch: app.fetch, port: options.port ?? 0, hostname: '127.0.0.1' }, () => resolve(s)) | ||
| 270 | + s.once('error', reject) | ||
| 271 | + }) | ||
| 272 | + const port = (server.address() as AddressInfo).port | ||
| 273 | + armWatchdog(Math.max(STARTUP_GRACE_MS, options.exitOnIdleMs ?? 0)) | ||
| 274 | + | ||
| 275 | + return { | ||
| 276 | + url: `http://127.0.0.1:${port}`, | ||
| 277 | + port, | ||
| 278 | + token: options.token, | ||
| 279 | + close: () => | ||
| 280 | + new Promise<void>((resolve) => { | ||
| 281 | + closed = true | ||
| 282 | + if (idleTimer) clearTimeout(idleTimer) | ||
| 283 | + server.close(() => resolve()) | ||
| 284 | + ;(server as unknown as Server).closeAllConnections?.() | ||
| 285 | + }), | ||
| 286 | + } | ||
| 287 | +} | ||
added
packages/server/src/store.ts +85 -0 | new file mode 100644 | ||
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { createHash } from 'node:crypto' | |
| 2 | +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | +import { normalizePath } from '@skilly/core' | |
| 5 | + | |
| 6 | +export interface StoredProject { | |
| 7 | + id: string | |
| 8 | + path: string | |
| 9 | + name: string | |
| 10 | + addedAt: string | |
| 11 | +} | |
| 12 | + | |
| 13 | +interface ConfigFile { | |
| 14 | + version: 1 | |
| 15 | + projects: StoredProject[] | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function projectId(p: string): string { | |
| 19 | + return createHash('sha1').update(normalizePath(p)).digest('hex').slice(0, 12) | |
| 20 | +} | |
| 21 | + | |
| 22 | +export class ProjectStore { | |
| 23 | + private readonly file: string | |
| 24 | + private queue: Promise<unknown> = Promise.resolve() | |
| 25 | + | |
| 26 | + constructor(private readonly configDir: string) { | |
| 27 | + this.file = path.join(configDir, 'config.json') | |
| 28 | + } | |
| 29 | + | |
| 30 | + async list(): Promise<StoredProject[]> { | |
| 31 | + try { | |
| 32 | + const text = (await readFile(this.file, 'utf8')).replace(/^/, '') | |
| 33 | + const data = JSON.parse(text) as Partial<ConfigFile> | |
| 34 | + return Array.isArray(data.projects) ? data.projects : [] | |
| 35 | + } catch { | |
| 36 | + return [] | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */ | |
| 41 | + private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> { | |
| 42 | + const run = this.queue.then(async () => { | |
| 43 | + const projects = await this.list() | |
| 44 | + const result = fn(projects) | |
| 45 | + await this.save(projects) | |
| 46 | + return result | |
| 47 | + }) | |
| 48 | + this.queue = run.catch(() => undefined) | |
| 49 | + return run | |
| 50 | + } | |
| 51 | + | |
| 52 | + private async save(projects: StoredProject[]): Promise<void> { | |
| 53 | + await mkdir(this.configDir, { recursive: true }) | |
| 54 | + const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` | |
| 55 | + const body: ConfigFile = { version: 1, projects } | |
| 56 | + await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8') | |
| 57 | + await rename(tmp, this.file) | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** Returns undefined when the project is already present. */ | |
| 61 | + add(projectPath: string): Promise<StoredProject | undefined> { | |
| 62 | + return this.mutate((projects) => { | |
| 63 | + const abs = path.resolve(projectPath) | |
| 64 | + const id = projectId(abs) | |
| 65 | + if (projects.some((p) => p.id === id)) return undefined | |
| 66 | + const project: StoredProject = { | |
| 67 | + id, | |
| 68 | + path: abs, | |
| 69 | + name: path.basename(abs) || abs, | |
| 70 | + addedAt: new Date().toISOString(), | |
| 71 | + } | |
| 72 | + projects.push(project) | |
| 73 | + return project | |
| 74 | + }) | |
| 75 | + } | |
| 76 | + | |
| 77 | + remove(id: string): Promise<boolean> { | |
| 78 | + return this.mutate((projects) => { | |
| 79 | + const i = projects.findIndex((p) => p.id === id) | |
| 80 | + if (i < 0) return false | |
| 81 | + projects.splice(i, 1) | |
| 82 | + return true | |
| 83 | + }) | |
| 84 | + } | |
| 85 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | +import { createHash } from 'node:crypto' | ||
| 2 | +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import { normalizePath } from '@skilly/core' | ||
| 5 | + | ||
| 6 | +export interface StoredProject { | ||
| 7 | + id: string | ||
| 8 | + path: string | ||
| 9 | + name: string | ||
| 10 | + addedAt: string | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +interface ConfigFile { | ||
| 14 | + version: 1 | ||
| 15 | + projects: StoredProject[] | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +export function projectId(p: string): string { | ||
| 19 | + return createHash('sha1').update(normalizePath(p)).digest('hex').slice(0, 12) | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +export class ProjectStore { | ||
| 23 | + private readonly file: string | ||
| 24 | + private queue: Promise<unknown> = Promise.resolve() | ||
| 25 | + | ||
| 26 | + constructor(private readonly configDir: string) { | ||
| 27 | + this.file = path.join(configDir, 'config.json') | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + async list(): Promise<StoredProject[]> { | ||
| 31 | + try { | ||
| 32 | + const text = (await readFile(this.file, 'utf8')).replace(/^/, '') | ||
| 33 | + const data = JSON.parse(text) as Partial<ConfigFile> | ||
| 34 | + return Array.isArray(data.projects) ? data.projects : [] | ||
| 35 | + } catch { | ||
| 36 | + return [] | ||
| 37 | + } | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */ | ||
| 41 | + private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> { | ||
| 42 | + const run = this.queue.then(async () => { | ||
| 43 | + const projects = await this.list() | ||
| 44 | + const result = fn(projects) | ||
| 45 | + await this.save(projects) | ||
| 46 | + return result | ||
| 47 | + }) | ||
| 48 | + this.queue = run.catch(() => undefined) | ||
| 49 | + return run | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + private async save(projects: StoredProject[]): Promise<void> { | ||
| 53 | + await mkdir(this.configDir, { recursive: true }) | ||
| 54 | + const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` | ||
| 55 | + const body: ConfigFile = { version: 1, projects } | ||
| 56 | + await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8') | ||
| 57 | + await rename(tmp, this.file) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + /** Returns undefined when the project is already present. */ | ||
| 61 | + add(projectPath: string): Promise<StoredProject | undefined> { | ||
| 62 | + return this.mutate((projects) => { | ||
| 63 | + const abs = path.resolve(projectPath) | ||
| 64 | + const id = projectId(abs) | ||
| 65 | + if (projects.some((p) => p.id === id)) return undefined | ||
| 66 | + const project: StoredProject = { | ||
| 67 | + id, | ||
| 68 | + path: abs, | ||
| 69 | + name: path.basename(abs) || abs, | ||
| 70 | + addedAt: new Date().toISOString(), | ||
| 71 | + } | ||
| 72 | + projects.push(project) | ||
| 73 | + return project | ||
| 74 | + }) | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + remove(id: string): Promise<boolean> { | ||
| 78 | + return this.mutate((projects) => { | ||
| 79 | + const i = projects.findIndex((p) => p.id === id) | ||
| 80 | + if (i < 0) return false | ||
| 81 | + projects.splice(i, 1) | ||
| 82 | + return true | ||
| 83 | + }) | ||
| 84 | + } | ||
| 85 | +} | ||
added
packages/server/test/server.test.ts +230 -0 | new file mode 100644 | ||
| @@ -0,0 +1,230 @@ | ||
| 1 | +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' | |
| 2 | +import os from 'node:os' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { FileContent, FsListing, ProjectSummary } from '@skilly/core' | |
| 5 | +import type { Hono } from 'hono' | |
| 6 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 7 | +import { createApp, startServer } from '../src/index.ts' | |
| 8 | + | |
| 9 | +const TOKEN = 'test-token' | |
| 10 | +let tmp: string | |
| 11 | +let home: string | |
| 12 | +let configDir: string | |
| 13 | +let project: string | |
| 14 | +let app: Hono | |
| 15 | + | |
| 16 | +function req(url: string, init: RequestInit = {}, token: string | null = TOKEN) { | |
| 17 | + const headers = new Headers(init.headers) | |
| 18 | + if (token) headers.set('x-skilly-token', token) | |
| 19 | + return app.request(url, { ...init, headers }) | |
| 20 | +} | |
| 21 | + | |
| 22 | +function post(url: string, body: unknown) { | |
| 23 | + return req(url, { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }) | |
| 24 | +} | |
| 25 | + | |
| 26 | +beforeEach(async () => { | |
| 27 | + tmp = await realpath(await mkdtemp(path.join(os.tmpdir(), 'skilly-server-'))) | |
| 28 | + home = path.join(tmp, 'home') | |
| 29 | + configDir = path.join(tmp, 'config') | |
| 30 | + project = path.join(tmp, 'work', 'my-project') | |
| 31 | + await mkdir(path.join(home, '.claude', 'skills', 'foo'), { recursive: true }) | |
| 32 | + await mkdir(path.join(home, '.agents'), { recursive: true }) | |
| 33 | + await writeFile(path.join(home, '.claude', 'skills', 'foo', 'SKILL.md'), '# foo') | |
| 34 | + await writeFile(path.join(home, '.claude', '.credentials.json'), '{}') | |
| 35 | + await writeFile(path.join(home, '.claude', 'server.pem'), 'x') | |
| 36 | + await writeFile(path.join(home, '.claude.json'), '{"secret":1}') | |
| 37 | + await writeFile(path.join(tmp, 'outside.txt'), 'nope') | |
| 38 | + await mkdir(path.join(project, '.claude'), { recursive: true }) | |
| 39 | + await writeFile(path.join(project, '.env'), 'SECRET=1') | |
| 40 | + await writeFile(path.join(project, 'README.md'), 'hello') | |
| 41 | + app = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home } }) | |
| 42 | +}) | |
| 43 | + | |
| 44 | +afterEach(async () => { | |
| 45 | + await rm(tmp, { recursive: true, force: true }) | |
| 46 | +}) | |
| 47 | + | |
| 48 | +describe('auth and host', () => { | |
| 49 | + it('rejects requests without or with a wrong token', async () => { | |
| 50 | + expect((await req('/api/health', {}, null)).status).toBe(401) | |
| 51 | + expect((await req('/api/health', {}, 'wrong')).status).toBe(401) | |
| 52 | + expect((await req('/api/health')).status).toBe(200) | |
| 53 | + }) | |
| 54 | + | |
| 55 | + it('rejects foreign Host headers', async () => { | |
| 56 | + const bad = await req('/api/health', { headers: { host: 'evil.example.com' } }) | |
| 57 | + expect(bad.status).toBe(403) | |
| 58 | + expect((await req('/api/health', { headers: { host: '127.0.0.1:4321' } })).status).toBe(200) | |
| 59 | + expect((await req('/api/health', { headers: { host: 'localhost:4321' } })).status).toBe(200) | |
| 60 | + }) | |
| 61 | + | |
| 62 | + it('accepts heartbeat', async () => { | |
| 63 | + expect((await req('/api/heartbeat', { method: 'POST' })).status).toBe(204) | |
| 64 | + }) | |
| 65 | +}) | |
| 66 | + | |
| 67 | +describe('projects', () => { | |
| 68 | + it('adds, lists, and deletes a project', async () => { | |
| 69 | + const res = await post('/api/projects', { path: project }) | |
| 70 | + expect(res.status).toBe(201) | |
| 71 | + const added = (await res.json()) as ProjectSummary | |
| 72 | + expect(added.name).toBe('my-project') | |
| 73 | + expect(added.id).toMatch(/^[0-9a-f]{12}$/) | |
| 74 | + expect(added.exists).toBe(true) | |
| 75 | + expect(added.counts).toEqual({ skill: 0, rule: 0, mcp: 0, hook: 0, agent: 0, instructions: 0 }) | |
| 76 | + | |
| 77 | + const list = (await (await req('/api/projects')).json()) as ProjectSummary[] | |
| 78 | + expect(list.map((p) => p.id)).toEqual([added.id]) | |
| 79 | + | |
| 80 | + const inv = await req(`/api/projects/${added.id}/inventory`) | |
| 81 | + expect(inv.status).toBe(200) | |
| 82 | + | |
| 83 | + expect((await req(`/api/projects/${added.id}`, { method: 'DELETE' })).status).toBe(204) | |
| 84 | + expect(((await (await req('/api/projects')).json()) as unknown[]).length).toBe(0) | |
| 85 | + expect((await req(`/api/projects/${added.id}`, { method: 'DELETE' })).status).toBe(404) | |
| 86 | + }) | |
| 87 | + | |
| 88 | + it('rejects non-directories (400) and duplicates (409)', async () => { | |
| 89 | + expect((await post('/api/projects', { path: path.join(project, 'README.md') })).status).toBe(400) | |
| 90 | + expect((await post('/api/projects', { path: path.join(tmp, 'missing') })).status).toBe(400) | |
| 91 | + expect((await post('/api/projects', { path: 'relative/path' })).status).toBe(400) | |
| 92 | + expect((await post('/api/projects', { path: project })).status).toBe(201) | |
| 93 | + expect((await post('/api/projects', { path: project })).status).toBe(409) | |
| 94 | + }) | |
| 95 | + | |
| 96 | + it('reports exists=false when the folder disappears', async () => { | |
| 97 | + await post('/api/projects', { path: project }) | |
| 98 | + await rm(project, { recursive: true, force: true }) | |
| 99 | + const list = (await (await req('/api/projects')).json()) as ProjectSummary[] | |
| 100 | + expect(list[0]?.exists).toBe(false) | |
| 101 | + }) | |
| 102 | +}) | |
| 103 | + | |
| 104 | +describe('fs list', () => { | |
| 105 | + it('lists roots', async () => { | |
| 106 | + const listing = (await (await req('/api/fs/list')).json()) as FsListing | |
| 107 | + expect(listing.path).toBeNull() | |
| 108 | + expect(listing.parent).toBeNull() | |
| 109 | + expect(listing.entries.length).toBeGreaterThan(0) | |
| 110 | + }) | |
| 111 | + | |
| 112 | + it('lists subdirectories with agent config detection', async () => { | |
| 113 | + const work = path.join(tmp, 'work') | |
| 114 | + await mkdir(path.join(work, '.hidden'), { recursive: true }) | |
| 115 | + await mkdir(path.join(work, 'node_modules'), { recursive: true }) | |
| 116 | + await mkdir(path.join(work, 'Alpha'), { recursive: true }) | |
| 117 | + await writeFile(path.join(work, 'file.txt'), 'x') | |
| 118 | + const res = await req(`/api/fs/list?path=${encodeURIComponent(work)}`) | |
| 119 | + expect(res.status).toBe(200) | |
| 120 | + const listing = (await res.json()) as FsListing | |
| 121 | + expect(listing.path).toBe(work) | |
| 122 | + expect(listing.parent).toBe(tmp) | |
| 123 | + expect(listing.entries.map((e) => e.name)).toEqual(['Alpha', 'my-project']) | |
| 124 | + expect(listing.entries.find((e) => e.name === 'my-project')?.hasAgentConfig).toBe(true) | |
| 125 | + expect(listing.entries.find((e) => e.name === 'Alpha')?.hasAgentConfig).toBe(false) | |
| 126 | + }) | |
| 127 | + | |
| 128 | + it('errors on missing or relative paths', async () => { | |
| 129 | + expect((await req(`/api/fs/list?path=${encodeURIComponent(path.join(tmp, 'nope'))}`)).status).toBe(404) | |
| 130 | + expect((await req('/api/fs/list?path=relative')).status).toBe(400) | |
| 131 | + }) | |
| 132 | +}) | |
| 133 | + | |
| 134 | +describe('files', () => { | |
| 135 | + const get = (p: string) => req(`/api/files?path=${encodeURIComponent(p)}`) | |
| 136 | + | |
| 137 | + it('reads files inside home agent folders', async () => { | |
| 138 | + const res = await get(path.join(home, '.claude', 'skills', 'foo', 'SKILL.md')) | |
| 139 | + expect(res.status).toBe(200) | |
| 140 | + const file = (await res.json()) as FileContent | |
| 141 | + expect(file.content).toBe('# foo') | |
| 142 | + expect(file.truncated).toBe(false) | |
| 143 | + }) | |
| 144 | + | |
| 145 | + it('denies secrets and unrelated locations', async () => { | |
| 146 | + expect((await get(path.join(home, '.claude.json'))).status).toBe(403) | |
| 147 | + expect((await get(path.join(home, '.claude', '.credentials.json'))).status).toBe(403) | |
| 148 | + expect((await get(path.join(home, '.claude', 'server.pem'))).status).toBe(403) | |
| 149 | + expect((await get(path.join(tmp, 'outside.txt'))).status).toBe(403) | |
| 150 | + }) | |
| 151 | + | |
| 152 | + it('denies path traversal', async () => { | |
| 153 | + expect((await get(path.join(home, '.claude', '..', '..', 'outside.txt'))).status).toBe(403) | |
| 154 | + expect((await get(`${home}${path.sep}.claude${path.sep}..${path.sep}.claude.json`)).status).toBe(403) | |
| 155 | + expect((await get('relative.txt')).status).toBe(400) | |
| 156 | + }) | |
| 157 | + | |
| 158 | + it('allows project files only once the project is added, never .env', async () => { | |
| 159 | + expect((await get(path.join(project, 'README.md'))).status).toBe(403) | |
| 160 | + await post('/api/projects', { path: project }) | |
| 161 | + expect((await get(path.join(project, 'README.md'))).status).toBe(200) | |
| 162 | + expect((await get(path.join(project, '.env'))).status).toBe(403) | |
| 163 | + }) | |
| 164 | + | |
| 165 | + it('truncates large files', async () => { | |
| 166 | + const big = path.join(home, '.claude', 'big.txt') | |
| 167 | + await writeFile(big, 'a'.repeat(600 * 1024)) | |
| 168 | + const file = (await (await get(big)).json()) as FileContent | |
| 169 | + expect(file.truncated).toBe(true) | |
| 170 | + expect(file.sizeBytes).toBe(600 * 1024) | |
| 171 | + expect(file.content.length).toBe(512 * 1024) | |
| 172 | + }) | |
| 173 | + | |
| 174 | + it('follows a symlink located in an allowed root', async () => { | |
| 175 | + const target = path.join(tmp, 'skills-manager', 'bar') | |
| 176 | + await mkdir(target, { recursive: true }) | |
| 177 | + await writeFile(path.join(target, 'SKILL.md'), '# bar') | |
| 178 | + const link = path.join(home, '.claude', 'skills', 'bar') | |
| 179 | + try { | |
| 180 | + await symlink(target, link, 'junction') | |
| 181 | + } catch { | |
| 182 | + return // symlinks unavailable | |
| 183 | + } | |
| 184 | + const res = await get(path.join(link, 'SKILL.md')) | |
| 185 | + expect(res.status).toBe(200) | |
| 186 | + }) | |
| 187 | +}) | |
| 188 | + | |
| 189 | +describe('static ui', () => { | |
| 190 | + it('serves assets without token and falls back to the SPA shell', async () => { | |
| 191 | + const ui = path.join(tmp, 'ui') | |
| 192 | + await mkdir(path.join(ui, '_nuxt'), { recursive: true }) | |
| 193 | + await writeFile(path.join(ui, '200.html'), '<html>shell</html>') | |
| 194 | + await writeFile(path.join(ui, '_nuxt', 'a.js'), 'console.log(1)') | |
| 195 | + const withUi = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home }, staticDir: ui }) | |
| 196 | + const asset = await withUi.request('/_nuxt/a.js') | |
| 197 | + expect(asset.status).toBe(200) | |
| 198 | + expect(asset.headers.get('content-type')).toContain('javascript') | |
| 199 | + expect(await (await withUi.request('/project/abc')).text()).toContain('shell') | |
| 200 | + expect((await withUi.request('/_nuxt/missing.js')).status).toBe(404) | |
| 201 | + expect((await withUi.request('/..%2f..%2fsecret')).status).not.toBe(200) | |
| 202 | + expect((await withUi.request('/api/health')).status).toBe(401) | |
| 203 | + }) | |
| 204 | +}) | |
| 205 | + | |
| 206 | +describe('startServer', () => { | |
| 207 | + it('listens on a free port and calls onIdle once heartbeats stop', async () => { | |
| 208 | + let idle = 0 | |
| 209 | + const server = await startServer({ | |
| 210 | + token: TOKEN, | |
| 211 | + port: 0, | |
| 212 | + configDir, | |
| 213 | + scanContext: { homeDir: home }, | |
| 214 | + exitOnIdleMs: 150, | |
| 215 | + onIdle: () => idle++, | |
| 216 | + }) | |
| 217 | + try { | |
| 218 | + expect(server.port).toBeGreaterThan(0) | |
| 219 | + const headers = { 'x-skilly-token': TOKEN } | |
| 220 | + const res = await fetch(`${server.url}/api/health`, { headers }) | |
| 221 | + expect(res.status).toBe(200) | |
| 222 | + expect(idle).toBe(0) // startup grace is at least 60 s | |
| 223 | + await fetch(`${server.url}/api/heartbeat`, { method: 'POST', headers }) | |
| 224 | + await new Promise((r) => setTimeout(r, 400)) | |
| 225 | + expect(idle).toBe(1) | |
| 226 | + } finally { | |
| 227 | + await server.close() | |
| 228 | + } | |
| 229 | + }) | |
| 230 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,230 @@ | |||
| 1 | +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' | ||
| 2 | +import os from 'node:os' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import type { FileContent, FsListing, ProjectSummary } from '@skilly/core' | ||
| 5 | +import type { Hono } from 'hono' | ||
| 6 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
| 7 | +import { createApp, startServer } from '../src/index.ts' | ||
| 8 | + | ||
| 9 | +const TOKEN = 'test-token' | ||
| 10 | +let tmp: string | ||
| 11 | +let home: string | ||
| 12 | +let configDir: string | ||
| 13 | +let project: string | ||
| 14 | +let app: Hono | ||
| 15 | + | ||
| 16 | +function req(url: string, init: RequestInit = {}, token: string | null = TOKEN) { | ||
| 17 | + const headers = new Headers(init.headers) | ||
| 18 | + if (token) headers.set('x-skilly-token', token) | ||
| 19 | + return app.request(url, { ...init, headers }) | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +function post(url: string, body: unknown) { | ||
| 23 | + return req(url, { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }) | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +beforeEach(async () => { | ||
| 27 | + tmp = await realpath(await mkdtemp(path.join(os.tmpdir(), 'skilly-server-'))) | ||
| 28 | + home = path.join(tmp, 'home') | ||
| 29 | + configDir = path.join(tmp, 'config') | ||
| 30 | + project = path.join(tmp, 'work', 'my-project') | ||
| 31 | + await mkdir(path.join(home, '.claude', 'skills', 'foo'), { recursive: true }) | ||
| 32 | + await mkdir(path.join(home, '.agents'), { recursive: true }) | ||
| 33 | + await writeFile(path.join(home, '.claude', 'skills', 'foo', 'SKILL.md'), '# foo') | ||
| 34 | + await writeFile(path.join(home, '.claude', '.credentials.json'), '{}') | ||
| 35 | + await writeFile(path.join(home, '.claude', 'server.pem'), 'x') | ||
| 36 | + await writeFile(path.join(home, '.claude.json'), '{"secret":1}') | ||
| 37 | + await writeFile(path.join(tmp, 'outside.txt'), 'nope') | ||
| 38 | + await mkdir(path.join(project, '.claude'), { recursive: true }) | ||
| 39 | + await writeFile(path.join(project, '.env'), 'SECRET=1') | ||
| 40 | + await writeFile(path.join(project, 'README.md'), 'hello') | ||
| 41 | + app = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home } }) | ||
| 42 | +}) | ||
| 43 | + | ||
| 44 | +afterEach(async () => { | ||
| 45 | + await rm(tmp, { recursive: true, force: true }) | ||
| 46 | +}) | ||
| 47 | + | ||
| 48 | +describe('auth and host', () => { | ||
| 49 | + it('rejects requests without or with a wrong token', async () => { | ||
| 50 | + expect((await req('/api/health', {}, null)).status).toBe(401) | ||
| 51 | + expect((await req('/api/health', {}, 'wrong')).status).toBe(401) | ||
| 52 | + expect((await req('/api/health')).status).toBe(200) | ||
| 53 | + }) | ||
| 54 | + | ||
| 55 | + it('rejects foreign Host headers', async () => { | ||
| 56 | + const bad = await req('/api/health', { headers: { host: 'evil.example.com' } }) | ||
| 57 | + expect(bad.status).toBe(403) | ||
| 58 | + expect((await req('/api/health', { headers: { host: '127.0.0.1:4321' } })).status).toBe(200) | ||
| 59 | + expect((await req('/api/health', { headers: { host: 'localhost:4321' } })).status).toBe(200) | ||
| 60 | + }) | ||
| 61 | + | ||
| 62 | + it('accepts heartbeat', async () => { | ||
| 63 | + expect((await req('/api/heartbeat', { method: 'POST' })).status).toBe(204) | ||
| 64 | + }) | ||
| 65 | +}) | ||
| 66 | + | ||
| 67 | +describe('projects', () => { | ||
| 68 | + it('adds, lists, and deletes a project', async () => { | ||
| 69 | + const res = await post('/api/projects', { path: project }) | ||
| 70 | + expect(res.status).toBe(201) | ||
| 71 | + const added = (await res.json()) as ProjectSummary | ||
| 72 | + expect(added.name).toBe('my-project') | ||
| 73 | + expect(added.id).toMatch(/^[0-9a-f]{12}$/) | ||
| 74 | + expect(added.exists).toBe(true) | ||
| 75 | + expect(added.counts).toEqual({ skill: 0, rule: 0, mcp: 0, hook: 0, agent: 0, instructions: 0 }) | ||
| 76 | + | ||
| 77 | + const list = (await (await req('/api/projects')).json()) as ProjectSummary[] | ||
| 78 | + expect(list.map((p) => p.id)).toEqual([added.id]) | ||
| 79 | + | ||
| 80 | + const inv = await req(`/api/projects/${added.id}/inventory`) | ||
| 81 | + expect(inv.status).toBe(200) | ||
| 82 | + | ||
| 83 | + expect((await req(`/api/projects/${added.id}`, { method: 'DELETE' })).status).toBe(204) | ||
| 84 | + expect(((await (await req('/api/projects')).json()) as unknown[]).length).toBe(0) | ||
| 85 | + expect((await req(`/api/projects/${added.id}`, { method: 'DELETE' })).status).toBe(404) | ||
| 86 | + }) | ||
| 87 | + | ||
| 88 | + it('rejects non-directories (400) and duplicates (409)', async () => { | ||
| 89 | + expect((await post('/api/projects', { path: path.join(project, 'README.md') })).status).toBe(400) | ||
| 90 | + expect((await post('/api/projects', { path: path.join(tmp, 'missing') })).status).toBe(400) | ||
| 91 | + expect((await post('/api/projects', { path: 'relative/path' })).status).toBe(400) | ||
| 92 | + expect((await post('/api/projects', { path: project })).status).toBe(201) | ||
| 93 | + expect((await post('/api/projects', { path: project })).status).toBe(409) | ||
| 94 | + }) | ||
| 95 | + | ||
| 96 | + it('reports exists=false when the folder disappears', async () => { | ||
| 97 | + await post('/api/projects', { path: project }) | ||
| 98 | + await rm(project, { recursive: true, force: true }) | ||
| 99 | + const list = (await (await req('/api/projects')).json()) as ProjectSummary[] | ||
| 100 | + expect(list[0]?.exists).toBe(false) | ||
| 101 | + }) | ||
| 102 | +}) | ||
| 103 | + | ||
| 104 | +describe('fs list', () => { | ||
| 105 | + it('lists roots', async () => { | ||
| 106 | + const listing = (await (await req('/api/fs/list')).json()) as FsListing | ||
| 107 | + expect(listing.path).toBeNull() | ||
| 108 | + expect(listing.parent).toBeNull() | ||
| 109 | + expect(listing.entries.length).toBeGreaterThan(0) | ||
| 110 | + }) | ||
| 111 | + | ||
| 112 | + it('lists subdirectories with agent config detection', async () => { | ||
| 113 | + const work = path.join(tmp, 'work') | ||
| 114 | + await mkdir(path.join(work, '.hidden'), { recursive: true }) | ||
| 115 | + await mkdir(path.join(work, 'node_modules'), { recursive: true }) | ||
| 116 | + await mkdir(path.join(work, 'Alpha'), { recursive: true }) | ||
| 117 | + await writeFile(path.join(work, 'file.txt'), 'x') | ||
| 118 | + const res = await req(`/api/fs/list?path=${encodeURIComponent(work)}`) | ||
| 119 | + expect(res.status).toBe(200) | ||
| 120 | + const listing = (await res.json()) as FsListing | ||
| 121 | + expect(listing.path).toBe(work) | ||
| 122 | + expect(listing.parent).toBe(tmp) | ||
| 123 | + expect(listing.entries.map((e) => e.name)).toEqual(['Alpha', 'my-project']) | ||
| 124 | + expect(listing.entries.find((e) => e.name === 'my-project')?.hasAgentConfig).toBe(true) | ||
| 125 | + expect(listing.entries.find((e) => e.name === 'Alpha')?.hasAgentConfig).toBe(false) | ||
| 126 | + }) | ||
| 127 | + | ||
| 128 | + it('errors on missing or relative paths', async () => { | ||
| 129 | + expect((await req(`/api/fs/list?path=${encodeURIComponent(path.join(tmp, 'nope'))}`)).status).toBe(404) | ||
| 130 | + expect((await req('/api/fs/list?path=relative')).status).toBe(400) | ||
| 131 | + }) | ||
| 132 | +}) | ||
| 133 | + | ||
| 134 | +describe('files', () => { | ||
| 135 | + const get = (p: string) => req(`/api/files?path=${encodeURIComponent(p)}`) | ||
| 136 | + | ||
| 137 | + it('reads files inside home agent folders', async () => { | ||
| 138 | + const res = await get(path.join(home, '.claude', 'skills', 'foo', 'SKILL.md')) | ||
| 139 | + expect(res.status).toBe(200) | ||
| 140 | + const file = (await res.json()) as FileContent | ||
| 141 | + expect(file.content).toBe('# foo') | ||
| 142 | + expect(file.truncated).toBe(false) | ||
| 143 | + }) | ||
| 144 | + | ||
| 145 | + it('denies secrets and unrelated locations', async () => { | ||
| 146 | + expect((await get(path.join(home, '.claude.json'))).status).toBe(403) | ||
| 147 | + expect((await get(path.join(home, '.claude', '.credentials.json'))).status).toBe(403) | ||
| 148 | + expect((await get(path.join(home, '.claude', 'server.pem'))).status).toBe(403) | ||
| 149 | + expect((await get(path.join(tmp, 'outside.txt'))).status).toBe(403) | ||
| 150 | + }) | ||
| 151 | + | ||
| 152 | + it('denies path traversal', async () => { | ||
| 153 | + expect((await get(path.join(home, '.claude', '..', '..', 'outside.txt'))).status).toBe(403) | ||
| 154 | + expect((await get(`${home}${path.sep}.claude${path.sep}..${path.sep}.claude.json`)).status).toBe(403) | ||
| 155 | + expect((await get('relative.txt')).status).toBe(400) | ||
| 156 | + }) | ||
| 157 | + | ||
| 158 | + it('allows project files only once the project is added, never .env', async () => { | ||
| 159 | + expect((await get(path.join(project, 'README.md'))).status).toBe(403) | ||
| 160 | + await post('/api/projects', { path: project }) | ||
| 161 | + expect((await get(path.join(project, 'README.md'))).status).toBe(200) | ||
| 162 | + expect((await get(path.join(project, '.env'))).status).toBe(403) | ||
| 163 | + }) | ||
| 164 | + | ||
| 165 | + it('truncates large files', async () => { | ||
| 166 | + const big = path.join(home, '.claude', 'big.txt') | ||
| 167 | + await writeFile(big, 'a'.repeat(600 * 1024)) | ||
| 168 | + const file = (await (await get(big)).json()) as FileContent | ||
| 169 | + expect(file.truncated).toBe(true) | ||
| 170 | + expect(file.sizeBytes).toBe(600 * 1024) | ||
| 171 | + expect(file.content.length).toBe(512 * 1024) | ||
| 172 | + }) | ||
| 173 | + | ||
| 174 | + it('follows a symlink located in an allowed root', async () => { | ||
| 175 | + const target = path.join(tmp, 'skills-manager', 'bar') | ||
| 176 | + await mkdir(target, { recursive: true }) | ||
| 177 | + await writeFile(path.join(target, 'SKILL.md'), '# bar') | ||
| 178 | + const link = path.join(home, '.claude', 'skills', 'bar') | ||
| 179 | + try { | ||
| 180 | + await symlink(target, link, 'junction') | ||
| 181 | + } catch { | ||
| 182 | + return // symlinks unavailable | ||
| 183 | + } | ||
| 184 | + const res = await get(path.join(link, 'SKILL.md')) | ||
| 185 | + expect(res.status).toBe(200) | ||
| 186 | + }) | ||
| 187 | +}) | ||
| 188 | + | ||
| 189 | +describe('static ui', () => { | ||
| 190 | + it('serves assets without token and falls back to the SPA shell', async () => { | ||
| 191 | + const ui = path.join(tmp, 'ui') | ||
| 192 | + await mkdir(path.join(ui, '_nuxt'), { recursive: true }) | ||
| 193 | + await writeFile(path.join(ui, '200.html'), '<html>shell</html>') | ||
| 194 | + await writeFile(path.join(ui, '_nuxt', 'a.js'), 'console.log(1)') | ||
| 195 | + const withUi = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home }, staticDir: ui }) | ||
| 196 | + const asset = await withUi.request('/_nuxt/a.js') | ||
| 197 | + expect(asset.status).toBe(200) | ||
| 198 | + expect(asset.headers.get('content-type')).toContain('javascript') | ||
| 199 | + expect(await (await withUi.request('/project/abc')).text()).toContain('shell') | ||
| 200 | + expect((await withUi.request('/_nuxt/missing.js')).status).toBe(404) | ||
| 201 | + expect((await withUi.request('/..%2f..%2fsecret')).status).not.toBe(200) | ||
| 202 | + expect((await withUi.request('/api/health')).status).toBe(401) | ||
| 203 | + }) | ||
| 204 | +}) | ||
| 205 | + | ||
| 206 | +describe('startServer', () => { | ||
| 207 | + it('listens on a free port and calls onIdle once heartbeats stop', async () => { | ||
| 208 | + let idle = 0 | ||
| 209 | + const server = await startServer({ | ||
| 210 | + token: TOKEN, | ||
| 211 | + port: 0, | ||
| 212 | + configDir, | ||
| 213 | + scanContext: { homeDir: home }, | ||
| 214 | + exitOnIdleMs: 150, | ||
| 215 | + onIdle: () => idle++, | ||
| 216 | + }) | ||
| 217 | + try { | ||
| 218 | + expect(server.port).toBeGreaterThan(0) | ||
| 219 | + const headers = { 'x-skilly-token': TOKEN } | ||
| 220 | + const res = await fetch(`${server.url}/api/health`, { headers }) | ||
| 221 | + expect(res.status).toBe(200) | ||
| 222 | + expect(idle).toBe(0) // startup grace is at least 60 s | ||
| 223 | + await fetch(`${server.url}/api/heartbeat`, { method: 'POST', headers }) | ||
| 224 | + await new Promise((r) => setTimeout(r, 400)) | ||
| 225 | + expect(idle).toBe(1) | ||
| 226 | + } finally { | ||
| 227 | + await server.close() | ||
| 228 | + } | ||
| 229 | + }) | ||
| 230 | +}) | ||