@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,558 @@
1
+ import {
2
+ runRead,
3
+ renderReadHuman,
4
+ projectReadEnvelope,
5
+ ReadUsageError,
6
+ isAbsoluteFilesystemReference,
7
+ type ReadContext,
8
+ type ReadErrorClass,
9
+ type ReadOutcome,
10
+ type ReadRecoveryMeta,
11
+ type ReadRequest,
12
+ type LineRange,
13
+ } from "../capabilities/read.ts";
14
+ import { isValidPin } from "../capabilities/rename-recovery.ts";
15
+ import {
16
+ fetchDiscoveryDocument,
17
+ openRemoteTransport,
18
+ remoteRead,
19
+ remoteTokenFor,
20
+ resolveRemoteToken,
21
+ type RemoteTransportHandle,
22
+ } from "../capabilities/remote-client.ts";
23
+ import { isRemoteBinding, readRegistry, type RegistryBinding } from "../registry.ts";
24
+ import { ScopeError } from "../scope.ts";
25
+ import { KitUsageError, parseKitArgs, renderKitHelp, renderKitUsageError, type UkpCommandSpec } from "./kit.ts";
26
+ import { HelpRequestError, isHelpRequest } from "./flags.ts";
27
+
28
+ /** CLI-owned command result shape (ADR 0021). `recovery` exposes the
29
+ * structured rename-recovery metadata alongside the channels so programmatic
30
+ * consumers of the adapter need not re-parse stderr. */
31
+ export interface ReadCommandResult {
32
+ exitCode: number;
33
+ stdout: string;
34
+ stderr: string;
35
+ recovery?: ReadRecoveryMeta;
36
+ }
37
+
38
+ /** Error-class → exit-code mapping (ADR 0021): the capability classifies,
39
+ * the adapter decides process semantics. Inline tier-validation usage
40
+ * failures keep the bare exit-2 rendering; parse-layer usage errors render
41
+ * the usage block via the catch path. */
42
+ const READ_EXIT_BY_ERROR_CLASS: Record<ReadErrorClass, number> = {
43
+ "no-endpoint": 1,
44
+ "endpoint-name-mismatch": 1,
45
+ "provider-unavailable": 1,
46
+ "provider-timeout": 1,
47
+ "provider-cancelled": 130,
48
+ "provider-incompatible": 1,
49
+ "provider-no-content": 1,
50
+ "resource-missing": 1,
51
+ "qmd-route-unavailable": 1,
52
+ "resource-disappeared": 1,
53
+ "resource-is-directory": 1,
54
+ "start-beyond-eof": 1,
55
+ "ambiguous-match": 1,
56
+ "no-exact-match": 1,
57
+ "resource-not-found": 1,
58
+ "usage-error": 2,
59
+ };
60
+
61
+ /** Single-source command spec (ADR 0024). Options-only form (like search/
62
+ * rg): `--endpoint` is required only for the endpoint-scoped tier — the
63
+ * URI and absolute-filesystem tiers carry their own endpoint — so the
64
+ * singleEndpoint family's unconditional requirement does not apply; tier
65
+ * dispatch and its precedence stay command-side. */
66
+ export const READ_SPEC: UkpCommandSpec = {
67
+ name: "read",
68
+ summary: "read an endpoint-scoped resource (exact path, ukp:// URI, or docid handoff key)",
69
+ group: "endpoint",
70
+ description:
71
+ "Read an endpoint-scoped resource reference from one Service endpoint (endpoint names come from 'ukp list'). "
72
+ + "A ukp:// URI is addressed exactly (no fuzzy resolution); #L<line> maps to the line window start. "
73
+ + "A document-relative reference resolves against --from <route>; an absolute filesystem path maps to the endpoint whose Service folder contains it (mapping echoed on stderr). "
74
+ + "On a slot miss the layered rename recovery runs (git history, then search re-anchor); a recovered read echoes the move on stderr. "
75
+ + "On success stdout carries only the resource body; all diagnostics go to stderr.",
76
+ usage: "--endpoint <name> <reference> [--lines <start[:count]>] | ukp://<endpoint>/<rel-path>[#L<line>]",
77
+ arguments: [
78
+ { name: "reference", help: "endpoint-local path, ukp:// URI, docid[:line] handoff key, or qmd:// provider reference" },
79
+ ],
80
+ options: [
81
+ { flags: "-c, --endpoint <name>", help: "select the endpoint that owns the resource" },
82
+ {
83
+ flags: "-g",
84
+ help: "not supported; read is explicitly endpoint-scoped (current scope: 'ukp inspect'; endpoints: 'ukp list')",
85
+ teachingFlag: true,
86
+ },
87
+ { flags: "--lines <start[:count]>", help: "read a 1-based text line window (count omitted: to end of file)" },
88
+ {
89
+ flags: "--from <route>",
90
+ help: "resolve a document-relative reference (../x.md, bare filename) against this source document's endpoint-relative route",
91
+ },
92
+ { flags: "--pin <sha256-hex>", help: "verify rename recovery against this ukp-pin content hash (sha256-<64 hex>)" },
93
+ { flags: "--format <mode>", help: "output mode: 'json' emits a structured failure envelope (body still goes to stdout); default is human output" },
94
+ ],
95
+ helpSuffix: [
96
+ "",
97
+ "Reference forms:",
98
+ " endpoint-local path — requires --endpoint <name>",
99
+ " ukp://<endpoint>/<rel-path>[#L<line>] — carries its own endpoint",
100
+ " docid[:line] / qmd://<reference> — provider-owned references;",
101
+ " require --endpoint <name> and are resolved by the Service's provider",
102
+ " absolute filesystem path — the owning endpoint is matched from the",
103
+ " Host Registry (mapping echoed on stderr)",
104
+ "",
105
+ ].join("\n"),
106
+ };
107
+
108
+ interface ReadCommandOptions extends Record<string, unknown> {
109
+ endpoint?: string;
110
+ g?: boolean;
111
+ lines?: string;
112
+ from?: string;
113
+ pin?: string;
114
+ format?: string;
115
+ }
116
+
117
+ function parseLineRange(value: string): LineRange {
118
+ const match = /^([0-9]+)(?::([0-9]+))?$/.exec(value);
119
+ if (!match) throw new KitUsageError("--lines must use <start[:count]> with decimal integers");
120
+ const start = Number(match[1]);
121
+ const count = match[2] === undefined ? undefined : Number(match[2]);
122
+ if (!Number.isSafeInteger(start) || start < 1) {
123
+ throw new KitUsageError("--lines start must be a positive integer");
124
+ }
125
+ if (count !== undefined && (!Number.isSafeInteger(count) || count < 1)) {
126
+ throw new KitUsageError("--lines count must be a positive integer");
127
+ }
128
+ return count === undefined ? { start } : { start, count };
129
+ }
130
+
131
+ /** ASCII-only case-insensitive `ukp://` scheme match (RFC 3986: scheme is
132
+ * case-insensitive; restricting the classes to ASCII avoids Unicode
133
+ * case-folding surprises like the Kelvin sign). */
134
+ const UKP_URI_PREFIX_RE = /^[uU][kK][pP]:\/\//;
135
+
136
+ /**
137
+ * Percent-decode a `%XX`-escaped string into its UTF-8 form.
138
+ *
139
+ * WHATWG-URL-style lenient decoding applied per component (endpoint /
140
+ * rel-path / fragment): every valid `%XX` triplet contributes one byte to a
141
+ * UTF-8 decode; invalid `%` sequences are kept literally; invalid UTF-8
142
+ * bytes decode as U+FFFD. Decoding happens after the authority/path/fragment
143
+ * split, so `/` and `#` are always literal delimiters; a decoded `%2F`
144
+ * becomes a separator character — unambiguous because no supported
145
+ * filesystem allows `/` inside a name. Raw (unescaped) UTF-8 passes through
146
+ * verbatim (IRI semantics: URI/IRI distinction is deliberately not enforced).
147
+ */
148
+ function percentDecodeUtf8(component: string): string {
149
+ if (!component.includes("%")) return component;
150
+ const decoder = new TextDecoder("utf-8");
151
+ let result = "";
152
+ let bytes: number[] = [];
153
+ let i = 0;
154
+ const flush = () => {
155
+ if (bytes.length > 0) {
156
+ result += decoder.decode(new Uint8Array(bytes));
157
+ bytes = [];
158
+ }
159
+ };
160
+ while (i < component.length) {
161
+ const hex = /^%[0-9a-fA-F]{2}$/.exec(component.slice(i, i + 3));
162
+ if (hex) {
163
+ bytes.push(Number.parseInt(component.slice(i + 1, i + 3), 16));
164
+ i += 3;
165
+ continue;
166
+ }
167
+ flush();
168
+ result += component[i];
169
+ i += 1;
170
+ }
171
+ flush();
172
+ return result;
173
+ }
174
+
175
+ /**
176
+ * Parse a `ukp://<endpoint>/<rel-path>[#fragment]` URI (ADR 0014 target form)
177
+ * into a ReadRequest with exact slot addressing.
178
+ *
179
+ * Encoding stance: UTF-8/IRI semantics — raw UTF-8 is legal
180
+ * as-is; `%XX` triplets are percent-decoded per component (see
181
+ * percentDecodeUtf8). Case stance: exact compare, no case folding and no
182
+ * canonicalization (RFC 8089 precedent) — platform case-sensitivity
183
+ * differences are declared behavior, not normalized. `#L<line>` maps to the
184
+ * line-window start; any other fragment is an opaque navigation hint and is
185
+ * ignored for reading.
186
+ */
187
+ function parseUkpUri(uri: string, flags: { endpoint?: string; lines?: string }): ReadRequest {
188
+ // Precondition: the caller (parseReadArgs) has already matched
189
+ // UKP_URI_PREFIX_RE against this input; the non-null assertion below relies
190
+ // on that guard. New callers must test the prefix before calling.
191
+ if (flags.endpoint !== undefined) {
192
+ throw new KitUsageError("a ukp:// URI carries its own endpoint; do not also pass --endpoint");
193
+ }
194
+ const rest = uri.slice(uri.match(UKP_URI_PREFIX_RE)![0].length);
195
+ const hashIndex = rest.indexOf("#");
196
+ const fragment = hashIndex === -1 ? undefined : percentDecodeUtf8(rest.slice(hashIndex + 1));
197
+ const pathPart = hashIndex === -1 ? rest : rest.slice(0, hashIndex);
198
+ const slashIndex = pathPart.indexOf("/");
199
+ if (slashIndex === -1 && pathPart.includes("\\")) {
200
+ throw new KitUsageError("ukp:// URI uses '/' as the path separator: ukp://<endpoint>/<rel-path>");
201
+ }
202
+ // No slash: the whole remainder is the endpoint with an empty rel-path.
203
+ // Percent-decoding is applied per component after the split: `/`
204
+ // and `#` are always literal delimiters at split time; a decoded `%2F`
205
+ // becomes a separator character downstream, which is unambiguous because
206
+ // no filesystem allows `/` inside a name.
207
+ const endpoint = slashIndex === -1 ? percentDecodeUtf8(pathPart) : percentDecodeUtf8(pathPart.slice(0, slashIndex));
208
+ const relPath = percentDecodeUtf8(slashIndex === -1 ? "" : pathPart.slice(slashIndex + 1));
209
+ if (endpoint.length === 0) {
210
+ throw new KitUsageError("ukp:// URI must name an endpoint: ukp://<endpoint>/<rel-path>");
211
+ }
212
+ if (relPath.length === 0) {
213
+ throw new KitUsageError("ukp:// URI must carry a non-empty endpoint-relative path");
214
+ }
215
+
216
+ let lines: LineRange | undefined;
217
+ const lineMatch = fragment === undefined ? undefined : /^L([0-9]+)$/.exec(fragment);
218
+ if (lineMatch) {
219
+ if (flags.lines !== undefined) {
220
+ throw new KitUsageError("a ukp:// #L<line> fragment already carries a line; do not also pass --lines");
221
+ }
222
+ const start = Number(lineMatch[1]);
223
+ // Same integer discipline as --lines: an unrepresentably large number is a
224
+ // usage error here, not a deferred start-beyond-eof read failure.
225
+ if (!Number.isSafeInteger(start) || start < 1) {
226
+ throw new KitUsageError("ukp:// #L fragment must be a positive line number");
227
+ }
228
+ lines = { start };
229
+ } else if (fragment !== undefined && /^L\d/.test(fragment)) {
230
+ // G4 pin (fail-loud): a digit right after `L` commits to line-window intent,
231
+ // so a malformed window (`#L67:5`, `#L12x`) is a usage error — silently
232
+ // reading the whole document would hide the typo. Heading-style fragments
233
+ // (`#Lifecycle`, `#L-pipeline`) keep opaque semantics: `L` + non-digit was
234
+ // never a line window.
235
+ throw new KitUsageError(
236
+ `ukp:// #L fragment looks like a malformed line window: '${fragment}' (use #L<line> or a plain heading anchor)`,
237
+ );
238
+ }
239
+ // Non-`#L` fragments are opaque navigation hints (ADR 0014 rule 5): ignored
240
+ // for reading; a broken path invalidates the fragment, never the reverse.
241
+
242
+ return {
243
+ endpoint,
244
+ path: relPath,
245
+ addressing: "uri",
246
+ ...(lines !== undefined
247
+ ? { lines }
248
+ : flags.lines !== undefined
249
+ ? { lines: parseLineRange(flags.lines) }
250
+ : {}),
251
+ };
252
+ }
253
+
254
+ export function parseReadArgs(args: readonly string[]): ReadRequest {
255
+ return parseReadWithFormat(args).request;
256
+ }
257
+
258
+ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; format?: "json" } {
259
+ // Kit parse: commander translation + help-intent triage + generated
260
+ // singleton detection (--endpoint/--lines/--from/--pin/--format; the -g
261
+ // teaching flag is excluded). Command-side checks follow in the pre-kit
262
+ // order: pin form, format value, -g rejection, excess positionals, tiers.
263
+ const parsed = parseKitArgs<ReadCommandOptions>(READ_SPEC, args);
264
+ const [path, unexpected] = parsed.positionals;
265
+
266
+ if (parsed.options.pin !== undefined && !isValidPin(parsed.options.pin)) {
267
+ // Q7-narrowed SRI form only: no multi-algorithm negotiation, no upper-case
268
+ // hex tolerance — the pin is machine-written, not hand-typed.
269
+ throw new KitUsageError("--pin must use sha256-<64 lowercase hex> (the ukp-pin form emitted next to ukp:// references)");
270
+ }
271
+ if (parsed.options.format !== undefined && parsed.options.format !== "json") {
272
+ throw new KitUsageError("--format only supports 'json'");
273
+ }
274
+ const pin = parsed.options.pin;
275
+ if (parsed.options.g) throw new KitUsageError("read requires --endpoint <name> and does not support -g");
276
+ // Unexpected positionals are rejected before the missing-flag checks so the
277
+ // error names the real problem (extra argument), not a missing --endpoint.
278
+ if (unexpected !== undefined) {
279
+ throw new KitUsageError(
280
+ `unexpected argument '${unexpected}'; read accepts exactly one reference. Use '--endpoint <name>' to select an endpoint.`,
281
+ );
282
+ }
283
+ const withPin = <T extends object>(request: T): T & { pin?: string } =>
284
+ parsed.options.pin === undefined ? request : { ...request, pin: parsed.options.pin };
285
+ const format = parsed.options.format === "json" ? ("json" as const) : undefined;
286
+
287
+ if (path !== undefined && UKP_URI_PREFIX_RE.test(path)) {
288
+ if (parsed.options.from !== undefined) {
289
+ throw new KitUsageError("a ukp:// URI carries its own endpoint; --from is for document-relative references");
290
+ }
291
+ return { request: withPin(parseUkpUri(path, { endpoint: parsed.options.endpoint, lines: parsed.options.lines })), ...(format ? { format } : {}) };
292
+ }
293
+ // Tolerant tier (ADR-URI-001): an absolute filesystem path carries its own
294
+ // endpoint (Registry-matched), so --endpoint is neither required nor
295
+ // allowed; --from is for document-relative references only.
296
+ if (path !== undefined && isAbsoluteFilesystemReference(path)) {
297
+ if (parsed.options.from !== undefined) {
298
+ throw new KitUsageError("an absolute filesystem path cannot be combined with --from");
299
+ }
300
+ if (parsed.options.endpoint !== undefined) {
301
+ throw new KitUsageError(
302
+ "an absolute filesystem path carries its own endpoint (matched against registered endpoints); do not also pass --endpoint",
303
+ );
304
+ }
305
+ return {
306
+ request: withPin({
307
+ path,
308
+ ...(parsed.options.lines === undefined ? {} : { lines: parseLineRange(parsed.options.lines) }),
309
+ }),
310
+ ...(format ? { format } : {}),
311
+ };
312
+ }
313
+ if (parsed.options.endpoint === undefined || parsed.options.endpoint.length === 0) {
314
+ throw new KitUsageError("read requires --endpoint <name>");
315
+ }
316
+ if (path === undefined || path.length === 0) {
317
+ throw new KitUsageError("read reference must be a non-empty endpoint-scoped reference");
318
+ }
319
+ if (parsed.options.from !== undefined && parsed.options.from.length === 0) {
320
+ throw new KitUsageError("--from must be a non-empty endpoint-relative route");
321
+ }
322
+
323
+ return {
324
+ request: withPin({
325
+ endpoint: parsed.options.endpoint,
326
+ path,
327
+ ...(parsed.options.from === undefined ? {} : { fromRef: parsed.options.from }),
328
+ ...(parsed.options.lines === undefined ? {} : { lines: parseLineRange(parsed.options.lines) }),
329
+ }),
330
+ ...(format ? { format } : {}),
331
+ };
332
+ }
333
+
334
+ /** `--format json` (spec read-rename-recovery): failures emit a structured
335
+ * envelope on stdout (class + message + recovery metadata); success keeps the
336
+ * body on stdout and puts a small envelope on stderr. Human mode is the
337
+ * default and unchanged. The envelope is projected from the structured
338
+ * outcome — the class is data, never regex-extracted from rendered text. */
339
+ function renderJsonOutput(request: ReadRequest, outcome: ReadOutcome): ReadCommandResult {
340
+ const human = renderReadHuman(outcome);
341
+ const envelope = projectReadEnvelope(request, outcome);
342
+ const exitCode = outcome.ok
343
+ ? 0
344
+ : READ_EXIT_BY_ERROR_CLASS[outcome.failure.errorClass];
345
+ const recovery = outcome.ok ? outcome.result.recovery : outcome.failure.recovery;
346
+ if (outcome.ok) {
347
+ return {
348
+ exitCode,
349
+ stdout: human.body,
350
+ stderr: `${JSON.stringify(envelope)}\n${human.diagnostics}`,
351
+ ...(recovery !== undefined ? { recovery } : {}),
352
+ };
353
+ }
354
+ // Failure envelope replaces the human stdout (which is empty on failure);
355
+ // stderr keeps its diagnostics verbatim.
356
+ return {
357
+ exitCode,
358
+ stdout: `${JSON.stringify(envelope, null, 2)}\n`,
359
+ stderr: human.diagnostics,
360
+ ...(recovery !== undefined ? { recovery } : {}),
361
+ };
362
+ }
363
+
364
+ export function executeReadCommand(
365
+ args: readonly string[],
366
+ context: ReadContext,
367
+ ): ReadCommandResult | Promise<ReadCommandResult> {
368
+ if (isHelpRequest(args)) {
369
+ return { exitCode: 0, stdout: renderReadHelp(), stderr: "" };
370
+ }
371
+
372
+ try {
373
+ const { request, format } = parseReadWithFormat(args);
374
+ // Remote branch (ukp_remote W2): endpoint-scoped and ukp:// reads on a
375
+ // remote binding route through the remote transport; the absolute-path
376
+ // tier never carries a remote endpoint. The sync contract for local
377
+ // reads is unchanged (conditional-async seam, same as search).
378
+ if (request.endpoint !== undefined) {
379
+ const binding = readRegistry(context.registryPath).find((entry) => entry.name === request.endpoint);
380
+ if (binding !== undefined && isRemoteBinding(binding)) {
381
+ return executeRemoteRead(request, format, binding, context.registryPath);
382
+ }
383
+ }
384
+ const outcome = runRead(request, context);
385
+ return renderReadOutcome(request, outcome, format, []);
386
+ } catch (error) {
387
+ if (error instanceof HelpRequestError) {
388
+ return { exitCode: 0, stdout: renderReadHelp(), stderr: "" };
389
+ }
390
+ if (error instanceof KitUsageError || error instanceof ReadUsageError) {
391
+ // ReadUsageError: capability-side tier validation classifies as usage
392
+ // too — same rendering, exit 2.
393
+ return { exitCode: 2, stdout: "", stderr: renderReadUsageError(error.message) };
394
+ }
395
+ if (error instanceof ScopeError) {
396
+ return { exitCode: 1, stdout: "", stderr: `ukp read: ${error.message}\n` };
397
+ }
398
+ return {
399
+ exitCode: 1,
400
+ stdout: "",
401
+ stderr: `ukp read: ${error instanceof Error ? error.message : String(error)}\n`,
402
+ };
403
+ }
404
+ }
405
+
406
+ /** Shared render tail for the sync and remote read paths (byte-identical to
407
+ * the pre-remote adapter when `warnings` is empty). */
408
+ function renderReadOutcome(
409
+ request: ReadRequest,
410
+ outcome: ReadOutcome,
411
+ format: "json" | undefined,
412
+ warnings: string[],
413
+ ): ReadCommandResult {
414
+ if (format === "json") {
415
+ const base = renderJsonOutput(request, outcome);
416
+ if (warnings.length === 0) return base;
417
+ return { ...base, stderr: `${base.stderr}${warnings.join("\n")}\n` };
418
+ }
419
+ const human = renderReadHuman(outcome);
420
+ const recovery = outcome.ok ? outcome.result.recovery : outcome.failure.recovery;
421
+ const diagnostics = warnings.length > 0
422
+ ? `${human.diagnostics}${human.diagnostics === "" || human.diagnostics.endsWith("\n") ? "" : "\n"}${warnings.join("\n")}\n`
423
+ : human.diagnostics;
424
+ return {
425
+ exitCode: outcome.ok ? 0 : READ_EXIT_BY_ERROR_CLASS[outcome.failure.errorClass],
426
+ stdout: human.body,
427
+ stderr: diagnostics,
428
+ ...(recovery !== undefined ? { recovery } : {}),
429
+ };
430
+ }
431
+
432
+ function remoteTokenHint(endpointName: string): string {
433
+ return `endpoint '${endpointName}' requires a bearer token; set UKP_ENDPOINT_${endpointName.toUpperCase().replace(/-/g, "_")}_TOKEN`;
434
+ }
435
+
436
+ /** Wire error class → ReadErrorClass. Word-level classes pass through;
437
+ * transport-only verdicts (auth-failure, identity-mismatch, route misses)
438
+ * fold into provider-unavailable with the specifics preserved in the
439
+ * message (ukp_remote W2 mapping decision, workline-recorded). */
440
+ function mapRemoteReadErrorClass(errorClass: string | undefined): ReadErrorClass {
441
+ if (errorClass === "resource-missing") return "resource-missing";
442
+ if (errorClass === "provider-timeout") return "provider-timeout";
443
+ if (errorClass === "usage-error") return "usage-error";
444
+ return "provider-unavailable";
445
+ }
446
+
447
+ async function executeRemoteRead(
448
+ request: ReadRequest,
449
+ format: "json" | undefined,
450
+ binding: RegistryBinding,
451
+ registryPath?: string,
452
+ ): Promise<ReadCommandResult> {
453
+ const token = resolveRemoteToken(binding);
454
+ const warnings: string[] = [];
455
+ let transport: RemoteTransportHandle | undefined;
456
+ try {
457
+ transport = await openRemoteTransport(binding, registryPath === undefined ? {} : { registryPath });
458
+ const discovery = await fetchDiscoveryDocument(binding, transport, token);
459
+ warnings.push(...discovery.warnings);
460
+ if (discovery.bearerRequired && token === undefined) warnings.push(remoteTokenHint(binding.name));
461
+ } catch (error) {
462
+ transport?.close();
463
+ return renderReadOutcome(
464
+ request,
465
+ {
466
+ ok: false,
467
+ failure: {
468
+ errorClass: "provider-unavailable",
469
+ message: error instanceof Error ? error.message : String(error),
470
+ },
471
+ },
472
+ format,
473
+ warnings,
474
+ );
475
+ }
476
+
477
+ if (request.fromRef !== undefined) {
478
+ return renderReadOutcome(
479
+ request,
480
+ {
481
+ ok: false,
482
+ failure: {
483
+ errorClass: "provider-incompatible",
484
+ message: "--from document-relative references are local-only; remote reads take an endpoint-relative ref or a ukp:// URI (ukp_remote W2)",
485
+ },
486
+ },
487
+ format,
488
+ warnings,
489
+ );
490
+ }
491
+
492
+ const params = {
493
+ ref: request.path,
494
+ ...(request.lines !== undefined
495
+ ? { lines: `${request.lines.start}${request.lines.count !== undefined ? `:${request.lines.count}` : ""}` }
496
+ : {}),
497
+ ...(request.pin !== undefined ? { pin: request.pin } : {}),
498
+ };
499
+ let result;
500
+ try {
501
+ result = await remoteRead(transport, token, params);
502
+ } catch (error) {
503
+ return renderReadOutcome(
504
+ request,
505
+ {
506
+ ok: false,
507
+ failure: {
508
+ errorClass: "provider-unavailable",
509
+ message: error instanceof Error ? error.message : String(error),
510
+ },
511
+ },
512
+ format,
513
+ warnings,
514
+ );
515
+ } finally {
516
+ transport.close();
517
+ }
518
+ if (result.ok) {
519
+ return renderReadOutcome(
520
+ request,
521
+ { ok: true, result: { content: result.content, recoveryWarnings: [] } },
522
+ format,
523
+ warnings,
524
+ );
525
+ }
526
+ return renderReadOutcome(
527
+ request,
528
+ {
529
+ ok: false,
530
+ failure: {
531
+ errorClass: mapRemoteReadErrorClass(result.errorClass),
532
+ message: result.errorMessage ?? `remote read failed (status ${result.status})`,
533
+ },
534
+ },
535
+ format,
536
+ warnings,
537
+ );
538
+ }
539
+
540
+ export function renderReadHelp(): string {
541
+ return renderKitHelp(READ_SPEC);
542
+ }
543
+
544
+ /** Escape hatch (ADR 0024 non-goal #5): read's usage error keeps its
545
+ * hand-written four-form block instead of the kit's single usage line —
546
+ * the tier dispatch (endpoint-scoped / --from / absolute path / ukp://) has
547
+ * four legitimate invocations and the multi-form recovery text is the
548
+ * load-bearing part. */
549
+ export function renderReadUsageError(message: string): string {
550
+ return [
551
+ `ukp read: ${message}`,
552
+ "Usage: ukp read --endpoint <name> <reference> [--lines <start[:count]>]",
553
+ " ukp read --endpoint <name> --from <route> <document-relative reference>",
554
+ " ukp read <absolute filesystem path>",
555
+ " ukp read ukp://<endpoint>/<rel-path>[#L<line>]",
556
+ "Run 'ukp read --help' for details.",
557
+ ].join("\n");
558
+ }