@catheadowl/ukp 0.1.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,355 @@
1
+ import {
2
+ runRg,
3
+ projectRgEnvelope,
4
+ renderRgHuman,
5
+ RG_DEFAULT_LIMIT,
6
+ RG_MAX_LIMIT,
7
+ RgPlanningError,
8
+ RgUsageError,
9
+ validateRgPassthrough,
10
+ type ParsedRg,
11
+ type RgAggregateStatus,
12
+ type RgContext,
13
+ type RgEndpointOutcome,
14
+ type RgResult,
15
+ } from "../capabilities/rg.ts";
16
+ import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
17
+ import {
18
+ createTransportPool,
19
+ fetchDiscoveryDocument,
20
+ remoteRg,
21
+ resolveRemoteToken,
22
+ } from "../capabilities/remote-client.ts";
23
+ import { isRemoteBinding, readRegistry, type RegistryBinding } from "../registry.ts";
24
+ import { resolveScope, ScopeError } from "../scope.ts";
25
+ import { ManifestError } from "../config/manifest.ts";
26
+ import { KitUsageError, parseKitArgs, renderKitHelp, renderKitUsageError, type UkpCommandSpec } from "./kit.ts";
27
+ import { HelpRequestError, isHelpRequest } from "./flags.ts";
28
+
29
+ /** CLI-owned command result shape (ADR 0021). */
30
+ export interface RgCommandResult {
31
+ exitCode: number;
32
+ stdout: string;
33
+ stderr: string;
34
+ }
35
+
36
+ /** Aggregate classification → exit code (ADR 0021 / D-036 semantics). */
37
+ const RG_EXIT_BY_AGGREGATE: Record<RgAggregateStatus, number> = {
38
+ "succeeded": 0,
39
+ "failed": 1,
40
+ "no-success": 1,
41
+ "cancelled": 130,
42
+ };
43
+
44
+ /** Single-source command spec (ADR 0024). Options-only form (like search):
45
+ * `-c`/`-g` declared inline so the rendered option order and the error
46
+ * precedence (limit validation before duplicate warnings, excess before
47
+ * conflict) stay identical with the pre-kit command. */
48
+ export const RG_SPEC: UkpCommandSpec = {
49
+ name: "rg",
50
+ summary: "grep raw endpoint files with ripgrep (no index or declaration needed; results as ukp:// references)",
51
+ group: "endpoint",
52
+ description:
53
+ "Run base lexical search (ripgrep) across one or more Service endpoints. "
54
+ + "Available on every registered endpoint by default; results are shaped into ukp:// references that 'ukp read' consumes directly. "
55
+ + "For indexed/semantic search use 'ukp search'.",
56
+ usage: "[--endpoint <name> ... | -g] <pattern> [--limit <1-1000>] [--count] [--glob <glob>] [--type <type>] [-i] [-- <rg flags>]",
57
+ arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell" }],
58
+ options: [
59
+ { flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
60
+ { flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
61
+ { flags: "--limit <1-1000>", help: `maximum matches per run (default: ${RG_DEFAULT_LIMIT})` },
62
+ { flags: "--count", help: "count mode: per-file match counts instead of matches" },
63
+ { flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\")" },
64
+ { flags: "--type <type>", help: "file type filter passed to rg (e.g. md, py)" },
65
+ { flags: "-i", help: "case-insensitive search" },
66
+ { flags: "--json", help: "emit the structured response envelope" },
67
+ ],
68
+ helpSuffix: [
69
+ "",
70
+ "Passthrough:",
71
+ " After '--', rg native flags are passed through on an allowlist",
72
+ " (-A/-B/-C/-m/--glob/--type/--max-filesize and common boolean flags).",
73
+ " Path operands and output-changing flags (--json, -r, --pre, --config)",
74
+ " are rejected: the endpoint selector owns scope, UKP owns the output.",
75
+ "",
76
+ "Result scope:",
77
+ " With no --endpoint or -g, the workspace default scope applies: the",
78
+ " Client Config's default endpoints ('ukp inspect' shows the resolved",
79
+ " scope); with no Client Config, every registered endpoint. -g cannot",
80
+ " be combined with --endpoint. 'Available on",
81
+ " every registered endpoint' refers to capability availability, not this",
82
+ " default scope.",
83
+ " rg scans each endpoint's own files (same visibility root as read/file).",
84
+ " 'ukp search' covers indexed search where the Service declares a search",
85
+ " capability provider.",
86
+ "",
87
+ ].join("\n"),
88
+ };
89
+
90
+ interface RgCommandOptions extends Record<string, unknown> {
91
+ endpoint?: string[];
92
+ g?: boolean;
93
+ limit?: string;
94
+ count?: boolean;
95
+ glob?: string;
96
+ type?: string;
97
+ i?: boolean;
98
+ json?: boolean;
99
+ }
100
+
101
+ export function parseRgArgs(args: readonly string[]): ParsedRg {
102
+ const parsed = parseKitArgs<RgCommandOptions>(RG_SPEC, args);
103
+ const explicit: string[] = [];
104
+ const warnings: string[] = [];
105
+
106
+ // Command-side semantic checks, in the pre-kit order: limit validation,
107
+ // duplicate warnings, excess positional, emptiness, conflict.
108
+ let limit = RG_DEFAULT_LIMIT;
109
+ if (parsed.options.limit !== undefined) {
110
+ if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
111
+ limit = Number(parsed.options.limit);
112
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > RG_MAX_LIMIT) {
113
+ throw new KitUsageError(`--limit must be between 1 and ${RG_MAX_LIMIT}`);
114
+ }
115
+ }
116
+
117
+ for (const endpoint of parsed.options.endpoint ?? []) {
118
+ if (explicit.includes(endpoint)) warnings.push(`duplicate endpoint '${endpoint}' ignored`);
119
+ else explicit.push(endpoint);
120
+ }
121
+
122
+ const [pattern, unexpected] = parsed.positionals;
123
+ if (unexpected !== undefined) {
124
+ throw new KitUsageError(
125
+ `unexpected argument '${unexpected}'; rg accepts exactly one pattern. Use '--endpoint <name>' to select an endpoint; '-g' takes no value.`,
126
+ );
127
+ }
128
+ if (pattern === undefined || pattern.length === 0) throw new KitUsageError("rg pattern must be non-empty");
129
+ if (parsed.options.g && explicit.length > 0) {
130
+ throw new KitUsageError("--endpoint and -g cannot be used together");
131
+ }
132
+
133
+ return {
134
+ request: { query: pattern, limit },
135
+ options: {
136
+ explicitEndpoints: explicit.length > 0 ? explicit : undefined,
137
+ global: parsed.options.g ?? false,
138
+ ...(parsed.options.glob === undefined ? {} : { glob: parsed.options.glob }),
139
+ ...(parsed.options.type === undefined ? {} : { type: parsed.options.type }),
140
+ ...(parsed.options.i === true ? { ignoreCase: true } : {}),
141
+ ...(parsed.options.count === true ? { count: true } : {}),
142
+ ...(parsed.options.json === true ? { json: true } : {}),
143
+ passthrough: [],
144
+ },
145
+ warnings,
146
+ };
147
+ }
148
+
149
+ /** Splits argv at the first `--`; everything after is rg passthrough
150
+ * (validated against the allowlist, ADR-RG-002). */
151
+ export function splitRgPassthrough(args: readonly string[]): { commandArgs: string[]; passthrough: string[] } {
152
+ const separator = args.indexOf("--");
153
+ if (separator === -1) return { commandArgs: [...args], passthrough: [] };
154
+ return {
155
+ commandArgs: args.slice(0, separator),
156
+ passthrough: args.slice(separator + 1),
157
+ };
158
+ }
159
+
160
+ export function executeRgCommand(
161
+ args: readonly string[],
162
+ context: RgContext,
163
+ ): RgCommandResult | Promise<RgCommandResult> {
164
+ if (isHelpRequest(args)) {
165
+ return { exitCode: 0, stdout: renderRgHelp(), stderr: "" };
166
+ }
167
+
168
+ const { commandArgs, passthrough } = splitRgPassthrough(args);
169
+ let parsed: ParsedRg;
170
+ try {
171
+ // Validate passthrough before parsing so a rejected flag surfaces as a
172
+ // usage error (exit 2) before any endpoint work starts.
173
+ validateRgPassthrough(passthrough);
174
+ parsed = parseRgArgs(commandArgs);
175
+ parsed.options.passthrough = passthrough;
176
+ } catch (error) {
177
+ if (error instanceof HelpRequestError) {
178
+ return { exitCode: 0, stdout: renderRgHelp(), stderr: "" };
179
+ }
180
+ if (error instanceof KitUsageError || error instanceof RgUsageError) {
181
+ // RgUsageError: capability-side passthrough allowlist validation
182
+ // classifies as usage too — same rendering, exit 2.
183
+ return { exitCode: 2, stdout: "", stderr: renderRgUsageError(error.message) };
184
+ }
185
+ throw error;
186
+ }
187
+
188
+ try {
189
+ // Remote-in-scope detection (sync, ukp_remote W6): mirrors search's
190
+ // conditional-async seam — local endpoints keep the exact sync runRg
191
+ // path, remote endpoints run over the wire in the mixed driver.
192
+ const registry = readRegistry(context.registryPath);
193
+ const scope = resolveScope({
194
+ currentDirectory: context.currentDirectory,
195
+ registry,
196
+ explicitEndpoints: parsed.options.explicitEndpoints,
197
+ global: parsed.options.global,
198
+ });
199
+ const remotes = scope.bindings.filter(isRemoteBinding);
200
+ if (remotes.length === 0) {
201
+ return renderRgCommandResult(parsed, runRg(parsed, context));
202
+ }
203
+ // The mixed driver starts with a SYNC local runRg whose typed errors
204
+ // (ScopeError/RgPlanningError/ManifestError) would otherwise escape the
205
+ // try/catch as a promise rejection — the same catch renders both paths.
206
+ return executeMixedRg(parsed, context, scope, remotes).catch(renderRgCommandError);
207
+ } catch (error) {
208
+ return renderRgCommandError(error);
209
+ }
210
+ }
211
+
212
+ /** Error rendering shared by the sync path and the mixed driver's rejection
213
+ * tail: typed planning/scope/manifest failures get the classified renders,
214
+ * anything else the generic `ukp rg:` line. */
215
+ function renderRgCommandError(error: unknown): RgCommandResult {
216
+ if (error instanceof ScopeError) {
217
+ return {
218
+ exitCode: 1,
219
+ stdout: "",
220
+ stderr: `ukp rg: ${error.message}\nRun 'ukp list' to inspect registrations and 'ukp register' from a Service folder, then retry.\n`,
221
+ };
222
+ }
223
+ if (error instanceof RgPlanningError) {
224
+ return { exitCode: 1, stdout: "", stderr: `ukp rg: ${error.message}\n` };
225
+ }
226
+ if (error instanceof ManifestError) {
227
+ return { exitCode: 1, stdout: "", stderr: `ukp rg: ${error.message}\n` };
228
+ }
229
+ return {
230
+ exitCode: 1,
231
+ stdout: "",
232
+ stderr: `ukp rg: ${error instanceof Error ? error.message : String(error)}\n`,
233
+ };
234
+ }
235
+
236
+ function renderRgCommandResult(parsed: ParsedRg, result: RgResult): RgCommandResult {
237
+ const view = renderRgHuman(result);
238
+ if (parsed.options.json === true) {
239
+ return {
240
+ exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
241
+ stdout: `${JSON.stringify(projectRgEnvelope(result, parsed.options.count === true), null, 2)}\n`,
242
+ stderr: view.diagnostics,
243
+ };
244
+ }
245
+ return {
246
+ exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
247
+ stdout: view.body,
248
+ stderr: view.diagnostics,
249
+ };
250
+ }
251
+
252
+ /** Mixed local+remote execution (ukp_remote W6, mirrors search's driver):
253
+ * locals via the unchanged sync runRg narrowed to local names, remotes via
254
+ * discovery-check + GET /v1/rg, merged in scope order under the D-036
255
+ * aggregation. */
256
+ async function executeMixedRg(
257
+ parsed: ParsedRg,
258
+ context: RgContext,
259
+ scope: ReturnType<typeof resolveScope>,
260
+ remotes: RegistryBinding[],
261
+ ): Promise<RgCommandResult> {
262
+ const remoteNames = new Set(remotes.map((binding) => binding.name));
263
+ const localNames = scope.bindings.filter((binding) => !remoteNames.has(binding.name)).map((binding) => binding.name);
264
+ const baseWarnings: string[] = [...parsed.warnings, ...scope.warnings];
265
+
266
+ let localResult: RgResult | undefined;
267
+ if (localNames.length > 0) {
268
+ localResult = runRg(
269
+ {
270
+ ...parsed,
271
+ options: { ...parsed.options, explicitEndpoints: localNames, global: false },
272
+ warnings: [...baseWarnings],
273
+ },
274
+ context,
275
+ );
276
+ }
277
+ const warnings: string[] = localResult !== undefined ? [...localResult.warnings] : [...baseWarnings];
278
+ // An interrupted local run cancels the remaining endpoints — runRg's
279
+ // interrupt semantics extend across the mixed driver (remotes never start).
280
+ const interrupted = localResult?.endpoints.some((outcome) => outcome.status === "interrupted") ?? false;
281
+
282
+ const remoteOutcomes = new Map<string, RgEndpointOutcome>();
283
+ // One transport pool for the whole invocation (W7 / O-5): same-origin door
284
+ // bindings share a single ssh tunnel instead of one per row.
285
+ const pool = createTransportPool({ registryPath: context.registryPath });
286
+ try {
287
+ for (const binding of remotes) {
288
+ if (interrupted) {
289
+ remoteOutcomes.set(binding.name, { name: binding.name, provider: EXTERNAL_PROVIDER, status: "cancelled" });
290
+ continue;
291
+ }
292
+ const token = resolveRemoteToken(binding);
293
+ try {
294
+ const transport = await pool.acquire(binding);
295
+ const discovery = await fetchDiscoveryDocument(binding, transport, token);
296
+ warnings.push(...discovery.warnings);
297
+ if (discovery.bearerRequired && token === undefined) {
298
+ warnings.push(
299
+ `endpoint '${binding.name}' requires a bearer token; pass --token at registration or set UKP_ENDPOINT_${binding.name.toUpperCase().replace(/-/g, "_")}_TOKEN`,
300
+ );
301
+ }
302
+ const execution = await remoteRg(binding, transport, token, {
303
+ query: parsed.request.query,
304
+ limit: parsed.request.limit,
305
+ ...(parsed.options.glob !== undefined ? { glob: parsed.options.glob } : {}),
306
+ ...(parsed.options.type !== undefined ? { type: parsed.options.type } : {}),
307
+ ...(parsed.options.ignoreCase === true ? { ignoreCase: true } : {}),
308
+ ...(parsed.options.count === true ? { count: true } : {}),
309
+ passthrough: parsed.options.passthrough,
310
+ });
311
+ remoteOutcomes.set(binding.name, execution.outcome);
312
+ warnings.push(...execution.warnings);
313
+ } catch (error) {
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ warnings.push(`endpoint '${binding.name}' rg failed: ${message}`);
316
+ remoteOutcomes.set(binding.name, {
317
+ name: binding.name,
318
+ provider: EXTERNAL_PROVIDER,
319
+ status: "failed",
320
+ message,
321
+ });
322
+ }
323
+ }
324
+ } finally {
325
+ pool.close();
326
+ }
327
+
328
+ const byName = new Map((localResult?.endpoints ?? []).map((outcome) => [outcome.name, outcome]));
329
+ const endpoints: RgEndpointOutcome[] = scope.bindings.map(
330
+ (binding) => byName.get(binding.name) ?? remoteOutcomes.get(binding.name)!,
331
+ );
332
+ const statuses = endpoints.map((outcome) => outcome.status);
333
+ const aggregate: RgAggregateStatus = statuses.includes("cancelled") || statuses.includes("interrupted")
334
+ ? "cancelled"
335
+ : statuses.includes("failed")
336
+ ? "failed"
337
+ : statuses.some((status) => status === "succeeded" || status === "no_matches")
338
+ ? "succeeded"
339
+ : "no-success";
340
+ return renderRgCommandResult(parsed, {
341
+ query: parsed.request.query,
342
+ limit: parsed.request.limit,
343
+ endpoints,
344
+ warnings,
345
+ aggregate,
346
+ });
347
+ }
348
+
349
+ export function renderRgHelp(): string {
350
+ return renderKitHelp(RG_SPEC);
351
+ }
352
+
353
+ export function renderRgUsageError(message: string): string {
354
+ return renderKitUsageError(RG_SPEC, message);
355
+ }
@@ -0,0 +1,325 @@
1
+ import {
2
+ runSearch,
3
+ projectSearchEnvelope,
4
+ renderSearchHuman,
5
+ renderSearchJson,
6
+ SearchPlanningError,
7
+ type ParsedSearch,
8
+ type SearchAggregateStatus,
9
+ type SearchContext,
10
+ type SearchEndpointOutcome,
11
+ type SearchResult,
12
+ } from "../capabilities/search.ts";
13
+ import {
14
+ createTransportPool,
15
+ fetchDiscoveryDocument,
16
+ remoteSearch,
17
+ resolveRemoteToken,
18
+ } from "../capabilities/remote-client.ts";
19
+ import { isRemoteBinding, readRegistry } from "../registry.ts";
20
+ import { resolveScope, ScopeError } from "../scope.ts";
21
+ import { ManifestError } from "../config/manifest.ts";
22
+ import { KitUsageError, parseKitArgs, renderKitHelp, renderKitUsageError, type UkpCommandSpec } from "./kit.ts";
23
+ import { HelpRequestError, isHelpRequest } from "./flags.ts";
24
+
25
+ /** CLI-owned command result shape (ADR 0021: exit codes and channel text
26
+ * belong to the surface adapter, not the capability). */
27
+ export interface SearchCommandResult {
28
+ exitCode: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ }
32
+
33
+ /** Aggregate classification → exit code (ADR 0021: the D-036 aggregation
34
+ * rule lives here, driven by the capability's structured classification —
35
+ * skip never changes the success state; a provider failure fails the run;
36
+ * an interrupt is 130). */
37
+ const SEARCH_EXIT_BY_AGGREGATE: Record<SearchAggregateStatus, number> = {
38
+ "succeeded": 0,
39
+ "provider-failure": 1,
40
+ "no-success": 1,
41
+ "cancelled": 130,
42
+ };
43
+
44
+ /** Single-source command spec (ADR 0024). Options-only form: `-c`/`-g` are
45
+ * declared inline rather than via `scope` so the rendered option order and
46
+ * the error precedence (limit validation before duplicate warnings, excess
47
+ * check before the conflict) stay byte-identical with the pre-kit command;
48
+ * adopting the generated scope family waits for an adjudicated
49
+ * precedence-unification pass. */
50
+ export const SEARCH_SPEC: UkpCommandSpec = {
51
+ name: "search",
52
+ summary: "search a Service's indexed content (provider-backed; use 'ukp rg' to grep raw files)",
53
+ group: "endpoint",
54
+ description: "Run atomic lexical search against the selected Service endpoints.",
55
+ usage: "<query> [--limit <1-1000>] [--endpoint <name> ... | -g] [--recursive] [--json]",
56
+ arguments: [{ name: "query", help: "one non-empty search query; quote multi-word queries" }],
57
+ options: [
58
+ { flags: "--limit <1-1000>", help: "maximum results requested from each endpoint (default: 20)" },
59
+ { flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
60
+ { flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
61
+ { flags: "--recursive", help: "include direct authority and context dependencies" },
62
+ { flags: "--json", help: "write provider-native results to artifacts and print an envelope" },
63
+ ],
64
+ helpSuffix: [
65
+ "",
66
+ "Result scope:",
67
+ " With no --endpoint or -g, the workspace default scope applies: the",
68
+ " Client Config's default endpoints ('ukp inspect' shows the resolved",
69
+ " scope); with no Client Config, every registered endpoint. -g cannot",
70
+ " be combined with --endpoint.",
71
+ " --endpoint <name> requests that Service's search capability. The result",
72
+ " range is decided by the Service's provider configuration and may include",
73
+ " shared collections; results are not guaranteed to be the endpoint's own",
74
+ " content. '== <name> ==' reports which Service was asked, not content",
75
+ " ownership.",
76
+ " --recursive keeps selected endpoints as depth-0 seeds and adds their",
77
+ " registered authority/context dependencies at depth 1.",
78
+ " Human results carry a copyable 'read:'/'uri:' handoff line that",
79
+ " 'ukp read' accepts directly.",
80
+ "",
81
+ ].join("\n"),
82
+ };
83
+
84
+ interface SearchCommandOptions extends Record<string, unknown> {
85
+ limit?: string;
86
+ endpoint?: string[];
87
+ g?: boolean;
88
+ recursive?: boolean;
89
+ json?: boolean;
90
+ }
91
+
92
+ export function parseSearchArgs(args: readonly string[]): ParsedSearch {
93
+ let limit = 20;
94
+ const explicit: string[] = [];
95
+ const warnings: string[] = [];
96
+ const parsed = parseKitArgs<SearchCommandOptions>(SEARCH_SPEC, args);
97
+
98
+ // Command-side semantic checks, in the pre-kit order (limit validation
99
+ // directly after the generated singleton checks, then duplicates, excess,
100
+ // emptiness, conflict).
101
+ if (parsed.options.limit !== undefined) {
102
+ if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
103
+ limit = Number(parsed.options.limit);
104
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
105
+ throw new KitUsageError("--limit must be between 1 and 1000");
106
+ }
107
+ }
108
+
109
+ for (const endpoint of parsed.options.endpoint ?? []) {
110
+ if (explicit.includes(endpoint)) warnings.push(`duplicate endpoint '${endpoint}' ignored`);
111
+ else explicit.push(endpoint);
112
+ }
113
+
114
+ const [query, unexpected] = parsed.positionals;
115
+ if (unexpected !== undefined) {
116
+ throw new KitUsageError(
117
+ `unexpected argument '${unexpected}'; search accepts exactly one query. `
118
+ + "Use '--endpoint <name>' to select an endpoint; '-g' takes no value.",
119
+ );
120
+ }
121
+ if (query === undefined || query.length === 0) throw new KitUsageError("search query must be non-empty");
122
+ if (parsed.options.g && explicit.length > 0) {
123
+ throw new KitUsageError("--endpoint and -g cannot be used together");
124
+ }
125
+ return {
126
+ request: { query, limit },
127
+ options: {
128
+ explicitEndpoints: explicit.length > 0 ? explicit : undefined,
129
+ global: parsed.options.g ?? false,
130
+ recursive: parsed.options.recursive ?? false,
131
+ json: parsed.options.json ?? false,
132
+ },
133
+ warnings,
134
+ };
135
+ }
136
+
137
+ /** Remote-in-scope detection (sync): when the selected scope contains remote
138
+ * bindings, execution switches to the async mixed driver — local endpoints
139
+ * keep the exact sync runSearch path (zero contract change), remote
140
+ * endpoints run over the wire. The conditional-async seam is the ukp_remote
141
+ * W2 migration boundary; full async-native execution is a later refactor. */
142
+ function selectedRemoteBindings(parsed: ParsedSearch, context: SearchContext) {
143
+ const registry = readRegistry(context.registryPath);
144
+ const scope = resolveScope({
145
+ currentDirectory: context.currentDirectory,
146
+ registry,
147
+ explicitEndpoints: parsed.options.explicitEndpoints,
148
+ global: parsed.options.global,
149
+ });
150
+ return { registry, scope, remotes: scope.bindings.filter(isRemoteBinding) };
151
+ }
152
+
153
+ export function executeSearchCommand(
154
+ args: readonly string[],
155
+ context: SearchContext,
156
+ ): SearchCommandResult | Promise<SearchCommandResult> {
157
+ if (isHelpRequest(args)) {
158
+ return { exitCode: 0, stdout: renderSearchHelp(), stderr: "" };
159
+ }
160
+
161
+ try {
162
+ const parsed = parseSearchArgs(args);
163
+ const { scope, remotes } = selectedRemoteBindings(parsed, context);
164
+ if (remotes.length === 0) {
165
+ return executeHumanSearch(parsed, context);
166
+ }
167
+ // The mixed driver starts with a SYNC local runSearch whose typed errors
168
+ // would otherwise escape the try/catch as a promise rejection (W2 seam
169
+ // flaw, caught by the W6 review) — the same catch renders both paths.
170
+ return executeMixedSearch(parsed, context, scope, remotes).catch(renderSearchCommandError);
171
+ } catch (error) {
172
+ return renderSearchCommandError(error);
173
+ }
174
+ }
175
+
176
+ function renderSearchCommandError(error: unknown): SearchCommandResult {
177
+ if (error instanceof HelpRequestError) {
178
+ return { exitCode: 0, stdout: renderSearchHelp(), stderr: "" };
179
+ }
180
+ if (error instanceof KitUsageError) {
181
+ return { exitCode: 2, stdout: "", stderr: renderSearchUsageError(error.message) };
182
+ }
183
+ if (error instanceof ScopeError) {
184
+ return { exitCode: 1, stdout: "", stderr: `ukp search: ${error.message}\n` };
185
+ }
186
+ if (error instanceof SearchPlanningError) {
187
+ return { exitCode: 1, stdout: "", stderr: `ukp search: ${error.message}\n` };
188
+ }
189
+ if (error instanceof ManifestError) {
190
+ return { exitCode: 1, stdout: "", stderr: `ukp search: ${error.message}\n` };
191
+ }
192
+ throw error;
193
+ }
194
+
195
+ /** Mixed local+remote execution: locals via the unchanged sync runSearch,
196
+ * remotes via discovery-check + POST /v1/search, merged in scope order under
197
+ * the D-036 aggregation. */
198
+ async function executeMixedSearch(
199
+ parsed: ParsedSearch,
200
+ context: SearchContext,
201
+ scope: ReturnType<typeof resolveScope>,
202
+ remotes: ReturnType<typeof selectedRemoteBindings>["remotes"],
203
+ ): Promise<SearchCommandResult> {
204
+ const remoteNames = new Set(remotes.map((binding) => binding.name));
205
+ const localNames = scope.bindings.filter((binding) => !remoteNames.has(binding.name)).map((b) => b.name);
206
+ const baseWarnings: string[] = [...parsed.warnings, ...scope.warnings];
207
+
208
+ let localResult: SearchResult | undefined;
209
+ if (localNames.length > 0) {
210
+ localResult = runSearch(
211
+ {
212
+ ...parsed,
213
+ options: { ...parsed.options, explicitEndpoints: localNames, global: false },
214
+ warnings: [...baseWarnings],
215
+ },
216
+ context,
217
+ );
218
+ }
219
+ // Local run warnings (provider failures, duplicate-endpoint notes) carry
220
+ // into the merged view; remote-phase warnings append after them.
221
+ const warnings: string[] = localResult !== undefined ? [...localResult.warnings] : [...baseWarnings];
222
+
223
+ const remoteOutcomes = new Map<string, SearchEndpointOutcome>();
224
+ // One transport pool for the whole invocation (W7 / O-5): same-origin door
225
+ // bindings share a single ssh tunnel instead of one per row.
226
+ const pool = createTransportPool({ registryPath: context.registryPath });
227
+ try {
228
+ for (const binding of remotes) {
229
+ const token = resolveRemoteToken(binding);
230
+ try {
231
+ const transport = await pool.acquire(binding);
232
+ const discovery = await fetchDiscoveryDocument(binding, transport, token);
233
+ warnings.push(...discovery.warnings);
234
+ if (discovery.bearerRequired && token === undefined) {
235
+ warnings.push(
236
+ `endpoint '${binding.name}' requires a bearer token; pass --token at registration or set UKP_ENDPOINT_${binding.name.toUpperCase().replace(/-/g, "_")}_TOKEN`,
237
+ );
238
+ }
239
+ if (!("search" in discovery.doc.capabilities)) {
240
+ const message = `endpoint '${binding.name}' declares no search capability`;
241
+ // Local planned skips surface their reason as a warning (D-036 render
242
+ // contract); the remote skip joins them so stderr stays informative.
243
+ warnings.push(message);
244
+ remoteOutcomes.set(binding.name, {
245
+ name: binding.name,
246
+ provider: null,
247
+ status: "skipped",
248
+ message,
249
+ });
250
+ continue;
251
+ }
252
+ const execution = await remoteSearch(binding, transport, token, parsed.request.query, parsed.request.limit);
253
+ remoteOutcomes.set(binding.name, {
254
+ ...execution.outcome,
255
+ ...(execution.results.length > 0 ? { providerOutput: JSON.stringify(execution.results) } : {}),
256
+ ...(execution.references !== undefined
257
+ ? { remoteUris: execution.references.map((entry) => entry.ukp_uri) }
258
+ : {}),
259
+ });
260
+ } catch (error) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ // Local failures surface via warnings (D-036 render contract); remote
263
+ // transport failures join them so stderr stays the failure channel.
264
+ warnings.push(`endpoint '${binding.name}' provider failed: ${message}`);
265
+ remoteOutcomes.set(binding.name, {
266
+ name: binding.name,
267
+ provider: null,
268
+ status: "failed",
269
+ message,
270
+ });
271
+ }
272
+ }
273
+ } finally {
274
+ pool.close();
275
+ }
276
+
277
+ const byName = new Map((localResult?.endpoints ?? []).map((outcome) => [outcome.name, outcome]));
278
+ const endpoints: SearchEndpointOutcome[] = scope.bindings.map(
279
+ (binding) => byName.get(binding.name) ?? remoteOutcomes.get(binding.name)!,
280
+ );
281
+ const statuses = endpoints.map((outcome) => outcome.status);
282
+ const aggregate: SearchAggregateStatus = statuses.includes("cancelled")
283
+ ? "cancelled"
284
+ : statuses.includes("failed")
285
+ ? "provider-failure"
286
+ : statuses.includes("succeeded")
287
+ ? "succeeded"
288
+ : "no-success";
289
+ const merged: SearchResult = {
290
+ query: parsed.request.query,
291
+ limit: parsed.request.limit,
292
+ ...(localResult?.runId !== undefined ? { runId: localResult.runId } : {}),
293
+ endpoints,
294
+ warnings,
295
+ aggregate,
296
+ };
297
+ return renderSearchCommandResult(parsed.options.json, merged);
298
+ }
299
+
300
+ function renderSearchCommandResult(json: boolean, result: SearchResult): SearchCommandResult {
301
+ const exitCode = SEARCH_EXIT_BY_AGGREGATE[result.aggregate];
302
+ if (json) {
303
+ return { exitCode, stdout: renderSearchJson(projectSearchEnvelope(result)), stderr: "" };
304
+ }
305
+ return {
306
+ exitCode,
307
+ stdout: renderSearchHuman(result),
308
+ stderr: result.warnings.length > 0 ? `${result.warnings.join("\n")}\n` : "",
309
+ };
310
+ }
311
+
312
+ /** CLI composition of the structured outcome (ADR 0021): the shared renders
313
+ * plus the aggregate → exit-code mapping. Kept as the test entry so suites
314
+ * exercise the exact adapter path the `ukp search` bin takes. */
315
+ export function executeHumanSearch(parsed: ParsedSearch, context: SearchContext): SearchCommandResult {
316
+ return renderSearchCommandResult(parsed.options.json, runSearch(parsed, context));
317
+ }
318
+
319
+ export function renderSearchHelp(): string {
320
+ return renderKitHelp(SEARCH_SPEC);
321
+ }
322
+
323
+ export function renderSearchUsageError(message: string): string {
324
+ return renderKitUsageError(SEARCH_SPEC, message);
325
+ }