@g3nomaly/g3n-mods 0.1.3 → 0.1.5
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 +5 -5
- 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 +261 -52
- package/mods/custom-crab-agents/types/index.d.ts +12 -1
- 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 +71 -7
- package/mods/focus-mode/types/index.d.ts +1 -1
- package/mods/mini-game/hooks/register.tsx +14 -6
- package/mods/tool-timeline/hooks/register.tsx +6 -3
- package/package.json +2 -2
- package/mods/delete-guard/hooks/register.ts +0 -151
|
@@ -8,8 +8,17 @@ const PANE = 'focus-mode'
|
|
|
8
8
|
const TITLE = 'Focus'
|
|
9
9
|
const block = atom({ plugin: 'focus-mode', key: 'block' } as const, null)
|
|
10
10
|
const finished = atom({ plugin: 'focus-mode', key: 'finished' } as const, 0)
|
|
11
|
+
const length = atom({ plugin: 'focus-mode', key: 'length' } as const, 0)
|
|
11
12
|
|
|
12
13
|
const DEFAULT_MINUTES = 25
|
|
14
|
+
|
|
15
|
+
/** Minutes the pane's − and + buttons move the block length by, and its bounds. */
|
|
16
|
+
const STEP_MINUTES = 5
|
|
17
|
+
const MIN_MINUTES = 5
|
|
18
|
+
const MAX_MINUTES = 240
|
|
19
|
+
|
|
20
|
+
/** Key in the plugin's store that remembers the chosen block length between sessions. */
|
|
21
|
+
const LENGTH_KEY = 'minutes'
|
|
13
22
|
const MAX_SQUARES = 30
|
|
14
23
|
const FILLED = '#e6b450'
|
|
15
24
|
const EMPTY = '#3a3f4b'
|
|
@@ -59,6 +68,18 @@ function squaresSvg(count: number, filled: number): string {
|
|
|
59
68
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${rects.join('')}</svg>`
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Sets the block length the pane's start button uses, for this session and later ones.
|
|
73
|
+
*
|
|
74
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
75
|
+
* @param minutes - New length in minutes, kept within `MIN_MINUTES` and `MAX_MINUTES`.
|
|
76
|
+
*/
|
|
77
|
+
async function setLength($: EngineInterface, minutes: number): Promise<void> {
|
|
78
|
+
const kept = Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, Math.round(minutes)))
|
|
79
|
+
await update($, length, () => kept)
|
|
80
|
+
await $.store.set(LENGTH_KEY, kept)
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
/**
|
|
63
84
|
* Starts a block of `minutes`, replacing one already running, and shows the pane.
|
|
64
85
|
*
|
|
@@ -107,7 +128,20 @@ async function tick($: EngineInterface): Promise<void> {
|
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
/**
|
|
110
|
-
*
|
|
131
|
+
* Finds the running block, if any: while one runs the person is away from the keyboard.
|
|
132
|
+
*
|
|
133
|
+
* @param $ - Engine handle the hook received, see `EngineInterface`.
|
|
134
|
+
* @returns The block, or `null` when none runs or its time is up.
|
|
135
|
+
*/
|
|
136
|
+
async function awayBlock($: EngineInterface): Promise<FocusBlock | null> {
|
|
137
|
+
const current = await read($, block)
|
|
138
|
+
if (current === null) return null
|
|
139
|
+
return (await $.clock.now()) < current.endsAt ? current : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Registers this mod's `focus-mode` command, its session hook, its tick, its pane, and the hooks that keep Claude
|
|
144
|
+
* from asking anything during a block: questions are refused, and so are permission prompts.
|
|
111
145
|
*
|
|
112
146
|
* @param on - Hook registration function the engine passes in.
|
|
113
147
|
* @param options - Plugin options; `minutes` sets the default block length.
|
|
@@ -117,9 +151,11 @@ export const register: Register = (on, options) => {
|
|
|
117
151
|
const defaultMinutes = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MINUTES
|
|
118
152
|
|
|
119
153
|
on('session.start', async ($, e, next) => {
|
|
154
|
+
const remembered = Number(await $.store.get(LENGTH_KEY).catch(() => undefined))
|
|
155
|
+
await update($, length, () => (Number.isFinite(remembered) && remembered > 0 ? remembered : defaultMinutes))
|
|
120
156
|
await $.command.register({
|
|
121
157
|
name: 'focus-mode',
|
|
122
|
-
description:
|
|
158
|
+
description: 'Start a focus block in which Claude asks you nothing (the length you set, or the minutes given), or end the running one',
|
|
123
159
|
argumentHint: '[minutes] | end',
|
|
124
160
|
})
|
|
125
161
|
void $.ui.open({ id: PANE, title: TITLE })
|
|
@@ -133,24 +169,52 @@ export const register: Register = (on, options) => {
|
|
|
133
169
|
await endFocus($, false)
|
|
134
170
|
return { text: 'Focus block ended.' }
|
|
135
171
|
}
|
|
136
|
-
const
|
|
172
|
+
const chosen = (await read($, length)) || defaultMinutes
|
|
173
|
+
const minutes = arg === '' ? chosen : Number(arg)
|
|
137
174
|
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 600) {
|
|
138
175
|
return { text: `"${arg}" is not a number of minutes. Use /focus-mode 25, or /focus-mode end.` }
|
|
139
176
|
}
|
|
177
|
+
// A length typed with the command becomes the one the pane starts with next time.
|
|
178
|
+
if (arg !== '') await setLength($, minutes)
|
|
140
179
|
await startFocus($, minutes)
|
|
141
180
|
return { text: `Focus block started: ${minutes} min.` }
|
|
142
181
|
})
|
|
143
182
|
|
|
183
|
+
// While a block runs the person is away: a question Claude puts to them is refused, so Claude decides on its own.
|
|
184
|
+
on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
|
|
185
|
+
const away = await awayBlock($)
|
|
186
|
+
if (away === null) return next(e)
|
|
187
|
+
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.` }
|
|
188
|
+
}).catch(($, e, next) => next(e))
|
|
189
|
+
|
|
190
|
+
// A permission prompt would wait for the person too: refuse it, so Claude works around the step or leaves it for later.
|
|
191
|
+
on('classic.PermissionRequest', async ($, e, next) => {
|
|
192
|
+
const away = await awayBlock($)
|
|
193
|
+
if (away === null) return next(e)
|
|
194
|
+
return {
|
|
195
|
+
decision: {
|
|
196
|
+
behavior: 'deny',
|
|
197
|
+
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.`,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
}).catch(($, e, next) => next(e))
|
|
201
|
+
|
|
144
202
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
145
203
|
const { Box, Text, Button } = $.ui.resolve(e)
|
|
146
204
|
const current = await read($, block)
|
|
147
205
|
|
|
148
206
|
if (current === null) {
|
|
149
207
|
const done = await read($, finished)
|
|
208
|
+
const minutes = (await read($, length)) || defaultMinutes
|
|
150
209
|
return (
|
|
151
|
-
<Box flexDirection="row" justifyContent="space-between" alignItems="center">
|
|
152
|
-
<Text dimColor>{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
|
|
153
|
-
<
|
|
210
|
+
<Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2}>
|
|
211
|
+
<Text dimColor wrap="truncate-end">{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
|
|
212
|
+
<Box flexDirection="row" alignItems="center" columnGap={1}>
|
|
213
|
+
<Button key="shorter" label="−" onPress={() => setLength($, minutes - STEP_MINUTES)} />
|
|
214
|
+
<Text bold>{minutes} min</Text>
|
|
215
|
+
<Button key="longer" label="+" onPress={() => setLength($, minutes + STEP_MINUTES)} />
|
|
216
|
+
<Button key="start" label="start" variant="primary" onPress={() => startFocus($, minutes)} />
|
|
217
|
+
</Box>
|
|
154
218
|
</Box>
|
|
155
219
|
)
|
|
156
220
|
}
|
|
@@ -176,7 +240,7 @@ export const register: Register = (on, options) => {
|
|
|
176
240
|
<Text bold>{clock(remaining)}</Text>
|
|
177
241
|
<Box flexDirection="column" flexGrow={1} gap={0}>
|
|
178
242
|
{squares}
|
|
179
|
-
<Text dimColor>
|
|
243
|
+
<Text dimColor>away until {wallTime(current.endsAt)} · Claude asks you nothing</Text>
|
|
180
244
|
</Box>
|
|
181
245
|
<Button key="end" label="end focus" onPress={() => endFocus($, false)} />
|
|
182
246
|
</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))}
|
|
@@ -37,6 +37,9 @@ const IDLE_MS = 200
|
|
|
37
37
|
/** Most characters of a call's input the hover card keeps. */
|
|
38
38
|
const DETAIL_MAX = 2000
|
|
39
39
|
|
|
40
|
+
/** Most characters of a call's label kept; the pane cuts it to one line anyway. */
|
|
41
|
+
const LABEL_MAX = 200
|
|
42
|
+
|
|
40
43
|
/** Input fields that say most about a call, tried in order. */
|
|
41
44
|
const LABEL_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt'] as const
|
|
42
45
|
|
|
@@ -62,10 +65,10 @@ export function labelOf(input: Record<string, unknown>): string {
|
|
|
62
65
|
const value = input[field]
|
|
63
66
|
if (typeof value !== 'string' || value.length === 0) continue
|
|
64
67
|
const text = field === 'file_path' || field === 'notebook_path' ? value.split('/').pop() ?? value : value
|
|
65
|
-
return text.replace(/\s+/g, ' ').trim()
|
|
68
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX)
|
|
66
69
|
}
|
|
67
70
|
const first = Object.values(input).find((value): value is string => typeof value === 'string')
|
|
68
|
-
return first?.replace(/\s+/g, ' ').trim() ?? ''
|
|
71
|
+
return first?.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) ?? ''
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/**
|
|
@@ -331,7 +334,7 @@ export const register: Register = on => {
|
|
|
331
334
|
// One line per call; the keyed row reveals a card with the call's whole input while the pointer is on it.
|
|
332
335
|
const rows = shown.map((call, index) => (
|
|
333
336
|
<Box key={call.id} flexDirection="row" columnGap={1}>
|
|
334
|
-
<Text bold color={CARD} backgroundColor={colorOf(call.tool)}> {badgeOf(call.tool)} </Text>
|
|
337
|
+
<Text bold color={CARD} backgroundColor={colorOf(call.tool)}>{e.surface === 'terminal' ? ` ${badgeOf(call.tool)} ` : badgeOf(call.tool)}</Text>
|
|
335
338
|
<Box width={labelCells}>
|
|
336
339
|
<Text wrap="truncate-end" strikethrough={call.status === 'denied'} color={call.status === 'denied' ? ERROR : undefined}>
|
|
337
340
|
{call.agentId === undefined ? '' : '↳ '}{call.tool} <Text dimColor>{call.label}</Text>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@g3nomaly/g3n-mods",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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
|
-
}
|