SLOPSHOPPER

install-preflight

/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…

newpanecommandtoastprocess
★ 1v0.1.0MITupdated 2026-10-26StartupBros-com/hov-stack/mods/install-preflight
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · install-preflight
│ ┃ preflight ✕ › fix the failing auth test and add an audit log call │ ┃ Run /preflight <folder | owner/repo | git │ ┃ url> first. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /preflight │ ⎿ install-preflight: Preflight shows, in plain language, what a pl │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · preflight
Run /preflight <folder | owner/repo | git url> first.
README

install-preflight

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.

What !, ~ 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.

Install

claude --plugin-dir ./install-preflight

Or from a marketplace once it is published there:

/plugin install install-preflight@hov

What it can touch

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.

Limits

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.

Develop

claude plugin validate ./install-preflight
claude plugin test ./install-preflight

MIT. StartupBros / House of Vibe.

Source 3 files
hooks/register.tsx 335 lines
1import { 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}
335
hooks/explain.ts 279 lines
1import 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}
279
types/index.d.ts 33 lines
1/** 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