scoutline 0.1.0 → 0.6.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 (242) hide show
  1. package/README.md +394 -7
  2. package/bin/scoutline.js +36 -15
  3. package/dist/capabilities/diagnostics.d.ts +135 -0
  4. package/dist/capabilities/diagnostics.d.ts.map +1 -0
  5. package/dist/capabilities/diagnostics.js +139 -0
  6. package/dist/capabilities/diagnostics.js.map +1 -0
  7. package/dist/capabilities/quota.d.ts +99 -0
  8. package/dist/capabilities/quota.d.ts.map +1 -0
  9. package/dist/capabilities/quota.js +129 -0
  10. package/dist/capabilities/quota.js.map +1 -0
  11. package/dist/capabilities/reader.d.ts +227 -0
  12. package/dist/capabilities/reader.d.ts.map +1 -0
  13. package/dist/capabilities/reader.js +100 -0
  14. package/dist/capabilities/reader.js.map +1 -0
  15. package/dist/capabilities/repository.d.ts +221 -0
  16. package/dist/capabilities/repository.d.ts.map +1 -0
  17. package/dist/capabilities/repository.js +172 -0
  18. package/dist/capabilities/repository.js.map +1 -0
  19. package/dist/capabilities/search.d.ts +110 -0
  20. package/dist/capabilities/search.d.ts.map +1 -0
  21. package/dist/capabilities/search.js +21 -0
  22. package/dist/capabilities/search.js.map +1 -0
  23. package/dist/capabilities/vision.d.ts +156 -0
  24. package/dist/capabilities/vision.d.ts.map +1 -0
  25. package/dist/capabilities/vision.js +111 -0
  26. package/dist/capabilities/vision.js.map +1 -0
  27. package/dist/command-invocation.d.ts +58 -0
  28. package/dist/command-invocation.d.ts.map +1 -0
  29. package/dist/command-invocation.js +95 -0
  30. package/dist/command-invocation.js.map +1 -0
  31. package/dist/commands/cache.d.ts +106 -0
  32. package/dist/commands/cache.d.ts.map +1 -0
  33. package/dist/commands/cache.js +203 -0
  34. package/dist/commands/cache.js.map +1 -0
  35. package/dist/commands/code.d.ts +11 -4
  36. package/dist/commands/code.d.ts.map +1 -1
  37. package/dist/commands/code.js +16 -42
  38. package/dist/commands/code.js.map +1 -1
  39. package/dist/commands/doctor.d.ts +69 -3
  40. package/dist/commands/doctor.d.ts.map +1 -1
  41. package/dist/commands/doctor.js +179 -52
  42. package/dist/commands/doctor.js.map +1 -1
  43. package/dist/commands/quota.d.ts +45 -3
  44. package/dist/commands/quota.d.ts.map +1 -1
  45. package/dist/commands/quota.js +116 -60
  46. package/dist/commands/quota.js.map +1 -1
  47. package/dist/commands/read.d.ts +76 -2
  48. package/dist/commands/read.d.ts.map +1 -1
  49. package/dist/commands/read.js +260 -121
  50. package/dist/commands/read.js.map +1 -1
  51. package/dist/commands/repo.d.ts +55 -4
  52. package/dist/commands/repo.d.ts.map +1 -1
  53. package/dist/commands/repo.js +115 -172
  54. package/dist/commands/repo.js.map +1 -1
  55. package/dist/commands/repository-explorer.d.ts +147 -0
  56. package/dist/commands/repository-explorer.d.ts.map +1 -0
  57. package/dist/commands/repository-explorer.js +550 -0
  58. package/dist/commands/repository-explorer.js.map +1 -0
  59. package/dist/commands/search.d.ts +27 -2
  60. package/dist/commands/search.d.ts.map +1 -1
  61. package/dist/commands/search.js +95 -103
  62. package/dist/commands/search.js.map +1 -1
  63. package/dist/commands/tools.d.ts +9 -3
  64. package/dist/commands/tools.d.ts.map +1 -1
  65. package/dist/commands/tools.js +29 -59
  66. package/dist/commands/tools.js.map +1 -1
  67. package/dist/commands/vision.d.ts +35 -9
  68. package/dist/commands/vision.d.ts.map +1 -1
  69. package/dist/commands/vision.js +184 -131
  70. package/dist/commands/vision.js.map +1 -1
  71. package/dist/index.d.ts +62 -1
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +580 -223
  74. package/dist/index.js.map +1 -1
  75. package/dist/lib/cache.d.ts +178 -12
  76. package/dist/lib/cache.d.ts.map +1 -1
  77. package/dist/lib/cache.js +377 -39
  78. package/dist/lib/cache.js.map +1 -1
  79. package/dist/lib/code-mode.d.ts +13 -0
  80. package/dist/lib/code-mode.d.ts.map +1 -1
  81. package/dist/lib/code-mode.js +26 -6
  82. package/dist/lib/code-mode.js.map +1 -1
  83. package/dist/lib/config.d.ts.map +1 -1
  84. package/dist/lib/config.js +15 -18
  85. package/dist/lib/config.js.map +1 -1
  86. package/dist/lib/errors.d.ts +89 -11
  87. package/dist/lib/errors.d.ts.map +1 -1
  88. package/dist/lib/errors.js +168 -21
  89. package/dist/lib/errors.js.map +1 -1
  90. package/dist/lib/execution.d.ts +190 -0
  91. package/dist/lib/execution.d.ts.map +1 -0
  92. package/dist/lib/execution.js +383 -0
  93. package/dist/lib/execution.js.map +1 -0
  94. package/dist/lib/index.d.ts +10 -2
  95. package/dist/lib/index.d.ts.map +1 -1
  96. package/dist/lib/index.js +10 -2
  97. package/dist/lib/index.js.map +1 -1
  98. package/dist/lib/mcp-client.d.ts +75 -16
  99. package/dist/lib/mcp-client.d.ts.map +1 -1
  100. package/dist/lib/mcp-client.js +173 -108
  101. package/dist/lib/mcp-client.js.map +1 -1
  102. package/dist/lib/mcp-config.d.ts +17 -0
  103. package/dist/lib/mcp-config.d.ts.map +1 -1
  104. package/dist/lib/mcp-config.js +29 -0
  105. package/dist/lib/mcp-config.js.map +1 -1
  106. package/dist/lib/monitor-client.d.ts +17 -3
  107. package/dist/lib/monitor-client.d.ts.map +1 -1
  108. package/dist/lib/monitor-client.js +23 -69
  109. package/dist/lib/monitor-client.js.map +1 -1
  110. package/dist/lib/output.d.ts +53 -7
  111. package/dist/lib/output.d.ts.map +1 -1
  112. package/dist/lib/output.js +126 -39
  113. package/dist/lib/output.js.map +1 -1
  114. package/dist/lib/redact.d.ts +62 -3
  115. package/dist/lib/redact.d.ts.map +1 -1
  116. package/dist/lib/redact.js +165 -31
  117. package/dist/lib/redact.js.map +1 -1
  118. package/dist/lib/tool-cache.d.ts +86 -0
  119. package/dist/lib/tool-cache.d.ts.map +1 -0
  120. package/dist/lib/tool-cache.js +123 -0
  121. package/dist/lib/tool-cache.js.map +1 -0
  122. package/dist/lib/tty.d.ts +7 -20
  123. package/dist/lib/tty.d.ts.map +1 -1
  124. package/dist/lib/tty.js +51 -30
  125. package/dist/lib/tty.js.map +1 -1
  126. package/dist/node-command-invocation-adapter.d.ts +28 -0
  127. package/dist/node-command-invocation-adapter.d.ts.map +1 -0
  128. package/dist/node-command-invocation-adapter.js +88 -0
  129. package/dist/node-command-invocation-adapter.js.map +1 -0
  130. package/dist/providers/minimax/adapter.d.ts +49 -0
  131. package/dist/providers/minimax/adapter.d.ts.map +1 -0
  132. package/dist/providers/minimax/adapter.js +450 -0
  133. package/dist/providers/minimax/adapter.js.map +1 -0
  134. package/dist/providers/minimax/coding-plan-client.d.ts +60 -0
  135. package/dist/providers/minimax/coding-plan-client.d.ts.map +1 -0
  136. package/dist/providers/minimax/coding-plan-client.js +204 -0
  137. package/dist/providers/minimax/coding-plan-client.js.map +1 -0
  138. package/dist/providers/minimax/config.d.ts +32 -0
  139. package/dist/providers/minimax/config.d.ts.map +1 -0
  140. package/dist/providers/minimax/config.js +84 -0
  141. package/dist/providers/minimax/config.js.map +1 -0
  142. package/dist/providers/minimax/media.d.ts +88 -0
  143. package/dist/providers/minimax/media.d.ts.map +1 -0
  144. package/dist/providers/minimax/media.js +232 -0
  145. package/dist/providers/minimax/media.js.map +1 -0
  146. package/dist/providers/minimax/quota-client.d.ts +44 -0
  147. package/dist/providers/minimax/quota-client.d.ts.map +1 -0
  148. package/dist/providers/minimax/quota-client.js +83 -0
  149. package/dist/providers/minimax/quota-client.js.map +1 -0
  150. package/dist/providers/minimax/quota.d.ts +48 -0
  151. package/dist/providers/minimax/quota.d.ts.map +1 -0
  152. package/dist/providers/minimax/quota.js +120 -0
  153. package/dist/providers/minimax/quota.js.map +1 -0
  154. package/dist/providers/minimax/sdk-client.d.ts +29 -0
  155. package/dist/providers/minimax/sdk-client.d.ts.map +1 -0
  156. package/dist/providers/minimax/sdk-client.js +50 -0
  157. package/dist/providers/minimax/sdk-client.js.map +1 -0
  158. package/dist/providers/minimax/vision-attestations.d.ts +52 -0
  159. package/dist/providers/minimax/vision-attestations.d.ts.map +1 -0
  160. package/dist/providers/minimax/vision-attestations.js +79 -0
  161. package/dist/providers/minimax/vision-attestations.js.map +1 -0
  162. package/dist/providers/minimax/vision-conformance.d.ts +186 -0
  163. package/dist/providers/minimax/vision-conformance.d.ts.map +1 -0
  164. package/dist/providers/minimax/vision-conformance.js +371 -0
  165. package/dist/providers/minimax/vision-conformance.js.map +1 -0
  166. package/dist/providers/minimax/vision-mappings/chart.d.ts +35 -0
  167. package/dist/providers/minimax/vision-mappings/chart.d.ts.map +1 -0
  168. package/dist/providers/minimax/vision-mappings/chart.js +55 -0
  169. package/dist/providers/minimax/vision-mappings/chart.js.map +1 -0
  170. package/dist/providers/minimax/vision-mappings/common.d.ts +60 -0
  171. package/dist/providers/minimax/vision-mappings/common.d.ts.map +1 -0
  172. package/dist/providers/minimax/vision-mappings/common.js +75 -0
  173. package/dist/providers/minimax/vision-mappings/common.js.map +1 -0
  174. package/dist/providers/minimax/vision-mappings/diagnose-error.d.ts +35 -0
  175. package/dist/providers/minimax/vision-mappings/diagnose-error.d.ts.map +1 -0
  176. package/dist/providers/minimax/vision-mappings/diagnose-error.js +55 -0
  177. package/dist/providers/minimax/vision-mappings/diagnose-error.js.map +1 -0
  178. package/dist/providers/minimax/vision-mappings/diagram.d.ts +35 -0
  179. package/dist/providers/minimax/vision-mappings/diagram.d.ts.map +1 -0
  180. package/dist/providers/minimax/vision-mappings/diagram.js +55 -0
  181. package/dist/providers/minimax/vision-mappings/diagram.js.map +1 -0
  182. package/dist/providers/minimax/vision-mappings/extract-text.d.ts +35 -0
  183. package/dist/providers/minimax/vision-mappings/extract-text.d.ts.map +1 -0
  184. package/dist/providers/minimax/vision-mappings/extract-text.js +55 -0
  185. package/dist/providers/minimax/vision-mappings/extract-text.js.map +1 -0
  186. package/dist/providers/minimax/vision-mappings/ui-artifact.d.ts +35 -0
  187. package/dist/providers/minimax/vision-mappings/ui-artifact.d.ts.map +1 -0
  188. package/dist/providers/minimax/vision-mappings/ui-artifact.js +69 -0
  189. package/dist/providers/minimax/vision-mappings/ui-artifact.js.map +1 -0
  190. package/dist/providers/minimax/vision-mappings.generated.d.ts +29 -0
  191. package/dist/providers/minimax/vision-mappings.generated.d.ts.map +1 -0
  192. package/dist/providers/minimax/vision-mappings.generated.js +29 -0
  193. package/dist/providers/minimax/vision-mappings.generated.js.map +1 -0
  194. package/dist/providers/minimax/vision-revisions.d.ts +29 -0
  195. package/dist/providers/minimax/vision-revisions.d.ts.map +1 -0
  196. package/dist/providers/minimax/vision-revisions.js +34 -0
  197. package/dist/providers/minimax/vision-revisions.js.map +1 -0
  198. package/dist/providers/registry.d.ts +36 -0
  199. package/dist/providers/registry.d.ts.map +1 -0
  200. package/dist/providers/registry.js +45 -0
  201. package/dist/providers/registry.js.map +1 -0
  202. package/dist/providers/selection.d.ts +44 -0
  203. package/dist/providers/selection.d.ts.map +1 -0
  204. package/dist/providers/selection.js +85 -0
  205. package/dist/providers/selection.js.map +1 -0
  206. package/dist/providers/types.d.ts +281 -0
  207. package/dist/providers/types.d.ts.map +1 -0
  208. package/dist/providers/types.js +127 -0
  209. package/dist/providers/types.js.map +1 -0
  210. package/dist/providers/zai/adapter.d.ts +35 -0
  211. package/dist/providers/zai/adapter.d.ts.map +1 -0
  212. package/dist/providers/zai/adapter.js +685 -0
  213. package/dist/providers/zai/adapter.js.map +1 -0
  214. package/dist/providers/zai/credentials.d.ts +38 -0
  215. package/dist/providers/zai/credentials.d.ts.map +1 -0
  216. package/dist/providers/zai/credentials.js +60 -0
  217. package/dist/providers/zai/credentials.js.map +1 -0
  218. package/dist/providers/zai/encoded-error.d.ts +90 -0
  219. package/dist/providers/zai/encoded-error.d.ts.map +1 -0
  220. package/dist/providers/zai/encoded-error.js +169 -0
  221. package/dist/providers/zai/encoded-error.js.map +1 -0
  222. package/dist/providers/zai/media.d.ts +38 -0
  223. package/dist/providers/zai/media.d.ts.map +1 -0
  224. package/dist/providers/zai/media.js +107 -0
  225. package/dist/providers/zai/media.js.map +1 -0
  226. package/dist/providers/zai/monitor-client.d.ts +53 -0
  227. package/dist/providers/zai/monitor-client.d.ts.map +1 -0
  228. package/dist/providers/zai/monitor-client.js +117 -0
  229. package/dist/providers/zai/monitor-client.js.map +1 -0
  230. package/dist/providers/zai/quota.d.ts +47 -0
  231. package/dist/providers/zai/quota.d.ts.map +1 -0
  232. package/dist/providers/zai/quota.js +127 -0
  233. package/dist/providers/zai/quota.js.map +1 -0
  234. package/dist/providers/zai/reader.d.ts +82 -0
  235. package/dist/providers/zai/reader.d.ts.map +1 -0
  236. package/dist/providers/zai/reader.js +490 -0
  237. package/dist/providers/zai/reader.js.map +1 -0
  238. package/dist/providers/zai/repository.d.ts +76 -0
  239. package/dist/providers/zai/repository.d.ts.map +1 -0
  240. package/dist/providers/zai/repository.js +715 -0
  241. package/dist/providers/zai/repository.js.map +1 -0
  242. package/package.json +11 -6
package/dist/index.js CHANGED
@@ -6,34 +6,60 @@ import { search, SEARCH_HELP } from "./commands/search.js";
6
6
  import { read, READ_HELP } from "./commands/read.js";
7
7
  import { repoSearch, repoTree, repoRead, REPO_HELP } from "./commands/repo.js";
8
8
  import { listTools, showTool, callTool, TOOLS_HELP, CALL_HELP } from "./commands/tools.js";
9
- import { doctor, DOCTOR_HELP } from "./commands/doctor.js";
10
- import { quota, QUOTA_HELP } from "./commands/quota.js";
9
+ import { doctor, buildDiagnosticsReport, DOCTOR_HELP } from "./commands/doctor.js";
10
+ import { quota, buildQuotaDashboard, QUOTA_HELP } from "./commands/quota.js";
11
+ import { cacheStatsCommand, cacheClearCommand, formatDoctorCacheSummary, CACHE_HELP, } from "./commands/cache.js";
12
+ import { cacheStats, clearAllCaches } from "./lib/cache.js";
11
13
  import { isExtractMode } from "./lib/extract.js";
12
14
  import { runCodeFile, evalCode, printInterfaces, printPromptTemplate, CODE_HELP, } from "./commands/code.js";
13
- import { outputError, setOutputMode } from "./lib/output.js";
15
+ import { isOutputMode, OUTPUT_MODES } from "./lib/output.js";
16
+ import { formatErrorOutput } from "./lib/output.js";
17
+ import { ConfigurationError, ValidationError, UnsupportedCapabilityError, getErrorExitCode, } from "./lib/errors.js";
18
+ import { invokeCommand } from "./command-invocation.js";
19
+ import { defaultResponseCache } from "./lib/cache.js";
20
+ import { configuredSecrets } from "./lib/redact.js";
21
+ import { resolveProviderId } from "./providers/selection.js";
22
+ import { BUILT_IN_PROVIDER_DESCRIPTORS, getProviderDescriptor } from "./providers/registry.js";
23
+ import { visionOperationToCapability } from "./capabilities/vision.js";
14
24
  import { createRequire } from "node:module";
15
25
  const require = createRequire(import.meta.url);
16
26
  const { version: VERSION } = require("../package.json");
17
27
  const MAIN_HELP = `
18
- scoutline v${VERSION} - CLI for Z.AI MCP services
28
+ scoutline v${VERSION} - Multimodal source investigation CLI
19
29
 
20
30
  Usage: scoutline <command> [args] [options]
21
31
 
22
32
  Commands:
23
- vision Image and video analysis (MCP)
24
- search Real-time web search
25
- read Fetch and parse web pages
26
- repo GitHub repository exploration
27
- quota Authoritative Z.AI plan usage (calls remaining, reset time)
28
- tools List available MCP tools
29
- tool Show a tool schema
30
- call Call a tool directly
31
- doctor Environment + connectivity checks
32
- code Execute TypeScript tool chains (Code Mode)
33
+ vision Image and video analysis (Z.AI; MiniMax for interpret-image)
34
+ search Real-time web search (shared: Z.AI + MiniMax)
35
+ read Fetch and parse web pages (Provider Capability; Z.AI supports it,
36
+ MiniMax returns UNSUPPORTED_CAPABILITY)
37
+ repo GitHub repository exploration (Provider Capability; Z.AI supports it,
38
+ MiniMax returns UNSUPPORTED_CAPABILITY)
39
+ quota Provider-aware plan usage (calls remaining, reset time)
40
+ tools List available MCP tools (Z.AI)
41
+ tool Show a tool schema (Z.AI)
42
+ call Call a tool directly (Z.AI)
43
+ doctor Provider-aware environment + connectivity checks
44
+ cache Inspect or clear the local cache (stats / clear)
45
+ code Execute TypeScript tool chains (Code Mode, Z.AI)
46
+
47
+ Provider selection (precedence: --provider, then SCOUTLINE_PROVIDER, then zai):
48
+ --provider <zai|minimax> Select the active Provider for shared capabilities
49
+ SCOUTLINE_PROVIDER=<id> Fallback when --provider is not passed
50
+
51
+ Shared capabilities accept --provider. The 'repo' and 'read' commands
52
+ participate in Provider selection: Z.AI advertises and supplies the
53
+ repository-exploration and reader Capabilities; MiniMax advertises and
54
+ supplies neither, so selecting MiniMax (explicitly or via
55
+ SCOUTLINE_PROVIDER) returns UNSUPPORTED_CAPABILITY with no fallback.
56
+ Z.AI-only commands (tools, tool, call, code) carry the flag but ignore
57
+ it. Quota and doctor report per-Provider; --provider picks the effective
58
+ Provider for metadata.
33
59
 
34
60
  Global Options:
35
- --output-format <data|json|pretty|compact|markdown|refs> Output mode (default: data)
36
- -O <mode> Alias for --output-format
61
+ --output-format <data|json|pretty|compact|markdown|refs|tty> Output mode (default: data)
62
+ -O <mode> Alias for --output-format
37
63
 
38
64
  Help:
39
65
  scoutline --help
@@ -44,6 +70,7 @@ Help:
44
70
  scoutline tools --help
45
71
  scoutline call --help
46
72
  scoutline code --help
73
+ scoutline cache --help
47
74
  `.trim();
48
75
  function parseArgs(args) {
49
76
  const flags = {};
@@ -93,6 +120,7 @@ function extractGlobalOptions(args) {
93
120
  let outputFormat;
94
121
  let forcePretty = false;
95
122
  let forceRaw = false;
123
+ let provider;
96
124
  for (let i = 0; i < args.length; i += 1) {
97
125
  const arg = args[i];
98
126
  if (arg === "--output-format" || arg === "-O") {
@@ -108,90 +136,208 @@ function extractGlobalOptions(args) {
108
136
  forceRaw = true;
109
137
  continue;
110
138
  }
139
+ if (arg === "--provider") {
140
+ // Global option: accepted before OR after the command token. It is
141
+ // removed from the rest stream so command-local positional parsing
142
+ // never observes it. Only shared Search resolves/validates it; the
143
+ // Z.AI-only command families carry it but never consult it.
144
+ provider = args[i + 1];
145
+ i += 1;
146
+ continue;
147
+ }
111
148
  rest.push(arg);
112
149
  }
113
- return { outputFormat, forcePretty, forceRaw, rest };
150
+ return { outputFormat, forcePretty, forceRaw, provider, rest };
114
151
  }
115
- function applyOutputMode(mode) {
116
- const valid = ["data", "json", "pretty", "compact", "markdown", "refs", "tty"];
117
- if (!mode)
118
- return;
119
- if (valid.includes(mode)) {
120
- setOutputMode(mode);
121
- return;
122
- }
123
- outputError(`Invalid output format: ${mode}`, "INVALID_ARGS", `Use one of: ${valid.join(", ")}`);
152
+ function resolveOutputMode(explicit, forcePretty, forceRaw, adapter) {
153
+ if (explicit !== undefined) {
154
+ if (!isOutputMode(explicit)) {
155
+ throw new ValidationError(`Invalid output format: ${explicit}`, `Use one of: ${OUTPUT_MODES.join(", ")}`);
156
+ }
157
+ return explicit;
158
+ }
159
+ if (forcePretty)
160
+ return "tty";
161
+ if (forceRaw)
162
+ return "data";
163
+ const envMode = adapter.environmentOutputMode;
164
+ if (typeof envMode === "string" && isOutputMode(envMode)) {
165
+ return envMode;
166
+ }
167
+ if (adapter.stdoutIsTTY)
168
+ return "tty";
169
+ return "data";
124
170
  }
125
- async function handleVision(args) {
171
+ async function handleVision(args, outputMode, deps) {
126
172
  const { flags, positional } = parseArgs(args);
127
173
  if (flags.help || flags.h || positional.length === 0) {
128
- console.log(vision.VISION_HELP);
129
- return;
174
+ deps.invocation.writeStdout(vision.VISION_HELP);
175
+ return 0;
130
176
  }
131
177
  const command = positional[0];
132
178
  const source = positional[1];
133
179
  const prompt = positional[2];
180
+ // Map the subcommand to its Vision operation. Unknown subcommands are
181
+ // a parse-time VALIDATION_ERROR and are rejected before any Provider
182
+ // resolution, support check, or media access.
183
+ const operation = visionOperationForCommand(command);
184
+ // Resolve the effective Provider for Vision (DESIGN.md §6). Invalid
185
+ // explicit/env input fails here with VALIDATION_ERROR before any Vision
186
+ // support check or media access. Selection never consults credentials
187
+ // (FR-003); the configured check below is the caller's responsibility.
188
+ const providerId = resolveProviderId(deps.provider, deps.env);
189
+ const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
190
+ // Gate the operation on descriptor metadata BEFORE Adapter construction.
191
+ // An unsupported operation (e.g. MiniMax for any specialized op, diff, or
192
+ // video) fails with UNSUPPORTED_CAPABILITY before credentials, media,
193
+ // transport, cache, or any Z.AI fallback (FR-023, FR-024). No command
194
+ // branches on a Provider ID: the support check alone decides availability.
195
+ const capabilityId = visionOperationToCapability(operation);
196
+ if (!descriptor.capabilities().has(capabilityId)) {
197
+ throw new UnsupportedCapabilityError(providerId, capabilityId);
198
+ }
199
+ // FR-003: selection returns the default zai even when unconfigured. The
200
+ // dispatch layer surfaces a missing credential as ConfigurationError
201
+ // (exit 3), AFTER the capability support check (FR-023) but before any
202
+ // Adapter construction or media access (Fixup A — B5).
203
+ if (!descriptor.isConfigured(deps.env)) {
204
+ throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
205
+ }
206
+ const adapter = descriptor.create({ env: deps.env });
207
+ const visionCapability = adapter.vision;
208
+ if (!visionCapability || !visionCapability.supports(operation)) {
209
+ throw new UnsupportedCapabilityError(providerId, capabilityId);
210
+ }
211
+ // Vision bypasses the response cache (FR-022). The shared execution
212
+ // primitives (sleep/random) are the same ones Search consumes; they
213
+ // drive retry backoff deterministically under test.
214
+ const visionDeps = {
215
+ capability: visionCapability,
216
+ sleep: deps.searchSleep,
217
+ random: deps.searchRandom,
218
+ };
134
219
  switch (command) {
135
220
  case "analyze":
136
- if (!source) {
137
- outputError("Missing image source", "INVALID_ARGS", "Usage: scoutline vision analyze <image> [prompt]");
138
- }
139
- await vision.analyze(source, prompt);
140
- break;
141
- case "ui-to-code":
142
- if (!source) {
143
- outputError("Missing image source", "INVALID_ARGS", "Usage: scoutline vision ui-to-code <image> [prompt]");
144
- }
221
+ return invokeCommand(deps.invocation, (context) => vision.analyze(source, prompt, visionDeps, context), outputMode, deps.now, deps.secrets);
222
+ case "ui-to-code": {
145
223
  const outputType = flags.output || "code";
146
- await vision.uiToCode(source, prompt, outputType);
147
- break;
224
+ return invokeCommand(deps.invocation, (context) => vision.uiToCode(source, prompt, outputType, visionDeps, context), outputMode, deps.now, deps.secrets);
225
+ }
148
226
  case "extract-text":
149
- if (!source) {
150
- outputError("Missing image source", "INVALID_ARGS", "Usage: scoutline vision extract-text <image> [prompt] [--language <lang>]");
151
- }
152
- await vision.extractText(source, prompt, flags.language);
153
- break;
227
+ return invokeCommand(deps.invocation, (context) => vision.extractText(source, prompt, flags.language, visionDeps, context), outputMode, deps.now, deps.secrets);
154
228
  case "diagnose-error":
155
- if (!source) {
156
- outputError("Missing image source", "INVALID_ARGS", "Usage: scoutline vision diagnose-error <image> [prompt] [--context <ctx>]");
157
- }
158
- await vision.diagnoseError(source, prompt, flags.context);
159
- break;
229
+ return invokeCommand(deps.invocation, (context) => vision.diagnoseError(source, prompt, flags.context, visionDeps, context), outputMode, deps.now, deps.secrets);
160
230
  case "diagram":
161
- if (!source) {
162
- outputError("Missing image source", "INVALID_ARGS", "Usage: scoutline vision diagram <image> [prompt] [--type <type>]");
163
- }
164
- await vision.diagram(source, prompt, flags.type);
165
- break;
231
+ return invokeCommand(deps.invocation, (context) => vision.diagram(source, prompt, flags.type, visionDeps, context), outputMode, deps.now, deps.secrets);
166
232
  case "chart":
167
- if (!source) {
168
- outputError("Missing image source", "INVALID_ARGS", "Usage: scoutline vision chart <image> [prompt] [--focus <focus>]");
169
- }
170
- await vision.chart(source, prompt, flags.focus);
171
- break;
172
- case "diff":
233
+ return invokeCommand(deps.invocation, (context) => vision.chart(source, prompt, flags.focus, visionDeps, context), outputMode, deps.now, deps.secrets);
234
+ case "diff": {
173
235
  const actual = positional[2];
174
236
  const diffPrompt = positional[3];
175
- if (!source || !actual) {
176
- outputError("Missing image sources", "INVALID_ARGS", "Usage: scoutline vision diff <expected> <actual> [prompt]");
177
- }
178
- await vision.diff(source, actual, diffPrompt);
179
- break;
237
+ return invokeCommand(deps.invocation, (context) => vision.diff(source, actual, diffPrompt, visionDeps, context), outputMode, deps.now, deps.secrets);
238
+ }
180
239
  case "video":
181
- if (!source) {
182
- outputError("Missing video source", "INVALID_ARGS", "Usage: scoutline vision video <video> [prompt]");
183
- }
184
- await vision.video(source, prompt);
185
- break;
240
+ return invokeCommand(deps.invocation, (context) => vision.video(source, prompt, visionDeps, context), outputMode, deps.now, deps.secrets);
241
+ default:
242
+ throw new ValidationError(`Unknown vision command: ${command}`, 'Run "scoutline vision --help" for available commands');
243
+ }
244
+ }
245
+ /**
246
+ * Map a Vision subcommand to its discriminated operation id. Used by
247
+ * `handleVision` to gate the operation against descriptor metadata before
248
+ * Adapter construction. Unknown subcommands throw `ValidationError`.
249
+ */
250
+ function visionOperationForCommand(command) {
251
+ switch (command) {
252
+ case "analyze":
253
+ return "interpret-image";
254
+ case "ui-to-code":
255
+ return "ui-artifact";
256
+ case "extract-text":
257
+ return "extract-text";
258
+ case "diagnose-error":
259
+ return "diagnose-error";
260
+ case "diagram":
261
+ return "diagram";
262
+ case "chart":
263
+ return "chart";
264
+ case "diff":
265
+ return "diff";
266
+ case "video":
267
+ return "video";
186
268
  default:
187
- outputError(`Unknown vision command: ${command}`, "INVALID_ARGS", 'Run "scoutline vision --help" for available commands');
269
+ throw new ValidationError(`Unknown vision command: ${command}`, 'Run "scoutline vision --help" for available commands');
270
+ }
271
+ }
272
+ /**
273
+ * Parse and validate the `--count` flag value (Fixup C — B11, Fixup D). Per
274
+ * DESIGN.md §7, count must be a safe integer >= 0. Invalid values (NaN,
275
+ * negative, non-integer, Infinity, values above Number.MAX_SAFE_INTEGER)
276
+ * throw `ValidationError` BEFORE any Provider resolution or invocation.
277
+ *
278
+ * Fixup D hardens two gaps:
279
+ * - `--count` without a value parses to `true`; that is a user error,
280
+ * not an absent flag, and now throws VALIDATION_ERROR instead of being
281
+ * silently treated as absent.
282
+ * - Uses `Number.isSafeInteger` instead of `Number.isFinite` +
283
+ * `Number.isInteger` so values above 2^53-1 are rejected rather than
284
+ * silently rounded.
285
+ *
286
+ * Exported for testing so the validation can be exercised without going
287
+ * through the CLI parser (which does not deliver negative numbers as flag
288
+ * values today).
289
+ */
290
+ export function parseAndValidateCount(raw) {
291
+ if (raw === undefined || raw === "")
292
+ return undefined;
293
+ if (raw === true) {
294
+ throw new ValidationError("Count requires a numeric value.", "Use a non-negative integer (e.g. --count 5).");
188
295
  }
296
+ const str = typeof raw === "string" ? raw : String(raw);
297
+ if (!/^\d+$/.test(str)) {
298
+ throw new ValidationError(`Invalid --count value "${str}": must be a non-negative integer`, "Use a non-negative integer (e.g. --count 5).");
299
+ }
300
+ const parsed = Number(str);
301
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
302
+ throw new ValidationError(`Invalid --count value "${str}": must be a non-negative safe integer`, "Use a non-negative integer (e.g. --count 5).");
303
+ }
304
+ return parsed;
189
305
  }
190
- async function handleSearch(args) {
306
+ async function handleSearch(args, outputMode, deps) {
191
307
  const { flags, positional } = parseArgs(args);
192
308
  if (flags.help || flags.h || positional.length === 0) {
193
- console.log(SEARCH_HELP);
194
- return;
309
+ deps.invocation.writeStdout(SEARCH_HELP);
310
+ return 0;
311
+ }
312
+ // Fixup D — B11: validate --count BEFORE Provider resolution and the
313
+ // configured/credential check. A syntax error in a CLI argument must
314
+ // not depend on whether credentials are present: `search q --count nope`
315
+ // with NO credentials must surface VALIDATION_ERROR (exit 1), not
316
+ // CONFIGURATION_ERROR (exit 3). Order: parse global options -> validate
317
+ // count -> resolve provider -> check configured -> dispatch.
318
+ const count = parseAndValidateCount(flags.count);
319
+ // Resolve the Provider ONLY inside shared Search (DESIGN.md §6). Other
320
+ // command families carry the parsed flag but never resolve or validate
321
+ // it. An invalid explicit/env value throws VALIDATION_ERROR here, before
322
+ // any Adapter construction or invocation. Selection never consults
323
+ // credentials (FR-003); the configured check below is the caller's
324
+ // responsibility (Fixup A — B5).
325
+ const providerId = resolveProviderId(deps.provider, deps.env);
326
+ const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
327
+ if (!descriptor.capabilities().has("search")) {
328
+ throw new UnsupportedCapabilityError(providerId, "search");
329
+ }
330
+ // FR-003: selection returns the default zai even when unconfigured. The
331
+ // dispatch layer surfaces a missing credential as ConfigurationError
332
+ // (exit 3), AFTER the capability support check (FR-023) but before any
333
+ // Adapter construction (Fixup A — B5).
334
+ if (!descriptor.isConfigured(deps.env)) {
335
+ throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
336
+ }
337
+ const adapter = descriptor.create({ env: deps.env });
338
+ const capability = adapter.search;
339
+ if (!capability) {
340
+ throw new UnsupportedCapabilityError(providerId, "search");
195
341
  }
196
342
  const query = positional.join(" ");
197
343
  const fieldsRaw = flags.fields;
@@ -201,26 +347,87 @@ async function handleSearch(args) {
201
347
  .map((f) => f.trim())
202
348
  .filter(Boolean)
203
349
  : undefined;
204
- await search(query, {
205
- count: flags.count ? parseInt(flags.count, 10) : undefined,
350
+ return invokeCommand(deps.invocation, (context) => search(query, {
351
+ count,
206
352
  domain: flags.domain,
207
353
  recency: flags.recency,
208
354
  contentSize: flags["content-size"],
209
355
  location: flags.location,
210
- maxSummary: flags["max-summary"] ? parseInt(flags["max-summary"], 10) : undefined,
356
+ maxSummary: flags["max-summary"]
357
+ ? parseInt(flags["max-summary"], 10)
358
+ : undefined,
211
359
  fields: fields && fields.length > 0 ? fields : undefined,
212
360
  noCache: flags["no-cache"] === true,
213
361
  merge: flags.merge === true,
214
- });
362
+ }, {
363
+ capability,
364
+ cache: deps.searchCache,
365
+ sleep: deps.searchSleep,
366
+ random: deps.searchRandom,
367
+ }, context), outputMode, deps.now, deps.secrets);
215
368
  }
216
- async function handleRead(args) {
369
+ async function handleRead(args, outputMode, deps) {
217
370
  const { flags, positional } = parseArgs(args);
218
371
  if (flags.help || flags.h || positional.length === 0) {
219
- console.log(READ_HELP);
220
- return;
372
+ deps.invocation.writeStdout(READ_HELP);
373
+ return 0;
221
374
  }
222
375
  const url = positional[0];
223
- await read(url, {
376
+ // Parse-level validation BEFORE Provider resolution. URL scheme and
377
+ // --extract mode are validated at parse time so an invalid value
378
+ // surfaces VALIDATION_ERROR regardless of which Provider would have
379
+ // been selected, because parse-level validation fires before the
380
+ // support/configuration gates. The handler re-runs an identical
381
+ // check as defensive backstop. The pre-dispatch
382
+ // configuredSecrets(env) redaction read in `main` is the only
383
+ // permitted credential-related read before this point.
384
+ if (typeof url !== "string" || (!url.startsWith("http://") && !url.startsWith("https://"))) {
385
+ throw new ValidationError("URL must start with http:// or https://");
386
+ }
387
+ const extractFlag = flags.extract;
388
+ if (extractFlag !== undefined && !isExtractMode(extractFlag)) {
389
+ throw new ValidationError(`Invalid --extract mode: ${extractFlag}. Use one of: code, links, tables, headings`);
390
+ }
391
+ const extract = extractFlag !== undefined ? extractFlag : undefined;
392
+ // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
393
+ // explicit --provider > SCOUTLINE_PROVIDER > default zai. Selection
394
+ // never consults credentials (FR-003) and never branches on Provider
395
+ // ID; an unknown explicit/env value throws VALIDATION_ERROR here.
396
+ const providerId = resolveProviderId(deps.provider, deps.env);
397
+ const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
398
+ // Capability support check BEFORE `descriptor.isConfigured`,
399
+ // `descriptor.create`, Adapter access, operation validation/
400
+ // cacheIdentity, credential use, cache, or transport. Unsupported
401
+ // MiniMax (explicit or environment) returns UNSUPPORTED_CAPABILITY
402
+ // with no fallback to Z.AI; zero selected-Provider work occurs.
403
+ if (!descriptor.capabilities().has("reader")) {
404
+ throw new UnsupportedCapabilityError(providerId, "reader");
405
+ }
406
+ // FR-003: selection returns the default zai even when unconfigured.
407
+ // Supported-but-unconfigured Z.AI surfaces a missing credential as
408
+ // ConfigurationError (exit 3) AFTER the support metadata check and
409
+ // BEFORE `descriptor.create`.
410
+ if (!descriptor.isConfigured(deps.env)) {
411
+ throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
412
+ }
413
+ const adapter = descriptor.create({ env: deps.env });
414
+ const capability = adapter.reader;
415
+ // Defensive fail-closed: the descriptor advertised support but the
416
+ // Adapter omitted the handle. Treat as unsupported so a registry
417
+ // mismatch can never reach transport.
418
+ if (!capability) {
419
+ throw new UnsupportedCapabilityError(providerId, "reader");
420
+ }
421
+ // Shared Reader execution dependencies. The cache/sleep/random
422
+ // default to the same production values as Search and Repository
423
+ // but are kept as separate optional MainDependencies so reader
424
+ // tests can inject isolated in-memory doubles.
425
+ const executionDeps = {
426
+ cache: deps.readerCache,
427
+ sleep: deps.readerSleep,
428
+ random: deps.readerRandom,
429
+ };
430
+ return invokeCommand(deps.invocation, (context) => read(url, {
224
431
  format: flags.format,
225
432
  noImages: flags["no-images"] === true,
226
433
  noCache: flags["no-cache"] === true,
@@ -231,112 +438,231 @@ async function handleRead(args) {
231
438
  withImagesSummary: flags["with-images-summary"] === true,
232
439
  maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
233
440
  fullEnvelope: flags["full-envelope"] === true,
234
- extract: isExtractMode(flags.extract) ? flags.extract : undefined,
235
- });
441
+ extract,
442
+ }, { capability, execution: executionDeps }, context), outputMode, deps.now, deps.secrets);
236
443
  }
237
- async function handleRepo(args) {
444
+ async function handleRepo(args, outputMode, deps) {
238
445
  const { flags, positional } = parseArgs(args);
239
446
  if (flags.help || flags.h || positional.length === 0) {
240
- console.log(REPO_HELP);
241
- return;
447
+ deps.invocation.writeStdout(REPO_HELP);
448
+ return 0;
242
449
  }
243
450
  const command = positional[0];
244
451
  const repo = positional[1];
452
+ // Parse-level validation. Subcommand grammar and required positionals
453
+ // are validated BEFORE Provider resolution, capability support check,
454
+ // configuration check, Adapter construction, or any
455
+ // operation/cacheIdentity/credential/cache/transport work. The
456
+ // pre-dispatch `configuredSecrets(env)` redaction read in `main` is
457
+ // the only permitted credential-related read before this point; it is
458
+ // not selected-Provider resolution and is covered separately by the
459
+ // ordering tests.
460
+ let searchQuery;
461
+ let readPath;
462
+ if (command === "search") {
463
+ searchQuery = positional.slice(2).join(" ");
464
+ if (!repo || !searchQuery) {
465
+ throw new ValidationError("Missing repo or query", "Usage: scoutline repo search <owner/repo> <query>");
466
+ }
467
+ }
468
+ else if (command === "tree") {
469
+ if (!repo) {
470
+ throw new ValidationError("Missing repo", "Usage: scoutline repo tree <owner/repo>");
471
+ }
472
+ }
473
+ else if (command === "read") {
474
+ readPath = positional[2];
475
+ if (!repo || !readPath) {
476
+ throw new ValidationError("Missing repo or path", "Usage: scoutline repo read <owner/repo> <path>");
477
+ }
478
+ }
479
+ else {
480
+ throw new ValidationError(`Unknown repo command: ${command}`, 'Run "scoutline repo --help" for available commands');
481
+ }
482
+ // Resolve the effective Provider (DESIGN.md §6, FR-001–FR-005):
483
+ // explicit --provider > SCOUTLINE_PROVIDER > default zai. Selection
484
+ // never consults credentials (FR-003) and never branches on Provider
485
+ // ID; an unknown explicit/env value throws VALIDATION_ERROR here.
486
+ const providerId = resolveProviderId(deps.provider, deps.env);
487
+ const descriptor = getProviderDescriptor(providerId, deps.providerDescriptors);
488
+ // Capability support check BEFORE `descriptor.isConfigured`,
489
+ // `descriptor.create`, Adapter access, operation validation/
490
+ // cacheIdentity, credential use, cache, or transport. Unsupported
491
+ // MiniMax (explicit or environment) returns UNSUPPORTED_CAPABILITY
492
+ // with no fallback to Z.AI; zero selected-Provider work occurs.
493
+ if (!descriptor.capabilities().has("repository-exploration")) {
494
+ throw new UnsupportedCapabilityError(providerId, "repository-exploration");
495
+ }
496
+ // FR-003: selection returns the default zai even when unconfigured.
497
+ // Supported-but-unconfigured Z.AI surfaces a missing credential as
498
+ // ConfigurationError (exit 3) AFTER the support metadata check and
499
+ // BEFORE `descriptor.create`.
500
+ if (!descriptor.isConfigured(deps.env)) {
501
+ throw new ConfigurationError(`Provider "${providerId}" is not configured. Set the required API key.`);
502
+ }
503
+ const adapter = descriptor.create({ env: deps.env });
504
+ const capability = adapter.repository;
505
+ // Defensive fail-closed: the descriptor advertised support but the
506
+ // Adapter omitted the handle. Treat as unsupported so a registry
507
+ // mismatch can never reach transport.
508
+ if (!capability) {
509
+ throw new UnsupportedCapabilityError(providerId, "repository-exploration");
510
+ }
511
+ // Shared Repository execution dependencies. The cache/sleep/random
512
+ // default to the same production values as Search but are kept as
513
+ // separate optional MainDependencies so repository tests can inject
514
+ // isolated in-memory doubles.
515
+ const executionDeps = {
516
+ cache: deps.repositoryCache,
517
+ sleep: deps.repositorySleep,
518
+ random: deps.repositoryRandom,
519
+ };
245
520
  switch (command) {
246
- case "search":
247
- const query = positional.slice(2).join(" ");
248
- if (!repo || !query) {
249
- outputError("Missing repo or query", "INVALID_ARGS", "Usage: scoutline repo search <owner/repo> <query>");
250
- }
251
- await repoSearch(repo, query, {
252
- language: flags.language,
253
- maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
254
- noCache: flags["no-cache"] === true,
255
- });
256
- break;
257
- case "tree":
258
- if (!repo) {
259
- outputError("Missing repo", "INVALID_ARGS", "Usage: scoutline repo tree <owner/repo>");
260
- }
261
- await repoTree(repo, {
262
- path: flags.path,
263
- depth: flags.depth ? parseInt(flags.depth, 10) : undefined,
264
- noCache: flags["no-cache"] === true,
265
- });
266
- break;
267
- case "read":
268
- const path = positional[2];
269
- if (!repo || !path) {
270
- outputError("Missing repo or path", "INVALID_ARGS", "Usage: scoutline repo read <owner/repo> <path>");
271
- }
272
- await repoRead(repo, path, {
273
- maxChars: flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined,
274
- noCache: flags["no-cache"] === true,
275
- });
276
- break;
521
+ case "search": {
522
+ const language = flags.language;
523
+ const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
524
+ const noCache = flags["no-cache"] === true;
525
+ return invokeCommand(deps.invocation, () => repoSearch(repo, searchQuery, { language, maxChars, noCache }, { capability, execution: executionDeps }), outputMode, deps.now, deps.secrets);
526
+ }
527
+ case "tree": {
528
+ const treePath = flags.path;
529
+ const depth = flags.depth ? parseInt(flags.depth, 10) : undefined;
530
+ const noCache = flags["no-cache"] === true;
531
+ return invokeCommand(deps.invocation, () => repoTree(repo, { path: treePath, depth, noCache }, { capability, execution: executionDeps }), outputMode, deps.now, deps.secrets);
532
+ }
533
+ case "read": {
534
+ const maxChars = flags["max-chars"] ? parseInt(flags["max-chars"], 10) : undefined;
535
+ const noCache = flags["no-cache"] === true;
536
+ return invokeCommand(deps.invocation, () => repoRead(repo, readPath, { maxChars, noCache }, { capability, execution: executionDeps }), outputMode, deps.now, deps.secrets);
537
+ }
277
538
  default:
278
- outputError(`Unknown repo command: ${command}`, "INVALID_ARGS", 'Run "scoutline repo --help" for available commands');
539
+ // Unreachable: the parse-level validation above already rejected
540
+ // unknown subcommands. Keep a defensive throw so the dispatch
541
+ // table stays total.
542
+ throw new ValidationError(`Unknown repo command: ${command}`, 'Run "scoutline repo --help" for available commands');
279
543
  }
280
544
  }
281
- async function handleTools(args) {
545
+ async function handleTools(args, outputMode, deps) {
282
546
  const { flags } = parseArgs(args);
283
547
  if (flags.help || flags.h) {
284
- console.log(TOOLS_HELP);
285
- return;
548
+ deps.invocation.writeStdout(TOOLS_HELP);
549
+ return 0;
286
550
  }
287
- await listTools({
551
+ return invokeCommand(deps.invocation, (context) => listTools({
288
552
  filter: flags.filter,
289
553
  full: flags.full === true,
290
554
  typescript: flags.typescript === true || flags.ts === true,
291
555
  enableVision: flags.vision !== false,
292
- });
556
+ }, context), outputMode, deps.now, deps.secrets);
293
557
  }
294
- async function handleTool(args) {
558
+ async function handleTool(args, outputMode, deps) {
295
559
  const { flags, positional } = parseArgs(args);
296
560
  if (flags.help || flags.h || positional.length === 0) {
297
- console.log(TOOLS_HELP);
298
- return;
561
+ deps.invocation.writeStdout(TOOLS_HELP);
562
+ return 0;
299
563
  }
300
- await showTool(positional[0], { enableVision: flags.vision !== false });
564
+ return invokeCommand(deps.invocation, (context) => showTool(positional[0], { enableVision: flags.vision !== false }, context), outputMode, deps.now, deps.secrets);
301
565
  }
302
- async function handleCall(args) {
566
+ async function handleCall(args, outputMode, deps) {
303
567
  const { flags, positional } = parseArgs(args);
304
568
  if (flags.help || flags.h || positional.length === 0) {
305
- console.log(CALL_HELP);
306
- return;
569
+ deps.invocation.writeStdout(CALL_HELP);
570
+ return 0;
307
571
  }
308
- await callTool(positional[0], {
572
+ return invokeCommand(deps.invocation, (context) => callTool(positional[0], {
309
573
  json: flags.json,
310
574
  file: flags.file,
311
575
  stdin: flags.stdin === true,
312
576
  dryRun: flags["dry-run"] === true,
313
577
  enableVision: flags.vision !== false,
314
- });
578
+ }, context), outputMode, deps.now, deps.secrets);
315
579
  }
316
- async function handleDoctor(args) {
580
+ async function handleDoctor(args, outputMode, deps) {
317
581
  const { flags } = parseArgs(args);
318
582
  if (flags.help || flags.h) {
319
- console.log(DOCTOR_HELP);
320
- return;
583
+ deps.invocation.writeStdout(DOCTOR_HELP);
584
+ return 0;
585
+ }
586
+ const noTools = flags["no-tools"] === true;
587
+ // Resolve the effective Provider ID for report metadata, mirroring
588
+ // Search/Vision/quota. Descriptors are intentionally NOT passed here so
589
+ // the report always lists every built-in Provider even when the
590
+ // effective Provider is unconfigured.
591
+ const effectiveProvider = resolveProviderId(deps.provider, deps.env);
592
+ // Cache Module Unification Ticket 03 — Doctor's one-line cache summary.
593
+ // The dispatcher formats the summary here (L1 fix) from `cacheStats()`
594
+ // output; the report builder only embeds the pre-formatted string.
595
+ // `cacheStats()` never throws (it catches all I/O internally); on a
596
+ // missing directory it returns zeros, which still format correctly.
597
+ const cacheSummary = formatDoctorCacheSummary(await cacheStats());
598
+ return invokeCommand(deps.invocation, () => doctor({
599
+ buildReport: () => buildDiagnosticsReport({
600
+ noTools,
601
+ effectiveProvider,
602
+ descriptors: deps.providerDescriptors,
603
+ env: deps.env,
604
+ sleep: deps.searchSleep,
605
+ random: deps.searchRandom,
606
+ cacheSummary,
607
+ }),
608
+ }), outputMode, deps.now, deps.secrets);
609
+ }
610
+ /**
611
+ * `scoutline cache <stats|clear>` — local cache utility. Like Doctor,
612
+ * it bypasses Provider resolution entirely (no descriptor lookup, no
613
+ * Adapter, no transport). The command surfaces the inventory and clear
614
+ * helpers owned by `src/lib/cache.ts` (Ticket 01) through the
615
+ * presentation-only handlers in `src/commands/cache.ts`.
616
+ */
617
+ async function handleCache(args, outputMode, deps) {
618
+ const { flags, positional } = parseArgs(args);
619
+ if (flags.help || flags.h || positional.length === 0) {
620
+ deps.invocation.writeStdout(CACHE_HELP);
621
+ return 0;
622
+ }
623
+ const subcommand = positional[0];
624
+ switch (subcommand) {
625
+ case "stats":
626
+ return invokeCommand(deps.invocation, () => cacheStatsCommand({
627
+ getStats: () => cacheStats(),
628
+ }), outputMode, deps.now, deps.secrets);
629
+ case "clear":
630
+ return invokeCommand(deps.invocation, () => cacheClearCommand({
631
+ clear: () => clearAllCaches(),
632
+ }), outputMode, deps.now, deps.secrets);
633
+ default:
634
+ throw new ValidationError(`Unknown cache command: ${subcommand}`, 'Run "scoutline cache --help" for available commands');
321
635
  }
322
- await doctor({
323
- noTools: flags["no-tools"] === true,
324
- enableVision: flags.vision !== false,
325
- });
326
636
  }
327
- async function handleQuota(args) {
637
+ async function handleQuota(args, outputMode, deps) {
328
638
  const { flags } = parseArgs(args);
329
639
  if (flags.help || flags.h) {
330
- console.log(QUOTA_HELP);
331
- return;
640
+ deps.invocation.writeStdout(QUOTA_HELP);
641
+ return 0;
332
642
  }
333
- await quota({});
643
+ const allProviders = flags["all-providers"] === true;
644
+ // Resolve the effective Provider ID for dashboard metadata. Config
645
+ // validation is owned by the dashboard builder (ConfigurationError,
646
+ // exit 3) so an unconfigured default is reported as configuration, not
647
+ // a registry error. Descriptors are intentionally NOT passed here so
648
+ // all-provider mode is not blocked by an unconfigured effective.
649
+ const effectiveProvider = resolveProviderId(deps.provider, deps.env);
650
+ return invokeCommand(deps.invocation, () => quota({
651
+ buildDashboard: () => buildQuotaDashboard({
652
+ allProviders,
653
+ effectiveProvider,
654
+ descriptors: deps.providerDescriptors,
655
+ env: deps.env,
656
+ sleep: deps.searchSleep,
657
+ random: deps.searchRandom,
658
+ }),
659
+ }), outputMode, deps.now, deps.secrets);
334
660
  }
335
- async function handleCode(args) {
661
+ async function handleCode(args, outputMode, deps) {
336
662
  const { flags, positional } = parseArgs(args);
337
663
  if (flags.help || flags.h || positional.length === 0) {
338
- console.log(CODE_HELP);
339
- return;
664
+ deps.invocation.writeStdout(CODE_HELP);
665
+ return 0;
340
666
  }
341
667
  const command = positional[0];
342
668
  const timeout = flags.timeout ? parseInt(flags.timeout, 10) : undefined;
@@ -345,99 +671,130 @@ async function handleCode(args) {
345
671
  case "run": {
346
672
  const filePath = positional[1];
347
673
  if (!filePath) {
348
- outputError("Missing code file", "INVALID_ARGS", "Usage: scoutline code run <file>");
674
+ throw new ValidationError("Missing code file", "Usage: scoutline code run <file>");
349
675
  }
350
- await runCodeFile(filePath, { timeout, includeLogs });
351
- break;
676
+ return invokeCommand(deps.invocation, (context) => runCodeFile(filePath, { timeout, includeLogs }, context), outputMode, deps.now, deps.secrets);
352
677
  }
353
678
  case "eval": {
354
679
  const code = positional.slice(1).join(" ");
355
680
  if (!code) {
356
- outputError("Missing code string", "INVALID_ARGS", "Usage: scoutline code eval <code>");
681
+ throw new ValidationError("Missing code string", "Usage: scoutline code eval <code>");
357
682
  }
358
- await evalCode(code, { timeout, includeLogs });
359
- break;
683
+ return invokeCommand(deps.invocation, (context) => evalCode(code, { timeout, includeLogs }, context), outputMode, deps.now, deps.secrets);
360
684
  }
361
685
  case "interfaces":
362
- await printInterfaces();
363
- break;
686
+ return invokeCommand(deps.invocation, (context) => printInterfaces(context), outputMode, deps.now, deps.secrets);
364
687
  case "prompt":
365
- printPromptTemplate();
366
- break;
688
+ return invokeCommand(deps.invocation, async (context) => printPromptTemplate(context), outputMode, deps.now, deps.secrets);
367
689
  default:
368
- outputError(`Unknown code command: ${command}`, "INVALID_ARGS", 'Run "scoutline code --help" for available commands');
690
+ throw new ValidationError(`Unknown code command: ${command}`, 'Run "scoutline code --help" for available commands');
369
691
  }
370
692
  }
371
- export async function main(args) {
372
- const cleanArgs = args[0]?.includes("node") || args[0]?.includes("scoutline") ? args.slice(2) : args;
373
- const { outputFormat, forcePretty, forceRaw, rest } = extractGlobalOptions(cleanArgs);
374
- // Resolve effective mode: explicit -O wins; else --pretty-output / --raw;
375
- // else auto: "tty" when stdout is a TTY, "data" when piped.
376
- if (outputFormat) {
377
- applyOutputMode(outputFormat);
378
- }
379
- else if (forcePretty) {
380
- setOutputMode("tty");
381
- }
382
- else if (forceRaw) {
383
- setOutputMode("data");
384
- }
385
- else if (process.stdout.isTTY) {
386
- setOutputMode("tty");
387
- } // else: default "data" mode (set at module load in output.ts)
693
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
694
+ export async function main(args, dependencies) {
695
+ const { invocation, env, now } = dependencies;
696
+ const providerDescriptors = dependencies.providerDescriptors ?? BUILT_IN_PROVIDER_DESCRIPTORS;
697
+ const searchCache = dependencies.searchCache ?? defaultResponseCache;
698
+ const searchSleep = dependencies.searchSleep ?? realSleep;
699
+ const searchRandom = dependencies.searchRandom ?? Math.random;
700
+ // P6-07: Repository execution defaults to the same production values
701
+ // as Search but stays as separate optional MainDependencies so
702
+ // repository tests can inject isolated in-memory doubles.
703
+ const repositoryCache = dependencies.repositoryCache ?? defaultResponseCache;
704
+ const repositorySleep = dependencies.repositorySleep ?? realSleep;
705
+ const repositoryRandom = dependencies.repositoryRandom ?? Math.random;
706
+ // Reader Migration Ticket 04: Reader execution defaults to the same
707
+ // production values as Search/Repository but stays as separate
708
+ // optional MainDependencies so reader tests can inject isolated
709
+ // in-memory doubles.
710
+ const readerCache = dependencies.readerCache ?? defaultResponseCache;
711
+ const readerSleep = dependencies.readerSleep ?? realSleep;
712
+ const readerRandom = dependencies.readerRandom ?? Math.random;
713
+ // Resolve configured Provider credentials from the INJECTED env (B3) so
714
+ // redaction follows the same environment the handlers see — a secret
715
+ // that exists only in MainDependencies.env is still redacted from output.
716
+ const secrets = configuredSecrets(env);
717
+ const { outputFormat, forcePretty, forceRaw, provider, rest } = extractGlobalOptions([...args]);
718
+ // Fixup C — B10: resolve the output mode BEFORE the dispatch try/catch.
719
+ // An invalid explicit mode still surfaces as a typed ValidationError,
720
+ // but the surface formatter uses the user's REQUESTED mode (or the
721
+ // best deterministic fallback) so the envelope matches what the user
722
+ // asked for. Pre-invocation validation errors (provider resolution,
723
+ // missing credentials, count parsing, etc.) MUST honour the requested
724
+ // output mode the same way handler errors do.
725
+ let outputMode;
726
+ try {
727
+ outputMode = resolveOutputMode(outputFormat, forcePretty ?? false, forceRaw ?? false, invocation);
728
+ }
729
+ catch (error) {
730
+ // The explicit mode is invalid — fall back to a deterministic
731
+ // compact form so we can still surface a structured error envelope.
732
+ invocation.writeStderr(formatErrorOutput(error, "data", secrets));
733
+ return getErrorExitCode(error);
734
+ }
388
735
  if (rest.length === 0 || rest[0] === "--help" || rest[0] === "-h") {
389
- console.log(MAIN_HELP);
390
- return;
736
+ invocation.writeStdout(MAIN_HELP);
737
+ return 0;
391
738
  }
392
739
  if (rest[0] === "--version" || rest[0] === "-v") {
393
- console.log(VERSION);
394
- return;
740
+ invocation.writeStdout(VERSION);
741
+ return 0;
395
742
  }
396
743
  const command = rest[0];
397
744
  const commandArgs = rest.slice(1);
398
- switch (command) {
399
- case "vision":
400
- await handleVision(commandArgs);
401
- break;
402
- case "search":
403
- await handleSearch(commandArgs);
404
- break;
405
- case "read":
406
- await handleRead(commandArgs);
407
- break;
408
- case "repo":
409
- await handleRepo(commandArgs);
410
- break;
411
- case "tools":
412
- await handleTools(commandArgs);
413
- break;
414
- case "tool":
415
- await handleTool(commandArgs);
416
- break;
417
- case "call":
418
- await handleCall(commandArgs);
419
- break;
420
- case "doctor":
421
- await handleDoctor(commandArgs);
422
- break;
423
- case "quota":
424
- await handleQuota(commandArgs);
425
- break;
426
- case "code":
427
- await handleCode(commandArgs);
428
- break;
429
- default:
430
- outputError(`Unknown command: ${command}`, "INVALID_ARGS", 'Run "scoutline --help" for available commands');
745
+ const handlerDeps = {
746
+ invocation,
747
+ env,
748
+ secrets,
749
+ now,
750
+ provider,
751
+ providerDescriptors,
752
+ searchCache,
753
+ searchSleep,
754
+ searchRandom,
755
+ repositoryCache,
756
+ repositorySleep,
757
+ repositoryRandom,
758
+ readerCache,
759
+ readerSleep,
760
+ readerRandom,
761
+ };
762
+ try {
763
+ switch (command) {
764
+ case "vision":
765
+ return await handleVision(commandArgs, outputMode, handlerDeps);
766
+ case "search":
767
+ return await handleSearch(commandArgs, outputMode, handlerDeps);
768
+ case "read":
769
+ return await handleRead(commandArgs, outputMode, handlerDeps);
770
+ case "repo":
771
+ return await handleRepo(commandArgs, outputMode, handlerDeps);
772
+ case "tools":
773
+ return await handleTools(commandArgs, outputMode, handlerDeps);
774
+ case "tool":
775
+ return await handleTool(commandArgs, outputMode, handlerDeps);
776
+ case "call":
777
+ return await handleCall(commandArgs, outputMode, handlerDeps);
778
+ case "doctor":
779
+ return await handleDoctor(commandArgs, outputMode, handlerDeps);
780
+ case "cache":
781
+ return await handleCache(commandArgs, outputMode, handlerDeps);
782
+ case "quota":
783
+ return await handleQuota(commandArgs, outputMode, handlerDeps);
784
+ case "code":
785
+ return await handleCode(commandArgs, outputMode, handlerDeps);
786
+ default:
787
+ invocation.writeStderr(formatErrorOutput(new ValidationError(`Unknown command: ${command}`, 'Run "scoutline --help" for available commands'), outputMode, secrets));
788
+ return 1;
789
+ }
790
+ }
791
+ catch (error) {
792
+ // Fixup C — B10: pre-invocation validation errors (provider
793
+ // resolution, missing credential, count parsing, etc.) MUST be
794
+ // formatted in the resolved output mode — they used to be hardcoded
795
+ // to "data" regardless of what the user asked for.
796
+ invocation.writeStderr(formatErrorOutput(error, outputMode, secrets));
797
+ return getErrorExitCode(error);
431
798
  }
432
799
  }
433
- main(process.argv)
434
- .then(() => process.exit(0))
435
- .catch((error) => {
436
- console.error(JSON.stringify({
437
- success: false,
438
- error: error.message,
439
- code: "FATAL_ERROR",
440
- }, null, 2));
441
- process.exit(1);
442
- });
443
800
  //# sourceMappingURL=index.js.map