@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.
@@ -6,6 +6,7 @@ import { resolveExternalToolCapability, EXTERNAL_PROVIDER } from "../config/exte
6
6
  import { localPathOf, readRegistry } from "../registry.ts";
7
7
  import { resolveScope } from "../scope.ts";
8
8
  import { isInsideRealRoot } from "../path-safety.ts";
9
+ import { toolRelayArgv, TOOL_NOT_FOUND_EXIT } from "../spawn-relay.ts";
9
10
 
10
11
  // rg capability (ADR-RG-001..004): an independent atomic
11
12
  // capability parallel to `search` — base lexical search over the endpoint's
@@ -15,6 +16,8 @@ import { isInsideRealRoot } from "../path-safety.ts";
15
16
  // Structured outcome per ADR 0021 — never rendered text, never exit codes.
16
17
 
17
18
  export interface RgRequest {
19
+ /** Search pattern (match/count modes). Empty only in files mode, where
20
+ * the parse layer guarantees it was omitted (ADR-RG-005 mutual exclusion). */
18
21
  query: string;
19
22
  limit: number;
20
23
  }
@@ -22,10 +25,15 @@ export interface RgRequest {
22
25
  export interface RgOptions {
23
26
  explicitEndpoints?: string[];
24
27
  global: boolean;
25
- glob?: string;
28
+ /** Ordered glob filters (repeatable top-level flag, ADR-RG-005: rg glob
29
+ * grammar lets a later glob negate with `!` — include+exclude combos). */
30
+ globs?: readonly string[];
26
31
  type?: string;
27
32
  ignoreCase?: boolean;
28
33
  count?: boolean;
34
+ /** Files enumeration mode (ADR-RG-005): true tree walk via `rg --files` —
35
+ * no content matching, so empty and binary files are visible. */
36
+ files?: boolean;
29
37
  /** Validated passthrough args (allowlist + scope protection, ADR-RG-002). */
30
38
  passthrough: readonly string[];
31
39
  /** Surface concern (propose precedent): the capability never consumes it —
@@ -72,6 +80,14 @@ export interface RgCountEntry {
72
80
  count: number;
73
81
  }
74
82
 
83
+ /** One enumerated file (files mode, ADR-RG-005): endpoint-relative path plus
84
+ * the durable slot key under the same containment-gated emission rule as
85
+ * matches (ADR 0019 stance). */
86
+ export interface RgFileEntry {
87
+ path: string;
88
+ ukp_uri?: string;
89
+ }
90
+
75
91
  export interface RgEndpointOutcome {
76
92
  name: string;
77
93
  provider: typeof EXTERNAL_PROVIDER;
@@ -91,13 +107,20 @@ export interface RgEndpointOutcome {
91
107
  matches?: RgMatch[];
92
108
  /** Count mode results (`--count`). */
93
109
  counts?: RgCountEntry[];
110
+ /** Files mode results (`--files`, sorted then capped). */
111
+ files?: RgFileEntry[];
94
112
  /** True when matches were truncated at the request limit. */
95
113
  truncated?: boolean;
96
114
  }
97
115
 
116
+ /** The run's output mode — drives tool argv, intake, and both presentation
117
+ * stages (envelope count_mode/files_mode and the human render branches). */
118
+ export type RgMode = "match" | "count" | "files";
119
+
98
120
  export interface RgResult {
99
121
  query: string;
100
122
  limit: number;
123
+ mode: RgMode;
101
124
  endpoints: RgEndpointOutcome[];
102
125
  warnings: string[];
103
126
  aggregate: RgAggregateStatus;
@@ -118,6 +141,10 @@ export class RgPlanningError extends Error {
118
141
  }
119
142
 
120
143
  export const RG_DEFAULT_LIMIT = 50;
144
+ /** Files-mode request limit default (ADR-RG-005): enumeration units are a
145
+ * single path line vs the 3-4 line match unit, so the same consumer-comfort
146
+ * calibration that set 50 for matches lands on 500 here. */
147
+ export const RG_FILES_DEFAULT_LIMIT = 500;
121
148
  export const RG_MAX_LIMIT = 1000;
122
149
 
123
150
  export function defaultRgCommand(): readonly string[] {
@@ -125,10 +152,23 @@ export function defaultRgCommand(): readonly string[] {
125
152
  }
126
153
 
127
154
  /** Base-tool availability probe (diagnose / provider resolver): true when a
128
- * ripgrep executable starts. */
155
+ * ripgrep executable starts. Memoized per process — PATH does not change
156
+ * mid-process, and server startup (the door's ServeInfo carries this field)
157
+ * must not pay the probe again per start; the win32 relay costs ~0.8s where
158
+ * the direct spawn was Defender-taxed ~5s (2026-09-22 finding, see
159
+ * spawn-relay.ts). The relay's not-found guard exits TOOL_NOT_FOUND_EXIT,
160
+ * which no real ripgrep invocation produces (rg exits 0/1/2 only). */
161
+ let rgAvailabilityMemo: boolean | undefined;
162
+
129
163
  export function rgExecutableAvailable(): boolean {
130
- const result = spawnSync("rg", ["--version"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
131
- return !result.error;
164
+ if (rgAvailabilityMemo === undefined) {
165
+ const probeArgv = process.platform === "win32"
166
+ ? toolRelayArgv(["rg", "--version"], { notFoundGuard: true })
167
+ : ["rg", "--version"];
168
+ const result = spawnSync(probeArgv[0]!, probeArgv.slice(1), { encoding: "utf8", windowsHide: true, timeout: 5_000 });
169
+ rgAvailabilityMemo = !result.error && result.status !== TOOL_NOT_FOUND_EXIT;
170
+ }
171
+ return rgAvailabilityMemo;
132
172
  }
133
173
 
134
174
  export function rgTimeoutMs(): number {
@@ -142,8 +182,9 @@ export function rgTimeoutMs(): number {
142
182
  // Passthrough validation (ADR-RG-002): allowlist + scope protection.
143
183
  // ---------------------------------------------------------------------------
144
184
 
145
- /** Value flags consume the next token (or an attached value: `-C2`). */
146
- const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "-t", "--type", "--max-filesize"]);
185
+ /** Value flags consume the next token (or an attached value: `-C2`). `--iglob`
186
+ * joined with ADR-RG-005 (case-insensitive glob variant, probe friction 2/8). */
187
+ const PASSTHROUGH_VALUE_FLAGS = new Set(["-A", "-B", "-C", "-m", "--max-count", "-g", "--glob", "--iglob", "-t", "--type", "--max-filesize"]);
147
188
  const PASSTHROUGH_BOOL_FLAGS = new Set(["-i", "--ignore-case", "-S", "--smart-case", "-s", "--case-sensitive", "-w", "--word-regexp", "-F", "--fixed-strings", "-v", "--invert-match", "-U", "--multiline", "--no-ignore", "--hidden", "--no-messages", "--column", "--no-heading"]);
148
189
 
149
190
  /** Validates passthrough args: allowlist membership + scope protection.
@@ -155,7 +196,7 @@ export function validateRgPassthrough(args: readonly string[]): readonly string[
155
196
  const token = args[index]!;
156
197
  if (!token.startsWith("-")) {
157
198
  throw new RgUsageError(
158
- `passthrough argument '${token}' is not allowed: rg targets the Service folder — no path operands (use the endpoint selector for scope)`,
199
+ `passthrough argument '${token}' is not allowed: rg targets the Service folder - no path operands (use the endpoint selector for scope)`,
159
200
  );
160
201
  }
161
202
  if (PASSTHROUGH_BOOL_FLAGS.has(token)) continue;
@@ -234,13 +275,15 @@ function toUkpUri(endpointName: string, path: string): string {
234
275
  * provider-location mapping. A miss silently drops the `ukp_uri` field
235
276
  * (the match and its `path` stay): rg output is tool output, not user
236
277
  * error. Output-side filtering is the second line of defense; the
237
- * passthrough allowlist (no path operands) is the first. */
238
- function ukpUriIfInside(endpointFolder: string, endpointName: string, path: string): string | undefined {
278
+ * passthrough allowlist (no path operands) is the first. The folder's
279
+ * realpath is passed in pre-resolved (per endpoint, not per entry — review
280
+ * 2026-09-22: per-file resolution paid 2 realpaths/file on exactly the
281
+ * win32 Defender-taxed path); an unresolvable folder emits nothing. */
282
+ function ukpUriIfInside(folderReal: string | undefined, endpointFolder: string, endpointName: string, path: string): string | undefined {
283
+ if (folderReal === undefined) return undefined;
239
284
  const absolute = resolve(endpointFolder, ...path.replace(/\\/g, "/").split("/"));
240
- let folderReal: string;
241
285
  let targetReal: string;
242
286
  try {
243
- folderReal = realpathSync(endpointFolder);
244
287
  targetReal = realpathSync(absolute);
245
288
  } catch {
246
289
  return undefined;
@@ -259,6 +302,16 @@ interface RgJsonEvent {
259
302
  }
260
303
 
261
304
  function rgToolArgs(parsed: ParsedRg): string[] {
305
+ if (parsed.options.files) {
306
+ // Files mode (ADR-RG-005): true tree walk — rg lists paths without
307
+ // reading content, so empty and binary files are visible. No `-e`:
308
+ // the parse layer guarantees no pattern rides along.
309
+ const args: string[] = ["--files", "--no-messages"];
310
+ for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
311
+ if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
312
+ args.push(...parsed.options.passthrough);
313
+ return args;
314
+ }
262
315
  const args: string[] = ["--no-heading", "--no-messages"];
263
316
  if (parsed.options.count) {
264
317
  args.push("--count");
@@ -266,7 +319,7 @@ function rgToolArgs(parsed: ParsedRg): string[] {
266
319
  args.push("--json");
267
320
  }
268
321
  if (parsed.options.ignoreCase) args.push("-i");
269
- if (parsed.options.glob !== undefined) args.push("--glob", parsed.options.glob);
322
+ for (const glob of parsed.options.globs ?? []) args.push("--glob", glob);
270
323
  if (parsed.options.type !== undefined) args.push("--type", parsed.options.type);
271
324
  args.push(...parsed.options.passthrough);
272
325
  args.push("-e", parsed.request.query);
@@ -280,6 +333,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
280
333
  validateRgPassthrough(parsed.options.passthrough);
281
334
  const { plan, warnings } = planRg(parsed, context);
282
335
  const command = context.rgCommand ?? defaultRgCommand();
336
+ const mode: RgMode = parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match";
283
337
  const endpoints: RgEndpointOutcome[] = [];
284
338
  let succeeded = false;
285
339
  let failed = false;
@@ -293,13 +347,36 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
293
347
  continue;
294
348
  }
295
349
 
350
+ // Lazy once-per-endpoint folder realpath for the uri emission rule: the
351
+ // folder cannot change mid-run, and a skip/fail path never pays for it.
352
+ // `null` marks "not attempted yet" vs `undefined` = "attempted, failed"
353
+ // (a failed resolution emits no uris without re-attempting per entry).
354
+ let endpointFolderReal: string | undefined | null = null;
355
+ const folderRealpath = (): string | undefined => {
356
+ if (endpointFolderReal === null) {
357
+ try {
358
+ endpointFolderReal = realpathSync(endpoint.folder);
359
+ } catch {
360
+ endpointFolderReal = undefined;
361
+ }
362
+ }
363
+ return endpointFolderReal;
364
+ };
365
+
296
366
  // Explicit search root: with no path operand rg searches stdin instead
297
367
  // of the cwd whenever stdin is not a tty (ISSUE-013 root cause,
298
368
  // confirmed on the ali repro host: spawned rg read an empty pipe and
299
369
  // exited 1 with zero traversal; tty and /dev/null stdin walk the cwd).
300
370
  // "." keeps rg's rendered paths endpoint-relative; the "./" prefix is
301
- // stripped at both intakes below.
302
- const result = spawnSync(command[0]!, [...command.slice(1), ...rgToolArgs(parsed), "."], {
371
+ // stripped at both intakes below. Win32: the real-tool invocation routes
372
+ // through the spawn relay (Defender taxes the direct bun-to-rg.exe edge
373
+ // ~5s — 2026-09-22 finding, see spawn-relay.ts); the injected test
374
+ // command is never wrapped, and the relay's not-found guard maps a
375
+ // missing rg onto the same skip path the spawn error always took.
376
+ const runArgv = process.platform === "win32" && context.rgCommand === undefined
377
+ ? toolRelayArgv([...command, ...rgToolArgs(parsed), "."], { notFoundGuard: true })
378
+ : [command[0]!, ...command.slice(1), ...rgToolArgs(parsed), "."];
379
+ const result = spawnSync(runArgv[0]!, runArgv.slice(1), {
303
380
  cwd: endpoint.folder,
304
381
  encoding: "utf8",
305
382
  windowsHide: true,
@@ -307,7 +384,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
307
384
  timeout: rgTimeoutMs(),
308
385
  });
309
386
 
310
- if (result.error) {
387
+ if (result.error || result.status === TOOL_NOT_FOUND_EXIT) {
311
388
  // ADR-RG-003/R-7: a missing base tool is a degradation, not a fault —
312
389
  // skipped + warning, the run continues with other endpoints.
313
390
  warnings.push(`endpoint '${endpoint.name}' rg unavailable: rg executable is not available`);
@@ -340,6 +417,42 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
340
417
  continue;
341
418
  }
342
419
 
420
+ if (mode === "files") {
421
+ // Plain path-per-line intake. The full list is sorted BEFORE the
422
+ // client-side cap so truncation yields a deterministic window (the
423
+ // first N paths in lexicographic order, ADR-RG-005) rather than an
424
+ // arbitrary subset of rg's parallel-walk order.
425
+ const paths = (result.stdout ?? "")
426
+ .split(/\r?\n/)
427
+ .filter((line) => line.length > 0)
428
+ .map((line) => line.replace(/\\/g, "/").replace(/^\.\//, ""))
429
+ .sort();
430
+ const files: RgFileEntry[] = [];
431
+ let truncated = false;
432
+ for (const path of paths) {
433
+ if (files.length >= parsed.request.limit) {
434
+ truncated = true;
435
+ break;
436
+ }
437
+ const entry: RgFileEntry = { path };
438
+ const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, path);
439
+ if (uri !== undefined) entry.ukp_uri = uri;
440
+ files.push(entry);
441
+ }
442
+ if (files.length === 0 && !truncated) {
443
+ // An empty enumeration (empty folder or everything filtered out) is
444
+ // a successful result — same D-036 stance as a no-match search.
445
+ succeeded = true;
446
+ outcome.status = "no_matches";
447
+ } else {
448
+ succeeded = true;
449
+ outcome.status = "succeeded";
450
+ outcome.files = files;
451
+ if (truncated) outcome.truncated = true;
452
+ }
453
+ continue;
454
+ }
455
+
343
456
  if (parsed.options.count) {
344
457
  const counts: RgCountEntry[] = [];
345
458
  for (const line of (result.stdout ?? "").split(/\r?\n/)) {
@@ -386,7 +499,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
386
499
  const firstLine = text.split(/\r?\n/)[0] ?? "";
387
500
  if (firstLine.length > 0) entry.text = firstLine.length > 240 ? `${firstLine.slice(0, 240)}...` : firstLine;
388
501
  }
389
- const uri = ukpUriIfInside(endpoint.folder, endpoint.name, entry.path);
502
+ const uri = ukpUriIfInside(folderRealpath(), endpoint.folder, endpoint.name, entry.path);
390
503
  if (uri !== undefined) entry.ukp_uri = uri;
391
504
  matches.push(entry);
392
505
  }
@@ -406,6 +519,7 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
406
519
  return {
407
520
  query: parsed.request.query,
408
521
  limit: parsed.request.limit,
522
+ mode,
409
523
  endpoints,
410
524
  warnings,
411
525
  aggregate: interrupted
@@ -426,35 +540,41 @@ export interface RgEnvelope {
426
540
  schema: "ukp.rg.v1";
427
541
  command: "rg";
428
542
  capability: "rg";
429
- query: string;
543
+ /** Omitted in files mode (ADR-RG-005): there is no pattern to echo. */
544
+ query?: string;
430
545
  limit: number;
431
546
  count_mode: boolean;
547
+ files_mode: boolean;
432
548
  endpoints: Array<{
433
549
  name: string;
434
550
  provider: string;
435
551
  status: string;
436
552
  match_count?: number;
553
+ file_count?: number;
437
554
  truncated?: boolean;
438
555
  message?: string;
439
556
  matches?: RgMatch[];
440
557
  counts?: RgCountEntry[];
558
+ files?: RgFileEntry[];
441
559
  }>;
442
560
  warnings: string[];
443
561
  }
444
562
 
445
- export function projectRgEnvelope(result: RgResult, countMode: boolean): RgEnvelope {
563
+ export function projectRgEnvelope(result: RgResult): RgEnvelope {
446
564
  return {
447
565
  schema: "ukp.rg.v1",
448
566
  command: "rg",
449
567
  capability: "rg",
450
- query: result.query,
568
+ ...(result.mode === "files" ? {} : { query: result.query }),
451
569
  limit: result.limit,
452
- count_mode: countMode,
570
+ count_mode: result.mode === "count",
571
+ files_mode: result.mode === "files",
453
572
  endpoints: result.endpoints.map((endpoint) => ({
454
573
  name: endpoint.name,
455
574
  provider: endpoint.provider,
456
575
  status: endpoint.status ?? "failed",
457
576
  ...(endpoint.matches !== undefined ? { match_count: endpoint.matches.length, matches: endpoint.matches } : {}),
577
+ ...(endpoint.files !== undefined ? { file_count: endpoint.files.length, files: endpoint.files } : {}),
458
578
  ...(endpoint.counts !== undefined ? { counts: endpoint.counts } : {}),
459
579
  ...(endpoint.truncated !== undefined ? { truncated: endpoint.truncated } : {}),
460
580
  ...(endpoint.message !== undefined ? { message: endpoint.message } : {}),
@@ -480,7 +600,14 @@ export function renderRgHuman(result: RgResult): RgHumanView {
480
600
  continue;
481
601
  }
482
602
  if (endpoint.status === "no_matches") {
483
- lines.push("(no matches)");
603
+ lines.push(result.mode === "files" ? "(no files)" : "(no matches)");
604
+ continue;
605
+ }
606
+ if (endpoint.files !== undefined) {
607
+ // Exhibit form (probe 20260922, zero misreads): one line per file,
608
+ // the durable uri when containment allows it, the raw path otherwise.
609
+ for (const file of endpoint.files) lines.push(file.ukp_uri ?? file.path);
610
+ if (endpoint.truncated) lines.push(`(truncated at the result limit ${result.limit}; raise --limit)`);
484
611
  continue;
485
612
  }
486
613
  if (endpoint.counts !== undefined) {
@@ -872,7 +872,7 @@ function renderResultUnit(
872
872
  const base = basenameOf(providerLocation);
873
873
  const location = base ? (line ? `${base}:${line}` : base) : "";
874
874
  const identity = title
875
- ? (location ? `${title} — ${location}` : title)
875
+ ? (location ? `${title} - ${location}` : title)
876
876
  : (location || "");
877
877
 
878
878
  const snippet = snippetOf(result);
@@ -890,7 +890,7 @@ function renderResultUnit(
890
890
  const key = line ? `${docid}:${line}` : docid;
891
891
  lines.push(` read: ukp read --endpoint ${endpointName} ${key}`);
892
892
  } else {
893
- lines.push(` (no direct read — provider-managed result)`);
893
+ lines.push(` (no direct read - provider-managed result)`);
894
894
  }
895
895
  if (ukpUri && remoteUri === undefined) lines.push(` uri: ${ukpUri}`);
896
896
  return lines.join("\n");
package/src/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { executeSearchCommand, SEARCH_SPEC } from "./commands/search.ts";
24
24
  export { renderSearchHelp } from "./commands/search.ts";
25
25
  export { renderDiagnoseHelp } from "./commands/diagnose.ts";
26
26
  export { renderReadHelp } from "./commands/read.ts";
27
- export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
27
+ export { renderGuideHelp, renderServiceGuide, renderServiceQmdGuide, renderClientGuide, renderRgGuide, renderRemoteGuide, renderProposeGuide } from "./commands/guide.ts";
28
28
  export { renderInitHelp, renderInitServiceHelp } from "./commands/init.ts";
29
29
  export { renderInspectHelp } from "./commands/inspect.ts";
30
30
  export { renderUpdateHelp } from "./commands/update.ts";
@@ -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,15 +3,17 @@ 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
+ Peek at the door before importing: ssh <host> ukp list shows the endpoint
16
+ names and capabilities the host declares
15
17
 
16
18
  2. On the consuming machine, import the door (the first call wakes it)
17
19
  ukp register --url ssh://<host>
@@ -45,7 +47,7 @@ Use this when you deliberately want to expose only one endpoint instead of the w
45
47
  ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
46
48
 
47
49
  Name and identity:
48
- - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken).
50
+ - register --url fetches /.well-known/ukp.json and registers the declared name under a local handle (--name <handle> chooses another, e.g. when the name is taken). That document is public - curl it before registering to preview the endpoint names and capabilities the door declares (self-signed doors: curl -k).
49
51
  - --endpoint <name> on register asserts the expected name; if discovery declares a different name, registration fails.
50
52
  - A host door can import everything, --select a comma-separated subset, or --endpoint exactly one endpoint.
51
53
  - Self-signed HTTPS certificates are pinned on first registration; compare the pinned fingerprint with the serve banner on untrusted networks.
@@ -54,9 +56,9 @@ Name and identity:
54
56
  - 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
57
 
56
58
  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.
59
+ - 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.
60
+ - 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.
61
+ - 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
62
  - serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
61
63
 
62
64
  Remember:
@@ -0,0 +1,35 @@
1
+ UKP rg quickstart
2
+
3
+ Goal: search or enumerate a Service's own files with ripgrep - no index, no declaration, real-time.
4
+
5
+ Model:
6
+ - rg is the lexical baseline: it scans files at call time, so it works on every registered endpoint. 'ukp search' is the indexed path and needs the Service to declare a provider.
7
+ - Output is ukp:// references that 'ukp read' consumes directly.
8
+ - Three modes: match (default), --count (per-file totals), --files (enumeration).
9
+
10
+ 1. Search
11
+ ukp rg --endpoint <name> "pattern"
12
+ -i is case-insensitive; --glob narrows by name; --limit raises the cap (default 50 matches).
13
+
14
+ 2. Enumerate files (no pattern)
15
+ ukp rg --endpoint <name> --files
16
+ A true tree walk, not a content match: empty and binary files are listed too.
17
+ Narrow by name with --glob, by kind with --type. Output is sorted; the
18
+ cap defaults to 500 (--limit to change).
19
+
20
+ 3. Glob semantics
21
+ --glob follows ripgrep/gitignore rules: a glob without "/" matches the
22
+ file name at any depth ("*.md"); a glob containing "/" anchors to the
23
+ endpoint-relative path ("docs/*"). Matching is case-sensitive; pass
24
+ -- --iglob "<glob>" after '--' for a case-insensitive variant.
25
+ --glob repeats: a leading "!" excludes, so "*.md" plus "!docs/*" is
26
+ markdown outside docs/.
27
+
28
+ 4. Visibility (every rg mode)
29
+ Hidden files (dot-prefixed, including the .ukp/ wiring) are skipped by
30
+ default. Add -- --hidden after '--' to include them; -uu also lifts
31
+ .gitignore rules. 'ukp read' can address hidden files directly either way.
32
+
33
+ Remember:
34
+ - Exact flags and the passthrough allowlist live in 'ukp rg --help'.
35
+ - Remote endpoints behave the same: one command drives local and door-registered endpoints alike.
@@ -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)
@@ -20,10 +20,11 @@ const GUIDE_CONTENT_URL = new URL("./guide-content/", import.meta.url);
20
20
  * `ukp guide --help` render the same summaries (blank-agent sweep 2026-09-11:
21
21
  * the inversion of root help describing guides better than guide's own help
22
22
  * was a confirmed doc-gap). */
23
- export const GUIDE_TOPICS: ReadonlyArray<readonly [topic: string, summary: string]> = [
23
+ export const GUIDE_TOPICS: ReadonlyArray<readonly [string, string]> = [
24
24
  ["service", "first Service setup with provider-free nav/read/rg baseline"],
25
25
  ["service qmd", "provider setup for the default QMD provider"],
26
26
  ["client", "use registered Services by default from a workspace; set your default scope here"],
27
+ ["rg", "lexical search and file enumeration: modes, glob semantics, visibility tiers"],
27
28
  ["remote", "serve and consume remote endpoints with a host door, ssh, or TLS"],
28
29
  ["propose", "submit idempotent change proposals to a Service"],
29
30
  ];
@@ -38,7 +39,7 @@ export const GUIDE_SPEC: UkpCommandSpec = {
38
39
  description: "Show short operational guides.",
39
40
  usage: "<topic> [subtopic]",
40
41
  arguments: [
41
- { name: "topic", required: true, help: "guide topic: service | service qmd | client | remote | propose" },
42
+ { name: "topic", required: true, help: "guide topic: service | service qmd | client | rg | remote | propose" },
42
43
  { name: "subtopic", help: "provider subtopic for a topic, e.g. 'qmd' as in 'ukp guide service qmd'" },
43
44
  ],
44
45
  strictArguments: true,
@@ -72,17 +73,19 @@ export function executeGuideCommand(args: readonly string[]): GuideCommandResult
72
73
  return { exitCode: 0, stdout: renderServiceGuide(), stderr: "" };
73
74
  }
74
75
  if (subtopic) {
75
- throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, remote, propose`);
76
+ throw new KitUsageError(`unknown guide topic '${topic} ${subtopic}'. Available topics: service, service qmd, client, rg, remote, propose`);
76
77
  }
77
78
  switch (topic) {
78
79
  case "client":
79
80
  return { exitCode: 0, stdout: renderClientGuide(), stderr: "" };
81
+ case "rg":
82
+ return { exitCode: 0, stdout: renderRgGuide(), stderr: "" };
80
83
  case "remote":
81
84
  return { exitCode: 0, stdout: renderRemoteGuide(), stderr: "" };
82
85
  case "propose":
83
86
  return { exitCode: 0, stdout: renderProposeGuide(), stderr: "" };
84
87
  default:
85
- throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, remote, propose`);
88
+ throw new KitUsageError(`unknown guide topic '${topic}'. Available topics: service, service qmd, client, rg, remote, propose`);
86
89
  }
87
90
  } catch (error) {
88
91
  if (error instanceof HelpRequestError) {
@@ -115,6 +118,10 @@ export function renderClientGuide(): string {
115
118
  return readGuideContent("client.txt");
116
119
  }
117
120
 
121
+ export function renderRgGuide(): string {
122
+ return readGuideContent("rg.txt");
123
+ }
124
+
118
125
  export function renderRemoteGuide(): string {
119
126
  return readGuideContent("remote.txt");
120
127
  }