/preflight <folder | owner/repo | git url> shows, in plain language, what a plugin or mod can do before you install it, with the raw hooks: and calls: lines as…

See what a plugin or mod can do before you install it.
A Claude Code mod. Mods run with your permissions: they can read your files, start programs, reach the internet and spend your plan. /preflight reads a plugin the way Claude Code will, with claude plugin validate --json, and turns its hooks: and calls: lines into plain language:
/preflight owner/some-mod
Can start programs, reach the internet and read environment variables.
some-mod 0.3.1 by Someone
! Can start programs on your machine, with your permissions (from: $.process.run)
! Can send data to and fetch from the internet (from: $.http.fetch)
! Can read your environment variables, where API keys often live (from: $.env.get)
~ Sees every tool call and can change or refuse it (from: tool.call)
- Draws in the interface (from: ui.render, $.ui.resolve)
./register.ts hooks: session.start, tool.call, ui.render{component=Pane}
./register.ts calls: $.env.get, $.http.fetch, $.process.run, $.ui.resolve
A list of powers, not a verdict. Read the code before you install: mods run with your permissions.
The same report opens in a pane with c Copy install command and x Close. Nothing is installed.
It takes a local folder, a GitHub owner/repo, or a git URL. A repo is cloned shallow into /tmp/install-preflight/ and reused next time. Point it at a marketplace repo and it lists the plugins so you can preflight one.
!, ~ and - mean! high: starts programs, reaches the internet, reads environment variables, writes files, approves or refuses tool calls before you are asked, refuses or alters other mods, or runs shell commands from settings hooks.~ medium: sees or changes tool calls, prompts or the system prompt, reads files or your transcript, spends your plan on model calls, messages other sessions, starts subagents.- info: draws in the interface, adds a command or tool, keeps state, asks you questions.The raw hooks: and calls: lines underneath are what Claude Code read from the code and are the authority; the rows above only explain them. When claude plugin validate itself reports the plugin as failed, the answer leads with "Validation failed" and its errors instead of a verdict, because a failed scan may be missing hooks or calls. Static reading cannot tell you what the code does with a power, only that it has it. Read the code.
claude --plugin-dir ./install-preflight
Or from a marketplace once it is published there:
/plugin install install-preflight@hov
claude plugin validate ./install-preflight prints the authority:
hooks: session.start, command.run{command=preflight}, ui.render{component=Pane}
calls: $.command.register, $.fs.exists, $.fs.read, $.process.run, $.state.get, $.state.set, $.ui.close, $.ui.copy, $.ui.open, $.ui.resolve, $.ui.toast
It runs git clone --depth 1 for a repo you name and claude plugin validate --json on the folder. It reads that plugin's plugin.json and hooks.json and nothing else, makes no network calls of its own, and calls no model.
A list of powers, not a verdict. It reads the code statically, the same way claude plugin validate does, so a mod that hides a call behind indirection may show fewer powers than it has; the hooks: and calls: lines are only as good as that scan. It complements harness-vet, which vets skills.
claude plugin validate ./install-preflight
claude plugin test ./install-preflight
MIT. StartupBros / House of Vibe.
hooks/register.tsx 335 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Report } from '../types'
5import { FOOTER, glyphOf, parseValidate, powersOf, rawLinesOf, summaryText, verdictOf } from './explain'
6
7/**
8 * install-preflight: /preflight <folder | owner/repo | git url>.
9 *
10 * Runs `claude plugin validate --json` on the target, turns the hooks: and
11 * calls: lines into a plain list of powers, and shows it in a pane and as the
12 * command's answer. It lists powers, it never judges: you stay in control.
13 */
14
15const PANE = 'preflight'
16const report = atom({ plugin: 'install-preflight', key: 'report' } as const, null as Report | null)
17
18const USAGE =
19 'Preflight shows, in plain language, what a plugin or mod can do before you install it. ' +
20 'Give it a local folder (/preflight ./my-plugin), a GitHub repo (/preflight owner/repo) or a git url ' +
21 '(/preflight https://github.com/owner/repo.git). It runs claude plugin validate and lists the powers ' +
22 'it finds, with the raw hooks: and calls: lines underneath. Nothing is installed.'
23
24const REPO_FORM = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
25
26function firstLine(text: string): string {
27 return (text.split('\n').find(line => line.trim().length > 0) ?? '').trim()
28}
29
30function baseName(dir: string): string {
31 const parts = dir.split('/').filter(Boolean)
32 return parts[parts.length - 1] ?? dir
33}
34
35function isRecord(value: unknown): value is Record<string, unknown> {
36 return typeof value === 'object' && value !== null && !Array.isArray(value)
37}
38
39function parseJson(text: string): unknown {
40 try {
41 return JSON.parse(text)
42 } catch {
43 return undefined
44 }
45}
46
47/** FNV-1a of the url, so two urls that differ only in punctuation never share a clone directory. */
48function fnv1a(text: string): string {
49 let h = 0x811c9dc5
50 for (let i = 0; i < text.length; i += 1) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193) >>> 0
51 return h.toString(16).padStart(8, '0')
52}
53
54/** Where a repository url is cloned for preflight: a readable tail of the url plus a digest of all of it. */
55export function cloneDirOf(url: string): string {
56 const tail = url.replace(/[^A-Za-z0-9_-]/g, '_').slice(-48)
57 return `/tmp/install-preflight/${tail}-${fnv1a(url)}`
58}
59
60/**
61 * Moves an existing shallow clone of `url` to the remote's current head; false
62 * when the clone is of another repository or either step fails.
63 */
64async function refreshClone($: EngineInterface, dir: string, url: string): Promise<boolean> {
65 try {
66 const origin = await $.process.run(['git', '-C', dir, 'remote', 'get-url', 'origin'], { timeoutMs: 10000 })
67 if (origin.exitCode !== 0 || origin.stdout.trim() !== url) return false
68 const fetched = await $.process.run(['git', '-C', dir, 'fetch', '--depth', '1', '--quiet', 'origin'], {
69 timeoutMs: 60000,
70 })
71 if (fetched.exitCode !== 0) return false
72 const reset = await $.process.run(['git', '-C', dir, 'reset', '--hard', '--quiet', 'FETCH_HEAD'], {
73 timeoutMs: 30000,
74 })
75 return reset.exitCode === 0
76 } catch {
77 return false
78 }
79}
80
81function authorOf(value: unknown): string {
82 if (typeof value === 'string' && value.trim()) return value.trim()
83 if (isRecord(value) && typeof value.name === 'string' && value.name.trim()) return value.name.trim()
84 return 'unknown author'
85}
86
87export const register: Register = on => {
88 on('session.start', async ($, e, next) => {
89 try {
90 await $.command.register({
91 name: 'preflight',
92 description: 'Show what a plugin or mod can do before you install it',
93 argumentHint: '<folder | owner/repo | git url>',
94 })
95 } catch {
96 // the name is taken; /preflight is simply not offered
97 }
98 return next(e)
99 })
100
101 on('command.run', { command: 'preflight' }, async ($, e) => {
102 const arg = e.args.trim()
103 if (!arg) return { text: USAGE }
104
105 let dir = arg
106 let source: Report['source'] = 'folder'
107 let url: string | null = null
108 let note = ''
109
110 if (!(await $.fs.exists(arg))) {
111 if (REPO_FORM.test(arg)) url = `https://github.com/${arg}.git`
112 else if (/^(https?:\/\/|git@)/.test(arg)) url = arg
113 else return { text: `Could not find ${arg}. ${USAGE}` }
114
115 source = 'repo'
116 dir = cloneDirOf(url)
117 let needsClone = true
118 if (await $.fs.exists(dir)) {
119 // A clone from an earlier run may be stale, half-written, or of another repository: bring it
120 // to the current head, and when that fails leave it alone and clone afresh beside it.
121 if (await refreshClone($, dir, url)) {
122 note = 'Refreshed the clone fetched earlier.\n\n'
123 needsClone = false
124 } else {
125 dir = `${dir}-${await $.clock.now()}`
126 }
127 }
128 if (needsClone) {
129 try {
130 const cloned = await $.process.run(
131 ['git', 'clone', '--depth', '1', '--quiet', url, dir],
132 { timeoutMs: 60000 },
133 )
134 if (cloned.exitCode !== 0) {
135 return { text: `Could not fetch ${url}: ${firstLine(cloned.stderr) || 'git clone failed'}` }
136 }
137 } catch (err) {
138 return { text: `Could not fetch ${url}: ${err instanceof Error ? err.message : 'git did not run'}` }
139 }
140 }
141 }
142
143 const manifestPath = `${dir}/.claude-plugin/plugin.json`
144 const marketPath = `${dir}/.claude-plugin/marketplace.json`
145 if ((await $.fs.exists(marketPath)) && !(await $.fs.exists(manifestPath))) {
146 let names: string[] = []
147 try {
148 const market = parseJson(await $.fs.read(marketPath))
149 if (isRecord(market) && Array.isArray(market.plugins)) {
150 names = market.plugins
151 .map(p => (isRecord(p) && typeof p.name === 'string' ? p.name : ''))
152 .filter(Boolean)
153 }
154 } catch {
155 // an unreadable marketplace file reads as an empty list
156 }
157 return {
158 text:
159 `This is a marketplace with ${names.length} plugin${names.length === 1 ? '' : 's'}: ` +
160 `${names.join(', ') || 'none listed'}. Run /preflight on one plugin folder or repo.`,
161 }
162 }
163
164 let stdout = ''
165 let stderr = ''
166 try {
167 const ran = await $.process.run(['claude', 'plugin', 'validate', '--json', dir], { timeoutMs: 30000 })
168 stdout = ran.stdout
169 stderr = ran.stderr
170 } catch (err) {
171 stderr = err instanceof Error ? err.message : 'claude did not run'
172 }
173 const parsed = parseValidate(stdout)
174 if (!parsed) {
175 return {
176 text:
177 `Could not read the report from claude plugin validate for ${dir}: ` +
178 `${firstLine(stdout) || firstLine(stderr) || 'it printed nothing'}`,
179 }
180 }
181
182 let manifest: unknown
183 try {
184 manifest = parseJson(await $.fs.read(manifestPath))
185 } catch {
186 manifest = undefined
187 }
188 let settingsHooks = false
189 try {
190 const hooksFile = parseJson(await $.fs.read(`${dir}/hooks/hooks.json`))
191 settingsHooks = isRecord(hooksFile) && 'hooks' in hooksFile
192 } catch {
193 // no hooks.json: no settings hooks
194 }
195 const meta = isRecord(manifest) ? manifest : {}
196 const name = typeof meta.name === 'string' && meta.name ? meta.name : baseName(dir)
197 const version = typeof meta.version === 'string' && meta.version ? meta.version : 'no version'
198 const author = authorOf(meta.author)
199
200 const powers = powersOf(parsed, { settingsHooks, mcpServers: 'mcpServers' in meta })
201 const rawLines = rawLinesOf(parsed)
202 const all = [parsed.manifest, ...parsed.contents]
203 const errorLines = all.flatMap(s => s.errors.map(i => i.message))
204 // A report that validate itself marked as failed may be missing hooks or calls: never read it as clean.
205 const verdict = parsed.success
206 ? verdictOf(powers)
207 : `Validation failed (${errorLines.length} error${errorLines.length === 1 ? '' : 's'}): ` +
208 `${errorLines[0] ?? 'see the raw lines'}. The powers below may be incomplete.`
209 const installCommand =
210 source === 'folder'
211 ? `claude --plugin-dir ${dir}`
212 : `/plugin marketplace add ${arg} then /plugin install ${name}@${name} (edit the marketplace name)`
213
214 await update($, report, () => ({
215 dir,
216 source,
217 url,
218 name,
219 version,
220 author,
221 verdict,
222 powers,
223 rawLines,
224 errors: errorLines,
225 warnings: all.flatMap(s => s.warnings.map(i => i.message)),
226 installCommand,
227 }))
228 try {
229 await $.ui.open({ id: PANE, title: 'Preflight', focus: true, closeOnEscape: true })
230 } catch {
231 // headless or no surface: the text below is the answer
232 }
233
234 const lines = summaryText({ name, version, author }, powers, rawLines, verdict).split('\n')
235 if (!parsed.success) lines.splice(1, 0, ...errorLines.map(l => `error: ${l}`))
236 return { text: note + lines.join('\n') }
237 })
238
239 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
240 if (e.requestId !== PANE) return next(e)
241
242 const { Box, Text, Button } = $.ui.resolve(e)
243 const r = await read($, report)
244 if (!r) return <Text dimColor>Run /preflight {'<folder | owner/repo | git url>'} first.</Text>
245
246 const cols = Math.max(20, e.props.bodyColumns)
247 const color = r.powers.some(p => p.level === 'high')
248 ? 'red'
249 : r.powers.some(p => p.level === 'medium')
250 ? 'yellow'
251 : undefined
252
253 const rows = [
254 <Text key="name" bold>
255 {r.name} {r.version} by {r.author}
256 </Text>,
257 <Text key="verdict" color={color} dimColor={color === undefined}>
258 {r.verdict}
259 </Text>,
260 <Text key="gap1"> </Text>,
261 ]
262 r.powers.forEach((p, i) => {
263 const glyphColor = p.level === 'high' ? 'red' : p.level === 'medium' ? 'yellow' : undefined
264 const room = Math.max(0, cols - p.text.length - 4)
265 const cells = [
266 <Text
267 key="glyph"
268 color={glyphColor}
269 dimColor={glyphColor === undefined}
270 bold={glyphColor !== undefined}
271 >
272 {glyphOf(p.level)}
273 </Text>,
274 <Text key="text">{p.text}</Text>,
275 ]
276 if (room > 12) {
277 cells.push(
278 <Text key="from" dimColor wrap="truncate">
279 {`(from: ${p.from.join(', ')})`}
280 </Text>,
281 )
282 }
283 rows.push(
284 <Box key={`power-${i}`} flexDirection="row" columnGap={1}>
285 {cells}
286 </Box>,
287 )
288 })
289 rows.push(<Text key="gap2"> </Text>)
290 r.rawLines.forEach((line, i) =>
291 rows.push(
292 <Text key={`raw-${i}`} dimColor wrap="wrap">
293 {line}
294 </Text>,
295 ),
296 )
297 r.errors.forEach((line, i) =>
298 rows.push(
299 <Text key={`err-${i}`} color="red" wrap="wrap">
300 {`error: ${line}`}
301 </Text>,
302 ),
303 )
304 r.warnings.forEach((line, i) =>
305 rows.push(
306 <Text key={`warn-${i}`} color="yellow" wrap="wrap">
307 {`warning: ${line}`}
308 </Text>,
309 ),
310 )
311 rows.push(
312 <Box key="buttons" flexDirection="row" columnGap={2} marginTop={1}>
313 <Button
314 key="copy"
315 label="Copy install command"
316 hotkey="c"
317 onPress={() => {
318 void $.ui
319 .copy({ text: r.installCommand, surface: e.surface })
320 .then(() => $.ui.toast('copied'))
321 }}
322 />
323 <Button key="close" label="Close" hotkey="x" onPress={() => void $.ui.close({ id: PANE })} />
324 </Box>,
325 )
326 rows.push(
327 <Text key="footer" dimColor wrap="wrap">
328 {FOOTER}
329 </Text>,
330 )
331
332 return <Box flexDirection="column">{rows}</Box>
333 })
334}
335hooks/explain.ts 279 lines1import type { Power } from '../types'
2
3/**
4 * Pure half of install-preflight: read the output of `claude plugin validate
5 * --json` and say, in plain language, what the plugin can do. No `$` in here.
6 * The raw `hooks:` and `calls:` lines stay the authority; this only explains them.
7 */
8
9export type ValidateIssue = { path?: string; message: string; code?: string }
10
11export type ValidateSection = {
12 file?: string
13 type?: string
14 errors: ValidateIssue[]
15 warnings: ValidateIssue[]
16 notes: string[]
17}
18
19export type ValidateReport = {
20 success: boolean
21 manifest: ValidateSection
22 contents: ValidateSection[]
23}
24
25/** What the register finds by reading hooks.json and plugin.json itself. */
26export type Extras = { settingsHooks: boolean; mcpServers: boolean }
27
28export type Meta = { name: string; version: string; author: string }
29
30export const FOOTER =
31 'A list of powers, not a verdict. Read the code before you install: mods run with your permissions.'
32
33function isRecord(value: unknown): value is Record<string, unknown> {
34 return typeof value === 'object' && value !== null && !Array.isArray(value)
35}
36
37function issuesOf(value: unknown): ValidateIssue[] {
38 if (!Array.isArray(value)) return []
39 const out: ValidateIssue[] = []
40 for (const one of value) {
41 if (typeof one === 'string') out.push({ message: one })
42 else if (isRecord(one) && typeof one.message === 'string') {
43 out.push({
44 message: one.message,
45 path: typeof one.path === 'string' ? one.path : undefined,
46 code: typeof one.code === 'string' ? one.code : undefined,
47 })
48 }
49 }
50 return out
51}
52
53function sectionOf(value: unknown): ValidateSection | null {
54 if (!isRecord(value)) return null
55 const notes = Array.isArray(value.notes) ? value.notes.filter((n): n is string => typeof n === 'string') : []
56 return {
57 file: typeof value.file === 'string' ? value.file : undefined,
58 type: typeof value.type === 'string' ? value.type : undefined,
59 errors: issuesOf(value.errors),
60 warnings: issuesOf(value.warnings),
61 notes,
62 }
63}
64
65/** The report, or null when `json` is not the shape `claude plugin validate --json` prints. */
66export function parseValidate(json: string): ValidateReport | null {
67 let data: unknown
68 try {
69 data = JSON.parse(json)
70 } catch {
71 return null
72 }
73 if (!isRecord(data) || !Array.isArray(data.contents)) return null
74 const manifest = sectionOf(data.manifest)
75 if (!manifest) return null
76 const contents: ValidateSection[] = []
77 for (const one of data.contents) {
78 const section = sectionOf(one)
79 if (section) contents.push(section)
80 }
81 return { success: data.success === true, manifest, contents }
82}
83
84export type Listing = { module: string; kind: 'hooks' | 'calls'; names: string[]; line: string }
85
86/** Split on commas that are not inside matcher braces, then drop the braces. */
87function splitNames(list: string): string[] {
88 const names: string[] = []
89 let depth = 0
90 let current = ''
91 for (const ch of list) {
92 if (ch === '{') depth += 1
93 if (ch === '}') depth = Math.max(0, depth - 1)
94 if (ch === ',' && depth === 0) {
95 names.push(current)
96 current = ''
97 } else current += ch
98 }
99 names.push(current)
100 return names
101 .map(n => {
102 const brace = n.indexOf('{')
103 return (brace === -1 ? n : n.slice(0, brace)).trim()
104 })
105 .filter(n => n.length > 0)
106}
107
108/** Every `<module> hooks: ...` and `<module> calls: ...` note, in the order validate printed them. */
109export function listingsOf(report: ValidateReport): Listing[] {
110 const out: Listing[] = []
111 for (const section of report.contents) {
112 for (const line of section.notes) {
113 for (const kind of ['hooks', 'calls'] as const) {
114 const marker = ` ${kind}: `
115 const at = line.indexOf(marker)
116 if (at <= 0) continue
117 const module = line.slice(0, at).trim()
118 const names = splitNames(line.slice(at + marker.length)).map(n => (n.startsWith('$.') ? n.slice(2) : n))
119 out.push({ module, kind, names, line })
120 break
121 }
122 }
123 }
124 return out
125}
126
127/** The raw lines, verbatim, that the pane and the transcript print as the authority. */
128export function rawLinesOf(report: ValidateReport): string[] {
129 return listingsOf(report).map(l => l.line)
130}
131
132type Rule = { level: Power['level']; text: string }
133
134const FS_READ = new Set(['fs.read', 'fs.list', 'fs.stat', 'fs.exists', 'fs.ancestors'])
135
136function callRule(name: string): Rule {
137 const family = name.split('.')[0] ?? name
138 if (name === 'process.run' || name === 'process.spawn')
139 return { level: 'high', text: 'Can start programs on your machine, with your permissions' }
140 if (name === 'http.fetch') return { level: 'high', text: 'Can send data to and fetch from the internet' }
141 if (name === 'env.get' || name === 'env.set')
142 return { level: 'high', text: 'Can read your environment variables, where API keys often live' }
143 if (FS_READ.has(name)) return { level: 'medium', text: 'Can read files anywhere your account can' }
144 if (family === 'fs') return { level: 'high', text: 'Can create or overwrite files anywhere your account can' }
145 if (name === 'model.complete' || name === 'model.fork' || name === 'model.classify')
146 return { level: 'medium', text: "Can spend your plan's usage on model calls of its own" }
147 if (name === 'prompt.submit') return { level: 'medium', text: 'Can start a turn as if you had typed it' }
148 if (name === 'prompt.fill' || name === 'prompt.suggest' || name === 'prompt.compose')
149 return { level: 'medium', text: 'Can change what goes into your prompt box' }
150 if (name === 'session.send') return { level: 'medium', text: 'Can message your other sessions and subagents' }
151 if (name === 'session.messages') return { level: 'medium', text: "Can read this session's whole transcript" }
152 if (name === 'settings.read') return { level: 'medium', text: 'Can read your settings files' }
153 if (name === 'mcp.call' || name === 'mcp.connect') return { level: 'medium', text: 'Can call MCP servers' }
154 if (name === 'tool.register') return { level: 'info', text: 'Adds a tool Claude can call' }
155 if (name === 'command.register') return { level: 'info', text: 'Adds a /command' }
156 if (name === 'agent.register' || name === 'agent.spawn') return { level: 'medium', text: 'Can start subagents' }
157 if (name === 'turn.abort') return { level: 'info', text: 'Can stop a running turn' }
158 if (family === 'store') return { level: 'info', text: 'Keeps data between sessions' }
159 if (family === 'state') return { level: 'info', text: 'Keeps data for the session' }
160 if (name === 'ui.ask') return { level: 'info', text: 'Can ask you questions in the dialog' }
161 if (family === 'ui') return { level: 'info', text: 'Draws in the interface' }
162 if (family === 'audio') return { level: 'info', text: 'Plays sound' }
163 if (family === 'telemetry') return { level: 'info', text: 'Logs usage records' }
164 if (family === 'clock') return { level: 'info', text: 'Runs work on a timer' }
165 return { level: 'info', text: `Calls $.${name}` }
166}
167
168const READS_CLAUDE = new Set([
169 'prompt.submit',
170 'prompt.compose',
171 'prompt.section',
172 'prompt.context',
173 'prompt.attachment',
174 'skill.prompt',
175])
176
177function hookRule(name: string): Rule {
178 const family = name.split('.')[0] ?? name
179 if (name === 'tool.check') return { level: 'high', text: 'Can approve or refuse tool calls before you are asked' }
180 if (name === 'tool.call') return { level: 'medium', text: 'Sees every tool call and can change or refuse it' }
181 if (READS_CLAUDE.has(name))
182 return { level: 'medium', text: 'Can change what Claude reads: your prompts, the system prompt or reminders' }
183 if (name === 'session.append') return { level: 'medium', text: 'Can rewrite what is saved in your transcript' }
184 if (name === 'session.receive' || name === 'session.send')
185 return { level: 'medium', text: 'Sees messages between your sessions' }
186 if (name === 'agent.spawn' || name === 'agent.offer')
187 return { level: 'medium', text: 'Can change which subagents run and with which model' }
188 if (name === 'turn.step') return { level: 'medium', text: 'Can change which model answers each request' }
189 if (name === 'plugin.register' || name === 'engine.create')
190 return { level: 'high', text: 'Can refuse or alter other mods' }
191 if (name === 'ui.render') return { level: 'info', text: 'Draws in the interface' }
192 if (name === 'command.run') return { level: 'info', text: 'Answers a /command' }
193 if (
194 name === 'session.start' ||
195 name === 'session.end' ||
196 name === 'turn.start' ||
197 name === 'turn.complete' ||
198 name === 'session.measure'
199 )
200 return { level: 'info', text: 'Watches the session' }
201 if (family === 'classic') return { level: 'info', text: 'Also reacts to settings-hook events' }
202 if (family === 'fs' || family === 'process' || family === 'http' || family === 'model')
203 return { level: 'high', text: 'Can see or change what other mods read, run or fetch' }
204 return { level: 'info', text: `Hooks ${name}` }
205}
206
207const ORDER: Record<Power['level'], number> = { high: 0, medium: 1, info: 2 }
208
209/** Within a level, machine access reads first: the order of the SHORT table below, unknown rows last. */
210function rankOf(power: Power): number {
211 const at = Object.keys(SHORT).indexOf(power.text)
212 return at === -1 ? Object.keys(SHORT).length : at
213}
214
215/** Plain-language powers, duplicates merged into one row that lists every source. */
216export function powersOf(report: ValidateReport, extras: Extras): Power[] {
217 const byText = new Map<string, Power>()
218 const add = (rule: Rule, from: string) => {
219 const known = byText.get(rule.text)
220 if (!known) byText.set(rule.text, { level: rule.level, text: rule.text, from: [from] })
221 else if (!known.from.includes(from)) known.from.push(from)
222 }
223 for (const listing of listingsOf(report)) {
224 for (const name of listing.names) {
225 if (listing.kind === 'calls') add(callRule(name), `$.${name}`)
226 else add(hookRule(name), name)
227 }
228 }
229 if (extras.settingsHooks)
230 add({ level: 'high', text: 'Runs shell commands on events (settings hooks in hooks.json)' }, 'hooks.json')
231 if (extras.mcpServers) add({ level: 'medium', text: 'Starts an MCP server of its own' }, 'plugin.json')
232 return [...byText.values()].sort((a, b) => ORDER[a.level] - ORDER[b.level] || rankOf(a) - rankOf(b))
233}
234
235const SHORT: Record<string, string> = {
236 'Can start programs on your machine, with your permissions': 'start programs',
237 'Can send data to and fetch from the internet': 'reach the internet',
238 'Can read your environment variables, where API keys often live': 'read environment variables',
239 'Can create or overwrite files anywhere your account can': 'create or overwrite files',
240 'Can approve or refuse tool calls before you are asked': 'approve or refuse tool calls before you are asked',
241 'Can refuse or alter other mods': 'refuse or alter other mods',
242 'Can see or change what other mods read, run or fetch': 'see or change what other mods read, run or fetch',
243 'Runs shell commands on events (settings hooks in hooks.json)': 'run shell commands on events',
244}
245
246function joinAnd(items: string[]): string {
247 if (items.length <= 1) return items.join('')
248 return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
249}
250
251/** One sentence about the high rows. Never says "safe": no high rows is not a promise. */
252export function verdictOf(powers: Power[]): string {
253 const high = powers.filter(p => p.level === 'high')
254 if (high.length === 0) return 'No high-risk powers found.'
255 const phrases = high.map(p => SHORT[p.text] ?? p.text.replace(/^Can /, '').replace(/^./, c => c.toLowerCase()))
256 return `Can ${joinAnd(phrases)}.`
257}
258
259const GLYPH: Record<Power['level'], string> = { high: '!', medium: '~', info: '-' }
260
261export function glyphOf(level: Power['level']): string {
262 return GLYPH[level]
263}
264
265/**
266 * The plain-text answer for the transcript, headless runs and VS Code. The
267 * first line is the verdict; a caller passes its own when validate itself
268 * failed, so a failed scan is never summarised as clean.
269 */
270export function summaryText(meta: Meta, powers: Power[], raw: string[], verdict = verdictOf(powers)): string {
271 const lines = [verdict, `${meta.name} ${meta.version} by ${meta.author}`, '']
272 for (const p of powers) {
273 lines.push(`${GLYPH[p.level]} ${p.text} (from: ${p.from.join(', ')})`)
274 }
275 if (raw.length > 0) lines.push('', ...raw)
276 lines.push('', FOOTER)
277 return lines.join('\n')
278}
279types/index.d.ts 33 lines1/** One thing a plugin can do, in plain language, and where we saw it. */
2export type Power = {
3 level: 'high' | 'medium' | 'info'
4 text: string
5 /** The hooks and calls this power was read from, for example `$.process.run`. */
6 from: string[]
7}
8
9/** Everything the pane and the transcript answer show for one preflight. */
10export type Report = {
11 dir: string
12 source: 'folder' | 'repo'
13 url: string | null
14 name: string
15 version: string
16 author: string
17 verdict: string
18 powers: Power[]
19 rawLines: string[]
20 errors: string[]
21 warnings: string[]
22 installCommand: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'install-preflight': {
28 /** The last preflight, or null before the first /preflight. */
29 report: Report | null
30 }
31 }
32}
33