@vibescore/tracker 0.2.4 → 0.2.5

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 CHANGED
@@ -33,7 +33,7 @@ _Real-time AI Analytics for Codex CLI_
33
33
 
34
34
  We believe your code and thoughts are your own. VibeScore is built with strict privacy pillars to ensure your data never leaves your control.
35
35
 
36
- - 🛡️ **Zero Code Infiltration**: We never touch your source code or prompts. Our sniffer only extracts numeric token counts (Input, Output, Reasoning, Cached).
36
+ - 🛡️ **No Content Upload**: We never upload prompts or responses. We only compute token counts locally and send counts plus minimal metadata (timestamps, model, device).
37
37
  - 📡 **Local Aggregation**: All token consumption analysis happens on your machine. We only relay quantized 30-minute usage buckets to the cloud.
38
38
  - 🔐 **Hashed Identity**: Device tokens are hashed using SHA-256 server-side. Your raw credentials never exist in our database.
39
39
  - 🔦 **Full Transparency**: Audit the sync logic yourself in `src/lib/rollout.js`. We literally only capture numbers and timestamps.
@@ -63,6 +63,8 @@ Initialize your environment once and forget it. VibeScore handles all synchroniz
63
63
  npx --yes @vibescore/tracker init
64
64
  ```
65
65
 
66
+ Note: `init` shows a consent prompt in interactive shells. Use `--yes` to skip prompts in non-interactive environments.
67
+ Optional: `--dry-run` previews planned changes without writing files.
66
68
  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
69
  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).
68
70
 
@@ -134,6 +136,30 @@ npm install
134
136
  npm run dev
135
137
  ```
136
138
 
139
+ ### Debug Payload (Usage Endpoints)
140
+
141
+ When `debug=1` is included in a usage endpoint request, the response adds a `debug` object that helps the dashboard attribute slow queries without relying on response headers.
142
+
143
+ ```ts
144
+ const res = await fetch(
145
+ `${baseUrl}/functions/vibescore-usage-summary?from=2025-12-30&to=2025-12-30&debug=1`,
146
+ {
147
+ headers: { Authorization: `Bearer ${userJwt}` }
148
+ }
149
+ );
150
+ const data = await res.json();
151
+
152
+ if (data.debug) {
153
+ console.debug('usage debug', {
154
+ requestId: data.debug.request_id,
155
+ status: data.debug.status,
156
+ queryMs: data.debug.query_ms,
157
+ slowThresholdMs: data.debug.slow_threshold_ms,
158
+ slowQuery: data.debug.slow_query
159
+ });
160
+ }
161
+ ```
162
+
137
163
  ### Architecture Validation
138
164
 
139
165
  ```bash
package/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@ _Codex CLI 实时 AI 分析工具_
33
33
 
34
34
  我们坚信你的代码和思想属于你自己。VibeScore 建立在严格的隐私支柱之上,确保你的数据始终处于受控状态。
35
35
 
36
- - 🛡️ **代码零入侵**:我们绝不触碰你的源代码或 Prompt。我们的嗅探器仅提取数值化的 Token 计数(输入、输出、推理、缓存)。
36
+ - 🛡️ **内容不出本地**:我们从不上传 Prompt 或响应内容。只在本地计算 Token 数量,并上传 Token 计数与最小元数据(时间、模型、设备)。
37
37
  - 📡 **本地聚合**:所有 Token 消耗分析均在你的机器上完成。我们仅将量化的 30 分钟使用桶(Usage Buckets)中继到云端。
38
38
  - 🔐 **身份哈希**:设备令牌在服务端使用 SHA-256 进行哈希处理。你的原始凭据绝不会存在于我们的数据库中。
39
39
  - 🔦 **全程透明**:你可以亲自审计 `src/lib/rollout.js` 中的同步逻辑。我们真正采集的只有数字和时间戳。
@@ -63,6 +63,8 @@ _Codex CLI 实时 AI 分析工具_
63
63
  npx --yes @vibescore/tracker init
64
64
  ```
65
65
 
66
+ 说明:交互式终端会显示授权菜单;非交互环境可使用 `--yes` 跳过。
67
+ 可选:`--dry-run` 仅预览将发生的变更,不写入任何文件。
66
68
  说明:若存在 `~/.code/config.toml`(或 `CODE_HOME`),`init` 会自动配置 Every Code 的 `notify`。配置完成后,数据同步完全自动化,无需后续人工干预。
67
69
  说明:若检测到 Gemini CLI home,`init` 会在 `~/.gemini/settings.json` 安装 `SessionEnd` hook,并将 `tools.enableHooks = true` 以确保 hook 生效。这会启用所有 Gemini hooks;如需关闭,可将 `tools.enableHooks = false`(或禁用 `vibescore-tracker` hook)。
68
70
 
@@ -134,6 +136,30 @@ npm install
134
136
  npm run dev
135
137
  ```
136
138
 
139
+ ### 调试字段(Usage 接口)
140
+
141
+ 当请求包含 `debug=1` 时,usage 接口会在响应体中附带 `debug` 对象,方便前端定位慢查询而不依赖响应头。
142
+
143
+ ```ts
144
+ const res = await fetch(
145
+ `${baseUrl}/functions/vibescore-usage-summary?from=2025-12-30&to=2025-12-30&debug=1`,
146
+ {
147
+ headers: { Authorization: `Bearer ${userJwt}` }
148
+ }
149
+ );
150
+ const data = await res.json();
151
+
152
+ if (data.debug) {
153
+ console.debug('usage debug', {
154
+ requestId: data.debug.request_id,
155
+ status: data.debug.status,
156
+ queryMs: data.debug.query_ms,
157
+ slowThresholdMs: data.debug.slow_threshold_ms,
158
+ slowQuery: data.debug.slow_query
159
+ });
160
+ }
161
+ ```
162
+
137
163
  ### 整体架构验证
138
164
 
139
165
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibescore/tracker",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,9 @@
18
18
  "dev:shim": "node scripts/dev-bin-shim.cjs",
19
19
  "validate:copy": "node scripts/validate-copy-registry.cjs",
20
20
  "copy:pull": "node scripts/copy-sync.cjs pull",
21
- "copy:push": "node scripts/copy-sync.cjs push"
21
+ "copy:push": "node scripts/copy-sync.cjs push",
22
+ "architecture:canvas": "node scripts/ops/architecture-canvas.cjs",
23
+ "validate:guardrails": "node scripts/validate-architecture-guardrails.cjs"
22
24
  },
23
25
  "bin": {
24
26
  "tracker": "bin/tracker.js",
package/src/cli.js CHANGED
@@ -40,19 +40,22 @@ function printHelp() {
40
40
  '@vibescore/tracker',
41
41
  '',
42
42
  'Usage:',
43
- ' npx @vibescore/tracker [--debug] init',
43
+ ' npx @vibescore/tracker [--debug] init [--yes] [--dry-run] [--no-open] [--link-code <code>]',
44
44
  ' npx @vibescore/tracker [--debug] sync [--auto] [--drain]',
45
45
  ' npx @vibescore/tracker [--debug] status',
46
46
  ' npx @vibescore/tracker [--debug] diagnostics [--out diagnostics.json]',
47
47
  ' npx @vibescore/tracker [--debug] uninstall [--purge]',
48
48
  '',
49
49
  'Notes:',
50
- ' - init installs a Codex notify hook and issues a device token (default: browser sign in/up).',
51
- ' - optional: pass --link-code <code> to skip browser login when provided by Dashboard.',
52
- ' - when ~/.code/config.toml exists, init also installs an Every Code notify hook.',
53
- ' - optional: set VIBESCORE_DASHBOARD_URL (or --dashboard-url) to use a hosted landing page.',
54
- ' - sync parses ~/.codex/sessions/**/rollout-*.jsonl and ~/.code/sessions/**/rollout-*.jsonl (Every Code), then uploads token_count deltas.',
55
- ' - --debug prints original backend errors when they are normalized.',
50
+ ' - init: consent first, local setup next, browser sign-in last.',
51
+ ' - --yes skips the consent menu (non-interactive safe).',
52
+ ' - --dry-run previews changes without writing files.',
53
+ ' - optional: --link-code <code> skips browser login when provided by Dashboard.',
54
+ ' - Every Code notify installs when ~/.code/config.toml exists.',
55
+ ' - auto sync waits for a device token.',
56
+ ' - optional: VIBESCORE_DASHBOARD_URL or --dashboard-url for hosted landing.',
57
+ ' - sync parses ~/.codex/sessions/**/rollout-*.jsonl and ~/.code/sessions/**/rollout-*.jsonl, then uploads token deltas.',
58
+ ' - --debug shows original backend errors.',
56
59
  ''
57
60
  ].join('\n')
58
61
  );
@@ -9,24 +9,50 @@ const { ensureDir, writeFileAtomic, readJson, writeJson, chmod600IfPossible } =
9
9
  const { prompt, promptHidden } = require('../lib/prompt');
10
10
  const {
11
11
  upsertCodexNotify,
12
- loadCodexNotifyOriginal,
13
12
  upsertEveryCodeNotify,
14
- loadEveryCodeNotifyOriginal
13
+ readCodexNotify,
14
+ readEveryCodeNotify
15
15
  } = require('../lib/codex-config');
16
- const { upsertClaudeHook, buildClaudeHookCommand } = require('../lib/claude-config');
16
+ const { upsertClaudeHook, buildClaudeHookCommand, isClaudeHookConfigured } = require('../lib/claude-config');
17
17
  const {
18
18
  resolveGeminiConfigDir,
19
19
  resolveGeminiSettingsPath,
20
20
  buildGeminiHookCommand,
21
- upsertGeminiHook
21
+ upsertGeminiHook,
22
+ isGeminiHookConfigured
22
23
  } = require('../lib/gemini-config');
23
- const { resolveOpencodeConfigDir, upsertOpencodePlugin } = require('../lib/opencode-config');
24
- const { beginBrowserAuth } = require('../lib/browser-auth');
24
+ const { resolveOpencodeConfigDir, upsertOpencodePlugin, isOpencodePluginInstalled } = require('../lib/opencode-config');
25
+ const { beginBrowserAuth, openInBrowser } = require('../lib/browser-auth');
25
26
  const {
26
27
  issueDeviceTokenWithPassword,
27
28
  issueDeviceTokenWithAccessToken,
28
29
  issueDeviceTokenWithLinkCode
29
30
  } = require('../lib/insforge');
31
+ const {
32
+ BOLD,
33
+ DIM,
34
+ CYAN,
35
+ RESET,
36
+ color,
37
+ underline,
38
+ renderBox,
39
+ isInteractive,
40
+ promptMenu,
41
+ promptEnter,
42
+ createSpinner,
43
+ formatSummaryLine
44
+ } = require('../lib/cli-ui');
45
+
46
+ const ASCII_LOGO = [
47
+ '██╗ ██╗██╗██████╗ ███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗',
48
+ '██║ ██║██║██╔══██╗██╔════╝██╔════╝██╔════╝ ██╔═══██╗██╔══██╗██╔════╝',
49
+ '██║ ██║██║██████╔╝█████╗ ███████╗██║ ██║ ██║██████╔╝█████╗',
50
+ '╚██╗ ██╔╝██║██╔══██╗██╔══╝ ╚════██║██║ ██║ ██║██╔══██╗██╔══╝',
51
+ ' ╚████╔╝ ██║██████╔╝███████╗███████║╚██████╗ ╚██████╔╝██║ ██║███████╗',
52
+ ' ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝'
53
+ ].join('\n');
54
+
55
+ const DIVIDER = '----------------------------------------------';
30
56
 
31
57
  async function cmdInit(argv) {
32
58
  const opts = parseArgs(argv);
@@ -36,9 +62,6 @@ async function cmdInit(argv) {
36
62
  const trackerDir = path.join(rootDir, 'tracker');
37
63
  const binDir = path.join(rootDir, 'bin');
38
64
 
39
- await ensureDir(trackerDir);
40
- await ensureDir(binDir);
41
-
42
65
  const configPath = path.join(trackerDir, 'config.json');
43
66
  const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
44
67
  const linkCodeStatePath = path.join(trackerDir, 'link_code_state.json');
@@ -49,11 +72,210 @@ async function cmdInit(argv) {
49
72
  const appDir = path.join(trackerDir, 'app');
50
73
  const trackerBinPath = path.join(appDir, 'bin', 'tracker.js');
51
74
 
75
+ renderWelcome();
76
+
77
+ if (opts.dryRun) {
78
+ process.stdout.write(`${color('Dry run: preview only (no changes applied).', DIM)}\n\n`);
79
+ }
80
+
81
+ if (isInteractive() && !opts.yes && !opts.dryRun) {
82
+ const choice = await promptMenu({
83
+ message: '? How would you like to proceed?',
84
+ options: ['Start Setup (Recommended)', 'Exit'],
85
+ defaultIndex: 0
86
+ });
87
+ if (choice.toLowerCase().startsWith('exit')) {
88
+ process.stdout.write('Setup cancelled.\n');
89
+ return;
90
+ }
91
+ }
92
+
93
+ if (opts.dryRun) {
94
+ const preview = await buildDryRunSummary({
95
+ opts,
96
+ home,
97
+ trackerDir,
98
+ configPath,
99
+ notifyPath
100
+ });
101
+ renderTransparencyReport({ summary: preview.summary, isDryRun: true });
102
+ if (preview.pendingBrowserAuth) {
103
+ process.stdout.write('Account linking would be required for full setup.\n');
104
+ } else if (!preview.deviceToken) {
105
+ renderAccountNotLinked({ context: 'dry-run' });
106
+ }
107
+ return;
108
+ }
109
+
110
+ const spinner = createSpinner({ text: 'Analyzing and configuring local environment...' });
111
+ spinner.start();
112
+ let setup;
113
+ try {
114
+ setup = await runSetup({
115
+ opts,
116
+ home,
117
+ baseUrl,
118
+ trackerDir,
119
+ binDir,
120
+ configPath,
121
+ notifyOriginalPath,
122
+ linkCodeStatePath,
123
+ notifyPath,
124
+ appDir,
125
+ trackerBinPath
126
+ });
127
+ } catch (err) {
128
+ spinner.stop();
129
+ throw err;
130
+ }
131
+ spinner.stop();
132
+
133
+ renderTransparencyReport({
134
+ summary: setup.summary,
135
+ isDryRun: false,
136
+ includeDivider: setup.pendingBrowserAuth
137
+ });
138
+
139
+ let deviceToken = setup.deviceToken;
140
+ let deviceId = setup.deviceId;
141
+
142
+ if (setup.pendingBrowserAuth) {
143
+ const deviceName = opts.deviceName || os.hostname();
144
+ if (!dashboardUrl) dashboardUrl = await detectLocalDashboardUrl();
145
+ const flow = await beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs: 10 * 60_000, open: false });
146
+ const canAutoOpen = !opts.noOpen;
147
+ renderFinalStep({ authUrl: flow.authUrl, canAutoOpen });
148
+ if (canAutoOpen && isInteractive()) {
149
+ await promptEnter('');
150
+ }
151
+ if (canAutoOpen) {
152
+ if (isInteractive()) await sleep(250);
153
+ openInBrowser(flow.authUrl);
154
+ }
155
+ const callback = await flow.waitForCallback();
156
+ const issued = await issueDeviceTokenWithAccessToken({ baseUrl, accessToken: callback.accessToken, deviceName });
157
+ deviceToken = issued.token;
158
+ deviceId = issued.deviceId;
159
+ await writeJson(configPath, { baseUrl, deviceToken, deviceId, installedAt: setup.installedAt });
160
+ await chmod600IfPossible(configPath);
161
+ renderSuccessBox({ deviceId, configPath });
162
+ } else if (deviceToken) {
163
+ renderSuccessBox({ deviceId, configPath });
164
+ } else {
165
+ renderAccountNotLinked();
166
+ }
167
+
168
+ try {
169
+ spawnInitSync({ trackerBinPath, packageName: '@vibescore/tracker' });
170
+ } catch (err) {
171
+ const msg = err && err.message ? err.message : 'unknown error';
172
+ process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
173
+ }
174
+ }
175
+
176
+ function renderWelcome() {
177
+ process.stdout.write(
178
+ [
179
+ ASCII_LOGO,
180
+ '',
181
+ `${BOLD}Welcome to VibeScore CLI${RESET}`,
182
+ DIVIDER,
183
+ `${CYAN}Privacy First: Your content stays local. We only upload token counts and minimal metadata, never prompts or responses.${RESET}`,
184
+ DIVIDER,
185
+ '',
186
+ 'This tool will:',
187
+ ' - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode)',
188
+ ' - Set up lightweight hooks to track your flow state',
189
+ ' - Link your device to your VibeScore account',
190
+ '',
191
+ '(Nothing will be changed until you confirm below)',
192
+ ''
193
+ ].join('\n')
194
+ );
195
+ }
196
+
197
+ function renderTransparencyReport({ summary, isDryRun, includeDivider = false }) {
198
+ const header = isDryRun ? 'Dry run complete. Preview only; no changes were applied.' : 'Local setup complete.';
199
+ const lines = [header, '', "We've integrated VibeScore with:"];
200
+ for (const item of summary) lines.push(formatSummaryLine(item));
201
+ if (includeDivider) lines.push('', DIVIDER, '');
202
+ process.stdout.write(`${lines.join('\n')}\n`);
203
+ }
204
+
205
+ function renderFinalStep({ authUrl, canAutoOpen }) {
206
+ const lines = [
207
+ 'Final Step: Link your account',
208
+ '',
209
+ canAutoOpen ? 'Press [Enter] to open your browser and sign in.' : 'Open the link below to sign in.'
210
+ ];
211
+ if (authUrl) lines.push(`(Or visit: ${underline(authUrl)})`);
212
+ lines.push('');
213
+ process.stdout.write(lines.join('\n'));
214
+ }
215
+
216
+ function renderSuccessBox({ deviceId, configPath }) {
217
+ const identityLine = deviceId ? `Device ID: ${deviceId}` : 'Account linked.';
218
+ const lines = [
219
+ 'You are all set!',
220
+ '',
221
+ identityLine,
222
+ `Token saved to: ${configPath}`,
223
+ '',
224
+ 'VibeScore is now running in the background.',
225
+ 'You can close this terminal window.'
226
+ ];
227
+ process.stdout.write(`${renderBox(lines)}\n`);
228
+ }
229
+
230
+ function renderAccountNotLinked({ context } = {}) {
231
+ if (context === 'dry-run') {
232
+ process.stdout.write(['', 'Account not linked (dry run).', 'Run init without --dry-run to link your account.', ''].join('\n'));
233
+ return;
234
+ }
235
+ process.stdout.write(['', 'Account not linked.', 'Set VIBESCORE_DEVICE_TOKEN then re-run init.', ''].join('\n'));
236
+ }
237
+
238
+ function shouldUseBrowserAuth({ deviceToken, opts }) {
239
+ if (deviceToken) return false;
240
+ if (opts.noAuth) return false;
241
+ if (opts.linkCode) return false;
242
+ if (opts.email || opts.password) return false;
243
+ return true;
244
+ }
245
+
246
+ async function buildDryRunSummary({ opts, home, trackerDir, configPath, notifyPath }) {
247
+ const existingConfig = await readJson(configPath);
248
+ const deviceTokenFromEnv = process.env.VIBESCORE_DEVICE_TOKEN || null;
249
+ const deviceToken = deviceTokenFromEnv || existingConfig?.deviceToken || null;
250
+ const pendingBrowserAuth = shouldUseBrowserAuth({ deviceToken, opts });
251
+ const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
252
+ const summary = await previewIntegrations({ context });
253
+ return { summary, pendingBrowserAuth, deviceToken };
254
+ }
255
+
256
+ async function runSetup({
257
+ opts,
258
+ home,
259
+ baseUrl,
260
+ trackerDir,
261
+ binDir,
262
+ configPath,
263
+ notifyOriginalPath,
264
+ linkCodeStatePath,
265
+ notifyPath,
266
+ appDir,
267
+ trackerBinPath
268
+ }) {
269
+ await ensureDir(trackerDir);
270
+ await ensureDir(binDir);
271
+
52
272
  const existingConfig = await readJson(configPath);
53
273
  const deviceTokenFromEnv = process.env.VIBESCORE_DEVICE_TOKEN || null;
54
274
 
55
275
  let deviceToken = deviceTokenFromEnv || existingConfig?.deviceToken || null;
56
276
  let deviceId = existingConfig?.deviceId || null;
277
+ const installedAt = existingConfig?.installedAt || new Date().toISOString();
278
+ let pendingBrowserAuth = false;
57
279
 
58
280
  await installLocalTrackerApp({ appDir });
59
281
 
@@ -96,21 +318,7 @@ async function cmdInit(argv) {
96
318
  deviceToken = issued.token;
97
319
  deviceId = issued.deviceId;
98
320
  } else {
99
- if (!dashboardUrl) dashboardUrl = await detectLocalDashboardUrl();
100
- const flow = await beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs: 10 * 60_000, open: !opts.noOpen });
101
- process.stdout.write(
102
- [
103
- '',
104
- 'Connect your account:',
105
- `- Open: ${flow.authUrl}`,
106
- '- Finish sign in/up in your browser, then come back here.',
107
- ''
108
- ].join('\n')
109
- );
110
- const callback = await flow.waitForCallback();
111
- const issued = await issueDeviceTokenWithAccessToken({ baseUrl, accessToken: callback.accessToken, deviceName });
112
- deviceToken = issued.token;
113
- deviceId = issued.deviceId;
321
+ pendingBrowserAuth = true;
114
322
  }
115
323
  }
116
324
 
@@ -118,135 +326,228 @@ async function cmdInit(argv) {
118
326
  baseUrl,
119
327
  deviceToken,
120
328
  deviceId,
121
- installedAt: existingConfig?.installedAt || new Date().toISOString()
329
+ installedAt
122
330
  };
123
331
 
124
332
  await writeJson(configPath, config);
125
333
  await chmod600IfPossible(configPath);
126
334
 
127
- // Install notify handler (non-blocking; chains the previous notify if present).
128
335
  await writeFileAtomic(
129
336
  notifyPath,
130
337
  buildNotifyHandler({ trackerDir, trackerBinPath, packageName: '@vibescore/tracker' })
131
338
  );
132
339
  await fs.chmod(notifyPath, 0o755).catch(() => {});
133
340
 
134
- // Configure Codex notify hook.
341
+ const summary = await applyIntegrationSetup({
342
+ home,
343
+ trackerDir,
344
+ notifyPath,
345
+ notifyOriginalPath
346
+ });
347
+
348
+ return {
349
+ summary,
350
+ pendingBrowserAuth,
351
+ deviceToken,
352
+ deviceId,
353
+ installedAt
354
+ };
355
+ }
356
+
357
+ function buildIntegrationTargets({ home, trackerDir, notifyPath }) {
135
358
  const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
136
359
  const codexConfigPath = path.join(codexHome, 'config.toml');
137
360
  const codeHome = process.env.CODE_HOME || path.join(home, '.code');
138
361
  const codeConfigPath = path.join(codeHome, 'config.toml');
362
+ const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
363
+ const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
139
364
  const notifyCmd = ['/usr/bin/env', 'node', notifyPath];
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
365
+ const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
366
+ const claudeDir = path.join(home, '.claude');
367
+ const claudeSettingsPath = path.join(claudeDir, 'settings.json');
368
+ const claudeHookCommand = buildClaudeHookCommand(notifyPath);
369
+ const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
370
+ const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
371
+ const geminiHookCommand = buildGeminiHookCommand(notifyPath);
372
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
373
+
374
+ return {
375
+ codexConfigPath,
376
+ codeConfigPath,
377
+ notifyOriginalPath,
378
+ codeNotifyOriginalPath,
379
+ notifyCmd,
380
+ codeNotifyCmd,
381
+ claudeDir,
382
+ claudeSettingsPath,
383
+ claudeHookCommand,
384
+ geminiConfigDir,
385
+ geminiSettingsPath,
386
+ geminiHookCommand,
387
+ opencodeConfigDir
388
+ };
389
+ }
390
+
391
+ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOriginalPath }) {
392
+ const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
393
+ context.notifyOriginalPath = notifyOriginalPath;
394
+
395
+ const summary = [];
396
+
397
+ const codexProbe = await probeFile(context.codexConfigPath);
398
+ if (codexProbe.exists) {
399
+ const result = await upsertCodexNotify({
400
+ codexConfigPath: context.codexConfigPath,
401
+ notifyCmd: context.notifyCmd,
402
+ notifyOriginalPath: context.notifyOriginalPath
149
403
  });
150
- chained = await loadCodexNotifyOriginal(notifyOriginalPath);
151
- }
152
- const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
153
- const codeProbe = await probeFile(codeConfigPath);
154
- const codeConfigExists = codeProbe.exists;
155
- let codeResult = null;
156
- let codeChained = null;
157
- if (codeConfigExists) {
158
- const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
159
- codeResult = await upsertEveryCodeNotify({
160
- codeConfigPath,
161
- notifyCmd: codeNotifyCmd,
162
- notifyOriginalPath: codeNotifyOriginalPath
404
+ summary.push({
405
+ label: 'Codex CLI',
406
+ status: result.changed ? 'updated' : 'set',
407
+ detail: result.changed ? 'Updated config' : 'Config already set'
163
408
  });
164
- codeChained = await loadEveryCodeNotifyOriginal(codeNotifyOriginalPath);
409
+ } else {
410
+ summary.push({ label: 'Codex CLI', status: 'skipped', detail: renderSkipDetail(codexProbe) });
165
411
  }
166
412
 
167
- const claudeDir = path.join(home, '.claude');
168
- const claudeSettingsPath = path.join(claudeDir, 'settings.json');
169
- const claudeDirExists = await isDir(claudeDir);
170
- let claudeResult = null;
413
+ const claudeDirExists = await isDir(context.claudeDir);
171
414
  if (claudeDirExists) {
172
- const claudeHookCommand = buildClaudeHookCommand(notifyPath);
173
- claudeResult = await upsertClaudeHook({
174
- settingsPath: claudeSettingsPath,
175
- hookCommand: claudeHookCommand
415
+ await upsertClaudeHook({
416
+ settingsPath: context.claudeSettingsPath,
417
+ hookCommand: context.claudeHookCommand
176
418
  });
419
+ summary.push({ label: 'Claude', status: 'installed', detail: 'Hooks installed' });
420
+ } else {
421
+ summary.push({ label: 'Claude', status: 'skipped', detail: 'Config not found' });
177
422
  }
178
423
 
179
- const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
180
- const geminiConfigExists = await isDir(geminiConfigDir);
181
- const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
182
- let geminiResult = null;
424
+ const geminiConfigExists = await isDir(context.geminiConfigDir);
183
425
  if (geminiConfigExists) {
184
- const geminiHookCommand = buildGeminiHookCommand(notifyPath);
185
- geminiResult = await upsertGeminiHook({
186
- settingsPath: geminiSettingsPath,
187
- hookCommand: geminiHookCommand
426
+ await upsertGeminiHook({
427
+ settingsPath: context.geminiSettingsPath,
428
+ hookCommand: context.geminiHookCommand
188
429
  });
430
+ summary.push({ label: 'Gemini', status: 'installed', detail: 'Hooks installed' });
431
+ } else {
432
+ summary.push({ label: 'Gemini', status: 'skipped', detail: 'Config not found' });
189
433
  }
190
434
 
191
- const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
192
435
  const opencodeResult = await upsertOpencodePlugin({
193
- configDir: opencodeConfigDir,
436
+ configDir: context.opencodeConfigDir,
194
437
  notifyPath
195
438
  });
439
+ if (opencodeResult?.skippedReason === 'config-missing') {
440
+ summary.push({ label: 'Opencode Plugin', status: 'skipped', detail: 'Config not found' });
441
+ } else {
442
+ summary.push({ label: 'Opencode Plugin', status: opencodeResult?.changed ? 'installed' : 'set', detail: 'Plugin installed' });
443
+ }
196
444
 
197
- process.stdout.write(
198
- [
199
- 'Installed:',
200
- `- Tracker config: ${configPath}`,
201
- `- Notify handler: ${notifyPath}`,
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)})`,
214
- codeConfigExists && codeResult
215
- ? codeResult.changed
216
- ? '- Every Code notify: updated'
217
- : '- Every Code notify: already set'
218
- : null,
219
- codeConfigExists
220
- ? codeChained
221
- ? '- Every Code notify: chained (original preserved)'
222
- : '- Every Code notify: no original'
223
- : null,
224
- claudeDirExists
225
- ? claudeResult?.changed
226
- ? `- Claude hooks: updated (${claudeSettingsPath})`
227
- : `- Claude hooks: already set (${claudeSettingsPath})`
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)`,
234
- opencodeResult?.skippedReason === 'config-missing'
235
- ? '- Opencode plugin: skipped (config dir missing)'
236
- : opencodeResult?.changed
237
- ? `- Opencode plugin: updated (${opencodeConfigDir})`
238
- : `- Opencode plugin: already set (${opencodeConfigDir})`,
239
- deviceToken ? `- Device token: stored (${maskSecret(deviceToken)})` : '- Device token: not configured (set VIBESCORE_DEVICE_TOKEN and re-run init)',
240
- ''
241
- ].join('\n')
242
- );
445
+ const codeProbe = await probeFile(context.codeConfigPath);
446
+ if (codeProbe.exists) {
447
+ const result = await upsertEveryCodeNotify({
448
+ codeConfigPath: context.codeConfigPath,
449
+ notifyCmd: context.codeNotifyCmd,
450
+ notifyOriginalPath: context.codeNotifyOriginalPath
451
+ });
452
+ summary.push({
453
+ label: 'Every Code',
454
+ status: result.changed ? 'updated' : 'set',
455
+ detail: result.changed ? 'Updated config' : 'Config already set'
456
+ });
457
+ } else {
458
+ summary.push({ label: 'Every Code', status: 'skipped', detail: renderSkipDetail(codeProbe) });
459
+ }
243
460
 
244
- try {
245
- spawnInitSync({ trackerBinPath, packageName: '@vibescore/tracker' });
246
- } catch (err) {
247
- const msg = err && err.message ? err.message : 'unknown error';
248
- process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
461
+ return summary;
462
+ }
463
+
464
+ async function previewIntegrations({ context }) {
465
+ const summary = [];
466
+
467
+ const codexProbe = await probeFile(context.codexConfigPath);
468
+ if (codexProbe.exists) {
469
+ const existing = await readCodexNotify(context.codexConfigPath);
470
+ const matches = arraysEqual(existing, context.notifyCmd);
471
+ summary.push({
472
+ label: 'Codex CLI',
473
+ status: matches ? 'set' : 'updated',
474
+ detail: matches ? 'Already configured' : 'Will update config'
475
+ });
476
+ } else {
477
+ summary.push({ label: 'Codex CLI', status: 'skipped', detail: renderSkipDetail(codexProbe) });
478
+ }
479
+
480
+ const claudeDirExists = await isDir(context.claudeDir);
481
+ if (claudeDirExists) {
482
+ const configured = await isClaudeHookConfigured({
483
+ settingsPath: context.claudeSettingsPath,
484
+ hookCommand: context.claudeHookCommand
485
+ });
486
+ summary.push({
487
+ label: 'Claude',
488
+ status: 'installed',
489
+ detail: configured ? 'Hooks already installed' : 'Will install hooks'
490
+ });
491
+ } else {
492
+ summary.push({ label: 'Claude', status: 'skipped', detail: 'Config not found' });
493
+ }
494
+
495
+ const geminiConfigExists = await isDir(context.geminiConfigDir);
496
+ if (geminiConfigExists) {
497
+ const configured = await isGeminiHookConfigured({
498
+ settingsPath: context.geminiSettingsPath,
499
+ hookCommand: context.geminiHookCommand
500
+ });
501
+ summary.push({
502
+ label: 'Gemini',
503
+ status: 'installed',
504
+ detail: configured ? 'Hooks already installed' : 'Will install hooks'
505
+ });
506
+ } else {
507
+ summary.push({ label: 'Gemini', status: 'skipped', detail: 'Config not found' });
508
+ }
509
+
510
+ const opencodeDirExists = await isDir(context.opencodeConfigDir);
511
+ const installed = await isOpencodePluginInstalled({ configDir: context.opencodeConfigDir });
512
+ const opencodeDetail = installed
513
+ ? 'Plugin already installed'
514
+ : opencodeDirExists
515
+ ? 'Will install plugin'
516
+ : 'Will create config and install plugin';
517
+ summary.push({
518
+ label: 'Opencode Plugin',
519
+ status: 'installed',
520
+ detail: opencodeDetail
521
+ });
522
+
523
+ const codeProbe = await probeFile(context.codeConfigPath);
524
+ if (codeProbe.exists) {
525
+ const existing = await readEveryCodeNotify(context.codeConfigPath);
526
+ const matches = arraysEqual(existing, context.codeNotifyCmd);
527
+ summary.push({
528
+ label: 'Every Code',
529
+ status: matches ? 'set' : 'updated',
530
+ detail: matches ? 'Already configured' : 'Will update config'
531
+ });
532
+ } else {
533
+ summary.push({ label: 'Every Code', status: 'skipped', detail: renderSkipDetail(codeProbe) });
249
534
  }
535
+
536
+ return summary;
537
+ }
538
+
539
+ function renderSkipDetail(probe) {
540
+ if (!probe || probe.reason === 'missing') return 'Config not found';
541
+ if (probe.reason === 'permission-denied') return 'Permission denied';
542
+ if (probe.reason === 'not-file') return 'Invalid config';
543
+ return 'Unavailable';
544
+ }
545
+
546
+ function arraysEqual(a, b) {
547
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
548
+ if (a.length !== b.length) return false;
549
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
550
+ return true;
250
551
  }
251
552
 
252
553
  function parseArgs(argv) {
@@ -258,7 +559,9 @@ function parseArgs(argv) {
258
559
  deviceName: null,
259
560
  linkCode: null,
260
561
  noAuth: false,
261
- noOpen: false
562
+ noOpen: false,
563
+ yes: false,
564
+ dryRun: false
262
565
  };
263
566
 
264
567
  for (let i = 0; i < argv.length; i++) {
@@ -271,14 +574,16 @@ function parseArgs(argv) {
271
574
  else if (a === '--link-code') out.linkCode = argv[++i] || null;
272
575
  else if (a === '--no-auth') out.noAuth = true;
273
576
  else if (a === '--no-open') out.noOpen = true;
577
+ else if (a === '--yes') out.yes = true;
578
+ else if (a === '--dry-run') out.dryRun = true;
274
579
  else throw new Error(`Unknown option: ${a}`);
275
580
  }
276
581
  return out;
277
582
  }
278
583
 
279
- function maskSecret(s) {
280
- if (typeof s !== 'string' || s.length < 8) return '***';
281
- return `${s.slice(0, 4)}…${s.slice(-4)}`;
584
+ function sleep(ms) {
585
+ if (!ms || ms <= 0) return Promise.resolve();
586
+ return new Promise((resolve) => setTimeout(resolve, ms));
282
587
  }
283
588
 
284
589
  function normalizePlatform(value) {
@@ -326,7 +631,8 @@ const signalPath = ${JSON.stringify(queueSignalPath)};
326
631
  const codexOriginalPath = ${JSON.stringify(originalPath)};
327
632
  const codeOriginalPath = ${JSON.stringify(path.join(trackerDir, 'code_notify_original.json'))};
328
633
  const trackerBinPath = ${JSON.stringify(trackerBinPath)};
329
- const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');
634
+ const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');
635
+ const configPath = path.join(trackerDir, 'config.json');
330
636
  const fallbackPkg = ${JSON.stringify(fallbackPkg)};
331
637
  const selfPath = path.resolve(__filename);
332
638
  const home = os.homedir();
@@ -338,11 +644,19 @@ try {
338
644
 
339
645
  // Throttle spawn: at most once per 20 seconds.
340
646
  try {
341
- const throttlePath = path.join(trackerDir, 'sync.throttle');
342
- const now = Date.now();
343
- let last = 0;
344
- try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}
345
- if (now - last > 20_000) {
647
+ const throttlePath = path.join(trackerDir, 'sync.throttle');
648
+ let deviceToken = process.env.VIBESCORE_DEVICE_TOKEN || null;
649
+ if (!deviceToken) {
650
+ try {
651
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
652
+ if (cfg && typeof cfg.deviceToken === 'string') deviceToken = cfg.deviceToken;
653
+ } catch (_) {}
654
+ }
655
+ const canSync = Boolean(deviceToken && deviceToken.length > 0);
656
+ const now = Date.now();
657
+ let last = 0;
658
+ try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}
659
+ if (canSync && now - last > 20_000) {
346
660
  try { fs.writeFileSync(throttlePath, String(now), 'utf8'); } catch (_) {}
347
661
  const hasLocalRuntime = fs.existsSync(trackerBinPath);
348
662
  const hasLocalDeps = fs.existsSync(depsMarkerPath);
@@ -435,15 +749,6 @@ async function checkUrlReachable(url) {
435
749
  }
436
750
  }
437
751
 
438
- async function isFile(p) {
439
- try {
440
- const st = await fs.stat(p);
441
- return st.isFile();
442
- } catch (_e) {
443
- return false;
444
- }
445
- }
446
-
447
752
  async function probeFile(p) {
448
753
  try {
449
754
  const st = await fs.stat(p);
@@ -456,14 +761,6 @@ async function probeFile(p) {
456
761
  }
457
762
  }
458
763
 
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
-
467
764
  async function isDir(p) {
468
765
  try {
469
766
  const st = await fs.stat(p);
@@ -508,7 +805,9 @@ function spawnInitSync({ trackerBinPath, packageName }) {
508
805
  });
509
806
  child.on('error', (err) => {
510
807
  const msg = err && err.message ? err.message : 'unknown error';
511
- process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
808
+ const detail = process.env.VIBESCORE_DEBUG === '1' ? ` (${msg})` : '';
809
+ process.stderr.write(`Minor issue: Background sync could not start${detail}.\n`);
810
+ process.stderr.write('Run: npx --yes @vibescore/tracker sync\n');
512
811
  });
513
812
  child.unref();
514
813
  }
@@ -170,5 +170,6 @@ function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
170
170
  }
171
171
 
172
172
  module.exports = {
173
- beginBrowserAuth
173
+ beginBrowserAuth,
174
+ openInBrowser
174
175
  };
@@ -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
+ };