@catheadowl/ukp 0.2.3 → 0.2.4

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/src/cli.ts CHANGED
@@ -186,7 +186,7 @@ export function renderHelp(): string {
186
186
  const lines = [
187
187
  "Usage: ukp <command> [options]",
188
188
  "",
189
- "Start here: 'ukp guide service' (first setup — covers init and register) or 'ukp list' (see registered endpoints).",
189
+ "Start here: 'ukp guide service' (first setup - covers init and register) or 'ukp list' (see registered endpoints).",
190
190
  "",
191
191
  "Commands:",
192
192
  ...COMMAND_GROUPS.flatMap((group) => [
@@ -222,6 +222,51 @@ function writeCommandResult(
222
222
  return result.exitCode;
223
223
  }
224
224
 
225
+ /** Channel philosophy for `ukp list` (ukp_list W2): stdout is the data
226
+ * channel — byte contract, one row per line, the terminal wraps what it
227
+ * must; stderr to an INTERACTIVE terminal is the human channel — progress
228
+ * frames already live there, and the footnote lines (degradation warnings,
229
+ * drift notes) may exceed any terminal width (pins are ~44 chars, urls
230
+ * longer), so the terminal would otherwise break them mid-word or
231
+ * mid-hash. This wraps them at word boundaries (over-long tokens
232
+ * hard-split at the width) — only when the emitter knows the width;
233
+ * piped/CI stderr keeps raw single lines, the machine-parseable form. */
234
+ export function wrapLinesForTerminal(text: string, columns: number): string {
235
+ if (!Number.isSafeInteger(columns) || columns < 8) return text;
236
+ const wrapped: string[] = [];
237
+ for (const line of text.split("\n")) {
238
+ if (line.length <= columns) {
239
+ wrapped.push(line);
240
+ continue;
241
+ }
242
+ let current = "";
243
+ for (const token of line.split(" ")) {
244
+ // A token longer than the width hard-splits at the width (paths and
245
+ // SPKI pins have no spaces to break on); the remainder re-enters the
246
+ // same greedy fill so following words still pack.
247
+ const pieces: string[] = [];
248
+ let rest = token;
249
+ while (rest.length > columns) {
250
+ pieces.push(rest.slice(0, columns));
251
+ rest = rest.slice(columns);
252
+ }
253
+ pieces.push(rest);
254
+ for (const piece of pieces) {
255
+ if (current.length === 0) {
256
+ current = piece;
257
+ } else if (current.length + 1 + piece.length <= columns) {
258
+ current += ` ${piece}`;
259
+ } else {
260
+ wrapped.push(current);
261
+ current = piece;
262
+ }
263
+ }
264
+ }
265
+ wrapped.push(current);
266
+ }
267
+ return wrapped.join("\n");
268
+ }
269
+
225
270
  function executeVersionCommand(args: readonly string[]): CliCommandResult {
226
271
  if (args.length === 0) {
227
272
  return { exitCode: 0, stdout: renderVersion(), stderr: "" };
@@ -369,10 +414,34 @@ export function runCli(
369
414
  }
370
415
 
371
416
  if (command === "list") {
372
- return writeCommandResultMaybeAsync(executeListCommand(args.slice(1), {
417
+ // Streaming delivery (ukp_list W2 / ADR 0026 rule 3): line-by-line
418
+ // through the same stdout writer every other print uses (console.log
419
+ // per line ≡ the single print's trailing-newline form, so piped bytes
420
+ // match the returned-text contract); progress frames only on an
421
+ // interactive stderr, so piped runs stay progress-silent. The stderr
422
+ // footnotes wrap at the terminal width there too — piped stderr keeps
423
+ // the raw single lines (wrapLinesForTerminal's contract).
424
+ const stderrColumns = process.stderr.columns ?? process.stdout.columns;
425
+ const interactivelyWrap = (result: CliCommandResult): CliCommandResult =>
426
+ process.stderr.isTTY === true && typeof stderrColumns === "number" && result.stderr.length > 0
427
+ ? { ...result, stderr: wrapLinesForTerminal(result.stderr, stderrColumns) }
428
+ : result;
429
+ // Conditionality preserved (the W2 sync seam): an all-local listing
430
+ // stays a synchronous value wrapped synchronously; only the remote
431
+ // branch's promise goes through .then.
432
+ const result = executeListCommand(args.slice(1), {
373
433
  currentDirectory,
374
434
  registryPath,
375
- }), stdout, stderr);
435
+ ...(process.stderr.isTTY === true
436
+ ? { emitProgress: (frame: string) => process.stderr.write(frame) }
437
+ : {}),
438
+ emitStdout: (line: string) => stdout(line),
439
+ });
440
+ return writeCommandResultMaybeAsync(
441
+ result instanceof Promise ? result.then(interactivelyWrap) : interactivelyWrap(result),
442
+ stdout,
443
+ stderr,
444
+ );
376
445
  }
377
446
 
378
447
  if (command === "nav") {
@@ -1,6 +1,6 @@
1
1
  UKP propose quickstart
2
2
 
3
- Goal: submit a change proposal to a Service endpoint as an idempotent, adjudicable document — not a file write.
3
+ Goal: submit a change proposal to a Service endpoint as an idempotent, adjudicable document - not a file write.
4
4
 
5
5
  Model:
6
6
  - A proposal is a pending change the Service owner will adjudicate; the submitter proposes, the owner decides.
@@ -3,13 +3,13 @@ UKP Remote quickstart
3
3
  Goal: expose registered Services from one machine and consume them from another with the same endpoint commands: search, read, nav, rg, and propose when declared.
4
4
 
5
5
  Current model:
6
- - The ssh:// path needs nothing running on the host: each invocation wakes a loopback door over the SSH connection (it serves the host Registry fresh and self-reaps when idle). Host prerequisites: the host is ssh reachable and has ukp installed the usual way (npm i -g or bun add -g — the wake finds the standard locations).
6
+ - The ssh:// path needs nothing running on the host: each invocation wakes a loopback door over the SSH connection (it serves the host Registry fresh and self-reaps when idle). Host prerequisites: the host is ssh reachable and has ukp installed the usual way (npm i -g or bun add -g - the wake finds the standard locations).
7
7
  - The https path (LAN, public IP, or behind a reverse proxy) uses a resident door: run ukp serve under your process manager. Arbitrary consumers have no SSH credentials, so the listener itself is the trust boundary (TLS + bearer token).
8
8
  - Prefer host door mode: run ukp serve without --endpoint to expose every local endpoint behind one port.
9
9
  - The door reads the host Registry fresh on each request, so newly registered local endpoints appear without restarting the door.
10
- - Declared endpoint names come from the host Service Manifest or discovery document; your registry binds each one under a local handle — the declared name by default, or --name <handle> when that name is already taken (two hosts both declaring 'notes' become your 'notes' and 'ali-notes'). --endpoint during register stays an expected-name assertion on the declared name.
10
+ - Declared endpoint names come from the host Service Manifest or discovery document; your registry binds each one under a local handle - the declared name by default, or --name <handle> when that name is already taken (two hosts both declaring 'notes' become your 'notes' and 'ali-notes'). --endpoint during register stays an expected-name assertion on the declared name.
11
11
 
12
- Owner path over SSH (zero ceremony — nothing to start on the host):
12
+ Owner path over SSH (zero ceremony - nothing to start on the host):
13
13
  1. Prerequisites (once): the host accepts your SSH key and has ukp installed the usual way
14
14
  ssh <host> ukp --help
15
15
 
@@ -54,9 +54,9 @@ Name and identity:
54
54
  - ssh:// urls take no port: registration rejects one (the woken door listens on a client-chosen loopback port; use an ssh config Host alias for a non-standard sshd port).
55
55
 
56
56
  Operational note:
57
- - ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself (on Windows, enabling the built-in OpenSSH Server feature once is the low-burden path). SSH lockdown (login-shell restriction, why a forced authorized_keys command= does not compose) and nvm-style install layouts are deployment-handbook material.
58
- - https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) — prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door; on Windows, ukp serve --print-task prints the resident-door artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) for you to review and apply yourself — the token stays yours to paste, never printed.
59
- - The deployment handbook is where those recipes live: it ships with this package (docs/remote-deployment.md) and online (https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — Windows resident door (certificate, start script, Task Scheduler), systemd socket-activation units, Caddy, Tailscale, hardening notes.
57
+ - ssh:// hosts: nothing to manage - ukp wakes and reaps doors itself (on Windows, enabling the built-in OpenSSH Server feature once is the low-burden path). SSH lockdown (login-shell restriction, why a forced authorized_keys command= does not compose) and nvm-style install layouts are deployment-handbook material.
58
+ - https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) - prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door; on Windows, ukp serve --print-task prints the resident-door artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) for you to review and apply yourself - the token stays yours to paste, never printed.
59
+ - The deployment handbook is where those recipes live: it ships with this package (docs/remote-deployment.md) and online (https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) - Windows resident door (certificate, start script, Task Scheduler), systemd socket-activation units, Caddy, Tailscale, hardening notes.
60
60
  - serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
61
61
 
62
62
  Remember:
@@ -25,7 +25,7 @@ Fast path for a simple knowledge folder:
25
25
  By default add registers the collection under the folder's full path as
26
26
  its name; the short name comes from this rename, never from add itself.
27
27
  rename matches the exact current name only, so re-check collection list
28
- before renaming — the short name you had in mind may already belong to an
28
+ before renaming - the short name you had in mind may already belong to an
29
29
  existing collection.
30
30
  4. Update the index (qmd update, qmd status)
31
31
  5. Verify the provider before returning to UKP (qmd search / qmd get)
@@ -36,6 +36,22 @@ export interface InventoryCommandContext {
36
36
  resolveProvider?: ProviderResolver;
37
37
  /** Test injection point for the ssh binary behind ssh:// transports. */
38
38
  sshCommand?: readonly string[];
39
+ /** Streaming seam (ukp_list W2 / ADR 0026 rule 3): when present, the
40
+ * remote-mixed branch delivers stdout line-by-line through it — the header
41
+ * and every local row flush synchronously before any network work starts,
42
+ * remote rows append in registry order as they resolve — and the returned
43
+ * result carries an empty stdout (the output already went out on this
44
+ * channel; writeCommandResult's falsy check skips the final print).
45
+ * Absent, the full text returns in result.stdout unchanged. The all-local
46
+ * path never streams: it stays synchronous and byte-identical either
47
+ * way. */
48
+ emitStdout?: (line: string) => void;
49
+ /** Progress seam (ukp_list W2 / ADR 0026 rule 3): self-contained stderr
50
+ * frames for the remote fetch — the first frame is plain text, updates
51
+ * carry a leading \r, the final frame clears the line. The wiring layer
52
+ * injects it only when stderr is a TTY, so piped runs stay silent and
53
+ * stdout never gains a control character. */
54
+ emitProgress?: (frame: string) => void;
39
55
  }
40
56
 
41
57
  export interface InventoryCommandResult {
@@ -52,13 +68,13 @@ export const REGISTER_SPEC: UkpCommandSpec = {
52
68
  summary: "register a Service endpoint",
53
69
  group: "registry",
54
70
  description:
55
- "Register the current Service folder, a remote ukp-serve endpoint via --url, or — when that url is a host door — every endpoint behind it in one gesture.",
71
+ "Register the current Service folder, a remote ukp-serve endpoint via --url, or - when that url is a host door - every endpoint behind it in one gesture.",
56
72
  usage: "[options]",
57
73
  strictArguments: true,
58
74
  options: [
59
- { flags: "--url <url>", help: "register a remote ukp-serve endpoint (https://host[:port], ssh://host[/endpoint] — ssh takes no port, the woken door picks its own — or loopback http); a host door url (its document declares scope:\"host\") imports every endpoint behind the door — binding urls gain the endpoint-name path (ssh://ali/notes)" },
75
+ { flags: "--url <url>", help: "register a remote ukp-serve endpoint (https://host[:port], ssh://host[/endpoint] - ssh takes no port, the woken door picks its own - or loopback http); a host door url (its document declares scope:\"host\") imports every endpoint behind the door - binding urls gain the endpoint-name path (ssh://ali/notes)" },
60
76
  { flags: "--endpoint <name>", help: "assert the remote-declared name of the endpoint being registered (expected-name assertion; with a host door this imports only that endpoint)" },
61
- { flags: "--name <handle>", help: "remote only: register under this local handle — the registry key and ukp:// authority — instead of the declared name; use it when the declared name is already taken" },
77
+ { flags: "--name <handle>", help: "remote only: register under this local handle - the registry key and ukp:// authority - instead of the declared name; use it when the declared name is already taken" },
62
78
  { flags: "--select <names>", help: "with a host door url: import only the named endpoints (comma-separated); a name not on the door is a usage error" },
63
79
  { flags: "--token <token>", help: "store the bearer token in the binding (plaintext, 0600 registry file); with a host door it is copied into every imported binding; UKP_ENDPOINT_<NAME>_TOKEN overrides it at call time" },
64
80
  ],
@@ -75,7 +91,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
75
91
  " With --url, the declared name and instance identity come from the",
76
92
  " remote discovery document (/.well-known/ukp.json) and are pinned",
77
93
  " TOFU-style as provenance; your registry binds them under a local",
78
- " handle — the declared name by default, or --name <handle> when that",
94
+ " handle - the declared name by default, or --name <handle> when that",
79
95
  " name is already taken. Use --endpoint <name> as an expected-name",
80
96
  " assertion on the declared name; bearer-token services read",
81
97
  " UKP_ENDPOINT_<NAME>_TOKEN (keyed by the handle) at call time.",
@@ -85,7 +101,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
85
101
  " and imports every endpoint behind it (a door url may also name one",
86
102
  " endpoint: ssh://ali/notes). Import is explicit and idempotent:",
87
103
  " re-running refreshes TOFU pins and tokens. Name conflicts are skipped",
88
- " with a visible reason (never silently renamed) — re-register that one",
104
+ " with a visible reason (never silently renamed) - re-register that one",
89
105
  " endpoint with --name to land it under another handle; --select narrows",
90
106
  " the import; door growth shows in 'ukp list' as drift notes until",
91
107
  " imported.",
@@ -126,7 +142,23 @@ const DEFAULT_CAPABILITIES = (Object.keys(FILE_NATIVE_CAPABILITIES) as Array<key
126
142
  .filter((name) => FILE_NATIVE_CAPABILITIES[name].derived)
127
143
  .sort();
128
144
 
129
- function renderLocalListRow(endpoint: { name: string; path: string }): { line: string; warning?: string } {
145
+ /** One list row as structured cells — column geometry is decided once, in
146
+ * renderListOutput, from every row's actual widths (raw tab stops made the
147
+ * columns land on a different multiple-of-8 per row once names, paths and
148
+ * urls of very different widths mixed). */
149
+ interface InventoryListRow {
150
+ /** Endpoint handle; a remote handle that differs from its declared name
151
+ * carries the declaration inline (`name (declares X)`). */
152
+ name: string;
153
+ /** Local folder path or remote binding url. */
154
+ location: string;
155
+ /** Comma-separated declared extras, `-` for none, `(unavailable)` on
156
+ * degraded rows. */
157
+ capabilities: string;
158
+ warning?: string;
159
+ }
160
+
161
+ function renderLocalListRow(endpoint: { name: string; path: string }): InventoryListRow {
130
162
  let service;
131
163
  try {
132
164
  service = loadManifest(endpoint.path);
@@ -135,14 +167,16 @@ function renderLocalListRow(endpoint: { name: string; path: string }): { line: s
135
167
  // when the underlying ManifestError carries a full zod schema dump.
136
168
  const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
137
169
  return {
138
- line: `${endpoint.name}\t${endpoint.path}\t(unavailable)`,
139
- warning: `endpoint '${endpoint.name}' capabilities unavailable: ${headline}`,
170
+ name: endpoint.name,
171
+ location: endpoint.path,
172
+ capabilities: "(unavailable)",
173
+ warning: `endpoint '${endpoint.name}' declared capabilities unavailable: ${headline}`,
140
174
  };
141
175
  }
142
176
  const extras = Object.keys(service.manifest.capabilities)
143
177
  .filter((name) => !(isFileNativeCapability(name) && FILE_NATIVE_CAPABILITIES[name].derived))
144
178
  .sort();
145
- return { line: `${endpoint.name}\t${endpoint.path}\t${extras.length > 0 ? extras.join(",") : "-"}` };
179
+ return { name: endpoint.name, location: endpoint.path, capabilities: extras.length > 0 ? extras.join(",") : "-" };
146
180
  }
147
181
 
148
182
  export function executeRegisterCommand(
@@ -351,6 +385,7 @@ async function executeRegisterRemote(
351
385
  const wellKnown = await fetchRegistrationDocument(transport.base, {
352
386
  ...(token !== undefined ? { token } : {}),
353
387
  ...(pinSelfSigned && tlsProbe !== undefined ? { tlsAnchor: { ca: tlsProbe.certPem } } : {}),
388
+ displayUrl: parts.origin,
354
389
  });
355
390
 
356
391
  if (wellKnown.kind === "door") {
@@ -520,22 +555,97 @@ export function executeListCommand(
520
555
  // byte-identical and synchronous. One transport pool serves the whole
521
556
  // invocation (O-5): same-origin door bindings — and their drift check —
522
557
  // share a single ssh tunnel.
558
+ //
559
+ // Fan-out + streaming (ukp_list W1/W2 / ADR 0026 rules 1 and 3): local
560
+ // rows flush first — they are registry data in memory, so first output
561
+ // needs no network at all — then every remote fetch and the drift check
562
+ // start together (wall clock = max over origins, not the sum;
563
+ // same-origin acquires converge on the pool's single open promise), and
564
+ // remote rows append in registry order as they resolve, never rewriting
565
+ // an already-emitted line. Row order is W2's one agent-visible change:
566
+ // locals first, then remotes, each group in registry (name-sorted)
567
+ // order.
523
568
  return (async () => {
524
569
  const pool = createTransportPool({
525
570
  registryPath: context.registryPath,
526
571
  ...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
527
572
  });
528
573
  try {
529
- const rendered: Array<{ line: string; warning?: string }> = [];
530
- for (const endpoint of endpoints) {
531
- if (!isRemoteBinding(endpoint)) {
532
- rendered.push(renderLocalListRow({ name: endpoint.name, path: endpoint.path! }));
533
- continue;
534
- }
535
- rendered.push(await renderRemoteListRow(endpoint, pool));
574
+ const localRows = endpoints
575
+ .filter((endpoint) => !isRemoteBinding(endpoint))
576
+ .map((endpoint) => renderLocalListRow({ name: endpoint.name, path: endpoint.path! }));
577
+ // Column geometry is decided once, before the first flush, from
578
+ // every row's registry-known leading cells (ADR 0026 rule 4): only
579
+ // the last cell depends on the network, so streaming never shifts a
580
+ // column width.
581
+ const geometry = listRowGeometry([
582
+ ...localRows,
583
+ ...remotes.map((endpoint) => ({ name: remoteRowName(endpoint), location: endpoint.url! })),
584
+ ]);
585
+ const lines: string[] = [];
586
+ // The progress line and the streamed rows share one terminal: a
587
+ // frame never ends with \n, so the cursor parks at its end — the
588
+ // next row would print BESIDE the frame and the following \r would
589
+ // overwrite the row's head (Windows Terminal dogfood finding). The
590
+ // interlock: every row flush clears the frame first, owns its line,
591
+ // and lets the next drawProgress redraw on the fresh bottom row.
592
+ // With no progress channel (piped runs) the clear is a no-op and
593
+ // the stream is untouched.
594
+ const progress = context.emitProgress;
595
+ let progressWidth = 0;
596
+ const drawProgress = (frame: string) => {
597
+ if (progress === undefined) return;
598
+ progress(frame);
599
+ progressWidth = frame.length;
600
+ };
601
+ const clearProgress = () => {
602
+ if (progress === undefined || progressWidth === 0) return;
603
+ progress(`\r${" ".repeat(progressWidth)}\r`);
604
+ progressWidth = 0;
605
+ };
606
+ const flush = (line: string) => {
607
+ clearProgress();
608
+ lines.push(line);
609
+ context.emitStdout?.(line);
610
+ };
611
+
612
+ flush(listHeaderLine());
613
+ for (const row of localRows) flush(renderListLine(row, geometry));
614
+
615
+ // Everything network-facing starts only after the local flush — and
616
+ // the progress line goes out first, so the user never watches a
617
+ // spinner-less wait. Frames must stay ASCII/narrow and each update
618
+ // at least as wide as its predecessor: the clear blanks
619
+ // progressWidth columns, so a wider glyph than code units (or a
620
+ // shorter update) would leave stale tail cells on a real terminal.
621
+ drawProgress(`fetching ${remotes.length} remote endpoint(s)...`);
622
+ const remoteRows = remotes.map((endpoint) => renderRemoteListRow(endpoint, pool));
623
+ const drift = collectDoorDriftNotes(endpoints, pool);
624
+
625
+ const warnings = localRows.flatMap((row) => (row.warning !== undefined ? [row.warning] : []));
626
+ let done = 0;
627
+ // Head-of-line emission: the fetches already run concurrently; rows
628
+ // print in registry order as their predecessors resolve. Each flush
629
+ // cleared the frame; the update redraws it on the fresh bottom row.
630
+ for (const row of remoteRows) {
631
+ const resolved = await row;
632
+ flush(renderListLine(resolved, geometry));
633
+ if (resolved.warning !== undefined) warnings.push(resolved.warning);
634
+ done += 1;
635
+ if (done < remotes.length) drawProgress(`\rfetching remote endpoint(s): ${done}/${remotes.length}`);
536
636
  }
537
- const drift = await collectDoorDriftNotes(endpoints, pool);
538
- return renderListOutput(rendered, [...collectSameInstanceNotes(endpoints), ...drift]);
637
+ // Cleared before the drift await (the last row's flush already
638
+ // blanked the frame): every row is on screen, so the table reads
639
+ // complete while the stderr footnotes gather — an https door's
640
+ // drift fetch may be the slowest leg and a spinner under a finished
641
+ // table would be noise.
642
+ clearProgress();
643
+ const stderrLines = [...warnings, ...collectSameInstanceNotes(endpoints), ...(await drift)];
644
+ return {
645
+ exitCode: 0,
646
+ stdout: context.emitStdout === undefined ? lines.join("\n") : "",
647
+ stderr: stderrLines.length > 0 ? `${stderrLines.join("\n")}\n` : "",
648
+ };
539
649
  } finally {
540
650
  pool.close();
541
651
  }
@@ -552,14 +662,11 @@ export function executeListCommand(
552
662
  }
553
663
 
554
664
  function renderListOutput(
555
- rows: ReadonlyArray<{ line: string; warning?: string }>,
665
+ rows: ReadonlyArray<InventoryListRow>,
556
666
  extraNotes: readonly string[] = [],
557
667
  ): InventoryCommandResult {
558
668
  const warnings = [...rows.flatMap((row) => (row.warning !== undefined ? [row.warning] : [])), ...extraNotes];
559
- const stdout = [
560
- `capabilities on every endpoint: ${DEFAULT_CAPABILITIES.join(", ")} (derived file-native); additional declared capabilities per endpoint:`,
561
- ...rows.map((row) => row.line),
562
- ].join("\n");
669
+ const stdout = [listHeaderLine(), ...rows.map((row) => renderListLine(row, listRowGeometry(rows)))].join("\n");
563
670
  return {
564
671
  exitCode: 0,
565
672
  stdout,
@@ -567,20 +674,57 @@ function renderListOutput(
567
674
  };
568
675
  }
569
676
 
677
+ function listHeaderLine(): string {
678
+ // The parenthetical is plain-language by probe verdict (20260921-list-wording):
679
+ // blank readers parsed "(built-in, from the folder itself)" directly, while
680
+ // "(derived file-native)" read as undefined jargon.
681
+ return `capabilities on every endpoint: ${DEFAULT_CAPABILITIES.join(", ")} (built-in, from the folder itself); additional declared capabilities per endpoint:`;
682
+ }
683
+
684
+ /** Space-padded columns: each cell pads to the widest same-column cell plus
685
+ * a two-space gutter (the guide topic list's idiom), last column unpadded so
686
+ * no line carries trailing whitespace. Tab-separated rows rendered ragged
687
+ * whenever name/location widths straddled the terminal's tab stops. The
688
+ * padded columns are registry-known strings (ADR 0026 rule 4): only the
689
+ * last, unpadded cell depends on the remote fetch — so geometry can be
690
+ * computed before the first flush and streaming never shifts a width. */
691
+ interface ListRowGeometry {
692
+ nameWidth: number;
693
+ locationWidth: number;
694
+ }
695
+
696
+ function listRowGeometry(rows: ReadonlyArray<Pick<InventoryListRow, "name" | "location">>): ListRowGeometry {
697
+ return {
698
+ nameWidth: Math.max(...rows.map((row) => row.name.length)),
699
+ locationWidth: Math.max(...rows.map((row) => row.location.length)),
700
+ };
701
+ }
702
+
703
+ function renderListLine(row: InventoryListRow, geometry: ListRowGeometry): string {
704
+ return `${row.name.padEnd(geometry.nameWidth)} ${row.location.padEnd(geometry.locationWidth)} ${row.capabilities}`;
705
+ }
706
+
707
+ /** The name cell of a remote row — registry-known without any network, so
708
+ * column geometry and the streamed local flush can lay out before any
709
+ * fetch resolves. A handle that differs from the declared name
710
+ * (ADR-REM-007) carries the declaration inline for audit. */
711
+ function remoteRowName(endpoint: RegistryBinding): string {
712
+ return endpoint.declared_name !== undefined && endpoint.declared_name !== endpoint.name
713
+ ? `${endpoint.name} (declares ${endpoint.declared_name})`
714
+ : endpoint.name;
715
+ }
716
+
570
717
  /** Remote row: capabilities from the discovery document; unreachable
571
718
  * endpoints degrade to `(unavailable)` + one stderr warning — the same
572
719
  * inventory semantics as an unreadable local manifest. Door-form bindings
573
720
  * (`ssh://ali/notes`) fetch their per-endpoint document through the shared
574
- * pool's tunnel. A handle that differs from the declared name (ADR-REM-007)
575
- * carries the declaration inline for audit. */
721
+ * pool's tunnel. */
576
722
  async function renderRemoteListRow(
577
723
  endpoint: RegistryBinding,
578
724
  pool: RemoteTransportPool,
579
- ): Promise<{ line: string; warning?: string }> {
725
+ ): Promise<InventoryListRow> {
580
726
  const url = endpoint.url!;
581
- const declaredNote = endpoint.declared_name !== undefined && endpoint.declared_name !== endpoint.name
582
- ? ` (declares ${endpoint.declared_name})`
583
- : "";
727
+ const name = remoteRowName(endpoint);
584
728
  try {
585
729
  const transport = await pool.acquire(endpoint);
586
730
  const discovery = await fetchDiscoveryDocument(endpoint, transport, resolveRemoteToken(endpoint));
@@ -589,12 +733,14 @@ async function renderRemoteListRow(
589
733
  .map(([name]) => name)
590
734
  .sort();
591
735
  const warnings = discovery.warnings.length > 0 ? { warning: discovery.warnings.join("; ") } : {};
592
- return { line: `${endpoint.name}${declaredNote}\t${url}\t${extras.length > 0 ? extras.join(",") : "-"}`, ...warnings };
736
+ return { name, location: url, capabilities: extras.length > 0 ? extras.join(",") : "-", ...warnings };
593
737
  } catch (error) {
594
738
  const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
595
739
  return {
596
- line: `${endpoint.name}${declaredNote}\t${url}\t(unavailable)`,
597
- warning: `endpoint '${endpoint.name}' capabilities unavailable: ${headline}`,
740
+ name,
741
+ location: url,
742
+ capabilities: "(unavailable)",
743
+ warning: `endpoint '${endpoint.name}' declared capabilities unavailable: ${headline}`,
598
744
  };
599
745
  }
600
746
  }
@@ -616,7 +762,7 @@ function collectSameInstanceNotes(endpoints: readonly RegistryBinding[]): string
616
762
  for (const [uid, handles] of byUid) {
617
763
  if (handles.length > 1) {
618
764
  notes.push(
619
- `note: ${[...handles].sort().map((handle) => `'${handle}'`).join(" and ")} pin the same instance_uid ${uid} — one service under two handles`,
765
+ `note: ${[...handles].sort().map((handle) => `'${handle}'`).join(" and ")} pin the same instance_uid ${uid} - one service under two handles`,
620
766
  );
621
767
  }
622
768
  }
@@ -661,7 +807,7 @@ async function collectDoorDriftNotes(
661
807
  url: origin,
662
808
  ...(anchor.tls_cert !== undefined ? { tls_cert: anchor.tls_cert, tls_pin: anchor.tls_pin } : {}),
663
809
  });
664
- const door = await fetchDoorDocument(transport.base, {});
810
+ const door = await fetchDoorDocument(transport.base, { displayUrl: transport.displayUrl });
665
811
  const unimported = door.doc.endpoints
666
812
  .map((endpoint) => endpoint.name)
667
813
  .filter((name) => !group.imported.has(name));
@@ -674,12 +820,12 @@ async function collectDoorDriftNotes(
674
820
  const taken = unimported.filter((name) => endpoints.some((binding) => binding.name === name));
675
821
  if (free.length > 0) {
676
822
  notes.push(
677
- `door ${origin}: ${free.length} unimported endpoint(s): ${free.join(", ")} — run 'ukp register --url ${origin}' to import`,
823
+ `door ${origin}: ${free.length} unimported endpoint(s): ${free.join(", ")} - run 'ukp register --url ${origin}' to import`,
678
824
  );
679
825
  }
680
826
  if (taken.length > 0) {
681
827
  notes.push(
682
- `door ${origin}: name(s) taken: ${taken.join(", ")} — import under another handle: 'ukp register --url ${origin}/<name> --name <handle>'`,
828
+ `door ${origin}: name(s) taken: ${taken.join(", ")} - import under another handle: 'ukp register --url ${origin}/<name> --name <handle>'`,
683
829
  );
684
830
  }
685
831
  } catch {
@@ -73,7 +73,7 @@ export const PROPOSE_SPEC: UkpCommandSpec = {
73
73
  options: [
74
74
  {
75
75
  flags: "--id <slug>",
76
- help: "revision key: resubmitting the same id updates the same proposal (revision +1); 1-63 lowercase ASCII slug (enforced for explicit ids and the default alike); defaults to the --file basename, which then becomes the proposal's persistent id — renaming the file creates a new proposal",
76
+ help: "revision key: resubmitting the same id updates the same proposal (revision +1); 1-63 lowercase ASCII slug (enforced for explicit ids and the default alike); defaults to the --file basename, which then becomes the proposal's persistent id - renaming the file creates a new proposal",
77
77
  },
78
78
  { flags: "--file <path>", help: "read the proposal content from a file (required)" },
79
79
  { flags: "--json", help: "emit the structured response envelope" },
@@ -96,18 +96,18 @@ export const READ_SPEC: UkpCommandSpec = {
96
96
  },
97
97
  {
98
98
  flags: "--show-pin",
99
- help: "emit the ukp-pin for the read resource on stderr (<!-- ukp-pin: sha256-… -->, whole-file LF-normalized sha256; embed next to a ukp:// reference to power rename recovery). Local reads allow a line window — the pin still covers the whole file; remote reads need the whole file, so drop the window there",
99
+ help: "emit the ukp-pin for the read resource on stderr (<!-- ukp-pin: sha256-... -->, whole-file LF-normalized sha256; embed next to a ukp:// reference to power rename recovery). Local reads allow a line window - the pin still covers the whole file; remote reads need the whole file, so drop the window there",
100
100
  },
101
101
  { flags: "--format <mode>", help: "output mode: 'json' emits a structured failure envelope (body still goes to stdout); default is human output" },
102
102
  ],
103
103
  helpSuffix: [
104
104
  "",
105
105
  "Reference forms:",
106
- " endpoint-local path — requires --endpoint <name>",
107
- " ukp://<endpoint>/<rel-path>[#L<line>] — carries its own endpoint",
108
- " docid[:line] / qmd://<reference> — provider-owned references;",
106
+ " endpoint-local path - requires --endpoint <name>",
107
+ " ukp://<endpoint>/<rel-path>[#L<line>] - carries its own endpoint",
108
+ " docid[:line] / qmd://<reference> - provider-owned references;",
109
109
  " require --endpoint <name> and are resolved by the Service's provider",
110
- " absolute filesystem path — the owning endpoint is matched from the",
110
+ " absolute filesystem path - the owning endpoint is matched from the",
111
111
  " Host Registry (mapping echoed on stderr)",
112
112
  "",
113
113
  ].join("\n"),
@@ -481,7 +481,7 @@ async function executeRemoteRead(
481
481
  exitCode: 2,
482
482
  stdout: "",
483
483
  stderr: renderReadUsageError(
484
- "--show-pin needs whole-file content; drop the line window (--lines / #L) — the pin always covers the whole file",
484
+ "--show-pin needs whole-file content; drop the line window (--lines / #L) - the pin always covers the whole file",
485
485
  ),
486
486
  };
487
487
  }