vibeusage 0.2.8
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/LICENSE +21 -0
- package/README.md +182 -0
- package/README.zh-CN.md +182 -0
- package/bin/tracker.js +28 -0
- package/package.json +46 -0
- package/src/cli.js +64 -0
- package/src/commands/diagnostics.js +39 -0
- package/src/commands/init.js +798 -0
- package/src/commands/status.js +155 -0
- package/src/commands/sync.js +479 -0
- package/src/commands/uninstall.js +153 -0
- package/src/lib/browser-auth.js +175 -0
- package/src/lib/claude-config.js +190 -0
- package/src/lib/cli-ui.js +179 -0
- package/src/lib/codex-config.js +224 -0
- package/src/lib/debug-flags.js +9 -0
- package/src/lib/diagnostics.js +190 -0
- package/src/lib/fs.js +62 -0
- package/src/lib/gemini-config.js +284 -0
- package/src/lib/init-flow.js +48 -0
- package/src/lib/insforge-client.js +75 -0
- package/src/lib/insforge.js +23 -0
- package/src/lib/opencode-config.js +98 -0
- package/src/lib/progress.js +77 -0
- package/src/lib/prompt.js +20 -0
- package/src/lib/rollout.js +1317 -0
- package/src/lib/tracker-paths.js +66 -0
- package/src/lib/upload-throttle.js +129 -0
- package/src/lib/uploader.js +116 -0
- package/src/lib/vibescore-api.js +222 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { restoreCodexNotify, restoreEveryCodeNotify } = require('../lib/codex-config');
|
|
6
|
+
const { removeClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
|
|
7
|
+
const {
|
|
8
|
+
resolveGeminiConfigDir,
|
|
9
|
+
resolveGeminiSettingsPath,
|
|
10
|
+
buildGeminiHookCommand,
|
|
11
|
+
removeGeminiHook
|
|
12
|
+
} = require('../lib/gemini-config');
|
|
13
|
+
const { resolveOpencodeConfigDir, removeOpencodePlugin } = require('../lib/opencode-config');
|
|
14
|
+
const { resolveTrackerPaths } = require('../lib/tracker-paths');
|
|
15
|
+
|
|
16
|
+
async function cmdUninstall(argv) {
|
|
17
|
+
const opts = parseArgs(argv);
|
|
18
|
+
const home = os.homedir();
|
|
19
|
+
const { trackerDir, binDir, legacyRootDir } = await resolveTrackerPaths({
|
|
20
|
+
home,
|
|
21
|
+
migrate: false
|
|
22
|
+
});
|
|
23
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
24
|
+
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
25
|
+
const codeHome = process.env.CODE_HOME || path.join(home, '.code');
|
|
26
|
+
const codeConfigPath = path.join(codeHome, 'config.toml');
|
|
27
|
+
const claudeSettingsPath = path.join(home, '.claude', 'settings.json');
|
|
28
|
+
const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
|
|
29
|
+
const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
|
|
30
|
+
const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
|
|
31
|
+
const notifyPath = path.join(binDir, 'notify.cjs');
|
|
32
|
+
const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
|
|
33
|
+
const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
|
|
34
|
+
const codexNotifyCmd = ['/usr/bin/env', 'node', notifyPath];
|
|
35
|
+
const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
|
|
36
|
+
const claudeHookCommand = buildClaudeHookCommand(notifyPath);
|
|
37
|
+
const geminiHookCommand = buildGeminiHookCommand(notifyPath);
|
|
38
|
+
|
|
39
|
+
const codexConfigExists = await isFile(codexConfigPath);
|
|
40
|
+
const codeConfigExists = await isFile(codeConfigPath);
|
|
41
|
+
const claudeConfigExists = await isFile(claudeSettingsPath);
|
|
42
|
+
const geminiConfigExists = await isDir(geminiConfigDir);
|
|
43
|
+
const opencodeConfigExists = await isDir(opencodeConfigDir);
|
|
44
|
+
const codexRestore = codexConfigExists
|
|
45
|
+
? await restoreCodexNotify({
|
|
46
|
+
codexConfigPath,
|
|
47
|
+
notifyOriginalPath,
|
|
48
|
+
notifyCmd: codexNotifyCmd
|
|
49
|
+
})
|
|
50
|
+
: { restored: false, skippedReason: 'config-missing' };
|
|
51
|
+
const codeRestore = codeConfigExists
|
|
52
|
+
? await restoreEveryCodeNotify({
|
|
53
|
+
codeConfigPath,
|
|
54
|
+
notifyOriginalPath: codeNotifyOriginalPath,
|
|
55
|
+
notifyCmd: codeNotifyCmd
|
|
56
|
+
})
|
|
57
|
+
: { restored: false, skippedReason: 'config-missing' };
|
|
58
|
+
const claudeRemove = claudeConfigExists
|
|
59
|
+
? await removeClaudeHook({ settingsPath: claudeSettingsPath, hookCommand: claudeHookCommand })
|
|
60
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
61
|
+
const geminiRemove = geminiConfigExists
|
|
62
|
+
? await removeGeminiHook({ settingsPath: geminiSettingsPath, hookCommand: geminiHookCommand })
|
|
63
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
64
|
+
const opencodeRemove = opencodeConfigExists
|
|
65
|
+
? await removeOpencodePlugin({ configDir: opencodeConfigDir })
|
|
66
|
+
: { removed: false, skippedReason: 'config-missing' };
|
|
67
|
+
|
|
68
|
+
// Remove installed notify handler.
|
|
69
|
+
await fs.unlink(notifyPath).catch(() => {});
|
|
70
|
+
|
|
71
|
+
// Remove local app runtime (installed by init for notify-driven sync).
|
|
72
|
+
await fs.rm(path.join(trackerDir, 'app'), { recursive: true, force: true }).catch(() => {});
|
|
73
|
+
|
|
74
|
+
if (opts.purge) {
|
|
75
|
+
await fs.rm(path.join(home, '.vibeusage'), { recursive: true, force: true }).catch(() => {});
|
|
76
|
+
await fs.rm(legacyRootDir, { recursive: true, force: true }).catch(() => {});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
process.stdout.write(
|
|
80
|
+
[
|
|
81
|
+
'Uninstalled:',
|
|
82
|
+
codexConfigExists
|
|
83
|
+
? codexRestore?.restored
|
|
84
|
+
? `- Codex notify restored: ${codexConfigPath}`
|
|
85
|
+
: codexRestore?.skippedReason === 'no-backup-not-installed'
|
|
86
|
+
? '- Codex notify: skipped (no backup; not installed)'
|
|
87
|
+
: '- Codex notify: no change'
|
|
88
|
+
: '- Codex notify: skipped (config.toml not found)',
|
|
89
|
+
codeConfigExists
|
|
90
|
+
? codeRestore?.restored
|
|
91
|
+
? `- Every Code notify restored: ${codeConfigPath}`
|
|
92
|
+
: codeRestore?.skippedReason === 'no-backup-not-installed'
|
|
93
|
+
? '- Every Code notify: skipped (no backup; not installed)'
|
|
94
|
+
: '- Every Code notify: no change'
|
|
95
|
+
: '- Every Code notify: skipped (config.toml not found)',
|
|
96
|
+
claudeConfigExists
|
|
97
|
+
? claudeRemove?.removed
|
|
98
|
+
? `- Claude hooks removed: ${claudeSettingsPath}`
|
|
99
|
+
: claudeRemove?.skippedReason === 'hook-missing'
|
|
100
|
+
? '- Claude hooks: no change'
|
|
101
|
+
: '- Claude hooks: skipped'
|
|
102
|
+
: '- Claude hooks: skipped (settings.json not found)',
|
|
103
|
+
geminiConfigExists
|
|
104
|
+
? geminiRemove?.removed
|
|
105
|
+
? `- Gemini hooks removed: ${geminiSettingsPath}`
|
|
106
|
+
: geminiRemove?.skippedReason === 'hook-missing'
|
|
107
|
+
? '- Gemini hooks: no change'
|
|
108
|
+
: '- Gemini hooks: skipped'
|
|
109
|
+
: `- Gemini hooks: skipped (${geminiConfigDir} not found)`,
|
|
110
|
+
opencodeConfigExists
|
|
111
|
+
? opencodeRemove?.removed
|
|
112
|
+
? `- Opencode plugin removed: ${opencodeConfigDir}`
|
|
113
|
+
: opencodeRemove?.skippedReason === 'plugin-missing'
|
|
114
|
+
? '- Opencode plugin: no change'
|
|
115
|
+
: opencodeRemove?.skippedReason === 'unexpected-content'
|
|
116
|
+
? '- Opencode plugin: skipped (unexpected content)'
|
|
117
|
+
: '- Opencode plugin: skipped'
|
|
118
|
+
: `- Opencode plugin: skipped (${opencodeConfigDir} not found)`,
|
|
119
|
+
opts.purge ? `- Purged: ${path.join(home, '.vibeusage')}` : '- Purge: skipped (use --purge)',
|
|
120
|
+
''
|
|
121
|
+
].join('\n')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseArgs(argv) {
|
|
126
|
+
const out = { purge: false };
|
|
127
|
+
for (let i = 0; i < argv.length; i++) {
|
|
128
|
+
const a = argv[i];
|
|
129
|
+
if (a === '--purge') out.purge = true;
|
|
130
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { cmdUninstall };
|
|
136
|
+
|
|
137
|
+
async function isFile(p) {
|
|
138
|
+
try {
|
|
139
|
+
const st = await fs.stat(p);
|
|
140
|
+
return st.isFile();
|
|
141
|
+
} catch (_e) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function isDir(p) {
|
|
147
|
+
try {
|
|
148
|
+
const st = await fs.stat(p);
|
|
149
|
+
return st.isDirectory();
|
|
150
|
+
} catch (_e) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const http = require('node:http');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const cp = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE_URL = 'https://5tmappuk.us-east.insforge.app';
|
|
6
|
+
|
|
7
|
+
async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
8
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
9
|
+
const callbackPath = `/vibescore/callback/${nonce}`;
|
|
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
|
+
});
|
|
17
|
+
authUrl.searchParams.set('redirect', callbackUrl);
|
|
18
|
+
if (dashboardUrl && baseUrl && baseUrl !== DEFAULT_BASE_URL) authUrl.searchParams.set('base_url', baseUrl);
|
|
19
|
+
|
|
20
|
+
if (open !== false) openInBrowser(authUrl.toString());
|
|
21
|
+
|
|
22
|
+
return { authUrl: authUrl.toString(), waitForCallback };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function startLocalCallbackServer({ callbackPath, timeoutMs, redirectUrl }) {
|
|
26
|
+
let resolved = false;
|
|
27
|
+
let resolveResult;
|
|
28
|
+
let rejectResult;
|
|
29
|
+
|
|
30
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
31
|
+
resolveResult = resolve;
|
|
32
|
+
rejectResult = reject;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const server = http.createServer((req, res) => {
|
|
36
|
+
if (resolved) {
|
|
37
|
+
res.writeHead(409, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
38
|
+
res.end('Already authenticated.\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const method = req.method || 'GET';
|
|
43
|
+
if (method !== 'GET') {
|
|
44
|
+
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
45
|
+
res.end('Method not allowed.\n');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
50
|
+
if (url.pathname !== callbackPath) {
|
|
51
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
52
|
+
res.end('Not found.\n');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const accessToken = url.searchParams.get('access_token') || '';
|
|
57
|
+
if (!accessToken) {
|
|
58
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
59
|
+
res.end('Missing access_token.\n');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
resolved = true;
|
|
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
|
+
}
|
|
92
|
+
|
|
93
|
+
resolveResult({
|
|
94
|
+
accessToken,
|
|
95
|
+
userId: url.searchParams.get('user_id') || null,
|
|
96
|
+
email: url.searchParams.get('email') || null,
|
|
97
|
+
name: url.searchParams.get('name') || null
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await new Promise((resolve, reject) => {
|
|
102
|
+
server.once('error', reject);
|
|
103
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const addr = server.address();
|
|
107
|
+
const port = typeof addr === 'object' && addr ? addr.port : null;
|
|
108
|
+
if (!port) {
|
|
109
|
+
server.close();
|
|
110
|
+
throw new Error('Failed to bind local callback server');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const callbackUrl = `http://127.0.0.1:${port}${callbackPath}`;
|
|
114
|
+
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
if (resolved) return;
|
|
117
|
+
resolved = true;
|
|
118
|
+
rejectResult(new Error('Authentication timed out'));
|
|
119
|
+
server.close();
|
|
120
|
+
}, timeoutMs);
|
|
121
|
+
|
|
122
|
+
async function waitForCallback() {
|
|
123
|
+
try {
|
|
124
|
+
return await resultPromise;
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
server.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { callbackUrl, waitForCallback };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function openInBrowser(url) {
|
|
135
|
+
const platform = process.platform;
|
|
136
|
+
|
|
137
|
+
let cmd = null;
|
|
138
|
+
let args = [];
|
|
139
|
+
|
|
140
|
+
if (platform === 'darwin') {
|
|
141
|
+
cmd = 'open';
|
|
142
|
+
args = [url];
|
|
143
|
+
} else if (platform === 'win32') {
|
|
144
|
+
cmd = 'cmd';
|
|
145
|
+
args = ['/c', 'start', '', url];
|
|
146
|
+
} else {
|
|
147
|
+
cmd = 'xdg-open';
|
|
148
|
+
args = [url];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const child = cp.spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
153
|
+
child.unref();
|
|
154
|
+
} catch (_e) {}
|
|
155
|
+
}
|
|
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
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
beginBrowserAuth,
|
|
174
|
+
openInBrowser
|
|
175
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_EVENT = 'SessionEnd';
|
|
7
|
+
|
|
8
|
+
async function upsertClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
9
|
+
const existing = await readJson(settingsPath);
|
|
10
|
+
const settings = normalizeSettings(existing);
|
|
11
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
12
|
+
const entries = normalizeEntries(hooks[event]);
|
|
13
|
+
|
|
14
|
+
const normalized = normalizeEntriesForCommand(entries, hookCommand);
|
|
15
|
+
if (normalized.changed) {
|
|
16
|
+
const nextHooks = { ...hooks, [event]: normalized.entries };
|
|
17
|
+
const nextSettings = { ...settings, hooks: nextHooks };
|
|
18
|
+
const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
|
|
19
|
+
return { changed: true, backupPath };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (hasHook(entries, hookCommand)) {
|
|
23
|
+
return { changed: false, backupPath: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nextEntries = entries.concat([{ hooks: [{ type: 'command', command: hookCommand }] }]);
|
|
27
|
+
const nextHooks = { ...hooks, [event]: nextEntries };
|
|
28
|
+
const nextSettings = { ...settings, hooks: nextHooks };
|
|
29
|
+
|
|
30
|
+
const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
|
|
31
|
+
return { changed: true, backupPath };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function removeClaudeHook({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
35
|
+
const existing = await readJson(settingsPath);
|
|
36
|
+
if (!existing) return { removed: false, skippedReason: 'settings-missing' };
|
|
37
|
+
|
|
38
|
+
const settings = normalizeSettings(existing);
|
|
39
|
+
const hooks = normalizeHooks(settings.hooks);
|
|
40
|
+
const entries = normalizeEntries(hooks[event]);
|
|
41
|
+
if (entries.length === 0) return { removed: false, skippedReason: 'hook-missing' };
|
|
42
|
+
|
|
43
|
+
let removed = false;
|
|
44
|
+
const nextEntries = [];
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const res = stripHookFromEntry(entry, hookCommand);
|
|
47
|
+
if (res.removed) removed = true;
|
|
48
|
+
if (res.entry) nextEntries.push(res.entry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!removed) return { removed: false, skippedReason: 'hook-missing' };
|
|
52
|
+
|
|
53
|
+
const nextHooks = { ...hooks };
|
|
54
|
+
if (nextEntries.length > 0) nextHooks[event] = nextEntries;
|
|
55
|
+
else delete nextHooks[event];
|
|
56
|
+
|
|
57
|
+
const nextSettings = { ...settings };
|
|
58
|
+
if (Object.keys(nextHooks).length > 0) nextSettings.hooks = nextHooks;
|
|
59
|
+
else delete nextSettings.hooks;
|
|
60
|
+
|
|
61
|
+
const backupPath = await writeClaudeSettings({ settingsPath, settings: nextSettings });
|
|
62
|
+
return { removed: true, skippedReason: null, backupPath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function isClaudeHookConfigured({ settingsPath, hookCommand, event = DEFAULT_EVENT }) {
|
|
66
|
+
const settings = await readJson(settingsPath);
|
|
67
|
+
if (!settings || typeof settings !== 'object') return false;
|
|
68
|
+
const hooks = settings.hooks;
|
|
69
|
+
if (!hooks || typeof hooks !== 'object') return false;
|
|
70
|
+
const entries = normalizeEntries(hooks[event]);
|
|
71
|
+
return hasHook(entries, hookCommand);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildClaudeHookCommand(notifyPath) {
|
|
75
|
+
const cmd = typeof notifyPath === 'string' ? notifyPath : '';
|
|
76
|
+
return `/usr/bin/env node ${quoteArg(cmd)} --source=claude`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeSettings(raw) {
|
|
80
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeHooks(raw) {
|
|
84
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeEntries(raw) {
|
|
88
|
+
return Array.isArray(raw) ? raw.slice() : [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeCommand(cmd) {
|
|
92
|
+
if (Array.isArray(cmd)) return cmd.map((v) => String(v)).join('\u0000');
|
|
93
|
+
if (typeof cmd === 'string') return cmd.trim();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasHook(entries, hookCommand) {
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
100
|
+
if (entry.command && commandsEqual(entry.command, hookCommand)) return true;
|
|
101
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
102
|
+
for (const hook of hooks) {
|
|
103
|
+
if (hook && commandsEqual(hook.command, hookCommand)) return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stripHookFromEntry(entry, hookCommand) {
|
|
110
|
+
if (!entry || typeof entry !== 'object') return { entry, removed: false };
|
|
111
|
+
|
|
112
|
+
if (entry.command) {
|
|
113
|
+
if (commandsEqual(entry.command, hookCommand)) return { entry: null, removed: true };
|
|
114
|
+
return { entry, removed: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hooks = Array.isArray(entry.hooks) ? entry.hooks : null;
|
|
118
|
+
if (!hooks) return { entry, removed: false };
|
|
119
|
+
|
|
120
|
+
const nextHooks = hooks.filter((hook) => !commandsEqual(hook?.command, hookCommand));
|
|
121
|
+
if (nextHooks.length === hooks.length) return { entry, removed: false };
|
|
122
|
+
if (nextHooks.length === 0) return { entry: null, removed: true };
|
|
123
|
+
|
|
124
|
+
return { entry: { ...entry, hooks: nextHooks }, removed: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeEntriesForCommand(entries, hookCommand) {
|
|
128
|
+
let changed = false;
|
|
129
|
+
const nextEntries = entries.map((entry) => {
|
|
130
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
131
|
+
if (entry.command && commandsEqual(entry.command, hookCommand)) {
|
|
132
|
+
if (entry.type !== 'command') {
|
|
133
|
+
changed = true;
|
|
134
|
+
return { ...entry, type: 'command' };
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
if (!Array.isArray(entry.hooks)) return entry;
|
|
139
|
+
let hooksChanged = false;
|
|
140
|
+
const nextHooks = entry.hooks.map((hook) => {
|
|
141
|
+
if (hook && commandsEqual(hook.command, hookCommand)) {
|
|
142
|
+
if (hook.type !== 'command') {
|
|
143
|
+
hooksChanged = true;
|
|
144
|
+
return { ...hook, type: 'command' };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return hook;
|
|
148
|
+
});
|
|
149
|
+
if (!hooksChanged) return entry;
|
|
150
|
+
changed = true;
|
|
151
|
+
return { ...entry, hooks: nextHooks };
|
|
152
|
+
});
|
|
153
|
+
return { entries: nextEntries, changed };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function commandsEqual(a, b) {
|
|
157
|
+
const left = normalizeCommand(a);
|
|
158
|
+
const right = normalizeCommand(b);
|
|
159
|
+
return Boolean(left && right && left === right);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function quoteArg(value) {
|
|
163
|
+
const v = typeof value === 'string' ? value : '';
|
|
164
|
+
if (!v) return '""';
|
|
165
|
+
if (/^[A-Za-z0-9_\-./:@]+$/.test(v)) return v;
|
|
166
|
+
return `"${v.replace(/"/g, '\\"')}"`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writeClaudeSettings({ settingsPath, settings }) {
|
|
170
|
+
await ensureDir(path.dirname(settingsPath));
|
|
171
|
+
let backupPath = null;
|
|
172
|
+
try {
|
|
173
|
+
const st = await fs.stat(settingsPath);
|
|
174
|
+
if (st && st.isFile()) {
|
|
175
|
+
backupPath = `${settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
176
|
+
await fs.copyFile(settingsPath, backupPath);
|
|
177
|
+
}
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
// Ignore missing file.
|
|
180
|
+
}
|
|
181
|
+
await writeJson(settingsPath, settings);
|
|
182
|
+
return backupPath;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
upsertClaudeHook,
|
|
187
|
+
removeClaudeHook,
|
|
188
|
+
isClaudeHookConfigured,
|
|
189
|
+
buildClaudeHookCommand
|
|
190
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const readline = require('node:readline');
|
|
2
|
+
|
|
3
|
+
const RESET = '\x1b[0m';
|
|
4
|
+
const BOLD = '\x1b[1m';
|
|
5
|
+
const DIM = '\x1b[2m';
|
|
6
|
+
const CYAN = '\x1b[36m';
|
|
7
|
+
const GREEN = '\x1b[32m';
|
|
8
|
+
const YELLOW = '\x1b[33m';
|
|
9
|
+
const BLUE = '\x1b[34m';
|
|
10
|
+
const UNDERLINE = '\x1b[4m';
|
|
11
|
+
|
|
12
|
+
const SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
13
|
+
|
|
14
|
+
function isInteractive() {
|
|
15
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatLine(line, width) {
|
|
19
|
+
if (!width) return line;
|
|
20
|
+
const raw = String(line || '');
|
|
21
|
+
const pad = Math.max(0, width - raw.length);
|
|
22
|
+
return raw + ' '.repeat(pad);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderBox(lines, { padding = 1 } = {}) {
|
|
26
|
+
const content = lines.map((line) => String(line || ''));
|
|
27
|
+
const maxLen = content.reduce((max, line) => Math.max(max, line.length), 0);
|
|
28
|
+
const innerWidth = maxLen + padding * 2;
|
|
29
|
+
const top = `+${'-'.repeat(innerWidth)}+`;
|
|
30
|
+
const bottom = `+${'-'.repeat(innerWidth)}+`;
|
|
31
|
+
const body = content.map((line) => {
|
|
32
|
+
const padded = ' '.repeat(padding) + formatLine(line, maxLen) + ' '.repeat(padding);
|
|
33
|
+
return `|${padded}|`;
|
|
34
|
+
});
|
|
35
|
+
return [top, ...body, bottom].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function color(text, token) {
|
|
39
|
+
return `${token}${text}${RESET}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function underline(text) {
|
|
43
|
+
return `${UNDERLINE}${text}${RESET}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function promptEnter(message) {
|
|
47
|
+
if (!isInteractive()) return;
|
|
48
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
49
|
+
await new Promise((resolve) => rl.question(message, () => resolve()));
|
|
50
|
+
rl.close();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function promptMenu({ message, options, defaultIndex = 0 }) {
|
|
54
|
+
if (!isInteractive()) return options[defaultIndex] || options[0];
|
|
55
|
+
|
|
56
|
+
const safeOptions = Array.isArray(options) ? options : [];
|
|
57
|
+
if (safeOptions.length === 0) return '';
|
|
58
|
+
|
|
59
|
+
const maxIndex = safeOptions.length - 1;
|
|
60
|
+
let currentIndex = Math.min(Math.max(defaultIndex, 0), maxIndex);
|
|
61
|
+
const promptMessage = `${message} (Use Up/Down arrows, Enter)`;
|
|
62
|
+
const linesCount = safeOptions.length + 1;
|
|
63
|
+
|
|
64
|
+
const renderLines = () => {
|
|
65
|
+
const lines = [promptMessage];
|
|
66
|
+
safeOptions.forEach((opt, idx) => {
|
|
67
|
+
const prefix = idx === currentIndex ? '>' : ' ';
|
|
68
|
+
lines.push(`${prefix} ${opt}`);
|
|
69
|
+
});
|
|
70
|
+
process.stdout.write(lines.join('\n'));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const rerender = () => {
|
|
74
|
+
for (let i = 0; i < linesCount; i += 1) {
|
|
75
|
+
process.stdout.write('\x1b[2K');
|
|
76
|
+
if (i < linesCount - 1) process.stdout.write('\x1b[1A');
|
|
77
|
+
}
|
|
78
|
+
process.stdout.write('\r');
|
|
79
|
+
renderLines();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
renderLines();
|
|
83
|
+
|
|
84
|
+
return await new Promise((resolve) => {
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
process.stdin.off('keypress', onKeypress);
|
|
87
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
88
|
+
process.stdin.pause();
|
|
89
|
+
process.stdout.write('\n');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const onKeypress = (str, key = {}) => {
|
|
93
|
+
if (key.ctrl && key.name === 'c') {
|
|
94
|
+
cleanup();
|
|
95
|
+
return resolve(safeOptions[currentIndex]);
|
|
96
|
+
}
|
|
97
|
+
if (key.name === 'up' || str === 'k') {
|
|
98
|
+
currentIndex = currentIndex === 0 ? maxIndex : currentIndex - 1;
|
|
99
|
+
rerender();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (key.name === 'down' || str === 'j') {
|
|
103
|
+
currentIndex = currentIndex === maxIndex ? 0 : currentIndex + 1;
|
|
104
|
+
rerender();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (key.name === 'return') {
|
|
108
|
+
cleanup();
|
|
109
|
+
return resolve(safeOptions[currentIndex]);
|
|
110
|
+
}
|
|
111
|
+
if (str && /^[1-9]$/.test(str)) {
|
|
112
|
+
const idx = Number.parseInt(str, 10) - 1;
|
|
113
|
+
if (idx >= 0 && idx <= maxIndex) {
|
|
114
|
+
currentIndex = idx;
|
|
115
|
+
cleanup();
|
|
116
|
+
return resolve(safeOptions[currentIndex]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
readline.emitKeypressEvents(process.stdin);
|
|
122
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
123
|
+
process.stdin.resume();
|
|
124
|
+
process.stdin.on('keypress', onKeypress);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createSpinner({ text, intervalMs = 80 }) {
|
|
129
|
+
let frame = 0;
|
|
130
|
+
let timer = null;
|
|
131
|
+
|
|
132
|
+
function start() {
|
|
133
|
+
if (!isInteractive()) {
|
|
134
|
+
process.stdout.write(`${text}\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
timer = setInterval(() => {
|
|
138
|
+
const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
139
|
+
frame += 1;
|
|
140
|
+
process.stdout.write(`\r${glyph} ${text}`);
|
|
141
|
+
}, intervalMs);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stop(successText) {
|
|
145
|
+
if (timer) clearInterval(timer);
|
|
146
|
+
if (isInteractive()) {
|
|
147
|
+
process.stdout.write(`\r${' '.repeat(text.length + 4)}\r`);
|
|
148
|
+
}
|
|
149
|
+
if (successText) process.stdout.write(`${successText}\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { start, stop };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatSummaryLine({ label, status, detail }) {
|
|
156
|
+
const isSuccess = status === 'updated' || status === 'set' || status === 'installed';
|
|
157
|
+
const bullet = isSuccess ? color('*', GREEN) : 'o';
|
|
158
|
+
const statusLabel = isSuccess ? (detail || status) : detail ? `Skipped - ${detail}` : 'Skipped';
|
|
159
|
+
const line = ` ${bullet} ${label.padEnd(22)} [${statusLabel}]`;
|
|
160
|
+
return isSuccess ? line : color(line, DIM);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
BOLD,
|
|
165
|
+
DIM,
|
|
166
|
+
CYAN,
|
|
167
|
+
GREEN,
|
|
168
|
+
YELLOW,
|
|
169
|
+
BLUE,
|
|
170
|
+
RESET,
|
|
171
|
+
color,
|
|
172
|
+
underline,
|
|
173
|
+
renderBox,
|
|
174
|
+
isInteractive,
|
|
175
|
+
promptMenu,
|
|
176
|
+
promptEnter,
|
|
177
|
+
createSpinner,
|
|
178
|
+
formatSummaryLine
|
|
179
|
+
};
|