@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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- 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
|
+
}
|