@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.
- package/README.md +5 -2
- package/bin/ukp.js +31 -11
- package/docs/remote-deployment.md +7 -0
- package/package.json +1 -1
- package/src/capabilities/propose.ts +1 -1
- package/src/capabilities/read.ts +1 -1
- package/src/capabilities/remote-client.ts +361 -70
- package/src/capabilities/rg.ts +148 -21
- package/src/capabilities/search.ts +2 -2
- package/src/cli.ts +73 -4
- package/src/commands/guide-content/propose.txt +1 -1
- package/src/commands/guide-content/remote.txt +9 -7
- package/src/commands/guide-content/rg.txt +35 -0
- package/src/commands/guide-content/service-qmd.txt +1 -1
- package/src/commands/guide.ts +11 -4
- package/src/commands/inventory.ts +182 -36
- package/src/commands/propose.ts +1 -1
- package/src/commands/read.ts +6 -6
- package/src/commands/rg.ts +45 -14
- package/src/commands/serve-task.ts +17 -17
- package/src/commands/serve.ts +22 -22
- package/src/registry.ts +2 -2
- package/src/server.ts +41 -26
- package/src/spawn-relay.ts +57 -0
package/src/capabilities/rg.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
package/src/commands/guide.ts
CHANGED
|
@@ -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 [
|
|
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
|
}
|