@g3nomaly/g3n-mods 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.md +4 -4
- package/mods/agents-panel/hooks/register.tsx +35 -8
- package/mods/custom-crab-agents/.claude-plugin/plugin.json +5 -2
- package/mods/custom-crab-agents/hooks/register.tsx +39 -21
- package/mods/delete-guard/.claude-plugin/plugin.json +1 -1
- package/mods/delete-guard/hooks/hooks.json +1 -1
- package/mods/delete-guard/hooks/register.tsx +585 -0
- package/mods/delete-guard/types/index.d.ts +26 -1
- package/mods/focus-mode/.claude-plugin/plugin.json +1 -1
- package/mods/focus-mode/hooks/register.tsx +36 -3
- package/mods/mini-game/hooks/register.tsx +15 -6
- package/mods/tool-timeline/hooks/register.tsx +102 -38
- package/mods/tool-timeline/types/index.d.ts +2 -0
- package/package.json +2 -2
- package/mods/delete-guard/hooks/register.ts +0 -151
|
@@ -107,7 +107,20 @@ async function tick($: EngineInterface): Promise<void> {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
*
|
|
110
|
+
* Finds the running block, if any: while one runs the person is away from the keyboard.
|
|
111
|
+
*
|
|
112
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
113
|
+
* @returns The block, or `null` when none runs or its time is up.
|
|
114
|
+
*/
|
|
115
|
+
async function awayBlock($: EngineInterface): Promise<FocusBlock | null> {
|
|
116
|
+
const current = await read($, block)
|
|
117
|
+
if (current === null) return null
|
|
118
|
+
return (await $.clock.now()) < current.endsAt ? current : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Registers this mod's `focus-mode` command, its session hook, its tick, its pane, and the hooks that keep Claude
|
|
123
|
+
* from asking anything during a block: questions are refused, and so are permission prompts.
|
|
111
124
|
*
|
|
112
125
|
* @param on - Hook registration function the engine passes in.
|
|
113
126
|
* @param options - Plugin options; `minutes` sets the default block length.
|
|
@@ -119,9 +132,10 @@ export const register: Register = (on, options) => {
|
|
|
119
132
|
on('session.start', async ($, e, next) => {
|
|
120
133
|
await $.command.register({
|
|
121
134
|
name: 'focus-mode',
|
|
122
|
-
description: `Start a focus block (${defaultMinutes} min by default), or end the running one`,
|
|
135
|
+
description: `Start a focus block (${defaultMinutes} min by default) in which Claude asks you nothing, or end the running one`,
|
|
123
136
|
argumentHint: '[minutes] | end',
|
|
124
137
|
})
|
|
138
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
125
139
|
$.clock.every(1000, () => void tick($))
|
|
126
140
|
return next(e)
|
|
127
141
|
})
|
|
@@ -140,6 +154,25 @@ export const register: Register = (on, options) => {
|
|
|
140
154
|
return { text: `Focus block started: ${minutes} min.` }
|
|
141
155
|
})
|
|
142
156
|
|
|
157
|
+
// While a block runs the person is away: a question Claude puts to them is refused, so Claude decides on its own.
|
|
158
|
+
on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
|
|
159
|
+
const away = await awayBlock($)
|
|
160
|
+
if (away === null) return next(e)
|
|
161
|
+
return { deny: `The user is in a focus block until ${wallTime(away.endsAt)} and cannot answer. Pick the option you would recommend, say in your reply what you assumed, and carry on.` }
|
|
162
|
+
}).catch(($, e, next) => next(e))
|
|
163
|
+
|
|
164
|
+
// A permission prompt would wait for the person too: refuse it, so Claude works around the step or leaves it for later.
|
|
165
|
+
on('classic.PermissionRequest', async ($, e, next) => {
|
|
166
|
+
const away = await awayBlock($)
|
|
167
|
+
if (away === null) return next(e)
|
|
168
|
+
return {
|
|
169
|
+
decision: {
|
|
170
|
+
behavior: 'deny',
|
|
171
|
+
message: `The user is in a focus block until ${wallTime(away.endsAt)} and cannot approve ${e.tool_name}. Do what you can without it, and list this step at the end of your reply for the user to approve later.`,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}).catch(($, e, next) => next(e))
|
|
175
|
+
|
|
143
176
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
144
177
|
const { Box, Text, Button } = $.ui.resolve(e)
|
|
145
178
|
const current = await read($, block)
|
|
@@ -175,7 +208,7 @@ export const register: Register = (on, options) => {
|
|
|
175
208
|
<Text bold>{clock(remaining)}</Text>
|
|
176
209
|
<Box flexDirection="column" flexGrow={1} gap={0}>
|
|
177
210
|
{squares}
|
|
178
|
-
<Text dimColor>
|
|
211
|
+
<Text dimColor>away until {wallTime(current.endsAt)} · Claude asks you nothing</Text>
|
|
179
212
|
</Box>
|
|
180
213
|
<Button key="end" label="end focus" onPress={() => endFocus($, false)} />
|
|
181
214
|
</Box>
|
|
@@ -14,6 +14,9 @@ const MINES = 10
|
|
|
14
14
|
const BEST_KEY = 'best-seconds'
|
|
15
15
|
/** How long a turn runs before the mod suggests a game, once per session. */
|
|
16
16
|
const HINT_AFTER_MS = 60_000
|
|
17
|
+
/** Width of every cell, in columns: room for the widest glyph (`■`, `⚑`, `✹`) on any surface. */
|
|
18
|
+
const CELL_WIDTH = 2
|
|
19
|
+
|
|
17
20
|
const NUMBER_COLORS = [undefined, '#6b8fd6', '#7cc47f', '#e06c6c', '#9b87f5', '#e0775a', '#5fb3c4', '#d0d0d0', '#8a8f98']
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -187,6 +190,7 @@ export const register: Register = on => {
|
|
|
187
190
|
await $.command.register({ name: 'mini-game', description: 'Play Minesweeper while Claude works' })
|
|
188
191
|
const stored = await $.store.get(BEST_KEY)
|
|
189
192
|
if (typeof stored === 'number') await update($, best, () => stored)
|
|
193
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
190
194
|
$.clock.every(1000, () => void tick($))
|
|
191
195
|
return next(e)
|
|
192
196
|
})
|
|
@@ -223,21 +227,21 @@ export const register: Register = on => {
|
|
|
223
227
|
const isOver = board.status === 'won' || board.status === 'lost'
|
|
224
228
|
|
|
225
229
|
/**
|
|
226
|
-
* Draws
|
|
230
|
+
* Draws what a cell shows: a number, a flag, a mine, or a button that reveals or flags it.
|
|
227
231
|
*
|
|
228
232
|
* @param cell - Cell number to draw.
|
|
229
|
-
* @returns The cell
|
|
233
|
+
* @returns The cell's content.
|
|
230
234
|
*/
|
|
231
|
-
const
|
|
235
|
+
const cellFace = (cell: number) => {
|
|
232
236
|
const isRevealed = board.revealed.includes(cell)
|
|
233
237
|
const isMine = board.mines.includes(cell)
|
|
234
238
|
if (isRevealed || (isOver && isMine)) {
|
|
235
|
-
if (isMine) return <Text color="#e06c6c"
|
|
239
|
+
if (isMine) return <Text color="#e06c6c">✹</Text>
|
|
236
240
|
const n = adjacentMines(board, cell)
|
|
237
|
-
return <Text color={NUMBER_COLORS[n]}>{n === 0 ? '
|
|
241
|
+
return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
|
|
238
242
|
}
|
|
239
243
|
const label = board.flagged.includes(cell) ? '⚑' : '■'
|
|
240
|
-
if (isOver) return <Text dimColor>{label}
|
|
244
|
+
if (isOver) return <Text dimColor>{label}</Text>
|
|
241
245
|
return (
|
|
242
246
|
<Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
|
|
243
247
|
<Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
|
|
@@ -245,6 +249,11 @@ export const register: Register = on => {
|
|
|
245
249
|
)
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
// Every cell sits in a box of one fixed width, so revealing a cell never moves the others: glyphs differ in width.
|
|
253
|
+
const cellView = (cell: number) => (
|
|
254
|
+
<Box width={CELL_WIDTH} flexShrink={0} justifyContent="center">{cellFace(cell)}</Box>
|
|
255
|
+
)
|
|
256
|
+
|
|
248
257
|
const rows = Array.from({ length: board.size }, (_, r) => (
|
|
249
258
|
<Box flexDirection="row" columnGap={1}>
|
|
250
259
|
{Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
|
|
@@ -28,6 +28,15 @@ const TOOL_COLORS: ReadonlyArray<[RegExp, string]> = [
|
|
|
28
28
|
[/^(Agent|Task)$/, '#f09bb5'],
|
|
29
29
|
]
|
|
30
30
|
|
|
31
|
+
/** Background of a row's hover card. */
|
|
32
|
+
const CARD = '#1b1f27'
|
|
33
|
+
|
|
34
|
+
/** Idle time between calls, in milliseconds, kept on the bars' axis; longer idle stretches shrink to this. */
|
|
35
|
+
const IDLE_MS = 200
|
|
36
|
+
|
|
37
|
+
/** Most characters of a call's input the hover card keeps. */
|
|
38
|
+
const DETAIL_MAX = 2000
|
|
39
|
+
|
|
31
40
|
/** Input fields that say most about a call, tried in order. */
|
|
32
41
|
const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
|
|
33
42
|
|
|
@@ -59,6 +68,47 @@ export function labelOf(input: Record<string, unknown>): string {
|
|
|
59
68
|
return first?.replace(/\s+/g, ' ').trim() ?? ''
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @brief Finds a call's whole input text for the row's hover card: the full command, path, pattern or first text field.
|
|
73
|
+
*
|
|
74
|
+
* @param input - Arguments of the tool call.
|
|
75
|
+
* @returns The text with its line breaks, cut at `DETAIL_MAX` characters.
|
|
76
|
+
*/
|
|
77
|
+
export function detailOf(input: Record<string, unknown>): string {
|
|
78
|
+
const field = LABEL_FIELDS.find(name => typeof input[name] === 'string' && input[name] !== '')
|
|
79
|
+
const value = field === undefined ? Object.values(input).find((one): one is string => typeof one === 'string') : (input[field] as string)
|
|
80
|
+
const text = value?.trim() ?? ''
|
|
81
|
+
return text.length > DETAIL_MAX ? `${text.slice(0, DETAIL_MAX - 1)}…` : text
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Where a call sits on the bars' axis, in milliseconds from the first shown call's start. */
|
|
85
|
+
type Span = { from: number; to: number }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @brief Places calls on one time axis, shrinking idle stretches between them to `IDLE_MS`, so the bars read as a waterfall.
|
|
89
|
+
*
|
|
90
|
+
* @param list - Calls in start order.
|
|
91
|
+
* @param now - Current time, the end of calls still running.
|
|
92
|
+
* @returns Each call's span, in `list` order, and the axis length (at least 1).
|
|
93
|
+
*/
|
|
94
|
+
export function placeCalls(list: readonly TimelineCall[], now: number): { spans: Span[]; length: number } {
|
|
95
|
+
const origin = list[0]?.startedAt ?? now
|
|
96
|
+
let busyUntil = origin
|
|
97
|
+
let shift = 0
|
|
98
|
+
let length = 1
|
|
99
|
+
const spans = list.map(call => {
|
|
100
|
+
const end = Math.max(call.startedAt, call.endedAt ?? now)
|
|
101
|
+
const idle = call.startedAt - busyUntil
|
|
102
|
+
if (idle > IDLE_MS) shift += idle - IDLE_MS
|
|
103
|
+
busyUntil = Math.max(busyUntil, end)
|
|
104
|
+
const from = call.startedAt - origin - shift
|
|
105
|
+
const to = end - origin - shift
|
|
106
|
+
length = Math.max(length, to)
|
|
107
|
+
return { from, to }
|
|
108
|
+
})
|
|
109
|
+
return { spans, length }
|
|
110
|
+
}
|
|
111
|
+
|
|
62
112
|
/**
|
|
63
113
|
* @brief Joins the text of a tool result's content, which is a string or a list of text blocks.
|
|
64
114
|
*
|
|
@@ -146,16 +196,16 @@ function replacementOf(list: readonly TimelineCall[], index: number): TimelineCa
|
|
|
146
196
|
}
|
|
147
197
|
|
|
148
198
|
/**
|
|
149
|
-
* @brief Builds the SVG of a track with a bar
|
|
199
|
+
* @brief Builds the SVG of a track with a bar from `x` that is `fill` pixels long.
|
|
150
200
|
*
|
|
151
201
|
* @param width - Width of the track in pixels.
|
|
152
|
-
* @param
|
|
202
|
+
* @param x - Start of the bar in pixels.
|
|
203
|
+
* @param fill - Length of the bar in pixels.
|
|
153
204
|
* @param color - Fill color.
|
|
154
205
|
* @returns The SVG markup.
|
|
155
206
|
*/
|
|
156
|
-
function barSvg(width: number, fill: number, color: string): string {
|
|
157
|
-
|
|
158
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="2" fill="${TRACK}"/><rect width="${w.toFixed(1)}" height="8" rx="2" fill="${color}"/></svg>`
|
|
207
|
+
function barSvg(width: number, x: number, fill: number, color: string): string {
|
|
208
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="2" fill="${TRACK}"/><rect x="${x.toFixed(1)}" width="${fill.toFixed(1)}" height="8" rx="2" fill="${color}"/></svg>`
|
|
159
209
|
}
|
|
160
210
|
|
|
161
211
|
/**
|
|
@@ -182,12 +232,15 @@ export const register: Register = on => {
|
|
|
182
232
|
|
|
183
233
|
on('tool.call', async ($, e, next) => {
|
|
184
234
|
const startedAt = await $.clock.now()
|
|
185
|
-
const
|
|
235
|
+
const input = e as unknown as Record<string, unknown>
|
|
236
|
+
const label = labelOf(input)
|
|
237
|
+
const detail = detailOf(input)
|
|
186
238
|
await patchCall($, e.tool_use_id, call => ({
|
|
187
239
|
...call,
|
|
188
240
|
id: e.tool_use_id,
|
|
189
241
|
tool: e.tool,
|
|
190
242
|
label,
|
|
243
|
+
detail,
|
|
191
244
|
status: 'running',
|
|
192
245
|
startedAt,
|
|
193
246
|
agentId: e.agentId,
|
|
@@ -209,7 +262,7 @@ export const register: Register = on => {
|
|
|
209
262
|
const input = typeof block.input === 'object' && block.input !== null ? (block.input as Record<string, unknown>) : {}
|
|
210
263
|
const id = block.id
|
|
211
264
|
const tool = block.name
|
|
212
|
-
await patchCall($, id, call => call ?? { id, tool, label: labelOf(input), status: 'queued', startedAt: now, agentId: e.agentId })
|
|
265
|
+
await patchCall($, id, call => call ?? { id, tool, label: labelOf(input), detail: detailOf(input), status: 'queued', startedAt: now, agentId: e.agentId })
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
268
|
if (e.door === 'tool-result') {
|
|
@@ -236,27 +289,32 @@ export const register: Register = on => {
|
|
|
236
289
|
const shown = list.slice(-room)
|
|
237
290
|
/** Length of a call so far, or up to its end when it has one. */
|
|
238
291
|
const durationOf = (call: TimelineCall): number => (call.endedAt ?? now) - call.startedAt
|
|
239
|
-
const longest = Math.max(1, ...shown.map(durationOf))
|
|
240
292
|
const total = list.reduce((sum, call) => sum + (call.status === 'denied' ? 0 : durationOf(call)), 0)
|
|
293
|
+
const axis = placeCalls(shown, now)
|
|
241
294
|
const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.3))
|
|
242
295
|
const labelCells = Math.max(12, e.props.bodyColumns - barCells - 12)
|
|
243
296
|
|
|
244
|
-
/** Draws
|
|
245
|
-
const bar = (call: TimelineCall) => {
|
|
297
|
+
/** Draws a call's bar on the shared axis: where it started and how long it ran, idle time between calls shrunk. */
|
|
298
|
+
const bar = (call: TimelineCall, span: Span) => {
|
|
246
299
|
const color = call.status === 'error' ? ERROR : call.status === 'ok' && /^Bash$/.test(call.tool) ? '#5fae6e' : colorOf(call.tool)
|
|
247
|
-
const
|
|
300
|
+
const start = span.from / axis.length
|
|
301
|
+
const share = call.status === 'denied' ? 0.03 : Math.max(0.02, (span.to - span.from) / axis.length)
|
|
248
302
|
if (e.surface === 'terminal') {
|
|
249
303
|
const filled = Math.max(1, Math.round(share * barCells))
|
|
304
|
+
const before = Math.min(barCells - filled, Math.round(start * barCells))
|
|
250
305
|
return (
|
|
251
306
|
<Text>
|
|
307
|
+
<Text color={TRACK}>{'▬'.repeat(before)}</Text>
|
|
252
308
|
<Text color={color}>{'▬'.repeat(filled)}</Text>
|
|
253
|
-
<Text color={TRACK}>{'▬'.repeat(barCells - filled)}</Text>
|
|
309
|
+
<Text color={TRACK}>{'▬'.repeat(barCells - before - filled)}</Text>
|
|
254
310
|
</Text>
|
|
255
311
|
)
|
|
256
312
|
}
|
|
257
313
|
const { Svg } = $.ui.resolve(e)
|
|
258
314
|
const width = barCells * CELL_PX
|
|
259
|
-
|
|
315
|
+
const fill = Math.max(3, share * width)
|
|
316
|
+
const x = Math.min(width - fill, start * width)
|
|
317
|
+
return <Svg source={barSvg(width, x, fill, color)} alt={`${call.tool} ${seconds(durationOf(call))}`} width={width} height={8} />
|
|
260
318
|
}
|
|
261
319
|
|
|
262
320
|
/** Renders a call's status at the end of its row: its time, `denied`, `queued` or a mark for an error. */
|
|
@@ -270,32 +328,37 @@ export const register: Register = on => {
|
|
|
270
328
|
}
|
|
271
329
|
}
|
|
272
330
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
<
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
</Box>
|
|
287
|
-
{call.status === 'denied' && (
|
|
288
|
-
<Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
|
|
289
|
-
<Text wrap="truncate-end">
|
|
290
|
-
<Text color={ERROR}>⊘ </Text>
|
|
291
|
-
<Text strikethrough dimColor>{call.label}</Text>
|
|
292
|
-
{replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
|
|
293
|
-
</Text>
|
|
294
|
-
<Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
|
|
295
|
-
</Box>
|
|
296
|
-
)}
|
|
331
|
+
// One line per call; the keyed row reveals a card with the call's whole input while the pointer is on it.
|
|
332
|
+
const rows = shown.map((call, index) => (
|
|
333
|
+
<Box key={call.id} flexDirection="row" columnGap={1}>
|
|
334
|
+
<Text bold color={CARD} backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
|
|
335
|
+
<Box width={labelCells}>
|
|
336
|
+
<Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
|
|
337
|
+
{call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
|
|
338
|
+
</Text>
|
|
339
|
+
</Box>
|
|
340
|
+
{bar(call, axis.spans[index] ?? { from: 0, to: 0 })}
|
|
341
|
+
<Box width={8} justifyContent="flex-end">{outcome(call)}</Box>
|
|
342
|
+
<Box position="absolute" top={1} left={4} right={0} display="none" hover={{ display: 'flex' }} backgroundColor={CARD} borderStyle="round" borderColor={DIM} paddingX={1}>
|
|
343
|
+
<Text wrap="wrap">{call.tool} <Text dimColor>{call.detail ?? call.label}</Text></Text>
|
|
297
344
|
</Box>
|
|
298
|
-
|
|
345
|
+
</Box>
|
|
346
|
+
))
|
|
347
|
+
|
|
348
|
+
// Denied calls get their callout under the list, so every call keeps to its one line.
|
|
349
|
+
const callouts = shown.flatMap((call, index) => {
|
|
350
|
+
if (call.status !== 'denied') return []
|
|
351
|
+
const replacement = replacementOf(shown, index)
|
|
352
|
+
return [
|
|
353
|
+
<Box borderStyle="round" borderColor="#7a3b3b" paddingX={1} flexDirection="row" justifyContent="space-between">
|
|
354
|
+
<Text wrap="truncate-end">
|
|
355
|
+
<Text color={ERROR}>⊘ </Text>
|
|
356
|
+
<Text strikethrough dimColor>{call.label}</Text>
|
|
357
|
+
{replacement === undefined ? '' : <Text> → <Text color="#7cc47f">{replacement.label}</Text></Text>}
|
|
358
|
+
</Text>
|
|
359
|
+
<Text color={ERROR}>denied{call.note === undefined ? '' : ` · ${shortNote(call.note)}`}</Text>
|
|
360
|
+
</Box>,
|
|
361
|
+
]
|
|
299
362
|
})
|
|
300
363
|
|
|
301
364
|
return (
|
|
@@ -305,6 +368,7 @@ export const register: Register = on => {
|
|
|
305
368
|
<Text dimColor>{list.length} call{list.length === 1 ? '' : 's'} · {seconds(total)}</Text>
|
|
306
369
|
</Box>
|
|
307
370
|
{rows}
|
|
371
|
+
{callouts.length > 0 && <Box flexDirection="column" marginTop={1}>{callouts}</Box>}
|
|
308
372
|
</Box>
|
|
309
373
|
)
|
|
310
374
|
})
|
|
@@ -9,6 +9,8 @@ export type TimelineCall = {
|
|
|
9
9
|
tool: string
|
|
10
10
|
/** Short form of the call's input, e.g. `rm -rf dist` or `package.json` */
|
|
11
11
|
label: string
|
|
12
|
+
/** The call's whole input text for the row's hover card, e.g. the full command or path */
|
|
13
|
+
detail?: string
|
|
12
14
|
status: CallStatus
|
|
13
15
|
startedAt: number
|
|
14
16
|
endedAt?: number
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g3nomaly/g3n-mods",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The g3n Claude Code mods: panes, status lines, commands and hooks, installed with @g3nomaly/mods.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"types": "bun scripts/sync-types.ts"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"@g3nomaly/core": "^0.3.
|
|
18
|
+
"@g3nomaly/core": "^0.3.3",
|
|
19
19
|
"@types/bun": "^1.4.0",
|
|
20
20
|
"typescript": "~7.0.2",
|
|
21
21
|
"zod": "^4.4.3"
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
// packages/g3n-mods/mods/delete-guard/hooks/register.ts
|
|
2
|
-
import { atom, read, update } from 'claude-code'
|
|
3
|
-
import type { EngineInterface, Register } from 'claude-code'
|
|
4
|
-
|
|
5
|
-
import type { Deletion } from '../types'
|
|
6
|
-
|
|
7
|
-
const isOff = atom({ plugin: 'delete-guard', key: 'isOff' } as const, false)
|
|
8
|
-
|
|
9
|
-
/** Shell syntax that starts a new command: `&&`, `||`, `;`, `|`, a newline, `$(` and a backtick. */
|
|
10
|
-
const SEGMENT_BREAK = /&&|\|\||;|\||\n|\$\(|`/
|
|
11
|
-
|
|
12
|
-
/** Words that run the command after them: `sudo rm`, `xargs -0 rm`, `env X=1 rm`. */
|
|
13
|
-
const PREFIX = /^(?:\w+=\S*\s+|(?:sudo|command|exec|nice|nohup|time|env)\s+(?:-\S+\s+)*|xargs\s+(?:-\S+\s+)*)+/
|
|
14
|
-
|
|
15
|
-
const DELETERS = new Set(['rm', 'shred', 'unlink', 'rimraf', 'srm'])
|
|
16
|
-
|
|
17
|
-
/** Trash commands in order of preference, with how each one is spelled. */
|
|
18
|
-
const TRASH_COMMANDS = [
|
|
19
|
-
{ probe: 'trash', spell: 'trash' },
|
|
20
|
-
{ probe: 'trash-put', spell: 'trash-put' },
|
|
21
|
-
{ probe: 'gio', spell: 'gio trash' },
|
|
22
|
-
] as const
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Splits a shell command line into its commands, each trimmed.
|
|
26
|
-
*
|
|
27
|
-
* @param command - Shell command line, e.g. `cd build && rm -rf dist`.
|
|
28
|
-
* @returns The non-empty commands in order.
|
|
29
|
-
*/
|
|
30
|
-
function segments(command: string): string[] {
|
|
31
|
-
return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Splits one command into words, dropping its prefixes and the program's directory.
|
|
36
|
-
*
|
|
37
|
-
* @param segment - One command, as `segments` returns it.
|
|
38
|
-
* @returns The program name first, then its arguments.
|
|
39
|
-
*/
|
|
40
|
-
function words(segment: string): string[] {
|
|
41
|
-
const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
|
|
42
|
-
return [program.split('/').pop() ?? program, ...rest]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Picks the operands of a command: every word that is not a flag, and every word after `--`.
|
|
47
|
-
*
|
|
48
|
-
* @param args - Words after the program name.
|
|
49
|
-
* @returns The operands in order.
|
|
50
|
-
*/
|
|
51
|
-
function operands(args: readonly string[]): string[] {
|
|
52
|
-
const end = args.indexOf('--')
|
|
53
|
-
const flagged = end === -1 ? args : args.slice(0, end)
|
|
54
|
-
const after = end === -1 ? [] : args.slice(end + 1)
|
|
55
|
-
return [...flagged.filter(arg => !arg.startsWith('-')), ...after]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* @brief Finds the first permanent delete in a shell command line.
|
|
60
|
-
*
|
|
61
|
-
* Recognises `rm`, `shred`, `unlink`, `rimraf`, `find -delete`, `find -exec rm`
|
|
62
|
-
* and a forced `git clean`.
|
|
63
|
-
*
|
|
64
|
-
* @param command - Full shell command line, as the Bash tool received it.
|
|
65
|
-
* @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
|
|
66
|
-
*/
|
|
67
|
-
export function findDeletion(command: string): Deletion | undefined {
|
|
68
|
-
for (const segment of segments(command)) {
|
|
69
|
-
const [program = '', ...args] = words(segment)
|
|
70
|
-
if (DELETERS.has(program)) {
|
|
71
|
-
return { segment, kind: 'files', paths: operands(args) }
|
|
72
|
-
}
|
|
73
|
-
if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
|
|
74
|
-
return { segment, kind: 'find', paths: [] }
|
|
75
|
-
}
|
|
76
|
-
if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
|
|
77
|
-
return { segment, kind: 'git-clean', paths: [] }
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return undefined
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Builds the command Claude should run instead of a permanent delete.
|
|
85
|
-
*
|
|
86
|
-
* @param deletion - Delete to replace, see `Deletion`.
|
|
87
|
-
* @param trash - Trash command spelled as this machine has it, e.g. `gio trash`.
|
|
88
|
-
* @returns The suggested command, with `<paths>` where no path was named.
|
|
89
|
-
*/
|
|
90
|
-
function insteadOf(deletion: Deletion, trash: string): string {
|
|
91
|
-
switch (deletion.kind) {
|
|
92
|
-
case 'files':
|
|
93
|
-
return deletion.paths.length > 0 ? `${trash} ${deletion.paths.join(' ')}` : `${trash} <paths>`
|
|
94
|
-
case 'find':
|
|
95
|
-
return `${deletion.segment.replace(/\s-delete\b/, '').replace(/-exec(?:dir)?\s+.*$/, '')} -exec ${trash} {} +`.replace(/\s+/g, ' ')
|
|
96
|
-
case 'git-clean':
|
|
97
|
-
return `git clean -n to list the files, then ${trash} <paths>`
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Finds the first trash command on this machine's PATH.
|
|
103
|
-
*
|
|
104
|
-
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
105
|
-
* @returns The command as it should be spelled, or `undefined` when none is installed.
|
|
106
|
-
*/
|
|
107
|
-
async function detectTrash($: EngineInterface): Promise<string | undefined> {
|
|
108
|
-
for (const candidate of TRASH_COMMANDS) {
|
|
109
|
-
const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
|
|
110
|
-
if (found?.exitCode === 0) return candidate.spell
|
|
111
|
-
}
|
|
112
|
-
return undefined
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Registers this mod's `delete-guard` command, its session hook, and its Bash tool hook.
|
|
117
|
-
*
|
|
118
|
-
* @param on - Hook registration function the engine passes in.
|
|
119
|
-
*/
|
|
120
|
-
export const register: Register = on => {
|
|
121
|
-
let trash: string | undefined
|
|
122
|
-
|
|
123
|
-
on('session.start', async ($, e, next) => {
|
|
124
|
-
trash = await detectTrash($)
|
|
125
|
-
await $.command.register({
|
|
126
|
-
name: 'delete-guard',
|
|
127
|
-
description: 'Turn the permanent-delete guard on or off for this session',
|
|
128
|
-
argumentHint: 'on | off',
|
|
129
|
-
})
|
|
130
|
-
return next(e)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
on('command.run', { command: 'delete-guard' }, async ($, e) => {
|
|
134
|
-
const wanted = e.args.trim()
|
|
135
|
-
if (wanted === 'on' || wanted === 'off') await update($, isOff, () => wanted === 'off')
|
|
136
|
-
const off = await read($, isOff)
|
|
137
|
-
return { text: `delete-guard is ${off ? 'off: permanent deletes run' : 'on: permanent deletes are denied'}.` }
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
141
|
-
if (await read($, isOff)) return next(e)
|
|
142
|
-
const deletion = findDeletion(e.command)
|
|
143
|
-
if (deletion === undefined) return next(e)
|
|
144
|
-
|
|
145
|
-
const spelled = trash ?? 'trash'
|
|
146
|
-
const install = trash === undefined ? ' (install trash-cli: sudo apt install trash-cli)' : ''
|
|
147
|
-
return {
|
|
148
|
-
deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: \`${insteadOf(deletion, spelled)}\`${install}.`,
|
|
149
|
-
}
|
|
150
|
-
}).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
|
|
151
|
-
}
|