@opendatalabs/connect 0.8.1-canary.ff55fb0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +35 -33
  2. package/dist/cli/bin.js +8 -0
  3. package/dist/cli/bin.js.map +1 -1
  4. package/dist/cli/index.d.ts +87 -0
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +3582 -305
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/main.js +8 -0
  10. package/dist/cli/main.js.map +1 -1
  11. package/dist/cli/mcp-server.d.ts +15 -0
  12. package/dist/cli/mcp-server.d.ts.map +1 -0
  13. package/dist/cli/mcp-server.js +199 -0
  14. package/dist/cli/mcp-server.js.map +1 -0
  15. package/dist/cli/queries.d.ts +128 -0
  16. package/dist/cli/queries.d.ts.map +1 -0
  17. package/dist/cli/queries.js +415 -0
  18. package/dist/cli/queries.js.map +1 -0
  19. package/dist/cli/render/capabilities.d.ts +9 -0
  20. package/dist/cli/render/capabilities.d.ts.map +1 -0
  21. package/dist/cli/render/capabilities.js +24 -0
  22. package/dist/cli/render/capabilities.js.map +1 -0
  23. package/dist/cli/render/connect-renderer.d.ts +18 -0
  24. package/dist/cli/render/connect-renderer.d.ts.map +1 -0
  25. package/dist/cli/render/connect-renderer.js +255 -0
  26. package/dist/cli/render/connect-renderer.js.map +1 -0
  27. package/dist/cli/render/format.d.ts +27 -0
  28. package/dist/cli/render/format.d.ts.map +1 -0
  29. package/dist/cli/render/format.js +111 -0
  30. package/dist/cli/render/format.js.map +1 -0
  31. package/dist/cli/render/index.d.ts +7 -0
  32. package/dist/cli/render/index.d.ts.map +1 -0
  33. package/dist/cli/render/index.js +7 -0
  34. package/dist/cli/render/index.js.map +1 -0
  35. package/dist/cli/render/progress.d.ts +11 -0
  36. package/dist/cli/render/progress.d.ts.map +1 -0
  37. package/dist/cli/render/progress.js +56 -0
  38. package/dist/cli/render/progress.js.map +1 -0
  39. package/dist/cli/render/symbols.d.ts +11 -0
  40. package/dist/cli/render/symbols.d.ts.map +1 -0
  41. package/dist/cli/render/symbols.js +21 -0
  42. package/dist/cli/render/symbols.js.map +1 -0
  43. package/dist/cli/render/theme.d.ts +15 -0
  44. package/dist/cli/render/theme.d.ts.map +1 -0
  45. package/dist/cli/render/theme.js +41 -0
  46. package/dist/cli/render/theme.js.map +1 -0
  47. package/dist/cli/search-select.d.ts +17 -0
  48. package/dist/cli/search-select.d.ts.map +1 -0
  49. package/dist/cli/search-select.js +29 -0
  50. package/dist/cli/search-select.js.map +1 -0
  51. package/dist/cli/update-check-worker.d.ts +2 -0
  52. package/dist/cli/update-check-worker.d.ts.map +1 -0
  53. package/dist/cli/update-check-worker.js +55 -0
  54. package/dist/cli/update-check-worker.js.map +1 -0
  55. package/dist/cli/update-check.d.ts +21 -0
  56. package/dist/cli/update-check.d.ts.map +1 -0
  57. package/dist/cli/update-check.js +52 -0
  58. package/dist/cli/update-check.js.map +1 -0
  59. package/dist/connectors/registry.d.ts +27 -1
  60. package/dist/connectors/registry.d.ts.map +1 -1
  61. package/dist/connectors/registry.js +168 -4
  62. package/dist/connectors/registry.js.map +1 -1
  63. package/dist/core/cli-types.d.ts +583 -1
  64. package/dist/core/cli-types.d.ts.map +1 -1
  65. package/dist/core/cli-types.js +262 -1
  66. package/dist/core/cli-types.js.map +1 -1
  67. package/dist/core/index.d.ts +3 -2
  68. package/dist/core/index.d.ts.map +1 -1
  69. package/dist/core/index.js +2 -2
  70. package/dist/core/index.js.map +1 -1
  71. package/dist/core/paths.d.ts +22 -3
  72. package/dist/core/paths.d.ts.map +1 -1
  73. package/dist/core/paths.js +71 -10
  74. package/dist/core/paths.js.map +1 -1
  75. package/dist/core/state-store.d.ts +23 -0
  76. package/dist/core/state-store.d.ts.map +1 -1
  77. package/dist/core/state-store.js +83 -5
  78. package/dist/core/state-store.js.map +1 -1
  79. package/dist/personal-server/client.d.ts +34 -0
  80. package/dist/personal-server/client.d.ts.map +1 -0
  81. package/dist/personal-server/client.js +94 -0
  82. package/dist/personal-server/client.js.map +1 -0
  83. package/dist/personal-server/index.d.ts +10 -0
  84. package/dist/personal-server/index.d.ts.map +1 -1
  85. package/dist/personal-server/index.js +79 -32
  86. package/dist/personal-server/index.js.map +1 -1
  87. package/dist/personal-server/scope-resolver.d.ts +22 -0
  88. package/dist/personal-server/scope-resolver.d.ts.map +1 -0
  89. package/dist/personal-server/scope-resolver.js +68 -0
  90. package/dist/personal-server/scope-resolver.js.map +1 -0
  91. package/dist/runtime/core/contracts.d.ts +84 -0
  92. package/dist/runtime/core/contracts.d.ts.map +1 -0
  93. package/dist/runtime/core/contracts.js +2 -0
  94. package/dist/runtime/core/contracts.js.map +1 -0
  95. package/dist/runtime/core/index.d.ts +2 -0
  96. package/dist/runtime/core/index.d.ts.map +1 -0
  97. package/dist/runtime/core/index.js +2 -0
  98. package/dist/runtime/core/index.js.map +1 -0
  99. package/dist/runtime/index.d.ts +1 -0
  100. package/dist/runtime/index.d.ts.map +1 -1
  101. package/dist/runtime/index.js.map +1 -1
  102. package/dist/runtime/managed-playwright.d.ts +12 -3
  103. package/dist/runtime/managed-playwright.d.ts.map +1 -1
  104. package/dist/runtime/managed-playwright.js +124 -187
  105. package/dist/runtime/managed-playwright.js.map +1 -1
  106. package/dist/runtime/playwright/browser.d.ts +12 -0
  107. package/dist/runtime/playwright/browser.d.ts.map +1 -0
  108. package/dist/runtime/playwright/browser.js +229 -0
  109. package/dist/runtime/playwright/browser.js.map +1 -0
  110. package/dist/runtime/playwright/in-process-run.d.ts +6 -0
  111. package/dist/runtime/playwright/in-process-run.d.ts.map +1 -0
  112. package/dist/runtime/playwright/in-process-run.js +628 -0
  113. package/dist/runtime/playwright/in-process-run.js.map +1 -0
  114. package/dist/runtime/playwright/index.d.ts +3 -0
  115. package/dist/runtime/playwright/index.d.ts.map +1 -0
  116. package/dist/runtime/playwright/index.js +3 -0
  117. package/dist/runtime/playwright/index.js.map +1 -0
  118. package/dist/runtime/repo-paths.d.ts.map +1 -1
  119. package/dist/runtime/repo-paths.js +24 -7
  120. package/dist/runtime/repo-paths.js.map +1 -1
  121. package/dist/skills/index.d.ts +4 -0
  122. package/dist/skills/index.d.ts.map +1 -0
  123. package/dist/skills/index.js +3 -0
  124. package/dist/skills/index.js.map +1 -0
  125. package/dist/skills/paths.d.ts +33 -0
  126. package/dist/skills/paths.d.ts.map +1 -0
  127. package/dist/skills/paths.js +81 -0
  128. package/dist/skills/paths.js.map +1 -0
  129. package/dist/skills/registry.d.ts +48 -0
  130. package/dist/skills/registry.d.ts.map +1 -0
  131. package/dist/skills/registry.js +173 -0
  132. package/dist/skills/registry.js.map +1 -0
  133. package/package.json +34 -9
  134. package/runtime-assets/playwright-runner/entitlements.plist +0 -12
  135. package/runtime-assets/playwright-runner/index.cjs +0 -1011
  136. package/runtime-assets/playwright-runner/package-lock.json +0 -1242
  137. package/runtime-assets/playwright-runner/package.json +0 -29
  138. package/runtime-assets/playwright-runner/scripts/build.js +0 -182
  139. package/runtime-assets/run-connector.cjs +0 -275
package/dist/cli/index.js CHANGED
@@ -1,41 +1,222 @@
1
1
  import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { spawn, execSync } from "node:child_process";
6
+ import os from "node:os";
4
7
  import { confirm, input, password } from "@inquirer/prompts";
5
- import { Command } from "commander";
6
- import { CliOutcomeStatus, getBrowserProfilesDir, getConnectorCacheDir, getLastResultPath, readCliState, updateSourceState, } from "../core/index.js";
7
- import { listAvailableSources } from "../connectors/registry.js";
8
+ import { searchSelect } from "./search-select.js";
9
+ import { Command, CommanderError } from "commander";
10
+ // Vana-branded theme for inquirer prompts — matches brand palette
11
+ const VANA_BLUE = "\x1b[38;2;65;65;252m";
12
+ const VANA_GREEN = "\x1b[38;2;0;213;11m";
13
+ const VANA_MUTED = "\x1b[38;2;112;112;112m";
14
+ const RESET = "\x1b[0m";
15
+ const BOLD = "\x1b[1m";
16
+ const BOLD_RESET = "\x1b[22m";
17
+ const vanaPromptTheme = {
18
+ theme: {
19
+ prefix: { idle: `${VANA_BLUE}?${RESET}`, done: `${VANA_GREEN}✓${RESET}` },
20
+ style: {
21
+ answer: (text) => `${BOLD}${text}${BOLD_RESET}`,
22
+ message: (text, status) => status === "done" ? `${VANA_MUTED}${text}${RESET}` : text,
23
+ highlight: (text) => `${VANA_BLUE}${text}${RESET}`,
24
+ help: (text) => `${VANA_MUTED}${text}${RESET}`,
25
+ error: (text) => `\x1b[38;2;231;0;11m${text}${RESET}`,
26
+ },
27
+ },
28
+ };
29
+ import { createConnectRenderer, createHumanRenderer, formatDisplayPath, formatRelativeTime, } from "./render/index.js";
30
+ import { CliOutcomeStatus, migrateLegacyDataHome, getBrowserProfilesDir, getConnectorCacheDir, getLogsDir, getSessionsDir, getSourceResultPath, readCliState, readCliConfig, updateCliConfig, updateSourceState, } from "../core/index.js";
31
+ import { fetchConnectorToCache, listAvailableSources, readCachedConnectorMetadata, } from "../connectors/registry.js";
8
32
  import { detectPersonalServerTarget, ingestResult, } from "../personal-server/index.js";
9
33
  import { findDataConnectorsDir, ManagedPlaywrightRuntime, } from "../runtime/index.js";
34
+ import { listAvailableSkills, installSkill, readInstalledSkills, } from "../skills/index.js";
35
+ import { queryStatus, querySources, queryDataList, queryDataShow, queryDoctor, } from "./queries.js";
36
+ import { readUpdateCheck, isNewerVersion, spawnUpdateCheck, } from "./update-check.js";
37
+ function cleanDescription(desc) {
38
+ return desc
39
+ .replace(/ using Playwright browser automation\.?/i, ".")
40
+ .replace(/^Exports\b\s*(your\s+)?/i, "Your ");
41
+ }
42
+ const require = createRequire(import.meta.url);
10
43
  export async function runCli(argv = process.argv) {
44
+ // Migrate ~/.dataconnect → ~/.vana, symlink old path for DataConnect compat
45
+ if (migrateLegacyDataHome()) {
46
+ process.stderr.write("Moved your data to ~/.vana.\n\n");
47
+ }
11
48
  const normalizedArgv = normalizeArgv(argv);
49
+ if (normalizedArgv.length <= 2) {
50
+ normalizedArgv.push("--help");
51
+ }
12
52
  const parsedOptions = extractGlobalOptions(normalizedArgv);
53
+ const cliVersion = getCliVersion();
54
+ // Non-blocking update check — compute suppression flags early
55
+ const shouldNotify = !parsedOptions.json &&
56
+ process.stdout.isTTY &&
57
+ !process.env.VANA_NO_UPDATE_NOTIFIER &&
58
+ !process.env.CI &&
59
+ !process.env.AGENT &&
60
+ !process.env.VANA_DETACHED;
61
+ let updateNotice;
62
+ if (shouldNotify) {
63
+ try {
64
+ const cached = await readUpdateCheck();
65
+ if (cached && isNewerVersion(cliVersion, cached.latestVersion)) {
66
+ const lifecycle = getLifecycleCommands(getCliInstallMethod(), getCliChannel());
67
+ updateNotice = `\nUpdate available: ${cliVersion} → ${cached.latestVersion}\nRun: ${lifecycle.upgrade}\n`;
68
+ }
69
+ else if (!cached) {
70
+ // Cache missing or expired — spawn background check
71
+ spawnUpdateCheck(cliVersion, getCliInstallMethod());
72
+ }
73
+ }
74
+ catch {
75
+ // Suppress all errors — update check is purely informational
76
+ }
77
+ }
13
78
  const program = new Command();
14
- program.name("vana").description("Vana Connect CLI");
15
79
  program
16
- .command("connect <source>")
80
+ .name("vana")
81
+ .description("Connect sources, collect data, and inspect it locally.")
82
+ .version(cliVersion, "-v, --version", "Print CLI version")
83
+ .showSuggestionAfterError(true)
84
+ .addHelpText("after", `
85
+ Quick start:
86
+ vana connect Connect a source and collect data
87
+ vana sources Browse available sources
88
+ vana status Check system health
89
+
90
+ Data:
91
+ vana data list List collected datasets
92
+ vana data show <src> Inspect a dataset
93
+
94
+ Server:
95
+ vana server Personal Server status and management
96
+
97
+ Agent:
98
+ vana mcp Start MCP server (for Claude Code, Cursor, etc.)
99
+ vana skills list List available agent skills
100
+ vana skills install Install a skill for your agent
101
+
102
+ Background:
103
+ vana connect <src> --detach Connect in the background
104
+ vana schedule add Schedule daily collection
105
+ vana schedule list Show scheduled tasks
106
+
107
+ More:
108
+ vana doctor Detailed diagnostics
109
+ vana logs [source] View run logs
110
+ vana setup Install or repair runtime
111
+ `);
112
+ program.exitOverride();
113
+ program
114
+ .command("version")
115
+ .description("Print CLI version")
116
+ .option("--json", "Output machine-readable JSON")
117
+ .action(async () => {
118
+ if (parsedOptions.json) {
119
+ process.stdout.write(`${JSON.stringify({
120
+ cliVersion,
121
+ channel: getCliChannel(cliVersion),
122
+ installMethod: getCliInstallMethod(),
123
+ })}\n`);
124
+ process.exitCode = 0;
125
+ return;
126
+ }
127
+ process.stdout.write(`${cliVersion} (${getCliChannel(cliVersion)}, ${formatInstallMethodLabel(getCliInstallMethod()).toLowerCase()})\n`);
128
+ process.exitCode = 0;
129
+ });
130
+ const connectCommand = program
131
+ .command("connect [source]")
132
+ .description("Connect a source and collect data")
17
133
  .option("--json", "Output machine-readable JSON")
18
134
  .option("--no-input", "Fail instead of prompting for input")
135
+ .option("--ipc", "Use file-based IPC for credential prompts (for agents)")
19
136
  .option("--yes", "Approve safe setup prompts automatically")
20
137
  .option("--quiet", "Reduce non-essential output")
138
+ .option("--detach", "Run in the background")
21
139
  .action(async (source) => {
22
- process.exitCode = await runConnect(source, parsedOptions);
140
+ if (parsedOptions.detach && source) {
141
+ process.exitCode = await runDetached("connect", source, parsedOptions);
142
+ return;
143
+ }
144
+ process.exitCode = source
145
+ ? await runConnect(source, parsedOptions)
146
+ : await runConnectEntry(parsedOptions);
23
147
  });
24
- program
25
- .command("sources")
26
- .description("List supported sources")
148
+ connectCommand.addHelpText("after", `
149
+ Examples:
150
+ vana connect
151
+ vana connect github
152
+ vana connect github --json --no-input
153
+ vana connect github --json --ipc
154
+ `);
155
+ const sourcesCommand = program
156
+ .command("sources [source]")
157
+ .description("List supported sources, or show detail for one source")
27
158
  .option("--json", "Output machine-readable JSON")
28
- .action(async () => {
29
- process.exitCode = await runList(parsedOptions);
159
+ .action(async (source) => {
160
+ process.exitCode = source
161
+ ? await runSourceDetail(source, parsedOptions)
162
+ : await runList(parsedOptions);
30
163
  });
31
- program
164
+ sourcesCommand.addHelpText("after", `
165
+ Examples:
166
+ vana sources
167
+ vana sources github
168
+ vana sources --json | jq '.sources'
169
+ `);
170
+ const collectCommand = program
171
+ .command("collect [source]")
172
+ .description("Re-collect data from a previously connected source")
173
+ .option("--json", "Output machine-readable JSON")
174
+ .option("--no-input", "Fail instead of prompting for input")
175
+ .option("--ipc", "Use file-based IPC for credential prompts (for agents)")
176
+ .option("--yes", "Approve safe setup prompts automatically")
177
+ .option("--quiet", "Reduce non-essential output")
178
+ .option("--detach", "Run in the background")
179
+ .option("--all", "Collect from all connected sources")
180
+ .action(async (source) => {
181
+ if (parsedOptions.detach && source) {
182
+ process.exitCode = await runDetached("collect", source, parsedOptions);
183
+ return;
184
+ }
185
+ process.exitCode = source
186
+ ? await runCollect(source, parsedOptions)
187
+ : await runCollectAll(parsedOptions);
188
+ });
189
+ collectCommand.addHelpText("after", `
190
+ Examples:
191
+ vana collect github
192
+ vana collect
193
+ vana collect --json
194
+ `);
195
+ const statusCommand = program
32
196
  .command("status")
33
197
  .description("Show runtime and Personal Server status")
34
198
  .option("--json", "Output machine-readable JSON")
35
199
  .action(async () => {
36
200
  process.exitCode = await runStatus(parsedOptions);
37
201
  });
38
- program
202
+ statusCommand.addHelpText("after", `
203
+ Examples:
204
+ vana status
205
+ vana status --json | jq
206
+ `);
207
+ const doctorCommand = program
208
+ .command("doctor")
209
+ .description("Inspect local CLI, runtime, and install health")
210
+ .option("--json", "Output machine-readable JSON")
211
+ .action(async () => {
212
+ process.exitCode = await runDoctor(parsedOptions);
213
+ });
214
+ doctorCommand.addHelpText("after", `
215
+ Examples:
216
+ vana doctor
217
+ vana doctor --json | jq
218
+ `);
219
+ const setupCommand = program
39
220
  .command("setup")
40
221
  .description("Install or repair the local runtime")
41
222
  .option("--json", "Output machine-readable JSON")
@@ -43,43 +224,264 @@ export async function runCli(argv = process.argv) {
43
224
  .action(async () => {
44
225
  process.exitCode = await runSetup(parsedOptions);
45
226
  });
46
- await program.parseAsync(normalizedArgv);
47
- return Number(process.exitCode ?? 0);
227
+ setupCommand.addHelpText("after", `
228
+ Examples:
229
+ vana setup
230
+ vana setup --yes
231
+ `);
232
+ const data = program
233
+ .command("data")
234
+ .description("Inspect collected datasets, paths, and summaries");
235
+ data.addHelpText("after", `
236
+ Examples:
237
+ vana data list
238
+ vana data show github
239
+ vana data path github --json
240
+ `);
241
+ data.action(() => {
242
+ data.outputHelp();
243
+ process.exitCode = 0;
244
+ });
245
+ const dataListCommand = data
246
+ .command("list")
247
+ .description("List locally available collected datasets")
248
+ .option("--json", "Output machine-readable JSON")
249
+ .action(async () => {
250
+ process.exitCode = await runDataList(parsedOptions);
251
+ });
252
+ dataListCommand.addHelpText("after", `
253
+ Examples:
254
+ vana data list
255
+ vana data list --json | jq '.datasets'
256
+ `);
257
+ const dataShowCommand = data
258
+ .command("show <source>")
259
+ .description("Show a collected dataset")
260
+ .option("--json", "Output machine-readable JSON")
261
+ .action(async (source) => {
262
+ process.exitCode = await runDataShow(source, parsedOptions);
263
+ });
264
+ dataShowCommand.addHelpText("after", `
265
+ Examples:
266
+ vana data show github
267
+ vana data show github --json | jq '.summary'
268
+ `);
269
+ const dataPathCommand = data
270
+ .command("path <source>")
271
+ .description("Print the local path for a collected dataset")
272
+ .option("--json", "Output machine-readable JSON")
273
+ .action(async (source) => {
274
+ process.exitCode = await runDataPath(source, parsedOptions);
275
+ });
276
+ dataPathCommand.addHelpText("after", `
277
+ Examples:
278
+ vana data path github
279
+ vana data path github --json | jq -r '.path'
280
+ `);
281
+ const logsCommand = program
282
+ .command("logs [source]")
283
+ .description("Inspect stored connector run logs")
284
+ .option("--json", "Output machine-readable JSON")
285
+ .action(async (source) => {
286
+ process.exitCode = await runLogs(source, parsedOptions);
287
+ });
288
+ logsCommand.addHelpText("after", `
289
+ Examples:
290
+ vana logs
291
+ vana logs github
292
+ vana logs github --json | jq
293
+ `);
294
+ const server = program
295
+ .command("server")
296
+ .description("Manage Personal Server connection")
297
+ .option("--json", "Output machine-readable JSON");
298
+ server.addHelpText("after", `
299
+ Examples:
300
+ vana server
301
+ vana server set-url http://localhost:8080
302
+ vana server set-url https://ps-abc123.server.vana.org
303
+ vana server clear-url
304
+ `);
305
+ server.action(async () => {
306
+ process.exitCode = await runServerStatus(parsedOptions);
307
+ });
308
+ server
309
+ .command("status")
310
+ .description("Show Personal Server status")
311
+ .option("--json", "Output machine-readable JSON")
312
+ .action(async () => {
313
+ process.exitCode = await runServerStatus(parsedOptions);
314
+ });
315
+ server
316
+ .command("set-url <url>")
317
+ .description("Save a Personal Server URL")
318
+ .option("--json", "Output machine-readable JSON")
319
+ .action(async (url) => {
320
+ process.exitCode = await runServerSetUrl(url, parsedOptions);
321
+ });
322
+ server
323
+ .command("clear-url")
324
+ .description("Remove the saved Personal Server URL")
325
+ .option("--json", "Output machine-readable JSON")
326
+ .action(async () => {
327
+ process.exitCode = await runServerClearUrl(parsedOptions);
328
+ });
329
+ server
330
+ .command("sync")
331
+ .description("Sync all local-only datasets to your Personal Server")
332
+ .option("--json", "Output machine-readable JSON")
333
+ .action(async () => {
334
+ process.exitCode = await runServerSync(parsedOptions);
335
+ });
336
+ server
337
+ .command("data [scope]")
338
+ .description("List scopes stored in your Personal Server")
339
+ .option("--json", "Output machine-readable JSON")
340
+ .action(async (scope) => {
341
+ process.exitCode = await runServerData(scope, parsedOptions);
342
+ });
343
+ program
344
+ .command("mcp")
345
+ .description("Start MCP server for agent integration")
346
+ .action(async () => {
347
+ const { startMcpServer } = await import("./mcp-server.js");
348
+ await startMcpServer();
349
+ });
350
+ const skill = program
351
+ .command("skills")
352
+ .description("Manage agent skills")
353
+ .option("--json", "Output as JSON");
354
+ skill.addHelpText("after", `
355
+ Examples:
356
+ vana skills list
357
+ vana skills install connect-data
358
+ vana skills show connect-data
359
+ `);
360
+ skill.action(async () => {
361
+ process.exitCode = await runSkillsGuidedPicker(parsedOptions);
362
+ });
363
+ skill
364
+ .command("list")
365
+ .description("List available agent skills")
366
+ .option("--json", "Output as JSON")
367
+ .action(async () => {
368
+ process.exitCode = await runSkillList(parsedOptions);
369
+ });
370
+ skill
371
+ .command("install <name>")
372
+ .description("Install a skill for your agent")
373
+ .action(async (name) => {
374
+ process.exitCode = await runSkillInstall(name, parsedOptions);
375
+ });
376
+ skill
377
+ .command("show <name>")
378
+ .description("Show skill details")
379
+ .action(async (name) => {
380
+ process.exitCode = await runSkillShow(name, parsedOptions);
381
+ });
382
+ // --- Schedule commands ---
383
+ const schedule = program
384
+ .command("schedule")
385
+ .description("Manage scheduled data collection");
386
+ schedule.addHelpText("after", `
387
+ Examples:
388
+ vana schedule add
389
+ vana schedule add --every 12h
390
+ vana schedule list
391
+ vana schedule remove
392
+ `);
393
+ schedule.action(() => {
394
+ schedule.outputHelp();
395
+ process.exitCode = 0;
396
+ });
397
+ schedule
398
+ .command("add")
399
+ .description("Add a scheduled collection")
400
+ .option("--every <interval>", "Collection interval (e.g. 24h, 12h, 1h)", "24h")
401
+ .action(async (opts) => {
402
+ process.exitCode = await runScheduleAdd(opts.every, parsedOptions);
403
+ });
404
+ schedule
405
+ .command("list")
406
+ .description("Show scheduled tasks")
407
+ .option("--json", "Output machine-readable JSON")
408
+ .action(async () => {
409
+ process.exitCode = await runScheduleList(parsedOptions);
410
+ });
411
+ schedule
412
+ .command("remove")
413
+ .description("Remove the scheduled collection")
414
+ .action(async () => {
415
+ process.exitCode = await runScheduleRemove(parsedOptions);
416
+ });
417
+ try {
418
+ try {
419
+ await program.parseAsync(normalizedArgv);
420
+ }
421
+ catch (error) {
422
+ if (error instanceof CommanderError) {
423
+ if (error.code === "commander.help" ||
424
+ error.code === "commander.helpDisplayed" ||
425
+ error.code === "commander.version") {
426
+ process.exitCode = error.exitCode;
427
+ return Number(process.exitCode ?? 0);
428
+ }
429
+ // Commander already printed to stderr; just set exit code.
430
+ process.exitCode = error.exitCode;
431
+ return Number(process.exitCode ?? 1);
432
+ }
433
+ throw error;
434
+ }
435
+ return Number(process.exitCode ?? 0);
436
+ }
437
+ finally {
438
+ if (updateNotice)
439
+ process.stderr.write(updateNotice);
440
+ }
48
441
  }
49
- async function runConnect(source, options) {
442
+ async function runConnect(rawSource, options) {
443
+ const source = rawSource.toLowerCase();
50
444
  const runtime = new ManagedPlaywrightRuntime();
51
445
  const emit = createEmitter(options);
446
+ const renderer = !options.json && !options.quiet ? createConnectRenderer() : null;
52
447
  const registrySources = await loadRegistrySources();
53
448
  const sourceLabels = createSourceLabelMap(registrySources);
449
+ const displayName = displaySource(source, sourceLabels);
54
450
  let setupLogPath;
55
451
  let fetchLogPath;
56
452
  let runLogPath;
453
+ let terminalExitCode = null;
57
454
  try {
58
- emit.info(`Finding a connector for ${displaySource(source, sourceLabels)}...`);
455
+ // Title
456
+ renderer?.title(displayName);
59
457
  const target = await detectPersonalServerTarget();
458
+ // --- Phase 1: Runtime check (silent if installed) ---
60
459
  if (runtime.state !== "installed") {
61
- emit.info(`Vana Connect needs a local browser runtime before it can connect ${displaySource(source, sourceLabels)}.`);
62
- emit.info("");
63
- emit.info("This will install:");
64
- emit.info("- the connector runner");
65
- emit.info("- a Chromium browser engine");
66
- emit.info("- local runtime files under ~/.dataconnect/");
67
- emit.info("");
68
- emit.info("Your credentials stay on this machine. Nothing is sent anywhere except the platform you’re connecting to.");
460
+ if (options.noInput) {
461
+ emit.event({
462
+ type: "outcome",
463
+ status: CliOutcomeStatus.SETUP_REQUIRED,
464
+ source,
465
+ });
466
+ renderer?.fail(`${displayName} needs a local browser runtime. Run without --no-input to install.`);
467
+ return 1;
468
+ }
69
469
  if (!options.yes) {
70
- if (options.noInput) {
71
- emit.event({
72
- type: "outcome",
73
- status: CliOutcomeStatus.SETUP_REQUIRED,
74
- source,
75
- });
76
- return 1;
77
- }
470
+ renderer?.cleanup();
471
+ process.stderr.write("\n");
472
+ process.stderr.write("Vana Connect needs a local browser runtime.\n\n");
473
+ process.stderr.write("This will install:\n");
474
+ process.stderr.write(" \u2022 Connector runner\n");
475
+ process.stderr.write(" \u2022 Chromium browser engine\n");
476
+ process.stderr.write(" \u2022 Local files under ~/.vana/\n\n");
477
+ process.stderr.write("Your credentials stay on this machine.\n\n");
78
478
  const shouldContinue = await confirm({
79
479
  message: "Continue?",
80
480
  default: true,
481
+ ...vanaPromptTheme,
81
482
  });
82
483
  if (!shouldContinue) {
484
+ renderer?.fail("Cancelled.");
83
485
  emit.event({
84
486
  type: "outcome",
85
487
  status: CliOutcomeStatus.SETUP_REQUIRED,
@@ -88,6 +490,7 @@ async function runConnect(source, options) {
88
490
  });
89
491
  return 1;
90
492
  }
493
+ process.stderr.write("\n");
91
494
  }
92
495
  const installResult = await runtime.ensureInstalled(Boolean(options.yes));
93
496
  setupLogPath = installResult.logPath;
@@ -96,10 +499,7 @@ async function runConnect(source, options) {
96
499
  runtime: installResult.runtime,
97
500
  logPath: installResult.logPath,
98
501
  });
99
- emit.info("Runtime ready.");
100
- if (installResult.logPath) {
101
- emit.info(`Setup log: ${installResult.logPath}`);
102
- }
502
+ renderer?.scopeDone("Runtime ready");
103
503
  }
104
504
  else {
105
505
  emit.event({
@@ -107,7 +507,82 @@ async function runConnect(source, options) {
107
507
  runtime: runtime.state,
108
508
  });
109
509
  }
110
- const fetched = await runtime.fetchConnector(source);
510
+ // --- Phase 2: Connector fetch (silent if cached/fast) ---
511
+ const preState = await readCliState();
512
+ const currentVersion = preState.sources[source]?.connectorVersion;
513
+ let fetched;
514
+ try {
515
+ fetched = await runtime.fetchConnector(source, currentVersion);
516
+ }
517
+ catch (firstError) {
518
+ const firstMessage = firstError instanceof Error ? firstError.message : "";
519
+ const isChecksumError = firstMessage.toLowerCase().includes("checksum") ||
520
+ firstMessage.toLowerCase().includes("mismatch");
521
+ // Auto-retry on stale cache: clear cached connector and re-fetch
522
+ // from remote (skip local data-connectors dir which may be stale).
523
+ if (isChecksumError) {
524
+ try {
525
+ const cacheDir = getConnectorCacheDir();
526
+ const sourceCacheDir = path.join(cacheDir, source);
527
+ await fsp.rm(sourceCacheDir, { recursive: true, force: true });
528
+ const resolution = await fetchConnectorToCache(source, cacheDir, undefined);
529
+ fetched = {
530
+ connectorPath: resolution.connectorPath,
531
+ logPath: "",
532
+ version: resolution.version,
533
+ };
534
+ }
535
+ catch (retryError) {
536
+ const retryMessage = retryError instanceof Error
537
+ ? retryError.message
538
+ : `Could not fetch ${displayName} connector.`;
539
+ const message = formatHumanSourceMessage(retryMessage, source, displayName);
540
+ await updateSourceState(source, {
541
+ connectorInstalled: false,
542
+ lastRunAt: new Date().toISOString(),
543
+ lastRunOutcome: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
544
+ dataState: "none",
545
+ lastError: message,
546
+ lastResultPath: null,
547
+ lastLogPath: getErrorLogPath(retryError),
548
+ });
549
+ renderer?.fail(`${displayName} connector could not be verified.`);
550
+ renderer?.detail(`Try again later, or report: https://github.com/vana-com/data-connectors/issues`);
551
+ emit.event({
552
+ type: "outcome",
553
+ status: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
554
+ source,
555
+ reason: message,
556
+ });
557
+ return 1;
558
+ }
559
+ }
560
+ else {
561
+ const message = formatHumanSourceMessage(firstMessage ||
562
+ `No connector is available for ${displayName} right now.`, source, displayName);
563
+ await updateSourceState(source, {
564
+ connectorInstalled: false,
565
+ lastRunAt: new Date().toISOString(),
566
+ lastRunOutcome: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
567
+ dataState: "none",
568
+ lastError: message,
569
+ lastResultPath: null,
570
+ lastLogPath: getErrorLogPath(firstError),
571
+ });
572
+ renderer?.fail(`${displayName} is not available.`);
573
+ renderer?.detail(`See what’s ready: vana sources`);
574
+ emit.event({
575
+ type: "outcome",
576
+ status: CliOutcomeStatus.CONNECTOR_UNAVAILABLE,
577
+ source,
578
+ reason: message,
579
+ });
580
+ return 1;
581
+ }
582
+ }
583
+ if (fetched.updated && fetched.previousVersion) {
584
+ emit.detail(`Updated connector (${fetched.previousVersion} → ${fetched.version}).`);
585
+ }
111
586
  fetchLogPath = fetched.logPath;
112
587
  const sourceDetails = registrySources.find((item) => item.id === source);
113
588
  const resolution = {
@@ -120,84 +595,161 @@ async function runConnect(source, options) {
120
595
  connectorPath: resolution.connectorPath,
121
596
  logPath: fetched.logPath,
122
597
  });
123
- emit.info("Connector ready.");
124
- if (sourceDetails?.description) {
125
- emit.info(sourceDetails.description);
126
- }
598
+ // --- Phase 3: Pre-connection validation (silent) ---
127
599
  const profilePath = path.join(getBrowserProfilesDir(), `${path.basename(resolution.connectorPath, path.extname(resolution.connectorPath))}`);
128
- if (fs.existsSync(profilePath)) {
129
- emit.info(`Found an existing ${displaySource(source, sourceLabels)} session. Trying that first...`);
600
+ if (sourceDetails?.authMode === "legacy" &&
601
+ !options.noInput &&
602
+ process.platform === "linux" &&
603
+ !process.env.DISPLAY &&
604
+ !process.env.WAYLAND_DISPLAY) {
605
+ const message = "This source needs a manual browser step, but no local display server is available.";
606
+ await updateSourceState(resolution.source, {
607
+ connectorInstalled: true,
608
+ sessionPresent: fs.existsSync(profilePath),
609
+ lastRunAt: new Date().toISOString(),
610
+ lastRunOutcome: CliOutcomeStatus.LEGACY_AUTH,
611
+ dataState: "none",
612
+ lastError: message,
613
+ lastResultPath: null,
614
+ lastLogPath: fetchLogPath ?? null,
615
+ });
616
+ renderer?.fail(`${displayName} requires a browser window, but no display is available.`);
617
+ renderer?.detail("Run this command in a desktop terminal.");
618
+ emit.event({
619
+ type: "outcome",
620
+ status: CliOutcomeStatus.LEGACY_AUTH,
621
+ source: resolution.source,
622
+ reason: "display_server_unavailable",
623
+ });
624
+ return 1;
130
625
  }
131
626
  await updateSourceState(resolution.source, {
132
627
  connectorInstalled: true,
133
628
  sessionPresent: fs.existsSync(profilePath),
134
629
  lastError: null,
630
+ lastLogPath: fetchLogPath ?? null,
135
631
  });
136
- emit.info(`Connecting to ${displaySource(source, sourceLabels)}...`);
137
- emit.info("Collecting your data...");
632
+ // --- Phase 4-5: Authentication + Collection ---
138
633
  let finalStatus = CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR;
139
- let resultPath = getLastResultPath();
634
+ let finalDataState = "none";
635
+ let ingestFailureMessage = null;
636
+ let resultPath = getSourceResultPath(source);
140
637
  let collectedResult = false;
141
- for await (const event of runtime.runConnector({
142
- connectorPath: resolution.connectorPath,
143
- source: resolution.source,
144
- noInput: options.noInput,
145
- onNeedInput: async (needInput) => {
146
- emit.info("");
147
- emit.info(`To connect ${displaySource(source, sourceLabels)}, Vana Connect will open a local browser session on this machine.`);
148
- emit.info("Your credentials stay local.");
149
- emit.info("");
150
- emit.info(needInput.message ??
151
- `${displaySource(source, sourceLabels)} needs additional details to continue.`);
638
+ let ingestScopeResults;
639
+ // In IPC mode (--ipc), don’t provide an interactive callback.
640
+ // The runtime will write a pending-input file and poll for the
641
+ // response, letting an external agent handle credential collection.
642
+ const interactiveCallback = options.ipc
643
+ ? undefined
644
+ : async (needInput) => {
645
+ renderer?.pauseForPrompt();
646
+ // Show connector’s prompt message
647
+ if (renderer) {
648
+ const promptMessage = needInput.message ?? `${displayName} needs your login.`;
649
+ process.stderr.write(`\n${promptMessage}\n\n`);
650
+ }
152
651
  const values = {};
153
- for (const field of needInput.fields) {
154
- if (field.toLowerCase().includes("password")) {
155
- values[field] = await password({ message: humanizeField(field) });
652
+ try {
653
+ for (const field of needInput.fields) {
654
+ const isPasswordField = field.toLowerCase().includes("password");
655
+ if (isPasswordField) {
656
+ values[field] = await password({
657
+ message: humanizeField(field),
658
+ ...vanaPromptTheme,
659
+ });
660
+ }
661
+ else {
662
+ values[field] = await input({
663
+ message: humanizeField(field),
664
+ ...vanaPromptTheme,
665
+ });
666
+ }
156
667
  }
157
- else {
158
- values[field] = await input({ message: humanizeField(field) });
668
+ }
669
+ catch (error) {
670
+ if (isPromptCancelled(error)) {
671
+ throw new Error("__vana_prompt_cancelled__");
159
672
  }
673
+ throw error;
674
+ }
675
+ if (renderer) {
676
+ process.stderr.write("\n");
160
677
  }
678
+ renderer?.resumeAfterPrompt();
161
679
  return values;
162
- },
680
+ };
681
+ for await (const event of runtime.runConnector({
682
+ connectorPath: resolution.connectorPath,
683
+ source: resolution.source,
684
+ noInput: options.ipc ? false : options.noInput,
685
+ onNeedInput: interactiveCallback,
163
686
  })) {
164
687
  emit.event(event);
165
688
  if (event.logPath) {
166
689
  runLogPath = event.logPath;
167
690
  }
691
+ if (terminalExitCode !== null) {
692
+ continue;
693
+ }
168
694
  if (event.type === "needs-input") {
169
695
  await updateSourceState(resolution.source, {
170
696
  lastRunAt: new Date().toISOString(),
171
697
  lastRunOutcome: CliOutcomeStatus.NEEDS_INPUT,
172
698
  lastError: event.message ?? "Input required.",
699
+ lastLogPath: event.logPath,
700
+ connectionHealth: "needs_reauth",
173
701
  });
174
702
  emit.event({
175
703
  type: "outcome",
176
704
  status: CliOutcomeStatus.NEEDS_INPUT,
177
705
  source: resolution.source,
178
706
  });
179
- if (!options.json) {
180
- emit.info(`${displaySource(source, sourceLabels)} needs additional input before it can connect.`);
181
- emit.info(`Next: run \`vana connect ${source}\` without \`--no-input\`.`);
707
+ renderer?.fail(`${displayName} needs credentials. Run without --no-input to authenticate.`);
708
+ terminalExitCode = 1;
709
+ continue;
710
+ }
711
+ if (event.type === "progress-update") {
712
+ // Drive the renderer with scope information from the event
713
+ const scopeName = extractScopeName(event);
714
+ if (scopeName && renderer) {
715
+ const isComplete = typeof event.message === "string" &&
716
+ /^complete\b/i.test(event.message.trim());
717
+ if (isComplete) {
718
+ const detail = formatScopeDetail(event);
719
+ renderer.scopeDone(scopeName, detail);
720
+ }
721
+ else {
722
+ renderer.scopeActive(scopeName);
723
+ }
182
724
  }
183
- return 1;
725
+ continue;
726
+ }
727
+ if (event.type === "status-update") {
728
+ // Status updates are silent in the new design
729
+ continue;
184
730
  }
185
731
  if (event.type === "runtime-error") {
186
732
  await updateSourceState(resolution.source, {
187
733
  lastRunAt: new Date().toISOString(),
188
734
  lastRunOutcome: CliOutcomeStatus.RUNTIME_ERROR,
189
735
  lastError: event.message ?? "Connector run failed.",
736
+ lastLogPath: event.logPath,
737
+ connectionHealth: "error",
190
738
  });
191
- emit.info(event.message ?? "Connector run failed.");
739
+ renderer?.fail(`Problem connecting ${displayName}.`);
740
+ renderer?.detail(event.message ?? "Connector run failed.");
741
+ renderer?.detail(`Retry: vana connect ${source}`);
192
742
  emit.event({
193
743
  type: "outcome",
194
744
  status: CliOutcomeStatus.RUNTIME_ERROR,
195
745
  source: resolution.source,
196
746
  });
197
- if (event.logPath) {
198
- emit.info(`Run log: ${event.logPath}`);
199
- }
200
- return 1;
747
+ terminalExitCode = 1;
748
+ continue;
749
+ }
750
+ if (event.type === "headed-required") {
751
+ // Silent — the browser opens automatically
752
+ continue;
201
753
  }
202
754
  if (event.type === "legacy-auth") {
203
755
  await updateSourceState(resolution.source, {
@@ -205,33 +757,97 @@ async function runConnect(source, options) {
205
757
  lastRunOutcome: CliOutcomeStatus.LEGACY_AUTH,
206
758
  lastError: event.message ?? "Legacy authentication is required.",
207
759
  dataState: "none",
760
+ lastResultPath: null,
761
+ lastLogPath: event.logPath,
762
+ connectionHealth: "needs_reauth",
208
763
  });
209
- emit.info(event.message ??
210
- "This connector requires legacy headed authentication that is not available in batch mode.");
211
- emit.info(`Next: establish a reusable ${displaySource(source, sourceLabels)} session manually, or migrate this connector to requestInput.`);
212
- if (event.logPath) {
213
- emit.info(`Run log: ${event.logPath}`);
214
- }
764
+ renderer?.fail(`Manual step required for ${displayName}.`);
765
+ renderer?.detail(`Complete the browser step locally, then rerun vana connect ${source}.`);
215
766
  emit.event({
216
767
  type: "outcome",
217
768
  status: CliOutcomeStatus.LEGACY_AUTH,
218
769
  source: resolution.source,
219
770
  });
220
- return 1;
771
+ terminalExitCode = 1;
772
+ continue;
221
773
  }
222
774
  if (event.type === "collection-complete" && event.resultPath) {
775
+ // Check if the result is actually an error object
776
+ try {
777
+ const raw = await fsp.readFile(event.resultPath, "utf8");
778
+ const parsed = JSON.parse(raw);
779
+ if (parsed &&
780
+ typeof parsed === "object" &&
781
+ "error" in parsed &&
782
+ Object.keys(parsed).length <= 2) {
783
+ // Connector returned an error, not real data
784
+ await updateSourceState(source, {
785
+ lastRunAt: new Date().toISOString(),
786
+ lastRunOutcome: CliOutcomeStatus.RUNTIME_ERROR,
787
+ connectionHealth: "error",
788
+ lastError: typeof parsed.error === "string"
789
+ ? parsed.error
790
+ : "Collection returned an error",
791
+ lastLogPath: runLogPath ?? fetchLogPath,
792
+ });
793
+ renderer?.fail(`Problem connecting ${displayName}.`);
794
+ renderer?.detail(typeof parsed.error === "string"
795
+ ? parsed.error
796
+ : "The connector returned an error instead of data.");
797
+ emit.event({
798
+ type: "outcome",
799
+ status: CliOutcomeStatus.RUNTIME_ERROR,
800
+ source,
801
+ });
802
+ terminalExitCode = 1;
803
+ continue;
804
+ }
805
+ }
806
+ catch {
807
+ // Can't read/parse result — proceed normally, let downstream handle it
808
+ }
223
809
  collectedResult = true;
224
810
  resultPath = event.resultPath;
225
811
  const ingestEvents = await ingestResult(resolution.source, resultPath, target);
226
812
  for (const ingestEvent of ingestEvents) {
227
813
  emit.event(ingestEvent);
228
814
  }
815
+ const scopeResults = ingestEvents.find((e) => e.type === "ingest-complete" ||
816
+ e.type === "ingest-partial" ||
817
+ e.type === "ingest-failed")?.scopeResults;
229
818
  const ingestCompleted = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-complete");
230
- finalStatus = ingestCompleted
231
- ? CliOutcomeStatus.CONNECTED_AND_INGESTED
232
- : CliOutcomeStatus.CONNECTED_LOCAL_ONLY;
819
+ const ingestPartial = ingestEvents.some((ingestEvent) => ingestEvent.type === "ingest-partial");
820
+ const ingestFailedEvent = ingestEvents.find((ingestEvent) => ingestEvent.type === "ingest-failed");
821
+ if (ingestCompleted) {
822
+ finalStatus = CliOutcomeStatus.CONNECTED_AND_INGESTED;
823
+ finalDataState = "ingested_personal_server";
824
+ }
825
+ else if (ingestPartial) {
826
+ finalStatus = CliOutcomeStatus.CONNECTED_AND_INGESTED;
827
+ finalDataState = "ingested_personal_server";
828
+ }
829
+ else if (ingestFailedEvent?.type === "ingest-failed") {
830
+ finalStatus = CliOutcomeStatus.INGEST_FAILED;
831
+ finalDataState = "ingest_failed";
832
+ ingestFailureMessage =
833
+ ingestFailedEvent.message ?? "Personal Server sync failed.";
834
+ }
835
+ else {
836
+ finalStatus = CliOutcomeStatus.CONNECTED_LOCAL_ONLY;
837
+ finalDataState = "collected_local";
838
+ }
839
+ // Store per-scope results in state
840
+ ingestScopeResults = scopeResults?.map((r) => ({
841
+ scope: r.scope,
842
+ status: r.status,
843
+ syncedAt: r.status === "stored" ? new Date().toISOString() : undefined,
844
+ error: r.error,
845
+ }));
233
846
  }
234
847
  }
848
+ if (terminalExitCode !== null) {
849
+ return terminalExitCode;
850
+ }
235
851
  if (!collectedResult) {
236
852
  await updateSourceState(resolution.source, {
237
853
  connectorInstalled: true,
@@ -240,51 +856,87 @@ async function runConnect(source, options) {
240
856
  lastRunOutcome: CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR,
241
857
  dataState: "none",
242
858
  lastError: "Connector run ended without a result.",
859
+ lastResultPath: null,
860
+ lastLogPath: runLogPath ?? fetchLogPath ?? null,
243
861
  });
862
+ renderer?.fail(`Problem connecting ${displayName}.`);
863
+ renderer?.detail("Connector run ended without a result.");
244
864
  emit.event({
245
865
  type: "outcome",
246
866
  status: CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR,
247
867
  source: resolution.source,
248
868
  reason: "Connector run ended without a result.",
249
869
  });
250
- if (runLogPath) {
251
- emit.info(`Run log: ${runLogPath}`);
252
- }
253
870
  return 1;
254
871
  }
255
- const dataState = finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED
256
- ? "ingested_personal_server"
257
- : "collected_local";
258
872
  await updateSourceState(resolution.source, {
259
873
  connectorInstalled: true,
874
+ connectorVersion: fetched.version,
875
+ exportFrequency: fetched.exportFrequency,
260
876
  sessionPresent: true,
261
877
  lastRunAt: new Date().toISOString(),
878
+ lastCollectedAt: new Date().toISOString(),
262
879
  lastRunOutcome: finalStatus,
263
- dataState,
264
- lastError: null,
880
+ dataState: finalDataState,
881
+ lastError: ingestFailureMessage,
882
+ lastResultPath: resultPath,
883
+ lastLogPath: runLogPath ?? fetchLogPath ?? setupLogPath ?? null,
884
+ connectionHealth: "healthy",
885
+ ingestScopes: ingestScopeResults,
265
886
  });
266
- if (finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED) {
267
- emit.info(`Connected ${displaySource(source, sourceLabels)}.`);
268
- emit.info(`Collected your ${displaySource(source, sourceLabels)} data and synced it to your Personal Server.`);
887
+ // Build scope-aware success summary
888
+ const storedCount = ingestScopeResults?.filter((r) => r.status === "stored").length ?? 0;
889
+ const failedCount = ingestScopeResults?.filter((r) => r.status === "failed").length ?? 0;
890
+ const totalScopes = ingestScopeResults?.length ?? 0;
891
+ let successSummary;
892
+ if (finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED &&
893
+ totalScopes > 0) {
894
+ if (failedCount === 0) {
895
+ successSummary = `Collected your ${displayName} data and synced it to your Personal Server.`;
896
+ }
897
+ else {
898
+ successSummary = `Collected your ${displayName} data. ${storedCount}/${totalScopes} scopes synced, ${failedCount} failed.`;
899
+ }
900
+ }
901
+ else if (finalStatus === CliOutcomeStatus.CONNECTED_AND_INGESTED) {
902
+ successSummary = `Collected your ${displayName} data and synced it to your Personal Server.`;
269
903
  }
270
904
  else {
271
- if (target.state !== "available") {
272
- emit.info(`No Personal Server is available right now, so your ${displaySource(source, sourceLabels)} data was saved locally.`);
273
- }
274
- emit.info(`Connected ${displaySource(source, sourceLabels)}.`);
275
- emit.info(`Collected your ${displaySource(source, sourceLabels)} data and saved it locally.`);
276
- emit.info(`Local result: ${resultPath}`);
905
+ successSummary = `Collected your ${displayName} data and saved it locally.`;
906
+ }
907
+ // Auto-schedule collection if no schedule exists (non-blocking)
908
+ await maybeAutoSchedule(fetched.exportFrequency ?? sourceDetails?.exportFrequency, emit, options).catch(() => { });
909
+ // --- Phase 7: Success summary ---
910
+ renderer?.success(`Connected ${displayName}.`);
911
+ renderer?.detail(successSummary);
912
+ // Partial sync guidance
913
+ if (failedCount > 0 && storedCount > 0) {
914
+ renderer?.detail(`Retry: vana server sync`);
277
915
  }
278
- if (runLogPath) {
279
- emit.info(`Run log: ${runLogPath}`);
916
+ // Journey-aware next step
917
+ const state = await readCliState();
918
+ const connectedSourceCount = Object.values(state.sources ?? {}).filter((s) => hasCollectedData(s?.dataState)).length;
919
+ renderer?.detail("");
920
+ if (connectedSourceCount > 1) {
921
+ renderer?.next("vana sources");
280
922
  }
281
- else if (fetchLogPath) {
282
- emit.info(`Fetch log: ${fetchLogPath}`);
923
+ else {
924
+ renderer?.next(`vana data show ${source}`);
925
+ }
926
+ // Suggest skills if not yet installed
927
+ const installedSkills = await readInstalledSkills();
928
+ if (installedSkills.length === 0) {
929
+ renderer?.detail("Your coding agent can use this data — run `vana skills` to see how.");
283
930
  }
284
- else if (setupLogPath) {
285
- emit.info(`Setup log: ${setupLogPath}`);
931
+ renderer?.bell();
932
+ // Offer skill install on first successful connect (ask once)
933
+ if (!state.config?.skillsPromptCompleted &&
934
+ !options.json &&
935
+ !options.noInput &&
936
+ process.stdin.isTTY) {
937
+ await maybePromptSkillInstall(emit);
286
938
  }
287
- emit.info("Next: run `vana status` to inspect your current connection state.");
939
+ // Emit for --json consumers (unchanged)
288
940
  emit.event({
289
941
  type: "outcome",
290
942
  status: finalStatus,
@@ -294,95 +946,529 @@ async function runConnect(source, options) {
294
946
  return 0;
295
947
  }
296
948
  catch (error) {
949
+ if (error instanceof Error &&
950
+ error.message === "__vana_prompt_cancelled__") {
951
+ await updateSourceState(source, {
952
+ lastRunAt: new Date().toISOString(),
953
+ lastRunOutcome: CliOutcomeStatus.NEEDS_INPUT,
954
+ lastError: "Cancelled before input was completed.",
955
+ lastLogPath: runLogPath ?? null,
956
+ });
957
+ renderer?.fail("Cancelled.");
958
+ emit.event({
959
+ type: "outcome",
960
+ status: CliOutcomeStatus.NEEDS_INPUT,
961
+ source,
962
+ reason: "prompt_cancelled",
963
+ });
964
+ return 1;
965
+ }
297
966
  const message = error instanceof Error ? error.message : "Unexpected error.";
298
- emit.info(message);
967
+ renderer?.fail(`Problem connecting ${displayName}.`);
968
+ renderer?.detail(message);
969
+ renderer?.detail(`Retry: vana connect ${source}`);
299
970
  emit.event({
300
971
  type: "outcome",
301
972
  status: CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR,
302
973
  source,
303
974
  reason: message,
304
975
  });
305
- if (runLogPath) {
306
- emit.info(`Run log: ${runLogPath}`);
307
- }
308
- else if (fetchLogPath) {
309
- emit.info(`Fetch log: ${fetchLogPath}`);
310
- }
311
- else if (setupLogPath) {
312
- emit.info(`Setup log: ${setupLogPath}`);
313
- }
314
976
  return 1;
315
977
  }
978
+ finally {
979
+ renderer?.cleanup();
980
+ }
316
981
  }
317
- async function runList(options) {
982
+ async function runConnectEntry(options) {
983
+ const emit = createEmitter(options);
318
984
  const sources = await loadRegistrySources();
319
- const installedSourceIds = new Set((await listInstalledConnectorFiles()).map((source) => source.source));
320
- const enrichedSources = sources.map((source) => ({
321
- ...source,
322
- installed: installedSourceIds.has(source.id),
323
- }));
985
+ const state = await readCliState();
986
+ const sourceMetadata = createSourceMetadataMap(sources);
987
+ const statuses = await gatherSourceStatuses(state.sources, sourceMetadata);
988
+ const statusMap = new Map(statuses.map((source) => [source.source, source]));
989
+ const enrichedSources = sources.map((source) => {
990
+ const status = statusMap.get(source.id);
991
+ return {
992
+ ...source,
993
+ dataState: status?.dataState,
994
+ lastRunOutcome: status?.lastRunOutcome ?? null,
995
+ sessionPresent: status?.sessionPresent ?? false,
996
+ };
997
+ });
998
+ const suggestedSource = enrichedSources.find((source) => source.authMode !== "legacy" && !hasCollectedData(source.dataState)) ??
999
+ enrichedSources.find((source) => source.authMode !== "legacy") ??
1000
+ enrichedSources[0];
1001
+ const missingSourceMessage = formatMissingConnectSourceMessage(suggestedSource);
324
1002
  if (options.json) {
325
- process.stdout.write(`${JSON.stringify({ sources: enrichedSources })}\n`);
1003
+ process.stdout.write(`${JSON.stringify({
1004
+ error: "source_required",
1005
+ message: missingSourceMessage,
1006
+ suggestedSource: suggestedSource
1007
+ ? {
1008
+ id: suggestedSource.id,
1009
+ name: suggestedSource.name,
1010
+ authMode: suggestedSource.authMode,
1011
+ }
1012
+ : null,
1013
+ })}\n`);
1014
+ return 1;
1015
+ }
1016
+ if (options.noInput) {
1017
+ emit.info(missingSourceMessage);
1018
+ return 1;
1019
+ }
1020
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1021
+ emit.info(missingSourceMessage);
1022
+ return 1;
1023
+ }
1024
+ if (enrichedSources.length === 0) {
1025
+ emit.info("No sources are available right now.");
1026
+ emit.info("Run `vana sources` to verify the local connector registry.");
1027
+ return 1;
1028
+ }
1029
+ // Build inquirer-compatible choices from enriched sources
1030
+ const choices = enrichedSources.map((item) => {
1031
+ const connected = hasCollectedData(item.dataState);
1032
+ const hint = connected
1033
+ ? "connected"
1034
+ : item.authMode === "legacy"
1035
+ ? "browser login"
1036
+ : undefined;
1037
+ return {
1038
+ value: item.id,
1039
+ name: item.name,
1040
+ description: hint,
1041
+ };
1042
+ });
1043
+ try {
1044
+ const source = await searchSelect({
1045
+ message: "Choose a source to connect.",
1046
+ choices,
1047
+ ...vanaPromptTheme,
1048
+ });
1049
+ return runConnect(source, options);
1050
+ }
1051
+ catch (error) {
1052
+ if (isPromptCancelled(error)) {
1053
+ emit.info("Cancelled.");
1054
+ return 1;
1055
+ }
1056
+ throw error;
1057
+ }
1058
+ }
1059
+ async function runList(options) {
1060
+ const result = await querySources();
1061
+ const { sources: enrichedSources, recommendedSource } = result;
1062
+ if (options.json) {
1063
+ process.stdout.write(`${JSON.stringify(result)}\n`);
326
1064
  return 0;
327
1065
  }
328
1066
  const emit = createEmitter(options);
329
- for (const source of enrichedSources) {
330
- const description = source.description ? ` - ${source.description}` : "";
331
- const installed = source.installed ? " [installed]" : "";
332
- emit.info(`${source.name}${installed}${description}`);
1067
+ emit.title("Available sources");
1068
+ emit.blank();
1069
+ if (enrichedSources.length === 0) {
1070
+ emit.info("No sources are available right now.");
1071
+ }
1072
+ else {
1073
+ const connectedSources = enrichedSources.filter((source) => hasCollectedData(source.dataState));
1074
+ const unconnectedSources = enrichedSources.filter((source) => !hasCollectedData(source.dataState));
1075
+ // Connected sources are always shown expanded
1076
+ if (connectedSources.length > 0) {
1077
+ emit.section("Connected");
1078
+ for (const source of connectedSources) {
1079
+ const badges = [];
1080
+ if (source.dataState === "ingested_personal_server") {
1081
+ badges.push({ text: "synced", tone: "success" });
1082
+ }
1083
+ else if (source.dataState === "ingest_failed") {
1084
+ badges.push({ text: "sync failed", tone: "warning" });
1085
+ }
1086
+ else {
1087
+ badges.push({ text: "local", tone: "muted" });
1088
+ }
1089
+ emit.sourceTitle(source.name, badges);
1090
+ emit.detail(`Inspect with ${emit.code(`vana data show ${source.id}`)}.`);
1091
+ }
1092
+ emit.blank();
1093
+ emit.section("Available");
1094
+ }
1095
+ for (const source of unconnectedSources) {
1096
+ const badges = [];
1097
+ if (recommendedSource?.id === source.id &&
1098
+ recommendedSource.authMode !== "legacy") {
1099
+ badges.push({ text: "recommended", tone: "accent" });
1100
+ }
1101
+ emit.sourceTitle(source.name, badges);
1102
+ if (source.description) {
1103
+ emit.detail(cleanDescription(source.description));
1104
+ }
1105
+ }
1106
+ if (recommendedSource) {
1107
+ emit.blank();
1108
+ emit.next(`vana connect ${recommendedSource.id}`);
1109
+ }
333
1110
  }
334
1111
  return 0;
335
1112
  }
336
1113
  async function runStatus(options) {
337
- const emit = createEmitter(options);
338
- const runtime = new ManagedPlaywrightRuntime();
339
- const personalServer = await detectPersonalServerTarget();
1114
+ const { status, nextSteps } = await queryStatus();
340
1115
  const state = await readCliState();
341
- const registrySources = await loadRegistrySources();
342
- const sourceLabels = createSourceLabelMap(registrySources);
343
- const sourceMetadata = createSourceMetadataMap(registrySources);
344
- const sources = await gatherSourceStatuses(state.sources, sourceMetadata);
345
- const status = {
346
- runtime: runtime.state,
347
- runtimePath: runtime.state === "installed" ? runtime.runnerDir : null,
348
- personalServer: personalServer.state,
349
- personalServerUrl: personalServer.url,
350
- sources,
351
- };
1116
+ // Build per-source health map from stored state
1117
+ const sourceHealthMap = {};
1118
+ for (const [sourceId, stored] of Object.entries(state.sources)) {
1119
+ if (stored) {
1120
+ sourceHealthMap[sourceId] = {
1121
+ connectionHealth: stored.connectionHealth,
1122
+ lastCollectedAt: stored.lastCollectedAt,
1123
+ };
1124
+ }
1125
+ }
352
1126
  if (options.json) {
353
- process.stdout.write(`${JSON.stringify(status)}\n`);
1127
+ const compactJson = {
1128
+ runtime: status.runtime,
1129
+ personalServer: status.personalServer,
1130
+ personalServerUrl: status.personalServerUrl,
1131
+ sources: {
1132
+ connected: status.summary?.connectedCount ?? 0,
1133
+ needsAttention: status.summary?.needsAttentionCount ?? 0,
1134
+ },
1135
+ sourceHealth: sourceHealthMap,
1136
+ next: nextSteps[0] ?? null,
1137
+ };
1138
+ process.stdout.write(`${JSON.stringify(compactJson)}\n`);
354
1139
  return 0;
355
1140
  }
356
- emit.info("Vana Connect status");
357
- emit.info("");
358
- emit.info(`Runtime: ${status.runtime}`);
359
- emit.info(`Personal Server: ${status.personalServer}`);
360
- emit.info("");
361
- for (const source of status.sources) {
362
- emit.info(formatSourceStatus(source, sourceLabels));
363
- const details = formatSourceStatusDetail(source);
364
- if (details) {
365
- emit.info(` ${details}`);
1141
+ const emit = createEmitter(options);
1142
+ const registrySources = await loadRegistrySources();
1143
+ const sourceLabels = createSourceLabelMap(registrySources);
1144
+ emit.title("Vana Connect");
1145
+ emit.blank();
1146
+ emit.keyValue("Runtime", status.runtime, toneForRuntime(status.runtime));
1147
+ if (status.personalServer === "available") {
1148
+ emit.keyValue("Personal Server", status.personalServerUrl ?? "connected", "success");
1149
+ }
1150
+ else {
1151
+ emit.keyValue("Personal Server", "not connected", "warning");
1152
+ }
1153
+ const connectedCount = status.summary?.connectedCount ?? 0;
1154
+ const attentionCount = status.summary?.needsAttentionCount ?? 0;
1155
+ const sourceParts = [
1156
+ connectedCount > 0 ? `${connectedCount} connected` : "none connected",
1157
+ ...(connectedCount > 0 && attentionCount > 0
1158
+ ? [`${attentionCount} need${attentionCount === 1 ? "s" : ""} attention`]
1159
+ : []),
1160
+ ];
1161
+ emit.keyValue("Sources", sourceParts.join(", "), attentionCount > 0 && connectedCount > 0
1162
+ ? "warning"
1163
+ : connectedCount > 0
1164
+ ? "success"
1165
+ : "muted");
1166
+ // Show per-source health when sources are connected
1167
+ const connectedSources = Object.entries(state.sources).filter(([, stored]) => stored?.connectorInstalled &&
1168
+ (stored.dataState === "collected_local" ||
1169
+ stored.dataState === "ingested_personal_server" ||
1170
+ stored.dataState === "ingest_failed" ||
1171
+ stored.connectionHealth));
1172
+ if (connectedSources.length > 0) {
1173
+ emit.blank();
1174
+ let needsReauthSource = null;
1175
+ for (const [sourceId, stored] of connectedSources) {
1176
+ const health = stored?.connectionHealth ?? "healthy";
1177
+ const displayName = displaySource(sourceId, sourceLabels);
1178
+ const sourceStatus = status.sources.find((s) => s.source === sourceId);
1179
+ const sourceOverdue = sourceStatus?.isOverdue ?? false;
1180
+ const healthTone = sourceOverdue ? "warning" : toneForHealth(health);
1181
+ const healthLabel = health === "needs_reauth" ? "needs login" : health;
1182
+ const staleTag = sourceOverdue
1183
+ ? ` ${emit.badge("stale", "warning")}`
1184
+ : "";
1185
+ const collectedAgo = stored?.lastCollectedAt
1186
+ ? `collected ${formatRelativeTime(stored.lastCollectedAt)}`
1187
+ : "";
1188
+ emit.keyValue(` ${displayName}`, `${healthLabel}${staleTag} ${collectedAgo}`, healthTone);
1189
+ if (health === "needs_reauth" && !needsReauthSource) {
1190
+ needsReauthSource = sourceId;
1191
+ }
1192
+ }
1193
+ if (needsReauthSource) {
1194
+ emit.blank();
1195
+ emit.next(`vana connect ${needsReauthSource}`);
1196
+ return 0;
1197
+ }
1198
+ }
1199
+ if (nextSteps.length > 0) {
1200
+ emit.blank();
1201
+ const command = extractCommand(nextSteps[0]);
1202
+ if (command) {
1203
+ emit.next(command);
1204
+ }
1205
+ else {
1206
+ emit.detail(`Next: ${nextSteps[0]}`);
366
1207
  }
367
1208
  }
368
1209
  return 0;
369
1210
  }
370
- async function runSetup(options) {
371
- const emit = createEmitter(options);
372
- const runtime = new ManagedPlaywrightRuntime();
373
- if (runtime.state === "installed") {
374
- emit.info("Vana Connect runtime is already installed.");
375
- emit.event({ type: "setup-check", runtime: runtime.state });
1211
+ async function runDoctor(options) {
1212
+ const payload = await queryDoctor();
1213
+ if (options.json) {
1214
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
376
1215
  return 0;
377
1216
  }
378
- try {
379
- const result = await runtime.ensureInstalled(Boolean(options.yes));
380
- emit.info("Runtime ready.");
381
- if (result.logPath) {
382
- emit.info(`Setup log: ${result.logPath}`);
383
- }
384
- emit.event({
385
- type: "setup-complete",
1217
+ const sourceLabels = createSourceLabelMap(await loadRegistrySources());
1218
+ const { recentSources } = payload;
1219
+ const attentionSources = recentSources.filter((source) => rankSourceStatus(source) <= 4);
1220
+ const emit = createEmitter(options);
1221
+ emit.title("Vana Connect doctor");
1222
+ emit.section("Summary");
1223
+ emit.keyValue("CLI", payload.cliVersion, "muted");
1224
+ emit.keyValue("Channel", payload.channel, "muted");
1225
+ emit.keyValue("Install", formatInstallMethodLabel(payload.installMethod), "muted");
1226
+ emit.keyValue("Runtime", payload.runtime, toneForRuntime(payload.runtime));
1227
+ emit.keyValue("Personal Server", payload.personalServer, payload.personalServer === "available" ? "success" : "warning");
1228
+ emit.keyValue("Tracked sources", String(payload.summary.trackedSourceCount), "muted");
1229
+ emit.keyValue("Attention", String(payload.summary.attentionCount), payload.summary.attentionCount > 0 ? "warning" : "muted");
1230
+ emit.keyValue("Connected", String(payload.summary.connectedCount), payload.summary.connectedCount > 0 ? "success" : "muted");
1231
+ emit.blank();
1232
+ emit.section("Checks");
1233
+ for (const check of payload.checks) {
1234
+ const tone = check.status === "ok"
1235
+ ? "success"
1236
+ : check.status === "warn"
1237
+ ? "warning"
1238
+ : "error";
1239
+ emit.keyValue(check.label, check.detail, tone);
1240
+ }
1241
+ if (recentSources.length > 0) {
1242
+ emit.blank();
1243
+ emit.section(attentionSources.length > 0
1244
+ ? "Needs attention"
1245
+ : "Recent source activity");
1246
+ for (const source of attentionSources.length > 0
1247
+ ? attentionSources
1248
+ : recentSources) {
1249
+ const status = getSourceStatusPresentation(source);
1250
+ const badges = [];
1251
+ badges.push({ text: status.label, tone: status.tone });
1252
+ emit.sourceTitle(displaySource(source.source, sourceLabels), badges);
1253
+ const details = formatSourceStatusDetails(source);
1254
+ for (const detail of details) {
1255
+ if (detail.kind === "row") {
1256
+ emit.keyValue(detail.label, detail.value, detail.tone ?? "muted");
1257
+ }
1258
+ else {
1259
+ emit.detail(humanizeIssue(detail.message));
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+ emit.blank();
1265
+ emit.section("Paths");
1266
+ emit.keyValue("Executable", formatDisplayPath(payload.paths.executable), "muted");
1267
+ if (payload.paths.appRoot) {
1268
+ emit.keyValue("App root", formatDisplayPath(payload.paths.appRoot), "muted");
1269
+ }
1270
+ emit.keyValue("Data home", formatDisplayPath(payload.paths.dataHome), "muted");
1271
+ emit.keyValue("State file", formatDisplayPath(payload.paths.stateFile), "muted");
1272
+ emit.keyValue("Connector cache", formatDisplayPath(payload.paths.connectorCache), "muted");
1273
+ emit.keyValue("Browser profiles", formatDisplayPath(payload.paths.browserProfiles), "muted");
1274
+ emit.keyValue("Logs", formatDisplayPath(payload.paths.logs), "muted");
1275
+ emit.blank();
1276
+ emit.section("Lifecycle");
1277
+ emit.keyValue("Upgrade", payload.lifecycle.upgrade, "muted");
1278
+ emit.keyValue("Uninstall", payload.lifecycle.uninstall, "muted");
1279
+ if (payload.nextSteps.length > 0) {
1280
+ emit.blank();
1281
+ const command = extractCommand(payload.nextSteps[0]);
1282
+ if (command) {
1283
+ emit.next(command);
1284
+ }
1285
+ else {
1286
+ emit.detail(`Next: ${payload.nextSteps[0]}`);
1287
+ }
1288
+ }
1289
+ return 0;
1290
+ }
1291
+ async function runServerStatus(options) {
1292
+ const emit = createEmitter(options);
1293
+ const target = await detectPersonalServerTarget();
1294
+ const state = await readCliState();
1295
+ // Count scopes from state
1296
+ let totalScopeCount = 0;
1297
+ for (const stored of Object.values(state.sources)) {
1298
+ if (stored?.ingestScopes) {
1299
+ totalScopeCount += stored.ingestScopes.filter((s) => s.status === "stored").length;
1300
+ }
1301
+ }
1302
+ if (options.json) {
1303
+ process.stdout.write(`${JSON.stringify({
1304
+ state: target.state,
1305
+ url: target.url,
1306
+ source: target.source,
1307
+ health: target.health,
1308
+ scopeCount: totalScopeCount,
1309
+ })}\n`);
1310
+ return 0;
1311
+ }
1312
+ emit.title("Personal Server");
1313
+ emit.blank();
1314
+ if (target.url) {
1315
+ const urlSuffix = target.source === "scan"
1316
+ ? "(auto-detected)"
1317
+ : target.source === "config"
1318
+ ? "(saved)"
1319
+ : target.source === "env"
1320
+ ? "(from VANA_PERSONAL_SERVER_URL)"
1321
+ : `(${target.source ?? "unknown"})`;
1322
+ emit.keyValue("URL", `${target.url} ${urlSuffix}`, "muted");
1323
+ }
1324
+ const stateLabel = target.state === "available" ? "healthy" : "Not connected";
1325
+ emit.keyValue("Status", stateLabel, target.state === "available" ? "success" : "warning");
1326
+ if (target.health) {
1327
+ emit.keyValue("Version", target.health.version, "muted");
1328
+ }
1329
+ if (totalScopeCount > 0) {
1330
+ emit.keyValue("Scopes", `${totalScopeCount} stored`, "muted");
1331
+ }
1332
+ if (target.source && !target.url) {
1333
+ const sourceLabel = {
1334
+ config: "Saved config",
1335
+ env: "VANA_PERSONAL_SERVER_URL",
1336
+ scan: "Localhost scan",
1337
+ };
1338
+ emit.keyValue("Resolved via", sourceLabel[target.source] ?? target.source, "muted");
1339
+ }
1340
+ if (target.health) {
1341
+ emit.keyValue("Uptime", formatUptime(target.health.uptime), "muted");
1342
+ if (target.health.owner) {
1343
+ emit.keyValue("Owner", target.health.owner, "muted");
1344
+ }
1345
+ }
1346
+ if (target.source === "scan" && target.url) {
1347
+ emit.blank();
1348
+ emit.detail(`Save with ${emit.code(`vana server set-url ${target.url}`)}.`);
1349
+ }
1350
+ if (target.state !== "available") {
1351
+ emit.blank();
1352
+ emit.next("vana server set-url <url>");
1353
+ }
1354
+ emit.blank();
1355
+ emit.detail(`More: ${emit.code("vana server sync")} | ${emit.code("vana server data")} | ${emit.code("vana server --help")}`);
1356
+ return 0;
1357
+ }
1358
+ async function runServerSetUrl(url, options) {
1359
+ const emit = createEmitter(options);
1360
+ try {
1361
+ new URL(url);
1362
+ }
1363
+ catch {
1364
+ if (options.json) {
1365
+ process.stdout.write(`${JSON.stringify({ ok: false, error: "Invalid URL" })}\n`);
1366
+ }
1367
+ else {
1368
+ emit.info(`Invalid URL: ${url}`);
1369
+ }
1370
+ return 1;
1371
+ }
1372
+ await updateCliConfig({ personalServerUrl: url });
1373
+ const target = await detectPersonalServerTarget();
1374
+ if (options.json) {
1375
+ process.stdout.write(`${JSON.stringify({
1376
+ ok: true,
1377
+ url,
1378
+ reachable: target.state === "available",
1379
+ health: target.health,
1380
+ })}\n`);
1381
+ return 0;
1382
+ }
1383
+ emit.info(`Saved Personal Server URL: ${url}`);
1384
+ if (target.state === "available") {
1385
+ emit.info(`Server is reachable (${target.health?.version ?? "unknown version"}).`);
1386
+ }
1387
+ else {
1388
+ emit.info("Server is not reachable yet. It will be used when available.");
1389
+ }
1390
+ return 0;
1391
+ }
1392
+ async function runServerClearUrl(options) {
1393
+ const emit = createEmitter(options);
1394
+ const config = await readCliConfig();
1395
+ if (!config.personalServerUrl) {
1396
+ if (options.json) {
1397
+ process.stdout.write(`${JSON.stringify({ ok: true, cleared: false })}\n`);
1398
+ }
1399
+ else {
1400
+ const target = await detectPersonalServerTarget();
1401
+ if (target.source === "scan" && target.url) {
1402
+ emit.info("No saved URL to clear. Current connection is auto-detected on localhost.");
1403
+ emit.info(`Run ${emit.code("vana server set-url <url>")} to save a specific URL.`);
1404
+ }
1405
+ else {
1406
+ emit.info("No saved Personal Server URL to clear.");
1407
+ }
1408
+ }
1409
+ return 0;
1410
+ }
1411
+ await updateCliConfig({ personalServerUrl: undefined });
1412
+ if (options.json) {
1413
+ process.stdout.write(`${JSON.stringify({ ok: true, cleared: true })}\n`);
1414
+ }
1415
+ else {
1416
+ emit.info("Cleared saved Personal Server URL.");
1417
+ }
1418
+ return 0;
1419
+ }
1420
+ export function formatUptime(seconds) {
1421
+ if (seconds < 60)
1422
+ return `${Math.round(seconds)}s`;
1423
+ if (seconds < 3600)
1424
+ return `${Math.floor(seconds / 60)}m`;
1425
+ if (seconds < 86400) {
1426
+ const hours = Math.floor(seconds / 3600);
1427
+ const minutes = Math.floor((seconds % 3600) / 60);
1428
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
1429
+ }
1430
+ const days = Math.floor(seconds / 86400);
1431
+ const hours = Math.floor((seconds % 86400) / 3600);
1432
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
1433
+ }
1434
+ async function runSetup(options) {
1435
+ const emit = createEmitter(options);
1436
+ const runtime = new ManagedPlaywrightRuntime();
1437
+ const registrySources = await loadRegistrySources();
1438
+ const suggestedSource = registrySources.find((source) => source.authMode !== "legacy") ??
1439
+ registrySources[0];
1440
+ emit.title("Vana Connect setup");
1441
+ emit.section("Runtime");
1442
+ if (runtime.state === "installed") {
1443
+ emit.info("The local runtime is already installed.");
1444
+ if (runtime.runtimePath) {
1445
+ emit.keyValue("Browser", formatDisplayPath(runtime.runtimePath), "muted");
1446
+ }
1447
+ emit.blank();
1448
+ if (suggestedSource) {
1449
+ emit.next(`vana connect ${suggestedSource.id}`);
1450
+ }
1451
+ else {
1452
+ emit.next("vana connect");
1453
+ }
1454
+ emit.event({ type: "setup-check", runtime: runtime.state });
1455
+ return 0;
1456
+ }
1457
+ try {
1458
+ const result = await runtime.ensureInstalled(Boolean(options.yes));
1459
+ emit.success("Runtime ready.");
1460
+ if (result.logPath) {
1461
+ emit.detail(`Setup log: ${formatDisplayPath(result.logPath)}`);
1462
+ }
1463
+ emit.blank();
1464
+ if (suggestedSource) {
1465
+ emit.next(`vana connect ${suggestedSource.id}`);
1466
+ }
1467
+ else {
1468
+ emit.next("vana connect");
1469
+ }
1470
+ emit.event({
1471
+ type: "setup-complete",
386
1472
  runtime: result.runtime,
387
1473
  logPath: result.logPath,
388
1474
  });
@@ -401,167 +1487,2358 @@ async function runSetup(options) {
401
1487
  return 1;
402
1488
  }
403
1489
  }
404
- function createEmitter(options) {
405
- return {
406
- event(event) {
407
- if (options.json) {
408
- process.stdout.write(`${JSON.stringify(event)}\n`);
409
- }
410
- },
411
- info(message) {
412
- if (options.json || options.quiet) {
413
- return;
1490
+ async function runDataList(options) {
1491
+ const result = await queryDataList();
1492
+ if (options.json) {
1493
+ process.stdout.write(`${JSON.stringify(result)}\n`);
1494
+ return 0;
1495
+ }
1496
+ const { datasets: datasetRecords } = result;
1497
+ const registrySources = await loadRegistrySources();
1498
+ const emit = createEmitter(options);
1499
+ if (datasetRecords.length === 0) {
1500
+ const suggestedSource = registrySources.find((source) => source.authMode !== "legacy") ??
1501
+ registrySources[0];
1502
+ emit.title("Collected data");
1503
+ emit.blank();
1504
+ emit.info(" No datasets yet.");
1505
+ emit.blank();
1506
+ if (suggestedSource) {
1507
+ emit.next(`vana connect ${suggestedSource.id}`);
1508
+ }
1509
+ else {
1510
+ emit.next("vana connect");
1511
+ }
1512
+ return 0;
1513
+ }
1514
+ emit.title(datasetRecords.length > 0
1515
+ ? `Collected data (${datasetRecords.length})`
1516
+ : "Collected data");
1517
+ emit.blank();
1518
+ emit.info(joinOverviewParts([
1519
+ formatCountLabel("dataset", datasetRecords.length),
1520
+ formatCountLabel("local only", datasetRecords.filter((dataset) => dataset.dataState !== "ingested_personal_server").length),
1521
+ formatCountLabel("synced", datasetRecords.filter((dataset) => dataset.dataState === "ingested_personal_server").length),
1522
+ datasetRecords.some((dataset) => dataset.dataState === "ingest_failed")
1523
+ ? formatCountLabel("sync failed", datasetRecords.filter((dataset) => dataset.dataState === "ingest_failed").length)
1524
+ : "",
1525
+ ]));
1526
+ emit.blank();
1527
+ datasetRecords.forEach((dataset, index) => {
1528
+ if (index > 0) {
1529
+ emit.blank();
1530
+ }
1531
+ const badges = dataset.dataState === "ingested_personal_server"
1532
+ ? [{ text: "synced", tone: "success" }]
1533
+ : dataset.dataState === "ingest_failed"
1534
+ ? [{ text: "sync failed", tone: "warning" }]
1535
+ : [{ text: "local", tone: "muted" }];
1536
+ emit.sourceTitle(dataset.name ?? displaySource(dataset.source), badges);
1537
+ if (dataset.summary) {
1538
+ for (const line of dataset.summary.lines) {
1539
+ emit.detail(line);
414
1540
  }
415
- process.stdout.write(`${message}\n`);
416
- },
417
- };
418
- }
419
- function displaySource(source, labels = {}) {
420
- return labels[source] ?? source.charAt(0).toUpperCase() + source.slice(1);
421
- }
422
- function humanizeField(value) {
423
- return value
424
- .replace(/([a-z])([A-Z])/g, "$1 $2")
425
- .replace(/[_-]/g, " ")
426
- .replace(/^\w/, (match) => match.toUpperCase());
427
- }
428
- async function gatherSourceStatuses(storedSources, metadata = {}) {
429
- const installedFiles = await listInstalledConnectorFiles();
430
- const sourceNames = new Set([
431
- ...Object.keys(storedSources),
432
- ...installedFiles.map((file) => file.source),
433
- ]);
434
- return [...sourceNames]
435
- .sort((left, right) => left.localeCompare(right))
436
- .map((source) => {
437
- const stored = storedSources[source] ?? {};
438
- const installed = installedFiles.some((file) => file.source === source);
439
- const details = metadata[source];
440
- return {
441
- source,
442
- name: details?.name,
443
- company: details?.company,
444
- description: details?.description,
445
- installed,
446
- sessionPresent: stored.sessionPresent ?? false,
447
- lastRunAt: stored.lastRunAt ?? null,
448
- lastRunOutcome: stored.lastRunOutcome ?? null,
449
- dataState: stored.dataState === "ingested_personal_server"
450
- ? "ingested_personal_server"
451
- : stored.dataState === "collected_local"
452
- ? "collected_local"
453
- : "none",
454
- lastError: stored.lastError ?? null,
455
- };
1541
+ }
1542
+ if (dataset.dataState === "ingested_personal_server") {
1543
+ emit.keyValue("State", "Synced to Personal Server", "success");
1544
+ }
1545
+ else if (dataset.dataState === "ingest_failed") {
1546
+ emit.keyValue("State", "Saved locally, sync failed", "warning");
1547
+ }
1548
+ else {
1549
+ emit.keyValue("State", "Saved locally", "muted");
1550
+ }
1551
+ if (dataset.lastRunAt) {
1552
+ emit.keyValue("Updated", formatTimestamp(dataset.lastRunAt), "muted");
1553
+ }
1554
+ if (dataset.path) {
1555
+ emit.keyValue("Path", formatDisplayPath(dataset.path), "muted");
1556
+ }
456
1557
  });
1558
+ emit.blank();
1559
+ if (datasetRecords.length > 0) {
1560
+ emit.next(`vana data show ${datasetRecords[0].source}`);
1561
+ }
1562
+ return 0;
457
1563
  }
458
- async function listInstalledConnectorFiles() {
459
- const connectorsDir = getConnectorCacheDir();
460
- try {
461
- const results = [];
462
- const entries = await fsp.readdir(connectorsDir, { withFileTypes: true });
463
- for (const entry of entries) {
464
- if (!entry.isDirectory()) {
465
- continue;
1564
+ async function runDataShow(source, options) {
1565
+ const result = await queryDataShow(source);
1566
+ if (!result.ok) {
1567
+ if (result.error === "dataset_not_found") {
1568
+ if (options.json) {
1569
+ process.stdout.write(`${JSON.stringify({
1570
+ error: result.error,
1571
+ source: result.source,
1572
+ message: result.message,
1573
+ nextSteps: result.nextSteps,
1574
+ })}\n`);
466
1575
  }
467
- const companyDir = path.join(connectorsDir, entry.name);
468
- const files = await fsp.readdir(companyDir);
469
- for (const file of files) {
470
- if (!file.endsWith("-playwright.js")) {
471
- continue;
472
- }
473
- results.push({
474
- source: file.replace(/-playwright\.js$/, ""),
475
- path: path.join(companyDir, file),
476
- });
1576
+ else {
1577
+ const emit = createEmitter(options);
1578
+ emit.info(result.message);
1579
+ emit.blank();
1580
+ emit.next(`vana connect ${source}`);
477
1581
  }
1582
+ return 1;
478
1583
  }
479
- return results;
1584
+ // dataset_read_failed
1585
+ if (options.json) {
1586
+ process.stdout.write(`${JSON.stringify({ error: result.error, source: result.source, path: result.path, message: result.message })}\n`);
1587
+ }
1588
+ else {
1589
+ createEmitter(options).info(result.message);
1590
+ }
1591
+ return 1;
480
1592
  }
481
- catch {
482
- return [];
1593
+ if (options.json) {
1594
+ process.stdout.write(`${JSON.stringify({
1595
+ source: result.source,
1596
+ name: result.name,
1597
+ path: result.path,
1598
+ summary: result.summary,
1599
+ lastRunAt: result.lastRunAt,
1600
+ dataState: result.dataState,
1601
+ nextSteps: result.nextSteps,
1602
+ data: result.data,
1603
+ })}\n`);
1604
+ return 0;
483
1605
  }
484
- }
485
- function formatSourceStatus(source, labels = {}) {
486
- if (!source.installed) {
487
- return `${displaySource(source.source, labels)}: not connected`;
1606
+ const emit = createEmitter(options);
1607
+ const state = await readCliState();
1608
+ const record = state.sources[source];
1609
+ emit.title(`${result.name} data`);
1610
+ emit.blank();
1611
+ if (result.summary) {
1612
+ for (const line of result.summary.lines) {
1613
+ emit.detail(line);
1614
+ }
1615
+ emit.blank();
488
1616
  }
489
- if (!source.lastRunOutcome) {
490
- return `${displaySource(source.source, labels)}: installed`;
1617
+ emit.keyValue("Path", formatDisplayPath(result.path), "muted");
1618
+ if (record?.lastRunAt) {
1619
+ emit.keyValue("Updated", formatTimestamp(record.lastRunAt), "muted");
491
1620
  }
492
- if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
493
- return `${displaySource(source.source, labels)}: needs input`;
1621
+ if (record?.dataState === "ingested_personal_server") {
1622
+ emit.keyValue("State", "Synced to Personal Server", "success");
494
1623
  }
495
- if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
496
- return `${displaySource(source.source, labels)}: error`;
1624
+ else if (record?.dataState === "ingest_failed") {
1625
+ emit.keyValue("State", "Saved locally, sync failed", "warning");
497
1626
  }
498
- if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
499
- return `${displaySource(source.source, labels)}: legacy auth required`;
1627
+ else {
1628
+ emit.keyValue("State", "Saved locally", "muted");
500
1629
  }
501
- if (source.dataState === "ingested_personal_server") {
502
- return `${displaySource(source.source, labels)}: connected, synced`;
1630
+ emit.blank();
1631
+ if (result.datasetCount > 1) {
1632
+ emit.next("vana data list");
503
1633
  }
504
- if (source.dataState === "collected_local") {
505
- return `${displaySource(source.source, labels)}: connected, local only`;
1634
+ else {
1635
+ emit.next(`vana connect ${source}`);
506
1636
  }
507
- return `${displaySource(source.source, labels)}: connected`;
1637
+ return 0;
508
1638
  }
509
- function formatSourceStatusDetail(source) {
510
- if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
511
- return source.lastError
512
- ? `${source.lastError}. Run \`vana connect ${source.source}\` interactively.`
513
- : `Run \`vana connect ${source.source}\` interactively.`;
514
- }
515
- if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
516
- return "This source still uses legacy headed auth and cannot complete in batch mode.";
517
- }
518
- if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
519
- return source.lastError ?? "The last connector run failed.";
1639
+ async function runDataPath(source, options) {
1640
+ const sourceLabels = createSourceLabelMap(await loadRegistrySources());
1641
+ const state = await readCliState();
1642
+ const resultPath = state.sources[source]?.lastResultPath;
1643
+ if (!resultPath) {
1644
+ if (options.json) {
1645
+ process.stdout.write(`${JSON.stringify({
1646
+ error: "dataset_not_found",
1647
+ source,
1648
+ name: displaySource(source, sourceLabels),
1649
+ message: `No collected dataset found for ${displaySource(source, sourceLabels)}. Run \`vana connect ${source}\` first.`,
1650
+ })}\n`);
1651
+ }
1652
+ else {
1653
+ createEmitter(options).info(`No collected dataset found for ${displaySource(source, sourceLabels)}. Run \`vana connect ${source}\` first.`);
1654
+ }
1655
+ return 1;
520
1656
  }
521
- if (!source.lastRunOutcome && source.installed) {
522
- return `Run \`vana connect ${source.source}\` to collect data.`;
1657
+ if (options.json) {
1658
+ process.stdout.write(`${JSON.stringify({
1659
+ source,
1660
+ name: displaySource(source, sourceLabels),
1661
+ path: resultPath,
1662
+ lastRunAt: state.sources[source]?.lastRunAt ?? null,
1663
+ dataState: state.sources[source]?.dataState ?? null,
1664
+ nextSteps: [
1665
+ `Inspect the dataset with \`vana data show ${source}\`.`,
1666
+ `Reconnect ${displaySource(source, sourceLabels)} with \`vana connect ${source}\`.`,
1667
+ ],
1668
+ })}\n`);
523
1669
  }
524
- return null;
525
- }
526
- function normalizeArgv(argv) {
527
- if (argv[2] === "connect" &&
528
- ["list", "status", "setup"].includes(argv[3] ?? "")) {
529
- const mapping = {
530
- list: "sources",
531
- status: "status",
532
- setup: "setup",
533
- };
534
- return [argv[0], argv[1], mapping[argv[3]], ...argv.slice(4)];
1670
+ else {
1671
+ process.stdout.write(`${formatDisplayPath(resultPath)}\n`);
535
1672
  }
536
- return argv;
1673
+ return 0;
537
1674
  }
538
- function extractGlobalOptions(argv) {
539
- return {
540
- json: argv.includes("--json"),
541
- noInput: argv.includes("--no-input"),
542
- yes: argv.includes("--yes"),
543
- quiet: argv.includes("--quiet"),
1675
+ async function runLogs(source, options) {
1676
+ const sourceLabels = createSourceLabelMap(await loadRegistrySources());
1677
+ const state = await readCliState();
1678
+ const records = Object.entries(state.sources)
1679
+ .filter(([, entry]) => Boolean(entry?.lastLogPath))
1680
+ .map(([sourceId, entry]) => ({
1681
+ source: sourceId,
1682
+ name: displaySource(sourceId, sourceLabels),
1683
+ path: entry?.lastLogPath ?? "",
1684
+ lastRunAt: entry?.lastRunAt ?? null,
1685
+ lastRunOutcome: entry?.lastRunOutcome ?? null,
1686
+ dataState: (entry?.dataState === "collected_local" ||
1687
+ entry?.dataState === "ingested_personal_server" ||
1688
+ entry?.dataState === "ingest_failed"
1689
+ ? entry.dataState
1690
+ : null),
1691
+ }))
1692
+ .sort(compareLogRecordOrder);
1693
+ const logSummary = {
1694
+ attentionCount: records.filter((record) => isAttentionLog(record.lastRunOutcome, record.dataState)).length,
1695
+ successfulCount: records.filter((record) => record.dataState === "collected_local" ||
1696
+ record.dataState === "ingested_personal_server").length,
1697
+ localCount: records.filter((record) => record.dataState === "collected_local").length,
1698
+ syncedCount: records.filter((record) => record.dataState === "ingested_personal_server").length,
544
1699
  };
545
- }
546
- function createSourceLabelMap(sources) {
547
- return Object.fromEntries(sources.map((source) => [source.id, source.name]));
548
- }
549
- function createSourceMetadataMap(sources) {
550
- return Object.fromEntries(sources.map((source) => [
551
- source.id,
1700
+ if (source) {
1701
+ const match = records.find((record) => record.source === source);
1702
+ if (!match) {
1703
+ const payload = {
1704
+ error: "log_not_found",
1705
+ source,
1706
+ message: `No stored run log found for ${displaySource(source, sourceLabels)}.`,
1707
+ nextSteps: [
1708
+ `Run \`vana connect ${source}\` to create a new log.`,
1709
+ ...(records.length > 0
1710
+ ? ["Run `vana logs` to inspect other logs."]
1711
+ : []),
1712
+ ],
1713
+ };
1714
+ if (options.json) {
1715
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
1716
+ }
1717
+ else {
1718
+ const emit = createEmitter(options);
1719
+ emit.info(payload.message);
1720
+ emit.blank();
1721
+ emit.next(`vana connect ${source}`);
1722
+ }
1723
+ return 1;
1724
+ }
1725
+ if (options.json) {
1726
+ process.stdout.write(`${JSON.stringify(match)}\n`);
1727
+ }
1728
+ else {
1729
+ process.stdout.write(`${formatDisplayPath(match.path)}\n`);
1730
+ }
1731
+ return 0;
1732
+ }
1733
+ const nextSteps = buildLogsNextSteps(records);
1734
+ if (options.json) {
1735
+ process.stdout.write(`${JSON.stringify({
1736
+ count: records.length,
1737
+ latestLog: records[0] ?? null,
1738
+ nextSteps,
1739
+ summary: logSummary,
1740
+ logs: records,
1741
+ })}\n`);
1742
+ return 0;
1743
+ }
1744
+ const emit = createEmitter(options);
1745
+ emit.title(records.length > 0 ? `Run logs (${records.length})` : "Run logs");
1746
+ emit.blank();
1747
+ if (records.length === 0) {
1748
+ emit.info("No stored run logs yet.");
1749
+ emit.blank();
1750
+ emit.next("vana connect");
1751
+ return 0;
1752
+ }
1753
+ emit.info(joinOverviewParts([
1754
+ logSummary.attentionCount > 0
1755
+ ? formatCountLabel("need attention", logSummary.attentionCount)
1756
+ : "",
1757
+ logSummary.successfulCount > 0
1758
+ ? formatCountLabel("successful", logSummary.successfulCount)
1759
+ : "",
1760
+ logSummary.localCount > 0
1761
+ ? formatCountLabel("local", logSummary.localCount)
1762
+ : "",
1763
+ logSummary.syncedCount > 0
1764
+ ? formatCountLabel("synced", logSummary.syncedCount)
1765
+ : "",
1766
+ ]));
1767
+ emit.blank();
1768
+ const groups = [
552
1769
  {
553
- name: source.name,
554
- company: source.company,
555
- description: source.description,
1770
+ title: "Needs attention",
1771
+ items: records.filter((record) => isAttentionLog(record.lastRunOutcome, record.dataState)),
556
1772
  },
557
- ]));
558
- }
559
- async function loadRegistrySources() {
560
- try {
561
- return ((await listAvailableSources(findDataConnectorsDir() ?? undefined)) ?? []);
562
- }
563
- catch {
564
- return [];
1773
+ {
1774
+ title: "Successful runs",
1775
+ items: records.filter((record) => !isAttentionLog(record.lastRunOutcome, record.dataState)),
1776
+ },
1777
+ ].filter((group) => group.items.length > 0);
1778
+ groups.forEach((group, groupIndex) => {
1779
+ if (groupIndex > 0) {
1780
+ emit.blank();
1781
+ }
1782
+ emit.section(formatCountLabel(group.title, group.items.length));
1783
+ for (const record of group.items) {
1784
+ emit.sourceTitle(record.name, [
1785
+ {
1786
+ text: formatLogOutcomeLabel(record.lastRunOutcome, record.dataState),
1787
+ tone: toneForLogOutcome(record.lastRunOutcome, record.dataState),
1788
+ },
1789
+ ]);
1790
+ emit.keyValue("Path", formatDisplayPath(record.path), "muted");
1791
+ if (record.lastRunAt) {
1792
+ emit.keyValue("Updated", formatTimestamp(record.lastRunAt), "muted");
1793
+ }
1794
+ }
1795
+ });
1796
+ emit.blank();
1797
+ if (nextSteps.length > 0) {
1798
+ const command = extractCommand(nextSteps[0]);
1799
+ if (command) {
1800
+ emit.next(command);
1801
+ }
1802
+ else {
1803
+ emit.detail(`Next: ${nextSteps[0]}`);
1804
+ }
1805
+ }
1806
+ return 0;
1807
+ }
1808
+ async function runSourceDetail(source, options) {
1809
+ const emit = createEmitter(options);
1810
+ const registrySources = await loadRegistrySources();
1811
+ const state = await readCliState();
1812
+ const match = registrySources.find((s) => s.id === source || s.name.toLowerCase() === source.toLowerCase());
1813
+ if (!match) {
1814
+ if (options.json) {
1815
+ process.stdout.write(`${JSON.stringify({ error: "unknown_source", source, message: `Unknown source: ${source}. Run \`vana sources\` to see available options.` })}\n`);
1816
+ }
1817
+ else {
1818
+ emit.info(`Unknown source: ${source}. Run \`vana sources\` to see available options.`);
1819
+ }
1820
+ return 1;
1821
+ }
1822
+ const stored = state.sources[match.id];
1823
+ const metadata = await readCachedConnectorMetadata(match.id, getConnectorCacheDir());
1824
+ const scopes = metadata?.scopes ?? [];
1825
+ const sourceStatus = stored
1826
+ ? {
1827
+ source: match.id,
1828
+ installed: Boolean(stored.connectorInstalled),
1829
+ sessionPresent: stored.sessionPresent ?? false,
1830
+ lastRunOutcome: stored.lastRunOutcome ?? null,
1831
+ dataState: stored.dataState,
1832
+ }
1833
+ : undefined;
1834
+ const badge = sourceStatus ? getSourceBadge(sourceStatus) : undefined;
1835
+ if (options.json) {
1836
+ process.stdout.write(`${JSON.stringify({
1837
+ id: match.id,
1838
+ name: match.name,
1839
+ company: match.company,
1840
+ description: match.description,
1841
+ version: match.version ?? stored?.connectorVersion,
1842
+ exportFrequency: match.exportFrequency ?? stored?.exportFrequency,
1843
+ authMode: match.authMode,
1844
+ scopes,
1845
+ scopeLabels: scopes.map((s) => s.label),
1846
+ connectorVersion: stored?.connectorVersion,
1847
+ lastCollectedAt: stored?.lastCollectedAt,
1848
+ dataState: stored?.dataState,
1849
+ })}\n`);
1850
+ return 0;
1851
+ }
1852
+ const iconPrefix = await renderIconInline(match.id);
1853
+ const badgeList = [];
1854
+ if (badge && badge.label !== "new") {
1855
+ badgeList.push({ text: badge.label, tone: badge.style });
1856
+ }
1857
+ emit.sourceTitle(`${iconPrefix}${match.name}`, badgeList);
1858
+ emit.blank();
1859
+ if (match.description) {
1860
+ emit.info(cleanDescription(match.description));
1861
+ emit.blank();
1862
+ }
1863
+ if (scopes.length > 0) {
1864
+ emit.section("Collects");
1865
+ for (const scope of scopes) {
1866
+ if (scope.description) {
1867
+ emit.keyValue(scope.label, cleanDescription(scope.description), "muted");
1868
+ }
1869
+ else {
1870
+ emit.bullet(scope.label);
1871
+ }
1872
+ }
1873
+ }
1874
+ if (stored?.connectorVersion &&
1875
+ match.version &&
1876
+ stored.connectorVersion !== match.version) {
1877
+ emit.blank();
1878
+ emit.detail(`A newer connector version is available (${match.version}). Reconnect to update.`);
1879
+ }
1880
+ emit.blank();
1881
+ emit.next(`vana connect ${match.id}`);
1882
+ return 0;
1883
+ }
1884
+ async function runCollect(source, options) {
1885
+ const emit = createEmitter(options);
1886
+ const state = await readCliState();
1887
+ const stored = state.sources[source];
1888
+ if (!stored || !stored.connectorInstalled) {
1889
+ if (options.json) {
1890
+ process.stdout.write(`${JSON.stringify({
1891
+ error: "not_previously_connected",
1892
+ source,
1893
+ message: `Source "${source}" has not been connected yet. Run \`vana connect ${source}\` first.`,
1894
+ })}\n`);
1895
+ }
1896
+ else {
1897
+ emit.info(`Source "${source}" has not been connected yet. Run \`vana connect ${source}\` first.`);
1898
+ }
1899
+ return 1;
1900
+ }
1901
+ return runConnect(source, options);
1902
+ }
1903
+ async function runCollectAll(options) {
1904
+ const emit = createEmitter(options);
1905
+ const state = await readCliState();
1906
+ const dueSources = Object.entries(state.sources)
1907
+ .filter(([, stored]) => stored?.connectorInstalled &&
1908
+ isCollectionDue(stored.exportFrequency, stored.lastCollectedAt))
1909
+ .map(([id]) => id);
1910
+ if (dueSources.length === 0) {
1911
+ if (options.json) {
1912
+ process.stdout.write(`${JSON.stringify({ message: "No sources are due for collection.", count: 0 })}\n`);
1913
+ }
1914
+ else {
1915
+ emit.info("No sources are due for collection.");
1916
+ }
1917
+ return 0;
1918
+ }
1919
+ let exitCode = 0;
1920
+ for (const source of dueSources) {
1921
+ const result = await runConnect(source, options);
1922
+ if (result !== 0) {
1923
+ exitCode = result;
1924
+ }
1925
+ }
1926
+ return exitCode;
1927
+ }
1928
+ async function runServerSync(options) {
1929
+ const emit = createEmitter(options);
1930
+ const target = await detectPersonalServerTarget();
1931
+ if (target.state !== "available") {
1932
+ if (options.json) {
1933
+ process.stdout.write(`${JSON.stringify({
1934
+ error: "personal_server_unavailable",
1935
+ message: "Personal Server is not available. Run `vana server set-url <url>` to configure.",
1936
+ })}\n`);
1937
+ }
1938
+ else {
1939
+ emit.info("Personal Server is not available. Run `vana server set-url <url>` to configure.");
1940
+ }
1941
+ return 1;
1942
+ }
1943
+ const state = await readCliState();
1944
+ // Find sources that are local-only OR have failed ingest scopes
1945
+ const pendingSources = Object.entries(state.sources).filter(([, stored]) => stored?.lastResultPath &&
1946
+ (stored.dataState === "collected_local" ||
1947
+ stored.dataState === "ingest_failed" ||
1948
+ stored?.ingestScopes?.some((s) => s.status === "failed")));
1949
+ if (pendingSources.length === 0) {
1950
+ if (options.json) {
1951
+ process.stdout.write(`${JSON.stringify({ message: "No pending datasets to sync.", syncedCount: 0 })}\n`);
1952
+ }
1953
+ else {
1954
+ emit.info("No pending datasets to sync.");
1955
+ }
1956
+ return 0;
1957
+ }
1958
+ let syncedCount = 0;
1959
+ const allScopeResults = [];
1960
+ for (const [source, stored] of pendingSources) {
1961
+ if (!stored?.lastResultPath) {
1962
+ continue;
1963
+ }
1964
+ const ingestEvents = await ingestResult(source, stored.lastResultPath, target);
1965
+ const resultEvent = ingestEvents.find((e) => e.type === "ingest-complete" ||
1966
+ e.type === "ingest-partial" ||
1967
+ e.type === "ingest-failed");
1968
+ const scopeResults = resultEvent?.scopeResults;
1969
+ const ingestCompleted = ingestEvents.some((e) => e.type === "ingest-complete");
1970
+ const ingestPartial = ingestEvents.some((e) => e.type === "ingest-partial");
1971
+ if (ingestCompleted || ingestPartial) {
1972
+ syncedCount++;
1973
+ const dataState = ingestCompleted || ingestPartial
1974
+ ? "ingested_personal_server"
1975
+ : stored.dataState;
1976
+ await updateSourceState(source, {
1977
+ dataState,
1978
+ ingestScopes: scopeResults?.map((r) => ({
1979
+ scope: r.scope,
1980
+ status: r.status,
1981
+ syncedAt: r.status === "stored" ? new Date().toISOString() : undefined,
1982
+ error: r.error,
1983
+ })),
1984
+ });
1985
+ }
1986
+ allScopeResults.push({ source, scopeResults });
1987
+ for (const event of ingestEvents) {
1988
+ emit.event(event);
1989
+ }
1990
+ }
1991
+ if (options.json) {
1992
+ process.stdout.write(`${JSON.stringify({ message: `Synced ${syncedCount} dataset(s).`, syncedCount })}\n`);
1993
+ }
1994
+ else {
1995
+ // Show per-scope results with scope manifest style
1996
+ const renderer = createHumanRenderer();
1997
+ for (const entry of allScopeResults) {
1998
+ if (entry.scopeResults && entry.scopeResults.length > 0) {
1999
+ emit.info(`${entry.source}:`);
2000
+ for (const sr of entry.scopeResults) {
2001
+ if (sr.status === "stored") {
2002
+ emit.info(` ${renderer.theme.success("\u2713")} ${sr.scope}`);
2003
+ }
2004
+ else {
2005
+ const errDetail = sr.error ?? "failed";
2006
+ emit.info(` ${renderer.theme.error("\u2717")} ${sr.scope} ${renderer.theme.muted(`\u2014 ${errDetail}`)}`);
2007
+ }
2008
+ }
2009
+ }
2010
+ }
2011
+ emit.blank();
2012
+ const allStored = allScopeResults.every((entry) => !entry.scopeResults ||
2013
+ entry.scopeResults.every((sr) => sr.status === "stored"));
2014
+ emit.success(`Synced ${syncedCount} dataset(s).`);
2015
+ emit.blank();
2016
+ if (allStored) {
2017
+ emit.next("vana data list");
2018
+ }
2019
+ else {
2020
+ emit.next("vana server sync");
2021
+ }
2022
+ }
2023
+ return 0;
2024
+ }
2025
+ async function runServerData(scope, options) {
2026
+ const emit = createEmitter(options);
2027
+ const target = await detectPersonalServerTarget();
2028
+ const state = await readCliState();
2029
+ // Gather locally-known scopes from state
2030
+ const localScopes = [];
2031
+ for (const [src, stored] of Object.entries(state.sources)) {
2032
+ if (stored?.ingestScopes) {
2033
+ for (const is of stored.ingestScopes) {
2034
+ localScopes.push({ scope: is.scope, source: src, status: is.status });
2035
+ }
2036
+ }
2037
+ }
2038
+ // If PS is available, try to list remote scopes via client
2039
+ let remoteScopes = [];
2040
+ if (target.state === "available" && target.url) {
2041
+ try {
2042
+ const { createPersonalServerClient: createClient } = await import("../personal-server/client.js");
2043
+ const client = createClient({ url: target.url });
2044
+ remoteScopes = await client.listScopes(scope);
2045
+ }
2046
+ catch {
2047
+ // Auth required or PS unavailable — fall back to local
2048
+ }
2049
+ }
2050
+ // Use remote scopes if available, otherwise fall back to local
2051
+ const scopeList = remoteScopes.length > 0
2052
+ ? remoteScopes.map((s) => ({
2053
+ scope: s.scope,
2054
+ detail: `${s.count} version${s.count !== 1 ? "s" : ""}`,
2055
+ }))
2056
+ : localScopes
2057
+ .filter((s) => s.status === "stored")
2058
+ .filter((s) => !scope || s.scope.startsWith(scope))
2059
+ .map((s) => ({ scope: s.scope, detail: "1 version" }));
2060
+ if (options.json) {
2061
+ process.stdout.write(`${JSON.stringify({
2062
+ count: scopeList.length,
2063
+ scopes: scopeList,
2064
+ source: remoteScopes.length > 0 ? "remote" : "local",
2065
+ })}\n`);
2066
+ return 0;
2067
+ }
2068
+ if (scopeList.length === 0) {
2069
+ emit.info("No scopes found.");
2070
+ if (target.state !== "available") {
2071
+ emit.detail("Personal Server is not available. Showing locally-known scopes only.");
2072
+ }
2073
+ return 0;
2074
+ }
2075
+ for (const entry of scopeList) {
2076
+ emit.keyValue(entry.scope, entry.detail, "muted");
2077
+ }
2078
+ if (remoteScopes.length === 0 && localScopes.length > 0) {
2079
+ emit.blank();
2080
+ emit.detail("Showing locally-known scopes. Connect your Personal Server for live data.");
2081
+ }
2082
+ return 0;
2083
+ }
2084
+ function getSourceBadge(source) {
2085
+ if (source.dataState === "collected_local" ||
2086
+ source.dataState === "ingested_personal_server" ||
2087
+ source.dataState === "ingest_failed") {
2088
+ return { label: "connected", style: "success" };
2089
+ }
2090
+ if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT ||
2091
+ source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
2092
+ return { label: "needs login", style: "warning" };
2093
+ }
2094
+ if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR ||
2095
+ source.lastRunOutcome === CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR) {
2096
+ return { label: "error", style: "error" };
2097
+ }
2098
+ return { label: "new", style: "muted" };
2099
+ }
2100
+ function isCollectionDue(frequency, lastCollectedAt) {
2101
+ if (!frequency || !lastCollectedAt) {
2102
+ return true;
2103
+ }
2104
+ const lastMs = new Date(lastCollectedAt).getTime();
2105
+ if (Number.isNaN(lastMs)) {
2106
+ return true;
2107
+ }
2108
+ const now = Date.now();
2109
+ const elapsed = now - lastMs;
2110
+ const intervalMs = parseFrequencyToMs(frequency);
2111
+ return elapsed >= intervalMs;
2112
+ }
2113
+ function parseFrequencyToMs(frequency) {
2114
+ const lower = frequency.toLowerCase().trim();
2115
+ if (lower === "daily") {
2116
+ return 24 * 60 * 60 * 1000;
2117
+ }
2118
+ if (lower === "weekly") {
2119
+ return 7 * 24 * 60 * 60 * 1000;
2120
+ }
2121
+ if (lower === "monthly") {
2122
+ return 30 * 24 * 60 * 60 * 1000;
2123
+ }
2124
+ const match = /^(\d+)\s*(h|d|m|w)$/i.exec(lower);
2125
+ if (match) {
2126
+ const value = parseInt(match[1], 10);
2127
+ const unit = match[2].toLowerCase();
2128
+ if (unit === "h")
2129
+ return value * 60 * 60 * 1000;
2130
+ if (unit === "d")
2131
+ return value * 24 * 60 * 60 * 1000;
2132
+ if (unit === "w")
2133
+ return value * 7 * 24 * 60 * 60 * 1000;
2134
+ if (unit === "m")
2135
+ return value * 30 * 24 * 60 * 60 * 1000;
2136
+ }
2137
+ // Default to daily if unparseable.
2138
+ return 24 * 60 * 60 * 1000;
2139
+ }
2140
+ async function renderIconInline(source) {
2141
+ const iconPath = findCachedIconPath(source);
2142
+ if (!iconPath) {
2143
+ return "";
2144
+ }
2145
+ try {
2146
+ // terminal-image is optional — not in package.json dependencies.
2147
+ // The `as string` cast prevents TypeScript from resolving the module at compile time.
2148
+ const terminalImage = (await import("terminal-image"));
2149
+ const imageBuffer = await fsp.readFile(iconPath);
2150
+ return await terminalImage.default.buffer(imageBuffer, {
2151
+ width: 2,
2152
+ height: 1,
2153
+ });
2154
+ }
2155
+ catch {
2156
+ return "";
2157
+ }
2158
+ }
2159
+ function findCachedIconPath(source) {
2160
+ const cacheDir = getConnectorCacheDir();
2161
+ const extensions = [".png", ".svg", ".jpg", ".jpeg", ".webp"];
2162
+ for (const ext of extensions) {
2163
+ const candidate = path.join(cacheDir, `${source}.icon${ext}`);
2164
+ if (fs.existsSync(candidate)) {
2165
+ return candidate;
2166
+ }
2167
+ }
2168
+ return null;
2169
+ }
2170
+ function createEmitter(options) {
2171
+ const renderer = createHumanRenderer();
2172
+ return {
2173
+ event(event) {
2174
+ if (options.json) {
2175
+ process.stdout.write(`${JSON.stringify(event)}\n`);
2176
+ }
2177
+ },
2178
+ info(message) {
2179
+ if (options.json || options.quiet) {
2180
+ return;
2181
+ }
2182
+ process.stdout.write(`${message}\n`);
2183
+ },
2184
+ blank() {
2185
+ if (options.json || options.quiet) {
2186
+ return;
2187
+ }
2188
+ process.stdout.write("\n");
2189
+ },
2190
+ title(message) {
2191
+ if (options.json || options.quiet) {
2192
+ return;
2193
+ }
2194
+ process.stdout.write(`${renderer.title(message)}\n`);
2195
+ },
2196
+ success(message) {
2197
+ if (options.json || options.quiet) {
2198
+ return;
2199
+ }
2200
+ process.stdout.write(`${renderer.success(message)}\n`);
2201
+ },
2202
+ section(message) {
2203
+ if (options.json || options.quiet) {
2204
+ return;
2205
+ }
2206
+ process.stdout.write(`${renderer.section(message)}\n`);
2207
+ },
2208
+ keyValue(label, value, tone = "muted") {
2209
+ if (options.json || options.quiet) {
2210
+ return;
2211
+ }
2212
+ process.stdout.write(`${renderer.keyValue(label, value, tone)}\n`);
2213
+ },
2214
+ detail(message) {
2215
+ if (options.json || options.quiet) {
2216
+ return;
2217
+ }
2218
+ process.stdout.write(`${renderer.detail(message)}\n`);
2219
+ },
2220
+ next(command) {
2221
+ if (options.json || options.quiet) {
2222
+ return;
2223
+ }
2224
+ process.stdout.write(` ${renderer.theme.muted("Next:")} ${renderer.theme.code(command)}\n`);
2225
+ },
2226
+ bullet(message) {
2227
+ if (options.json || options.quiet) {
2228
+ return;
2229
+ }
2230
+ process.stdout.write(`${renderer.bullet(message)}\n`);
2231
+ },
2232
+ sourceTitle(name, badges = []) {
2233
+ if (options.json || options.quiet) {
2234
+ return;
2235
+ }
2236
+ process.stdout.write(`${renderer.sourceTitle(name, badges.map((badge) => renderer.badge(badge.text, badge.tone)))}\n`);
2237
+ },
2238
+ badge(text, tone = "muted") {
2239
+ return renderer.badge(text, tone);
2240
+ },
2241
+ code(text) {
2242
+ return renderer.theme.code(text);
2243
+ },
2244
+ };
2245
+ }
2246
+ export function displaySource(source, labels = {}) {
2247
+ return labels[source] ?? source.charAt(0).toUpperCase() + source.slice(1);
2248
+ }
2249
+ function formatCountLabel(label, count) {
2250
+ const normalizedLabel = label.charAt(0).toUpperCase() + label.slice(1);
2251
+ return `${normalizedLabel} (${count})`;
2252
+ }
2253
+ function joinOverviewParts(parts) {
2254
+ return parts.filter(Boolean).join(" · ");
2255
+ }
2256
+ function humanizeField(value) {
2257
+ return value
2258
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
2259
+ .replace(/[_-]/g, " ")
2260
+ .replace(/^\w/, (match) => match.toUpperCase());
2261
+ }
2262
+ export function humanizeIssue(message) {
2263
+ if (/checksum|mismatch/i.test(message)) {
2264
+ return "Connector is out of date. Will auto-update on next connect.";
2265
+ }
2266
+ return message;
2267
+ }
2268
+ function formatHumanSourceMessage(message, source, displayName) {
2269
+ if (!message || source === displayName) {
2270
+ return message;
2271
+ }
2272
+ return message.replace(new RegExp(`\\b${escapeRegExp(source)}\\b`, "gi"), displayName);
2273
+ }
2274
+ function escapeRegExp(value) {
2275
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2276
+ }
2277
+ function getErrorLogPath(error) {
2278
+ if (error &&
2279
+ typeof error === "object" &&
2280
+ "logPath" in error &&
2281
+ typeof error.logPath === "string") {
2282
+ return error.logPath;
2283
+ }
2284
+ return null;
2285
+ }
2286
+ export async function gatherSourceStatuses(storedSources, metadata = {}) {
2287
+ const installedFiles = await listInstalledConnectorFiles();
2288
+ const sourceNames = new Set([
2289
+ ...Object.keys(storedSources),
2290
+ ...installedFiles.map((file) => file.source),
2291
+ ]);
2292
+ return [...sourceNames]
2293
+ .map((source) => {
2294
+ const stored = storedSources[source] ?? {};
2295
+ const installed = installedFiles.some((file) => file.source === source);
2296
+ const details = metadata[source];
2297
+ const dataState = stored.dataState === "ingested_personal_server"
2298
+ ? "ingested_personal_server"
2299
+ : stored.dataState === "ingest_failed"
2300
+ ? "ingest_failed"
2301
+ : stored.dataState === "collected_local"
2302
+ ? "collected_local"
2303
+ : "none";
2304
+ const ingestScopes = stored.ingestScopes;
2305
+ const syncedScopeCount = ingestScopes?.filter((s) => s.status === "stored").length ?? 0;
2306
+ const failedScopeCount = ingestScopes?.filter((s) => s.status === "failed").length ?? 0;
2307
+ const isOverdue = stored.lastCollectedAt && stored.exportFrequency
2308
+ ? isCollectionDue(stored.exportFrequency, stored.lastCollectedAt)
2309
+ : undefined;
2310
+ const suggestedNextCollectionAt = stored.lastCollectedAt && stored.exportFrequency
2311
+ ? new Date(new Date(stored.lastCollectedAt).getTime() +
2312
+ parseFrequencyToMs(stored.exportFrequency)).toISOString()
2313
+ : undefined;
2314
+ return {
2315
+ source,
2316
+ name: details?.name,
2317
+ company: details?.company,
2318
+ description: details?.description,
2319
+ authMode: details?.authMode ?? inferInstalledAuthMode(installedFiles, source),
2320
+ connectorVersion: stored.connectorVersion,
2321
+ exportFrequency: stored.exportFrequency,
2322
+ lastCollectedAt: stored.lastCollectedAt,
2323
+ installed,
2324
+ sessionPresent: stored.sessionPresent ?? false,
2325
+ lastRunAt: stored.lastRunAt ?? null,
2326
+ lastRunOutcome: stored.lastRunOutcome ?? null,
2327
+ dataState,
2328
+ lastError: stored.lastError ?? null,
2329
+ lastResultPath: stored.lastResultPath ?? null,
2330
+ lastLogPath: stored.lastLogPath ?? null,
2331
+ ingestScopes,
2332
+ syncedScopeCount: syncedScopeCount > 0 ? syncedScopeCount : undefined,
2333
+ failedScopeCount: failedScopeCount > 0 ? failedScopeCount : undefined,
2334
+ suggestedNextCollectionAt,
2335
+ isOverdue,
2336
+ };
2337
+ })
2338
+ .sort(compareSourceStatusOrder);
2339
+ }
2340
+ export async function listInstalledConnectorFiles() {
2341
+ const connectorsDir = getConnectorCacheDir();
2342
+ try {
2343
+ const results = [];
2344
+ const entries = await fsp.readdir(connectorsDir, { withFileTypes: true });
2345
+ for (const entry of entries) {
2346
+ if (!entry.isDirectory()) {
2347
+ continue;
2348
+ }
2349
+ const companyDir = path.join(connectorsDir, entry.name);
2350
+ const files = await fsp.readdir(companyDir);
2351
+ for (const file of files) {
2352
+ if (!file.endsWith("-playwright.js")) {
2353
+ continue;
2354
+ }
2355
+ results.push({
2356
+ source: file.replace(/-playwright\.js$/, ""),
2357
+ path: path.join(companyDir, file),
2358
+ });
2359
+ }
2360
+ }
2361
+ return results;
2362
+ }
2363
+ catch {
2364
+ return [];
2365
+ }
2366
+ }
2367
+ function formatSourceStatusDetails(source) {
2368
+ const details = [];
2369
+ const displayName = source.name ?? displaySource(source.source);
2370
+ if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2371
+ details.push(source.lastError
2372
+ ? {
2373
+ kind: "text",
2374
+ message: `${source.lastError}. Run \`vana connect ${source.source}\` interactively.`,
2375
+ }
2376
+ : {
2377
+ kind: "text",
2378
+ message: `Run \`vana connect ${source.source}\` interactively.`,
2379
+ });
2380
+ }
2381
+ if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
2382
+ details.push({
2383
+ kind: "text",
2384
+ message: `Run \`vana connect ${source.source}\` without \`--no-input\` to complete the manual browser step.`,
2385
+ });
2386
+ }
2387
+ if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
2388
+ details.push(source.lastError
2389
+ ? {
2390
+ kind: "text",
2391
+ message: formatHumanSourceMessage(source.lastError, source.source, displayName),
2392
+ }
2393
+ : {
2394
+ kind: "text",
2395
+ message: "The last connector run failed.",
2396
+ });
2397
+ }
2398
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
2399
+ details.push(source.lastError
2400
+ ? {
2401
+ kind: "text",
2402
+ message: formatHumanSourceMessage(source.lastError, source.source, displayName),
2403
+ }
2404
+ : {
2405
+ kind: "text",
2406
+ message: "No connector is available for this source.",
2407
+ });
2408
+ }
2409
+ if (!source.lastRunOutcome && source.installed) {
2410
+ details.push({
2411
+ kind: "text",
2412
+ message: `Run \`vana connect ${source.source}\` to collect data.`,
2413
+ });
2414
+ }
2415
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTED_LOCAL_ONLY &&
2416
+ source.lastResultPath) {
2417
+ details.push({
2418
+ kind: "text",
2419
+ message: `Inspect the latest local dataset with \`vana data show ${source.source}\`.`,
2420
+ });
2421
+ }
2422
+ if (source.sessionPresent &&
2423
+ (source.lastRunOutcome === CliOutcomeStatus.CONNECTED_LOCAL_ONLY ||
2424
+ source.lastRunOutcome === CliOutcomeStatus.CONNECTED_AND_INGESTED ||
2425
+ source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED)) {
2426
+ details.push({
2427
+ kind: "row",
2428
+ label: "Session",
2429
+ value: "Session cached.",
2430
+ tone: "muted",
2431
+ });
2432
+ }
2433
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTED_AND_INGESTED) {
2434
+ details.push({
2435
+ kind: "text",
2436
+ message: `Inspect the latest local dataset with \`vana data show ${source.source}\` or use your Personal Server copy.`,
2437
+ });
2438
+ }
2439
+ if (source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED) {
2440
+ details.push(source.lastError
2441
+ ? {
2442
+ kind: "text",
2443
+ message: `${source.lastError} Inspect the local dataset with \`vana data show ${source.source}\`.`,
2444
+ }
2445
+ : {
2446
+ kind: "text",
2447
+ message: `Personal Server sync failed. Inspect the local dataset with \`vana data show ${source.source}\`.`,
2448
+ });
2449
+ }
2450
+ if (source.dataState === "ingested_personal_server") {
2451
+ details.push({
2452
+ kind: "row",
2453
+ label: "State",
2454
+ value: "Synced to Personal Server",
2455
+ tone: "success",
2456
+ });
2457
+ }
2458
+ else if (source.dataState === "ingest_failed") {
2459
+ details.push({
2460
+ kind: "row",
2461
+ label: "State",
2462
+ value: "Saved locally, sync failed",
2463
+ tone: "warning",
2464
+ });
2465
+ }
2466
+ else if (source.dataState === "collected_local") {
2467
+ details.push({
2468
+ kind: "row",
2469
+ label: "State",
2470
+ value: "Saved locally",
2471
+ tone: "muted",
2472
+ });
2473
+ }
2474
+ if (source.lastRunAt) {
2475
+ details.push({
2476
+ kind: "row",
2477
+ label: "Updated",
2478
+ value: `${formatTimestamp(source.lastRunAt)} (${formatRelativeTime(source.lastRunAt)})`,
2479
+ tone: "muted",
2480
+ });
2481
+ }
2482
+ if (source.lastResultPath && source.dataState !== "none") {
2483
+ details.push({
2484
+ kind: "row",
2485
+ label: "Path",
2486
+ value: formatDisplayPath(source.lastResultPath),
2487
+ tone: "muted",
2488
+ });
2489
+ }
2490
+ if (source.lastLogPath &&
2491
+ source.lastRunOutcome &&
2492
+ source.lastRunOutcome !== CliOutcomeStatus.CONNECTED_LOCAL_ONLY &&
2493
+ source.lastRunOutcome !== CliOutcomeStatus.CONNECTED_AND_INGESTED) {
2494
+ details.push({
2495
+ kind: "row",
2496
+ label: "Run log",
2497
+ value: formatDisplayPath(source.lastLogPath),
2498
+ tone: "muted",
2499
+ });
2500
+ }
2501
+ return details;
2502
+ }
2503
+ export function buildStatusNextSteps(sources, sourceLabels = {}, runtime = "unhealthy", availableSources = []) {
2504
+ const nextSteps = [];
2505
+ const highestPriority = [...sources].sort(compareSourceStatusOrder)[0];
2506
+ const connectedSources = sources.filter((source) => source.dataState === "collected_local" ||
2507
+ source.dataState === "ingested_personal_server" ||
2508
+ source.dataState === "ingest_failed");
2509
+ const needsAttention = sources.some((source) => rankSourceStatus(source) <= 4);
2510
+ const highestPriorityLabel = highestPriority
2511
+ ? displaySource(highestPriority.source, sourceLabels)
2512
+ : null;
2513
+ const suggestedSource = availableSources.find((source) => source.authMode !== "legacy") ??
2514
+ availableSources[0];
2515
+ if (!highestPriority) {
2516
+ if (runtime === "installed") {
2517
+ if (suggestedSource) {
2518
+ nextSteps.push(`Connect ${suggestedSource.name} with \`vana connect ${suggestedSource.id}\`.`);
2519
+ }
2520
+ else {
2521
+ nextSteps.push("Connect your first source with `vana connect`.");
2522
+ }
2523
+ }
2524
+ else if (runtime === "missing") {
2525
+ nextSteps.push("Install the local runtime with `vana setup`.");
2526
+ nextSteps.push("Inspect install health with `vana doctor`.");
2527
+ }
2528
+ else if (runtime === "unhealthy") {
2529
+ nextSteps.push("Inspect install health with `vana doctor`.");
2530
+ }
2531
+ }
2532
+ else if (highestPriority.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2533
+ nextSteps.push(`Continue ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
2534
+ if (highestPriority.lastLogPath) {
2535
+ nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
2536
+ }
2537
+ }
2538
+ else if (highestPriority.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
2539
+ nextSteps.push(`Complete the manual browser step for ${highestPriorityLabel} with \`vana connect ${highestPriority.source}\`.`);
2540
+ if (highestPriority.lastLogPath) {
2541
+ nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
2542
+ }
2543
+ }
2544
+ else if (highestPriority.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
2545
+ nextSteps.push("Browse available sources with `vana sources`.");
2546
+ if (highestPriority.lastLogPath) {
2547
+ nextSteps.push(`Inspect the latest run log with \`vana logs ${highestPriority.source}\`.`);
2548
+ }
2549
+ }
2550
+ else if (highestPriority.dataState === "collected_local" ||
2551
+ highestPriority.dataState === "ingested_personal_server" ||
2552
+ highestPriority.dataState === "ingest_failed") {
2553
+ if (connectedSources.length > 1) {
2554
+ nextSteps.push("Review your collected data with `vana data list`.");
2555
+ }
2556
+ else {
2557
+ nextSteps.push(`Inspect the latest dataset with \`vana data show ${highestPriority.source}\`.`);
2558
+ }
2559
+ }
2560
+ if (connectedSources.length > 0 && needsAttention) {
2561
+ nextSteps.push(connectedSources.length > 1
2562
+ ? "Review the data you already collected with `vana data list`."
2563
+ : `Inspect the data you already collected with \`vana data show ${connectedSources[0].source}\`.`);
2564
+ }
2565
+ if (sources.some((source) => source.installed || source.lastRunOutcome) &&
2566
+ (!needsAttention || connectedSources.length === 0)) {
2567
+ nextSteps.push("Connect another source with `vana sources`.");
2568
+ }
2569
+ if (runtime !== "installed" ||
2570
+ sources.some((source) => source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR ||
2571
+ source.lastRunOutcome === CliOutcomeStatus.UNEXPECTED_INTERNAL_ERROR)) {
2572
+ nextSteps.push("Inspect install health with `vana doctor`.");
2573
+ }
2574
+ return [...new Set(nextSteps)];
2575
+ }
2576
+ export function buildSourcesNextSteps(recommendedSource, connectedCount) {
2577
+ const nextSteps = [];
2578
+ if (connectedCount > 0) {
2579
+ nextSteps.push("Inspect what you already collected with `vana data list`.");
2580
+ }
2581
+ if (recommendedSource) {
2582
+ nextSteps.push(`${recommendedSource.authMode === "legacy" ? "Complete" : "Connect"} ${recommendedSource.name} with \`vana connect ${recommendedSource.id}\`.`);
2583
+ }
2584
+ nextSteps.push("Or browse the guided picker with `vana connect`.");
2585
+ return [...new Set(nextSteps)];
2586
+ }
2587
+ export function buildDataListNextSteps(datasetRecords, registrySources) {
2588
+ if (datasetRecords.length === 0) {
2589
+ const suggestedSource = registrySources.find((source) => source.authMode !== "legacy") ??
2590
+ registrySources[0];
2591
+ return [
2592
+ suggestedSource
2593
+ ? `Collect your first dataset with \`vana connect ${suggestedSource.id}\`.`
2594
+ : "Collect your first dataset with `vana connect`.",
2595
+ "Check overall status with `vana status`.",
2596
+ ];
2597
+ }
2598
+ return [
2599
+ `Inspect ${datasetRecords[0].name ?? displaySource(datasetRecords[0].source)} with \`vana data show ${datasetRecords[0].source}\`.`,
2600
+ `Or print its path with \`vana data path ${datasetRecords[0].source}\`.`,
2601
+ "Connect another source with `vana sources`.",
2602
+ ];
2603
+ }
2604
+ export function buildDataShowNextSteps(source, datasetCount, sourceLabels = {}) {
2605
+ return [
2606
+ `Print the path with \`vana data path ${source}\`.`,
2607
+ `Reconnect ${displaySource(source, sourceLabels)} with \`vana connect ${source}\`.`,
2608
+ ...(datasetCount > 1
2609
+ ? ["See all datasets with `vana data list`."]
2610
+ : ["Connect another source with `vana sources`."]),
2611
+ ];
2612
+ }
2613
+ function buildLogsNextSteps(records) {
2614
+ if (records.length === 0) {
2615
+ return [
2616
+ "Run `vana connect <source>` to create a connector run log.",
2617
+ "Check overall status with `vana status`.",
2618
+ ];
2619
+ }
2620
+ const attentionRecord = records.find((record) => isAttentionLog(record.lastRunOutcome, record.dataState));
2621
+ const successfulRecord = records.find((record) => !isAttentionLog(record.lastRunOutcome, record.dataState));
2622
+ return [
2623
+ attentionRecord
2624
+ ? `Inspect the latest issue log with \`vana logs ${attentionRecord.source}\`.`
2625
+ : `Print the latest log path with \`vana logs ${records[0].source}\`.`,
2626
+ ...(successfulRecord
2627
+ ? [
2628
+ `Inspect a successful run with \`vana logs ${successfulRecord.source}\`.`,
2629
+ ]
2630
+ : []),
2631
+ "Check overall status with `vana status`.",
2632
+ ];
2633
+ }
2634
+ /** Extract a `vana ...` command from a next-step sentence wrapped in backticks. */
2635
+ function extractCommand(sentence) {
2636
+ const match = sentence.match(/`(vana\s[^`]+)`/);
2637
+ return match ? match[1] : null;
2638
+ }
2639
+ // describeConnectTrust and buildConnectChoices removed — replaced by clack-based picker
2640
+ function formatMissingConnectSourceMessage(source) {
2641
+ if (source) {
2642
+ return `Specify a source. Start with \`vana connect ${source.id}\`, or run \`vana sources\` to see available options.`;
2643
+ }
2644
+ return "Specify a source. Run `vana sources` to see available options.";
2645
+ }
2646
+ // formatSourcePickerDescription removed — replaced by clack-based picker with hints
2647
+ function normalizeArgv(argv) {
2648
+ if (argv[2] === "connect" &&
2649
+ ["list", "status", "setup"].includes(argv[3] ?? "")) {
2650
+ const mapping = {
2651
+ list: "sources",
2652
+ status: "status",
2653
+ setup: "setup",
2654
+ };
2655
+ return [argv[0], argv[1], mapping[argv[3]], ...argv.slice(4)];
2656
+ }
2657
+ return argv;
2658
+ }
2659
+ export function getCliVersion() {
2660
+ if (process.env.VANA_APP_ROOT) {
2661
+ try {
2662
+ const packageJson = JSON.parse(fs.readFileSync(path.join(process.env.VANA_APP_ROOT, "package.json"), "utf8"));
2663
+ if (packageJson.version) {
2664
+ return packageJson.version;
2665
+ }
2666
+ }
2667
+ catch {
2668
+ // Fall through to the repo/dev package metadata.
2669
+ }
2670
+ }
2671
+ try {
2672
+ const packageJson = require("../../package.json");
2673
+ if (packageJson.version) {
2674
+ return packageJson.version;
2675
+ }
2676
+ }
2677
+ catch {
2678
+ // Fall through to the hard default.
2679
+ }
2680
+ return "0.0.0";
2681
+ }
2682
+ export function getCliChannel(version = getCliVersion()) {
2683
+ if (version.includes("canary")) {
2684
+ return "canary";
2685
+ }
2686
+ const candidates = [process.env.VANA_APP_ROOT ?? "", process.execPath].map((value) => value.replace(/\\/g, "/").toLowerCase());
2687
+ return candidates.some((normalizedPath) => /\/releases\/canary-[^/]+(?:\/app)?$/.test(normalizedPath))
2688
+ ? "canary"
2689
+ : "stable";
2690
+ }
2691
+ export function getCliInstallMethod(execPath = process.execPath) {
2692
+ const candidates = [process.env.VANA_APP_ROOT ?? "", execPath].map((value) => value.replace(/\\/g, "/").toLowerCase());
2693
+ for (const normalizedPath of candidates) {
2694
+ if (!normalizedPath) {
2695
+ continue;
2696
+ }
2697
+ if (normalizedPath.includes("/cellar/vana/")) {
2698
+ return "homebrew";
2699
+ }
2700
+ if (normalizedPath.includes("/.local/share/vana/") ||
2701
+ normalizedPath.includes("/appdata/local/vana/") ||
2702
+ normalizedPath.endsWith("/current/app") ||
2703
+ /\/releases\/[^/]+\/app$/.test(normalizedPath)) {
2704
+ return "installer";
2705
+ }
2706
+ if (normalizedPath.endsWith("/node") ||
2707
+ normalizedPath.endsWith("/node.exe") ||
2708
+ normalizedPath.includes("/.nvm/") ||
2709
+ normalizedPath.includes("/volta/") ||
2710
+ normalizedPath.includes("/pnpm/")) {
2711
+ return "development";
2712
+ }
2713
+ }
2714
+ return "unknown";
2715
+ }
2716
+ function getCliAppRoot(execPath = process.execPath) {
2717
+ return process.env.VANA_APP_ROOT ?? path.join(path.dirname(execPath), "app");
2718
+ }
2719
+ export function getDoctorAppRootPath(installMethod, execPath = process.execPath) {
2720
+ if (process.env.VANA_APP_ROOT) {
2721
+ return process.env.VANA_APP_ROOT;
2722
+ }
2723
+ if (installMethod === "homebrew" || installMethod === "installer") {
2724
+ return getCliAppRoot(execPath);
2725
+ }
2726
+ return null;
2727
+ }
2728
+ export function formatInstallMethodLabel(method) {
2729
+ switch (method) {
2730
+ case "homebrew":
2731
+ return "Homebrew";
2732
+ case "installer":
2733
+ return "Hosted installer";
2734
+ case "development":
2735
+ return "Development checkout";
2736
+ default:
2737
+ return "Unknown";
2738
+ }
2739
+ }
2740
+ export function getLifecycleCommands(installMethod, channel) {
2741
+ switch (installMethod) {
2742
+ case "homebrew":
2743
+ return {
2744
+ upgrade: "brew update && brew upgrade vana",
2745
+ uninstall: "brew uninstall vana",
2746
+ };
2747
+ case "installer":
2748
+ if (process.platform === "win32") {
2749
+ return {
2750
+ upgrade: 'powershell -Command "irm https://raw.githubusercontent.com/vana-com/vana-connect/main/install/install.ps1 | iex"',
2751
+ uninstall: 'powershell -Command "Remove-Item $env:LOCALAPPDATA\\Vana -Recurse -Force; Remove-Item $env:USERPROFILE\\.vana -Recurse -Force"',
2752
+ };
2753
+ }
2754
+ return {
2755
+ upgrade: channel === "canary"
2756
+ ? "curl -fsSL https://raw.githubusercontent.com/vana-com/vana-connect/feat/connect-cli-v1/install/install.sh | sh -s -- --version canary-feat-connect-cli-v1"
2757
+ : "curl -fsSL https://raw.githubusercontent.com/vana-com/vana-connect/main/install/install.sh | sh",
2758
+ uninstall: "rm -f ~/.local/bin/vana && rm -rf ~/.local/share/vana ~/.vana",
2759
+ };
2760
+ case "development":
2761
+ return {
2762
+ upgrade: "git pull && pnpm install && pnpm build",
2763
+ uninstall: "Remove the local checkout and any generated ~/.vana state.",
2764
+ };
2765
+ default:
2766
+ return {
2767
+ upgrade: "Reinstall vana using Homebrew or the hosted installer.",
2768
+ uninstall: "Remove the installed vana binary and any ~/.vana state you no longer need.",
2769
+ };
2770
+ }
2771
+ }
2772
+ function extractGlobalOptions(argv) {
2773
+ return {
2774
+ json: argv.includes("--json"),
2775
+ noInput: argv.includes("--no-input"),
2776
+ ipc: argv.includes("--ipc"),
2777
+ yes: argv.includes("--yes"),
2778
+ quiet: argv.includes("--quiet"),
2779
+ detach: argv.includes("--detach"),
2780
+ };
2781
+ }
2782
+ export function createSourceLabelMap(sources) {
2783
+ return Object.fromEntries(sources.map((source) => [source.id, source.name]));
2784
+ }
2785
+ export function createSourceMetadataMap(sources) {
2786
+ return Object.fromEntries(sources.map((source) => [
2787
+ source.id,
2788
+ {
2789
+ name: source.name,
2790
+ company: source.company,
2791
+ description: source.description,
2792
+ authMode: source.authMode,
2793
+ },
2794
+ ]));
2795
+ }
2796
+ // formatAuthModeBadge removed — replaced by clack-based picker with hints
2797
+ export function getSourceStatusPresentation(source) {
2798
+ if (!source.installed && !source.lastRunOutcome) {
2799
+ return { label: "not connected", tone: "muted" };
2800
+ }
2801
+ if (!source.lastRunOutcome) {
2802
+ return { label: "installed", tone: "success" };
2803
+ }
2804
+ if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2805
+ return { label: "needs input", tone: "warning" };
2806
+ }
2807
+ if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
2808
+ return { label: "error", tone: "error" };
2809
+ }
2810
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
2811
+ return { label: "unavailable", tone: "warning" };
2812
+ }
2813
+ if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
2814
+ return { label: "manual step", tone: "warning" };
2815
+ }
2816
+ if (source.dataState === "ingested_personal_server") {
2817
+ // Check per-scope state for more granular badges
2818
+ if (source.ingestScopes && source.ingestScopes.length > 0) {
2819
+ const storedCount = source.ingestScopes.filter((s) => s.status === "stored").length;
2820
+ const failedCount = source.ingestScopes.filter((s) => s.status === "failed").length;
2821
+ if (failedCount > 0 && storedCount > 0) {
2822
+ return { label: "partial sync", tone: "warning" };
2823
+ }
2824
+ if (failedCount > 0 && storedCount === 0) {
2825
+ return { label: "sync failed", tone: "error" };
2826
+ }
2827
+ }
2828
+ return { label: "synced", tone: "success" };
2829
+ }
2830
+ if (source.dataState === "collected_local") {
2831
+ return { label: "local", tone: "muted" };
2832
+ }
2833
+ if (source.dataState === "ingest_failed") {
2834
+ return { label: "sync failed", tone: "error" };
2835
+ }
2836
+ return { label: "connected", tone: "success" };
2837
+ }
2838
+ export function toneForRuntime(runtime) {
2839
+ if (runtime === "installed") {
2840
+ return "success";
2841
+ }
2842
+ if (runtime === "missing") {
2843
+ return "warning";
2844
+ }
2845
+ return "muted";
2846
+ }
2847
+ // formatProgressUpdate removed — replaced by ConnectRenderer scope methods
2848
+ /**
2849
+ * Extract a human-readable scope name from a progress-update event.
2850
+ * The scope name comes from `phase.label` when `phase` is a structured object.
2851
+ */
2852
+ function extractScopeName(event) {
2853
+ if (event.phase &&
2854
+ typeof event.phase === "object" &&
2855
+ "label" in event.phase &&
2856
+ typeof event.phase.label === "string") {
2857
+ return event.phase.label;
2858
+ }
2859
+ return null;
2860
+ }
2861
+ /**
2862
+ * Format detail text for a completed scope (e.g. "8 found").
2863
+ * Extracts count from event.count or parses it from the message.
2864
+ */
2865
+ function formatScopeDetail(event) {
2866
+ if (typeof event.count === "number") {
2867
+ return `${event.count} found`;
2868
+ }
2869
+ // Try to extract a count from the completion message (e.g. "Complete! 8 repositories collected.")
2870
+ if (typeof event.message === "string") {
2871
+ const match = event.message.match(/(\d+)\s+\w+/);
2872
+ if (match) {
2873
+ return match[0];
2874
+ }
2875
+ }
2876
+ return undefined;
2877
+ }
2878
+ // shouldRenderStatusUpdate removed — status updates are silent in the new design
2879
+ function inferInstalledAuthMode(installedFiles, source) {
2880
+ const match = installedFiles.find((file) => file.source === source);
2881
+ if (!match) {
2882
+ return undefined;
2883
+ }
2884
+ try {
2885
+ const script = fs.readFileSync(match.path, "utf8");
2886
+ if (/page\.requestInput\(/.test(script)) {
2887
+ return "interactive";
2888
+ }
2889
+ if (/page\.(showBrowser|promptUser)\(/.test(script)) {
2890
+ return "legacy";
2891
+ }
2892
+ return "automated";
2893
+ }
2894
+ catch {
2895
+ return undefined;
2896
+ }
2897
+ }
2898
+ export async function loadRegistrySources() {
2899
+ try {
2900
+ return ((await listAvailableSources(findDataConnectorsDir() ?? undefined)) ?? []).sort(compareRegistrySourceOrder);
2901
+ }
2902
+ catch {
2903
+ return [];
2904
+ }
2905
+ }
2906
+ function compareRegistrySourceOrder(left, right) {
2907
+ return (rankAuthMode(left.authMode) - rankAuthMode(right.authMode) ||
2908
+ left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
2909
+ }
2910
+ export function compareSourceStatusOrder(left, right) {
2911
+ return (rankSourceStatus(left) - rankSourceStatus(right) ||
2912
+ compareRegistrySourceOrder({
2913
+ id: left.source,
2914
+ name: left.name ?? displaySource(left.source),
2915
+ authMode: left.authMode,
2916
+ }, {
2917
+ id: right.source,
2918
+ name: right.name ?? displaySource(right.source),
2919
+ authMode: right.authMode,
2920
+ }));
2921
+ }
2922
+ export function rankSourceStatus(source) {
2923
+ if (source.lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
2924
+ return 0;
2925
+ }
2926
+ if (source.lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
2927
+ return 1;
2928
+ }
2929
+ if (source.lastRunOutcome === CliOutcomeStatus.INGEST_FAILED) {
2930
+ return 2;
2931
+ }
2932
+ if (source.lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
2933
+ return 3;
2934
+ }
2935
+ if (source.lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
2936
+ return 4;
2937
+ }
2938
+ if (source.dataState === "ingested_personal_server") {
2939
+ return 5;
2940
+ }
2941
+ if (source.dataState === "collected_local") {
2942
+ return 6;
2943
+ }
2944
+ if (source.installed) {
2945
+ return 7;
2946
+ }
2947
+ return 8;
2948
+ }
2949
+ function rankAuthMode(authMode) {
2950
+ if (authMode === "interactive") {
2951
+ return 0;
2952
+ }
2953
+ if (authMode === "automated") {
2954
+ return 1;
2955
+ }
2956
+ if (authMode === "legacy") {
2957
+ return 2;
2958
+ }
2959
+ return 3;
2960
+ }
2961
+ export async function readResultSummary(resultPath) {
2962
+ try {
2963
+ const raw = await fsp.readFile(resultPath, "utf8");
2964
+ return summarizeResultData(JSON.parse(raw));
2965
+ }
2966
+ catch {
2967
+ return null;
2968
+ }
2969
+ }
2970
+ export function summarizeResultData(data) {
2971
+ const lines = [];
2972
+ const exportSummary = typeof data.exportSummary === "object" && data.exportSummary
2973
+ ? data.exportSummary
2974
+ : null;
2975
+ const profile = typeof data.profile === "object" && data.profile
2976
+ ? data.profile
2977
+ : null;
2978
+ if (profile?.username && typeof profile.username === "string") {
2979
+ lines.push(`Profile: ${profile.username}`);
2980
+ }
2981
+ if (Array.isArray(data.repositories)) {
2982
+ lines.push(`Repositories: ${data.repositories.length}`);
2983
+ const preview = summarizeNamedItems(data.repositories, "Latest repos");
2984
+ if (preview) {
2985
+ lines.push(preview);
2986
+ }
2987
+ }
2988
+ if (Array.isArray(data.starred)) {
2989
+ lines.push(`Starred: ${data.starred.length}`);
2990
+ }
2991
+ if (Array.isArray(data.orders)) {
2992
+ lines.push(`Orders: ${data.orders.length}`);
2993
+ }
2994
+ if (Array.isArray(data.playlists)) {
2995
+ lines.push(`Playlists: ${data.playlists.length}`);
2996
+ const preview = summarizeNamedItems(data.playlists, "Playlists");
2997
+ if (preview) {
2998
+ lines.push(preview);
2999
+ }
3000
+ }
3001
+ if (exportSummary?.details &&
3002
+ typeof exportSummary.details === "string" &&
3003
+ !lines.includes(exportSummary.details) &&
3004
+ !Array.isArray(data.repositories) &&
3005
+ !Array.isArray(data.starred) &&
3006
+ !Array.isArray(data.orders) &&
3007
+ !Array.isArray(data.playlists)) {
3008
+ lines.push(exportSummary.details);
3009
+ }
3010
+ return lines.length > 0 ? { lines } : null;
3011
+ }
3012
+ function summarizeNamedItems(items, label, maxItems = 2) {
3013
+ const names = items
3014
+ .map((item) => {
3015
+ if (typeof item === "object" &&
3016
+ item &&
3017
+ "name" in item &&
3018
+ typeof item.name === "string") {
3019
+ return item.name;
3020
+ }
3021
+ return null;
3022
+ })
3023
+ .filter((value) => Boolean(value))
3024
+ .slice(0, maxItems);
3025
+ if (names.length === 0) {
3026
+ return null;
3027
+ }
3028
+ return `${label}: ${names.join(", ")}`;
3029
+ }
3030
+ export function formatTimestamp(value) {
3031
+ const date = new Date(value);
3032
+ if (Number.isNaN(date.getTime())) {
3033
+ return value;
3034
+ }
3035
+ return new Intl.DateTimeFormat(undefined, {
3036
+ dateStyle: "medium",
3037
+ timeStyle: "short",
3038
+ }).format(date);
3039
+ }
3040
+ export function compareDatasetOrder(left, right) {
3041
+ const leftTime = left.lastRunAt ? Date.parse(left.lastRunAt) : 0;
3042
+ const rightTime = right.lastRunAt ? Date.parse(right.lastRunAt) : 0;
3043
+ return (rightTime - leftTime ||
3044
+ (left.name ?? left.source).localeCompare(right.name ?? right.source, undefined, {
3045
+ sensitivity: "base",
3046
+ }));
3047
+ }
3048
+ function compareLogRecordOrder(left, right) {
3049
+ const leftTimestamp = left.lastRunAt ? Date.parse(left.lastRunAt) : 0;
3050
+ const rightTimestamp = right.lastRunAt ? Date.parse(right.lastRunAt) : 0;
3051
+ return (rightTimestamp - leftTimestamp ||
3052
+ left.source.localeCompare(right.source, undefined, {
3053
+ sensitivity: "base",
3054
+ }));
3055
+ }
3056
+ export function hasCollectedData(dataState) {
3057
+ return (dataState === "collected_local" ||
3058
+ dataState === "ingested_personal_server" ||
3059
+ dataState === "ingest_failed");
3060
+ }
3061
+ function formatLogOutcomeLabel(lastRunOutcome, dataState) {
3062
+ if (lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE) {
3063
+ return "unavailable";
3064
+ }
3065
+ if (lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH) {
3066
+ return "manual step";
3067
+ }
3068
+ if (lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
3069
+ return "error";
3070
+ }
3071
+ if (lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT) {
3072
+ return "needs input";
3073
+ }
3074
+ if (dataState === "ingested_personal_server") {
3075
+ return "synced";
3076
+ }
3077
+ if (dataState === "ingest_failed") {
3078
+ return "sync failed";
3079
+ }
3080
+ if (dataState === "collected_local") {
3081
+ return "local";
3082
+ }
3083
+ return "recent";
3084
+ }
3085
+ function isAttentionLog(lastRunOutcome, dataState) {
3086
+ return !(dataState === "collected_local" ||
3087
+ dataState === "ingested_personal_server" ||
3088
+ lastRunOutcome === CliOutcomeStatus.CONNECTED_LOCAL_ONLY ||
3089
+ lastRunOutcome === CliOutcomeStatus.CONNECTED_AND_INGESTED);
3090
+ }
3091
+ function toneForLogOutcome(lastRunOutcome, dataState) {
3092
+ if (lastRunOutcome === CliOutcomeStatus.RUNTIME_ERROR) {
3093
+ return "error";
3094
+ }
3095
+ if (lastRunOutcome === CliOutcomeStatus.CONNECTOR_UNAVAILABLE ||
3096
+ lastRunOutcome === CliOutcomeStatus.LEGACY_AUTH ||
3097
+ lastRunOutcome === CliOutcomeStatus.NEEDS_INPUT ||
3098
+ dataState === "ingest_failed") {
3099
+ return "warning";
3100
+ }
3101
+ if (dataState === "ingested_personal_server") {
3102
+ return "success";
3103
+ }
3104
+ if (dataState === "collected_local") {
3105
+ return "muted";
3106
+ }
3107
+ return "muted";
3108
+ }
3109
+ function toneForHealth(health) {
3110
+ switch (health) {
3111
+ case "healthy":
3112
+ return "accent";
3113
+ case "needs_reauth":
3114
+ return "warning";
3115
+ case "error":
3116
+ return "error";
3117
+ case "stale":
3118
+ return "muted";
3119
+ default:
3120
+ return "muted";
3121
+ }
3122
+ }
3123
+ // ---------------------------------------------------------------------------
3124
+ // Detach (background process)
3125
+ // ---------------------------------------------------------------------------
3126
+ async function runDetached(command, source, options) {
3127
+ const emit = createEmitter(options);
3128
+ const registrySources = await loadRegistrySources();
3129
+ const sourceLabels = createSourceLabelMap(registrySources);
3130
+ const displayName = displaySource(source, sourceLabels);
3131
+ // Check if source has been previously connected (has a session to reuse).
3132
+ // Detach is for re-collection with existing sessions, not first-time auth.
3133
+ const state = await readCliState();
3134
+ const sourceState = state.sources[source];
3135
+ if (!sourceState?.lastResultPath && !sourceState?.sessionPresent) {
3136
+ emit.info(`Run ${emit.code(`vana connect ${source}`)} first to authenticate.`);
3137
+ emit.detail("Use --detach for background re-collection after the first connect.");
3138
+ return 1;
3139
+ }
3140
+ const sessionsDir = getSessionsDir();
3141
+ const logsDir = getLogsDir();
3142
+ await fsp.mkdir(sessionsDir, { recursive: true });
3143
+ await fsp.mkdir(logsDir, { recursive: true });
3144
+ const logPath = path.join(logsDir, `${source}-detach.log`);
3145
+ const sessionPath = path.join(sessionsDir, `${source}.json`);
3146
+ const logFd = fs.openSync(logPath, "a");
3147
+ // --no-input: if auth is needed, fail fast and record needs_reauth.
3148
+ // Don't use --ipc: nobody is watching a detached process.
3149
+ const childArgs = [
3150
+ process.argv[1],
3151
+ command,
3152
+ source,
3153
+ "--json",
3154
+ "--quiet",
3155
+ "--no-input",
3156
+ ];
3157
+ const child = spawn(process.execPath, childArgs, {
3158
+ detached: true,
3159
+ stdio: ["ignore", logFd, logFd],
3160
+ env: { ...process.env, VANA_DETACHED: "1" },
3161
+ });
3162
+ child.unref();
3163
+ fs.closeSync(logFd);
3164
+ // Write session file
3165
+ const session = {
3166
+ source,
3167
+ command,
3168
+ pid: child.pid,
3169
+ startedAt: new Date().toISOString(),
3170
+ status: "running",
3171
+ logPath,
3172
+ };
3173
+ await fsp.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
3174
+ if (options.json) {
3175
+ process.stdout.write(`${JSON.stringify(session)}\n`);
3176
+ return 0;
3177
+ }
3178
+ const verb = command === "connect" ? "Connecting" : "Collecting";
3179
+ emit.info(`${verb} ${displayName} in the background.`);
3180
+ emit.detail(`Check progress: ${emit.code("vana status")}`);
3181
+ return 0;
3182
+ }
3183
+ // ---------------------------------------------------------------------------
3184
+ // Schedule commands
3185
+ // ---------------------------------------------------------------------------
3186
+ const LAUNCHD_LABEL = "com.vana.collect";
3187
+ const LAUNCHD_PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
3188
+ const CRONTAB_MARKER = "# vana-scheduled-collection";
3189
+ const WINDOWS_TASK_NAME = "VanaScheduledCollection";
3190
+ function parseIntervalSeconds(interval) {
3191
+ const lower = interval.toLowerCase().trim();
3192
+ const match = /^(\d+)\s*(h|d|m|w)$/i.exec(lower);
3193
+ if (match) {
3194
+ const value = parseInt(match[1], 10);
3195
+ const unit = match[2].toLowerCase();
3196
+ if (unit === "h")
3197
+ return value * 3600;
3198
+ if (unit === "d")
3199
+ return value * 86400;
3200
+ if (unit === "w")
3201
+ return value * 7 * 86400;
3202
+ if (unit === "m")
3203
+ return value * 30 * 86400;
3204
+ }
3205
+ if (lower === "daily")
3206
+ return 86400;
3207
+ if (lower === "weekly")
3208
+ return 7 * 86400;
3209
+ // Default to 24h
3210
+ return 86400;
3211
+ }
3212
+ function formatIntervalHuman(seconds) {
3213
+ if (seconds < 3600)
3214
+ return `${Math.round(seconds / 60)}m`;
3215
+ if (seconds < 86400)
3216
+ return `${Math.round(seconds / 3600)}h`;
3217
+ return `${Math.round(seconds / 86400)}d`;
3218
+ }
3219
+ function resolveVanaBinaryPath() {
3220
+ // For SEA binaries, process.execPath is the binary itself
3221
+ const installMethod = getCliInstallMethod();
3222
+ if (installMethod === "homebrew" || installMethod === "installer") {
3223
+ return process.execPath;
3224
+ }
3225
+ // For development, try to find the vana binary via which/where
3226
+ try {
3227
+ const cmd = process.platform === "win32" ? "where vana" : "which vana";
3228
+ const result = execSync(cmd, { encoding: "utf8" }).trim();
3229
+ // `where` on Windows may return multiple lines; take the first
3230
+ return result.split("\n")[0].trim();
3231
+ }
3232
+ catch {
3233
+ // Fall back to process.execPath + argv[1]
3234
+ return `${process.execPath} ${process.argv[1]}`;
3235
+ }
3236
+ }
3237
+ async function getExistingScheduleInterval() {
3238
+ if (process.platform === "darwin") {
3239
+ try {
3240
+ const content = await fsp.readFile(LAUNCHD_PLIST_PATH, "utf8");
3241
+ const match = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
3242
+ return match ? parseInt(match[1], 10) : null;
3243
+ }
3244
+ catch {
3245
+ return null;
3246
+ }
3247
+ }
3248
+ if (process.platform === "linux") {
3249
+ try {
3250
+ const crontab = execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
3251
+ const vanaLine = crontab
3252
+ .split("\n")
3253
+ .find((line) => line.includes(CRONTAB_MARKER));
3254
+ if (!vanaLine)
3255
+ return null;
3256
+ // Parse hour interval from crontab: look for */N pattern
3257
+ const hourMatch = /\*\/(\d+)/.exec(vanaLine);
3258
+ if (hourMatch)
3259
+ return parseInt(hourMatch[1], 10) * 3600;
3260
+ // Daily (0 0 * * *)
3261
+ return 86400;
3262
+ }
3263
+ catch {
3264
+ return null;
3265
+ }
3266
+ }
3267
+ if (process.platform === "win32") {
3268
+ try {
3269
+ execSync(`schtasks /Query /TN "${WINDOWS_TASK_NAME}" /FO LIST`, {
3270
+ encoding: "utf8",
3271
+ });
3272
+ // Task exists but we can't easily parse the interval; return a
3273
+ // sentinel value (86400) to indicate "schedule present".
3274
+ return 86400;
3275
+ }
3276
+ catch {
3277
+ return null;
3278
+ }
3279
+ }
3280
+ return null;
3281
+ }
3282
+ async function maybeAutoSchedule(exportFrequency, emit, options) {
3283
+ // Skip if --no-input (detached/agent context shouldn't create schedules)
3284
+ if (options.noInput)
3285
+ return;
3286
+ // Skip unsupported platforms
3287
+ if (!["darwin", "linux", "win32"].includes(process.platform))
3288
+ return;
3289
+ const newInterval = exportFrequency
3290
+ ? parseIntervalSeconds(exportFrequency)
3291
+ : 86400;
3292
+ const existing = await getExistingScheduleInterval();
3293
+ if (existing === null) {
3294
+ // No schedule → create one
3295
+ await runScheduleAdd(exportFrequency ?? "daily", {
3296
+ json: false,
3297
+ quiet: true,
3298
+ });
3299
+ emit.detail(`Auto-scheduled collection every ${formatIntervalHuman(newInterval)}.`);
3300
+ }
3301
+ else if (newInterval < existing) {
3302
+ // New source has shorter frequency → adjust down
3303
+ await runScheduleAdd(exportFrequency ?? "daily", {
3304
+ json: false,
3305
+ quiet: true,
3306
+ });
3307
+ emit.detail(`Adjusted schedule to every ${formatIntervalHuman(newInterval)} (was ${formatIntervalHuman(existing)}).`);
3308
+ }
3309
+ // else: existing schedule is already at or below needed frequency, no-op
3310
+ }
3311
+ function generateLaunchdPlist(vanaBinary, intervalSeconds) {
3312
+ const logsPath = path.join(getLogsDir(), "schedule.log");
3313
+ // Handle the case where vanaBinary might contain a space (node + script)
3314
+ const programArgs = vanaBinary.includes(" ")
3315
+ ? vanaBinary
3316
+ .split(" ")
3317
+ .map((arg) => ` <string>${arg}</string>`)
3318
+ .join("\n")
3319
+ : ` <string>${vanaBinary}</string>`;
3320
+ return `<?xml version="1.0" encoding="UTF-8"?>
3321
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3322
+ <plist version="1.0">
3323
+ <dict>
3324
+ <key>Label</key>
3325
+ <string>${LAUNCHD_LABEL}</string>
3326
+ <key>ProgramArguments</key>
3327
+ <array>
3328
+ ${programArgs}
3329
+ <string>collect</string>
3330
+ <string>--all</string>
3331
+ <string>--quiet</string>
3332
+ <string>--no-input</string>
3333
+ </array>
3334
+ <key>StartInterval</key>
3335
+ <integer>${intervalSeconds}</integer>
3336
+ <key>StandardOutPath</key>
3337
+ <string>${logsPath}</string>
3338
+ <key>StandardErrorPath</key>
3339
+ <string>${logsPath}</string>
3340
+ <key>RunAtLoad</key>
3341
+ <true/>
3342
+ </dict>
3343
+ </plist>
3344
+ `;
3345
+ }
3346
+ function generateCrontabEntry(vanaBinary, intervalHours) {
3347
+ const logsPath = path.join(getLogsDir(), "schedule.log");
3348
+ const hourExpr = intervalHours >= 24 ? "0" : `0`;
3349
+ const dayExpr = "*";
3350
+ const hourInterval = intervalHours >= 24 ? "0" : intervalHours >= 1 ? `*/${intervalHours}` : "*";
3351
+ return `${hourExpr} ${hourInterval} ${dayExpr} * * ${vanaBinary} collect --all --quiet --no-input >> ${logsPath} 2>&1 ${CRONTAB_MARKER}`;
3352
+ }
3353
+ async function runScheduleAdd(interval, options) {
3354
+ const emit = createEmitter(options);
3355
+ const intervalSeconds = parseIntervalSeconds(interval);
3356
+ const intervalLabel = formatIntervalHuman(intervalSeconds);
3357
+ const vanaBinary = resolveVanaBinaryPath();
3358
+ await fsp.mkdir(getLogsDir(), { recursive: true });
3359
+ if (process.platform === "darwin") {
3360
+ // macOS: launchd
3361
+ const plist = generateLaunchdPlist(vanaBinary, intervalSeconds);
3362
+ const plistDir = path.dirname(LAUNCHD_PLIST_PATH);
3363
+ await fsp.mkdir(plistDir, { recursive: true });
3364
+ // Unload existing if present
3365
+ try {
3366
+ execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, {
3367
+ stdio: "ignore",
3368
+ });
3369
+ }
3370
+ catch {
3371
+ // Not loaded, that's fine
3372
+ }
3373
+ await fsp.writeFile(LAUNCHD_PLIST_PATH, plist);
3374
+ try {
3375
+ execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: "ignore" });
3376
+ }
3377
+ catch {
3378
+ emit.info("Could not load the launchd plist. Load it manually:");
3379
+ emit.detail(`launchctl load "${LAUNCHD_PLIST_PATH}"`);
3380
+ return 1;
3381
+ }
3382
+ if (options.json) {
3383
+ process.stdout.write(`${JSON.stringify({ ok: true, interval: intervalLabel, mechanism: "launchd", plistPath: LAUNCHD_PLIST_PATH })}\n`);
3384
+ return 0;
3385
+ }
3386
+ emit.info(`Added ${intervalLabel === "1d" ? "daily" : `every ${intervalLabel}`} collection schedule.`);
3387
+ emit.detail(`Runs: ${emit.code("vana collect --all --quiet")}`);
3388
+ emit.detail(`Managed by: launchd`);
3389
+ return 0;
3390
+ }
3391
+ if (process.platform === "linux") {
3392
+ // Linux: cron doesn't defer missed jobs (unlike launchd), so we run
3393
+ // hourly and let isCollectionDue() filter per-source. This way a
3394
+ // missed 2am tick self-heals at 3am instead of waiting 24h.
3395
+ const entry = generateCrontabEntry(vanaBinary, 1);
3396
+ try {
3397
+ // Read existing crontab, filter out old vana entries, add new one
3398
+ let existing = "";
3399
+ try {
3400
+ existing = execSync("crontab -l 2>/dev/null", {
3401
+ encoding: "utf8",
3402
+ });
3403
+ }
3404
+ catch {
3405
+ // No existing crontab
3406
+ }
3407
+ const filtered = existing
3408
+ .split("\n")
3409
+ .filter((line) => !line.includes(CRONTAB_MARKER))
3410
+ .join("\n");
3411
+ const newCrontab = `${filtered.trimEnd()}\n${entry}\n`;
3412
+ execSync("crontab -", {
3413
+ input: newCrontab,
3414
+ encoding: "utf8",
3415
+ });
3416
+ }
3417
+ catch {
3418
+ emit.info("Could not update crontab. Add this entry manually:");
3419
+ emit.detail(entry);
3420
+ return 1;
3421
+ }
3422
+ if (options.json) {
3423
+ process.stdout.write(`${JSON.stringify({ ok: true, interval: intervalLabel, mechanism: "cron" })}\n`);
3424
+ return 0;
3425
+ }
3426
+ emit.info(`Added ${intervalLabel === "1d" ? "daily" : `every ${intervalLabel}`} collection schedule.`);
3427
+ emit.detail(`Runs: ${emit.code("vana collect --all --quiet")}`);
3428
+ emit.detail(`Managed by: cron`);
3429
+ return 0;
3430
+ }
3431
+ if (process.platform === "win32") {
3432
+ // Windows: Task Scheduler
3433
+ const intervalMinutes = Math.max(1, Math.round(intervalSeconds / 60));
3434
+ const trCmd = `\\"${vanaBinary}\\" collect --all --quiet --no-input`;
3435
+ try {
3436
+ try {
3437
+ execSync(`schtasks /Delete /TN "${WINDOWS_TASK_NAME}" /F 2>nul`, {
3438
+ stdio: "ignore",
3439
+ });
3440
+ }
3441
+ catch {
3442
+ // Not present, that's fine
3443
+ }
3444
+ if (intervalSeconds >= 86400) {
3445
+ execSync(`schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${trCmd}" /SC DAILY /ST 09:00 /F`, { stdio: "ignore" });
3446
+ }
3447
+ else {
3448
+ execSync(`schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${trCmd}" /SC MINUTE /MO ${intervalMinutes} /F`, { stdio: "ignore" });
3449
+ }
3450
+ // Enable StartWhenAvailable for deferred execution
3451
+ try {
3452
+ execSync(`powershell -Command "$t = Get-ScheduledTask '${WINDOWS_TASK_NAME}'; $t.Settings.StartWhenAvailable = $true; Set-ScheduledTask -InputObject $t"`, { stdio: "ignore" });
3453
+ }
3454
+ catch {
3455
+ // Non-fatal if PowerShell cmdlet fails
3456
+ }
3457
+ }
3458
+ catch {
3459
+ emit.info("Could not create scheduled task. Create it manually:");
3460
+ emit.detail(`schtasks /Create /TN "${WINDOWS_TASK_NAME}" /TR "${trCmd}" /SC DAILY /ST 09:00 /F`);
3461
+ return 1;
3462
+ }
3463
+ if (options.json) {
3464
+ process.stdout.write(`${JSON.stringify({ ok: true, interval: intervalLabel, mechanism: "schtasks" })}\n`);
3465
+ return 0;
3466
+ }
3467
+ emit.info(`Added ${intervalLabel === "1d" ? "daily" : `every ${intervalLabel}`} collection schedule.`);
3468
+ emit.detail(`Runs: ${emit.code("vana collect --all --quiet")}`);
3469
+ emit.detail(`Managed by: Task Scheduler`);
3470
+ return 0;
3471
+ }
3472
+ // Unsupported platform
3473
+ emit.info("Scheduled collection is not supported on this platform. Run `vana collect --all` manually.");
3474
+ return 1;
3475
+ }
3476
+ async function runScheduleList(options) {
3477
+ const emit = createEmitter(options);
3478
+ if (process.platform === "darwin") {
3479
+ // Check launchd plist
3480
+ try {
3481
+ await fsp.access(LAUNCHD_PLIST_PATH);
3482
+ const content = await fsp.readFile(LAUNCHD_PLIST_PATH, "utf8");
3483
+ const intervalMatch = content.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/);
3484
+ const intervalSeconds = intervalMatch
3485
+ ? parseInt(intervalMatch[1], 10)
3486
+ : 86400;
3487
+ const intervalLabel = formatIntervalHuman(intervalSeconds);
3488
+ const nextInSeconds = intervalSeconds; // Approximate; launchd doesn't expose exact next-run
3489
+ const nextLabel = `~${formatIntervalHuman(nextInSeconds)}`;
3490
+ if (options.json) {
3491
+ process.stdout.write(`${JSON.stringify({
3492
+ scheduled: true,
3493
+ interval: intervalLabel,
3494
+ intervalSeconds,
3495
+ mechanism: "launchd",
3496
+ plistPath: LAUNCHD_PLIST_PATH,
3497
+ })}\n`);
3498
+ return 0;
3499
+ }
3500
+ emit.keyValue("Daily collection", `every ${intervalLabel} next: ${nextLabel}`, "muted");
3501
+ emit.detail(`Managed by: ${LAUNCHD_PLIST_PATH}`);
3502
+ return 0;
3503
+ }
3504
+ catch {
3505
+ // No plist found
3506
+ }
3507
+ }
3508
+ if (process.platform === "linux") {
3509
+ // Check crontab
3510
+ try {
3511
+ const crontab = execSync("crontab -l 2>/dev/null", {
3512
+ encoding: "utf8",
3513
+ });
3514
+ const vanaLine = crontab
3515
+ .split("\n")
3516
+ .find((line) => line.includes(CRONTAB_MARKER));
3517
+ if (vanaLine) {
3518
+ if (options.json) {
3519
+ process.stdout.write(`${JSON.stringify({
3520
+ scheduled: true,
3521
+ mechanism: "cron",
3522
+ entry: vanaLine,
3523
+ })}\n`);
3524
+ return 0;
3525
+ }
3526
+ emit.keyValue("Daily collection", "cron", "muted");
3527
+ emit.detail(`Entry: ${vanaLine.replace(CRONTAB_MARKER, "").trim()}`);
3528
+ return 0;
3529
+ }
3530
+ }
3531
+ catch {
3532
+ // No crontab available
3533
+ }
3534
+ }
3535
+ if (process.platform === "win32") {
3536
+ try {
3537
+ const output = execSync(`schtasks /Query /TN "${WINDOWS_TASK_NAME}" /FO LIST`, { encoding: "utf8" });
3538
+ if (options.json) {
3539
+ process.stdout.write(`${JSON.stringify({
3540
+ scheduled: true,
3541
+ mechanism: "schtasks",
3542
+ taskName: WINDOWS_TASK_NAME,
3543
+ })}\n`);
3544
+ return 0;
3545
+ }
3546
+ emit.keyValue("Daily collection", "Task Scheduler", "muted");
3547
+ // Extract schedule info from output
3548
+ const statusLine = output.split("\n").find((l) => l.includes("Status:"));
3549
+ if (statusLine) {
3550
+ emit.detail(statusLine.trim());
3551
+ }
3552
+ return 0;
3553
+ }
3554
+ catch {
3555
+ // Task not found
3556
+ }
3557
+ }
3558
+ if (options.json) {
3559
+ process.stdout.write(`${JSON.stringify({ scheduled: false })}\n`);
3560
+ return 0;
3561
+ }
3562
+ emit.info("No scheduled collection found.");
3563
+ emit.detail(`Add one with ${emit.code("vana schedule add")}.`);
3564
+ return 0;
3565
+ }
3566
+ async function runScheduleRemove(options) {
3567
+ const emit = createEmitter(options);
3568
+ if (process.platform === "darwin") {
3569
+ try {
3570
+ await fsp.access(LAUNCHD_PLIST_PATH);
3571
+ try {
3572
+ execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, {
3573
+ stdio: "ignore",
3574
+ });
3575
+ }
3576
+ catch {
3577
+ // Already unloaded
3578
+ }
3579
+ await fsp.unlink(LAUNCHD_PLIST_PATH);
3580
+ if (options.json) {
3581
+ process.stdout.write(`${JSON.stringify({ ok: true, removed: true })}\n`);
3582
+ return 0;
3583
+ }
3584
+ emit.info("Removed daily collection schedule.");
3585
+ return 0;
3586
+ }
3587
+ catch {
3588
+ // No plist found, fall through
3589
+ }
3590
+ }
3591
+ if (process.platform === "linux") {
3592
+ try {
3593
+ const existing = execSync("crontab -l 2>/dev/null", {
3594
+ encoding: "utf8",
3595
+ });
3596
+ if (existing.includes(CRONTAB_MARKER)) {
3597
+ const filtered = existing
3598
+ .split("\n")
3599
+ .filter((line) => !line.includes(CRONTAB_MARKER))
3600
+ .join("\n");
3601
+ execSync("crontab -", {
3602
+ input: `${filtered.trimEnd()}\n`,
3603
+ encoding: "utf8",
3604
+ });
3605
+ if (options.json) {
3606
+ process.stdout.write(`${JSON.stringify({ ok: true, removed: true })}\n`);
3607
+ return 0;
3608
+ }
3609
+ emit.info("Removed daily collection schedule.");
3610
+ return 0;
3611
+ }
3612
+ }
3613
+ catch {
3614
+ // No crontab available
3615
+ }
3616
+ }
3617
+ if (process.platform === "win32") {
3618
+ try {
3619
+ execSync(`schtasks /Delete /TN "${WINDOWS_TASK_NAME}" /F`, {
3620
+ stdio: "ignore",
3621
+ });
3622
+ if (options.json) {
3623
+ process.stdout.write(`${JSON.stringify({ ok: true, removed: true })}\n`);
3624
+ return 0;
3625
+ }
3626
+ emit.info("Removed daily collection schedule.");
3627
+ return 0;
3628
+ }
3629
+ catch {
3630
+ // Task not found
3631
+ }
3632
+ }
3633
+ if (options.json) {
3634
+ process.stdout.write(`${JSON.stringify({ ok: true, removed: false })}\n`);
3635
+ return 0;
3636
+ }
3637
+ emit.info("No scheduled collection found to remove.");
3638
+ return 0;
3639
+ }
3640
+ function isPromptCancelled(error) {
3641
+ return (error instanceof Error &&
3642
+ (error.name === "ExitPromptError" || error.message.includes("SIGINT")));
3643
+ }
3644
+ // ---------------------------------------------------------------------------
3645
+ // Skill commands
3646
+ // ---------------------------------------------------------------------------
3647
+ const BASE_SKILL_ID = "connect-data";
3648
+ async function maybePromptSkillInstall(emit) {
3649
+ try {
3650
+ const skills = await listAvailableSkills();
3651
+ const baseSkill = skills.find((s) => s.id === BASE_SKILL_ID);
3652
+ if (!baseSkill)
3653
+ return;
3654
+ const installed = await readInstalledSkills();
3655
+ if (installed.some((s) => s.id === BASE_SKILL_ID)) {
3656
+ await updateCliConfig({ skillsPromptCompleted: true });
3657
+ return;
3658
+ }
3659
+ emit.blank();
3660
+ const shouldInstall = await confirm({
3661
+ message: "Install a skill so your coding agent knows how to use your connected data?",
3662
+ default: true,
3663
+ ...vanaPromptTheme,
3664
+ });
3665
+ if (shouldInstall) {
3666
+ try {
3667
+ await installSkill(BASE_SKILL_ID);
3668
+ emit.success(`Installed skill: ${baseSkill.name}`);
3669
+ }
3670
+ catch {
3671
+ // Non-fatal.
3672
+ }
3673
+ const remaining = skills.filter((s) => s.id !== BASE_SKILL_ID);
3674
+ if (remaining.length > 0) {
3675
+ emit.next("vana skills");
3676
+ }
3677
+ }
3678
+ await updateCliConfig({ skillsPromptCompleted: true });
3679
+ }
3680
+ catch {
3681
+ // Prompt cancelled or error — mark as completed to avoid re-asking.
3682
+ await updateCliConfig({ skillsPromptCompleted: true });
3683
+ }
3684
+ }
3685
+ async function runSkillsGuidedPicker(options) {
3686
+ const emit = createEmitter(options);
3687
+ if (options.json) {
3688
+ // In JSON mode, fall back to list behavior
3689
+ return runSkillList(options);
3690
+ }
3691
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
3692
+ return runSkillList(options);
3693
+ }
3694
+ try {
3695
+ const skills = await listAvailableSkills();
3696
+ const installed = await readInstalledSkills();
3697
+ const installedIds = new Set(installed.map((s) => s.id));
3698
+ if (skills.length === 0) {
3699
+ emit.info("No skills are available right now.");
3700
+ return 0;
3701
+ }
3702
+ const choices = skills.map((skill) => {
3703
+ const isInstalled = installedIds.has(skill.id);
3704
+ return {
3705
+ value: skill.id,
3706
+ name: `${skill.name}${isInstalled ? " (installed)" : ""}`,
3707
+ description: skill.description,
3708
+ };
3709
+ });
3710
+ const selectedId = await searchSelect({
3711
+ message: "Select a skill.",
3712
+ choices,
3713
+ ...vanaPromptTheme,
3714
+ });
3715
+ if (installedIds.has(selectedId)) {
3716
+ return runSkillShow(selectedId, options);
3717
+ }
3718
+ return runSkillInstall(selectedId, options);
3719
+ }
3720
+ catch (error) {
3721
+ if (isPromptCancelled(error)) {
3722
+ emit.info("Cancelled.");
3723
+ return 1;
3724
+ }
3725
+ throw error;
3726
+ }
3727
+ }
3728
+ async function runSkillList(options) {
3729
+ const emit = createEmitter(options);
3730
+ try {
3731
+ const skills = await listAvailableSkills();
3732
+ const installed = await readInstalledSkills();
3733
+ const installedIds = new Set(installed.map((s) => s.id));
3734
+ const enriched = skills.map((skill) => ({
3735
+ ...skill,
3736
+ installed: installedIds.has(skill.id),
3737
+ }));
3738
+ if (options.json) {
3739
+ process.stdout.write(`${JSON.stringify({ count: enriched.length, skills: enriched })}\n`);
3740
+ return 0;
3741
+ }
3742
+ emit.title("Available skills");
3743
+ emit.blank();
3744
+ if (enriched.length === 0) {
3745
+ emit.info("No skills are available right now.");
3746
+ return 0;
3747
+ }
3748
+ for (const skill of enriched) {
3749
+ const tag = skill.installed
3750
+ ? ` ${emit.badge("installed", "accent")}`
3751
+ : "";
3752
+ emit.info(` ${skill.name}${tag}`);
3753
+ emit.detail(` ${skill.description}`);
3754
+ }
3755
+ const uninstalled = enriched.find((s) => !s.installed);
3756
+ if (uninstalled) {
3757
+ emit.blank();
3758
+ emit.next(`vana skills install ${uninstalled.id}`);
3759
+ }
3760
+ return 0;
3761
+ }
3762
+ catch (error) {
3763
+ if (options.json) {
3764
+ process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
3765
+ }
3766
+ else {
3767
+ emit.info(error instanceof Error ? error.message : String(error));
3768
+ }
3769
+ return 1;
3770
+ }
3771
+ }
3772
+ async function runSkillInstall(name, options) {
3773
+ const emit = createEmitter(options);
3774
+ try {
3775
+ const { installedPath } = await installSkill(name);
3776
+ if (options.json) {
3777
+ process.stdout.write(`${JSON.stringify({ ok: true, id: name, installedPath })}\n`);
3778
+ return 0;
3779
+ }
3780
+ emit.success(`Installed ${name}.`);
3781
+ emit.blank();
3782
+ const skills = await listAvailableSkills();
3783
+ const installed = await readInstalledSkills();
3784
+ const installedIds = new Set([...installed.map((s) => s.id), name]);
3785
+ const nextSkill = skills.find((s) => !installedIds.has(s.id));
3786
+ emit.next(nextSkill ? `vana skills install ${nextSkill.id}` : "vana skills list");
3787
+ return 0;
3788
+ }
3789
+ catch (error) {
3790
+ if (options.json) {
3791
+ process.stdout.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
3792
+ }
3793
+ else {
3794
+ emit.info(error instanceof Error ? error.message : String(error));
3795
+ }
3796
+ return 1;
3797
+ }
3798
+ }
3799
+ async function runSkillShow(name, options) {
3800
+ const emit = createEmitter(options);
3801
+ try {
3802
+ const skills = await listAvailableSkills();
3803
+ const match = skills.find((s) => s.id.toLowerCase() === name.toLowerCase());
3804
+ if (!match) {
3805
+ if (options.json) {
3806
+ process.stdout.write(`${JSON.stringify({ error: `No skill found with id "${name}".` })}\n`);
3807
+ }
3808
+ else {
3809
+ emit.info(`No skill found with id "${name}".`);
3810
+ emit.blank();
3811
+ emit.next("vana skills list");
3812
+ }
3813
+ return 1;
3814
+ }
3815
+ const installed = await readInstalledSkills();
3816
+ const isInstalled = installed.some((s) => s.id === match.id);
3817
+ if (options.json) {
3818
+ process.stdout.write(`${JSON.stringify({ ...match, installed: isInstalled })}\n`);
3819
+ return 0;
3820
+ }
3821
+ const badges = [];
3822
+ if (isInstalled) {
3823
+ badges.push({ text: "installed", tone: "success" });
3824
+ }
3825
+ emit.sourceTitle(match.name, badges);
3826
+ emit.detail(match.description);
3827
+ emit.keyValue("Version", match.version);
3828
+ if (!isInstalled) {
3829
+ emit.blank();
3830
+ emit.next(`vana skills install ${match.id}`);
3831
+ }
3832
+ return 0;
3833
+ }
3834
+ catch (error) {
3835
+ if (options.json) {
3836
+ process.stdout.write(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
3837
+ }
3838
+ else {
3839
+ emit.info(error instanceof Error ? error.message : String(error));
3840
+ }
3841
+ return 1;
565
3842
  }
566
3843
  }
567
3844
  //# sourceMappingURL=index.js.map