hlassiege/panopliepublic Fork
⑂ 9c75087
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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>
hlassiege committed 2026-10-23T11:45:45+02:00 Browse files
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+}
2225
3-// Placeholder: replaced by the real scanner implementation.
4226 export const claudeScanner: AgentScanner = {
5227 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,
12230 }
@@ -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+}
299
3-// Placeholder: replaced by the real scanner implementation.
4100 export const codexScanner: AgentScanner = {
5101 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,
12104 }
@@ -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+})