Shows what your coding agent does in plain English, in place of Claude Code's raw tool rows.

https://github.com/user-attachments/assets/e5145395-1287-4397-814b-cb42ea9760c7

A simple idea: make natural language the next coding language (everywhere).
codevibez replaces Claude Code's raw tool-call rows with plain English. Failures, deletions and other risky steps are never softened. Type /codevibez to switch to the raw rows. It makes no model calls and no network requests.
Claude Code shows this:
⏺ Bash(node --test)
⎿ TAP version 13
# Subtest: add
ok 1 - add
… +25 lines (ctrl+o to expand)
⏺ Bash(rm -rf build)
⎿ Done
With codevibez you see this:
→ Checking everything still works…
✓ All checks pass (3 checks)
⚠ Deleting 'build' and everything inside it
More lines from real sessions:
✗ 2 of 3 checks failed
⚠ Deleting 'docs' and everything inside it
⚠ Throwing away every unsaved edit in the project
→ Saving a checkpoint of the work (+1 more step)
✗ Not run: you said no
⏺ codevibez: ✗ This turn: changed 1 file, ran 5 commands (1 failed), all 3 checks passed, 1 destructive step.
Needs Claude Code 2.1.287 or later. In your terminal:
claude plugin install codevibez --marketplace leonhong18/codevibez
To uninstall:
claude plugin uninstall codevibez
Found a bug? Open an issue at https://github.com/leonhong18/codevibez/issues with what ran and what you expected it to say.
hooks/register.tsx 347 lines1import type { EngineInterface, Register, RenderElement, ResolveInput } from 'claude-code'
2
3import type { Chapter, View } from '../types'
4import { createLedger, observe, summarize } from './ledger.ts'
5import { describeCall, describeGroup, isKnownTool } from './narrate.ts'
6import type { CallView, Narration, Sentence, Tone } from './narrate.ts'
7import { addLines, fitNewest, linesOf, startChapter, storyRows } from './story.ts'
8
9/**
10 * codevibez: plain English in place of Claude Code's raw tool rows.
11 *
12 * Observe only. Every hook here either draws a row or passes the event on
13 * untouched; none of them refuses, rewrites, retries or delays a tool call,
14 * a prompt or a permission decision. Anything unexpected (a tool it does not
15 * know, a row of another shape, a throw) leaves Claude Code's own row.
16 */
17
18const VIEW = { plugin: 'codevibez', key: 'view' } as const
19const STORY = { plugin: 'codevibez', key: 'story' } as const
20const MEMO_LIMIT = 500
21const STORY_SAVE_MS = 1000
22
23const ENGLISH_ON =
24 "English view is on. Type /codevibez to see Claude Code's own rows."
25const RAW_ON =
26 'Raw view is on, showing exactly what ran in Claude Code\'s own rows. Type /codevibez to go back to English.'
27const USAGE =
28 'Use /codevibez to switch views, /codevibez english or /codevibez raw to pick one, or /codevibez story to read the session so far.'
29
30type Look = { color?: string; bold?: boolean; dimColor?: boolean }
31
32// Failures and destructive steps are bold and colored; routine reading is dim.
33const LOOK: Readonly<Record<Tone, Look>> = {
34 info: {},
35 read: { dimColor: true },
36 success: { color: 'success' },
37 failure: { color: 'error', bold: true },
38 danger: { color: 'warning', bold: true },
39 stopped: { color: 'warning' },
40}
41
42async function currentView($: EngineInterface): Promise<View> {
43 const { value } = await $.state.get(VIEW)
44 return value === 'raw' ? 'raw' : 'english'
45}
46
47// The story pane reads the session's state; writing it never holds anything up.
48function saveStory($: EngineInterface, story: Chapter[]): void {
49 $.state.set(STORY, story).catch(() => undefined)
50}
51
52function isRecord(value: unknown): value is Record<string, unknown> {
53 return typeof value === 'object' && value !== null && !Array.isArray(value)
54}
55
56/** A cheap signature of what the translation reads from a call's input. */
57function signature(input: unknown): string {
58 if (!isRecord(input)) return typeof input
59 const field = (name: string): string => {
60 const value = input[name]
61 return typeof value === 'string' ? value : ''
62 }
63 // String lengths, not JSON: a Write carries the whole file, and stringifying
64 // a large one on every draw costs milliseconds.
65 let length = 0
66 for (const value of Object.values(input)) length += typeof value === 'string' ? value.length : 1
67 return [Object.keys(input).length, length, field('command'), field('description'), field('file_path')].join('|')
68}
69
70function asCallView(value: unknown): CallView | null {
71 if (!isRecord(value) || typeof value.tool !== 'string') return null
72 return {
73 tool: value.tool,
74 input: value.input,
75 isRunning: value.isRunning === true,
76 isErrored: value.isErrored === true,
77 isInterrupted: value.isInterrupted === true,
78 output: value.output,
79 }
80}
81
82function draw(
83 $: EngineInterface,
84 e: ResolveInput,
85 lines: readonly Narration[],
86 isRunning: boolean,
87): RenderElement {
88 const { Box, Text } = $.ui.resolve(e)
89 const row = (line: Sentence, key: string, indent: boolean) => {
90 const words = isRunning && !line.text.endsWith('…') ? `${line.text}…` : line.text
91 return (
92 <Box key={key} paddingLeft={indent ? 2 : 0}>
93 <Text {...LOOK[line.tone]}>
94 {line.glyph} {words}
95 </Text>
96 </Box>
97 )
98 }
99 return (
100 <Box flexDirection="column">
101 {lines.flatMap((narration, index) => [
102 row(narration.action, `a${index}`, index > 0),
103 narration.outcome === undefined ? null : row(narration.outcome, `o${index}`, true),
104 ])}
105 </Box>
106 )
107}
108
109export const register: Register = on => {
110 // Narration state lives in memory for the session and is never written down.
111 let ledger = createLedger()
112 let story: Chapter[] = []
113 // The story reaches the session's state only once its pane has been opened,
114 // and then at most once a second while a turn runs, and at the turn's end.
115 let storyShown = false
116 let lastSave = 0
117 const storyDue = (force: boolean): boolean => {
118 if (!storyShown) return false
119 const now = Date.now()
120 if (!force && now - lastSave < STORY_SAVE_MS) return false
121 lastSave = now
122 return true
123 }
124 const memo = new Map<string, Narration>()
125 // Whether each call's row was drawn in English, so its result block below
126 // follows the same choice.
127 const drawnInEnglish = new Map<string, boolean>()
128
129 const narrate = (id: string, view: CallView): Narration | null => {
130 // Claude Code draws a row first with an empty input, then again once the
131 // input has arrived, so what the translation reads is part of the key, and
132 // a row that could not be read yet is never cached.
133 const key = `${id}|${view.isRunning}|${view.isErrored}|${view.isInterrupted}|${view.output === undefined}|${signature(view.input)}`
134 const cached = memo.get(key)
135 if (cached !== undefined) return cached
136 const narration = describeCall(view)
137 if (drawnInEnglish.size >= MEMO_LIMIT) {
138 const oldest = drawnInEnglish.keys().next().value
139 if (oldest !== undefined) drawnInEnglish.delete(oldest)
140 }
141 if (narration === null) {
142 // An input still streaming in says nothing about the finished row.
143 if (isRecord(view.input) && Object.keys(view.input).length > 0) drawnInEnglish.set(id, false)
144 return null
145 }
146 if (memo.size >= MEMO_LIMIT) {
147 const oldest = memo.keys().next().value
148 if (oldest !== undefined) memo.delete(oldest)
149 }
150 memo.set(key, narration)
151 drawnInEnglish.set(id, true)
152 return narration
153 }
154
155 on('session.start', async ($, e, next) => {
156 try {
157 await $.command.register({
158 name: 'codevibez',
159 description: "Switch between plain English and Claude Code's own tool rows",
160 argumentHint: '[english|raw|story]',
161 immediate: true,
162 })
163 } catch {
164 // Without the command the English still draws; nothing to interrupt.
165 }
166 return next(e)
167 })
168
169 on('command.run', { command: 'codevibez' }, async ($, e) => {
170 const asked = e.args.trim().toLowerCase()
171 if (asked === 'story') {
172 storyShown = true
173 lastSave = Date.now()
174 saveStory($, story)
175 const opened = await $.ui.open({ id: 'codevibez-story', title: 'The story so far', focus: true, closeOnEscape: true })
176 return {
177 text: opened.isPlaced
178 ? 'The story of this session is open in a pane. Esc closes it.'
179 : `The story pane could not open here (${opened.reason}).`,
180 }
181 }
182 const now = await currentView($)
183 const wanted: View | null =
184 asked === ''
185 ? now === 'raw' ? 'english' : 'raw'
186 : asked === 'raw'
187 ? 'raw'
188 : asked === 'english' || asked === 'plain'
189 ? 'english'
190 : null
191 if (wanted === null) return { text: USAGE }
192 if (wanted !== now) await $.state.set(VIEW, wanted)
193 return { text: wanted === 'raw' ? RAW_ON : ENGLISH_ON }
194 }).catch(() => ({ text: 'Could not switch views; nothing changed.' }))
195
196 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
197 let tree: RenderElement | undefined
198 try {
199 if ((await currentView($)) === 'english') {
200 const view = asCallView(e.props)
201 const narration = view === null ? null : narrate(e.props.tool_use_id, view)
202 if (view !== null && narration !== null) {
203 tree = draw($, e, [narration], view.isRunning)
204 }
205 }
206 } catch {
207 tree = undefined
208 }
209 return tree ?? next(e)
210 })
211
212 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
213 let tree: RenderElement | undefined
214 try {
215 if ((await currentView($)) === 'english') {
216 // The English row above already says what came of the call, failures
217 // included, so the raw result block under it is left out.
218 const known = drawnInEnglish.get(e.props.tool_use_id) ?? isKnownTool(e.props.tool)
219 if (known) {
220 const { Box } = $.ui.resolve(e)
221 tree = <Box />
222 }
223 }
224 } catch {
225 tree = undefined
226 }
227 return tree ?? next(e)
228 })
229
230 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
231 let tree: RenderElement | undefined
232 try {
233 if (!e.props.isExpanded && (await currentView($)) === 'english') {
234 const calls = e.props.calls.map(asCallView)
235 if (calls.every((call): call is CallView => call !== null)) {
236 const lines = describeGroup(calls)
237 if (lines !== null) tree = draw($, e, lines, false)
238 }
239 }
240 } catch {
241 tree = undefined
242 }
243 return tree ?? next(e)
244 })
245
246 on('ui.render', { component: 'Pane', requestId: 'codevibez-story' }, async ($, e) => {
247 const { Box, Text } = $.ui.resolve(e)
248 try {
249 const { value } = await $.state.get(STORY)
250 const rows = storyRows(value ?? [])
251 if (rows.length === 0) {
252 return <Text dimColor>Nothing has happened yet. Each prompt you type starts a chapter here.</Text>
253 }
254 // The pane opens short, so it shows the newest steps that fit.
255 const { shown, hidden } = fitNewest(rows, e.props.bodyColumns, e.props.scroll.bodyRows)
256 return (
257 <Box flexDirection="column">
258 {hidden > 0 && (
259 <Text key="hidden" dimColor>
260 {hidden} earlier {hidden === 1 ? 'line' : 'lines'} not shown; the newest are below
261 </Text>
262 )}
263 {shown.map((row, index) =>
264 row.kind === 'heading' ? (
265 <Box key={`h${index}`} marginTop={index === 0 ? 0 : 1}>
266 <Text bold>{row.text}</Text>
267 </Box>
268 ) : (
269 <Box key={`l${index}`} paddingLeft={2}>
270 <Text {...(row.kind === 'note' ? LOOK.read : LOOK[row.tone ?? 'info'])}>{row.text}</Text>
271 </Box>
272 ),
273 )}
274 </Box>
275 )
276 } catch {
277 return <Text dimColor>The story could not be drawn just now.</Text>
278 }
279 })
280
281 on('turn.start', ($, e, next) => {
282 ledger = createLedger()
283 try {
284 if (e.text.trim() !== '') {
285 story = startChapter(story, e.text)
286 if (storyDue(false)) saveStory($, story)
287 }
288 } catch {
289 // The story missing a chapter never holds up a prompt.
290 }
291 return next(e)
292 })
293
294 on('tool.call', async ($, e, next) => {
295 const ran = await next(e)
296 try {
297 const { tool, tool_use_id: _id, agentId, ...input } = e as Record<string, unknown>
298 const isError = ran.isError === true
299 const text = typeof ran.text === 'string' ? ran.text : ''
300 observe(ledger, {
301 tool: String(tool),
302 input,
303 isError,
304 isDenied: ran.deny !== undefined,
305 result: ran.result,
306 text,
307 })
308 // The story follows the main conversation; a helper's own steps stay
309 // inside the line that handed it the task.
310 if (agentId === undefined && ran.deny === undefined) {
311 const narration = describeCall({
312 tool: String(tool),
313 input,
314 isRunning: false,
315 isErrored: isError,
316 isInterrupted: false,
317 output: isError ? text || ran.result : ran.result,
318 })
319 if (narration !== null) {
320 story = addLines(story, linesOf(narration))
321 if (storyDue(false)) saveStory($, story)
322 }
323 }
324 } catch {
325 // A summary that misses one call beats a session that stalls on it.
326 }
327 return ran
328 }).catch((_$, e, next) => next(e))
329
330 on('turn.complete', async ($, e, next) => {
331 const ran = await next(e)
332 if (e.agentId !== undefined) return ran
333 try {
334 const summary = summarize(ledger)
335 if (summary !== null) {
336 if ((await currentView($)) === 'english') $.ui.log(`${summary.glyph} ${summary.text}`)
337 story = addLines(story, [summary], true)
338 }
339 if (storyDue(true)) saveStory($, story)
340 } catch {
341 // No summary line is better than a turn that cannot end.
342 }
343 ledger = createLedger()
344 return ran
345 })
346}
347hooks/ledger.ts 190 lines1import { detectDanger } from '../engine/src/bash.ts';
2import { isTestCommand } from '../engine/src/rules/bash-rules.ts';
3import { parseTestCounts } from '../engine/src/rules/outcome-rules.ts';
4import { counted, plural } from '../engine/src/words.ts';
5import { isDeclined, isHookBlocked } from './narrate.ts';
6
7/**
8 * What one turn changed, kept in memory only and counted exactly, so the line
9 * under the answer can say it in plain words. Nothing here touches the disk.
10 */
11export type Ledger = {
12 created: Set<string>;
13 changed: Set<string>;
14 deleted: Set<string>;
15 commands: number;
16 failedCommands: number;
17 failedSteps: number;
18 declined: number;
19 hookBlocked: number;
20 destructive: number;
21 tests: TestRun | null;
22};
23
24type TestRun =
25 | { kind: 'counted'; failed: number; passed: number }
26 | { kind: 'failed' }
27 | { kind: 'passed' };
28
29/** A finished call as `tool.call` saw it: the tool, its input, its result. */
30export type FinishedCall = {
31 tool: string;
32 input: Record<string, unknown>;
33 isError: boolean;
34 isDenied: boolean;
35 result: unknown;
36 text: string;
37};
38
39export function createLedger(): Ledger {
40 return {
41 created: new Set(),
42 changed: new Set(),
43 deleted: new Set(),
44 commands: 0,
45 failedCommands: 0,
46 failedSteps: 0,
47 declined: 0,
48 hookBlocked: 0,
49 destructive: 0,
50 tests: null,
51 };
52}
53
54function isRecord(value: unknown): value is Record<string, unknown> {
55 return typeof value === 'object' && value !== null && !Array.isArray(value);
56}
57
58function touched(ledger: Ledger, path: unknown): void {
59 if (typeof path !== 'string' || path === '') return;
60 if (!ledger.created.has(path)) ledger.changed.add(path);
61}
62
63function testRun(output: string, ok: boolean): TestRun {
64 const counts = parseTestCounts(output);
65 if (counts !== null) return { kind: 'counted', ...counts };
66 return ok ? { kind: 'passed' } : { kind: 'failed' };
67}
68
69/** Records one finished call. Never throws on a shape it does not expect. */
70export function observe(ledger: Ledger, call: FinishedCall): void {
71 if (call.isDenied) return;
72 const { tool, input, result } = call;
73 // A step the person said no to never ran: not a command, not a failure.
74 if (call.isError && isDeclined(call.text || (typeof result === 'string' ? result : ''))) {
75 ledger.declined += 1;
76 return;
77 }
78 // A step a settings hook stopped never ran either.
79 if (call.isError && isHookBlocked(call.text || (typeof result === 'string' ? result : ''))) {
80 ledger.hookBlocked += 1;
81 return;
82 }
83
84 if (tool === 'Bash') {
85 const command = typeof input.command === 'string' ? input.command : '';
86 ledger.commands += 1;
87 if (call.isError) ledger.failedCommands += 1;
88 if (command !== '' && detectDanger(command) !== null) ledger.destructive += 1;
89 if (command !== '' && isTestCommand(command)) {
90 const output = isRecord(result)
91 ? `${typeof result.stdout === 'string' ? result.stdout : ''}\n${
92 typeof result.stderr === 'string' ? result.stderr : ''
93 }`
94 : call.text;
95 ledger.tests = testRun(output, !call.isError);
96 }
97 const diff = isRecord(result) ? result.bashEditDiff : undefined;
98 if (isRecord(diff) && Array.isArray(diff.files)) {
99 for (const file of diff.files) {
100 if (!isRecord(file) || typeof file.filePath !== 'string') continue;
101 if (file.deleted === true) ledger.deleted.add(file.filePath);
102 else if (file.created === true) ledger.created.add(file.filePath);
103 else touched(ledger, file.filePath);
104 }
105 }
106 return;
107 }
108
109 if (call.isError) {
110 ledger.failedSteps += 1;
111 return;
112 }
113
114 if (tool === 'Write' && isRecord(result)) {
115 const path = typeof result.filePath === 'string' ? result.filePath : input.file_path;
116 if (result.type === 'create' && typeof path === 'string') ledger.created.add(path);
117 else touched(ledger, path);
118 } else if (tool === 'Edit' || tool === 'MultiEdit') {
119 touched(ledger, input.file_path);
120 } else if (tool === 'NotebookEdit') {
121 touched(ledger, input.notebook_path);
122 if (input.edit_mode === 'delete') ledger.destructive += 1;
123 }
124}
125
126function testWords(run: TestRun): string {
127 if (run.kind === 'counted') {
128 const total = run.failed + run.passed;
129 return run.failed > 0
130 ? `${run.failed} of ${total} ${plural(total, 'check')} failed`
131 : `all ${counted(run.passed, 'check')} passed`;
132 }
133 return run.kind === 'failed'
134 ? 'the checks failed (codevibez could not read how many)'
135 : 'the checks passed';
136}
137
138export type Summary = {
139 glyph: string;
140 text: string;
141 tone: 'info' | 'failure' | 'danger';
142};
143
144/**
145 * One plain line for the turn, or null when nothing changed and nothing ran.
146 */
147export function summarize(ledger: Ledger): Summary | null {
148 const parts: string[] = [];
149 const files = ledger.created.size + ledger.changed.size;
150 if (files > 0) {
151 parts.push(
152 ledger.created.size > 0
153 ? `changed ${counted(files, 'file')} (${ledger.created.size} new)`
154 : `changed ${counted(files, 'file')}`,
155 );
156 }
157 if (ledger.deleted.size > 0) parts.push(`deleted ${counted(ledger.deleted.size, 'file')}`);
158 if (ledger.commands > 0) {
159 parts.push(
160 ledger.failedCommands > 0
161 ? `ran ${counted(ledger.commands, 'command')} (${ledger.failedCommands} failed)`
162 : `ran ${counted(ledger.commands, 'command')}`,
163 );
164 }
165 if (ledger.tests !== null) parts.push(testWords(ledger.tests));
166 if (ledger.failedSteps > 0) {
167 parts.push(`${counted(ledger.failedSteps, 'other step')} failed`);
168 }
169 if (ledger.destructive > 0) {
170 parts.push(`${counted(ledger.destructive, 'destructive step')}`);
171 }
172 if (ledger.declined > 0) {
173 parts.push(`you said no to ${counted(ledger.declined, 'step')}`);
174 }
175 if (ledger.hookBlocked > 0) {
176 parts.push(`a hook blocked ${counted(ledger.hookBlocked, 'step')}`);
177 }
178 if (parts.length === 0) return null;
179
180 const failed =
181 ledger.failedCommands > 0 ||
182 ledger.failedSteps > 0 ||
183 (ledger.tests?.kind === 'counted' && ledger.tests.failed > 0) ||
184 ledger.tests?.kind === 'failed';
185 const text = `This turn: ${parts.join(', ')}.`;
186 if (failed) return { glyph: '✗', text, tone: 'failure' };
187 if (ledger.destructive > 0) return { glyph: '⚠', text, tone: 'danger' };
188 return { glyph: '●', text, tone: 'info' };
189}
190hooks/narrate.ts 598 lines1import { formatPrompt } from '../engine/src/prompt.ts';
2import { translateBash } from '../engine/src/rules/bash-rules.ts';
3import { translateFileTool } from '../engine/src/rules/file-rules.ts';
4import { translateOutcome } from '../engine/src/rules/outcome-rules.ts';
5import type { CodevibezEvent, Level, Line } from '../engine/src/types.ts';
6import { counted, plural } from '../engine/src/words.ts';
7import { redactSecrets } from './redact.ts';
8
9/**
10 * Turns one tool call, as a transcript row sees it, into plain English.
11 *
12 * Pure functions only: no `$`, no clock, no disk. The same row in gives the
13 * same sentence out, which is what lets the hooks memoize them per call.
14 */
15
16const LEVEL: Level = 'plain';
17
18export type Tone = 'info' | 'read' | 'success' | 'failure' | 'danger' | 'stopped';
19
20export type Sentence = {
21 glyph: string;
22 text: string;
23 tone: Tone;
24 source?: 'description';
25};
26
27export type Narration = {
28 /** What the call does. */
29 action: Sentence;
30 /** What came of it, when there is something worth saying. */
31 outcome?: Sentence;
32};
33
34/** A tool call as the `ToolUse` row and each call of a `ToolGroup` carry it. */
35export type CallView = {
36 tool: string;
37 input: unknown;
38 isRunning: boolean;
39 isErrored: boolean;
40 isInterrupted: boolean;
41 output?: unknown;
42};
43
44type Input = Record<string, unknown>;
45
46function isRecord(value: unknown): value is Input {
47 return typeof value === 'object' && value !== null && !Array.isArray(value);
48}
49
50function text(value: unknown): string | null {
51 return typeof value === 'string' && value.trim() !== '' ? value : null;
52}
53
54function quoted(value: string, limit = 60): string {
55 return `"${formatPrompt(value, limit)}"`;
56}
57
58function sentence(glyph: string, words: string, tone: Tone): Sentence {
59 return { glyph, text: redactSecrets(words), tone };
60}
61
62function fromLine(line: Line): Sentence {
63 const tone: Tone =
64 line.severity === 'danger'
65 ? 'danger'
66 : line.severity === 'failure'
67 ? 'failure'
68 : line.severity === 'success'
69 ? 'success'
70 : line.tier === 'read'
71 ? 'read'
72 : 'info';
73 return sentence(line.glyph, line.text, tone);
74}
75
76function plainDescription(said: string): string {
77 const one = formatPrompt(said.replace(/[.\s]+$/, ''), 80);
78 return one.charAt(0).toUpperCase() + one.slice(1);
79}
80
81function hostOf(url: string): string | null {
82 try {
83 return new URL(url).hostname || null;
84 } catch {
85 return null;
86 }
87}
88
89function serviceOf(tool: string): { service: string; action: string } | null {
90 const match = /^mcp__(.+?)__(.+)$/.exec(tool);
91 if (match === null) return null;
92 const service = (match[1] ?? '').replace(/^plugin_[^_]+_/, '').replace(/[_-]+/g, ' ');
93 const action = (match[2] ?? '').replace(/[_-]+/g, ' ');
94 return service === '' || action === '' ? null : { service, action };
95}
96
97type Todo = { status?: unknown };
98
99function todoCounts(todos: readonly Todo[]): string {
100 const done = todos.filter((todo) => todo.status === 'completed').length;
101 return `(${done} of ${counted(todos.length, 'item')} done)`;
102}
103
104/** What the call does, or null when the call is not one this mod knows. */
105function describeAction(view: CallView): Sentence | null {
106 const { tool, input } = view;
107 if (!isRecord(input)) return null;
108
109 switch (tool) {
110 case 'Bash': {
111 const command = text(input.command);
112 if (command === null) return null;
113 const rule = translateBash(command, LEVEL);
114 // A routine step the rules cannot name reads as the step's own
115 // description. Destructive steps (their own ids) and failed or stopped
116 // calls never take this path: they keep the rule-based line.
117 const said = text(input.description);
118 const line =
119 rule.id === 'bash.generic' && said !== null && !view.isErrored && !view.isInterrupted
120 ? { ...sentence('→', plainDescription(said), rule.tier === 'read' ? 'read' : 'info'), source: 'description' as const }
121 : fromLine(rule);
122 return input.run_in_background === true
123 ? { ...line, text: `${line.text} (in the background)` }
124 : line;
125 }
126
127 case 'Read':
128 case 'Edit':
129 case 'MultiEdit': {
130 if (text(input.file_path) === null) return null;
131 const line = translateFileTool(tool, input, LEVEL);
132 return line === null ? null : fromLine(line);
133 }
134
135 case 'Grep':
136 case 'Glob': {
137 if (text(input.pattern) === null) return null;
138 const line = translateFileTool(tool, input, LEVEL);
139 return line === null ? null : fromLine(line);
140 }
141
142 case 'Write': {
143 if (text(input.file_path) === null) return null;
144 // Whether this creates a file or replaces one is only known once it ran.
145 const kind = isRecord(view.output) ? view.output.type : undefined;
146 if (kind !== 'create' && kind !== 'update') {
147 return sentence('✎', 'Writing code to a file', 'info');
148 }
149 const line = translateFileTool(
150 'Write',
151 { ...input, codevibez_exists: kind === 'update' },
152 LEVEL,
153 );
154 return line === null ? null : fromLine(line);
155 }
156
157 case 'NotebookEdit': {
158 if (text(input.notebook_path) === null) return null;
159 if (input.edit_mode === 'delete') {
160 return sentence('⚠', 'Deleting a section of a notebook', 'danger');
161 }
162 return input.edit_mode === 'insert'
163 ? sentence('✎', 'Adding a section to a notebook', 'info')
164 : sentence('✎', 'Updating a section of a notebook', 'info');
165 }
166
167 case 'WebFetch': {
168 const url = text(input.url);
169 if (url === null) return null;
170 const host = hostOf(url);
171 return sentence(
172 '→',
173 host === null ? 'Reading a web page' : `Reading a web page (${host})`,
174 'read',
175 );
176 }
177
178 case 'WebSearch': {
179 const query = text(input.query);
180 if (query === null) return null;
181 return sentence('→', `Searching the web for ${quoted(query)}`, 'read');
182 }
183
184 case 'Agent':
185 case 'Task': {
186 const task = text(input.description) ?? text(input.prompt);
187 if (task === null) return null;
188 return sentence('→', `Handing a task to a helper: ${quoted(task)}`, 'info');
189 }
190
191 case 'TodoWrite': {
192 if (!Array.isArray(input.todos)) return null;
193 const todos = input.todos.filter(isRecord);
194 return sentence('→', `Updating its to-do list ${todoCounts(todos)}`, 'read');
195 }
196
197 case 'TaskCreate': {
198 const subject = text(input.subject);
199 if (subject === null) return null;
200 return sentence('→', `Adding a to-do: ${quoted(subject)}`, 'read');
201 }
202
203 case 'TaskUpdate': {
204 if (text(input.taskId) === null) return null;
205 const words =
206 input.status === 'completed'
207 ? 'Marking a to-do as done'
208 : input.status === 'in_progress'
209 ? 'Starting on a to-do'
210 : input.status === 'deleted'
211 ? 'Removing a to-do'
212 : 'Updating a to-do';
213 return sentence('→', words, 'read');
214 }
215
216 case 'TaskList':
217 case 'TaskGet':
218 return sentence('→', 'Checking its to-do list', 'read');
219
220 case 'TaskStop':
221 return sentence('→', 'Stopping a task running in the background', 'info');
222
223 case 'Skill': {
224 const skill = text(input.skill);
225 if (skill === null) return null;
226 return sentence('→', `Following a saved set of instructions (${skill})`, 'info');
227 }
228
229 case 'ToolSearch':
230 return sentence('→', 'Looking for the right tool for the job', 'read');
231
232 case 'AskUserQuestion':
233 return sentence('→', 'Asking you a question', 'info');
234
235 case 'EnterPlanMode':
236 return sentence('→', 'Making a plan before changing anything', 'info');
237
238 case 'ExitPlanMode':
239 return sentence('→', 'Showing you its plan for approval', 'info');
240
241 case 'LSP':
242 return sentence('→', 'Checking how the code fits together', 'read');
243
244 case 'Monitor':
245 return sentence('→', 'Keeping an eye on something in the background', 'info');
246
247 case 'SendMessage':
248 return sentence('→', 'Sending a message to a helper', 'info');
249 case 'ScheduleWakeup':
250 return sentence('→', 'Setting a reminder to check back later', 'info');
251 case 'CronCreate':
252 return sentence('→', 'Scheduling a job to repeat', 'info');
253 case 'CronDelete':
254 return sentence('→', 'Cancelling a scheduled job', 'info');
255 case 'CronList':
256 return sentence('→', 'Checking its scheduled jobs', 'read');
257 case 'EnterWorktree':
258 return sentence('→', 'Starting work in a separate copy of the project', 'info');
259 case 'ExitWorktree':
260 return input.action === 'remove'
261 ? sentence('⚠', input.discard_changes === true
262 ? 'Removing the separate copy of the project and throwing away its unsaved edits'
263 : 'Removing the separate copy of the project', 'danger')
264 : sentence('→', 'Leaving the separate copy of the project', 'info');
265 case 'Workflow':
266 return sentence('→', 'Running a planned set of helper steps', 'info');
267 case 'ListMcpResourcesTool':
268 case 'ReadMcpResourceDirTool':
269 return sentence('→', 'Looking at what a connected service offers', 'read');
270 case 'ReadMcpResourceTool':
271 return sentence('→', 'Reading from a connected service', 'read');
272 case 'PushNotification':
273 return sentence('→', 'Sending you a notification', 'info');
274 case 'SendUserMessage':
275 return sentence('→', 'Sending you a message', 'info');
276 case 'SendUserFile':
277 case 'SendFile':
278 return sentence('→', 'Sending you a file', 'info');
279 case 'ListAgents':
280 return sentence('→', 'Checking which helpers are running', 'read');
281 case 'memory_read':
282 case 'memory_list':
283 return sentence('→', 'Looking at its saved notes', 'read');
284 case 'memory_write':
285 return sentence('✎', 'Saving a note for later', 'info');
286
287 default: {
288 const mcp = serviceOf(tool);
289 if (mcp === null) {
290 // Any other tool still gets a plain line; its exact name is kept.
291 return /^[A-Za-z][\w-]*$/.test(tool)
292 ? sentence('→', `Using one of Claude Code's tools (${tool.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_+/g, ' ').trim().toLowerCase()})`, 'info')
293 : null;
294 }
295 return sentence(
296 '→',
297 `Using the connected service "${mcp.service}" (${mcp.action})`,
298 'info',
299 );
300 }
301 }
302}
303
304const KNOWN_TOOLS = new Set([
305 'Bash', 'Read', 'Edit', 'MultiEdit', 'Grep', 'Glob', 'Write', 'NotebookEdit',
306 'WebFetch', 'WebSearch', 'Agent', 'Task', 'TodoWrite', 'TaskCreate', 'TaskUpdate',
307 'TaskList', 'TaskGet', 'TaskStop', 'Skill', 'ToolSearch', 'AskUserQuestion',
308 'EnterPlanMode', 'ExitPlanMode', 'LSP', 'Monitor', 'SendMessage', 'ScheduleWakeup',
309 'CronCreate', 'CronDelete', 'CronList', 'EnterWorktree', 'ExitWorktree', 'Workflow',
310 'ListMcpResourcesTool', 'ReadMcpResourceDirTool', 'ReadMcpResourceTool', 'PushNotification',
311 'SendUserMessage', 'SendUserFile', 'SendFile', 'ListAgents', 'memory_read', 'memory_list', 'memory_write',
312]);
313
314/** Whether this mod has English for the tool at all, judged by name alone. */
315export function isKnownTool(tool: string): boolean {
316 return KNOWN_TOOLS.has(tool) || serviceOf(tool) !== null;
317}
318
319/** The text a failed call handed back, whatever shape it came in. */
320function errorText(output: unknown): string {
321 if (typeof output === 'string') return output;
322 if (isRecord(output)) {
323 for (const key of ['error', 'stderr', 'message', 'text']) {
324 const value = output[key];
325 if (typeof value === 'string' && value !== '') return value;
326 }
327 }
328 if (Array.isArray(output)) {
329 return output
330 .map((block) => (isRecord(block) && typeof block.text === 'string' ? block.text : ''))
331 .join('\n');
332 }
333 return '';
334}
335
336function plainReason(error: string): string | null {
337 if (/string to replace not found|old_string/i.test(error)) {
338 return 'the text it meant to change was not there';
339 }
340 if (/has not been read|read it first/i.test(error)) {
341 return 'it had not looked at the file first';
342 }
343 if (/does not exist|no such file|not found|enoent\b|cannot find/i.test(error)) {
344 return 'something it needed could not be found';
345 }
346 if (/permission denied|eacces\b|eperm\b|not permitted/i.test(error)) {
347 return 'it was not allowed to';
348 }
349 if (/timed? ?out\b|timeout/i.test(error)) return 'it took too long';
350 if (/econnrefused\b|enotfound\b|network|fetch failed|unreachable/i.test(error)) {
351 return 'the network could not be reached';
352 }
353 return null;
354}
355
356const FAILED_STEP: Readonly<Record<string, string>> = {
357 Read: 'Could not open the file',
358 Edit: 'Could not change the file',
359 MultiEdit: 'Could not change the file',
360 Write: 'Could not save the file',
361 NotebookEdit: 'Could not change the notebook',
362 Grep: 'The search failed',
363 Glob: 'The search failed',
364 WebFetch: 'Could not read the web page',
365 WebSearch: 'The web search failed',
366 Agent: 'The helper could not finish its task',
367 Task: 'The helper could not finish its task',
368};
369
370function bashFailure(command: string, error: string): Sentence {
371 // Claude Code reports a non-zero exit as its own line, `Exit code 1`,
372 // sometimes led by `Error:` or wrapped in a tag, ahead of the output.
373 const exit = /(?:^|\n)[^\S\n]*(?:<[^>\n]+>)?[^\S\n]*(?:Error:[^\S\n]*)?Exit code (\d+)[^\S\n]*(?:\n|$)/.exec(error);
374 const exitCode = exit === null ? undefined : Number(exit[1]);
375 const output = exit === null ? error : error.replace(exit[0], '\n');
376 const event: CodevibezEvent = {
377 v: 1,
378 ts: '',
379 session: '',
380 agent: 'claude-code',
381 kind: 'tool.post',
382 tool: 'Bash',
383 raw: { command },
384 outcome: {
385 ok: false,
386 ...(exitCode === undefined ? {} : { exitCode }),
387 stdout: output,
388 stderr: '',
389 },
390 };
391 const line = translateOutcome(event, LEVEL);
392 if (line === null) {
393 return sentence('✗', 'The command failed', 'failure');
394 }
395 // A test run's own line carries the exact counts; anything else gets the
396 // exit code, which is the one number every failed command has.
397 if (line.id === 'out.fail' && exitCode !== undefined) {
398 return sentence('✗', `${line.stem ?? line.text} (exit code ${exitCode})`, 'failure');
399 }
400 return fromLine(line);
401}
402
403function describeFailure(view: CallView): Sentence {
404 const error = errorText(view.output);
405 if (/has been denied|permission to use .* (?:was|has been) denied/i.test(error)) {
406 return sentence('✗', 'Not run: a permission rule blocked it', 'failure');
407 }
408 if (view.tool === 'Bash' && isRecord(view.input) && typeof view.input.command === 'string') {
409 return bashFailure(view.input.command, error);
410 }
411 const step = FAILED_STEP[view.tool] ?? (serviceOf(view.tool) === null
412 ? 'That step failed'
413 : 'The connected service reported a failure');
414 const reason = plainReason(error);
415 const status = /status code (\d{3})/i.exec(error)?.[1];
416 const words = [step, reason === null ? '' : `: ${reason}`, status === undefined ? '' : ` (error ${status})`].join('');
417 return sentence('✗', words, 'failure');
418}
419
420/** What came of a finished call that did not fail, when it is worth a line. */
421function describeSuccess(view: CallView): Sentence | undefined {
422 const { tool, input, output } = view;
423 if (tool === 'Bash' && isRecord(input) && typeof input.command === 'string' && isRecord(output)) {
424 const event: CodevibezEvent = {
425 v: 1,
426 ts: '',
427 session: '',
428 agent: 'claude-code',
429 kind: 'tool.post',
430 tool: 'Bash',
431 raw: { command: input.command },
432 outcome: {
433 ok: true,
434 stdout: typeof output.stdout === 'string' ? output.stdout : '',
435 stderr: typeof output.stderr === 'string' ? output.stderr : '',
436 },
437 };
438 const line = translateOutcome(event, LEVEL);
439 return line === null ? undefined : fromLine(line);
440 }
441 if (tool === 'WebFetch' && isRecord(output) && typeof output.code === 'number' && output.code >= 400) {
442 const status = typeof output.codeText === 'string' && output.codeText !== ''
443 ? `${output.code} ${output.codeText}`
444 : String(output.code);
445 return sentence('✗', `The web page answered with an error (${status})`, 'failure');
446 }
447 return undefined;
448}
449
450const DECLINED = /^(?:Error:\s*)?(?:The user doesn't want to proceed|User rejected)|tool use was rejected/i;
451
452/**
453 * The first line of what came back. Claude Code's own notices start there, so
454 * a failing step whose output merely mentions one is still a failure.
455 */
456function firstLine(text: string): string {
457 return text.trimStart().split('\n', 1)[0] ?? '';
458}
459
460/** Whether the person said no to the call at a permission prompt. */
461export function isDeclined(text: string): boolean {
462 return DECLINED.test(firstLine(text));
463}
464
465// What Claude Code reports when a PreToolUse hook stops a call, whether the
466// hook exits 2 or answers with a JSON deny: `PreToolUse:Bash hook error: …`.
467const HOOK_BLOCKED = /^(?:Error:\s*)?PreToolUse(?::[\w-]+)? hook error:/;
468
469/** Whether a settings hook stopped the call before it ran. */
470export function isHookBlocked(text: string): boolean {
471 return HOOK_BLOCKED.test(firstLine(text));
472}
473
474const NOT_RUN_DECLINED = 'Not run: you said no';
475const NOT_RUN_HOOK = 'Not run: a hook blocked it';
476
477/**
478 * The English for one call, or null when this mod does not know the tool or
479 * the row's shape, so Claude Code draws its own row.
480 */
481export function describeCall(view: CallView): Narration | null {
482 const action = describeAction(view);
483 if (action === null) return null;
484 if (view.isRunning) return { action };
485 if ((view.isErrored || view.isInterrupted) && isDeclined(errorText(view.output))) {
486 return { action, outcome: sentence('✗', NOT_RUN_DECLINED, 'stopped') };
487 }
488 if ((view.isErrored || view.isInterrupted) && isHookBlocked(errorText(view.output))) {
489 return { action, outcome: sentence('✗', NOT_RUN_HOOK, 'stopped') };
490 }
491 if (view.isInterrupted) {
492 return { action, outcome: sentence('■', 'Stopped before it finished', 'stopped') };
493 }
494 if (view.isErrored) return { action, outcome: describeFailure(view) };
495 const outcome = describeSuccess(view);
496 return outcome === undefined ? { action } : { action, outcome };
497}
498
499type GroupCall = CallView;
500
501const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'WebSearch', 'ToolSearch']);
502const TODO_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet']);
503
504/**
505 * One line for a run of calls Claude Code folds together (reads, searches,
506 * listings), with every failure and every destructive step spelled out.
507 */
508export function describeGroup(calls: readonly GroupCall[]): Narration[] | null {
509 if (calls.length === 0) return null;
510 const described = calls.map((call) => ({ call, narration: describeCall(call) }));
511 if (described.some((item) => item.narration === null)) return null;
512 // A group of one is that call's own line.
513 const only = described.length === 1 ? described[0]?.narration : undefined;
514 if (only !== undefined && only !== null) return [only];
515
516 let files = 0;
517 let searches = 0;
518 let commands = 0;
519 let writes = 0;
520 let pages = 0;
521 let todos = 0;
522 let other = 0;
523 for (const { call, narration } of described) {
524 if (call.tool === 'Read') files += 1;
525 else if (TODO_TOOLS.has(call.tool)) todos += 1;
526 else if (SEARCH_TOOLS.has(call.tool)) searches += 1;
527 // Claude Code folds `cat > f <<EOF` in with reads; it still writes a file.
528 else if (call.tool === 'Bash' && narration?.action.glyph === '✎') writes += 1;
529 else if (call.tool === 'Bash') commands += 1;
530 else if (call.tool === 'WebFetch') pages += 1;
531 else other += 1;
532 }
533 const parts = [
534 files > 0 ? `${counted(files, 'file')} read` : '',
535 searches > 0 ? counted(searches, 'search', 'searches') : '',
536 commands > 0 ? counted(commands, 'command') : '',
537 writes > 0 ? counted(writes, 'file write') : '',
538 pages > 0 ? `${counted(pages, 'web page')} read` : '',
539 todos > 0 ? counted(todos, 'to-do update') : '',
540 other > 0 ? counted(other, 'other step') : '',
541 ].filter((part) => part !== '');
542 const stem =
543 todos === described.length
544 ? 'Updating its to-do list'
545 : writes === described.length
546 ? 'Writing files'
547 : writes > 0
548 ? 'Looking around and writing files'
549 : commands === described.length
550 ? 'Looking around the project'
551 : 'Getting familiar with your code';
552
553 const lines: Narration[] = [
554 { action: writes > 0 ? sentence('✎', `${stem} (${parts.join(', ')})`, 'info') : sentence('→', `${stem} (${parts.join(', ')})`, 'read') },
555 ];
556
557 // Anything that is not routine is never folded into the count line.
558 for (const { narration } of described) {
559 if (narration === null) continue;
560 if (narration.action.tone === 'danger') lines.push({ action: narration.action });
561 }
562 const failed = described.filter(
563 (item) => item.narration?.outcome?.tone === 'failure',
564 );
565 if (failed.length > 0) {
566 const first = failed[0]?.narration?.outcome;
567 const total = described.length;
568 const head = `${failed.length} of ${total} ${plural(total, 'step')} failed`;
569 lines.push({
570 action: sentence(
571 '✗',
572 failed.length === 1 && first !== undefined ? `${head}: ${first.text}` : head,
573 'failure',
574 ),
575 });
576 }
577 const declined = described.filter((item) => item.narration?.outcome?.text === NOT_RUN_DECLINED).length;
578 if (declined > 0) {
579 lines.push({ action: sentence('✗', `Not run: you said no to ${counted(declined, 'step')}`, 'stopped') });
580 }
581 const hooked = described.filter((item) => item.narration?.outcome?.text === NOT_RUN_HOOK).length;
582 if (hooked > 0) {
583 lines.push({ action: sentence('✗', `Not run: a hook blocked ${counted(hooked, 'step')}`, 'stopped') });
584 }
585 const stopped = described.filter(
586 (item) =>
587 item.narration?.outcome?.tone === 'stopped' &&
588 item.narration.outcome.text !== NOT_RUN_DECLINED &&
589 item.narration.outcome.text !== NOT_RUN_HOOK,
590 ).length;
591 if (stopped > 0) {
592 lines.push({
593 action: sentence('■', `${counted(stopped, 'step')} stopped before finishing`, 'stopped'),
594 });
595 }
596 return lines;
597}
598hooks/story.ts 120 lines1import type { Chapter, StoryLine } from '../types';
2import type { Narration } from './narrate.ts';
3import { redactSecrets } from './redact.ts';
4
5/**
6 * The session told in plain English: one chapter per prompt you typed, its
7 * steps in order, and the turn's summary line. Pure functions over plain data,
8 * kept in the session's memory only.
9 */
10
11const MAX_CHAPTERS = 50;
12const MAX_LINES = 200;
13
14export function startChapter(story: readonly Chapter[], prompt: string): Chapter[] {
15 const chapter: Chapter = { prompt: redactSecrets(prompt), lines: [], more: 0 };
16 return [...story, chapter].slice(-MAX_CHAPTERS);
17}
18
19function lastChapter(story: readonly Chapter[]): Chapter[] {
20 return story.length > 0 ? [...story] : [{ prompt: '', lines: [], more: 0 }];
21}
22
23const ALWAYS_KEPT: ReadonlySet<StoryLine['tone']> = new Set(['failure', 'danger']);
24const MAX_SERIOUS = 300;
25
26/**
27 * Adds lines to the newest chapter. Past the chapter's room, routine lines
28 * are counted exactly instead of kept. Failures and destructive steps get 300
29 * more lines of room, then are counted exactly too, so a long session cannot
30 * grow the story without bound; the turn's summary is always kept.
31 */
32export function addLines(
33 story: readonly Chapter[],
34 lines: readonly StoryLine[],
35 isSummary = false,
36): Chapter[] {
37 if (lines.length === 0) return [...story];
38 const next = lastChapter(story);
39 const index = next.length - 1;
40 const chapter = next[index];
41 if (chapter === undefined) return next;
42 const kept = [...chapter.lines];
43 let more = chapter.more;
44 let serious = chapter.serious ?? 0;
45 for (const line of lines) {
46 const isSerious = ALWAYS_KEPT.has(line.tone);
47 if (isSummary || kept.length < MAX_LINES || (isSerious && kept.length < MAX_LINES + MAX_SERIOUS)) kept.push(line);
48 else {
49 more += 1;
50 if (isSerious) serious += 1;
51 }
52 }
53 next[index] = { ...chapter, lines: kept, more, ...(serious > 0 ? { serious } : {}) };
54 return next;
55}
56
57export function linesOf(narration: Narration): StoryLine[] {
58 return narration.outcome === undefined
59 ? [narration.action]
60 : [narration.action, narration.outcome];
61}
62
63export type StoryRow = { kind: 'heading' | 'line' | 'note'; text: string; tone?: StoryLine['tone'] };
64
65/** How many terminal lines a row takes at this width, wrapped. */
66function heightOf(row: StoryRow, columns: number): number {
67 const room = Math.max(10, columns - (row.kind === 'heading' ? 0 : 2));
68 return Math.max(1, Math.ceil(row.text.length / room)) + (row.kind === 'heading' ? 1 : 0);
69}
70
71/**
72 * The newest rows that fit the pane, and how many earlier rows are left out,
73 * so the latest step is always in view.
74 */
75export function fitNewest(
76 rows: readonly StoryRow[],
77 columns: number,
78 height: number,
79): { shown: StoryRow[]; hidden: number } {
80 let used = 0;
81 let start = rows.length;
82 while (start > 0) {
83 const next = rows[start - 1];
84 if (next === undefined) break;
85 const cost = heightOf(next, columns);
86 // Keep one line free to say how much is above, unless everything fits.
87 if (used + cost > height - (start > 1 ? 1 : 0) && start < rows.length) break;
88 used += cost;
89 start -= 1;
90 }
91 return { shown: rows.slice(start), hidden: start };
92}
93
94/** The story as rows to draw, oldest first. */
95export function storyRows(story: readonly Chapter[]): StoryRow[] {
96 const rows: StoryRow[] = [];
97 for (const chapter of story) {
98 rows.push({
99 kind: 'heading',
100 text: chapter.prompt === '' ? 'Before your first prompt' : chapter.prompt,
101 });
102 if (chapter.lines.length === 0 && chapter.more === 0) {
103 rows.push({ kind: 'note', text: 'No steps.' });
104 }
105 for (const line of chapter.lines) {
106 rows.push({ kind: 'line', text: `${line.glyph} ${line.text}`, tone: line.tone });
107 }
108 if (chapter.more > 0) {
109 rows.push({
110 kind: 'note',
111 text:
112 (chapter.serious ?? 0) > 0
113 ? `and ${chapter.more} more ${chapter.more === 1 ? 'line' : 'lines'} not shown here, ${chapter.serious} of them failures or destructive steps`
114 : `and ${chapter.more} more routine ${chapter.more === 1 ? 'line' : 'lines'} not shown here`,
115 });
116 }
117 }
118 return rows;
119}
120engine/src/bash.ts 935 lines1/**
2 * Shell text in, danger verdict out.
3 *
4 * Two properties matter more than coverage here, and they pull in opposite
5 * directions from ordinary parsing:
6 *
7 * 1. A missed destructive command is the worst possible failure. codevibez's
8 * whole claim is that you can trust the feed, so silence about a deletion
9 * is worse than a false alarm. Where a heuristic could go either way, it
10 * goes toward warning.
11 * 2. The most severe hit in a command wins. Returning the first match found
12 * let a harmless `> /dev/null` on the end of a line hide the `rm -rf` at
13 * the start of it.
14 */
15
16export function splitSegments(command: string): string[] {
17 const segments: string[] = [];
18 let current = '';
19 let quote: '"' | "'" | null = null;
20
21 for (let index = 0; index < command.length; index += 1) {
22 const character = command.charAt(index);
23 const next = command.charAt(index + 1);
24
25 if (character === '\\' && (next === '\n' || next === '\r')) {
26 index += next === '\r' && command.charAt(index + 2) === '\n' ? 2 : 1;
27 continue;
28 }
29 if (character === '\\' && [';', '|', '&'].includes(next)) {
30 current += `${character}${next}`;
31 index += 1;
32 continue;
33 }
34
35 if (quote !== null) {
36 current += character;
37 if (character === quote) quote = null;
38 continue;
39 }
40
41 if (character === '"' || character === "'") {
42 quote = character;
43 current += character;
44 continue;
45 }
46
47 if (character === '&' && next === '&') {
48 segments.push(current);
49 current = '';
50 index += 1;
51 continue;
52 }
53
54 // `2>&1`, `>&2` and `&>` are redirections, not a command sent to the background.
55 if (character === '&' && (command.charAt(index - 1) === '>' || command.charAt(index - 1) === '<' || next === '>')) {
56 current += character;
57 continue;
58 }
59
60 // `>|` is a single forced-clobber operator, so the pipe must not split it.
61 if (character === '|' && command.charAt(index - 1) === '>') {
62 current += character;
63 continue;
64 }
65
66 if (
67 character === ';' ||
68 character === '|' ||
69 character === '&' ||
70 character === '\n' ||
71 character === '\r'
72 ) {
73 segments.push(current);
74 current = '';
75 continue;
76 }
77
78 current += character;
79 }
80
81 segments.push(current);
82 return segments.map((segment) => segment.trim()).filter(Boolean);
83}
84
85export function tokenize(segment: string): string[] {
86 const tokens: string[] = [];
87 let current = '';
88 let quote: '"' | "'" | null = null;
89
90 for (let index = 0; index < segment.length; index += 1) {
91 const character = segment.charAt(index);
92 const next = segment.charAt(index + 1);
93
94 if (character === '\\' && (next === '\n' || next === '\r')) {
95 index += next === '\r' && segment.charAt(index + 2) === '\n' ? 2 : 1;
96 continue;
97 }
98
99 if (quote !== null) {
100 if (character === quote) {
101 quote = null;
102 } else {
103 current += character;
104 }
105 continue;
106 }
107
108 if (character === '"' || character === "'") {
109 quote = character;
110 continue;
111 }
112
113 if (/\s/.test(character)) {
114 if (current !== '') {
115 tokens.push(current);
116 current = '';
117 }
118 continue;
119 }
120
121 current += character;
122 }
123
124 if (current !== '') tokens.push(current);
125 return tokens;
126}
127
128export interface DangerHit {
129 reason:
130 | 'delete'
131 | 'delete-untracked'
132 | 'overwrite-history'
133 | 'discard-changes'
134 | 'discard-worktree'
135 | 'overwrite'
136 | 'permissions'
137 | 'drop-data'
138 | 'empty'
139 | 'empty-read';
140 target: string;
141 recursive?: boolean;
142}
143
144/**
145 * How much each kind of destruction outranks the others. Losing tracked work
146 * outranks clobbering one file, and anything outranks a redirect, which is the
147 * weakest signal and the one most likely to appear incidentally.
148 */
149const SEVERITY: Record<DangerHit['reason'], number> = {
150 delete: 6,
151 'delete-untracked': 6,
152 'discard-changes': 5,
153 'discard-worktree': 5,
154 'overwrite-history': 5,
155 'drop-data': 6,
156 permissions: 2,
157 'empty-read': 2,
158 empty: 2,
159 overwrite: 1,
160};
161
162const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
163const DELETE_EXECUTABLES = new Set(['rm', 'rmdir', 'rimraf', 'del']);
164const WRAPPERS = new Set(['sudo', 'env', 'command', 'nice', 'time', 'doas', 'timeout', 'nohup']);
165/** Control-flow words that sit where a command name would otherwise be. */
166const KEYWORDS = new Set([
167 'if', 'then', 'else', 'elif', 'fi',
168 'while', 'until', 'do', 'done',
169 'case', 'esac', 'in',
170 '{', '}', '(', ')', '!', 'exec',
171]);
172const PERMISSION_EXECUTABLES = new Set(['chmod', 'chown', 'chgrp']);
173const DATABASE_CLIENTS = new Set([
174 'psql',
175 'mysql',
176 'mariadb',
177 'sqlite3',
178 'duckdb',
179 'cockroach',
180 'clickhouse-client',
181 'mongosh',
182 'mongo',
183]);
184const DROP_SQL =
185 /\b(?:drop\s+(table|database|schema|collection|view)|truncate(?:\s+table)?)\s+(?:if\s+exists\s+)?([A-Za-z0-9_."`$-]+)/i;
186const DROP_MONGO = /\b(?:db\.dropDatabase\(\)|\.drop\(\))/;
187
188/** Writing here destroys nothing, so it must never register as an overwrite. */
189const NULL_SINKS = /^\/dev\/(null|zero|stdout|stderr|tty|fd\/\d+)$/;
190
191/**
192 * Where scratch work goes: a temp folder, a path made by `mktemp`, or a file
193 * named by a shell variable. Emptying one of these is routine, not a loss
194 * worth stopping for. `$HOME`, `$PWD` and project paths are real files.
195 */
196const TEMP_ROOT = /^(?:~?\/?(?:private\/)?|\.\/|\.)tmp\/|^\/(?:private\/)?var\/folders\/|^\/dev\/shm\//;
197const VARIABLE_ROOT = /^\$\{?(?!(?:HOME|PWD|OLDPWD)\b)[A-Za-z_][A-Za-z0-9_]*\}?(?:\/|$)/;
198const MKTEMP = /^(?:\$\(|`)\s*mktemp\b/;
199
200export function isScratchTarget(target: string): boolean {
201 return TEMP_ROOT.test(target) || VARIABLE_ROOT.test(target) || MKTEMP.test(target);
202}
203
204/** `truncate -s 0 f`: the file is cut to nothing. Any other size keeps or pads it. */
205export function truncatesToNothing(argv: readonly string[]): boolean {
206 return argv.some(
207 (argument, index) =>
208 argument === '-s0' ||
209 argument === '--size=0' ||
210 ((argument === '-s' || argument === '--size') && argv[index + 1] === '0'),
211 );
212}
213
214export function isNullSink(target: string): boolean {
215 return NULL_SINKS.test(target);
216}
217
218export interface Redirect {
219 target: string;
220 append: boolean;
221 /** `1` for output, `2` for errors, `&` for both. */
222 fd: string;
223}
224
225export interface Scan {
226 /** Files the command's output or errors are sent to; never `2>&1` or a null sink. */
227 redirects: Redirect[];
228 /** Files fed to the command with `<`. */
229 inputs: string[];
230 /** The command with every redirection, its target and heredoc markers blanked out. */
231 stripped: string;
232}
233
234/** The index of the `)` that closes the `(` at `open`, or the last index. */
235function closingParen(text: string, open: number): number {
236 let depth = 0;
237 let quote: string | null = null;
238 for (let index = open; index < text.length; index += 1) {
239 const character = text.charAt(index);
240 if (quote !== null) {
241 if (character === quote) quote = null;
242 continue;
243 }
244 if (character === '"' || character === "'") quote = character;
245 else if (character === '\\') index += 1;
246 else if (character === '(') depth += 1;
247 else if (character === ')') {
248 depth -= 1;
249 if (depth === 0) return index;
250 }
251 }
252 return text.length - 1;
253}
254
255function readWord(text: string, from: number): { value: string; end: number } {
256 let index = from;
257 while (index < text.length && (text.charAt(index) === ' ' || text.charAt(index) === '\t')) index += 1;
258 let value = '';
259 let quote: string | null = null;
260 for (; index < text.length; index += 1) {
261 const character = text.charAt(index);
262 if (quote !== null) {
263 if (character === quote) quote = null;
264 else value += character;
265 continue;
266 }
267 if (character === '$' && text.charAt(index + 1) === '(') {
268 const close = closingParen(text, index + 1);
269 value += text.slice(index, close + 1);
270 index = close;
271 continue;
272 }
273 if (character === '`') {
274 const close = text.indexOf('`', index + 1);
275 const end = close < 0 ? text.length - 1 : close;
276 value += text.slice(index, end + 1);
277 index = end;
278 continue;
279 }
280 if (character === '"' || character === "'") {
281 quote = character;
282 continue;
283 }
284 if (character === '\\' && index + 1 < text.length) {
285 value += text.charAt(index + 1);
286 index += 1;
287 continue;
288 }
289 if (/[\s;|&<>()]/.test(character)) break;
290 value += character;
291 }
292 return { value, end: index };
293}
294
295/** The file descriptor digits just before `index`, when they stand alone. */
296function fdBefore(text: string, index: number): { fd: string; start: number } | null {
297 let cursor = index - 1;
298 while (cursor >= 0 && /\d/.test(text.charAt(cursor))) cursor -= 1;
299 if (cursor === index - 1) return null;
300 if (cursor >= 0 && !/\s/.test(text.charAt(cursor))) return null;
301 return { fd: text.slice(cursor + 1, index), start: cursor + 1 };
302}
303
304/**
305 * Reads a segment's redirections from its raw text, so a quoted `">"`, an
306 * arithmetic `(( n > 5 ))` or a test `[[ a > b ]]` is never taken for one.
307 * What runs inside `$( )`, backticks or `<( )` is read as its own command.
308 */
309export function scanCommand(text: string): Scan {
310 const redirects: Redirect[] = [];
311 const inputs: string[] = [];
312 const out = text.split('');
313 const blank = (from: number, to: number): void => {
314 for (let cursor = from; cursor < to; cursor += 1) out[cursor] = ' ';
315 };
316 let quote: string | null = null;
317 let test = false;
318 for (let index = 0; index < text.length; index += 1) {
319 const character = text.charAt(index);
320 const next = text.charAt(index + 1);
321 if (quote !== null) {
322 if (character === quote) quote = null;
323 else if (character === '\\' && quote === '"') index += 1;
324 continue;
325 }
326 if (character === '\\') {
327 index += 1;
328 continue;
329 }
330 if (character === '"' || character === "'") {
331 quote = character;
332 continue;
333 }
334 if (next === '(' && ['$', '<', '>', '('].includes(character)) {
335 index = closingParen(text, index + 1);
336 continue;
337 }
338 if (character === '`') {
339 const close = text.indexOf('`', index + 1);
340 index = close < 0 ? text.length : close;
341 continue;
342 }
343 if (character === '[' && next === '[') {
344 test = true;
345 index += 1;
346 continue;
347 }
348 if (character === ']' && next === ']') {
349 test = false;
350 index += 1;
351 continue;
352 }
353 if (test) continue;
354 if (character === '>') {
355 let start = index;
356 let fd = '1';
357 if (text.charAt(index - 1) === '&') {
358 start = index - 1;
359 fd = '&';
360 } else {
361 const before = fdBefore(text, index);
362 if (before !== null) {
363 start = before.start;
364 fd = before.fd;
365 }
366 }
367 let operatorEnd = index + 1;
368 let append = false;
369 if (text.charAt(operatorEnd) === '>') {
370 append = true;
371 operatorEnd += 1;
372 } else if (text.charAt(operatorEnd) === '|') {
373 operatorEnd += 1;
374 }
375 if (text.charAt(operatorEnd) === '&') {
376 // `2>&1`, `>&2`: one stream joins another; no file is written.
377 const word = readWord(text, operatorEnd + 1);
378 blank(start, word.end);
379 index = word.end - 1;
380 continue;
381 }
382 const word = readWord(text, operatorEnd);
383 blank(start, word.end);
384 if (word.value !== '' && !NULL_SINKS.test(word.value)) redirects.push({ target: word.value, append, fd });
385 index = word.end - 1;
386 continue;
387 }
388 if (character === '<') {
389 if (next === '<') {
390 // `<<<` here-string or `<<EOF` heredoc marker: data, not a file.
391 let from = index + 2;
392 if (text.charAt(from) === '<' || text.charAt(from) === '-') from += 1;
393 const word = readWord(text, from);
394 blank(index, word.end);
395 index = word.end - 1;
396 continue;
397 }
398 const before = fdBefore(text, index);
399 const word = readWord(text, index + 1);
400 blank(before?.start ?? index, word.end);
401 if (word.value !== '') inputs.push(word.value);
402 index = word.end - 1;
403 }
404 }
405 return { redirects, inputs, stripped: out.join('') };
406}
407
408/** Split into pipelines: at `&&`, `||`, `;`, newlines and a lone `&`, never at a single `|`. */
409export function splitPipelines(command: string): string[] {
410 const pipelines: string[] = [];
411 let current = '';
412 let quote: '"' | "'" | null = null;
413 for (let index = 0; index < command.length; index += 1) {
414 const character = command.charAt(index);
415 const next = command.charAt(index + 1);
416 const previous = command.charAt(index - 1);
417 if (character === '\\' && (next === '\n' || next === '\r')) {
418 index += next === '\r' && command.charAt(index + 2) === '\n' ? 2 : 1;
419 continue;
420 }
421 if (character === '\\' && [';', '|', '&'].includes(next)) {
422 current += `${character}${next}`;
423 index += 1;
424 continue;
425 }
426 if (quote !== null) {
427 current += character;
428 if (character === quote) quote = null;
429 continue;
430 }
431 if (character === '"' || character === "'") {
432 quote = character;
433 current += character;
434 continue;
435 }
436 if ((character === '&' && next === '&') || (character === '|' && next === '|')) {
437 pipelines.push(current);
438 current = '';
439 index += 1;
440 continue;
441 }
442 const redirectAmpersand = character === '&' && (previous === '>' || previous === '<' || next === '>');
443 if (character === ';' || character === '\n' || character === '\r' || (character === '&' && !redirectAmpersand)) {
444 pipelines.push(current);
445 current = '';
446 continue;
447 }
448 current += character;
449 }
450 pipelines.push(current);
451 return pipelines.map((pipeline) => pipeline.trim()).filter(Boolean);
452}
453
454function executableName(value: string): string {
455 return value.split('/').pop() ?? value;
456}
457
458/** Matches a redirection operator, standalone (`>`, `2>`) or attached (`2>/dev/null`). */
459const REDIRECTION = /^\d*>{1,2}\|?/;
460
461/**
462 * Everything before the first redirection.
463 *
464 * Without this, `rm -rf node_modules > /dev/null` reports its target as
465 * "node_modules, >, /dev/null": the operator and the sink read as extra files
466 * being deleted, which is both wrong and alarming.
467 */
468function stripRedirections(argv: string[]): string[] {
469 const cut = argv.findIndex((argument) => REDIRECTION.test(argument));
470 return cut === -1 ? argv : argv.slice(0, cut);
471}
472
473function positionalArgs(argv: string[], start: number): string[] {
474 return stripRedirections(argv.slice(start)).filter(
475 (argument) =>
476 !argument.startsWith('-') &&
477 argument !== '{}' &&
478 argument !== '+' &&
479 argument !== ';' &&
480 argument !== '\\;',
481 );
482}
483
484/** Drop everything from an unquoted `#` onward, so a comment is never a target. */
485export function stripComment(segment: string): string {
486 let quote: '"' | "'" | null = null;
487 for (let index = 0; index < segment.length; index += 1) {
488 const character = segment.charAt(index);
489 if (quote !== null) {
490 if (character === quote) quote = null;
491 continue;
492 }
493 if (character === '"' || character === "'") {
494 quote = character;
495 continue;
496 }
497 if (character === '#' && (index === 0 || /\s/.test(segment.charAt(index - 1)))) {
498 return segment.slice(0, index);
499 }
500 }
501 return segment;
502}
503
504/**
505 * Lift heredoc bodies out of the command.
506 *
507 * Newlines separate commands, so without this a file being *written* with
508 * documentation about `rm -rf build` is read as a command that performs it. The
509 * bodies are returned rather than discarded because `bash <<EOF` really does
510 * execute what is inside.
511 */
512export function stripHeredocs(command: string): { command: string; bodies: string[] } {
513 if (!command.includes('<<')) return { command, bodies: [] };
514 const lines = command.split('\n');
515 const kept: string[] = [];
516 const bodies: string[] = [];
517 let index = 0;
518 while (index < lines.length) {
519 const line = lines[index] ?? '';
520 kept.push(line);
521 index += 1;
522 const header = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
523 if (header) {
524 const delimiter = header[2] ?? '';
525 const body: string[] = [];
526 while (index < lines.length && (lines[index] ?? '').trim() !== delimiter) {
527 body.push(lines[index] ?? '');
528 index += 1;
529 }
530 index += 1;
531 bodies.push(body.join('\n'));
532 }
533 }
534 return { command: kept.join('\n'), bodies };
535}
536
537function stripWrapper(argv: string[]): string[] {
538 const normalized = [...argv];
539
540 const dropNoise = (): void => {
541 while (
542 normalized[0] !== undefined &&
543 (ASSIGNMENT.test(normalized[0]) || KEYWORDS.has(normalized[0]))
544 ) {
545 normalized.shift();
546 }
547 };
548
549 dropNoise();
550 while (WRAPPERS.has(executableName(normalized[0] ?? ''))) {
551 const wrapper = executableName(normalized.shift() ?? '');
552 while (normalized[0]?.startsWith('-')) {
553 const option = normalized.shift() ?? '';
554 const takesValue =
555 (wrapper === 'sudo' && ['-u', '-g', '-h', '-p', '-C', '-T', '-R', '-D'].includes(option)) ||
556 (wrapper === 'env' && ['-u', '--unset'].includes(option)) ||
557 (wrapper === 'nice' && option === '-n') ||
558 (wrapper === 'time' && ['-f', '-o'].includes(option)) ||
559 (wrapper === 'timeout' && ['-s', '--signal', '-k', '--kill-after'].includes(option));
560 if (takesValue) normalized.shift();
561 }
562 // `timeout 10 rm -rf dist`: the duration comes before the command it runs.
563 if (wrapper === 'timeout') normalized.shift();
564 dropNoise();
565 }
566
567 if (normalized[0] === 'npx') return normalized.slice(1);
568 if (
569 ['yarn', 'pnpm', 'bun'].includes(normalized[0] ?? '') &&
570 ['dlx', 'exec', 'x'].includes(normalized[1] ?? '')
571 ) {
572 return normalized.slice(2);
573 }
574 return normalized;
575}
576
577function deleteHit(argv: string[], executableIndex: number, fallbackTarget = 'files'): DangerHit {
578 const executable = executableName(argv[executableIndex] ?? '');
579 const targets = positionalArgs(argv, executableIndex + 1);
580 const recursive =
581 executable === 'rimraf' ||
582 stripRedirections(argv.slice(executableIndex + 1))
583 .some((argument) => argument === '--recursive' || /^-[^-]*[rR]/.test(argument)) ||
584 targets.some((value) => value.endsWith('/'));
585 return {
586 reason: 'delete',
587 target: targets.join(', ') || fallbackTarget,
588 recursive,
589 };
590}
591
592function gitCommand(argv: string[]): { index: number; subcommand: string } | null {
593 if (executableName(argv[0] ?? '') !== 'git') return null;
594 let index = 1;
595 while (index < argv.length) {
596 const argument = argv[index] ?? '';
597 if (argument === '-C' || argument === '-c') {
598 index += 2;
599 continue;
600 }
601 if (['--git-dir', '--work-tree', '--namespace'].includes(argument)) {
602 index += 2;
603 continue;
604 }
605 if (argument.startsWith('-')) {
606 index += 1;
607 continue;
608 }
609 return { index, subcommand: argument };
610 }
611 return null;
612}
613
614/**
615 * Command substitutions and subshells, extracted with quote tracking so that
616 * prose merely *mentioning* a command is not treated as one. Without this,
617 * `git commit -m "fix (rm -rf dist) call"` warns about a deletion.
618 */
619function nestedCommands(command: string): string[] {
620 const nested: string[] = [];
621 let quote: '"' | "'" | null = null;
622 for (let index = 0; index < command.length; index += 1) {
623 const character = command.charAt(index);
624 if (quote !== null) {
625 if (character === quote) quote = null;
626 // Inside double quotes a substitution still runs; inside single quotes
627 // nothing does, so only the former is worth extracting.
628 else if (quote === '"' && character === '$' && command.charAt(index + 1) === '(') {
629 const end = command.indexOf(')', index);
630 if (end > index) {
631 nested.push(command.slice(index + 2, end));
632 index = end;
633 }
634 } else if (quote === '"' && character === '`') {
635 const end = command.indexOf('`', index + 1);
636 if (end > index) {
637 nested.push(command.slice(index + 1, end));
638 index = end;
639 }
640 }
641 continue;
642 }
643 if (character === '"' || character === "'") {
644 quote = character;
645 continue;
646 }
647 if (character === '$' && command.charAt(index + 1) === '(') {
648 const end = command.indexOf(')', index);
649 if (end > index) {
650 nested.push(command.slice(index + 2, end));
651 index = end;
652 }
653 continue;
654 }
655 if (character === '`') {
656 const end = command.indexOf('`', index + 1);
657 if (end > index) {
658 nested.push(command.slice(index + 1, end));
659 index = end;
660 }
661 continue;
662 }
663 if (character === '(' && (index === 0 || /[\s;|&]/.test(command.charAt(index - 1)))) {
664 const end = command.indexOf(')', index);
665 if (end > index) {
666 nested.push(command.slice(index + 1, end));
667 index = end;
668 }
669 }
670 }
671 return nested.filter((payload) => payload.trim() !== '');
672}
673
674/** True for `-c` and for any bundled cluster containing it, such as `-lc` or `-cx`. */
675function shellCommandFlag(argument: string): boolean {
676 return (
677 argument === '--command' ||
678 (/^-[a-zA-Z]+$/.test(argument) && argument.includes('c'))
679 );
680}
681
682/**
683 * A path-shaped argument to `git checkout` means "throw away my edits to this",
684 * which is nothing like switching branches. Refs and paths are genuinely
685 * ambiguous, so this leans toward warning: a needless caution costs a glance,
686 * while silence here costs someone their work.
687 */
688function looksLikePath(value: string): boolean {
689 return value === '.' || value.includes('*') || /\.[a-zA-Z]{1,5}$/.test(value) || value.endsWith('/');
690}
691
692function collectSegmentHits(segment: string, depth: number): DangerHit[] {
693 const hits: DangerHit[] = [];
694 if (depth > 8) return hits;
695
696 const cleaned = stripComment(segment);
697
698 for (const nested of nestedCommands(cleaned)) {
699 hits.push(...collectHits(nested, depth + 1));
700 }
701
702 const argv = stripWrapper(tokenize(cleaned));
703 const executable = executableName(argv[0] ?? '');
704
705 // A shell invoked with -c runs its argument, so the argument is the command.
706 if (['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(executable)) {
707 const flagIndex = argv.findIndex((argument, index) => index > 0 && shellCommandFlag(argument));
708 const payload = flagIndex >= 0 ? argv[flagIndex + 1] : undefined;
709 if (payload !== undefined) hits.push(...collectHits(payload, depth + 1));
710 }
711
712 if (DELETE_EXECUTABLES.has(executable)) hits.push(deleteHit(argv, 0));
713
714 const xargsIndex = argv.findIndex((argument) => executableName(argument) === 'xargs');
715 if (xargsIndex >= 0) {
716 const deleteIndex = argv.findIndex(
717 (argument, index) => index > xargsIndex && DELETE_EXECUTABLES.has(executableName(argument)),
718 );
719 if (deleteIndex >= 0) hits.push(deleteHit(argv, deleteIndex, 'the files it just listed'));
720 }
721
722 if (executable === 'find') {
723 const root = argv[1] ?? '.';
724 if (argv.includes('-delete')) {
725 hits.push({ reason: 'delete', target: `matching files inside '${root}'`, recursive: false });
726 }
727 const execIndex = argv.findIndex((argument) => argument === '-exec' || argument === '-execdir');
728 if (execIndex >= 0) {
729 const deleteIndex = argv.findIndex(
730 (argument, index) => index > execIndex && DELETE_EXECUTABLES.has(executableName(argument)),
731 );
732 if (deleteIndex >= 0) {
733 hits.push({
734 reason: 'delete',
735 target: `matching files inside '${root}'`,
736 recursive: false,
737 });
738 }
739 }
740 }
741
742 if (executable === 'truncate' && truncatesToNothing(argv)) {
743 const target = argv.at(-1) ?? 'a file';
744 if (!isScratchTarget(target)) hits.push({ reason: 'empty', target });
745 }
746
747 if (executable === 'dd') {
748 const output = argv.find((argument) => argument.startsWith('of='));
749 if (output !== undefined) {
750 hits.push({ reason: 'overwrite', target: output.slice(3) || 'a file or device' });
751 }
752 }
753
754 if (PERMISSION_EXECUTABLES.has(executable)) {
755 const options = argv.slice(1).filter((argument) => argument.startsWith('-'));
756 const positionals = positionalArgs(argv, 1);
757 // chmod and chown take the new mode or owner first; the rest are targets.
758 const targets = positionals.length > 1 ? positionals.slice(1) : positionals;
759 hits.push({
760 reason: 'permissions',
761 target: targets.join(', ') || 'files',
762 recursive: options.some((option) => option === '--recursive' || /^-[^-]*R/.test(option)),
763 });
764 }
765
766 if (executable === 'dropdb') {
767 hits.push({ reason: 'drop-data', target: positionalArgs(argv, 1).at(-1) ?? 'a database' });
768 }
769
770 if (executable === 'redis-cli' && argv.some((argument) => /^flush(all|db)$/i.test(argument))) {
771 hits.push({ reason: 'drop-data', target: 'everything stored in Redis' });
772 }
773
774 if (
775 executable === 'prisma' &&
776 ((argv[1] === 'migrate' && argv[2] === 'reset') ||
777 (argv[1] === 'db' && argv[2] === 'push' && argv.includes('--force-reset')))
778 ) {
779 hits.push({ reason: 'drop-data', target: "the project's database" });
780 }
781
782 if (DATABASE_CLIENTS.has(executable)) {
783 for (const argument of argv.slice(1)) {
784 const sql = DROP_SQL.exec(argument);
785 if (sql !== null) {
786 const kind = (sql[1] ?? 'table').toLowerCase();
787 hits.push({ reason: 'drop-data', target: `${kind} ${sql[2] ?? ''}`.trim() });
788 break;
789 }
790 if (DROP_MONGO.test(argument)) {
791 hits.push({ reason: 'drop-data', target: 'a database collection' });
792 break;
793 }
794 }
795 }
796
797 // Sending output to a file is a write like any other. It is a loss only
798 // when the command writes nothing: `: > f`, `> f`, `cat /dev/null > f`.
799 const scan = scanCommand(cleaned);
800 const words = stripWrapper(tokenize(scan.stripped));
801 const writesNothing =
802 words.length === 0 ||
803 (words.length === 1 && (words[0] === ':' || words[0] === 'true')) ||
804 (words.length === 2 && words[0] === 'cat' && words[1] === '/dev/null');
805 if (writesNothing) {
806 for (const redirect of scan.redirects) {
807 if (!redirect.append && !isScratchTarget(redirect.target)) {
808 hits.push({ reason: 'empty', target: redirect.target });
809 }
810 }
811 }
812
813 const git = gitCommand(argv);
814 if (git !== null) {
815 const gitArgs = argv.slice(git.index + 1);
816 const positionals = positionalArgs(argv, git.index + 1);
817
818 if (
819 git.subcommand === 'push' &&
820 (gitArgs.some((argument) =>
821 ['--force', '--force-with-lease'].includes(argument) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(argument)) ||
822 gitArgs.some((argument) => argument.startsWith('+')))
823 ) {
824 hits.push({ reason: 'overwrite-history', target: positionals.join(' ') || 'remote' });
825 }
826
827 if (git.subcommand === 'reset' && gitArgs.includes('--hard')) {
828 hits.push({
829 reason: 'discard-changes',
830 target: positionals[0] ?? 'working copy',
831 });
832 }
833
834 if (
835 git.subcommand === 'clean' &&
836 gitArgs.some((argument) => argument === '--force' || /^-[a-z]*f[a-z]*$/i.test(argument))
837 ) {
838 hits.push({ reason: 'delete-untracked', target: 'untracked files' });
839 }
840
841 if (
842 git.subcommand === 'checkout' &&
843 !gitArgs.some((argument) => ['-b', '-B', '--track', '--orphan'].includes(argument))
844 ) {
845 const separator = gitArgs.indexOf('--');
846 const paths = separator >= 0 ? gitArgs.slice(separator + 1) : positionals.filter(looksLikePath);
847 if (paths.length > 0) {
848 hits.push({ reason: 'discard-worktree', target: paths.join(', ') });
849 }
850 }
851
852 // `git restore --staged` only unstages; the working copy is untouched, so
853 // warning about it would be a false alarm about losing work.
854 if (git.subcommand === 'restore') {
855 const stagedOnly =
856 gitArgs.some((argument) => argument === '--staged' || argument === '-S') &&
857 !gitArgs.some((argument) => argument === '--worktree' || argument === '-W');
858 if (!stagedOnly) {
859 hits.push({ reason: 'discard-worktree', target: positionals.join(', ') || 'working copy' });
860 }
861 }
862
863 if (
864 git.subcommand === 'branch' &&
865 gitArgs.some((argument) => ['-D', '-d', '--delete'].includes(argument))
866 ) {
867 hits.push({ reason: 'delete', target: positionals.join(', ') || 'a branch', recursive: false });
868 }
869
870 if (git.subcommand === 'stash' && ['drop', 'clear'].includes(positionals[0] ?? '')) {
871 hits.push({ reason: 'delete', target: 'stashed changes', recursive: false });
872 }
873
874 if (
875 git.subcommand === 'worktree' &&
876 positionals[0] === 'remove' &&
877 gitArgs.some((argument) => argument === '--force' || argument === '-f')
878 ) {
879 hits.push({ reason: 'delete', target: positionals[1] ?? 'a worktree', recursive: true });
880 }
881 }
882
883 return hits;
884}
885
886/**
887 * `sort f > f`, `sed … f > f`, `cat f | … > f`: the shell empties f before
888 * anything in the pipeline reads it, so what was in it is lost.
889 */
890function selfOverwriteHits(command: string): DangerHit[] {
891 const hits: DangerHit[] = [];
892 for (const pipeline of splitPipelines(command)) {
893 const stages = splitSegments(stripComment(pipeline)).map((stage) => scanCommand(stage));
894 const reads = new Set<string>();
895 for (const stage of stages) {
896 stage.inputs.forEach((input) => reads.add(input));
897 const words = stripWrapper(tokenize(stage.stripped));
898 // echo and printf write their words; they read no file.
899 if (['echo', 'printf', 'tee'].includes(executableName(words[0] ?? ''))) continue;
900 words.slice(1).filter((word) => !word.startsWith('-')).forEach((word) => reads.add(word));
901 }
902 for (const stage of stages) {
903 for (const redirect of stage.redirects) {
904 if (!redirect.append && reads.has(redirect.target)) hits.push({ reason: 'empty-read', target: redirect.target });
905 }
906 }
907 }
908 return hits;
909}
910
911function collectHits(command: string, depth: number): DangerHit[] {
912 const { command: withoutBodies, bodies } = stripHeredocs(command);
913 const hits: DangerHit[] = [];
914 for (const segment of splitSegments(withoutBodies)) {
915 hits.push(...collectSegmentHits(segment, depth));
916 }
917 hits.push(...selfOverwriteHits(withoutBodies));
918 // A heredoc fed to a shell is executed; fed to anything else it is data.
919 const firstWord = executableName(tokenize(splitSegments(withoutBodies)[0] ?? '')[0] ?? '');
920 if (bodies.length > 0 && ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(firstWord)) {
921 for (const body of bodies) hits.push(...collectHits(body, depth + 1));
922 }
923 return hits;
924}
925
926/**
927 * The most severe hit in the whole command, so an incidental redirect can never
928 * hide a deletion that happens beside it.
929 */
930export function detectDanger(command: string): DangerHit | null {
931 const hits = collectHits(command, 0);
932 if (hits.length === 0) return null;
933 return hits.reduce((best, hit) => (SEVERITY[hit.reason] > SEVERITY[best.reason] ? hit : best));
934}
935engine/src/rules/bash-rules.ts 881 lines1import {
2 detectDanger,
3 isNullSink,
4 scanCommand,
5 splitPipelines,
6 splitSegments,
7 stripComment,
8 stripHeredocs,
9 tokenize,
10 truncatesToNothing,
11} from '../bash.ts';
12import { counted, plural } from '../words.ts';
13import type { Level, Line } from '../types.ts';
14
15export interface DangerHint {
16 count: number;
17 cap: boolean;
18}
19
20interface SegLine {
21 glyph: Line['glyph'];
22 id: string;
23 stem: string;
24 facts?: string;
25 tier: Line['tier'];
26 generic?: true;
27}
28
29type SegmentKind =
30 | 'test'
31 | 'install'
32 | 'remove'
33 | 'build'
34 | 'read'
35 | 'action'
36 | 'generic';
37
38const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
39const READ_COMMANDS = new Set([
40 'ls',
41 'cat',
42 'head',
43 'tail',
44 'pwd',
45 'which',
46 'echo',
47 'grep',
48 'rg',
49 'wc',
50 'stat',
51]);
52const PYTHONS = new Set(['python', 'python3']);
53const NAVIGATION = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'done', 'fi', 'esac']);
54const TEST_BINS = new Set([
55 'vitest',
56 'jest',
57 'pytest',
58 'mocha',
59 'playwright',
60]);
61
62function lineText(stem: string, facts?: string): string {
63 return facts === undefined ? stem : `${stem} ${facts}`;
64}
65
66function asLine(
67 command: string,
68 segment: SegLine,
69 tier = segment.tier,
70): Line {
71 return {
72 glyph: segment.glyph,
73 id: segment.id,
74 stem: segment.stem,
75 ...(segment.facts === undefined ? {} : { facts: segment.facts }),
76 text: lineText(segment.stem, segment.facts),
77 severity: 'info',
78 tier,
79 raw: command,
80 ...(segment.generic === true ? { generic: true as const } : {}),
81 };
82}
83
84function packageNames(argv: string[]): string | null {
85 const names = argv.slice(2).filter((argument) => !argument.startsWith('-'));
86 return names.length === 0 ? null : names.join(', ');
87}
88
89function testBin(argv: string[]): string {
90 const command = argv[0] ?? '';
91 return command === 'npx' ? (argv[1] ?? 'test runner') : command;
92}
93
94function kindOfSegment(argv: string[]): SegmentKind {
95 const command = argv[0] ?? '';
96 const subcommand = argv[1] ?? '';
97 const bin = testBin(argv);
98
99 if (
100 TEST_BINS.has(bin) ||
101 (command === 'cargo' && subcommand === 'test') ||
102 (command === 'go' && subcommand === 'test') ||
103 (command === 'deno' && subcommand === 'test') ||
104 (command === 'node' && argv.includes('--test')) ||
105 (PYTHONS.has(command) &&
106 subcommand === '-m' &&
107 ['unittest', 'pytest'].includes(argv[2] ?? '')) ||
108 (PACKAGE_MANAGERS.has(command) &&
109 (subcommand === 'test' ||
110 (subcommand === 'run' && argv[2] === 'test')))
111 ) {
112 return 'test';
113 }
114
115 if (
116 (['pip', 'pip3', 'poetry', 'brew', 'gem', 'cargo'].includes(command) && ['install', 'add'].includes(subcommand)) ||
117 (command === 'uv' && ['sync', 'add', 'pip'].includes(subcommand)) ||
118 (['yarn', 'pnpm', 'bun'].includes(command) && subcommand === 'add')
119 ) {
120 return 'install';
121 }
122
123 if (command === 'make' && (subcommand === 'test' || subcommand === 'check')) return 'test';
124 if (PACKAGE_MANAGERS.has(command) && subcommand === 'run' && /^(test|e2e)([:-]|$)/.test(argv[2] ?? '')) return 'test';
125 if (command === 'swift' && subcommand === 'test') return 'test';
126 if ((command === 'swift' && subcommand === 'build') || command === 'xcodebuild') return 'build';
127 if (
128 (command === 'make' && (subcommand === '' || ['build', 'all'].includes(subcommand))) ||
129 (['yarn', 'pnpm', 'bun'].includes(command) && subcommand === 'build') ||
130 (command === 'docker' && subcommand === 'build')
131 ) {
132 return 'build';
133 }
134
135 if (
136 PACKAGE_MANAGERS.has(command) &&
137 (subcommand === 'ci' ||
138 subcommand === 'install' ||
139 subcommand === 'add' ||
140 subcommand === 'i')
141 ) {
142 return 'install';
143 }
144
145 if (
146 PACKAGE_MANAGERS.has(command) &&
147 (subcommand === 'remove' ||
148 subcommand === 'uninstall' ||
149 subcommand === 'rm')
150 ) {
151 return 'remove';
152 }
153
154 if (
155 command === 'tsc' ||
156 (command === 'cargo' && subcommand === 'build') ||
157 (command === 'go' && subcommand === 'build') ||
158 (PACKAGE_MANAGERS.has(command) &&
159 subcommand === 'run' &&
160 argv[2] === 'build')
161 ) {
162 return 'build';
163 }
164
165 if (
166 READ_COMMANDS.has(command) ||
167 (command === 'find' &&
168 !argv.includes('-delete') &&
169 !argv.includes('-exec') &&
170 !argv.includes('-execdir')) ||
171 (command === 'git' &&
172 ['status', 'log', 'diff', 'show'].includes(subcommand))
173 ) {
174 return 'read';
175 }
176
177 if (
178 command === 'git' ||
179 ['mkdir', 'cp', 'mv', 'touch', 'chmod', 'curl', 'wget'].includes(command)
180 ) {
181 return 'action';
182 }
183
184 return 'generic';
185}
186
187function testLine(level: Level, runner: string): SegLine {
188 return {
189 glyph: '→',
190 id: 'bash.test',
191 tier: 'action',
192 stem:
193 level === 'plain'
194 ? 'Checking everything still works…'
195 : `Running the test suite (${runner || 'test runner'})`,
196 };
197}
198
199function gitLine(argv: string[], level: Level): SegLine {
200 const subcommand = argv[1] ?? '';
201 const positional = argv.slice(2).filter((a) => !a.startsWith('-'));
202 if (subcommand === '--version' || subcommand === 'version') return line('Checking which version of git is installed');
203 if (
204 ['blame', 'grep', 'ls-files', 'rev-parse', 'remote', 'shortlog', 'describe', 'reflog'].includes(subcommand) ||
205 (subcommand === 'tag' && positional.length === 0) ||
206 (subcommand === 'stash' && positional[0] === 'list') ||
207 (subcommand === 'branch' && positional.length === 0)
208 ) {
209 return line('Checking the state of the work');
210 }
211 if (subcommand === 'fetch') return line('Bringing in the latest shared work', 'action');
212 if (subcommand === 'stash' && ['pop', 'apply'].includes(positional[0] ?? '')) return line('Bringing back edits that were set aside', 'action');
213 if (subcommand === 'stash' && positional.length === 0) return line('Setting unsaved edits aside for later', 'action');
214 if (subcommand === 'rebase') return line('Replaying the work on top of another track', 'action');
215 if (subcommand === 'cherry-pick') return line('Copying a saved change from another track', 'action');
216
217 if (['status', 'log', 'diff', 'show'].includes(subcommand)) {
218 return {
219 glyph: '→',
220 id: 'bash.read',
221 tier: 'read',
222 stem:
223 level === 'plain'
224 ? 'Checking the state of the work'
225 : `Inspecting changes with git ${subcommand}`,
226 };
227 }
228
229 if (subcommand === 'commit') {
230 return {
231 glyph: '→',
232 id: 'bash.commit',
233 tier: 'action',
234 stem:
235 level === 'plain'
236 ? 'Saving a checkpoint of the work'
237 : 'Committing changes',
238 };
239 }
240
241 if (
242 (subcommand === 'checkout' && argv.includes('-b')) ||
243 subcommand === 'branch'
244 ) {
245 const branch =
246 subcommand === 'checkout'
247 ? (argv[argv.indexOf('-b') + 1] ?? 'a branch')
248 : (argv[2] ?? 'a branch');
249 return {
250 glyph: '→',
251 id: 'bash.branch',
252 tier: 'action',
253 stem:
254 level === 'plain'
255 ? 'Starting work on a separate track'
256 : `Creating branch ${branch}`,
257 };
258 }
259
260 if (subcommand === 'checkout' || subcommand === 'switch') {
261 const target =
262 argv.slice(2).find((argument) => !argument.startsWith('-')) ??
263 'another branch';
264 return {
265 glyph: '→',
266 id: 'bash.branch',
267 tier: 'action',
268 stem:
269 level === 'plain'
270 ? 'Moving to another track of work'
271 : `Switching to ${target}`,
272 };
273 }
274
275 const developer: Record<string, string> = {
276 add: 'Staging changes',
277 pull: 'Pulling changes',
278 push: 'Pushing changes',
279 merge: 'Merging changes',
280 };
281 const plain: Record<string, string> = {
282 add: 'Preparing changes for a checkpoint',
283 pull: 'Bringing in the latest shared work',
284 push: 'Sharing the saved work',
285 merge: 'Combining separate tracks of work',
286 };
287 const selected = level === 'plain' ? plain[subcommand] : developer[subcommand];
288 return {
289 glyph: '→',
290 // A git step with its own sentence is not the catch-all.
291 id: selected === undefined ? 'bash.generic' : `bash.git.${subcommand}`,
292 tier: 'action',
293 stem: selected ?? (level === 'plain' ? 'Managing saved work' : 'Running git'),
294 };
295}
296
297function genericLine(segment: string, level: Level): SegLine {
298 if (level === 'plain') {
299 return {
300 glyph: '→',
301 id: 'bash.generic',
302 tier: 'action',
303 stem: "Running one of the project's own tools",
304 generic: true,
305 };
306 }
307
308 const argv = tokenize(segment);
309 const executable = argv[0] ?? 'project-script';
310 const fileName = executable.split('/').pop() ?? executable;
311 const name = fileName.replace(/\.[^.]+$/, '') || 'project-script';
312 const optionCount = Math.max(0, argv.length - 1);
313 return {
314 glyph: '→',
315 id: 'bash.generic',
316 tier: 'action',
317 stem: `Running a project script (${name})`,
318 ...(optionCount === 0
319 ? {}
320 : {
321 facts: `(+${optionCount} option${optionCount === 1 ? '' : 's'})`,
322 }),
323 generic: true,
324 };
325}
326
327const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
328// Shell words that only lead into the real command (`then echo hi`, `if [ -f x ]`).
329const FLOW = new Set(['do', 'then', 'else', 'elif', 'if']);
330
331/** The words that say what a segment runs: no env prefix, wrapper or path. */
332export function normalizeArgv(raw: string[]): string[] {
333 let argv = [...raw];
334 while (argv[0] !== undefined && (ASSIGN.test(argv[0]) || FLOW.has(argv[0]))) argv.shift();
335 for (;;) {
336 const first = argv[0] ?? '';
337 if (first === 'timeout') argv = argv.slice(2);
338 else if (['time', 'nice', 'sudo', 'nohup'].includes(first)) argv = argv.slice(1);
339 else if (first.endsWith('/env') || (first === 'env' && argv.length > 1 && !argv[1]?.startsWith('-') && argv[1] !== '|')) {
340 argv = argv.slice(1);
341 while (argv[0] !== undefined && ASSIGN.test(argv[0])) argv.shift();
342 } else break;
343 }
344 if ((argv[0] ?? '').includes('/') && !(argv[0] ?? '').startsWith('./')) argv[0] = (argv[0] ?? '').split('/').pop() ?? '';
345 if (argv[0] === 'git' && argv[1] === '-C') argv.splice(1, 2);
346 if (PYTHONS.has(argv[0] ?? '') && argv[1] === '-m' && argv[2] === 'pip') argv = ['pip', ...argv.slice(3)];
347 return argv;
348}
349
350function line(stem: string, tier: Line['tier'] = 'read', id = 'bash.more'): SegLine {
351 return { glyph: '→', id, tier, stem };
352}
353
354function scriptName(path: string): string {
355 return path.split('/').pop() ?? path;
356}
357
358const NPM_TASKS: Record<string, string> = {
359 lint: 'Checking the code for mistakes',
360 typecheck: 'Checking the code for mistakes',
361 'type-check': 'Checking the code for mistakes',
362 format: "Tidying the code's layout",
363 dev: 'Starting the app',
364 start: 'Starting the app',
365 serve: 'Starting the app',
366 preview: 'Starting the app',
367};
368const HELPER_TOOLS: Record<string, string> = {
369 tsc: 'Checking the code for mistakes',
370 eslint: 'Checking the code for mistakes',
371 prettier: "Tidying the code's layout",
372 vite: 'Starting the app',
373};
374
375/** Common commands the original rules had no sentence for. */
376function moreLine(argv: string[]): SegLine | null {
377 const [command = '', sub = '', third = ''] = argv;
378 const args = argv.slice(1);
379 if (args.length > 0 && args.every((a) => ['--version', '-v', '-V', '-version', 'version'].includes(a))) {
380 return line(`Checking which version of ${command} is installed`);
381 }
382 if (command === 'clear') return line('Clearing the screen');
383 if (FLOW.has(command) || ['for', 'while', 'until', 'done', 'fi', 'esac'].includes(command)) return null;
384 if (['tree'].includes(command)) return line('Looking around the project');
385 if (['less', 'more', 'bat'].includes(command)) return line('Looking at existing files');
386 if (['du', 'df'].includes(command)) return line('Checking how much space things take up');
387 if (['file', 'stat'].includes(command)) return line("Looking at a file's details");
388 if (['which', 'type', 'whereis', 'realpath'].includes(command) || (command === 'command' && sub === '-v')) return line('Checking where something is on the computer');
389 if (['env', 'printenv'].includes(command)) return line("Checking the computer's settings");
390 if (['ps', 'lsof', 'pgrep', 'top'].includes(command)) return line('Checking which programs are running');
391 if (['date', 'uname', 'whoami', 'hostname', 'id', 'history', 'sw_vers', 'sysctl', 'system_profiler', 'uptime', 'arch', 'vm_stat', 'nproc', 'lscpu', 'free', 'ioreg'].includes(command)) return line('Checking details about the computer');
392 if (command === 'test' || command === '[' || command === '[[') return line('Checking whether something is there');
393 if (command === 'diff' || command === 'cmp') return line('Comparing two files');
394 if (command === 'sed' && args.some((a) => a === '-i' || a.startsWith('-i'))) return line('Changing text inside a file', 'action');
395 if (['sed', 'awk', 'cut', 'sort', 'uniq', 'tr', 'xargs', 'column', 'nl'].includes(command)) return line("Reading through a file's contents");
396 if (command === 'jq' || command === 'yq') return line('Pulling details out of a data file');
397 if (['kill', 'pkill', 'killall'].includes(command)) return line('Stopping a running program', 'action');
398 if (command === 'ping' || command === 'dig' || command === 'nslookup') return line('Checking the internet connection');
399 if (command === 'open' || command === 'xdg-open') return line('Opening something on your computer', 'action');
400 if (command === 'tar' && args.some((a) => /^-?[a-z]*x/.test(a))) return line('Unpacking an archive', 'action');
401 if (command === 'tar' || command === 'zip') return line('Packing files into an archive', 'action');
402 if (command === 'unzip' || command === 'gunzip') return line('Unpacking an archive', 'action');
403 if (command === 'ln') return line('Linking to a file or folder', 'action');
404 if (command === 'openssl') return line('Making or checking a security key', 'action');
405 if (['psql', 'sqlite3', 'mysql', 'redis-cli', 'mongosh'].includes(command)) return line('Looking at the database');
406 if (command === 'docker' || command === 'podman') {
407 if (['ps', 'logs', 'images', 'inspect'].includes(sub)) return line("Checking the app's containers");
408 if (sub === 'compose' && ['up', 'start'].includes(third)) return line("Starting the app's services", 'action');
409 if (sub === 'compose' && ['down', 'stop'].includes(third)) return line("Stopping the app's services", 'action');
410 return line("Managing the app's containers", 'action');
411 }
412 if (command === 'kubectl') return line(sub === 'get' || sub === 'describe' || sub === 'logs' ? "Checking the app's servers" : "Changing the app's servers", sub === 'get' ? 'read' : 'action');
413 if (command === 'terraform') return line(sub === 'plan' ? 'Planning changes to the servers' : 'Changing the servers', sub === 'plan' ? 'read' : 'action');
414 if (command === 'gh') return line(['list', 'view', 'status', 'diff', 'checks'].includes(third) ? 'Checking the project on GitHub' : 'Updating the project on GitHub', ['list', 'view', 'status', 'diff', 'checks'].includes(third) ? 'read' : 'action');
415 if (PACKAGE_MANAGERS.has(command)) {
416 const task = sub === 'run' ? third : sub;
417 if (NPM_TASKS[task] !== undefined) return line(NPM_TASKS[task] ?? '', 'action');
418 if (sub === 'outdated') return line('Checking for newer versions of its building blocks');
419 if (sub === 'ls' || sub === 'list' || sub === 'why') return line('Checking which building blocks are installed');
420 if (sub === 'run' && third !== '') return line(`Running the project's '${third}' task`, 'action');
421 }
422 if (['npx', 'pnpx', 'bunx'].includes(command) || (['pnpm', 'yarn', 'bun'].includes(command) && ['dlx', 'exec', 'x'].includes(sub))) {
423 const tool = command.endsWith('x') && command !== 'npx' ? sub : ['dlx', 'exec', 'x'].includes(sub) ? third : sub;
424 const name = tool.replace(/(.)@[^/]*$/, '$1');
425 return line(HELPER_TOOLS[name] ?? `Running a helper tool (${name})`, 'action');
426 }
427 if ((command === 'go' || command === 'cargo') && sub === 'run') return line('Starting the program', 'action');
428 if ((command === 'go' && sub === 'mod') || (command === 'cargo' && sub === 'update')) return line("Tidying the project's building blocks", 'action');
429 if ((command === 'cargo' && sub === 'fmt') || command === 'gofmt' || (command === 'go' && sub === 'fmt')) return line("Tidying the code's layout", 'action');
430 if (PYTHONS.has(command) && sub === '-m' && third === 'venv') return line('Setting up a separate Python workspace', 'action');
431 if (PYTHONS.has(command) && sub === '-m' && third !== '') return line(`Running a Python tool (${third})`, 'action');
432 if (['node', 'deno', 'bun', 'ruby', 'perl', 'php', 'python', 'python3'].includes(command)) {
433 if (['-e', '-c', '--eval', '-p'].includes(sub)) return line('Running a quick snippet of code', 'action');
434 if (args.includes('-')) return line('Running a quick snippet of code', 'action');
435 const file = args.find((a) => !a.startsWith('-') && a !== 'run');
436 if (file !== undefined) return line(`Running a script (${scriptName(file)})`, 'action');
437 }
438 if ((command === 'sh' || command === 'bash' || command === 'zsh') && sub !== '' && !sub.startsWith('-')) return line(`Running a script (${scriptName(sub)})`, 'action');
439 if (command.startsWith('./') || /\.(sh|py|js|rb)$/.test(command)) return line(`Running a script (${scriptName(command)})`, 'action');
440 return null;
441}
442
443function classifySegment(segment: string, level: Level): SegLine {
444 const argv = normalizeArgv(tokenize(segment));
445 const command = argv[0] ?? '';
446 const subcommand = argv[1] ?? '';
447 const kind = kindOfSegment(argv);
448
449 if (kind === 'test') return testLine(level, testBin(argv));
450
451 if (kind === 'install') {
452 if (subcommand === 'ci') {
453 return {
454 glyph: '→',
455 id: 'bash.install.ci',
456 tier: 'action',
457 stem:
458 level === 'plain'
459 ? "Setting up the project's building blocks"
460 : `Installing dependencies from lockfile (${command} ci)`,
461 };
462 }
463 const names = packageNames(argv);
464 return {
465 glyph: '→',
466 id: 'bash.install.pkg',
467 tier: 'action',
468 stem:
469 level === 'plain'
470 ? names === null
471 ? "Setting up the project's building blocks"
472 : 'Adding a ready-made tool to the project'
473 : names === null
474 ? 'Installing dependencies'
475 : `Installing package ${names}`,
476 };
477 }
478
479 if (kind === 'build') {
480 return {
481 glyph: '→',
482 id: 'bash.build',
483 tier: 'action',
484 stem: level === 'plain' ? 'Assembling the app' : 'Building the project',
485 };
486 }
487
488 if (kind === 'remove') {
489 const names = packageNames(argv);
490 return {
491 glyph: '→',
492 id: 'bash.install.pkg',
493 tier: 'action',
494 stem:
495 level === 'plain'
496 ? 'Removing a ready-made tool from the project'
497 : names === null
498 ? 'Removing a package'
499 : `Removing package ${names}`,
500 };
501 }
502
503 if (kind === 'read') {
504 if (command === 'grep' || command === 'rg') {
505 const terms = argv.filter((argument) => !argument.startsWith('-'));
506 return {
507 glyph: '→',
508 id: 'bash.search',
509 tier: 'read',
510 stem:
511 level === 'plain'
512 ? 'Searching through the project'
513 : `Searching for ${terms[1] ?? 'a pattern'}`,
514 };
515 }
516 if (command === 'ls') {
517 return {
518 glyph: '→',
519 id: 'bash.read',
520 tier: 'read',
521 stem: level === 'plain' ? 'Looking around the project' : 'Listing files',
522 };
523 }
524 if (command === 'git') return gitLine(argv, level);
525 if (level === 'plain') {
526 if (command === 'echo') {
527 return line(argv.slice(1).some((a) => a.startsWith('$')) ? "Checking the computer's settings" : 'Showing a message');
528 }
529 if (command === 'pwd') return line('Checking which folder it is working in');
530 if (command === 'which') return line('Checking where something is on the computer');
531 if (command === 'wc') return line('Counting what is in a file');
532 if (command === 'tail' && argv.includes('-f')) return line('Watching a log as it grows');
533 }
534 return {
535 glyph: '→',
536 id: 'bash.read',
537 tier: 'read',
538 stem:
539 level === 'plain'
540 ? 'Looking at existing files'
541 : `Reading with ${command || 'a file tool'}`,
542 };
543 }
544
545 if (command === 'git') return gitLine(argv, level);
546
547 if (command === 'mkdir') {
548 const target =
549 argv.slice(1).find((argument) => !argument.startsWith('-')) ?? '';
550 return {
551 glyph: '→',
552 id: 'bash.mkdir',
553 tier: 'action',
554 stem:
555 level === 'plain'
556 ? 'Creating a folder'
557 : `Creating folder ${target}`.trim(),
558 };
559 }
560
561 if (['cp', 'mv', 'touch', 'chmod'].includes(command)) {
562 const target =
563 argv.slice(1).find((argument) => !argument.startsWith('-')) ?? 'files';
564 const verb =
565 command === 'cp'
566 ? 'Copying'
567 : command === 'mv'
568 ? 'Moving'
569 : command === 'touch'
570 ? 'Creating'
571 : 'Changing permissions on';
572 return {
573 glyph: '→',
574 id: 'bash.fileops',
575 tier: 'action',
576 stem:
577 level === 'plain' ? 'Organizing project files' : `${verb} ${target}`,
578 };
579 }
580
581 if (command === 'curl' || command === 'wget') {
582 const url =
583 argv.find((argument) => argument.startsWith('http')) ?? 'a URL';
584 return {
585 glyph: '→',
586 id: 'bash.fetch',
587 tier: 'action',
588 stem:
589 level === 'plain'
590 ? 'Fetching something from the internet'
591 : `Fetching ${url}`,
592 };
593 }
594
595 const more = moreLine(argv);
596 if (more !== null) return more;
597 if (['for', 'while', 'until'].includes(command)) {
598 return { ...line('Repeating a step', 'read', 'bash.flow') };
599 }
600 return genericLine(segment, level);
601}
602
603function normalizedHint(hint: DangerHint | undefined): DangerHint | null {
604 if (
605 hint === undefined ||
606 !Number.isFinite(hint.count) ||
607 hint.count < 0 ||
608 typeof hint.cap !== 'boolean'
609 ) {
610 return null;
611 }
612 return { count: Math.floor(hint.count), cap: hint.cap };
613}
614
615function countText(hint: DangerHint): string {
616 if (hint.cap) {
617 return `${hint.count}+ ${
618 hint.count === 1 ? 'item' : 'items'
619 }`;
620 }
621 return counted(hint.count, 'item');
622}
623
624function dangerLine(
625 command: string,
626 level: Level,
627 hint: DangerHint | undefined,
628): Line | null {
629 const danger = detectDanger(command);
630 if (danger === null) return null;
631 const measured = normalizedHint(hint);
632 let stem: string;
633 let facts: string | undefined;
634
635 if (danger.reason === 'delete') {
636 if (measured === null && danger.recursive === true) {
637 if (level === 'developer') {
638 stem = `Deleting ${danger.target}`;
639 facts = '(entire folder)';
640 } else {
641 stem = `Deleting '${danger.target}' and everything inside it`;
642 }
643 } else if (measured === null) {
644 stem =
645 level === 'plain'
646 ? `Deleting '${danger.target}'`
647 : `Deleting ${danger.target}`;
648 } else if (danger.recursive !== true) {
649 stem =
650 level === 'plain'
651 ? `Deleting '${danger.target}'`
652 : `Deleting ${danger.target}`;
653 facts = `(${countText(measured)})`;
654 } else if (level === 'developer') {
655 stem = `Deleting ${danger.target}`;
656 facts = `(${countText(measured)})`;
657 } else {
658 stem = `Deleting '${danger.target}' and the ${countText(measured)} inside it`;
659 }
660 } else if (danger.reason === 'delete-untracked') {
661 if (measured === null) {
662 stem =
663 level === 'plain'
664 ? 'Permanently deleting files the project is not tracking'
665 : 'Permanently deleting untracked files';
666 } else if (level === 'plain') {
667 stem = `Permanently deleting the ${countText(measured)} the project is not tracking`;
668 } else {
669 stem = 'Permanently deleting untracked files';
670 facts = `(${countText(measured)})`;
671 }
672 } else if (danger.reason === 'overwrite-history') {
673 stem =
674 level === 'plain'
675 ? 'Replacing the shared online copy of this work; older versions there are lost'
676 : `Overwriting the online history (${danger.target})`;
677 } else if (danger.reason === 'empty') {
678 stem = level === 'plain' ? `Emptying '${danger.target}'` : `Emptying ${danger.target}`;
679 } else if (danger.reason === 'empty-read') {
680 stem =
681 level === 'plain'
682 ? `Emptying '${danger.target}' before it is read, so what was in it is lost`
683 : `Emptying ${danger.target} before it is read`;
684 } else if (danger.reason === 'overwrite') {
685 stem =
686 level === 'plain'
687 ? `Writing over '${danger.target}'; anything already in it is lost`
688 : `Overwriting ${danger.target}`;
689 } else if (danger.reason === 'permissions') {
690 if (level === 'developer') {
691 stem = `Changing permissions on ${danger.target}`;
692 if (danger.recursive === true) facts = '(entire folder)';
693 } else {
694 stem =
695 danger.recursive === true
696 ? `Changing who can open or change '${danger.target}' and everything inside it`
697 : `Changing who can open or change '${danger.target}'`;
698 }
699 } else if (danger.reason === 'drop-data') {
700 stem =
701 level === 'plain'
702 ? `Permanently erasing stored data (${danger.target})`
703 : `Dropping data (${danger.target})`;
704 } else if (danger.reason === 'discard-worktree') {
705 stem =
706 level === 'plain'
707 ? `Throwing away unsaved edits in ${danger.target}`
708 : `Discarding working-copy changes (${danger.target})`;
709 } else if (level === 'developer') {
710 stem = `Throwing away recent changes (${danger.target})`;
711 } else {
712 const revision = /^HEAD~(\d+)$/.exec(danger.target);
713 const count = revision === null ? null : Number(revision[1] ?? 0);
714 if (
715 danger.target === 'working copy' ||
716 danger.target === 'HEAD' ||
717 count === 0
718 ) {
719 stem = 'Throwing away every unsaved edit in the project';
720 } else if (count !== null) {
721 stem =
722 count === 1
723 ? 'Throwing away the last saved checkpoint and any unsaved edits'
724 : `Throwing away the last ${count} saved checkpoints and any unsaved edits`;
725 } else {
726 stem = `Throwing away unsaved edits and moving the work to ${danger.target}`;
727 }
728 }
729
730 return {
731 glyph: '⚠',
732 id:
733 danger.reason === 'delete' || danger.reason === 'delete-untracked'
734 ? 'bash.danger.delete'
735 : danger.reason === 'overwrite-history'
736 ? 'bash.danger.history'
737 : danger.reason === 'permissions'
738 ? 'bash.danger.permissions'
739 : danger.reason === 'drop-data'
740 ? 'bash.danger.data'
741 : 'bash.danger.discard',
742 stem,
743 ...(facts === undefined ? {} : { facts }),
744 text: lineText(stem, facts),
745 severity: 'danger',
746 tier: 'danger',
747 raw: command,
748 };
749}
750
751export function translateBash(
752 command: string,
753 level: Level,
754 hint?: DangerHint,
755): Line {
756 const danger = dangerLine(command, level, hint);
757 if (danger !== null) return danger;
758
759 // Moving to a folder or setting a variable is not a step anyone asked for:
760 // `cd app && npm test` is a test run, not "+1 more step".
761 const stages: Array<{ text: string; piped: boolean }> = [];
762 for (const pipeline of splitPipelines(stripHeredocs(command).command)) {
763 splitSegments(stripComment(pipeline)).forEach((text, index) => stages.push({ text, piped: index > 0 }));
764 }
765 const steps = stages.filter((stage) => !NAVIGATION.has(tokenize(stage.text)[0] ?? ''));
766 const chosen = steps.length > 0 ? steps : stages;
767 const { lines: classified, savedTo } = classifyStages(chosen, level);
768 const actions = classified.filter((segment) => segment.tier === 'action');
769 // Filler (a pause, staging before a commit, an unknown script) never names
770 // a chain when a step that says what the chain is for comes later.
771 const meaningful = actions.filter(
772 (segment) => segment.id !== 'bash.generic' && segment.id !== 'bash.git.add',
773 );
774 const picked =
775 meaningful[0] ??
776 actions[0] ??
777 classified[0] ??
778 genericLine(command.trim(), level);
779 const extraCount = Math.max(0, actions.length - 1);
780 const facts =
781 extraCount === 0
782 ? picked.facts
783 : `(+${extraCount} more ${plural(extraCount, 'step')})`;
784 // Output sent to a file is a short fact on the step that made it.
785 const saved = facts === undefined && savedTo !== undefined ? `(${savedTo})` : facts;
786 return asLine(
787 command,
788 {
789 ...picked,
790 ...(saved === undefined ? {} : { facts: saved }),
791 },
792 actions.length === 0 ? picked.tier : 'action',
793 );
794}
795
796const CONTENT_COMMANDS = new Set(['cat', 'echo', 'printf']);
797
798function fileName(target: string): string | undefined {
799 const clean = target.replace(/^["']|["']$/g, '');
800 if (clean === '' || /[$`]/.test(clean)) return undefined;
801 return clean.split('/').filter(Boolean).pop();
802}
803
804function writeLine(target: string, kind: 'write' | 'append' | 'empty'): SegLine {
805 const name = fileName(target);
806 const stem =
807 kind === 'append' ? 'Adding to a file' : kind === 'empty' ? 'Emptying a file' : 'Writing a file';
808 return { glyph: '✎', id: 'bash.write', tier: 'action', stem, ...(name === undefined ? {} : { facts: `(${name})` }) };
809}
810
811/**
812 * Classifies each step, reading shell writes the way the Write tool's rows
813 * read: `cat > f <<EOF`, `echo … > f` and `tee f` write a file (never "showing"
814 * one), and any other command whose output goes to a file stays that command.
815 * Whether the file existed before is never claimed either way.
816 */
817function classifyStages(
818 stages: ReadonlyArray<{ text: string; piped: boolean }>,
819 level: Level,
820): { lines: SegLine[]; savedTo?: string } {
821 const lines: SegLine[] = [];
822 let savedTo: string | undefined;
823 let producerIsContent = false;
824 for (const stage of stages) {
825 const scan = scanCommand(stage.text);
826 const words = normalizeArgv(tokenize(scan.stripped));
827 const output = scan.redirects.find((redirect) => redirect.fd === '1' || redirect.fd === '&');
828 const errors = scan.redirects.find((redirect) => redirect.fd === '2');
829 const first = words[0] ?? '';
830 const writesNothing = words.length === 0 || first === ':' || first === 'true' || (first === 'cat' && words[1] === '/dev/null');
831 const isContent = writesNothing || CONTENT_COMMANDS.has(first);
832 if (!stage.piped) producerIsContent = isContent;
833 if (first === 'tee') {
834 const target = words.slice(1).find((word) => !word.startsWith('-') && !isNullSink(word));
835 // `tee /dev/stderr` only passes the output along; it is not a step.
836 if (target === undefined) continue;
837 if (!stage.piped || producerIsContent) {
838 // `tee f <<EOF`, `echo … | tee f`: the file is the point of the step.
839 if (stage.piped && lines.length > 0) lines.pop();
840 const append = words.some((word) => word === '--append' || /^-[a-z]*a/.test(word));
841 lines.push(writeLine(target, append ? 'append' : 'write'));
842 } else {
843 savedTo ??= `output saved to ${fileName(target) ?? 'a file'}`;
844 }
845 continue;
846 }
847 if (first === 'truncate' && truncatesToNothing(words)) {
848 // A named file being emptied is a danger line already; a scratch one is routine.
849 lines.push(writeLine(words[words.length - 1] ?? '', 'empty'));
850 continue;
851 }
852 if (output !== undefined && isContent) {
853 const kind = writesNothing ? 'empty' : output.append ? 'append' : 'write';
854 lines.push(writeLine(output.target, kind));
855 continue;
856 }
857 if (output !== undefined) savedTo ??= `output saved to ${fileName(output.target) ?? 'a file'}`;
858 else if (errors !== undefined) savedTo ??= `errors saved to ${fileName(errors.target) ?? 'a file'}`;
859 lines.push(classifySegment(stage.text, level));
860 }
861 return savedTo === undefined ? { lines } : { lines, savedTo };
862}
863
864function hasKind(command: string, expected: SegmentKind): boolean {
865 return splitSegments(command).some(
866 (segment) => kindOfSegment(tokenize(segment)) === expected,
867 );
868}
869
870export function isTestCommand(command: string): boolean {
871 return hasKind(command, 'test');
872}
873
874export function isInstallCommand(command: string): boolean {
875 return hasKind(command, 'install');
876}
877
878export function isBuildCommand(command: string): boolean {
879 return hasKind(command, 'build');
880}
881engine/src/rules/outcome-rules.ts 325 lines1import type { Level, Line, CodevibezEvent } from '../types.ts';
2import { counted, plural } from '../words.ts';
3import { tokenize } from '../bash.ts';
4import {
5 isBuildCommand,
6 isInstallCommand,
7 isTestCommand,
8} from './bash-rules.ts';
9
10export interface TestCounts {
11 failed: number;
12 passed: number;
13}
14
15function numberAt(match: RegExpMatchArray, index: number): number {
16 return Number(match[index] ?? 0);
17}
18
19function pairedCounts(output: string): TestCounts | null {
20 const failedFirst = output.match(
21 /(\d+)\s+fail(?:ed|ing)\b[\s\S]{0,200}?(\d+)\s+pass(?:ed|ing)\b/i,
22 );
23 if (failedFirst !== null) {
24 return {
25 failed: numberAt(failedFirst, 1),
26 passed: numberAt(failedFirst, 2),
27 };
28 }
29
30 const passedFirst = output.match(
31 /(\d+)\s+pass(?:ed|ing)\b[\s\S]{0,200}?(\d+)\s+fail(?:ed|ing)\b/i,
32 );
33 if (passedFirst !== null) {
34 return {
35 failed: numberAt(passedFirst, 2),
36 passed: numberAt(passedFirst, 1),
37 };
38 }
39
40 return null;
41}
42
43function cargoCounts(output: string): TestCounts | null {
44 const lines = output
45 .split('\n')
46 .filter((line) => /\btest result:/i.test(line));
47 if (lines.length === 0) return null;
48
49 let failed = 0;
50 let passed = 0;
51 let matched = false;
52 for (const line of lines) {
53 const passedMatch = line.match(/(\d+)\s+passed\b/i);
54 const failedMatch = line.match(/(\d+)\s+failed\b/i);
55 if (passedMatch !== null || failedMatch !== null) matched = true;
56 passed += passedMatch === null ? 0 : numberAt(passedMatch, 1);
57 failed += failedMatch === null ? 0 : numberAt(failedMatch, 1);
58 }
59 return matched ? { failed, passed } : null;
60}
61
62function goCounts(output: string): TestCounts | null {
63 const passed = output.match(/^\s*--- PASS:/gm)?.length ?? 0;
64 const failed = output.match(/^\s*--- FAIL:/gm)?.length ?? 0;
65 return passed + failed === 0 ? null : { failed, passed };
66}
67
68// Node's built-in runner ends with summary lines, `# pass 1` (TAP) or
69// `ℹ pass 1` (spec): the word comes before the number.
70function nodeCounts(output: string): TestCounts | null {
71 const passed = output.match(/^\s*(?:#|ℹ)\s*pass\s+(\d+)\s*$/m);
72 const failed = output.match(/^\s*(?:#|ℹ)\s*fail\s+(\d+)\s*$/m);
73 if (passed === null || failed === null) return null;
74 return { failed: numberAt(failed, 1), passed: numberAt(passed, 1) };
75}
76
77// Python's unittest: `Ran 3 tests in 0.01s`, then `OK` or
78// `FAILED (failures=1, errors=1, skipped=2)`.
79function unittestCounts(output: string): TestCounts | null {
80 const ran = output.match(/^Ran (\d+) tests? in /m);
81 const status = output.match(/^(OK|FAILED)\b(?:\s*\(([^)]*)\))?/m);
82 if (ran === null || status === null) return null;
83 const detail = status[2] ?? '';
84 const field = (name: string): number =>
85 Number(new RegExp(`\\b${name}=(\\d+)`).exec(detail)?.[1] ?? 0);
86 const failed = field('failures') + field('errors') + field('unexpected successes');
87 const skipped = field('skipped') + field('expected failures');
88 return { failed, passed: Math.max(0, numberAt(ran, 1) - failed - skipped) };
89}
90
91export function parseTestCounts(output: string): TestCounts | null {
92 const lines = output.split('\n');
93 const authoritative = lines.filter(
94 (line) =>
95 /^\s*Tests(?::|\s)/i.test(line) &&
96 !/^\s*Test (?:Files|Suites)\b/i.test(line) &&
97 !/^\s*Suites\b/i.test(line),
98 );
99 for (let index = authoritative.length - 1; index >= 0; index -= 1) {
100 const line = authoritative[index];
101 if (line === undefined) continue;
102 const counts = pairedCounts(line);
103 if (counts !== null) return counts;
104 }
105
106 const cargo = cargoCounts(output);
107 if (cargo !== null) return cargo;
108
109 const go = goCounts(output);
110 if (go !== null) return go;
111
112 const node = nodeCounts(output);
113 if (node !== null) return node;
114
115 const unittest = unittestCounts(output);
116 if (unittest !== null) return unittest;
117
118 const filtered = lines
119 .filter(
120 (line) =>
121 !/^\s*Test (?:Files|Suites)\b/i.test(line) &&
122 !/^\s*Suites\b/i.test(line),
123 )
124 .join('\n');
125 const paired = pairedCounts(filtered);
126 if (paired !== null) return paired;
127
128 if (/\d+\s+fail/i.test(output)) return null;
129 const passed = output.match(
130 /(?:Tests\s*:?\s*)?(\d+)\s+pass(?:ed|ing)\b/i,
131 );
132 return passed === null
133 ? null
134 : { failed: 0, passed: numberAt(passed, 1) };
135}
136
137function resultLine(
138 glyph: Line['glyph'],
139 severity: Line['severity'],
140 stem: string,
141 raw: string,
142 facts?: string,
143): Line {
144 const id =
145 severity === 'failure'
146 ? /checks failed/.test(stem)
147 ? 'out.test.fail'
148 : 'out.fail'
149 : /checks/i.test(stem)
150 ? 'out.test.pass'
151 : stem === 'Set up finished'
152 ? 'out.install.ok'
153 : 'out.build.ok';
154 return {
155 glyph,
156 id,
157 severity,
158 tier: 'action',
159 stem,
160 ...(facts === undefined ? {} : { facts }),
161 text: facts === undefined ? stem : `${stem} ${facts}`,
162 raw,
163 };
164}
165
166function runnerName(command: string): string {
167 const argv = tokenize(command);
168 const executable = argv[0] ?? '';
169 if (executable === 'npx') return argv[1] ?? 'test';
170 if (
171 ['npm', 'pnpm', 'yarn', 'bun'].includes(executable) &&
172 (argv[1] === 'test' || argv[1] === 'run')
173 ) {
174 return executable;
175 }
176 const fileName = executable.split('/').pop() ?? executable;
177 return fileName.replace(/\.[^.]+$/, '') || 'test';
178}
179
180function firstOutputLine(event: CodevibezEvent): string {
181 const stderr =
182 typeof event.outcome?.stderr === 'string' ? event.outcome.stderr : '';
183 const stdout =
184 typeof event.outcome?.stdout === 'string' ? event.outcome.stdout : '';
185 return (
186 stderr.split('\n').find((line) => line.trim() !== '') ??
187 stdout.split('\n').find((line) => line.trim() !== '') ??
188 ''
189 )
190 .trim()
191 .slice(0, 80);
192}
193
194function plainFailureStem(output: string): string {
195 if (
196 /cannot find module|module not found|no such file|enoent\b/i.test(output)
197 ) {
198 return 'Something needed for that step could not be found';
199 }
200 if (/permission denied|eacces\b|eperm\b/i.test(output)) {
201 return 'That step was not allowed to access something it needed';
202 }
203 if (/eaddrinuse\b|address already in use|port .*in use/i.test(output)) {
204 return 'That step could not start because its connection point is already in use';
205 }
206 if (/syntaxerror\b|syntax error|unexpected token/i.test(output)) {
207 return 'That step could not run because its instructions were malformed';
208 }
209 if (
210 /enetunreach\b|econnrefused\b|network|dns|timed? out|fetch failed/i.test(
211 output,
212 )
213 ) {
214 return 'That step could not reach the network';
215 }
216 return 'Something went wrong while running that step';
217}
218
219export function translateOutcome(
220 event: CodevibezEvent,
221 level: Level,
222): Line | null {
223 try {
224 const outcome = event.outcome;
225 if (outcome === undefined) return null;
226
227 const raw =
228 event.raw !== undefined &&
229 typeof event.raw === 'object' &&
230 event.raw !== null &&
231 !Array.isArray(event.raw)
232 ? event.raw
233 : {};
234 const command =
235 typeof raw.command === 'string' ? raw.command : '';
236 const stdout =
237 typeof outcome.stdout === 'string' ? outcome.stdout : '';
238 const stderr =
239 typeof outcome.stderr === 'string' ? outcome.stderr : '';
240 const output = `${stdout}\n${stderr}`;
241 const rawOutput = output.slice(0, 400);
242 const test = command !== '' && isTestCommand(command);
243
244 if (test) {
245 const counts = parseTestCounts(output);
246 if (counts !== null) {
247 if (counts.failed > 0) {
248 const total = counts.failed + counts.passed;
249 const noun =
250 level === 'developer'
251 ? plural(total, 'test')
252 : plural(total, 'check');
253 const stem = `${counts.failed} of ${total} ${noun} failed`;
254 return resultLine('✗', 'failure', stem, rawOutput);
255 }
256 if (outcome.ok === true) {
257 return resultLine(
258 '✓',
259 'success',
260 'All checks pass',
261 rawOutput,
262 `(${counted(counts.passed, 'check')})`,
263 );
264 }
265 }
266
267 const hasFailureSignal = /\bfail(?:ed|ing|ure)?\b|error/i.test(output);
268 if (outcome.ok !== true || hasFailureSignal) {
269 const stem =
270 level === 'plain'
271 ? 'The checks failed; codevibez could not read how many'
272 : `The ${runnerName(
273 command,
274 )} checks failed; codevibez could not read how many`;
275 return resultLine('✗', 'failure', stem, rawOutput);
276 }
277
278 return resultLine('✓', 'success', 'Checks finished', rawOutput);
279 }
280
281 if (outcome.ok !== true) {
282 if (level === 'developer') {
283 const first = firstOutputLine(event);
284 return resultLine(
285 '✗',
286 'failure',
287 'Something went wrong while running that step',
288 rawOutput,
289 first === '' ? '(1 failure)' : `(${first})`,
290 );
291 }
292 return resultLine(
293 '✗',
294 'failure',
295 plainFailureStem(output),
296 rawOutput,
297 '(1 failure)',
298 );
299 }
300
301 if (command !== '' && isInstallCommand(command)) {
302 const installed = output.match(/added\s+(\d+)\s+packages?/i);
303 const installedCount =
304 installed === null ? null : numberAt(installed, 1);
305 return resultLine(
306 '✓',
307 'success',
308 'Set up finished',
309 rawOutput,
310 installedCount === null
311 ? undefined
312 : `(${counted(installedCount, 'package')})`,
313 );
314 }
315
316 if (command !== '' && isBuildCommand(command)) {
317 return resultLine('✓', 'success', 'Build finished', rawOutput);
318 }
319
320 return null;
321 } catch {
322 return null;
323 }
324}
325engine/src/words.ts 16 lines1export function plural(
2 count: number,
3 singular: string,
4 pluralForm = `${singular}s`,
5): string {
6 return count === 1 ? singular : pluralForm;
7}
8
9export function counted(
10 count: number,
11 singular: string,
12 pluralForm = `${singular}s`,
13): string {
14 return `${count} ${plural(count, singular, pluralForm)}`;
15}
16engine/src/prompt.ts 7 lines1export function formatPrompt(text: string, limit = 80): string {
2 const collapsed = text.replace(/\s+/g, ' ').trim();
3 return collapsed.length > limit
4 ? `${collapsed.slice(0, limit - 1)}…`
5 : collapsed;
6}
7engine/src/rules/file-rules.ts 145 lines1import type { Level, Line } from '../types.ts';
2
3function baseName(path: unknown): string {
4 if (typeof path !== 'string' || path.length === 0) return 'a file';
5 return path.split(/[\\/]/).pop() || 'a file';
6}
7
8function rawText(raw: Record<string, unknown>): string {
9 try {
10 return JSON.stringify(raw).slice(0, 200);
11 } catch {
12 return '[unavailable tool input]';
13 }
14}
15
16function makeLine(
17 raw: Record<string, unknown>,
18 glyph: Line['glyph'],
19 tier: Line['tier'],
20 stem: string,
21 facts?: string,
22 id = 'file.edit',
23): Line {
24 return {
25 glyph,
26 id,
27 stem,
28 ...(facts === undefined ? {} : { facts }),
29 text: facts === undefined ? stem : `${stem} ${facts}`,
30 tier,
31 severity: 'info',
32 raw: rawText(raw),
33 };
34}
35
36export function translateFileTool(
37 tool: string,
38 raw: Record<string, unknown>,
39 level: Level,
40): Line | null {
41 switch (tool) {
42 case 'Read':
43 return makeLine(
44 raw,
45 '→',
46 'read',
47 level === 'plain'
48 ? 'Looking at existing code'
49 : `Reading ${baseName(raw.file_path)}`,
50 undefined,
51 'file.read',
52 );
53
54 case 'Grep':
55 return makeLine(
56 raw,
57 '→',
58 'read',
59 level === 'plain'
60 ? 'Searching through the code'
61 : `Searching for ${
62 typeof raw.pattern === 'string'
63 ? `'${raw.pattern}'`
64 : 'a pattern'
65 }`,
66 undefined,
67 'file.search',
68 );
69
70 case 'Glob':
71 return makeLine(
72 raw,
73 '→',
74 'read',
75 level === 'plain'
76 ? 'Searching through the code'
77 : `Finding files matching ${
78 typeof raw.pattern === 'string' ? raw.pattern : 'a pattern'
79 }`,
80 undefined,
81 'file.search',
82 );
83
84 case 'Write': {
85 const exists = raw.codevibez_exists === true;
86 if (level === 'plain') {
87 return exists
88 ? makeLine(
89 raw,
90 '✎',
91 'action',
92 "Rewriting one of the app's files",
93 undefined,
94 'file.write.over',
95 )
96 : makeLine(
97 raw,
98 '✎',
99 'action',
100 'Writing new code',
101 '(1 new file)',
102 'file.write.new',
103 );
104 }
105 return exists
106 ? makeLine(
107 raw,
108 '✎',
109 'action',
110 `Rewriting ${baseName(raw.file_path)}`,
111 undefined,
112 'file.write.over',
113 )
114 : makeLine(
115 raw,
116 '✎',
117 'action',
118 `Writing ${baseName(raw.file_path)}`,
119 '(new file)',
120 'file.write.new',
121 );
122 }
123
124 case 'Edit':
125 case 'MultiEdit':
126 return level === 'plain'
127 ? makeLine(
128 raw,
129 '✎',
130 'action',
131 "Updating the app's code",
132 '(1 file)',
133 )
134 : makeLine(
135 raw,
136 '✎',
137 'action',
138 `Updating ${baseName(raw.file_path)}`,
139 );
140
141 default:
142 return null;
143 }
144}
145engine/src/types.ts 40 lines1export type Agent = 'claude-code' | 'codex';
2export type EventKind = 'prompt' | 'tool.pre' | 'tool.post' | 'agent.done';
3export type Level = 'plain' | 'developer';
4export type Tier = 'read' | 'action' | 'danger';
5
6export interface CodevibezOutcome {
7 ok: boolean;
8 exitCode?: number;
9 stdout?: string;
10 stderr?: string;
11}
12
13export interface CodevibezEvent {
14 v: 1;
15 ts: string;
16 session: string;
17 agent: Agent;
18 kind: EventKind;
19 tool?: string;
20 raw?: Record<string, unknown>;
21 outcome?: CodevibezOutcome;
22 text?: string;
23}
24
25export interface Line {
26 glyph: '→' | '✎' | '⚠' | '✗' | '✓' | '●';
27 text: string;
28 stem?: string;
29 facts?: string;
30 level?: Level;
31 severity: 'info' | 'success' | 'failure' | 'danger';
32 tier: Tier;
33 raw: string;
34 generic?: true;
35 id?: string;
36 mood?: 'neutral' | 'busy' | 'win' | 'oops' | 'alarm' | 'kickoff';
37 accent?: 'dim' | 'normal' | 'bold';
38 pulse?: number;
39}
40hooks/redact.ts 43 lines1/**
2 * English lines quote bits of what the agent ran: a deletion's target, a
3 * search query, a helper's task. Those bits can carry a secret, so every line
4 * is scrubbed before it is drawn.
5 *
6 * This is deliberately best-effort, ported from the July event-log privacy
7 * fix: common carriers are scrubbed, but arbitrary commands and text can
8 * encode secrets in forms no pattern can recognize. Nothing here is written
9 * anywhere; the mod keeps no log.
10 */
11const REDACTED = '[REDACTED]';
12
13/** Scrub common secret carriers from one string. */
14export function redactSecrets(value: string): string {
15 try {
16 return value
17 .replace(
18 /(\b(?:proxy-)?authorization\s*:\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\r\n"']+)/gi,
19 `$1${REDACTED}`,
20 )
21 .replace(
22 /\b([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^@\s/]+@/gi,
23 `$1${REDACTED}@`,
24 )
25 .replace(
26 /((?:^|\s)--(?:access[-_]?token|auth[-_]?token|token|api[-_]?key|password|secret|client[-_]?secret)\b(?:\s*=\s*|\s+))(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi,
27 `$1${REDACTED}`,
28 )
29 .replace(
30 /(\b(?:[a-z][a-z0-9_-]*?)?(?:token|password|secret|api[_-]?key)\b\s*=\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi,
31 `$1${REDACTED}`,
32 )
33 // Known token formats are useful coverage, but still cannot make this
34 // best-effort scrubber complete.
35 .replace(
36 /\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|sk-ant-[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]+|xox[bpar]-[A-Za-z0-9_-]+|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]+|glpat-[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*)(?![A-Za-z0-9_-])/g,
37 REDACTED,
38 );
39 } catch {
40 return REDACTED;
41 }
42}
43