@g3nomaly/g3n-mods 0.0.0-stage → 0.1.0

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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +68 -0
  2. package/LICENSE +95 -0
  3. package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
  4. package/mods/agents-panel/hooks/hooks.json +1 -0
  5. package/mods/agents-panel/hooks/register.tsx +224 -0
  6. package/mods/agents-panel/types/index.d.ts +24 -0
  7. package/mods/context-bar/.claude-plugin/plugin.json +9 -0
  8. package/mods/context-bar/hooks/hooks.json +1 -0
  9. package/mods/context-bar/hooks/register.tsx +187 -0
  10. package/mods/context-bar/types/index.d.ts +25 -0
  11. package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
  12. package/mods/custom-crab-agents/hooks/hooks.json +1 -0
  13. package/mods/custom-crab-agents/hooks/register.tsx +369 -0
  14. package/mods/custom-crab-agents/types/index.d.ts +45 -0
  15. package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
  16. package/mods/delete-guard/hooks/hooks.json +1 -0
  17. package/mods/delete-guard/hooks/register.ts +117 -0
  18. package/mods/delete-guard/types/index.d.ts +14 -0
  19. package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
  20. package/mods/focus-mode/hooks/hooks.json +1 -0
  21. package/mods/focus-mode/hooks/register.tsx +147 -0
  22. package/mods/focus-mode/types/index.d.ts +14 -0
  23. package/mods/handoff/.claude-plugin/plugin.json +9 -0
  24. package/mods/handoff/hooks/hooks.json +1 -0
  25. package/mods/handoff/hooks/register.tsx +119 -0
  26. package/mods/handoff/types/index.d.ts +15 -0
  27. package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
  28. package/mods/hide-personal-data/hooks/hooks.json +1 -0
  29. package/mods/hide-personal-data/hooks/register.tsx +163 -0
  30. package/mods/hide-personal-data/types/index.d.ts +17 -0
  31. package/mods/mini-game/.claude-plugin/plugin.json +9 -0
  32. package/mods/mini-game/hooks/hooks.json +1 -0
  33. package/mods/mini-game/hooks/register.tsx +211 -0
  34. package/mods/mini-game/types/index.d.ts +21 -0
  35. package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
  36. package/mods/tool-timeline/hooks/hooks.json +1 -0
  37. package/mods/tool-timeline/hooks/register.tsx +259 -0
  38. package/mods/tool-timeline/types/index.d.ts +25 -0
  39. package/mods/tsconfig.json +17 -0
  40. package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
  41. package/mods/you-should-know/hooks/hooks.json +1 -0
  42. package/mods/you-should-know/hooks/register.tsx +98 -0
  43. package/mods/you-should-know/types/index.d.ts +15 -0
  44. package/package.json +25 -4
  45. package/README.md +0 -3
@@ -0,0 +1,147 @@
1
+ // packages/g3n-mods/mods/focus-mode/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { FocusBlock } from '../types'
6
+
7
+ const PANE = 'focus-mode'
8
+ const TITLE = 'Focus'
9
+ const block = atom({ plugin: 'focus-mode', key: 'block' } as const, null)
10
+ const finished = atom({ plugin: 'focus-mode', key: 'finished' } as const, 0)
11
+
12
+ const DEFAULT_MINUTES = 25
13
+ const MAX_SQUARES = 30
14
+ const FILLED = '#e6b450'
15
+ const EMPTY = '#3a3f4b'
16
+
17
+ /** Remaining time as `mm:ss`, or `h:mm:ss` from an hour up. */
18
+ export function clock(ms: number): string {
19
+ const total = Math.max(0, Math.ceil(ms / 1000))
20
+ const h = Math.floor(total / 3600)
21
+ const m = Math.floor((total % 3600) / 60)
22
+ const s = total % 60
23
+ const mm = String(m).padStart(2, '0')
24
+ const ss = String(s).padStart(2, '0')
25
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
26
+ }
27
+
28
+ /** A wall-clock time as `3:10 pm`. */
29
+ function wallTime(epochMs: number): string {
30
+ const date = new Date(epochMs)
31
+ const hours = date.getHours()
32
+ const minutes = String(date.getMinutes()).padStart(2, '0')
33
+ return `${hours % 12 === 0 ? 12 : hours % 12}:${minutes} ${hours < 12 ? 'am' : 'pm'}`
34
+ }
35
+
36
+ function squaresSvg(count: number, filled: number): string {
37
+ const size = 10
38
+ const gap = 3
39
+ const width = count * (size + gap) - gap
40
+ const rects = Array.from({ length: count }, (_, i) =>
41
+ `<rect x="${i * (size + gap)}" y="0" width="${size}" height="${size}" rx="2" fill="${i < filled ? FILLED : EMPTY}"/>`)
42
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${rects.join('')}</svg>`
43
+ }
44
+
45
+ /** Starts a block of `minutes`, replacing one already running, and shows the pane. */
46
+ async function startFocus($: EngineInterface, minutes: number): Promise<void> {
47
+ const startedAt = await $.clock.now()
48
+ await update($, block, () => ({ startedAt, endsAt: startedAt + minutes * 60_000, minutes }))
49
+ await $.ui.open({ id: PANE, title: TITLE })
50
+ }
51
+
52
+ /** Ends the running block early, or on time when `isDone`. */
53
+ async function endFocus($: EngineInterface, isDone: boolean): Promise<void> {
54
+ const current = await read($, block)
55
+ if (current === null) return
56
+ await update($, block, () => null)
57
+ $.ui.status(undefined)
58
+ if (isDone) {
59
+ await update($, finished, n => n + 1)
60
+ $.ui.toast(`Focus block done: ${current.minutes} min`)
61
+ await $.ui.notify(`${current.minutes} minutes of focus done. Take a break.`, { title: 'Focus block done' }).catch(() => undefined)
62
+ }
63
+ }
64
+
65
+ /** Once a second: ends a block whose time is up, else moves the countdown on. */
66
+ async function tick($: EngineInterface): Promise<void> {
67
+ const current = await read($, block)
68
+ if (current === null) return
69
+ const now = await $.clock.now()
70
+ if (now >= current.endsAt) {
71
+ await endFocus($, true)
72
+ return
73
+ }
74
+ $.ui.status(`focus ${clock(current.endsAt - now)}`)
75
+ $.ui.invalidate('ui.render')
76
+ }
77
+
78
+ export const register: Register = (on, options) => {
79
+ const configured = Number(options.minutes ?? DEFAULT_MINUTES)
80
+ const defaultMinutes = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MINUTES
81
+
82
+ on('session.start', async ($, e, next) => {
83
+ await $.command.register({
84
+ name: 'focus-mode',
85
+ description: `Start a focus block (${defaultMinutes} min by default), or end the running one`,
86
+ argumentHint: '[minutes] | end',
87
+ })
88
+ $.clock.every(1000, () => void tick($))
89
+ return next(e)
90
+ })
91
+
92
+ on('command.run', { command: 'focus-mode' }, async ($, e) => {
93
+ const arg = e.args.trim()
94
+ if (arg === 'end' || arg === 'stop') {
95
+ await endFocus($, false)
96
+ return { text: 'Focus block ended.' }
97
+ }
98
+ const minutes = arg === '' ? defaultMinutes : Number(arg)
99
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 600) {
100
+ return { text: `"${arg}" is not a number of minutes. Use /focus-mode 25, or /focus-mode end.` }
101
+ }
102
+ await startFocus($, minutes)
103
+ return { text: `Focus block started: ${minutes} min.` }
104
+ })
105
+
106
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
107
+ const { Box, Text, Button } = $.ui.resolve(e)
108
+ const current = await read($, block)
109
+
110
+ if (current === null) {
111
+ const done = await read($, finished)
112
+ return (
113
+ <Box flexDirection="row" justifyContent="space-between" alignItems="center">
114
+ <Text dimColor>{done > 0 ? `${done} focus block${done === 1 ? '' : 's'} done this session.` : 'No focus block running.'}</Text>
115
+ <Button key="start" label={`start ${defaultMinutes} min`} variant="primary" onPress={() => startFocus($, defaultMinutes)} />
116
+ </Box>
117
+ )
118
+ }
119
+
120
+ const now = await $.clock.now()
121
+ const remaining = current.endsAt - now
122
+ const count = Math.min(MAX_SQUARES, Math.max(1, Math.round(current.minutes)))
123
+ const filled = Math.ceil((remaining / (current.endsAt - current.startedAt)) * count)
124
+ const squares = e.surface === 'terminal'
125
+ ? (
126
+ <Text>
127
+ <Text color={FILLED}>{'■'.repeat(filled)}</Text>
128
+ <Text color={EMPTY}>{'■'.repeat(count - filled)}</Text>
129
+ </Text>
130
+ )
131
+ : (() => {
132
+ const { Svg } = $.ui.resolve(e)
133
+ return <Svg source={squaresSvg(count, filled)} alt={`${clock(remaining)} left`} width={count * 13 - 3} height={10} />
134
+ })()
135
+
136
+ return (
137
+ <Box flexDirection="row" alignItems="center" columnGap={3}>
138
+ <Text bold>{clock(remaining)}</Text>
139
+ <Box flexDirection="column" flexGrow={1} gap={0}>
140
+ {squares}
141
+ <Text dimColor>focus block · {current.minutes} min · ends {wallTime(current.endsAt)}</Text>
142
+ </Box>
143
+ <Button key="end" label="end focus" onPress={() => endFocus($, false)} />
144
+ </Box>
145
+ )
146
+ })
147
+ }
@@ -0,0 +1,14 @@
1
+ // packages/g3n-mods/mods/focus-mode/types/index.d.ts
2
+
3
+ /** A running focus block; times are milliseconds since the epoch. */
4
+ export type FocusBlock = {
5
+ startedAt: number
6
+ endsAt: number
7
+ minutes: number
8
+ }
9
+
10
+ declare module 'claude-code' {
11
+ interface PluginState {
12
+ 'focus-mode': { block: FocusBlock | null; finished: number }
13
+ }
14
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "handoff",
3
+ "version": "0.1.0",
4
+ "description": "Writes a handoff with the handoff skill, clears the session, and starts the fresh one with your next prompt.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,119 @@
1
+ // packages/g3n-mods/mods/handoff/hooks/register.tsx
2
+ import { atom, update } from 'claude-code'
3
+ import type { EngineInterface, Register } from 'claude-code'
4
+
5
+ import type { PendingHandoff } from '../types'
6
+
7
+ const pending = atom({ plugin: 'handoff', key: 'pending' } as const, null)
8
+
9
+ const STORE_KEY = 'pending'
10
+ const SKILL_PATH = '.claude/skills/handoff/SKILL.md'
11
+ /** A handoff older than this is dropped instead of attached. */
12
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000
13
+
14
+ /** The handoff skill's instructions, used when no `handoff` skill is installed. */
15
+ const FALLBACK_SKILL = [
16
+ 'Write a handoff document summarising the current conversation so a fresh agent can continue the work.',
17
+ 'Include a "suggested skills" section, which suggests skills that the agent should invoke.',
18
+ 'Do not duplicate content already captured in other artifacts (specs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.',
19
+ 'Redact any sensitive information, such as API keys, passwords, or personally identifiable information.',
20
+ 'If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.',
21
+ ].join('\n\n')
22
+
23
+ function withoutFrontmatter(text: string): string {
24
+ return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
25
+ }
26
+
27
+ function isPendingHandoff(value: unknown): value is PendingHandoff {
28
+ return typeof value === 'object' && value !== null
29
+ && 'text' in value && typeof value.text === 'string'
30
+ && 'path' in value && typeof value.path === 'string'
31
+ && 'createdAt' in value && typeof value.createdAt === 'number'
32
+ }
33
+
34
+ /** The `handoff` skill's body from the project, else the user's skills, else the built-in copy. */
35
+ async function loadSkill($: EngineInterface): Promise<string> {
36
+ const root = await $.session.root()
37
+ const home = await $.env.get('HOME')
38
+ for (const path of [`${root}/${SKILL_PATH}`, home === undefined ? '' : `${home}/${SKILL_PATH}`]) {
39
+ if (path !== '' && (await $.fs.exists(path))) return withoutFrontmatter(await $.fs.read(path))
40
+ }
41
+ return FALLBACK_SKILL
42
+ }
43
+
44
+ /** Absolute path in the OS temporary folder for a handoff written at `now`. */
45
+ async function documentPath($: EngineInterface, now: number): Promise<string> {
46
+ const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
47
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-').slice(0, 19)
48
+ return `${tmp}/claude-handoff-${stamp}.md`
49
+ }
50
+
51
+ /** Runs /clear once the session is idle, after the command that asked for it has answered. */
52
+ async function clearSession($: EngineInterface): Promise<void> {
53
+ await $.command.run({ command: 'clear' }).catch(() => $.ui.toast('Run /clear to start the fresh session; the handoff waits for your next prompt.'))
54
+ }
55
+
56
+ /** The handoff waiting for the next prompt, dropping one that has gone stale. */
57
+ async function takePending($: EngineInterface): Promise<PendingHandoff | undefined> {
58
+ const stored = await $.store.get(STORE_KEY)
59
+ if (!isPendingHandoff(stored)) return undefined
60
+ await $.store.delete(STORE_KEY)
61
+ await update($, pending, () => null)
62
+ $.ui.status(undefined)
63
+ const now = await $.clock.now()
64
+ return now - stored.createdAt > MAX_AGE_MS ? undefined : stored
65
+ }
66
+
67
+ export const register: Register = on => {
68
+ on('session.start', async ($, e, next) => {
69
+ await $.command.register({
70
+ name: 'handoff-new',
71
+ description: 'Write a handoff, clear the session, and start the fresh one with your next prompt',
72
+ argumentHint: 'what the next session is for',
73
+ })
74
+ const stored = await $.store.get(STORE_KEY)
75
+ if (isPendingHandoff(stored)) {
76
+ await update($, pending, () => stored)
77
+ $.ui.status('handoff ready: your next prompt starts with it')
78
+ }
79
+ return next(e)
80
+ })
81
+
82
+ on('command.run', { command: 'handoff-new' }, async ($, e) => {
83
+ const skill = await loadSkill($)
84
+ const focus = e.args.trim()
85
+ const prompt = [
86
+ 'Write the handoff document these instructions describe, for the conversation so far.',
87
+ 'You cannot use tools. Answer with the document as Markdown and nothing else; it is saved for you.',
88
+ focus.length > 0 ? `The next session will be used for: ${focus}` : '',
89
+ '<instructions>',
90
+ skill,
91
+ '</instructions>',
92
+ ].filter(line => line.length > 0).join('\n\n')
93
+
94
+ $.ui.toast('Writing the handoff…')
95
+ const written = await $.model.fork({ prompt })
96
+ if (!written.isAnswered) {
97
+ return { text: `The handoff was not written (${written.reason}). The conversation is unchanged.`, exitCode: 1 }
98
+ }
99
+
100
+ const now = await $.clock.now()
101
+ const path = await documentPath($, now)
102
+ await $.fs.write(path, written.text)
103
+ const handoff: PendingHandoff = { text: written.text, path, createdAt: now }
104
+ await $.store.set(STORE_KEY, handoff)
105
+ await update($, pending, () => handoff)
106
+ $.ui.status('handoff ready: your next prompt starts with it')
107
+ $.clock.after(0, () => void clearSession($))
108
+ return { text: `Handoff saved to ${path}. The conversation clears now; your next prompt starts the fresh session with it.` }
109
+ })
110
+
111
+ on('prompt.submit', async ($, e, next) => {
112
+ if (e.text.trimStart().startsWith('/')) return next(e)
113
+ const handoff = await takePending($)
114
+ if (handoff === undefined) return next(e)
115
+ $.ui.toast('Handoff attached to this prompt')
116
+ const context = `Handoff from the previous session, saved at ${handoff.path}:\n\n${handoff.text}`
117
+ return next({ ...e, context: [...(e.context ?? []), context] })
118
+ }).catch(($, e, next) => next(e))
119
+ }
@@ -0,0 +1,15 @@
1
+ // packages/g3n-mods/mods/handoff/types/index.d.ts
2
+
3
+ /** A handoff written and waiting for the next prompt; `createdAt` is milliseconds since the epoch. */
4
+ export type PendingHandoff = {
5
+ text: string
6
+ /** Absolute path the document was saved to */
7
+ path: string
8
+ createdAt: number
9
+ }
10
+
11
+ declare module 'claude-code' {
12
+ interface PluginState {
13
+ handoff: { pending: PendingHandoff | null }
14
+ }
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "hide-personal-data",
3
+ "version": "0.1.0",
4
+ "description": "Masks names, emails and other personal data in replies; hover to show them.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts",
9
+ "userConfig": {
10
+ "names": {
11
+ "type": "string",
12
+ "title": "Names",
13
+ "default": "",
14
+ "description": "Extra names to mask, separated by commas"
15
+ }
16
+ }
17
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,163 @@
1
+ // packages/g3n-mods/mods/hide-personal-data/hooks/register.tsx
2
+ import { atom, read, update } from 'claude-code'
3
+ import type { EngineInterface, Register, RenderChildren } from 'claude-code'
4
+
5
+ import type { PersonalKind, PersonalSpan } from '../types'
6
+
7
+ const isOff = atom({ plugin: 'hide-personal-data', key: 'isOff' } as const, false)
8
+ const knownNames = atom({ plugin: 'hide-personal-data', key: 'names' } as const, [])
9
+
10
+ const MASK = '#3b4252'
11
+ const REVEAL = '#4c566a'
12
+ const ACCENT = '#e0775a'
13
+
14
+ /** Patterns per kind, tried in this order; an earlier kind wins an overlap. */
15
+ const PATTERNS: ReadonlyArray<[PersonalKind, RegExp]> = [
16
+ ['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g],
17
+ ['iban', /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g],
18
+ ['card', /\b(?:\d{4}[ -]?){3}\d{1,4}\b/g],
19
+ ['phone', /(?<![\w.])\+?\d[\d ().-]{7,}\d(?![\w.])/g],
20
+ ]
21
+
22
+ /** Common first names; one followed by a capitalised word reads as a full name. */
23
+ const FIRST_NAMES = new Set(`
24
+ aaron adam ahmed aisha alex alexander alice amanda amir amy ana andrea andrew angela anna anne anthony arjun ben benjamin
25
+ bob brian carlos carmen carol catherine charles chen chloe chris christina christopher daniel david deepak diana diego
26
+ dmitri elena elizabeth emily emma eric erik eva fatima felix fernando francesca frank gabriel george grace hannah hans
27
+ harry helen henry hiroshi ian isabel isabella jack jacob james jan jane jason javier jennifer jessica john jonathan jose
28
+ joseph juan julia julian karen kate katherine kevin kim laura lars lauren laura leo li lin linda lisa lucas lucia luis
29
+ maria mark marta martin mary matteo matthew max mei michael michelle miguel mohammed nadia natalia nicholas nina noah
30
+ oliver olivia omar pablo paul peter pieter priya rachel rahul raj rebecca richard robert ryan sam samuel sara sarah
31
+ sebastian sophia sophie stefan steven susan thomas tim tom tomas valentina victor wei william yuki yusuf zoe
32
+ `.split(/\s+/).filter(name => name.length > 0))
33
+
34
+ const FULL_NAME = /\b([A-Z][a-z]+) ((?:van |de |von |da )?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?)\b/g
35
+ const TITLED_NAME = /\b(?:Mr|Mrs|Ms|Dr|Prof)\.? [A-Z][a-z]+(?:[-'][A-Z][a-z]+)?\b/g
36
+
37
+ function digitCount(text: string): number {
38
+ return text.replace(/\D/g, '').length
39
+ }
40
+
41
+ function escapeRegExp(text: string): string {
42
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
43
+ }
44
+
45
+ /**
46
+ * Finds personal data in a text: emails, phone numbers, IBANs, card numbers
47
+ * and names, the latter from `names` and from common first names followed by
48
+ * a surname. Overlapping matches keep the earlier, longer one.
49
+ *
50
+ * @param names - Exact names to mask wherever they occur, e.g. the git user's
51
+ * @returns the matches in text order
52
+ */
53
+ export function findPersonalData(text: string, names: readonly string[]): PersonalSpan[] {
54
+ const spans: PersonalSpan[] = []
55
+ for (const [kind, pattern] of PATTERNS) {
56
+ for (const match of text.matchAll(pattern)) {
57
+ if (kind === 'phone' && digitCount(match[0]) < 9) continue
58
+ spans.push({ kind, start: match.index, end: match.index + match[0].length })
59
+ }
60
+ }
61
+ for (const name of names) {
62
+ if (name.trim().length < 3) continue
63
+ for (const match of text.matchAll(new RegExp(`\\b${escapeRegExp(name.trim())}\\b`, 'g'))) {
64
+ spans.push({ kind: 'name', start: match.index, end: match.index + match[0].length })
65
+ }
66
+ }
67
+ for (const match of text.matchAll(FULL_NAME)) {
68
+ if (FIRST_NAMES.has((match[1] ?? '').toLowerCase())) spans.push({ kind: 'name', start: match.index, end: match.index + match[0].length })
69
+ }
70
+ for (const match of text.matchAll(TITLED_NAME)) {
71
+ spans.push({ kind: 'name', start: match.index, end: match.index + match[0].length })
72
+ }
73
+
74
+ const sorted = spans.sort((a, b) => a.start - b.start || b.end - a.end)
75
+ const kept: PersonalSpan[] = []
76
+ for (const span of sorted) {
77
+ const last = kept[kept.length - 1]
78
+ if (last === undefined || span.start >= last.end) kept.push(span)
79
+ }
80
+ return kept
81
+ }
82
+
83
+ /** Inline Markdown a line drawn as plain text would show raw: emphasis marks and heading hashes. */
84
+ function plain(line: string): string {
85
+ return line.replace(/^#{1,6}\s+/, '').replace(/(\*\*|__|`)/g, '')
86
+ }
87
+
88
+ /** Reads the git user's name and email, the names most likely to show up in replies. */
89
+ async function gitIdentity($: EngineInterface): Promise<string[]> {
90
+ const found: string[] = []
91
+ for (const key of ['user.name', 'user.email']) {
92
+ const run = await $.process.run(['git', 'config', '--get', key]).catch(() => undefined)
93
+ const value = run?.exitCode === 0 ? run.stdout.trim() : ''
94
+ if (value.length > 0) found.push(value)
95
+ }
96
+ return found
97
+ }
98
+
99
+ export const register: Register = (on, options) => {
100
+ const configured = typeof options.names === 'string'
101
+ ? options.names.split(',').map(name => name.trim()).filter(name => name.length > 0)
102
+ : []
103
+
104
+ on('session.start', async ($, e, next) => {
105
+ await $.command.register({
106
+ name: 'hide-personal-data',
107
+ description: 'Turn masking of names, emails and other personal data on or off',
108
+ argumentHint: 'on | off',
109
+ })
110
+ const identity = await gitIdentity($)
111
+ await update($, knownNames, () => [...configured, ...identity])
112
+ return next(e)
113
+ })
114
+
115
+ on('command.run', { command: 'hide-personal-data' }, async ($, e) => {
116
+ const wanted = e.args.trim()
117
+ if (wanted === 'on' || wanted === 'off') await update($, isOff, () => wanted === 'off')
118
+ const off = await read($, isOff)
119
+ return { text: `Personal data is ${off ? 'shown as written' : 'masked; hover a mask to read it'}.` }
120
+ })
121
+
122
+ on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
123
+ if (await read($, isOff)) return next(e)
124
+ const text = e.props.text
125
+ const names = await read($, knownNames)
126
+ if (findPersonalData(text, names).length === 0) return next(e)
127
+
128
+ const { Box, Text, Markdown } = $.ui.resolve(e)
129
+ const blocks: RenderChildren[] = []
130
+ let prose: string[] = []
131
+ const flush = () => {
132
+ if (prose.length > 0) blocks.push(<Markdown text={prose.join('\n')} />)
133
+ prose = []
134
+ }
135
+
136
+ text.split('\n').forEach((line, lineIndex) => {
137
+ const spans = findPersonalData(line, names)
138
+ if (spans.length === 0) {
139
+ prose.push(line)
140
+ return
141
+ }
142
+ flush()
143
+ const parts: RenderChildren[] = []
144
+ let at = 0
145
+ spans.forEach((span, spanIndex) => {
146
+ if (span.start > at) parts.push(<Text>{plain(line.slice(at, span.start))}</Text>)
147
+ const scope = `pd-${e.requestId}-${lineIndex}-${spanIndex}`
148
+ parts.push(<Text color={ACCENT}>▎</Text>)
149
+ parts.push(
150
+ <Text color={MASK} backgroundColor={MASK} hover={{ scope, color: 'text', backgroundColor: REVEAL }}>
151
+ {line.slice(span.start, span.end)}
152
+ </Text>,
153
+ )
154
+ at = span.end
155
+ })
156
+ if (at < line.length) parts.push(<Text>{plain(line.slice(at))}</Text>)
157
+ blocks.push(<Box flexDirection="row" flexWrap="wrap">{parts}</Box>)
158
+ })
159
+ flush()
160
+
161
+ return <Box flexDirection="column">{blocks}</Box>
162
+ })
163
+ }
@@ -0,0 +1,17 @@
1
+ // packages/g3n-mods/mods/hide-personal-data/types/index.d.ts
2
+
3
+ /** What kind of personal data a match is. */
4
+ export type PersonalKind = 'email' | 'phone' | 'iban' | 'card' | 'name'
5
+
6
+ /** One match in a text: its kind and its character range, `end` exclusive. */
7
+ export type PersonalSpan = {
8
+ kind: PersonalKind
9
+ start: number
10
+ end: number
11
+ }
12
+
13
+ declare module 'claude-code' {
14
+ interface PluginState {
15
+ 'hide-personal-data': { isOff: boolean; names: string[] }
16
+ }
17
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "mini-game",
3
+ "version": "0.1.0",
4
+ "description": "Minesweeper to play while Claude works.",
5
+ "author": {
6
+ "name": "g3ntrovert"
7
+ },
8
+ "types": "./types/index.d.ts"
9
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }