vibeglish 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.md ADDED
@@ -0,0 +1,22 @@
1
+ # VibeGlish
2
+
3
+ 一个 Claude Code Hook 驱动的英语学习工具。
4
+
5
+ ## 架构
6
+ - `src/hooks/capture.sh` — UserPromptSubmit hook,写入 ~/.vibeglish/raw/
7
+ - `src/cli/index.mjs` — CLI 入口,子命令:init/review/report/serve/export
8
+ - `src/review/engine.mjs` — 通过 claude CLI 批量纠正引擎
9
+ - `src/dashboard/index.html` — 单文件 Web Dashboard
10
+ - `src/achievements.mjs` — 成就系统计算逻辑
11
+
12
+ ## 约束
13
+ - capture.sh 必须 < 50ms 完成,不做网络请求
14
+ - Dashboard 是单个 HTML 文件,不用框架,不用构建工具
15
+ - 所有数据存储在 ~/.vibeglish/,不用数据库
16
+ - CLI 用 Node.js ESM,最低支持 Node 18
17
+ - AI 纠正通过 `claude -p` CLI 调用,使用用户的订阅额度
18
+
19
+ ## 测试
20
+ - `npm test` 运行全部测试
21
+ - hook 测试用 `echo '{"prompt":"test"}' | bash src/hooks/capture.sh`
22
+ - API 测试用 mock,不实际调用
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # VibeGlish
2
+
3
+ > 把你每天跟 Claude Code 说的"蹩脚英语"变成最好的学习素材
4
+
5
+ VibeGlish 是一个 Claude Code Hook 驱动的**被动式英语学习系统**。它静默记录你发给 Claude Code 的每条 prompt,定期批量调用 AI 进行语法纠正与分析,通过本地 Web Dashboard 呈现你的英语成长轨迹。
6
+
7
+ ## 2 分钟上手
8
+
9
+ ```bash
10
+ # 1. 安装
11
+ npx vibeglish init
12
+
13
+ # 2. 重启 Claude Code(使 hook 生效)
14
+
15
+ # 3. 正常使用 Claude Code 写代码...
16
+ # 你的 prompt 会被静默采集
17
+
18
+ # 4. 触发 AI 纠正(通过 claude CLI,使用你的订阅额度)
19
+ vibeglish review
20
+
21
+ # 5. 查看结果
22
+ vibeglish serve # 打开 Web Dashboard
23
+ vibeglish report # 终端查看周报
24
+ ```
25
+
26
+ ## 特性
27
+
28
+ - **零打扰** — 采集阶段完全静默,不中断编码心流
29
+ - **后置学习** — 攒够素材后统一复习,非实时打断纠错
30
+ - **游戏化** — 成就系统 + 数据可视化让复习不枯燥
31
+ - **开发者友好** — 所有数据本地存储,CLI 优先,一切可 hack
32
+
33
+ ## 命令
34
+
35
+ | 命令 | 说明 |
36
+ |------|------|
37
+ | `vibeglish init` | 初始化 + 安装 Hook |
38
+ | `vibeglish status` | 显示采集/复习统计 |
39
+ | `vibeglish review` | 触发 AI 纠正 |
40
+ | `vibeglish report` | 终端打印周报 |
41
+ | `vibeglish serve` | 启动 Web Dashboard |
42
+ | `vibeglish export` | 导出数据 (JSON/CSV) |
43
+ | `vibeglish hook-test` | 测试 Hook 是否正常 |
44
+ | `vibeglish uninstall` | 移除 Hook(保留数据) |
45
+
46
+ ## Review 选项
47
+
48
+ ```bash
49
+ vibeglish review # 纠正所有未处理的日期
50
+ vibeglish review --date 2026-04-03 # 纠正指定日期
51
+ vibeglish review --from 2026-03-25 --to 2026-04-03 # 日期范围
52
+ vibeglish review --force # 强制重新纠正
53
+ ```
54
+
55
+ ## 前置依赖
56
+
57
+ - Node.js >= 18
58
+ - [jq](https://jqlang.github.io/jq/) (`brew install jq`)
59
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 已登录(review 使用你的订阅额度)
60
+
61
+ ## 数据安全
62
+
63
+ - 所有数据仅存储在本地 `~/.vibeglish/`
64
+ - AI 纠正通过 `claude` CLI 调用,使用你已有的 Claude 订阅
65
+ - 明显的 secret 模式 (API key 等) 会被自动脱敏
66
+
67
+ ## 配置
68
+
69
+ 配置文件位于 `~/.vibeglish/config.json`:
70
+
71
+ ```json
72
+ {
73
+ "min_word_count": 4,
74
+ "max_code_ratio": 0.7,
75
+ "batch_size": 20,
76
+ "dashboard_port": 6188
77
+ }
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "vibeglish",
3
+ "version": "0.1.0",
4
+ "description": "Learn English naturally while vibe coding with Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "vibeglish": "src/cli/index.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/**/*.test.mjs"
11
+ },
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "dependencies": {},
16
+ "keywords": [
17
+ "claude-code",
18
+ "english-learning",
19
+ "hook",
20
+ "developer-tools"
21
+ ],
22
+ "license": "MIT"
23
+ }
@@ -0,0 +1,240 @@
1
+ import { readJSON, writeJSON } from './utils.mjs';
2
+ import { ACHIEVEMENTS_PATH } from './constants.mjs';
3
+
4
+ const ACHIEVEMENTS = [
5
+ {
6
+ id: 'first_blood',
7
+ name: 'First Blood',
8
+ icon: '\u{1FA78}',
9
+ description: '完成第一次 review',
10
+ check(data) {
11
+ const reviewed = data.filter(d => d.stats.total_reviewed > 0);
12
+ return {
13
+ unlocked: reviewed.length > 0,
14
+ progress: Math.min(reviewed.length, 1),
15
+ target: 1,
16
+ };
17
+ },
18
+ },
19
+ {
20
+ id: 'century_club',
21
+ name: 'Century Club',
22
+ icon: '\u{1F4AF}',
23
+ description: '累计 review 100 条 prompt',
24
+ check(data) {
25
+ const total = data.reduce((sum, d) => sum + d.stats.total_reviewed, 0);
26
+ return { unlocked: total >= 100, progress: total, target: 100 };
27
+ },
28
+ },
29
+ {
30
+ id: 'thousand_words',
31
+ name: 'Thousand Words',
32
+ icon: '\u{1F4DA}',
33
+ description: '累计 review 1000 条 prompt',
34
+ check(data) {
35
+ const total = data.reduce((sum, d) => sum + d.stats.total_reviewed, 0);
36
+ return { unlocked: total >= 1000, progress: total, target: 1000 };
37
+ },
38
+ },
39
+ {
40
+ id: 'streak_7',
41
+ name: 'Streak Master x7',
42
+ icon: '\u{1F525}',
43
+ description: '连续 7 天有记录',
44
+ check(data) {
45
+ return { ...checkStreak(data, 7), target: 7 };
46
+ },
47
+ },
48
+ {
49
+ id: 'streak_30',
50
+ name: 'Streak Master x30',
51
+ icon: '\u{1F525}',
52
+ description: '连续 30 天有记录',
53
+ check(data) {
54
+ return { ...checkStreak(data, 30), target: 30 };
55
+ },
56
+ },
57
+ {
58
+ id: 'clean_coder',
59
+ name: 'Clean Coder',
60
+ icon: '\u2728',
61
+ description: '单日 clean prompt 占比 > 50%',
62
+ check(data) {
63
+ for (const day of data) {
64
+ if (day.entries.length === 0) continue;
65
+ const clean = day.entries.filter(e => e.is_clean).length;
66
+ if (clean / day.entries.length > 0.5) {
67
+ return { unlocked: true, progress: 1, target: 1 };
68
+ }
69
+ }
70
+ const best = data.reduce((max, day) => {
71
+ if (day.entries.length === 0) return max;
72
+ return Math.max(max, day.entries.filter(e => e.is_clean).length / day.entries.length);
73
+ }, 0);
74
+ return { unlocked: false, progress: Math.round(best * 100), target: 50 };
75
+ },
76
+ },
77
+ {
78
+ id: 'perfect_day',
79
+ name: 'Perfect Day',
80
+ icon: '\u{1F48E}',
81
+ description: '单日全部 prompt score >= 90',
82
+ check(data) {
83
+ for (const day of data) {
84
+ if (day.entries.length === 0) continue;
85
+ const allHigh = day.entries.every(e => (e.score || 0) >= 90);
86
+ if (allHigh) return { unlocked: true, progress: 1, target: 1 };
87
+ }
88
+ return { unlocked: false, progress: 0, target: 1 };
89
+ },
90
+ },
91
+ {
92
+ id: 'article_apprentice',
93
+ name: 'Article Apprentice',
94
+ icon: '\u{1F393}',
95
+ description: '连续 3 天无冠词错误',
96
+ check(data) {
97
+ const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date));
98
+ let streak = 0;
99
+ let maxStreak = 0;
100
+ for (const day of sorted) {
101
+ const hasArticleError = day.entries.some(e =>
102
+ (e.issues || []).some(i =>
103
+ i.type === 'grammar' && /article|冠词|a\/the/i.test(i.rule || '')
104
+ )
105
+ );
106
+ streak = hasArticleError ? 0 : streak + 1;
107
+ maxStreak = Math.max(maxStreak, streak);
108
+ }
109
+ return { unlocked: maxStreak >= 3, progress: maxStreak, target: 3 };
110
+ },
111
+ },
112
+ {
113
+ id: 'polyglot',
114
+ name: 'Polyglot',
115
+ icon: '\u{1F30D}',
116
+ description: '在 5 个不同 project 中留下记录',
117
+ check(data) {
118
+ const projects = new Set();
119
+ for (const day of data) {
120
+ for (const e of day.entries) {
121
+ if (e.project) projects.add(e.project);
122
+ }
123
+ }
124
+ return { unlocked: projects.size >= 5, progress: projects.size, target: 5 };
125
+ },
126
+ },
127
+ {
128
+ id: 'night_owl',
129
+ name: 'Night Owl',
130
+ icon: '\u{1F989}',
131
+ description: '凌晨 0-5 点仍在 coding 并留下 prompt',
132
+ check(data) {
133
+ for (const day of data) {
134
+ for (const e of day.entries) {
135
+ if (!e.ts) continue;
136
+ const hour = new Date(e.ts).getHours();
137
+ if (hour >= 0 && hour < 5) {
138
+ return { unlocked: true, progress: 1, target: 1 };
139
+ }
140
+ }
141
+ }
142
+ return { unlocked: false, progress: 0, target: 1 };
143
+ },
144
+ },
145
+ {
146
+ id: 'grammar_guru',
147
+ name: 'Grammar Guru',
148
+ icon: '\u{1F4D0}',
149
+ description: '连续 7 天 grammar 类错误 <= 2 次/天',
150
+ check(data) {
151
+ const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date));
152
+ let streak = 0;
153
+ let maxStreak = 0;
154
+ for (const day of sorted) {
155
+ const grammarCount = day.entries.reduce((sum, e) =>
156
+ sum + (e.issues || []).filter(i => i.type === 'grammar').length, 0);
157
+ streak = grammarCount <= 2 ? streak + 1 : 0;
158
+ maxStreak = Math.max(maxStreak, streak);
159
+ }
160
+ return { unlocked: maxStreak >= 7, progress: maxStreak, target: 7 };
161
+ },
162
+ },
163
+ {
164
+ id: 'speed_learner',
165
+ name: 'Speed Learner',
166
+ icon: '\u26A1',
167
+ description: '某错误类型周频次从 >10 降至 <3',
168
+ check(data) {
169
+ const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date));
170
+ if (sorted.length < 14) return { unlocked: false, progress: 0, target: 1 };
171
+
172
+ const types = ['grammar', 'vocabulary', 'spelling', 'punctuation', 'style', 'word_order'];
173
+ for (const type of types) {
174
+ // Compare first 7 days vs last 7 days
175
+ const firstWeek = sorted.slice(0, 7);
176
+ const lastWeek = sorted.slice(-7);
177
+ const countIssues = (days) => days.reduce((sum, d) =>
178
+ sum + d.entries.reduce((s, e) =>
179
+ s + (e.issues || []).filter(i => i.type === type).length, 0), 0);
180
+ const firstCount = countIssues(firstWeek);
181
+ const lastCount = countIssues(lastWeek);
182
+ if (firstCount > 10 && lastCount < 3) {
183
+ return { unlocked: true, progress: 1, target: 1 };
184
+ }
185
+ }
186
+ return { unlocked: false, progress: 0, target: 1 };
187
+ },
188
+ },
189
+ ];
190
+
191
+ function checkStreak(data, target) {
192
+ const dates = new Set(data.map(d => d.date));
193
+ const sorted = [...dates].sort();
194
+ if (sorted.length === 0) return { unlocked: false, progress: 0 };
195
+
196
+ let maxStreak = 1;
197
+ let streak = 1;
198
+ for (let i = 1; i < sorted.length; i++) {
199
+ const prev = new Date(sorted[i - 1]);
200
+ const curr = new Date(sorted[i]);
201
+ const diff = (curr - prev) / (1000 * 60 * 60 * 24);
202
+ streak = diff === 1 ? streak + 1 : 1;
203
+ maxStreak = Math.max(maxStreak, streak);
204
+ }
205
+
206
+ return { unlocked: maxStreak >= target, progress: maxStreak };
207
+ }
208
+
209
+ export function checkAchievements(reviewedData) {
210
+ const saved = readJSON(ACHIEVEMENTS_PATH) || {};
211
+
212
+ const results = ACHIEVEMENTS.map(ach => {
213
+ const { unlocked, progress, target } = ach.check(reviewedData);
214
+ const existing = saved[ach.id];
215
+
216
+ if (unlocked && !existing?.unlockedAt) {
217
+ saved[ach.id] = { unlockedAt: new Date().toISOString() };
218
+ }
219
+
220
+ return {
221
+ id: ach.id,
222
+ name: ach.name,
223
+ icon: ach.icon,
224
+ description: ach.description,
225
+ unlocked,
226
+ progress,
227
+ target,
228
+ unlockedAt: saved[ach.id]?.unlockedAt || null,
229
+ };
230
+ });
231
+
232
+ writeJSON(ACHIEVEMENTS_PATH, saved);
233
+ return results;
234
+ }
235
+
236
+ export function getAchievementDefinitions() {
237
+ return ACHIEVEMENTS.map(a => ({
238
+ id: a.id, name: a.name, icon: a.icon, description: a.description,
239
+ }));
240
+ }
@@ -0,0 +1,71 @@
1
+ import { join } from 'node:path';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { REVIEWED_DIR } from '../../constants.mjs';
4
+ import { readJSON, listFiles } from '../../utils.mjs';
5
+
6
+ export default async function exportCmd(args) {
7
+ const formatIdx = args.indexOf('--format');
8
+ const format = formatIdx !== -1 && args[formatIdx + 1] ? args[formatIdx + 1] : 'json';
9
+ const outputIdx = args.indexOf('--output');
10
+ const outputFile = outputIdx !== -1 ? args[outputIdx + 1] : null;
11
+
12
+ const files = listFiles(REVIEWED_DIR, '.json');
13
+ if (files.length === 0) {
14
+ console.log('No reviewed data to export. Run "vibeglish review" first.');
15
+ return;
16
+ }
17
+
18
+ const allData = [];
19
+ for (const f of files) {
20
+ const data = readJSON(join(REVIEWED_DIR, f));
21
+ if (data) allData.push(data);
22
+ }
23
+
24
+ let output;
25
+ if (format === 'csv') {
26
+ output = toCSV(allData);
27
+ } else {
28
+ output = JSON.stringify(allData, null, 2);
29
+ }
30
+
31
+ if (outputFile) {
32
+ writeFileSync(outputFile, output);
33
+ console.log(`Exported ${allData.length} day(s) to ${outputFile}`);
34
+ } else {
35
+ process.stdout.write(output + '\n');
36
+ }
37
+ }
38
+
39
+ function toCSV(allData) {
40
+ const headers = ['date', 'id', 'time', 'project', 'original', 'corrected', 'score', 'is_clean', 'issue_types', 'issues_detail'];
41
+ const rows = [headers.join(',')];
42
+
43
+ for (const day of allData) {
44
+ for (const entry of day.entries || []) {
45
+ const issueTypes = (entry.issues || []).map(i => i.type).join(';');
46
+ const issuesDetail = (entry.issues || []).map(i => `${i.original}->${i.corrected}`).join(';');
47
+ rows.push([
48
+ day.date,
49
+ entry.id || '',
50
+ entry.ts || '',
51
+ entry.project || '',
52
+ csvEscape(entry.original || ''),
53
+ csvEscape(entry.corrected || ''),
54
+ entry.score ?? '',
55
+ entry.is_clean ?? '',
56
+ csvEscape(issueTypes),
57
+ csvEscape(issuesDetail),
58
+ ].join(','));
59
+ }
60
+ }
61
+
62
+ return rows.join('\n');
63
+ }
64
+
65
+ function csvEscape(str) {
66
+ if (!str) return '';
67
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
68
+ return '"' + str.replace(/"/g, '""') + '"';
69
+ }
70
+ return str;
71
+ }
@@ -0,0 +1,60 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { HOOKS_DIR, RAW_DIR } from '../../constants.mjs';
5
+ import { formatDate } from '../../utils.mjs';
6
+
7
+ export default async function hookTest() {
8
+ const captureScript = join(HOOKS_DIR, 'capture.sh');
9
+ if (!existsSync(captureScript)) {
10
+ console.error('capture.sh not found. Run "vibeglish init" first.');
11
+ process.exit(1);
12
+ }
13
+
14
+ const today = formatDate();
15
+ const targetFile = join(RAW_DIR, `${today}.jsonl`);
16
+
17
+ // Count existing lines
18
+ let beforeCount = 0;
19
+ try {
20
+ beforeCount = readFileSync(targetFile, 'utf-8').trim().split('\n').filter(Boolean).length;
21
+ } catch { /* file doesn't exist yet */ }
22
+
23
+ // Send test prompt
24
+ const testPayload = JSON.stringify({
25
+ session_id: 'vibeglish-test',
26
+ prompt: 'This is a test prompt to verify the hook is working correctly',
27
+ cwd: '/tmp/vibeglish-test-project',
28
+ });
29
+
30
+ try {
31
+ execSync(`echo '${testPayload.replace(/'/g, "'\\''")}' | bash "${captureScript}"`, {
32
+ timeout: 5000,
33
+ stdio: 'pipe',
34
+ });
35
+ } catch (err) {
36
+ console.error('Hook script failed:', err.message);
37
+ process.exit(1);
38
+ }
39
+
40
+ // Check if line was added
41
+ let afterCount = 0;
42
+ try {
43
+ afterCount = readFileSync(targetFile, 'utf-8').trim().split('\n').filter(Boolean).length;
44
+ } catch {
45
+ console.error(`Failed: ${targetFile} was not created`);
46
+ process.exit(1);
47
+ }
48
+
49
+ if (afterCount > beforeCount) {
50
+ console.log('Hook test PASSED!');
51
+ console.log(` New entry written to: ${targetFile}`);
52
+ // Show the last line
53
+ const lines = readFileSync(targetFile, 'utf-8').trim().split('\n');
54
+ console.log(` Entry: ${lines[lines.length - 1]}`);
55
+ } else {
56
+ console.error('Hook test FAILED: No new entry was written.');
57
+ console.error(' Check ~/.vibeglish/error.log for details.');
58
+ process.exit(1);
59
+ }
60
+ }
@@ -0,0 +1,155 @@
1
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import {
5
+ VIBEGLISH_DIR, RAW_DIR, REVIEWED_DIR, HOOKS_DIR, DASHBOARD_DIR,
6
+ CONFIG_PATH, CLAUDE_SETTINGS_PATH, DEFAULT_CONFIG,
7
+ } from '../../constants.mjs';
8
+ import { ensureDir, readJSON, writeJSON } from '../../utils.mjs';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const SRC_ROOT = join(__dirname, '../..');
12
+
13
+ const HOOK_COMMAND = `${HOOKS_DIR}/capture.sh`;
14
+
15
+ export default async function init(args) {
16
+ const isUpgrade = args.includes('--upgrade');
17
+
18
+ console.log(isUpgrade ? 'Upgrading VibeGlish...' : 'Initializing VibeGlish...');
19
+
20
+ // 1. Create directory structure
21
+ for (const dir of [RAW_DIR, REVIEWED_DIR, HOOKS_DIR, DASHBOARD_DIR]) {
22
+ ensureDir(dir);
23
+ }
24
+ chmodSync(VIBEGLISH_DIR, 0o700);
25
+
26
+ // 2. Copy capture.sh
27
+ const captureSrc = join(SRC_ROOT, 'hooks/capture.sh');
28
+ const captureDst = join(HOOKS_DIR, 'capture.sh');
29
+ copyFileSync(captureSrc, captureDst);
30
+ chmodSync(captureDst, 0o755);
31
+ console.log(' [ok] capture.sh installed');
32
+
33
+ // 3. Copy dashboard
34
+ const dashSrc = join(SRC_ROOT, 'dashboard/index.html');
35
+ const dashDst = join(DASHBOARD_DIR, 'index.html');
36
+ if (existsSync(dashSrc)) {
37
+ copyFileSync(dashSrc, dashDst);
38
+ console.log(' [ok] Dashboard installed');
39
+ }
40
+
41
+ // 4. Write default config (don't overwrite existing unless upgrade)
42
+ if (!existsSync(CONFIG_PATH) || isUpgrade) {
43
+ const existing = readJSON(CONFIG_PATH) || {};
44
+ writeJSON(CONFIG_PATH, { ...DEFAULT_CONFIG, ...existing });
45
+ console.log(' [ok] config.json written');
46
+ } else {
47
+ console.log(' [--] config.json already exists, skipping');
48
+ }
49
+
50
+ // 5. Install Claude Code hook
51
+ installHook();
52
+
53
+ // 6. Check jq
54
+ try {
55
+ const { execSync } = await import('node:child_process');
56
+ execSync('which jq', { stdio: 'ignore' });
57
+ } catch {
58
+ console.log('\n [!!] jq is not installed. capture.sh requires jq.');
59
+ console.log(' Install it: brew install jq (macOS) or apt install jq (Linux)');
60
+ }
61
+
62
+ console.log(`
63
+ Done! VibeGlish is ready.
64
+
65
+ Next steps:
66
+ 1. Restart Claude Code (or start a new session) for the hook to take effect
67
+ 2. Start coding — your prompts will be captured automatically
68
+ 3. Run "vibeglish review" to get AI corrections
69
+ 4. Run "vibeglish serve" to open the Dashboard
70
+
71
+ Data stored in: ${VIBEGLISH_DIR}
72
+ `);
73
+ }
74
+
75
+ function installHook() {
76
+ const claudeDir = dirname(CLAUDE_SETTINGS_PATH);
77
+ ensureDir(claudeDir);
78
+
79
+ const vibeglishHook = {
80
+ type: 'command',
81
+ command: HOOK_COMMAND,
82
+ };
83
+
84
+ const vibeglishEntry = {
85
+ matcher: '',
86
+ hooks: [vibeglishHook],
87
+ };
88
+
89
+ if (!existsSync(CLAUDE_SETTINGS_PATH)) {
90
+ // No settings file — create one
91
+ writeJSON(CLAUDE_SETTINGS_PATH, {
92
+ hooks: {
93
+ UserPromptSubmit: [vibeglishEntry],
94
+ },
95
+ });
96
+ console.log(' [ok] Claude Code hook installed (new settings.json)');
97
+ return;
98
+ }
99
+
100
+ const settings = readJSON(CLAUDE_SETTINGS_PATH);
101
+ if (!settings) {
102
+ console.log(' [!!] Could not parse ~/.claude/settings.json — please install hook manually');
103
+ printManualHook();
104
+ return;
105
+ }
106
+
107
+ if (!settings.hooks) {
108
+ settings.hooks = {};
109
+ }
110
+
111
+ if (!settings.hooks.UserPromptSubmit) {
112
+ settings.hooks.UserPromptSubmit = [vibeglishEntry];
113
+ writeJSON(CLAUDE_SETTINGS_PATH, settings);
114
+ console.log(' [ok] Claude Code hook installed');
115
+ return;
116
+ }
117
+
118
+ // Check if vibeglish hook already exists
119
+ const existing = settings.hooks.UserPromptSubmit;
120
+ const alreadyInstalled = existing.some(entry =>
121
+ entry.hooks?.some(h => h.command?.includes('vibeglish') || h.command?.includes('capture.sh'))
122
+ );
123
+
124
+ if (alreadyInstalled) {
125
+ console.log(' [--] Hook already installed, skipping');
126
+ return;
127
+ }
128
+
129
+ // UserPromptSubmit exists but no vibeglish hook — append
130
+ existing.push(vibeglishEntry);
131
+ writeJSON(CLAUDE_SETTINGS_PATH, settings);
132
+ console.log(' [ok] Claude Code hook appended to existing UserPromptSubmit');
133
+ }
134
+
135
+ function printManualHook() {
136
+ console.log(`
137
+ Add this to your ~/.claude/settings.json:
138
+
139
+ {
140
+ "hooks": {
141
+ "UserPromptSubmit": [
142
+ {
143
+ "matcher": "",
144
+ "hooks": [
145
+ {
146
+ "type": "command",
147
+ "command": "${HOOK_COMMAND}"
148
+ }
149
+ ]
150
+ }
151
+ ]
152
+ }
153
+ }
154
+ `);
155
+ }