@vibescore/tracker 0.2.4 → 0.2.6

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.6",
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,47 @@ 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
+ isInteractive,
38
+ promptMenu,
39
+ createSpinner
40
+ } = require('../lib/cli-ui');
41
+ const { renderLocalReport, renderAuthTransition, renderSuccessBox } = require('../lib/init-flow');
42
+
43
+ const ASCII_LOGO = [
44
+ '██╗ ██╗██╗██████╗ ███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗',
45
+ '██║ ██║██║██╔══██╗██╔════╝██╔════╝██╔════╝ ██╔═══██╗██╔══██╗██╔════╝',
46
+ '██║ ██║██║██████╔╝█████╗ ███████╗██║ ██║ ██║██████╔╝█████╗',
47
+ '╚██╗ ██╔╝██║██╔══██╗██╔══╝ ╚════██║██║ ██║ ██║██╔══██╗██╔══╝',
48
+ ' ╚████╔╝ ██║██████╔╝███████╗███████║╚██████╗ ╚██████╔╝██║ ██║███████╗',
49
+ ' ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝'
50
+ ].join('\n');
51
+
52
+ const DIVIDER = '----------------------------------------------';
30
53
 
31
54
  async function cmdInit(argv) {
32
55
  const opts = parseArgs(argv);
@@ -36,9 +59,6 @@ async function cmdInit(argv) {
36
59
  const trackerDir = path.join(rootDir, 'tracker');
37
60
  const binDir = path.join(rootDir, 'bin');
38
61
 
39
- await ensureDir(trackerDir);
40
- await ensureDir(binDir);
41
-
42
62
  const configPath = path.join(trackerDir, 'config.json');
43
63
  const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
44
64
  const linkCodeStatePath = path.join(trackerDir, 'link_code_state.json');
@@ -49,11 +69,174 @@ async function cmdInit(argv) {
49
69
  const appDir = path.join(trackerDir, 'app');
50
70
  const trackerBinPath = path.join(appDir, 'bin', 'tracker.js');
51
71
 
72
+ renderWelcome();
73
+
74
+ if (opts.dryRun) {
75
+ process.stdout.write(`${color('Dry run: preview only (no changes applied).', DIM)}\n\n`);
76
+ }
77
+
78
+ if (isInteractive() && !opts.yes && !opts.dryRun) {
79
+ const choice = await promptMenu({
80
+ message: '? Proceed with installation?',
81
+ options: ['Yes, configure my environment', 'No, exit'],
82
+ defaultIndex: 0
83
+ });
84
+ const normalizedChoice = String(choice || '').trim().toLowerCase();
85
+ if (normalizedChoice.startsWith('no') || normalizedChoice.includes('exit')) {
86
+ process.stdout.write('Setup cancelled.\n');
87
+ return;
88
+ }
89
+ }
90
+
91
+ if (opts.dryRun) {
92
+ const preview = await buildDryRunSummary({
93
+ opts,
94
+ home,
95
+ trackerDir,
96
+ configPath,
97
+ notifyPath
98
+ });
99
+ renderLocalReport({ summary: preview.summary, isDryRun: true });
100
+ if (preview.pendingBrowserAuth) {
101
+ process.stdout.write('Account linking would be required for full setup.\n');
102
+ } else if (!preview.deviceToken) {
103
+ renderAccountNotLinked({ context: 'dry-run' });
104
+ }
105
+ return;
106
+ }
107
+
108
+ const spinner = createSpinner({ text: 'Analyzing and configuring local environment...' });
109
+ spinner.start();
110
+ let setup;
111
+ try {
112
+ setup = await runSetup({
113
+ opts,
114
+ home,
115
+ baseUrl,
116
+ trackerDir,
117
+ binDir,
118
+ configPath,
119
+ notifyOriginalPath,
120
+ linkCodeStatePath,
121
+ notifyPath,
122
+ appDir,
123
+ trackerBinPath
124
+ });
125
+ } catch (err) {
126
+ spinner.stop();
127
+ throw err;
128
+ }
129
+ spinner.stop();
130
+
131
+ renderLocalReport({ summary: setup.summary, isDryRun: false });
132
+
133
+ let deviceToken = setup.deviceToken;
134
+ let deviceId = setup.deviceId;
135
+
136
+ if (setup.pendingBrowserAuth) {
137
+ const deviceName = opts.deviceName || os.hostname();
138
+ if (!dashboardUrl) dashboardUrl = await detectLocalDashboardUrl();
139
+ const flow = await beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs: 10 * 60_000, open: false });
140
+ const canAutoOpen = !opts.noOpen;
141
+ renderAuthTransition({ authUrl: flow.authUrl, canAutoOpen });
142
+ if (canAutoOpen) {
143
+ if (isInteractive()) await sleep(250);
144
+ openInBrowser(flow.authUrl);
145
+ }
146
+ const callback = await flow.waitForCallback();
147
+ const issued = await issueDeviceTokenWithAccessToken({ baseUrl, accessToken: callback.accessToken, deviceName });
148
+ deviceToken = issued.token;
149
+ deviceId = issued.deviceId;
150
+ await writeJson(configPath, { baseUrl, deviceToken, deviceId, installedAt: setup.installedAt });
151
+ await chmod600IfPossible(configPath);
152
+ const resolvedDashboardUrl = dashboardUrl || null;
153
+ renderSuccessBox({ configPath, dashboardUrl: resolvedDashboardUrl });
154
+ } else if (deviceToken) {
155
+ if (!dashboardUrl) dashboardUrl = await detectLocalDashboardUrl();
156
+ const resolvedDashboardUrl = dashboardUrl || null;
157
+ renderSuccessBox({ configPath, dashboardUrl: resolvedDashboardUrl });
158
+ } else {
159
+ renderAccountNotLinked();
160
+ }
161
+
162
+ try {
163
+ spawnInitSync({ trackerBinPath, packageName: '@vibescore/tracker' });
164
+ } catch (err) {
165
+ const msg = err && err.message ? err.message : 'unknown error';
166
+ process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
167
+ }
168
+ }
169
+
170
+ function renderWelcome() {
171
+ process.stdout.write(
172
+ [
173
+ ASCII_LOGO,
174
+ '',
175
+ `${BOLD}Welcome to VibeScore CLI${RESET}`,
176
+ DIVIDER,
177
+ `${CYAN}Privacy First: Your content stays local. We only upload token counts and minimal metadata, never prompts or responses.${RESET}`,
178
+ DIVIDER,
179
+ '',
180
+ 'This tool will:',
181
+ ' - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode)',
182
+ ' - Set up lightweight hooks to track your flow state',
183
+ ' - Link your device to your VibeScore account',
184
+ '',
185
+ '(Nothing will be changed until you confirm below)',
186
+ ''
187
+ ].join('\n')
188
+ );
189
+ }
190
+
191
+ function renderAccountNotLinked({ context } = {}) {
192
+ if (context === 'dry-run') {
193
+ process.stdout.write(['', 'Account not linked (dry run).', 'Run init without --dry-run to link your account.', ''].join('\n'));
194
+ return;
195
+ }
196
+ process.stdout.write(['', 'Account not linked.', 'Set VIBESCORE_DEVICE_TOKEN then re-run init.', ''].join('\n'));
197
+ }
198
+
199
+ function shouldUseBrowserAuth({ deviceToken, opts }) {
200
+ if (deviceToken) return false;
201
+ if (opts.noAuth) return false;
202
+ if (opts.linkCode) return false;
203
+ if (opts.email || opts.password) return false;
204
+ return true;
205
+ }
206
+
207
+ async function buildDryRunSummary({ opts, home, trackerDir, configPath, notifyPath }) {
208
+ const existingConfig = await readJson(configPath);
209
+ const deviceTokenFromEnv = process.env.VIBESCORE_DEVICE_TOKEN || null;
210
+ const deviceToken = deviceTokenFromEnv || existingConfig?.deviceToken || null;
211
+ const pendingBrowserAuth = shouldUseBrowserAuth({ deviceToken, opts });
212
+ const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
213
+ const summary = await previewIntegrations({ context });
214
+ return { summary, pendingBrowserAuth, deviceToken };
215
+ }
216
+
217
+ async function runSetup({
218
+ opts,
219
+ home,
220
+ baseUrl,
221
+ trackerDir,
222
+ binDir,
223
+ configPath,
224
+ notifyOriginalPath,
225
+ linkCodeStatePath,
226
+ notifyPath,
227
+ appDir,
228
+ trackerBinPath
229
+ }) {
230
+ await ensureDir(trackerDir);
231
+ await ensureDir(binDir);
232
+
52
233
  const existingConfig = await readJson(configPath);
53
234
  const deviceTokenFromEnv = process.env.VIBESCORE_DEVICE_TOKEN || null;
54
235
 
55
236
  let deviceToken = deviceTokenFromEnv || existingConfig?.deviceToken || null;
56
237
  let deviceId = existingConfig?.deviceId || null;
238
+ const installedAt = existingConfig?.installedAt || new Date().toISOString();
239
+ let pendingBrowserAuth = false;
57
240
 
58
241
  await installLocalTrackerApp({ appDir });
59
242
 
@@ -96,21 +279,7 @@ async function cmdInit(argv) {
96
279
  deviceToken = issued.token;
97
280
  deviceId = issued.deviceId;
98
281
  } 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;
282
+ pendingBrowserAuth = true;
114
283
  }
115
284
  }
116
285
 
@@ -118,135 +287,228 @@ async function cmdInit(argv) {
118
287
  baseUrl,
119
288
  deviceToken,
120
289
  deviceId,
121
- installedAt: existingConfig?.installedAt || new Date().toISOString()
290
+ installedAt
122
291
  };
123
292
 
124
293
  await writeJson(configPath, config);
125
294
  await chmod600IfPossible(configPath);
126
295
 
127
- // Install notify handler (non-blocking; chains the previous notify if present).
128
296
  await writeFileAtomic(
129
297
  notifyPath,
130
298
  buildNotifyHandler({ trackerDir, trackerBinPath, packageName: '@vibescore/tracker' })
131
299
  );
132
300
  await fs.chmod(notifyPath, 0o755).catch(() => {});
133
301
 
134
- // Configure Codex notify hook.
302
+ const summary = await applyIntegrationSetup({
303
+ home,
304
+ trackerDir,
305
+ notifyPath,
306
+ notifyOriginalPath
307
+ });
308
+
309
+ return {
310
+ summary,
311
+ pendingBrowserAuth,
312
+ deviceToken,
313
+ deviceId,
314
+ installedAt
315
+ };
316
+ }
317
+
318
+ function buildIntegrationTargets({ home, trackerDir, notifyPath }) {
135
319
  const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
136
320
  const codexConfigPath = path.join(codexHome, 'config.toml');
137
321
  const codeHome = process.env.CODE_HOME || path.join(home, '.code');
138
322
  const codeConfigPath = path.join(codeHome, 'config.toml');
323
+ const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
324
+ const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
139
325
  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
326
+ const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
327
+ const claudeDir = path.join(home, '.claude');
328
+ const claudeSettingsPath = path.join(claudeDir, 'settings.json');
329
+ const claudeHookCommand = buildClaudeHookCommand(notifyPath);
330
+ const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
331
+ const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
332
+ const geminiHookCommand = buildGeminiHookCommand(notifyPath);
333
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
334
+
335
+ return {
336
+ codexConfigPath,
337
+ codeConfigPath,
338
+ notifyOriginalPath,
339
+ codeNotifyOriginalPath,
340
+ notifyCmd,
341
+ codeNotifyCmd,
342
+ claudeDir,
343
+ claudeSettingsPath,
344
+ claudeHookCommand,
345
+ geminiConfigDir,
346
+ geminiSettingsPath,
347
+ geminiHookCommand,
348
+ opencodeConfigDir
349
+ };
350
+ }
351
+
352
+ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOriginalPath }) {
353
+ const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
354
+ context.notifyOriginalPath = notifyOriginalPath;
355
+
356
+ const summary = [];
357
+
358
+ const codexProbe = await probeFile(context.codexConfigPath);
359
+ if (codexProbe.exists) {
360
+ const result = await upsertCodexNotify({
361
+ codexConfigPath: context.codexConfigPath,
362
+ notifyCmd: context.notifyCmd,
363
+ notifyOriginalPath: context.notifyOriginalPath
149
364
  });
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
365
+ summary.push({
366
+ label: 'Codex CLI',
367
+ status: result.changed ? 'updated' : 'set',
368
+ detail: result.changed ? 'Updated config' : 'Config already set'
163
369
  });
164
- codeChained = await loadEveryCodeNotifyOriginal(codeNotifyOriginalPath);
370
+ } else {
371
+ summary.push({ label: 'Codex CLI', status: 'skipped', detail: renderSkipDetail(codexProbe) });
165
372
  }
166
373
 
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;
374
+ const claudeDirExists = await isDir(context.claudeDir);
171
375
  if (claudeDirExists) {
172
- const claudeHookCommand = buildClaudeHookCommand(notifyPath);
173
- claudeResult = await upsertClaudeHook({
174
- settingsPath: claudeSettingsPath,
175
- hookCommand: claudeHookCommand
376
+ await upsertClaudeHook({
377
+ settingsPath: context.claudeSettingsPath,
378
+ hookCommand: context.claudeHookCommand
176
379
  });
380
+ summary.push({ label: 'Claude', status: 'installed', detail: 'Hooks installed' });
381
+ } else {
382
+ summary.push({ label: 'Claude', status: 'skipped', detail: 'Config not found' });
177
383
  }
178
384
 
179
- const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
180
- const geminiConfigExists = await isDir(geminiConfigDir);
181
- const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
182
- let geminiResult = null;
385
+ const geminiConfigExists = await isDir(context.geminiConfigDir);
183
386
  if (geminiConfigExists) {
184
- const geminiHookCommand = buildGeminiHookCommand(notifyPath);
185
- geminiResult = await upsertGeminiHook({
186
- settingsPath: geminiSettingsPath,
187
- hookCommand: geminiHookCommand
387
+ await upsertGeminiHook({
388
+ settingsPath: context.geminiSettingsPath,
389
+ hookCommand: context.geminiHookCommand
188
390
  });
391
+ summary.push({ label: 'Gemini', status: 'installed', detail: 'Hooks installed' });
392
+ } else {
393
+ summary.push({ label: 'Gemini', status: 'skipped', detail: 'Config not found' });
189
394
  }
190
395
 
191
- const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
192
396
  const opencodeResult = await upsertOpencodePlugin({
193
- configDir: opencodeConfigDir,
397
+ configDir: context.opencodeConfigDir,
194
398
  notifyPath
195
399
  });
400
+ if (opencodeResult?.skippedReason === 'config-missing') {
401
+ summary.push({ label: 'Opencode Plugin', status: 'skipped', detail: 'Config not found' });
402
+ } else {
403
+ summary.push({ label: 'Opencode Plugin', status: opencodeResult?.changed ? 'installed' : 'set', detail: 'Plugin installed' });
404
+ }
196
405
 
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
- );
406
+ const codeProbe = await probeFile(context.codeConfigPath);
407
+ if (codeProbe.exists) {
408
+ const result = await upsertEveryCodeNotify({
409
+ codeConfigPath: context.codeConfigPath,
410
+ notifyCmd: context.codeNotifyCmd,
411
+ notifyOriginalPath: context.codeNotifyOriginalPath
412
+ });
413
+ summary.push({
414
+ label: 'Every Code',
415
+ status: result.changed ? 'updated' : 'set',
416
+ detail: result.changed ? 'Updated config' : 'Config already set'
417
+ });
418
+ } else {
419
+ summary.push({ label: 'Every Code', status: 'skipped', detail: renderSkipDetail(codeProbe) });
420
+ }
243
421
 
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`);
422
+ return summary;
423
+ }
424
+
425
+ async function previewIntegrations({ context }) {
426
+ const summary = [];
427
+
428
+ const codexProbe = await probeFile(context.codexConfigPath);
429
+ if (codexProbe.exists) {
430
+ const existing = await readCodexNotify(context.codexConfigPath);
431
+ const matches = arraysEqual(existing, context.notifyCmd);
432
+ summary.push({
433
+ label: 'Codex CLI',
434
+ status: matches ? 'set' : 'updated',
435
+ detail: matches ? 'Already configured' : 'Will update config'
436
+ });
437
+ } else {
438
+ summary.push({ label: 'Codex CLI', status: 'skipped', detail: renderSkipDetail(codexProbe) });
439
+ }
440
+
441
+ const claudeDirExists = await isDir(context.claudeDir);
442
+ if (claudeDirExists) {
443
+ const configured = await isClaudeHookConfigured({
444
+ settingsPath: context.claudeSettingsPath,
445
+ hookCommand: context.claudeHookCommand
446
+ });
447
+ summary.push({
448
+ label: 'Claude',
449
+ status: 'installed',
450
+ detail: configured ? 'Hooks already installed' : 'Will install hooks'
451
+ });
452
+ } else {
453
+ summary.push({ label: 'Claude', status: 'skipped', detail: 'Config not found' });
454
+ }
455
+
456
+ const geminiConfigExists = await isDir(context.geminiConfigDir);
457
+ if (geminiConfigExists) {
458
+ const configured = await isGeminiHookConfigured({
459
+ settingsPath: context.geminiSettingsPath,
460
+ hookCommand: context.geminiHookCommand
461
+ });
462
+ summary.push({
463
+ label: 'Gemini',
464
+ status: 'installed',
465
+ detail: configured ? 'Hooks already installed' : 'Will install hooks'
466
+ });
467
+ } else {
468
+ summary.push({ label: 'Gemini', status: 'skipped', detail: 'Config not found' });
469
+ }
470
+
471
+ const opencodeDirExists = await isDir(context.opencodeConfigDir);
472
+ const installed = await isOpencodePluginInstalled({ configDir: context.opencodeConfigDir });
473
+ const opencodeDetail = installed
474
+ ? 'Plugin already installed'
475
+ : opencodeDirExists
476
+ ? 'Will install plugin'
477
+ : 'Will create config and install plugin';
478
+ summary.push({
479
+ label: 'Opencode Plugin',
480
+ status: 'installed',
481
+ detail: opencodeDetail
482
+ });
483
+
484
+ const codeProbe = await probeFile(context.codeConfigPath);
485
+ if (codeProbe.exists) {
486
+ const existing = await readEveryCodeNotify(context.codeConfigPath);
487
+ const matches = arraysEqual(existing, context.codeNotifyCmd);
488
+ summary.push({
489
+ label: 'Every Code',
490
+ status: matches ? 'set' : 'updated',
491
+ detail: matches ? 'Already configured' : 'Will update config'
492
+ });
493
+ } else {
494
+ summary.push({ label: 'Every Code', status: 'skipped', detail: renderSkipDetail(codeProbe) });
249
495
  }
496
+
497
+ return summary;
498
+ }
499
+
500
+ function renderSkipDetail(probe) {
501
+ if (!probe || probe.reason === 'missing') return 'Config not found';
502
+ if (probe.reason === 'permission-denied') return 'Permission denied';
503
+ if (probe.reason === 'not-file') return 'Invalid config';
504
+ return 'Unavailable';
505
+ }
506
+
507
+ function arraysEqual(a, b) {
508
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
509
+ if (a.length !== b.length) return false;
510
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
511
+ return true;
250
512
  }
251
513
 
252
514
  function parseArgs(argv) {
@@ -258,7 +520,9 @@ function parseArgs(argv) {
258
520
  deviceName: null,
259
521
  linkCode: null,
260
522
  noAuth: false,
261
- noOpen: false
523
+ noOpen: false,
524
+ yes: false,
525
+ dryRun: false
262
526
  };
263
527
 
264
528
  for (let i = 0; i < argv.length; i++) {
@@ -271,14 +535,16 @@ function parseArgs(argv) {
271
535
  else if (a === '--link-code') out.linkCode = argv[++i] || null;
272
536
  else if (a === '--no-auth') out.noAuth = true;
273
537
  else if (a === '--no-open') out.noOpen = true;
538
+ else if (a === '--yes') out.yes = true;
539
+ else if (a === '--dry-run') out.dryRun = true;
274
540
  else throw new Error(`Unknown option: ${a}`);
275
541
  }
276
542
  return out;
277
543
  }
278
544
 
279
- function maskSecret(s) {
280
- if (typeof s !== 'string' || s.length < 8) return '***';
281
- return `${s.slice(0, 4)}…${s.slice(-4)}`;
545
+ function sleep(ms) {
546
+ if (!ms || ms <= 0) return Promise.resolve();
547
+ return new Promise((resolve) => setTimeout(resolve, ms));
282
548
  }
283
549
 
284
550
  function normalizePlatform(value) {
@@ -326,7 +592,8 @@ const signalPath = ${JSON.stringify(queueSignalPath)};
326
592
  const codexOriginalPath = ${JSON.stringify(originalPath)};
327
593
  const codeOriginalPath = ${JSON.stringify(path.join(trackerDir, 'code_notify_original.json'))};
328
594
  const trackerBinPath = ${JSON.stringify(trackerBinPath)};
329
- const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');
595
+ const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');
596
+ const configPath = path.join(trackerDir, 'config.json');
330
597
  const fallbackPkg = ${JSON.stringify(fallbackPkg)};
331
598
  const selfPath = path.resolve(__filename);
332
599
  const home = os.homedir();
@@ -338,11 +605,19 @@ try {
338
605
 
339
606
  // Throttle spawn: at most once per 20 seconds.
340
607
  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) {
608
+ const throttlePath = path.join(trackerDir, 'sync.throttle');
609
+ let deviceToken = process.env.VIBESCORE_DEVICE_TOKEN || null;
610
+ if (!deviceToken) {
611
+ try {
612
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
613
+ if (cfg && typeof cfg.deviceToken === 'string') deviceToken = cfg.deviceToken;
614
+ } catch (_) {}
615
+ }
616
+ const canSync = Boolean(deviceToken && deviceToken.length > 0);
617
+ const now = Date.now();
618
+ let last = 0;
619
+ try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}
620
+ if (canSync && now - last > 20_000) {
346
621
  try { fs.writeFileSync(throttlePath, String(now), 'utf8'); } catch (_) {}
347
622
  const hasLocalRuntime = fs.existsSync(trackerBinPath);
348
623
  const hasLocalDeps = fs.existsSync(depsMarkerPath);
@@ -435,15 +710,6 @@ async function checkUrlReachable(url) {
435
710
  }
436
711
  }
437
712
 
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
713
  async function probeFile(p) {
448
714
  try {
449
715
  const st = await fs.stat(p);
@@ -456,14 +722,6 @@ async function probeFile(p) {
456
722
  }
457
723
  }
458
724
 
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
725
  async function isDir(p) {
468
726
  try {
469
727
  const st = await fs.stat(p);
@@ -508,7 +766,9 @@ function spawnInitSync({ trackerBinPath, packageName }) {
508
766
  });
509
767
  child.on('error', (err) => {
510
768
  const msg = err && err.message ? err.message : 'unknown error';
511
- process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
769
+ const detail = process.env.VIBESCORE_DEBUG === '1' ? ` (${msg})` : '';
770
+ process.stderr.write(`Minor issue: Background sync could not start${detail}.\n`);
771
+ process.stderr.write('Run: npx --yes @vibescore/tracker sync\n');
512
772
  });
513
773
  child.unref();
514
774
  }
@@ -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
+ };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const { formatSummaryLine, renderBox, underline } = require('./cli-ui');
4
+
5
+ const DIVIDER = '----------------------------------------------';
6
+
7
+ function renderLocalReport({ summary, isDryRun }) {
8
+ const header = isDryRun
9
+ ? 'Dry run complete. Preview only; no changes were applied.'
10
+ : 'Local configuration complete.';
11
+ const lines = [header, '', 'Integration Status:'];
12
+ for (const item of summary || []) lines.push(formatSummaryLine(item));
13
+ process.stdout.write(`${lines.join('\n')}\n`);
14
+ }
15
+
16
+ function renderAuthTransition({ authUrl, canAutoOpen }) {
17
+ const lines = ['', DIVIDER, '', 'Next: Registering device...'];
18
+ if (canAutoOpen) {
19
+ lines.push('Opening your browser to link account...');
20
+ if (authUrl) lines.push(`If it does not open, visit: ${underline(authUrl)}`);
21
+ } else {
22
+ lines.push('Open the link below to sign in.');
23
+ if (authUrl) lines.push(`Visit: ${underline(authUrl)}`);
24
+ }
25
+ lines.push('');
26
+ process.stdout.write(`${lines.join('\n')}\n`);
27
+ }
28
+
29
+ function renderSuccessBox({ configPath, dashboardUrl }) {
30
+ const identityLine = 'Account linked.';
31
+ const lines = [
32
+ 'You are all set!',
33
+ '',
34
+ identityLine,
35
+ `Token saved to: ${configPath}`,
36
+ ''
37
+ ];
38
+ if (dashboardUrl) lines.push(`View your stats at: ${dashboardUrl}`);
39
+ lines.push('You can close this terminal window.');
40
+ process.stdout.write(`${renderBox(lines)}\n`);
41
+ }
42
+
43
+ module.exports = {
44
+ DIVIDER,
45
+ renderLocalReport,
46
+ renderAuthTransition,
47
+ renderSuccessBox
48
+ };