@g3nomaly/g3n-mods 0.1.3 → 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 +34 -8
- package/mods/custom-crab-agents/.claude-plugin/plugin.json +5 -2
- package/mods/custom-crab-agents/hooks/register.tsx +39 -22
- 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 +35 -3
- package/mods/mini-game/hooks/register.tsx +14 -6
- package/package.json +2 -2
- package/mods/delete-guard/hooks/register.ts +0 -151
|
@@ -19,19 +19,19 @@
|
|
|
19
19
|
{
|
|
20
20
|
"name": "custom-crab-agents",
|
|
21
21
|
"source": "./mods/custom-crab-agents",
|
|
22
|
-
"description": "
|
|
22
|
+
"description": "Draws each subagent in the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
|
|
23
23
|
"version": "0.1.0"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"name": "delete-guard",
|
|
27
27
|
"source": "./mods/delete-guard",
|
|
28
|
-
"description": "
|
|
28
|
+
"description": "Holds rm -rf and other permanent deletes on a card that lists what they remove, until you press Delete or Cancel.",
|
|
29
29
|
"version": "0.1.0"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"name": "focus-mode",
|
|
33
33
|
"source": "./mods/focus-mode",
|
|
34
|
-
"description": "A timed focus block
|
|
34
|
+
"description": "A timed focus block in which you're away: Claude asks no questions and gets no permission prompts. Countdown, progress bar and end button.",
|
|
35
35
|
"version": "0.1.0"
|
|
36
36
|
},
|
|
37
37
|
{
|
package/README.md
CHANGED
|
@@ -22,16 +22,16 @@ Then open the project in VS Code and trust the workspace. New sessions load the
|
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `agents-panel` | The agents in `.claude/agents` with their model and a run button. A running agent shows a spinner, and a toast says when it starts and finishes | `/agents-panel` |
|
|
24
24
|
| `context-bar` | One bar of context-window use split by source, with the total and where compaction starts | `/context-bar` |
|
|
25
|
-
| `custom-crab-agents` |
|
|
26
|
-
| `delete-guard` |
|
|
27
|
-
| `focus-mode` | A timed focus block
|
|
25
|
+
| `custom-crab-agents` | Adds each subagent to the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time. Needs `agents-panel`, which installing it adds | `/agents-panel` |
|
|
26
|
+
| `delete-guard` | Off by default: deletes run unasked. Turn it on with the switch in its pane, or `/delete-guard on`; it stays on in later sessions. While on, `rm -rf` and other permanent deletes wait on a card listing the files, their sizes and whether git can restore them, until you press **Delete** or **Cancel**. Cancel tells Claude which trash command to use instead. With nothing to draw the card on, the delete is refused | `/delete-guard on \| off` |
|
|
27
|
+
| `focus-mode` | A timed focus block in which you're away: Claude's questions are refused, so it picks for itself and says what it assumed, and permission prompts are refused, so it works around the step and lists it for later. Countdown, progress bar and end button. 25 minutes unless you pass a number | `/focus-mode [minutes] \| end` |
|
|
28
28
|
| `handoff` | Writes a handoff with the `handoff` skill, clears the session, and attaches the handoff to your next prompt | `/handoff-new [what the next session is for]` |
|
|
29
29
|
| `hide-personal-data` | Masks names, emails, phone numbers, IBANs and card numbers in replies. Hover a mask to read it | `/hide-personal-data on \| off` |
|
|
30
30
|
| `mini-game` | Minesweeper, to play while Claude works | `/mini-game` |
|
|
31
31
|
| `tool-timeline` | Each tool call with its duration. A denied call is struck through, next to the call that replaced it | `/tool-timeline` |
|
|
32
32
|
| `you-should-know` | A card under long replies with their key point, a headline and one supporting line. It costs one small Haiku call per reply of 1500 characters or more | none |
|
|
33
33
|
|
|
34
|
-
For `agents-panel`, `context-bar
|
|
34
|
+
For `agents-panel`, `context-bar` and `tool-timeline`, the command opens the pane and running it again closes it.
|
|
35
35
|
|
|
36
36
|
Three mods take settings, declared under `userConfig` in their `.claude-plugin/plugin.json`:
|
|
37
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// packages/g3n-mods/mods/agents-panel/hooks/register.tsx
|
|
2
2
|
import { atom, read, update } from 'claude-code'
|
|
3
|
-
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register, RenderElement } from 'claude-code'
|
|
4
4
|
|
|
5
5
|
import type { AgentDef, RunningAgent } from '../types'
|
|
6
6
|
|
|
@@ -20,6 +20,27 @@ const NAMED_COLORS: Readonly<Record<string, string>> = {
|
|
|
20
20
|
const SPINNER = ['◐', '◓', '◑', '◒']
|
|
21
21
|
const DONE_STATUSES = new Set(['completed', 'failed', 'killed', 'idle'])
|
|
22
22
|
|
|
23
|
+
/** Key prefix of the panel's own elements; an add-on looks for it to find the panel's tree. */
|
|
24
|
+
export const OWN_KEY = 'agents:'
|
|
25
|
+
|
|
26
|
+
/** Key prefix an add-on's section carries, such as custom-crab-agents' crabs, so the panel knows to show it. */
|
|
27
|
+
export const ADDON_KEY = 'addon:'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tells whether a drawn tree holds an element whose key starts with `prefix`.
|
|
31
|
+
*
|
|
32
|
+
* @param tree - Tree as a `ui.render` chain returned it, or one of its children.
|
|
33
|
+
* @param prefix - Key prefix to look for, e.g. `addon:`.
|
|
34
|
+
* @returns True when some element's key starts with `prefix`.
|
|
35
|
+
*/
|
|
36
|
+
export function holdsKey(tree: unknown, prefix: string): boolean {
|
|
37
|
+
if (Array.isArray(tree)) return tree.some(child => holdsKey(child, prefix))
|
|
38
|
+
if (typeof tree !== 'object' || tree === null) return false
|
|
39
|
+
const { props, children } = tree as { props?: { key?: unknown }; children?: unknown }
|
|
40
|
+
if (typeof props?.key === 'string' && props.key.startsWith(prefix)) return true
|
|
41
|
+
return holdsKey(children, prefix)
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
/**
|
|
24
45
|
* Parses the frontmatter of a Markdown file into flat `key: value` pairs.
|
|
25
46
|
*
|
|
@@ -184,8 +205,13 @@ export const register: Register = on => {
|
|
|
184
205
|
return result
|
|
185
206
|
}).catch(($, e, next) => next(e))
|
|
186
207
|
|
|
187
|
-
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
208
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
|
|
188
209
|
const { Box, Text, Button } = $.ui.resolve(e)
|
|
210
|
+
// An add-on that depends on this panel, such as custom-crab-agents, may draw beneath this hook; its section joins the panel.
|
|
211
|
+
const below: RenderElement | undefined = await next(e).catch(() => undefined)
|
|
212
|
+
const addOn = below !== undefined && holdsKey(below, ADDON_KEY) ? below : undefined
|
|
213
|
+
/** Puts an add-on's section under the panel's own tree, when one was drawn beneath. */
|
|
214
|
+
const withAddOn = (own: RenderElement) => (addOn === undefined ? own : <Box flexDirection="column" gap={1}>{own}{addOn}</Box>)
|
|
189
215
|
const list = await read($, agents)
|
|
190
216
|
const runs = await read($, running)
|
|
191
217
|
const now = await $.clock.now()
|
|
@@ -203,11 +229,11 @@ export const register: Register = on => {
|
|
|
203
229
|
)
|
|
204
230
|
|
|
205
231
|
if (list.length === 0) {
|
|
206
|
-
return (
|
|
207
|
-
<Box flexDirection="column" gap={1}>
|
|
232
|
+
return withAddOn(
|
|
233
|
+
<Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
|
|
208
234
|
{header}
|
|
209
235
|
<Text dimColor>No agents in {AGENTS_DIR} or ~/{AGENTS_DIR}. Add one with /agents.</Text>
|
|
210
|
-
</Box
|
|
236
|
+
</Box>,
|
|
211
237
|
)
|
|
212
238
|
}
|
|
213
239
|
|
|
@@ -265,14 +291,14 @@ export const register: Register = on => {
|
|
|
265
291
|
)
|
|
266
292
|
})()
|
|
267
293
|
|
|
268
|
-
return (
|
|
269
|
-
<Box flexDirection="column" gap={1}>
|
|
294
|
+
return withAddOn(
|
|
295
|
+
<Box key={`${OWN_KEY}body`} flexDirection="column" gap={1}>
|
|
270
296
|
{header}
|
|
271
297
|
{input}
|
|
272
298
|
{section('project')}
|
|
273
299
|
{section('user')}
|
|
274
300
|
<Text dimColor>click ▶ run to start one · /agents-panel to hide</Text>
|
|
275
|
-
</Box
|
|
301
|
+
</Box>,
|
|
276
302
|
)
|
|
277
303
|
})
|
|
278
304
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "custom-crab-agents",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Draws each subagent in the agents panel as a crab with a hat: effort tier, model, steps, context, tokens, cost and time.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "g3ntrovert"
|
|
7
7
|
},
|
|
8
|
-
"types": "./types/index.d.ts"
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"dependencies": [
|
|
10
|
+
"agents-panel"
|
|
11
|
+
]
|
|
9
12
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// packages/g3n-mods/mods/custom-crab-agents/hooks/register.tsx
|
|
2
2
|
import { atom, read, update } from 'claude-code'
|
|
3
|
-
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register, RenderElement } from 'claude-code'
|
|
4
4
|
|
|
5
5
|
import type { Crab, CrabSteps, PlannedItem } from '../types'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
const
|
|
7
|
+
/** The agents-panel mod's pane, which this mod depends on and draws its crabs into. */
|
|
8
|
+
const PANEL = 'agents-panel'
|
|
9
|
+
|
|
10
|
+
/** Key prefix of the agents panel's own elements, and the one this section carries so the panel shows it. */
|
|
11
|
+
const PANEL_KEY = 'agents:'
|
|
12
|
+
const ADDON_KEY = 'addon:'
|
|
9
13
|
const crabs = atom({ plugin: 'custom-crab-agents', key: 'crabs' } as const, [])
|
|
10
14
|
const planned = atom({ plugin: 'custom-crab-agents', key: 'planned' } as const, [])
|
|
11
15
|
const contextWindow = atom({ plugin: 'custom-crab-agents', key: 'window' } as const, 1_000_000)
|
|
@@ -178,6 +182,21 @@ const isNumber = (v: unknown): v is number => typeof v === 'number'
|
|
|
178
182
|
/** Tells whether `v` is a non-null object. */
|
|
179
183
|
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
|
180
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Tells whether a drawn tree holds an element whose key starts with `prefix`.
|
|
187
|
+
*
|
|
188
|
+
* @param tree - Tree as a `ui.render` chain returned it, or one of its children.
|
|
189
|
+
* @param prefix - Key prefix to look for, e.g. `agents:`.
|
|
190
|
+
* @returns True when some element's key starts with `prefix`.
|
|
191
|
+
*/
|
|
192
|
+
export function holdsKey(tree: unknown, prefix: string): boolean {
|
|
193
|
+
if (Array.isArray(tree)) return tree.some(child => holdsKey(child, prefix))
|
|
194
|
+
if (typeof tree !== 'object' || tree === null) return false
|
|
195
|
+
const { props, children } = tree as { props?: { key?: unknown }; children?: unknown }
|
|
196
|
+
if (typeof props?.key === 'string' && props.key.startsWith(prefix)) return true
|
|
197
|
+
return holdsKey(children, prefix)
|
|
198
|
+
}
|
|
199
|
+
|
|
181
200
|
/**
|
|
182
201
|
* Applies `patch` to every crab that `match` selects.
|
|
183
202
|
*
|
|
@@ -190,14 +209,13 @@ async function patchCrab($: EngineInterface, match: (crab: Crab) => boolean, pat
|
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
/**
|
|
193
|
-
* Records a subagent the main thread just started
|
|
212
|
+
* Records a subagent the main thread just started.
|
|
194
213
|
*
|
|
195
214
|
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
196
215
|
* @param crab - New running crab to add, see `Crab`.
|
|
197
216
|
*/
|
|
198
217
|
async function startCrab($: EngineInterface, crab: Crab): Promise<void> {
|
|
199
218
|
await update($, crabs, list => [...list.filter(one => one.id !== crab.id), crab].slice(-KEEP))
|
|
200
|
-
void $.ui.open({ id: PANE, title: TITLE })
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
/**
|
|
@@ -332,28 +350,17 @@ async function readWindow($: EngineInterface): Promise<void> {
|
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
/**
|
|
335
|
-
* Registers this mod's
|
|
353
|
+
* Registers this mod's session, tool, subagent and turn hooks, and its section of the agents panel.
|
|
336
354
|
*
|
|
337
355
|
* @param on - Hook registration function the engine passes in.
|
|
338
356
|
*/
|
|
339
357
|
export const register: Register = on => {
|
|
340
358
|
on('session.start', async ($, e, next) => {
|
|
341
|
-
await $.command.register({ name: 'crabs', description: 'Show or hide the subagent crabs' })
|
|
342
359
|
await readWindow($)
|
|
343
|
-
void $.ui.open({ id: PANE, title: TITLE })
|
|
344
360
|
$.clock.every(1000, () => void tick($))
|
|
345
361
|
return next(e)
|
|
346
362
|
})
|
|
347
363
|
|
|
348
|
-
on('command.run', { command: 'crabs' }, async $ => {
|
|
349
|
-
if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
|
|
350
|
-
await $.ui.close({ id: PANE })
|
|
351
|
-
return { text: 'Crabs hidden.' }
|
|
352
|
-
}
|
|
353
|
-
await $.ui.open({ id: PANE, title: TITLE })
|
|
354
|
-
return { text: 'Crabs shown.' }
|
|
355
|
-
})
|
|
356
|
-
|
|
357
364
|
on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
|
|
358
365
|
if (e.agentId !== undefined) return next(e)
|
|
359
366
|
const startedAt = await $.clock.now()
|
|
@@ -399,13 +406,22 @@ export const register: Register = on => {
|
|
|
399
406
|
return result
|
|
400
407
|
}).catch(($, e, next) => next(e))
|
|
401
408
|
|
|
402
|
-
|
|
409
|
+
// Draws into the agents panel: under the panel's tree when the panel's hook lies beneath this one, or alone with an
|
|
410
|
+
// `addon:` key that the panel's hook picks up when it lies above. Either way the crabs end up under the agents.
|
|
411
|
+
on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, next) => {
|
|
403
412
|
const { Box, Text } = $.ui.resolve(e)
|
|
413
|
+
const below: RenderElement | undefined = await next(e).catch(() => undefined)
|
|
414
|
+
const panel = below !== undefined && holdsKey(below, PANEL_KEY) ? below : undefined
|
|
415
|
+
/** Puts the crabs' section under the panel's tree, when it was drawn beneath. */
|
|
416
|
+
const joined = (section: RenderElement) => (panel === undefined ? section : <Box flexDirection="column" gap={1}>{panel}{section}</Box>)
|
|
417
|
+
|
|
404
418
|
const list = await read($, crabs)
|
|
405
419
|
const plan = await read($, planned)
|
|
406
420
|
const window = await read($, contextWindow)
|
|
407
421
|
const now = await $.clock.now()
|
|
408
|
-
if (list.length === 0 && plan.length === 0)
|
|
422
|
+
if (list.length === 0 && plan.length === 0) {
|
|
423
|
+
return joined(<Box key={`${ADDON_KEY}crabs`}><Text dimColor>No subagents yet. Each one shows up here as a crab.</Text></Box>)
|
|
424
|
+
}
|
|
409
425
|
|
|
410
426
|
const barWidth = Math.max(120, (e.props.bodyColumns - 10) * 7)
|
|
411
427
|
/**
|
|
@@ -485,8 +501,9 @@ export const register: Register = on => {
|
|
|
485
501
|
const running = list.filter(crab => crab.status === 'running')
|
|
486
502
|
const done = list.filter(crab => crab.status !== 'running')
|
|
487
503
|
|
|
488
|
-
return (
|
|
489
|
-
<Box flexDirection="column">
|
|
504
|
+
return joined(
|
|
505
|
+
<Box key={`${ADDON_KEY}crabs`} flexDirection="column">
|
|
506
|
+
<Text bold>Subagents</Text>
|
|
490
507
|
{running.map(card)}
|
|
491
508
|
{done.length > 0 && <Text dimColor>▾ Finished · {done.length}</Text>}
|
|
492
509
|
{done.slice().reverse().map(card)}
|
|
@@ -503,7 +520,7 @@ export const register: Register = on => {
|
|
|
503
520
|
</Box>
|
|
504
521
|
</Box>
|
|
505
522
|
))}
|
|
506
|
-
</Box
|
|
523
|
+
</Box>,
|
|
507
524
|
)
|
|
508
525
|
})
|
|
509
526
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "delete-guard",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Holds rm -rf and other permanent deletes on a card that lists what they remove, until you press Delete or Cancel.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "g3ntrovert"
|
|
7
7
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{ "modules": ["./register.
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
// packages/g3n-mods/mods/delete-guard/hooks/register.tsx
|
|
2
|
+
import { atom, read, update } from 'claude-code'
|
|
3
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
4
|
+
|
|
5
|
+
import type { Answer, BlastRadius, Deletion, DoomedFile } from '../types'
|
|
6
|
+
|
|
7
|
+
const isOn = atom({ plugin: 'delete-guard', key: 'isOn' } as const, false)
|
|
8
|
+
|
|
9
|
+
const PANE = 'delete-guard'
|
|
10
|
+
const TITLE = 'Delete guard'
|
|
11
|
+
const HOLD_TITLE = 'Delete?'
|
|
12
|
+
|
|
13
|
+
/** Key in the plugin's store that remembers the switch between sessions. */
|
|
14
|
+
const STORE_KEY = 'isOn'
|
|
15
|
+
|
|
16
|
+
/** How long a delete waits on the card before it is refused unanswered. */
|
|
17
|
+
const HOLD_MS = 5 * 60_000
|
|
18
|
+
|
|
19
|
+
/** Most files one delete's walk counts before it stops. */
|
|
20
|
+
const MAX_FILES = 5000
|
|
21
|
+
|
|
22
|
+
/** File rows the card shows before its "Show more" button. */
|
|
23
|
+
const SHOWN_FILES = 8
|
|
24
|
+
|
|
25
|
+
/** Approximate pixel width of one cell of the editor's code font. */
|
|
26
|
+
const CELL_PX = 7
|
|
27
|
+
|
|
28
|
+
const RED = '#e06c6c'
|
|
29
|
+
const ORANGE = '#e0775a'
|
|
30
|
+
const TRACK = '#2b3240'
|
|
31
|
+
const CHIP = '#4a2626'
|
|
32
|
+
const WARN_BG = '#3a2222'
|
|
33
|
+
const WARN_EDGE = '#7a3b3b'
|
|
34
|
+
const UNTRACKED_BG = '#6b3a22'
|
|
35
|
+
|
|
36
|
+
/** Shell syntax that starts a new command: `&&`, `||`, `;`, `|`, a newline, `$(` and a backtick. */
|
|
37
|
+
const SEGMENT_BREAK = /&&|\|\||;|\||\n|\$\(|`/
|
|
38
|
+
|
|
39
|
+
/** Words that run the command after them: `sudo rm`, `xargs -0 rm`, `env X=1 rm`. */
|
|
40
|
+
const PREFIX = /^(?:\w+=\S*\s+|(?:sudo|command|exec|nice|nohup|time|env)\s+(?:-\S+\s+)*|xargs\s+(?:-\S+\s+)*)+/
|
|
41
|
+
|
|
42
|
+
const DELETERS = new Set(['rm', 'shred', 'unlink', 'rimraf', 'srm'])
|
|
43
|
+
|
|
44
|
+
/** Trash commands in order of preference, with how each one is spelled. */
|
|
45
|
+
const TRASH_COMMANDS = [
|
|
46
|
+
{ probe: 'trash', spell: 'trash' },
|
|
47
|
+
{ probe: 'trash-put', spell: 'trash-put' },
|
|
48
|
+
{ probe: 'gio', spell: 'gio trash' },
|
|
49
|
+
] as const
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Splits a shell command line into its commands, each trimmed.
|
|
53
|
+
*
|
|
54
|
+
* @param command - Shell command line, e.g. `cd build && rm -rf dist`.
|
|
55
|
+
* @returns The non-empty commands in order.
|
|
56
|
+
*/
|
|
57
|
+
function segments(command: string): string[] {
|
|
58
|
+
return command.split(SEGMENT_BREAK).map(part => part.trim()).filter(part => part.length > 0)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Splits one command into words, dropping its prefixes and the program's directory.
|
|
63
|
+
*
|
|
64
|
+
* @param segment - One command, as `segments` returns it.
|
|
65
|
+
* @returns The program name first, then its arguments.
|
|
66
|
+
*/
|
|
67
|
+
function words(segment: string): string[] {
|
|
68
|
+
const [program = '', ...rest] = segment.replace(PREFIX, '').split(/\s+/)
|
|
69
|
+
return [program.split('/').pop() ?? program, ...rest]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Picks the operands of a command: every word that is not a flag, and every word after `--`.
|
|
74
|
+
*
|
|
75
|
+
* @param args - Words after the program name.
|
|
76
|
+
* @returns The operands in order.
|
|
77
|
+
*/
|
|
78
|
+
function operands(args: readonly string[]): string[] {
|
|
79
|
+
const end = args.indexOf('--')
|
|
80
|
+
const flagged = end === -1 ? args : args.slice(0, end)
|
|
81
|
+
const after = end === -1 ? [] : args.slice(end + 1)
|
|
82
|
+
return [...flagged.filter(arg => !arg.startsWith('-')), ...after]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @brief Finds the first permanent delete in a shell command line.
|
|
87
|
+
*
|
|
88
|
+
* Recognises `rm`, `shred`, `unlink`, `rimraf`, `find -delete`, `find -exec rm`
|
|
89
|
+
* and a forced `git clean`.
|
|
90
|
+
*
|
|
91
|
+
* @param command - Full shell command line, as the Bash tool received it.
|
|
92
|
+
* @returns The delete and what it removes, or `undefined` when nothing is deleted for good.
|
|
93
|
+
*/
|
|
94
|
+
export function findDeletion(command: string): Deletion | undefined {
|
|
95
|
+
for (const segment of segments(command)) {
|
|
96
|
+
const [program = '', ...args] = words(segment)
|
|
97
|
+
if (DELETERS.has(program)) {
|
|
98
|
+
return { segment, kind: 'files', paths: operands(args) }
|
|
99
|
+
}
|
|
100
|
+
if (program === 'find' && (args.includes('-delete') || /-exec(?:dir)?\s+(?:\S*\/)?rm\b/.test(segment))) {
|
|
101
|
+
return { segment, kind: 'find', paths: [] }
|
|
102
|
+
}
|
|
103
|
+
if (program === 'git' && args[0] === 'clean' && args.some(arg => /^-\w*f/.test(arg) || arg === '--force')) {
|
|
104
|
+
return { segment, kind: 'git-clean', paths: [] }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds the command Claude should run instead of a permanent delete.
|
|
112
|
+
*
|
|
113
|
+
* @param deletion - Delete to replace, see `Deletion`.
|
|
114
|
+
* @param trash - Trash command spelled as this machine has it, e.g. `gio trash`.
|
|
115
|
+
* @returns The suggested command, with `<paths>` where no path was named.
|
|
116
|
+
*/
|
|
117
|
+
function insteadOf(deletion: Deletion, trash: string): string {
|
|
118
|
+
switch (deletion.kind) {
|
|
119
|
+
case 'files':
|
|
120
|
+
return deletion.paths.length > 0 ? `${trash} ${deletion.paths.join(' ')}` : `${trash} <paths>`
|
|
121
|
+
case 'find':
|
|
122
|
+
return `${deletion.segment.replace(/\s-delete\b/, '').replace(/-exec(?:dir)?\s+.*$/, '')} -exec ${trash} {} +`.replace(/\s+/g, ' ')
|
|
123
|
+
case 'git-clean':
|
|
124
|
+
return `git clean -n to list the files, then ${trash} <paths>`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Finds the first trash command on this machine's PATH.
|
|
130
|
+
*
|
|
131
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
132
|
+
* @returns The command as it should be spelled, or `undefined` when none is installed.
|
|
133
|
+
*/
|
|
134
|
+
async function detectTrash($: EngineInterface): Promise<string | undefined> {
|
|
135
|
+
for (const candidate of TRASH_COMMANDS) {
|
|
136
|
+
const found = await $.process.run(['sh', '-c', `command -v ${candidate.probe}`]).catch(() => undefined)
|
|
137
|
+
if (found?.exitCode === 0) return candidate.spell
|
|
138
|
+
}
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Strips one pair of matching quotes around a shell word.
|
|
144
|
+
*
|
|
145
|
+
* @param word - Word as the command spelled it, e.g. `"build dir"`.
|
|
146
|
+
* @returns The word without its quotes.
|
|
147
|
+
*/
|
|
148
|
+
function unquote(word: string): string {
|
|
149
|
+
return /^(['"]).*\1$/.test(word) ? word.slice(1, -1) : word
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Writes a path relative to the session's directory when it lies inside it.
|
|
154
|
+
*
|
|
155
|
+
* @param cwd - Session's directory, absolute.
|
|
156
|
+
* @param path - Path to shorten, absolute or relative.
|
|
157
|
+
* @returns The path without `./` and without the `cwd` prefix.
|
|
158
|
+
*/
|
|
159
|
+
function relativeTo(cwd: string, path: string): string {
|
|
160
|
+
const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`
|
|
161
|
+
const inside = path.startsWith(prefix) ? path.slice(prefix.length) : path
|
|
162
|
+
return inside.replace(/^(\.\/)+/, '')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Turns a shell glob's last segment into a regular expression: `*`, `?` and `[...]`.
|
|
167
|
+
*
|
|
168
|
+
* @param glob - One path segment, e.g. `*.log`.
|
|
169
|
+
* @returns The matching expression, anchored at both ends.
|
|
170
|
+
*/
|
|
171
|
+
function globRegExp(glob: string): RegExp {
|
|
172
|
+
const body = glob.replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
|
|
173
|
+
return new RegExp(`^${body}$`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Expands one operand of a delete into the paths it names: `~` to the home directory, and a glob in its last segment.
|
|
178
|
+
*
|
|
179
|
+
* @param $ - Engine handle the hook received.
|
|
180
|
+
* @param operand - Operand as the command spelled it.
|
|
181
|
+
* @param home - Home directory, for `~`.
|
|
182
|
+
* @returns The paths, empty when a glob matches nothing.
|
|
183
|
+
*/
|
|
184
|
+
async function expandOperand($: EngineInterface, operand: string, home: string): Promise<string[]> {
|
|
185
|
+
const path = unquote(operand).replace(/^~(?=\/|$)/, home)
|
|
186
|
+
if (!/[*?[]/.test(path)) return [path]
|
|
187
|
+
const cut = path.lastIndexOf('/')
|
|
188
|
+
const dir = cut === -1 ? '.' : path.slice(0, cut) || '/'
|
|
189
|
+
const pattern = globRegExp(path.slice(cut + 1))
|
|
190
|
+
const entries = await $.fs.list(dir).catch(() => [])
|
|
191
|
+
return entries
|
|
192
|
+
.filter(entry => pattern.test(entry.name) && !entry.name.startsWith('.'))
|
|
193
|
+
.map(entry => (cut === -1 ? entry.name : `${path.slice(0, cut)}/${entry.name}`))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Walks a path as a recursive delete would and adds every file under it; links count as files and are not followed.
|
|
198
|
+
*
|
|
199
|
+
* @param $ - Engine handle the hook received.
|
|
200
|
+
* @param path - File or directory to walk.
|
|
201
|
+
* @param found - Files found so far, as path and size; added to.
|
|
202
|
+
* @returns False once `MAX_FILES` is reached, so the caller stops.
|
|
203
|
+
*/
|
|
204
|
+
async function walk($: EngineInterface, path: string, found: { path: string; size: number }[]): Promise<boolean> {
|
|
205
|
+
const stat = await $.fs.stat(path).catch(() => undefined)
|
|
206
|
+
if (stat === undefined) return true
|
|
207
|
+
if (stat.isLink || stat.kind !== 'dir') {
|
|
208
|
+
found.push({ path, size: stat.isLink ? 0 : stat.size })
|
|
209
|
+
return found.length < MAX_FILES
|
|
210
|
+
}
|
|
211
|
+
for (const entry of await $.fs.list(path).catch(() => [])) {
|
|
212
|
+
const child = `${path.replace(/\/$/, '')}/${entry.name}`
|
|
213
|
+
if (entry.kind === 'dir' && !entry.isLink) {
|
|
214
|
+
if (!(await walk($, child, found))) return false
|
|
215
|
+
} else {
|
|
216
|
+
found.push({ path: child, size: entry.size })
|
|
217
|
+
if (found.length >= MAX_FILES) return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lists the paths a `find` delete or a forced `git clean` would remove, by running the same command without deleting.
|
|
225
|
+
*
|
|
226
|
+
* @param $ - Engine handle the hook received.
|
|
227
|
+
* @param deletion - A delete of kind `find` or `git-clean`.
|
|
228
|
+
* @returns The paths, or `undefined` when the listing failed.
|
|
229
|
+
*/
|
|
230
|
+
async function listedPaths($: EngineInterface, deletion: Deletion): Promise<string[] | undefined> {
|
|
231
|
+
const argv = words(deletion.segment).map(unquote)
|
|
232
|
+
if (deletion.kind === 'git-clean') {
|
|
233
|
+
const flags = argv.slice(2)
|
|
234
|
+
.filter(arg => arg !== '--force')
|
|
235
|
+
.map(arg => (/^-[^-]/.test(arg) ? arg.replace(/f/g, '') : arg))
|
|
236
|
+
.filter(arg => arg !== '-')
|
|
237
|
+
const ran = await $.process.run(['git', 'clean', '-n', ...flags], { timeoutMs: 10_000 }).catch(() => undefined)
|
|
238
|
+
if (ran?.exitCode !== 0) return undefined
|
|
239
|
+
return ran.stdout.split('\n').map(line => line.replace(/^Would remove /, '').trim()).filter(line => line !== '')
|
|
240
|
+
}
|
|
241
|
+
const kept: string[] = []
|
|
242
|
+
for (let i = 0; i < argv.length; i++) {
|
|
243
|
+
const arg = argv[i] ?? ''
|
|
244
|
+
if (arg === '-delete') continue
|
|
245
|
+
if (arg === '-exec' || arg === '-execdir') {
|
|
246
|
+
while (i < argv.length && !/^(\\?;|\+)$/.test(argv[i] ?? '')) i++
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
kept.push(arg)
|
|
250
|
+
}
|
|
251
|
+
const ran = await $.process.run([...kept, '-print'], { timeoutMs: 10_000 }).catch(() => undefined)
|
|
252
|
+
if (ran?.exitCode !== 0) return undefined
|
|
253
|
+
return ran.stdout.split('\n').filter(line => line !== '')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Finds the files under `paths` that git tracks, which `git checkout` can bring back.
|
|
258
|
+
*
|
|
259
|
+
* @param $ - Engine handle the hook received.
|
|
260
|
+
* @param paths - Roots of the delete, relative to the session's directory or absolute.
|
|
261
|
+
* @returns Tracked paths relative to the session's directory; empty outside a repository.
|
|
262
|
+
*/
|
|
263
|
+
async function trackedFiles($: EngineInterface, paths: readonly string[]): Promise<Set<string>> {
|
|
264
|
+
const ran = await $.process.run(['git', 'ls-files', '-z', '--', ...paths], { timeoutMs: 10_000 }).catch(() => undefined)
|
|
265
|
+
if (ran?.exitCode !== 0) return new Set()
|
|
266
|
+
return new Set(ran.stdout.split('\0').filter(path => path !== ''))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @brief Measures what a delete would remove: every file, its size, and whether git can restore it.
|
|
271
|
+
*
|
|
272
|
+
* @param $ - Engine handle the hook received.
|
|
273
|
+
* @param deletion - Delete to measure, see `findDeletion`.
|
|
274
|
+
* @param cwd - Session's directory, absolute.
|
|
275
|
+
* @param home - Home directory, for `~`.
|
|
276
|
+
* @returns The files largest first, with their total; `isKnown` false when they could not be listed.
|
|
277
|
+
*/
|
|
278
|
+
async function measure($: EngineInterface, deletion: Deletion, cwd: string, home: string): Promise<BlastRadius> {
|
|
279
|
+
const roots = deletion.kind === 'files'
|
|
280
|
+
? (await Promise.all(deletion.paths.map(operand => expandOperand($, operand, home)))).flat()
|
|
281
|
+
: await listedPaths($, deletion)
|
|
282
|
+
if (roots === undefined) return { files: [], total: 0, isCut: false, isKnown: false }
|
|
283
|
+
|
|
284
|
+
const found: { path: string; size: number }[] = []
|
|
285
|
+
let isCut = false
|
|
286
|
+
for (const root of roots) {
|
|
287
|
+
if (!(await walk($, root, found))) {
|
|
288
|
+
isCut = true
|
|
289
|
+
break
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const tracked = deletion.kind === 'git-clean' || roots.length === 0 ? new Set<string>() : await trackedFiles($, roots)
|
|
293
|
+
const files: DoomedFile[] = found
|
|
294
|
+
.map(file => {
|
|
295
|
+
const path = relativeTo(cwd, file.path)
|
|
296
|
+
return { path, size: file.size, isTracked: tracked.has(path) }
|
|
297
|
+
})
|
|
298
|
+
.sort((a, b) => b.size - a.size)
|
|
299
|
+
return { files, total: files.reduce((sum, file) => sum + file.size, 0), isCut, isKnown: true }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Formats bytes as the card shows them: `B`, whole `KB`, or `MB` and `GB` with one decimal. */
|
|
303
|
+
function bytes(size: number): string {
|
|
304
|
+
if (size < 1024) return `${size} B`
|
|
305
|
+
if (size < 1024 ** 2) return `${Math.round(size / 1024)} KB`
|
|
306
|
+
if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(1)} MB`
|
|
307
|
+
return `${(size / 1024 ** 3).toFixed(1)} GB`
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Counts files as `1 file` or `N files`, with `N+` when the walk was cut. */
|
|
311
|
+
function fileCount(count: number, isCut = false): string {
|
|
312
|
+
return `${count}${isCut ? '+' : ''} file${count === 1 && !isCut ? '' : 's'}`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Says whether git can bring the files back, the warning under the card's header.
|
|
317
|
+
*
|
|
318
|
+
* @param radius - What the delete removes.
|
|
319
|
+
* @returns One sentence about how many of the files git tracks.
|
|
320
|
+
*/
|
|
321
|
+
function restoreNote(radius: BlastRadius): string {
|
|
322
|
+
if (!radius.isKnown) return "Couldn't list what this deletes, so its size is unknown."
|
|
323
|
+
const tracked = radius.files.filter(file => file.isTracked).length
|
|
324
|
+
if (tracked === 0) return "None of these are tracked by git, so they can't be restored."
|
|
325
|
+
if (tracked === radius.files.length) return 'All of these are tracked by git: their committed versions can be restored, uncommitted changes cannot.'
|
|
326
|
+
return `${tracked} of these are tracked by git and can be restored; the other ${radius.files.length - tracked} cannot.`
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The folder a file sits in, as the card groups them: `.` for the session's directory. */
|
|
330
|
+
function folderOf(path: string): string {
|
|
331
|
+
const cut = path.lastIndexOf('/')
|
|
332
|
+
return cut === -1 ? '.' : path.slice(0, cut)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Builds the SVG of a size bar: a track with a fill `share` of its width.
|
|
337
|
+
*
|
|
338
|
+
* @param width - Width of the track in pixels.
|
|
339
|
+
* @param share - Filled part, from 0 to 1.
|
|
340
|
+
* @param color - Fill color.
|
|
341
|
+
* @returns The SVG markup.
|
|
342
|
+
*/
|
|
343
|
+
function barSvg(width: number, share: number, color: string): string {
|
|
344
|
+
const fill = Math.max(3, share * width)
|
|
345
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect width="${width}" height="8" rx="4" fill="${TRACK}"/><rect width="${fill.toFixed(1)}" height="8" rx="4" fill="${color}"/></svg>`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A delete held for the person's answer: what it runs, what it removes, and how to settle it. */
|
|
349
|
+
type Held = {
|
|
350
|
+
segment: string
|
|
351
|
+
folder: string
|
|
352
|
+
radius: BlastRadius
|
|
353
|
+
settle: (answer: Answer | 'timeout') => void
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Deletes waiting on the card, oldest first; the card shows the first. */
|
|
357
|
+
const held: Held[] = []
|
|
358
|
+
|
|
359
|
+
/** The card lists every file instead of the first `SHOWN_FILES`. */
|
|
360
|
+
let isExpanded = false
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Turns the guard on or off, for this session and every later one.
|
|
364
|
+
*
|
|
365
|
+
* @param $ - Engine handle the command or press came through.
|
|
366
|
+
* @param isGuarding - True to hold deletes on the card, false to let them run unasked.
|
|
367
|
+
*/
|
|
368
|
+
async function setGuard($: EngineInterface, isGuarding: boolean): Promise<void> {
|
|
369
|
+
await update($, isOn, () => isGuarding)
|
|
370
|
+
await $.store.set(STORE_KEY, isGuarding)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Shows the switch again once no delete waits: the pane's title goes back from the card's.
|
|
375
|
+
*
|
|
376
|
+
* @param $ - Engine handle the hook received.
|
|
377
|
+
*/
|
|
378
|
+
async function showSwitch($: EngineInterface): Promise<void> {
|
|
379
|
+
if (held.length === 0) await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
|
|
380
|
+
$.ui.invalidate('ui.render')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Answers the delete the card shows, then shows the next one or the switch.
|
|
385
|
+
*
|
|
386
|
+
* @param $ - Engine handle the press came through.
|
|
387
|
+
* @param answer - What the person chose.
|
|
388
|
+
*/
|
|
389
|
+
async function answerFirst($: EngineInterface, answer: Answer): Promise<void> {
|
|
390
|
+
const first = held.shift()
|
|
391
|
+
isExpanded = false
|
|
392
|
+
first?.settle(answer)
|
|
393
|
+
await showSwitch($)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @brief Holds a delete until the person answers its card, it waits `HOLD_MS`, or Claude's call is stopped.
|
|
398
|
+
*
|
|
399
|
+
* @param $ - Engine handle the hook received.
|
|
400
|
+
* @param card - The delete and what it removes.
|
|
401
|
+
* @param signal - Aborts the wait when Claude's call is stopped.
|
|
402
|
+
* @returns `delete`, `cancel`, or `timeout` when no one answered in time.
|
|
403
|
+
*/
|
|
404
|
+
async function hold($: EngineInterface, card: Omit<Held, 'settle'>, signal: AbortSignal | undefined): Promise<Answer | 'timeout'> {
|
|
405
|
+
let settle: (answer: Answer | 'timeout') => void = () => {}
|
|
406
|
+
const answered = new Promise<Answer | 'timeout'>(resolve => { settle = resolve })
|
|
407
|
+
const entry: Held = { ...card, settle }
|
|
408
|
+
held.push(entry)
|
|
409
|
+
|
|
410
|
+
/** Takes this delete off the card with `answer`, unless it was answered already. */
|
|
411
|
+
const drop = (answer: Answer | 'timeout'): void => {
|
|
412
|
+
const index = held.indexOf(entry)
|
|
413
|
+
if (index === -1) return
|
|
414
|
+
held.splice(index, 1)
|
|
415
|
+
settle(answer)
|
|
416
|
+
void showSwitch($)
|
|
417
|
+
}
|
|
418
|
+
const timer = $.clock.after(HOLD_MS, () => drop('timeout'))
|
|
419
|
+
const onAbort = (): void => drop('cancel')
|
|
420
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
421
|
+
|
|
422
|
+
// The switch's pane is usually open already: retitle and focus it, then redraw it as the card.
|
|
423
|
+
if (held.length === 1) await $.ui.open({ id: PANE, title: HOLD_TITLE, focus: true })
|
|
424
|
+
$.ui.invalidate('ui.render')
|
|
425
|
+
|
|
426
|
+
const answer = await answered
|
|
427
|
+
timer.cancel()
|
|
428
|
+
signal?.removeEventListener('abort', onAbort)
|
|
429
|
+
return answer
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Registers this mod's `delete-guard` command, its session hook, its Bash tool hook, and its pane: the switch, or the card while a delete waits.
|
|
434
|
+
*
|
|
435
|
+
* @param on - Hook registration function the engine passes in.
|
|
436
|
+
*/
|
|
437
|
+
export const register: Register = on => {
|
|
438
|
+
let trash: string | undefined
|
|
439
|
+
let home = ''
|
|
440
|
+
|
|
441
|
+
on('session.start', async ($, e, next) => {
|
|
442
|
+
trash = await detectTrash($)
|
|
443
|
+
const found = await $.process.run(['printenv', 'HOME']).catch(() => undefined)
|
|
444
|
+
home = found?.stdout.trim() ?? ''
|
|
445
|
+
const remembered = await $.store.get(STORE_KEY).catch(() => undefined)
|
|
446
|
+
await update($, isOn, () => remembered === true)
|
|
447
|
+
await $.command.register({
|
|
448
|
+
name: 'delete-guard',
|
|
449
|
+
description: 'Turn the permanent-delete guard on or off; it stays that way in later sessions',
|
|
450
|
+
argumentHint: 'on | off',
|
|
451
|
+
})
|
|
452
|
+
void $.ui.open({ id: PANE, title: TITLE })
|
|
453
|
+
return next(e)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
on('command.run', { command: 'delete-guard' }, async ($, e) => {
|
|
457
|
+
const wanted = e.args.trim()
|
|
458
|
+
if (wanted === 'on' || wanted === 'off') await setGuard($, wanted === 'on')
|
|
459
|
+
const isGuarding = await read($, isOn)
|
|
460
|
+
return { text: `delete-guard is ${isGuarding ? 'on: permanent deletes wait for your answer' : 'off: permanent deletes run unasked'}.` }
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
|
|
464
|
+
if (!(await read($, isOn))) return next(e)
|
|
465
|
+
const deletion = findDeletion(e.command)
|
|
466
|
+
if (deletion === undefined) return next(e)
|
|
467
|
+
|
|
468
|
+
const spelled = trash ?? 'trash'
|
|
469
|
+
const install = trash === undefined ? ' (install trash-cli: sudo apt install trash-cli)' : ''
|
|
470
|
+
const instead = `\`${insteadOf(deletion, spelled)}\`${install}`
|
|
471
|
+
|
|
472
|
+
// Nothing can draw the card, as in a -p run or VS Code without the viewer: refuse outright.
|
|
473
|
+
if ((await $.session.surfaces()).length === 0) {
|
|
474
|
+
return { deny: `delete-guard: \`${deletion.segment}\` deletes permanently. Move it to the trash instead: ${instead}.` }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const cwd = await $.session.cwd()
|
|
478
|
+
const radius = await measure($, deletion, cwd, home)
|
|
479
|
+
if (radius.isKnown && radius.files.length === 0) return next(e)
|
|
480
|
+
|
|
481
|
+
const folder = cwd.split('/').filter(Boolean).pop() ?? cwd
|
|
482
|
+
const answer = await hold($, { segment: deletion.segment, folder, radius }, next.signal)
|
|
483
|
+
if (answer === 'delete') return next(e)
|
|
484
|
+
if (answer === 'timeout') {
|
|
485
|
+
return { deny: `delete-guard: no one answered the delete card within ${HOLD_MS / 60_000} minutes, so \`${deletion.segment}\` did not run. Ask the user before trying again.` }
|
|
486
|
+
}
|
|
487
|
+
return { deny: `delete-guard: the user cancelled \`${deletion.segment}\`. Do not delete these files without asking; to remove them, move them to the trash: ${instead}.` }
|
|
488
|
+
}).catch(($, e, next) => (next.called ? next(e) : { deny: 'delete-guard: the guard failed, so the command was not run.' }))
|
|
489
|
+
|
|
490
|
+
on('ui.close', async ($, e, next) => {
|
|
491
|
+
// Closing the card by hand answers every delete it holds with Cancel.
|
|
492
|
+
if (e.id === PANE && e.origin.kind === 'person') {
|
|
493
|
+
for (const entry of held.splice(0)) entry.settle('cancel')
|
|
494
|
+
isExpanded = false
|
|
495
|
+
}
|
|
496
|
+
return next(e)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
500
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
501
|
+
const card = held[0]
|
|
502
|
+
if (card === undefined) {
|
|
503
|
+
const isGuarding = await read($, isOn)
|
|
504
|
+
return (
|
|
505
|
+
<Box flexDirection="row" columnGap={2} alignItems="center">
|
|
506
|
+
<Button key="switch" label={isGuarding ? '● on' : '○ off'} variant={isGuarding ? 'primary' : 'secondary'} onPress={() => setGuard($, !isGuarding)} />
|
|
507
|
+
<Text dimColor wrap="truncate-end">{isGuarding ? 'Deletes wait here for Delete or Cancel.' : 'Deletes run without asking.'}</Text>
|
|
508
|
+
</Box>
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
const { radius } = card
|
|
512
|
+
const barCells = Math.max(8, Math.floor(e.props.bodyColumns * 0.22))
|
|
513
|
+
|
|
514
|
+
/** Draws a size bar with `share` of it filled: an SVG, or block characters in the terminal. */
|
|
515
|
+
const bar = (share: number, color: string) => {
|
|
516
|
+
if (e.surface === 'terminal') {
|
|
517
|
+
const filled = Math.max(1, Math.round(share * barCells))
|
|
518
|
+
return <Text><Text color={color}>{'▬'.repeat(filled)}</Text><Text color={TRACK}>{'▬'.repeat(barCells - filled)}</Text></Text>
|
|
519
|
+
}
|
|
520
|
+
const { Svg } = $.ui.resolve(e)
|
|
521
|
+
const width = barCells * CELL_PX
|
|
522
|
+
return <Svg source={barSvg(width, share, color)} alt={`${Math.round(share * 100)}%`} width={width} height={8} />
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Folders by size, each with its largest files under it, until SHOWN_FILES file rows are used.
|
|
526
|
+
const folders = new Map<string, DoomedFile[]>()
|
|
527
|
+
for (const file of radius.files) folders.set(folderOf(file.path), [...(folders.get(folderOf(file.path)) ?? []), file])
|
|
528
|
+
const groups = [...folders.entries()]
|
|
529
|
+
.map(([name, files]) => ({ name, files, size: files.reduce((sum, file) => sum + file.size, 0) }))
|
|
530
|
+
.sort((a, b) => b.size - a.size)
|
|
531
|
+
const largest = Math.max(1, groups[0]?.size ?? 1)
|
|
532
|
+
let budget = isExpanded ? radius.files.length : SHOWN_FILES
|
|
533
|
+
const rows = groups.flatMap(group => {
|
|
534
|
+
if (budget <= 0) return []
|
|
535
|
+
const files = group.files.slice(0, budget)
|
|
536
|
+
budget -= files.length
|
|
537
|
+
return [
|
|
538
|
+
<Box key={`dir:${group.name}`} flexDirection="row" columnGap={1} marginTop={1}>
|
|
539
|
+
<Box flexGrow={1}><Text wrap="truncate-end" bold>▭ {group.name === '.' ? './' : `${group.name}/`}</Text></Box>
|
|
540
|
+
<Box width={9} justifyContent="flex-end"><Text dimColor>{fileCount(group.files.length)}</Text></Box>
|
|
541
|
+
<Box width={8} justifyContent="flex-end"><Text bold>{bytes(group.size)}</Text></Box>
|
|
542
|
+
{bar(group.size / largest, '#d8d4c8')}
|
|
543
|
+
</Box>,
|
|
544
|
+
...files.map(file => (
|
|
545
|
+
<Box key={`file:${file.path}`} flexDirection="row" columnGap={1} paddingLeft={2}>
|
|
546
|
+
<Box flexGrow={1}>
|
|
547
|
+
<Text wrap="truncate-end"><Text dimColor>▯ </Text><Text backgroundColor={file.isTracked ? undefined : UNTRACKED_BG}>{file.path.slice(file.path.lastIndexOf('/') + 1)}</Text></Text>
|
|
548
|
+
</Box>
|
|
549
|
+
<Box width={8} justifyContent="flex-end"><Text dimColor>{bytes(file.size)}</Text></Box>
|
|
550
|
+
{bar(file.size / largest, '#9a968c')}
|
|
551
|
+
</Box>
|
|
552
|
+
)),
|
|
553
|
+
]
|
|
554
|
+
})
|
|
555
|
+
const hidden = isExpanded ? 0 : Math.max(0, radius.files.length - SHOWN_FILES)
|
|
556
|
+
const count = radius.isKnown ? `${fileCount(radius.files.length, radius.isCut)} · ${bytes(radius.total)}` : 'unknown files'
|
|
557
|
+
const waiting = held.length > 1 ? ` · ${held.length - 1} more waiting` : ''
|
|
558
|
+
|
|
559
|
+
return (
|
|
560
|
+
<Box flexDirection="column">
|
|
561
|
+
<Box flexDirection="row" columnGap={2} alignItems="center">
|
|
562
|
+
<Text bold color={RED} backgroundColor={CHIP}> 🗑 </Text>
|
|
563
|
+
<Box flexDirection="column" flexShrink={1}>
|
|
564
|
+
<Text wrap="truncate-end"><Text bold>{card.segment}</Text><Text dimColor> will delete </Text><Text bold color={ORANGE}>{count}</Text></Text>
|
|
565
|
+
<Text dimColor>in {card.folder}{waiting}</Text>
|
|
566
|
+
</Box>
|
|
567
|
+
</Box>
|
|
568
|
+
<Box borderStyle="round" borderColor={WARN_EDGE} backgroundColor={WARN_BG} paddingX={1} marginTop={1}>
|
|
569
|
+
<Text color={RED}>⛉ {restoreNote(radius)}</Text>
|
|
570
|
+
</Box>
|
|
571
|
+
{rows}
|
|
572
|
+
{hidden > 0 && (
|
|
573
|
+
<Box marginTop={1}>
|
|
574
|
+
<Button key="more" plain onPress={() => { isExpanded = true; $.ui.invalidate('ui.render') }}><Text dimColor>Show {hidden} more</Text></Button>
|
|
575
|
+
</Box>
|
|
576
|
+
)}
|
|
577
|
+
<Box flexDirection="row" columnGap={2} alignItems="center" marginTop={1}>
|
|
578
|
+
<Button key="delete" variant="primary" label={`Delete ${fileCount(radius.files.length, radius.isCut)}`} onPress={() => answerFirst($, 'delete')} />
|
|
579
|
+
<Button key="cancel" label="Cancel" onPress={() => answerFirst($, 'cancel')} />
|
|
580
|
+
<Text dimColor>Held by delete-guard</Text>
|
|
581
|
+
</Box>
|
|
582
|
+
</Box>
|
|
583
|
+
)
|
|
584
|
+
})
|
|
585
|
+
}
|
|
@@ -7,8 +7,33 @@ export type Deletion = {
|
|
|
7
7
|
paths: string[]
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** One file a delete would remove: its path relative to the session's directory, its bytes, and whether git tracks it. */
|
|
11
|
+
export type DoomedFile = {
|
|
12
|
+
path: string
|
|
13
|
+
size: number
|
|
14
|
+
isTracked: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a delete would remove, as the card shows it.
|
|
19
|
+
*
|
|
20
|
+
* @property files - Every file found, largest first.
|
|
21
|
+
* @property total - Bytes of all of them.
|
|
22
|
+
* @property isCut - The walk stopped early, so there are more than `files` lists.
|
|
23
|
+
* @property isKnown - The files could be listed at all; false when the listing failed.
|
|
24
|
+
*/
|
|
25
|
+
export type BlastRadius = {
|
|
26
|
+
files: DoomedFile[]
|
|
27
|
+
total: number
|
|
28
|
+
isCut: boolean
|
|
29
|
+
isKnown: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The person's answer on the card: run the delete, or refuse it. */
|
|
33
|
+
export type Answer = 'delete' | 'cancel'
|
|
34
|
+
|
|
10
35
|
declare module 'claude-code' {
|
|
11
36
|
interface PluginState {
|
|
12
|
-
'delete-guard': {
|
|
37
|
+
'delete-guard': { isOn: boolean }
|
|
13
38
|
}
|
|
14
39
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "focus-mode",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "A timed focus block
|
|
4
|
+
"description": "A timed focus block in which you're away: Claude asks no questions and gets no permission prompts. Countdown, progress bar and end button.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "g3ntrovert"
|
|
7
7
|
},
|
|
@@ -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,7 +132,7 @@ 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
|
})
|
|
125
138
|
void $.ui.open({ id: PANE, title: TITLE })
|
|
@@ -141,6 +154,25 @@ export const register: Register = (on, options) => {
|
|
|
141
154
|
return { text: `Focus block started: ${minutes} min.` }
|
|
142
155
|
})
|
|
143
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
|
+
|
|
144
176
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
145
177
|
const { Box, Text, Button } = $.ui.resolve(e)
|
|
146
178
|
const current = await read($, block)
|
|
@@ -176,7 +208,7 @@ export const register: Register = (on, options) => {
|
|
|
176
208
|
<Text bold>{clock(remaining)}</Text>
|
|
177
209
|
<Box flexDirection="column" flexGrow={1} gap={0}>
|
|
178
210
|
{squares}
|
|
179
|
-
<Text dimColor>
|
|
211
|
+
<Text dimColor>away until {wallTime(current.endsAt)} · Claude asks you nothing</Text>
|
|
180
212
|
</Box>
|
|
181
213
|
<Button key="end" label="end focus" onPress={() => endFocus($, false)} />
|
|
182
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
|
/**
|
|
@@ -224,21 +227,21 @@ export const register: Register = on => {
|
|
|
224
227
|
const isOver = board.status === 'won' || board.status === 'lost'
|
|
225
228
|
|
|
226
229
|
/**
|
|
227
|
-
* Draws
|
|
230
|
+
* Draws what a cell shows: a number, a flag, a mine, or a button that reveals or flags it.
|
|
228
231
|
*
|
|
229
232
|
* @param cell - Cell number to draw.
|
|
230
|
-
* @returns The cell
|
|
233
|
+
* @returns The cell's content.
|
|
231
234
|
*/
|
|
232
|
-
const
|
|
235
|
+
const cellFace = (cell: number) => {
|
|
233
236
|
const isRevealed = board.revealed.includes(cell)
|
|
234
237
|
const isMine = board.mines.includes(cell)
|
|
235
238
|
if (isRevealed || (isOver && isMine)) {
|
|
236
|
-
if (isMine) return <Text color="#e06c6c"
|
|
239
|
+
if (isMine) return <Text color="#e06c6c">✹</Text>
|
|
237
240
|
const n = adjacentMines(board, cell)
|
|
238
|
-
return <Text color={NUMBER_COLORS[n]}>{n === 0 ? '
|
|
241
|
+
return <Text color={NUMBER_COLORS[n]}>{n === 0 ? ' ' : String(n)}</Text>
|
|
239
242
|
}
|
|
240
243
|
const label = board.flagged.includes(cell) ? '⚑' : '■'
|
|
241
|
-
if (isOver) return <Text dimColor>{label}
|
|
244
|
+
if (isOver) return <Text dimColor>{label}</Text>
|
|
242
245
|
return (
|
|
243
246
|
<Button key={`cell:${cell}`} plain onPress={() => pressCell($, cell)}>
|
|
244
247
|
<Text color={label === '⚑' ? '#e0775a' : '#6b7280'}>{label}</Text>
|
|
@@ -246,6 +249,11 @@ export const register: Register = on => {
|
|
|
246
249
|
)
|
|
247
250
|
}
|
|
248
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
|
+
|
|
249
257
|
const rows = Array.from({ length: board.size }, (_, r) => (
|
|
250
258
|
<Box flexDirection="row" columnGap={1}>
|
|
251
259
|
{Array.from({ length: board.size }, (_, c) => cellView(r * board.size + c))}
|
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
|
-
}
|