@catheadowl/ukp 0.2.3 → 0.2.5

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.
@@ -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
  }
@@ -3,6 +3,7 @@ import {
3
3
  projectRgEnvelope,
4
4
  renderRgHuman,
5
5
  RG_DEFAULT_LIMIT,
6
+ RG_FILES_DEFAULT_LIMIT,
6
7
  RG_MAX_LIMIT,
7
8
  RgPlanningError,
8
9
  RgUsageError,
@@ -11,6 +12,7 @@ import {
11
12
  type RgAggregateStatus,
12
13
  type RgContext,
13
14
  type RgEndpointOutcome,
15
+ type RgMode,
14
16
  type RgResult,
15
17
  } from "../capabilities/rg.ts";
16
18
  import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
@@ -50,17 +52,19 @@ export const RG_SPEC: UkpCommandSpec = {
50
52
  summary: "grep raw endpoint files with ripgrep (no index or declaration needed; results as ukp:// references)",
51
53
  group: "endpoint",
52
54
  description:
53
- "Run base lexical search (ripgrep) across one or more Service endpoints. "
55
+ "Run base lexical search (ripgrep) across one or more Service endpoints, "
56
+ + "or enumerate their files with --files. "
54
57
  + "Available on every registered endpoint by default; results are shaped into ukp:// references that 'ukp read' consumes directly. "
55
58
  + "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" }],
59
+ usage: "[--endpoint <name> ... | -g] (<pattern> | --files) [--limit <1-1000>] [--count] [--glob <glob> ...] [--type <type>] [-i] [-- <rg flags>]",
60
+ arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell (omit with --files)" }],
58
61
  options: [
59
62
  { flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
60
63
  { 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})` },
64
+ { flags: "--limit <1-1000>", help: `maximum results per run (default: ${RG_DEFAULT_LIMIT}; ${RG_FILES_DEFAULT_LIMIT} with --files)` },
62
65
  { 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\")" },
66
+ { flags: "--files", help: "files mode: enumerate files by tree walk with no pattern (empty and binary files included; combine with --glob/--type)" },
67
+ { flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\"); repeat to combine - a later glob may negate with !", multi: true },
64
68
  { flags: "--type <type>", help: "file type filter passed to rg (e.g. md, py)" },
65
69
  { flags: "-i", help: "case-insensitive search" },
66
70
  { flags: "--json", help: "emit the structured response envelope" },
@@ -69,7 +73,8 @@ export const RG_SPEC: UkpCommandSpec = {
69
73
  "",
70
74
  "Passthrough:",
71
75
  " After '--', rg native flags are passed through on an allowlist",
72
- " (-A/-B/-C/-m/--glob/--type/--max-filesize and common boolean flags).",
76
+ " (-A/-B/-C/-m/--glob/--iglob/--type/--max-filesize and common boolean",
77
+ " flags).",
73
78
  " Path operands and output-changing flags (--json, -r, --pre, --config)",
74
79
  " are rejected: the endpoint selector owns scope, UKP owns the output.",
75
80
  "",
@@ -84,6 +89,12 @@ export const RG_SPEC: UkpCommandSpec = {
84
89
  " 'ukp search' covers indexed search where the Service declares a search",
85
90
  " capability provider.",
86
91
  "",
92
+ "Visibility:",
93
+ " Every rg mode skips hidden files (dot-prefixed, including the .ukp/",
94
+ " wiring) by default; pass -- --hidden after '--' to include them",
95
+ " (-uu additionally lifts .gitignore rules). --files lists every",
96
+ " non-hidden file, including empty and binary ones.",
97
+ "",
87
98
  ].join("\n"),
88
99
  };
89
100
 
@@ -92,7 +103,8 @@ interface RgCommandOptions extends Record<string, unknown> {
92
103
  g?: boolean;
93
104
  limit?: string;
94
105
  count?: boolean;
95
- glob?: string;
106
+ files?: boolean;
107
+ glob?: string[];
96
108
  type?: string;
97
109
  i?: boolean;
98
110
  json?: boolean;
@@ -102,10 +114,13 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
102
114
  const parsed = parseKitArgs<RgCommandOptions>(RG_SPEC, args);
103
115
  const explicit: string[] = [];
104
116
  const warnings: string[] = [];
117
+ const files = parsed.options.files === true;
105
118
 
106
119
  // Command-side semantic checks, in the pre-kit order: limit validation,
107
- // duplicate warnings, excess positional, emptiness, conflict.
108
- let limit = RG_DEFAULT_LIMIT;
120
+ // duplicate warnings, excess positional, mode conflicts, pattern rule,
121
+ // scope conflict. Files mode (ADR-RG-005) relaxes the pattern requirement
122
+ // and rejects combinations that would silently no-op.
123
+ let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
109
124
  if (parsed.options.limit !== undefined) {
110
125
  if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
111
126
  limit = Number(parsed.options.limit);
@@ -125,20 +140,34 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
125
140
  `unexpected argument '${unexpected}'; rg accepts exactly one pattern. Use '--endpoint <name>' to select an endpoint; '-g' takes no value.`,
126
141
  );
127
142
  }
128
- if (pattern === undefined || pattern.length === 0) throw new KitUsageError("rg pattern must be non-empty");
143
+ if (files && parsed.options.count === true) {
144
+ throw new KitUsageError("--files and --count cannot be used together (pick one output mode)");
145
+ }
146
+ if (files && parsed.options.i === true) {
147
+ throw new KitUsageError("--files cannot be combined with -i: there is no pattern to match; pass '-- --iglob <glob>' for a case-insensitive glob filter");
148
+ }
149
+ if (files) {
150
+ if (pattern !== undefined) {
151
+ throw new KitUsageError(`unexpected argument '${pattern}'; --files takes no pattern - use --glob to filter by name`);
152
+ }
153
+ } else if (pattern === undefined || pattern.length === 0) {
154
+ throw new KitUsageError("rg pattern must be non-empty (or pass --files to enumerate files without a pattern)");
155
+ }
129
156
  if (parsed.options.g && explicit.length > 0) {
130
157
  throw new KitUsageError("--endpoint and -g cannot be used together");
131
158
  }
132
159
 
160
+ const globs = parsed.options.glob ?? [];
133
161
  return {
134
- request: { query: pattern, limit },
162
+ request: { query: files ? "" : pattern!, limit },
135
163
  options: {
136
164
  explicitEndpoints: explicit.length > 0 ? explicit : undefined,
137
165
  global: parsed.options.g ?? false,
138
- ...(parsed.options.glob === undefined ? {} : { glob: parsed.options.glob }),
166
+ ...(globs.length > 0 ? { globs } : {}),
139
167
  ...(parsed.options.type === undefined ? {} : { type: parsed.options.type }),
140
168
  ...(parsed.options.i === true ? { ignoreCase: true } : {}),
141
169
  ...(parsed.options.count === true ? { count: true } : {}),
170
+ ...(files ? { files: true } : {}),
142
171
  ...(parsed.options.json === true ? { json: true } : {}),
143
172
  passthrough: [],
144
173
  },
@@ -238,7 +267,7 @@ function renderRgCommandResult(parsed: ParsedRg, result: RgResult): RgCommandRes
238
267
  if (parsed.options.json === true) {
239
268
  return {
240
269
  exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
241
- stdout: `${JSON.stringify(projectRgEnvelope(result, parsed.options.count === true), null, 2)}\n`,
270
+ stdout: `${JSON.stringify(projectRgEnvelope(result), null, 2)}\n`,
242
271
  stderr: view.diagnostics,
243
272
  };
244
273
  }
@@ -305,10 +334,11 @@ async function executeMixedRg(
305
334
  const execution = await remoteRg(binding, transport, token, {
306
335
  query: parsed.request.query,
307
336
  limit: parsed.request.limit,
308
- ...(parsed.options.glob !== undefined ? { glob: parsed.options.glob } : {}),
337
+ ...(parsed.options.globs !== undefined ? { globs: parsed.options.globs } : {}),
309
338
  ...(parsed.options.type !== undefined ? { type: parsed.options.type } : {}),
310
339
  ...(parsed.options.ignoreCase === true ? { ignoreCase: true } : {}),
311
340
  ...(parsed.options.count === true ? { count: true } : {}),
341
+ ...(parsed.options.files === true ? { files: true } : {}),
312
342
  passthrough: parsed.options.passthrough,
313
343
  });
314
344
  remoteOutcomes.set(binding.name, execution.outcome);
@@ -343,6 +373,7 @@ async function executeMixedRg(
343
373
  return renderRgCommandResult(parsed, {
344
374
  query: parsed.request.query,
345
375
  limit: parsed.request.limit,
376
+ mode: (parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match") as RgMode,
346
377
  endpoints,
347
378
  warnings,
348
379
  aggregate,