@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.
- package/.claude-plugin/marketplace.json +68 -0
- package/LICENSE +95 -0
- package/mods/agents-panel/.claude-plugin/plugin.json +9 -0
- package/mods/agents-panel/hooks/hooks.json +1 -0
- package/mods/agents-panel/hooks/register.tsx +224 -0
- package/mods/agents-panel/types/index.d.ts +24 -0
- package/mods/context-bar/.claude-plugin/plugin.json +9 -0
- package/mods/context-bar/hooks/hooks.json +1 -0
- package/mods/context-bar/hooks/register.tsx +187 -0
- package/mods/context-bar/types/index.d.ts +25 -0
- package/mods/custom-crab-agents/.claude-plugin/plugin.json +9 -0
- package/mods/custom-crab-agents/hooks/hooks.json +1 -0
- package/mods/custom-crab-agents/hooks/register.tsx +369 -0
- package/mods/custom-crab-agents/types/index.d.ts +45 -0
- package/mods/delete-guard/.claude-plugin/plugin.json +9 -0
- package/mods/delete-guard/hooks/hooks.json +1 -0
- package/mods/delete-guard/hooks/register.ts +117 -0
- package/mods/delete-guard/types/index.d.ts +14 -0
- package/mods/focus-mode/.claude-plugin/plugin.json +17 -0
- package/mods/focus-mode/hooks/hooks.json +1 -0
- package/mods/focus-mode/hooks/register.tsx +147 -0
- package/mods/focus-mode/types/index.d.ts +14 -0
- package/mods/handoff/.claude-plugin/plugin.json +9 -0
- package/mods/handoff/hooks/hooks.json +1 -0
- package/mods/handoff/hooks/register.tsx +119 -0
- package/mods/handoff/types/index.d.ts +15 -0
- package/mods/hide-personal-data/.claude-plugin/plugin.json +17 -0
- package/mods/hide-personal-data/hooks/hooks.json +1 -0
- package/mods/hide-personal-data/hooks/register.tsx +163 -0
- package/mods/hide-personal-data/types/index.d.ts +17 -0
- package/mods/mini-game/.claude-plugin/plugin.json +9 -0
- package/mods/mini-game/hooks/hooks.json +1 -0
- package/mods/mini-game/hooks/register.tsx +211 -0
- package/mods/mini-game/types/index.d.ts +21 -0
- package/mods/tool-timeline/.claude-plugin/plugin.json +9 -0
- package/mods/tool-timeline/hooks/hooks.json +1 -0
- package/mods/tool-timeline/hooks/register.tsx +259 -0
- package/mods/tool-timeline/types/index.d.ts +25 -0
- package/mods/tsconfig.json +17 -0
- package/mods/you-should-know/.claude-plugin/plugin.json +23 -0
- package/mods/you-should-know/hooks/hooks.json +1 -0
- package/mods/you-should-know/hooks/register.tsx +98 -0
- package/mods/you-should-know/types/index.d.ts +15 -0
- package/package.json +25 -4
- 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 @@
|
|
|
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 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|