@vibescore/tracker 0.2.2 → 0.2.4
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/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/package.json +1 -1
- package/src/commands/init.js +72 -14
- package/src/commands/status.js +14 -0
- package/src/commands/uninstall.js +22 -1
- package/src/lib/browser-auth.js +50 -15
- package/src/lib/diagnostics.js +15 -0
- package/src/lib/gemini-config.js +284 -0
- package/src/lib/opencode-config.js +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,7 @@ npx --yes @vibescore/tracker init
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
Note: If `~/.code/config.toml` exists (or `CODE_HOME`), `init` also configures Every Code `notify` automatically. No further user intervention is required for data sync.
|
|
67
|
+
Note: If Gemini CLI home exists, `init` installs a `SessionEnd` hook in `~/.gemini/settings.json` and sets `tools.enableHooks = true` so hooks execute. This enables all Gemini hooks; disable by setting `tools.enableHooks = false` (or disabling the `vibescore-tracker` hook).
|
|
67
68
|
|
|
68
69
|
### Sync & Status
|
|
69
70
|
|
package/README.zh-CN.md
CHANGED
|
@@ -64,6 +64,7 @@ npx --yes @vibescore/tracker init
|
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
说明:若存在 `~/.code/config.toml`(或 `CODE_HOME`),`init` 会自动配置 Every Code 的 `notify`。配置完成后,数据同步完全自动化,无需后续人工干预。
|
|
67
|
+
说明:若检测到 Gemini CLI home,`init` 会在 `~/.gemini/settings.json` 安装 `SessionEnd` hook,并将 `tools.enableHooks = true` 以确保 hook 生效。这会启用所有 Gemini hooks;如需关闭,可将 `tools.enableHooks = false`(或禁用 `vibescore-tracker` hook)。
|
|
67
68
|
|
|
68
69
|
### 同步与状态查看
|
|
69
70
|
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -14,6 +14,12 @@ const {
|
|
|
14
14
|
loadEveryCodeNotifyOriginal
|
|
15
15
|
} = require('../lib/codex-config');
|
|
16
16
|
const { upsertClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
17
|
+
const {
|
|
18
|
+
resolveGeminiConfigDir,
|
|
19
|
+
resolveGeminiSettingsPath,
|
|
20
|
+
buildGeminiHookCommand,
|
|
21
|
+
upsertGeminiHook
|
|
22
|
+
} = require('../lib/gemini-config');
|
|
17
23
|
const { resolveOpencodeConfigDir, upsertOpencodePlugin } = require('../lib/opencode-config');
|
|
18
24
|
const { beginBrowserAuth } = require('../lib/browser-auth');
|
|
19
25
|
const {
|
|
@@ -126,19 +132,26 @@ async function cmdInit(argv) {
|
|
|
126
132
|
await fs.chmod(notifyPath, 0o755).catch(() => {});
|
|
127
133
|
|
|
128
134
|
// Configure Codex notify hook.
|
|
129
|
-
const
|
|
135
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
136
|
+
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
130
137
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
131
138
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
132
139
|
const notifyCmd = ['/usr/bin/env', 'node', notifyPath];
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
const codexProbe = await probeFile(codexConfigPath);
|
|
141
|
+
const codexConfigExists = codexProbe.exists;
|
|
142
|
+
let result = null;
|
|
143
|
+
let chained = null;
|
|
144
|
+
if (codexConfigExists) {
|
|
145
|
+
result = await upsertCodexNotify({
|
|
146
|
+
codexConfigPath,
|
|
147
|
+
notifyCmd,
|
|
148
|
+
notifyOriginalPath
|
|
149
|
+
});
|
|
150
|
+
chained = await loadCodexNotifyOriginal(notifyOriginalPath);
|
|
151
|
+
}
|
|
140
152
|
const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
|
|
141
|
-
const
|
|
153
|
+
const codeProbe = await probeFile(codeConfigPath);
|
|
154
|
+
const codeConfigExists = codeProbe.exists;
|
|
142
155
|
let codeResult = null;
|
|
143
156
|
let codeChained = null;
|
|
144
157
|
if (codeConfigExists) {
|
|
@@ -163,6 +176,18 @@ async function cmdInit(argv) {
|
|
|
163
176
|
});
|
|
164
177
|
}
|
|
165
178
|
|
|
179
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
180
|
+
const geminiConfigExists = await isDir(geminiConfigDir);
|
|
181
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
182
|
+
let geminiResult = null;
|
|
183
|
+
if (geminiConfigExists) {
|
|
184
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
185
|
+
geminiResult = await upsertGeminiHook({
|
|
186
|
+
settingsPath: geminiSettingsPath,
|
|
187
|
+
hookCommand: geminiHookCommand
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
166
191
|
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
167
192
|
const opencodeResult = await upsertOpencodePlugin({
|
|
168
193
|
configDir: opencodeConfigDir,
|
|
@@ -174,10 +199,18 @@ async function cmdInit(argv) {
|
|
|
174
199
|
'Installed:',
|
|
175
200
|
`- Tracker config: ${configPath}`,
|
|
176
201
|
`- Notify handler: ${notifyPath}`,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
202
|
+
codexConfigExists
|
|
203
|
+
? `- Codex config: ${codexConfigPath}`
|
|
204
|
+
: `- Codex notify: skipped (${renderProbeSkip(codexConfigPath, codexProbe)})`,
|
|
205
|
+
codexConfigExists
|
|
206
|
+
? result?.changed
|
|
207
|
+
? '- Codex notify: updated'
|
|
208
|
+
: '- Codex notify: already set'
|
|
209
|
+
: null,
|
|
210
|
+
codexConfigExists ? (chained ? '- Codex notify: chained (original preserved)' : '- Codex notify: no original') : null,
|
|
211
|
+
codeConfigExists
|
|
212
|
+
? `- Every Code config: ${codeConfigPath}`
|
|
213
|
+
: `- Every Code notify: skipped (${renderProbeSkip(codeConfigPath, codeProbe)})`,
|
|
181
214
|
codeConfigExists && codeResult
|
|
182
215
|
? codeResult.changed
|
|
183
216
|
? '- Every Code notify: updated'
|
|
@@ -193,6 +226,11 @@ async function cmdInit(argv) {
|
|
|
193
226
|
? `- Claude hooks: updated (${claudeSettingsPath})`
|
|
194
227
|
: `- Claude hooks: already set (${claudeSettingsPath})`
|
|
195
228
|
: '- Claude hooks: skipped (~/.claude not found)',
|
|
229
|
+
geminiConfigExists
|
|
230
|
+
? geminiResult?.changed
|
|
231
|
+
? `- Gemini hooks: updated (${geminiSettingsPath})`
|
|
232
|
+
: `- Gemini hooks: already set (${geminiSettingsPath})`
|
|
233
|
+
: `- Gemini hooks: skipped (${geminiConfigDir} not found)`,
|
|
196
234
|
opencodeResult?.skippedReason === 'config-missing'
|
|
197
235
|
? '- Opencode plugin: skipped (config dir missing)'
|
|
198
236
|
: opencodeResult?.changed
|
|
@@ -321,7 +359,7 @@ try {
|
|
|
321
359
|
const originalPath =
|
|
322
360
|
source === 'every-code'
|
|
323
361
|
? codeOriginalPath
|
|
324
|
-
: source === 'claude' || source === 'opencode'
|
|
362
|
+
: source === 'claude' || source === 'opencode' || source === 'gemini'
|
|
325
363
|
? null
|
|
326
364
|
: codexOriginalPath;
|
|
327
365
|
if (originalPath) {
|
|
@@ -406,6 +444,26 @@ async function isFile(p) {
|
|
|
406
444
|
}
|
|
407
445
|
}
|
|
408
446
|
|
|
447
|
+
async function probeFile(p) {
|
|
448
|
+
try {
|
|
449
|
+
const st = await fs.stat(p);
|
|
450
|
+
if (st.isFile()) return { exists: true, reason: null };
|
|
451
|
+
return { exists: false, reason: 'not-file' };
|
|
452
|
+
} catch (e) {
|
|
453
|
+
if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR') return { exists: false, reason: 'missing' };
|
|
454
|
+
if (e?.code === 'EACCES' || e?.code === 'EPERM') return { exists: false, reason: 'permission-denied' };
|
|
455
|
+
return { exists: false, reason: 'error', code: e?.code || 'unknown' };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function renderProbeSkip(pathname, probe) {
|
|
460
|
+
if (!probe || probe.reason === 'missing') return `${pathname} not found`;
|
|
461
|
+
if (probe.reason === 'not-file') return `${pathname} is not a file`;
|
|
462
|
+
if (probe.reason === 'permission-denied') return `permission denied: ${pathname}`;
|
|
463
|
+
const code = probe.code ? ` (${probe.code})` : '';
|
|
464
|
+
return `unavailable: ${pathname}${code}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
409
467
|
async function isDir(p) {
|
|
410
468
|
try {
|
|
411
469
|
const st = await fs.stat(p);
|
package/src/commands/status.js
CHANGED
|
@@ -5,6 +5,12 @@ const fs = require('node:fs/promises');
|
|
|
5
5
|
const { readJson } = require('../lib/fs');
|
|
6
6
|
const { readCodexNotify, readEveryCodeNotify } = require('../lib/codex-config');
|
|
7
7
|
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
8
|
+
const {
|
|
9
|
+
resolveGeminiConfigDir,
|
|
10
|
+
resolveGeminiSettingsPath,
|
|
11
|
+
isGeminiHookConfigured,
|
|
12
|
+
buildGeminiHookCommand
|
|
13
|
+
} = require('../lib/gemini-config');
|
|
8
14
|
const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('../lib/opencode-config');
|
|
9
15
|
const { normalizeState: normalizeUploadState } = require('../lib/upload-throttle');
|
|
10
16
|
const { collectTrackerDiagnostics } = require('../lib/diagnostics');
|
|
@@ -32,8 +38,11 @@ async function cmdStatus(argv = []) {
|
|
|
32
38
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
33
39
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
34
40
|
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
41
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
42
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
35
43
|
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
36
44
|
const claudeHookCommand = buildClaudeHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
45
|
+
const geminiHookCommand = buildGeminiHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
37
46
|
|
|
38
47
|
const config = await readJson(configPath);
|
|
39
48
|
const cursors = await readJson(cursorsPath);
|
|
@@ -55,6 +64,10 @@ async function cmdStatus(argv = []) {
|
|
|
55
64
|
settingsPath: claudeSettingsPath,
|
|
56
65
|
hookCommand: claudeHookCommand
|
|
57
66
|
});
|
|
67
|
+
const geminiHookConfigured = await isGeminiHookConfigured({
|
|
68
|
+
settingsPath: geminiSettingsPath,
|
|
69
|
+
hookCommand: geminiHookCommand
|
|
70
|
+
});
|
|
58
71
|
const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
|
|
59
72
|
|
|
60
73
|
const lastUpload = uploadThrottle.lastSuccessMs
|
|
@@ -91,6 +104,7 @@ async function cmdStatus(argv = []) {
|
|
|
91
104
|
`- Codex notify: ${notifyConfigured ? JSON.stringify(codexNotify) : 'unset'}`,
|
|
92
105
|
`- Every Code notify: ${everyCodeConfigured ? JSON.stringify(everyCodeNotify) : 'unset'}`,
|
|
93
106
|
`- Claude hooks: ${claudeHookConfigured ? 'set' : 'unset'}`,
|
|
107
|
+
`- Gemini hooks: ${geminiHookConfigured ? 'set' : 'unset'}`,
|
|
94
108
|
`- Opencode plugin: ${opencodePluginConfigured ? 'set' : 'unset'}`,
|
|
95
109
|
''
|
|
96
110
|
]
|
|
@@ -4,6 +4,12 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
|
|
5
5
|
const { restoreCodexNotify, restoreEveryCodeNotify } = require('../lib/codex-config');
|
|
6
6
|
const { removeClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
7
|
+
const {
|
|
8
|
+
resolveGeminiConfigDir,
|
|
9
|
+
resolveGeminiSettingsPath,
|
|
10
|
+
buildGeminiHookCommand,
|
|
11
|
+
removeGeminiHook
|
|
12
|
+
} = require('../lib/gemini-config');
|
|
7
13
|
const { resolveOpencodeConfigDir, removeOpencodePlugin } = require('../lib/opencode-config');
|
|
8
14
|
|
|
9
15
|
async function cmdUninstall(argv) {
|
|
@@ -11,10 +17,13 @@ async function cmdUninstall(argv) {
|
|
|
11
17
|
const home = os.homedir();
|
|
12
18
|
const trackerDir = path.join(home, '.vibescore', 'tracker');
|
|
13
19
|
const binDir = path.join(home, '.vibescore', 'bin');
|
|
14
|
-
const
|
|
20
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
21
|
+
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
15
22
|
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
16
23
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
17
24
|
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
25
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
26
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
18
27
|
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
19
28
|
const notifyPath = path.join(binDir, 'notify.cjs');
|
|
20
29
|
const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
|
|
@@ -22,10 +31,12 @@ async function cmdUninstall(argv) {
|
|
|
22
31
|
const codexNotifyCmd = ['/usr/bin/env', 'node', notifyPath];
|
|
23
32
|
const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
|
|
24
33
|
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
34
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
25
35
|
|
|
26
36
|
const codexConfigExists = await isFile(codexConfigPath);
|
|
27
37
|
const codeConfigExists = await isFile(codeConfigPath);
|
|
28
38
|
const claudeConfigExists = await isFile(claudeSettingsPath);
|
|
39
|
+
const geminiConfigExists = await isDir(geminiConfigDir);
|
|
29
40
|
const opencodeConfigExists = await isDir(opencodeConfigDir);
|
|
30
41
|
const codexRestore = codexConfigExists
|
|
31
42
|
? await restoreCodexNotify({
|
|
@@ -44,6 +55,9 @@ async function cmdUninstall(argv) {
|
|
|
44
55
|
const claudeRemove = claudeConfigExists
|
|
45
56
|
? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
|
|
46
57
|
: { removed: false, skippedReason: 'config-missing' };
|
|
58
|
+
const geminiRemove = geminiConfigExists
|
|
59
|
+
? await removeGeminiHook({ settingsPath: geminiSettingsPath, hookCommand: geminiHookCommand })
|
|
60
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
47
61
|
const opencodeRemove = opencodeConfigExists
|
|
48
62
|
? await removeOpencodePlugin({ configDir: opencodeConfigDir })
|
|
49
63
|
: { removed: false, skippedReason: 'config-missing' };
|
|
@@ -82,6 +96,13 @@ async function cmdUninstall(argv) {
|
|
|
82
96
|
? '- Claude hooks: no change'
|
|
83
97
|
: '- Claude hooks: skipped'
|
|
84
98
|
: '- Claude hooks: skipped (settings.json not found)',
|
|
99
|
+
geminiConfigExists
|
|
100
|
+
? geminiRemove?.removed
|
|
101
|
+
? `- Gemini hooks removed: ${geminiSettingsPath}`
|
|
102
|
+
: geminiRemove?.skippedReason === 'hook-missing'
|
|
103
|
+
? '- Gemini hooks: no change'
|
|
104
|
+
: '- Gemini hooks: skipped'
|
|
105
|
+
: `- Gemini hooks: skipped (${geminiConfigDir} not found)`,
|
|
85
106
|
opencodeConfigExists
|
|
86
107
|
? opencodeRemove?.removed
|
|
87
108
|
? `- Opencode plugin removed: ${opencodeConfigDir}`
|
package/src/lib/browser-auth.js
CHANGED
|
@@ -7,10 +7,13 @@ const DEFAULT_BASE_URL = 'https://5tmappuk.us-east.insforge.app';
|
|
|
7
7
|
async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
8
8
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
9
9
|
const callbackPath = `/vibescore/callback/${nonce}`;
|
|
10
|
-
|
|
11
|
-
const { callbackUrl, waitForCallback } = await startLocalCallbackServer({ callbackPath, timeoutMs });
|
|
12
|
-
|
|
13
10
|
const authUrl = dashboardUrl ? new URL('/', dashboardUrl) : new URL('/auth/sign-up', baseUrl);
|
|
11
|
+
const postAuthRedirect = resolvePostAuthRedirect({ dashboardUrl, authUrl });
|
|
12
|
+
const { callbackUrl, waitForCallback } = await startLocalCallbackServer({
|
|
13
|
+
callbackPath,
|
|
14
|
+
timeoutMs,
|
|
15
|
+
redirectUrl: postAuthRedirect
|
|
16
|
+
});
|
|
14
17
|
authUrl.searchParams.set('redirect', callbackUrl);
|
|
15
18
|
if (dashboardUrl && baseUrl && baseUrl !== DEFAULT_BASE_URL) authUrl.searchParams.set('base_url', baseUrl);
|
|
16
19
|
|
|
@@ -19,7 +22,7 @@ async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
|
19
22
|
return { authUrl: authUrl.toString(), waitForCallback };
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
async function startLocalCallbackServer({ callbackPath, timeoutMs }) {
|
|
25
|
+
async function startLocalCallbackServer({ callbackPath, timeoutMs, redirectUrl }) {
|
|
23
26
|
let resolved = false;
|
|
24
27
|
let resolveResult;
|
|
25
28
|
let rejectResult;
|
|
@@ -58,17 +61,34 @@ async function startLocalCallbackServer({ callbackPath, timeoutMs }) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
resolved = true;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
if (redirectUrl) {
|
|
65
|
+
res.writeHead(302, {
|
|
66
|
+
Location: redirectUrl,
|
|
67
|
+
'Content-Type': 'text/html; charset=utf-8'
|
|
68
|
+
});
|
|
69
|
+
res.end(
|
|
70
|
+
[
|
|
71
|
+
'<!doctype html>',
|
|
72
|
+
'<html><head><meta charset="utf-8"><title>VibeScore</title></head>',
|
|
73
|
+
'<body>',
|
|
74
|
+
'<h2>Login succeeded</h2>',
|
|
75
|
+
`<p>Redirecting to <a href="${redirectUrl}">dashboard</a>...</p>`,
|
|
76
|
+
'</body></html>'
|
|
77
|
+
].join('')
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
+
res.end(
|
|
82
|
+
[
|
|
83
|
+
'<!doctype html>',
|
|
84
|
+
'<html><head><meta charset="utf-8"><title>VibeScore</title></head>',
|
|
85
|
+
'<body>',
|
|
86
|
+
'<h2>Login succeeded</h2>',
|
|
87
|
+
'<p>You can close this tab and return to the CLI.</p>',
|
|
88
|
+
'</body></html>'
|
|
89
|
+
].join('')
|
|
90
|
+
);
|
|
91
|
+
}
|
|
72
92
|
|
|
73
93
|
resolveResult({
|
|
74
94
|
accessToken,
|
|
@@ -134,6 +154,21 @@ function openInBrowser(url) {
|
|
|
134
154
|
} catch (_e) {}
|
|
135
155
|
}
|
|
136
156
|
|
|
157
|
+
function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
|
|
158
|
+
try {
|
|
159
|
+
if (dashboardUrl) {
|
|
160
|
+
const target = new URL('/', dashboardUrl);
|
|
161
|
+
if (target.protocol === 'http:' || target.protocol === 'https:') {
|
|
162
|
+
return target.toString();
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
} catch (_e) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
module.exports = {
|
|
138
173
|
beginBrowserAuth
|
|
139
174
|
};
|
package/src/lib/diagnostics.js
CHANGED
|
@@ -5,6 +5,12 @@ const fs = require('node:fs/promises');
|
|
|
5
5
|
const { readJson } = require('./fs');
|
|
6
6
|
const { readCodexNotify, readEveryCodeNotify } = require('./codex-config');
|
|
7
7
|
const { isClaudeHookConfigured, buildClaudeHookCommand } = require('./claude-config');
|
|
8
|
+
const {
|
|
9
|
+
resolveGeminiConfigDir,
|
|
10
|
+
resolveGeminiSettingsPath,
|
|
11
|
+
buildGeminiHookCommand,
|
|
12
|
+
isGeminiHookConfigured
|
|
13
|
+
} = require('./gemini-config');
|
|
8
14
|
const { resolveOpencodeConfigDir, isOpencodePluginInstalled } = require('./opencode-config');
|
|
9
15
|
const { normalizeState: normalizeUploadState } = require('./upload-throttle');
|
|
10
16
|
|
|
@@ -25,6 +31,8 @@ async function collectTrackerDiagnostics({
|
|
|
25
31
|
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
26
32
|
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
27
33
|
const claudeConfigPath = path.join(home, '.claude', 'settings.json');
|
|
34
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
35
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
28
36
|
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
29
37
|
|
|
30
38
|
const config = await readJson(configPath);
|
|
@@ -51,6 +59,11 @@ async function collectTrackerDiagnostics({
|
|
|
51
59
|
settingsPath: claudeConfigPath,
|
|
52
60
|
hookCommand: claudeHookCommand
|
|
53
61
|
});
|
|
62
|
+
const geminiHookCommand = buildGeminiHookCommand(path.join(home, '.vibescore', 'bin', 'notify.cjs'));
|
|
63
|
+
const geminiHookConfigured = await isGeminiHookConfigured({
|
|
64
|
+
settingsPath: geminiSettingsPath,
|
|
65
|
+
hookCommand: geminiHookCommand
|
|
66
|
+
});
|
|
54
67
|
const opencodePluginConfigured = await isOpencodePluginInstalled({ configDir: opencodeConfigDir });
|
|
55
68
|
|
|
56
69
|
const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
|
|
@@ -72,6 +85,7 @@ async function collectTrackerDiagnostics({
|
|
|
72
85
|
code_home: redactValue(codeHome, home),
|
|
73
86
|
code_config: redactValue(codeConfigPath, home),
|
|
74
87
|
claude_config: redactValue(claudeConfigPath, home),
|
|
88
|
+
gemini_config: redactValue(geminiSettingsPath, home),
|
|
75
89
|
opencode_config: redactValue(opencodeConfigDir, home)
|
|
76
90
|
},
|
|
77
91
|
config: {
|
|
@@ -98,6 +112,7 @@ async function collectTrackerDiagnostics({
|
|
|
98
112
|
every_code_notify_configured: everyCodeConfigured,
|
|
99
113
|
every_code_notify: everyCodeNotify,
|
|
100
114
|
claude_hook_configured: claudeHookConfigured,
|
|
115
|
+
gemini_hook_configured: geminiHookConfigured,
|
|
101
116
|
opencode_plugin_configured: opencodePluginConfigured
|
|
102
117
|
},
|
|
103
118
|
upload: {
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EVENT = 'SessionEnd';
|
|
8
|
+
const DEFAULT_HOOK_NAME = 'vibescore-tracker';
|
|
9
|
+
const DEFAULT_MATCHER = 'exit|clear|logout|prompt_input_exit|other';
|
|
10
|
+
|
|
11
|
+
function resolveGeminiConfigDir({ home = os.homedir(), env = process.env } = {}) {
|
|
12
|
+
const explicit = typeof env.GEMINI_HOME === 'string' ? env.GEMINI_HOME.trim() : '';
|
|
13
|
+
if (explicit) return path.resolve(explicit);
|
|
14
|
+
return path.join(home, '.gemini');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveGeminiSettingsPath({ configDir }) {
|
|
18
|
+
return path.join(configDir, 'settings.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function upsertGeminiHook({
|
|
22
|
+
settingsPath,
|
|
23
|
+
hookCommand,
|
|
24
|
+
hookName = DEFAULT_HOOK_NAME,
|
|
25
|
+
matcher = DEFAULT_MATCHER,
|
|
26
|
+
event = DEFAULT_EVENT
|
|
27
|
+
}) {
|
|
28
|
+
const existing = await readJson(settingsPath);
|
|
29
|
+
const settings = normalizeSettings(existing);
|
|
30
|
+
const enableResult = ensureHooksEnabled(settings);
|
|
31
|
+
const baseSettings = enableResult.settings;
|
|
32
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
33
|
+
const entries = normalizeEntries(hooks[event]);
|
|
34
|
+
|
|
35
|
+
const normalized = normalizeEntriesForHook(entries, { hookCommand, hookName });
|
|
36
|
+
let nextEntries = normalized.entries;
|
|
37
|
+
let changed = normalized.changed || enableResult.changed;
|
|
38
|
+
|
|
39
|
+
if (!hasHook(nextEntries, { hookCommand, hookName })) {
|
|
40
|
+
nextEntries = nextEntries.concat([buildHookEntry({ hookCommand, hookName, matcher })]);
|
|
41
|
+
changed = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!changed) return { changed: false, backupPath: null };
|
|
45
|
+
|
|
46
|
+
const nextHooks = { ...hooks, [event]: nextEntries };
|
|
47
|
+
const nextSettings = { ...baseSettings, hooks: nextHooks };
|
|
48
|
+
const backupPath = await writeGeminiSettings({ settingsPath, settings: nextSettings });
|
|
49
|
+
return { changed: true, backupPath };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function removeGeminiHook({
|
|
53
|
+
settingsPath,
|
|
54
|
+
hookCommand,
|
|
55
|
+
hookName = DEFAULT_HOOK_NAME,
|
|
56
|
+
event = DEFAULT_EVENT
|
|
57
|
+
}) {
|
|
58
|
+
const existing = await readJson(settingsPath);
|
|
59
|
+
if (!existing) return { removed: false, skippedReason: 'settings-missing' };
|
|
60
|
+
|
|
61
|
+
const settings = normalizeSettings(existing);
|
|
62
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
63
|
+
const entries = normalizeEntries(hooks[event]);
|
|
64
|
+
if (entries.length === 0) return { removed: false, skippedReason: 'hook-missing' };
|
|
65
|
+
|
|
66
|
+
let removed = false;
|
|
67
|
+
const nextEntries = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const res = stripHookFromEntry(entry, { hookCommand, hookName });
|
|
70
|
+
if (res.removed) removed = true;
|
|
71
|
+
if (res.entry) nextEntries.push(res.entry);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!removed) return { removed: false, skippedReason: 'hook-missing' };
|
|
75
|
+
|
|
76
|
+
const nextHooks = { ...hooks };
|
|
77
|
+
if (nextEntries.length > 0) nextHooks[event] = nextEntries;
|
|
78
|
+
else delete nextHooks[event];
|
|
79
|
+
|
|
80
|
+
const nextSettings = { ...settings };
|
|
81
|
+
if (Object.keys(nextHooks).length > 0) nextSettings.hooks = nextHooks;
|
|
82
|
+
else delete nextSettings.hooks;
|
|
83
|
+
|
|
84
|
+
const backupPath = await writeGeminiSettings({ settingsPath, settings: nextSettings });
|
|
85
|
+
return { removed: true, skippedReason: null, backupPath };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function isGeminiHookConfigured({
|
|
89
|
+
settingsPath,
|
|
90
|
+
hookCommand,
|
|
91
|
+
hookName = DEFAULT_HOOK_NAME,
|
|
92
|
+
event = DEFAULT_EVENT
|
|
93
|
+
}) {
|
|
94
|
+
const settings = await readJson(settingsPath);
|
|
95
|
+
if (!settings || typeof settings !== 'object') return false;
|
|
96
|
+
const hooks = settings.hooks;
|
|
97
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
98
|
+
const entries = normalizeEntries(hooks[event]);
|
|
99
|
+
return hasHook(entries, { hookCommand, hookName });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildGeminiHookCommand(notifyPath) {
|
|
103
|
+
const cmd = typeof notifyPath === 'string' ? notifyPath : '';
|
|
104
|
+
return `/usr/bin/env node ${quoteArg(cmd)} --source=gemini`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildHookEntry({ hookCommand, hookName, matcher }) {
|
|
108
|
+
const hook = {
|
|
109
|
+
name: hookName,
|
|
110
|
+
type: 'command',
|
|
111
|
+
command: hookCommand
|
|
112
|
+
};
|
|
113
|
+
const entry = { hooks: [hook] };
|
|
114
|
+
if (typeof matcher === 'string' && matcher.length > 0) entry.matcher = matcher;
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeSettings(raw) {
|
|
119
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeHooks(raw) {
|
|
123
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeTools(raw) {
|
|
127
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeEntries(raw) {
|
|
131
|
+
return Array.isArray(raw) ? raw.slice() : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeCommand(cmd) {
|
|
135
|
+
if (Array.isArray(cmd)) return cmd.map((v) => String(v)).join('\u0000');
|
|
136
|
+
if (typeof cmd === 'string') return cmd.trim();
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeName(name) {
|
|
141
|
+
if (typeof name !== 'string') return null;
|
|
142
|
+
const trimmed = name.trim();
|
|
143
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ensureHooksEnabled(settings) {
|
|
147
|
+
const tools = normalizeTools(settings.tools);
|
|
148
|
+
if (tools.enableHooks === true) return { settings, changed: false };
|
|
149
|
+
const nextTools = { ...tools, enableHooks: true };
|
|
150
|
+
return { settings: { ...settings, tools: nextTools }, changed: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hookMatches(hook, { hookCommand, hookName, requireCommand = false }) {
|
|
154
|
+
if (!hook || typeof hook !== 'object') return false;
|
|
155
|
+
const name = normalizeName(hook.name);
|
|
156
|
+
const targetName = normalizeName(hookName);
|
|
157
|
+
const cmd = normalizeCommand(hook.command);
|
|
158
|
+
const targetCmd = normalizeCommand(hookCommand);
|
|
159
|
+
const commandMatches = Boolean(cmd && targetCmd && cmd === targetCmd);
|
|
160
|
+
if (requireCommand) return commandMatches;
|
|
161
|
+
const nameMatches = Boolean(name && targetName && name === targetName);
|
|
162
|
+
return Boolean(commandMatches || nameMatches);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function entryMatches(entry, { hookCommand, hookName, requireCommand = false }) {
|
|
166
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
167
|
+
if (entry.command || entry.name) return hookMatches(entry, { hookCommand, hookName, requireCommand });
|
|
168
|
+
if (!Array.isArray(entry.hooks)) return false;
|
|
169
|
+
return entry.hooks.some((hook) => hookMatches(hook, { hookCommand, hookName, requireCommand }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasHook(entries, { hookCommand, hookName }) {
|
|
173
|
+
return entries.some((entry) => entryMatches(entry, { hookCommand, hookName }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeEntriesForHook(entries, { hookCommand, hookName }) {
|
|
177
|
+
let changed = false;
|
|
178
|
+
const nextEntries = entries.map((entry) => {
|
|
179
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
180
|
+
|
|
181
|
+
if (entry.command || entry.name) {
|
|
182
|
+
if (hookMatches(entry, { hookCommand, hookName })) {
|
|
183
|
+
const next = normalizeHookObject(entry, { hookCommand, hookName });
|
|
184
|
+
if (next !== entry) changed = true;
|
|
185
|
+
return next;
|
|
186
|
+
}
|
|
187
|
+
return entry;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : null;
|
|
191
|
+
if (!hooks) return entry;
|
|
192
|
+
|
|
193
|
+
let hooksChanged = false;
|
|
194
|
+
const nextHooks = hooks.map((hook) => {
|
|
195
|
+
if (hookMatches(hook, { hookCommand, hookName })) {
|
|
196
|
+
const next = normalizeHookObject(hook, { hookCommand, hookName });
|
|
197
|
+
if (next !== hook) hooksChanged = true;
|
|
198
|
+
return next;
|
|
199
|
+
}
|
|
200
|
+
return hook;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!hooksChanged) return entry;
|
|
204
|
+
changed = true;
|
|
205
|
+
return { ...entry, hooks: nextHooks };
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { entries: nextEntries, changed };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeHookObject(hook, { hookCommand, hookName }) {
|
|
212
|
+
const next = { ...hook };
|
|
213
|
+
let changed = false;
|
|
214
|
+
|
|
215
|
+
if (next.type !== 'command') {
|
|
216
|
+
next.type = 'command';
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (hookCommand && next.command !== hookCommand) {
|
|
221
|
+
next.command = hookCommand;
|
|
222
|
+
changed = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (hookName && next.name !== hookName) {
|
|
226
|
+
next.name = hookName;
|
|
227
|
+
changed = true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return changed ? next : hook;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stripHookFromEntry(entry, { hookCommand, hookName }) {
|
|
234
|
+
if (!entry || typeof entry !== 'object') return { entry, removed: false };
|
|
235
|
+
|
|
236
|
+
if (entry.command || entry.name) {
|
|
237
|
+
if (hookMatches(entry, { hookCommand, hookName, requireCommand: true })) return { entry: null, removed: true };
|
|
238
|
+
return { entry, removed: false };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : null;
|
|
242
|
+
if (!hooks) return { entry, removed: false };
|
|
243
|
+
|
|
244
|
+
const nextHooks = hooks.filter((hook) => !hookMatches(hook, { hookCommand, hookName, requireCommand: true }));
|
|
245
|
+
if (nextHooks.length === hooks.length) return { entry, removed: false };
|
|
246
|
+
if (nextHooks.length === 0) return { entry: null, removed: true };
|
|
247
|
+
|
|
248
|
+
return { entry: { ...entry, hooks: nextHooks }, removed: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function quoteArg(value) {
|
|
252
|
+
const v = typeof value === 'string' ? value : '';
|
|
253
|
+
if (!v) return '""';
|
|
254
|
+
if (/^[A-Za-z0-9_\-./:@]+$/.test(v)) return v;
|
|
255
|
+
return `"${v.replace(/"/g, '\\"')}"`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function writeGeminiSettings({ settingsPath, settings }) {
|
|
259
|
+
await ensureDir(path.dirname(settingsPath));
|
|
260
|
+
let backupPath = null;
|
|
261
|
+
try {
|
|
262
|
+
const st = await fs.stat(settingsPath);
|
|
263
|
+
if (st && st.isFile()) {
|
|
264
|
+
backupPath = `${settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
265
|
+
await fs.copyFile(settingsPath, backupPath);
|
|
266
|
+
}
|
|
267
|
+
} catch (_e) {
|
|
268
|
+
// Ignore missing file.
|
|
269
|
+
}
|
|
270
|
+
await writeJson(settingsPath, settings);
|
|
271
|
+
return backupPath;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
DEFAULT_EVENT,
|
|
276
|
+
DEFAULT_HOOK_NAME,
|
|
277
|
+
DEFAULT_MATCHER,
|
|
278
|
+
resolveGeminiConfigDir,
|
|
279
|
+
resolveGeminiSettingsPath,
|
|
280
|
+
buildGeminiHookCommand,
|
|
281
|
+
upsertGeminiHook,
|
|
282
|
+
removeGeminiHook,
|
|
283
|
+
isGeminiHookConfigured
|
|
284
|
+
};
|
|
@@ -30,7 +30,7 @@ function buildOpencodePlugin({ notifyPath }) {
|
|
|
30
30
|
` if (!event || event.type !== ${JSON.stringify(DEFAULT_EVENT)}) return;\n` +
|
|
31
31
|
` try {\n` +
|
|
32
32
|
` if (!notifyPath) return;\n` +
|
|
33
|
-
` const proc =
|
|
33
|
+
` const proc = $\`/usr/bin/env node ${'${notifyPath}'} --source=opencode\`;\n` +
|
|
34
34
|
` if (proc && typeof proc.catch === 'function') proc.catch(() => {});\n` +
|
|
35
35
|
` } catch (_) {}\n` +
|
|
36
36
|
` }\n` +
|