@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
|
@@ -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
|
},
|