@catheadowl/ukp 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,26 @@
1
- import { ENDPOINT_NAME, loadManifest } from "../config/manifest.ts";
1
+ import { join } from "node:path";
2
+ import { ENDPOINT_NAME, loadManifest, ServiceFolderInaccessibleError } from "../config/manifest.ts";
2
3
  import { FILE_NATIVE_CAPABILITIES, isFileNativeCapability } from "../config/file-native.ts";
4
+ import { EXTERNAL_TOOL_CAPABILITIES } from "../config/external-tool.ts";
3
5
  import { diagnoseService, type ProviderResolver } from "./diagnose.ts";
6
+ import {
7
+ assertUkpResidents,
8
+ ensureVersionVisibleUkpIgnore,
9
+ renderUkpIgnorePostureLine,
10
+ ukpIgnoreStarWarning,
11
+ } from "./init.ts";
4
12
  import {
5
13
  assertRegistrableRemoteUrl,
6
14
  isRemoteBinding,
7
15
  parseRemoteUrl,
8
16
  readRegistry,
17
+ rebindAt,
9
18
  registerAt,
10
19
  registerRemoteAt,
11
20
  unregisterAt,
12
21
  type RegistryBinding,
13
22
  } from "../registry.ts";
23
+ import { resolveScope, type ResolvedScope } from "../scope.ts";
14
24
  import {
15
25
  createTransportPool,
16
26
  fetchDiscoveryDocument,
@@ -73,6 +83,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
73
83
  strictArguments: true,
74
84
  options: [
75
85
  { 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)" },
86
+ { flags: "--rebind", help: "local only: relocate this endpoint name to the current Service folder in one step (the folder moved); the name must already be bound to a local folder - registering a name bound elsewhere without this flag is rejected with the recovery path" },
76
87
  { 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)" },
77
88
  { 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" },
78
89
  { 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" },
@@ -82,12 +93,19 @@ export const REGISTER_SPEC: UkpCommandSpec = {
82
93
  "",
83
94
  "Recovery:",
84
95
  " Registering a name already bound to a different location is rejected.",
85
- " Unregister the old binding first, then register from the new Service folder:",
96
+ " If the Service folder moved, re-run register from the new folder with",
97
+ " --rebind to relocate the name in one step (local bindings only):",
98
+ " ukp register --rebind",
99
+ " Without the flag the two-step path stays available:",
86
100
  " ukp unregister --endpoint <name>",
87
101
  "",
88
102
  "Binding:",
89
103
  " The Host Registry binds the Service Manifest's effective name to this",
90
- " folder's location; 'ukp list' shows the resulting bindings.",
104
+ " folder's location; 'ukp list' shows the resulting bindings. A local",
105
+ " register (plain or --rebind) also brings .ukp/.gitignore to the",
106
+ " precise-ignore posture: created when absent, a legacy '*' template",
107
+ " migrated so the declarations can be committed, an operator-owned",
108
+ " file left untouched.",
91
109
  " With --url, the declared name and instance identity come from the",
92
110
  " remote discovery document (/.well-known/ukp.json) and are pinned",
93
111
  " TOFU-style as provenance; your registry binds them under a local",
@@ -111,11 +129,15 @@ export const REGISTER_SPEC: UkpCommandSpec = {
111
129
 
112
130
  export const LIST_SPEC: UkpCommandSpec = {
113
131
  name: "list",
114
- summary: "list registered endpoint bindings",
132
+ summary: "list endpoint bindings - workspace scope by default, every registration with -g",
115
133
  group: "registry",
116
- description: "List registered endpoint bindings and capabilities.",
117
- usage: "[options]",
134
+ description: "List endpoint bindings and capabilities: the workspace scope by default (nearest .ukp/client.toml; every registered endpoint when there is none), or the full Host Registry with -g.",
135
+ usage: "[-g] [--json]",
118
136
  strictArguments: true,
137
+ options: [
138
+ { flags: "-g, --global", help: "list every registered endpoint (the full Host Registry), ignoring the workspace scope and without reading .ukp/client.toml" },
139
+ { flags: "--json", help: "emit the offline machine-readable endpoint snapshot (schema ukp.list.v1; registry facts only - remote bindings are not probed, their capability fields are null); rides the same scope as the text listing" },
140
+ ],
119
141
  };
120
142
 
121
143
  export const UNREGISTER_SPEC: UkpCommandSpec = {
@@ -142,6 +164,59 @@ const DEFAULT_CAPABILITIES = (Object.keys(FILE_NATIVE_CAPABILITIES) as Array<key
142
164
  .filter((name) => FILE_NATIVE_CAPABILITIES[name].derived)
143
165
  .sort();
144
166
 
167
+ /** Base-tier membership for the machine snapshot (ukp.list.v1): the
168
+ * declaration-free baseline every healthy local endpoint carries — the
169
+ * derived file-native set (nav, read; ADR 0016 rule 2, the header's own
170
+ * statement) plus the external-tool base tier (rg; ADR-RG-003). Membership
171
+ * only: rg's runtime availability still degrades by tool presence (tier
172
+ * semantics unchanged — `base_tier` is not a liveness claim). */
173
+ const BASE_TIER_CAPABILITIES = [
174
+ ...DEFAULT_CAPABILITIES,
175
+ ...(Object.keys(EXTERNAL_TOOL_CAPABILITIES) as Array<keyof typeof EXTERNAL_TOOL_CAPABILITIES>)
176
+ .filter((name) => EXTERNAL_TOOL_CAPABILITIES[name].baseTier),
177
+ ].sort();
178
+
179
+ /** One endpoint as offline machine facts — the `ukp.list.v1` row (TODO
180
+ * 20261028-0053). The machine contract is a registry-snapshot projection:
181
+ * local rows read the Service Manifest (a local file read, never the
182
+ * network); remote rows carry registry facts only, because the registry
183
+ * pins no capability or liveness claims for remote bindings and the
184
+ * contract never probes — the text face's fresh discovery fetch is a
185
+ * per-invocation human affordance, not snapshot truth. Nullability is the
186
+ * contract's one rule: `null` = "not asserted in this snapshot". */
187
+ export interface ListEndpointSnapshot {
188
+ /** Registry handle — the ukp:// authority and the `--endpoint` key. */
189
+ name: string;
190
+ /** Local/remote discriminator (registry `kind`; absence = local). */
191
+ kind: "local" | "remote";
192
+ /** Binding location: the local canonical absolute path, or the
193
+ * registered remote url verbatim. Exactly one of path/url is non-null. */
194
+ location: { path: string | null; url: string | null };
195
+ /** Remote-declared name snapshot (ADR-REM-007 provenance); null for
196
+ * local bindings and legacy remote bindings that predate the field. */
197
+ declared_name: string | null;
198
+ /** Sorted declared capability extras (the text capabilities cell as an
199
+ * array); null on degraded local rows and on every remote row. */
200
+ capabilities: string[] | null;
201
+ /** Declaration-free baseline members in effect (nav/read/rg today);
202
+ * null on degraded local rows and on every remote row. */
203
+ base_tier: string[] | null;
204
+ /** Local rows: the Service Manifest loaded (true) or the row degraded
205
+ * (false). Remote rows: null — remote liveness is not an offline fact. */
206
+ available: boolean | null;
207
+ /** The same one-line degradation headline the text face emits on
208
+ * stderr; null when the row is not degraded. */
209
+ warning: string | null;
210
+ }
211
+
212
+ /** Envelope of the `--json` snapshot — the repo machine-face convention
213
+ * (versioned schema id + pretty-printed single document, as nav/rg). */
214
+ export interface ListSnapshotEnvelope {
215
+ schema: "ukp.list.v1";
216
+ command: "list";
217
+ endpoints: ListEndpointSnapshot[];
218
+ }
219
+
145
220
  /** One list row as structured cells — column geometry is decided once, in
146
221
  * renderListOutput, from every row's actual widths (raw tab stops made the
147
222
  * columns land on a different multiple-of-8 per row once names, paths and
@@ -158,7 +233,23 @@ interface InventoryListRow {
158
233
  warning?: string;
159
234
  }
160
235
 
161
- function renderLocalListRow(endpoint: { name: string; path: string }): InventoryListRow {
236
+ /** Offline facts for one local binding — the single producer BOTH faces
237
+ * consume (the same-truth seam of the machine contract: the text row and
238
+ * the ukp.list.v1 row are projections of this, never parallel assemblies).
239
+ * Manifest load failures degrade exactly like the text face: no capability
240
+ * assertions plus the same one-line warning. */
241
+ interface LocalListFacts {
242
+ name: string;
243
+ path: string;
244
+ /** Sorted declared extras; null on a degraded row. */
245
+ capabilities: string[] | null;
246
+ /** Baseline members; null on a degraded row. */
247
+ baseTier: string[] | null;
248
+ available: boolean;
249
+ warning: string | null;
250
+ }
251
+
252
+ function localListFacts(endpoint: { name: string; path: string }): LocalListFacts {
162
253
  let service;
163
254
  try {
164
255
  service = loadManifest(endpoint.path);
@@ -166,17 +257,100 @@ function renderLocalListRow(endpoint: { name: string; path: string }): Inventory
166
257
  // One-line headline only: an inventory warning must stay scannable even
167
258
  // when the underlying ManifestError carries a full zod schema dump.
168
259
  const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
260
+ // A dead binding (folder unreachable) is a binding-level failure, not a
261
+ // capability degradation: the binding itself routes nowhere. It gets its
262
+ // own frame plus the one-step rebind remedy (dead-binding item 1; the
263
+ // `declared capabilities` qualifier stays reserved for manifest-level
264
+ // failures where the folder IS reachable — probe-anchored wording).
265
+ if (error instanceof ServiceFolderInaccessibleError) {
266
+ return {
267
+ name: endpoint.name,
268
+ path: endpoint.path,
269
+ capabilities: null,
270
+ baseTier: null,
271
+ available: false,
272
+ warning: `endpoint '${endpoint.name}' binding is dead: ${headline}; `
273
+ + `if the folder moved, run 'ukp register --rebind' from the new folder `
274
+ + `(or 'ukp unregister --endpoint ${endpoint.name}')`,
275
+ };
276
+ }
169
277
  return {
170
278
  name: endpoint.name,
171
- location: endpoint.path,
172
- capabilities: "(unavailable)",
279
+ path: endpoint.path,
280
+ capabilities: null,
281
+ baseTier: null,
282
+ available: false,
173
283
  warning: `endpoint '${endpoint.name}' declared capabilities unavailable: ${headline}`,
174
284
  };
175
285
  }
176
286
  const extras = Object.keys(service.manifest.capabilities)
177
287
  .filter((name) => !(isFileNativeCapability(name) && FILE_NATIVE_CAPABILITIES[name].derived))
178
288
  .sort();
179
- return { name: endpoint.name, location: endpoint.path, capabilities: extras.length > 0 ? extras.join(",") : "-" };
289
+ return { name: endpoint.name, path: endpoint.path, capabilities: extras, baseTier: [...BASE_TIER_CAPABILITIES], available: true, warning: null };
290
+ }
291
+
292
+ function renderLocalListRow(endpoint: { name: string; path: string }): InventoryListRow {
293
+ const facts = localListFacts(endpoint);
294
+ return {
295
+ name: facts.name,
296
+ location: facts.path,
297
+ capabilities: facts.available
298
+ ? (facts.capabilities!.length > 0 ? facts.capabilities!.join(",") : "-")
299
+ : "(unavailable)",
300
+ ...(facts.warning !== null ? { warning: facts.warning } : {}),
301
+ };
302
+ }
303
+
304
+ /** ukp.list.v1 projection of one local binding — the same localListFacts
305
+ * the text row renders from, shaped as snapshot fields. */
306
+ function localSnapshotRow(endpoint: { name: string; path: string }): ListEndpointSnapshot {
307
+ const facts = localListFacts(endpoint);
308
+ return {
309
+ name: facts.name,
310
+ kind: "local",
311
+ location: { path: facts.path, url: null },
312
+ declared_name: null,
313
+ capabilities: facts.capabilities,
314
+ base_tier: facts.baseTier,
315
+ available: facts.available,
316
+ warning: facts.warning,
317
+ };
318
+ }
319
+
320
+ /** ukp.list.v1 row for one remote binding: pure registry projection, no
321
+ * transport. The handle stays the bare `name` — the text face's
322
+ * `name (declares X)` cell is a rendering; here the declaration rides in
323
+ * `declared_name` as structure. Capability and liveness fields are null
324
+ * per the offline contract (the registry pins none for remotes). */
325
+ function remoteSnapshotRow(endpoint: RegistryBinding): ListEndpointSnapshot {
326
+ return {
327
+ name: endpoint.name,
328
+ kind: "remote",
329
+ location: { path: null, url: endpoint.url! },
330
+ declared_name: endpoint.declared_name ?? null,
331
+ capabilities: null,
332
+ base_tier: null,
333
+ available: null,
334
+ warning: null,
335
+ };
336
+ }
337
+
338
+ /** `--json` emission (TODO 20261028-0053 machine contract): one
339
+ * pretty-printed document in the repo machine-face convention, rows in the
340
+ * text face's order (locals first, then remotes, each in registry order).
341
+ * Offline and synchronous by construction — no transport pool is ever
342
+ * created for this path, so a registry full of remote bindings costs zero
343
+ * network. stderr stays empty in this mode: degradation truth rides in the
344
+ * rows' `warning` fields. */
345
+ function renderListJsonSnapshot(endpoints: readonly RegistryBinding[]): InventoryCommandResult {
346
+ const rows = [
347
+ ...endpoints
348
+ .filter((endpoint) => !isRemoteBinding(endpoint))
349
+ .map((endpoint) => localSnapshotRow({ name: endpoint.name, path: endpoint.path! })),
350
+ ...endpoints.filter(isRemoteBinding).map(remoteSnapshotRow),
351
+ ];
352
+ const envelope: ListSnapshotEnvelope = { schema: "ukp.list.v1", command: "list", endpoints: rows };
353
+ return { exitCode: 0, stdout: `${JSON.stringify(envelope, null, 2)}\n`, stderr: "" };
180
354
  }
181
355
 
182
356
  export function executeRegisterCommand(
@@ -188,7 +362,7 @@ export function executeRegisterCommand(
188
362
  }
189
363
 
190
364
  try {
191
- const parsed = parseKitArgs<{ url?: string; token?: string; select?: string; endpoint?: string; name?: string }>(REGISTER_SPEC, args);
365
+ const parsed = parseKitArgs<{ url?: string; token?: string; select?: string; endpoint?: string; name?: string; rebind?: boolean }>(REGISTER_SPEC, args);
192
366
  const expectedName = parsed.options.endpoint;
193
367
  if (expectedName !== undefined && !ENDPOINT_NAME.test(expectedName)) {
194
368
  throw new KitUsageError("--endpoint requires a valid endpoint name");
@@ -202,6 +376,11 @@ export function executeRegisterCommand(
202
376
  // folder owner controls; the registry naming slot is remote-only.
203
377
  throw new KitUsageError("--name chooses a remote registration handle and requires --url");
204
378
  }
379
+ if (parsed.options.rebind === true && parsed.options.url !== undefined) {
380
+ // --rebind is the narrow local relocation exit; remote handles keep
381
+ // the unregister path (registry-commands rebind-one-step, 2026-10-24).
382
+ throw new KitUsageError("--rebind relocates a local binding and cannot be combined with --url");
383
+ }
205
384
  if (parsed.options.url !== undefined) {
206
385
  return executeRegisterRemote(parsed.options.url, parsed.options.token, parsed.options.select, expectedName, handleName, context);
207
386
  }
@@ -212,11 +391,45 @@ export function executeRegisterCommand(
212
391
  if (expectedName !== undefined && expectedName !== report.service.effectiveName) {
213
392
  throw new Error(`expected endpoint '${expectedName}', but this Service effective name is '${report.service.effectiveName}'`);
214
393
  }
394
+ // INV-21 admission gate BEFORE the binding write: a foreign resident in
395
+ // .ukp/ refuses the whole gesture — registry untouched, guard not
396
+ // rewritten over material it no longer covers (the 2026-11-02 KB shape:
397
+ // a '*' guard swapped onto the current template above a legacy tls/).
398
+ const ukpDirectory = join(report.service.folder, ".ukp");
399
+ assertUkpResidents(ukpDirectory, { endpointName: report.service.effectiveName });
400
+ if (parsed.options.rebind === true) {
401
+ // Diagnosis already validated the new Service folder — a failed rebind
402
+ // (unknown name, remote handle, taken location) leaves the old binding
403
+ // untouched; the swap itself is one atomic registry transition.
404
+ const previous = readRegistry(context.registryPath).find(
405
+ (endpoint) => endpoint.name === report.service.effectiveName,
406
+ );
407
+ rebindAt(context.registryPath, report.service.effectiveName, report.service.folder);
408
+ // The service-side version-control step rides the same binding
409
+ // gestures (use is the client-face carrier; a pure Service folder —
410
+ // the fresh-clone revival path this command IS — has no reason to
411
+ // run use, and re-running init is refused once the manifest exists).
412
+ const posture = ensureVersionVisibleUkpIgnore(ukpDirectory);
413
+ const lines = [
414
+ `rebound: ${report.service.effectiveName}`,
415
+ `from: ${previous?.path ?? "(unknown)"}`,
416
+ ...(report.service.manifest.description ? [`description: ${report.service.manifest.description}`] : []),
417
+ `location: ${report.service.folder}`,
418
+ renderUkpIgnorePostureLine(posture, ukpDirectory),
419
+ ];
420
+ return {
421
+ exitCode: 0,
422
+ stdout: lines.join("\n"),
423
+ stderr: posture === "operator-owned-star" ? ukpIgnoreStarWarning(ukpDirectory) : "",
424
+ };
425
+ }
215
426
  registerAt(context.registryPath, report.service.effectiveName, report.service.folder);
427
+ const posture = ensureVersionVisibleUkpIgnore(ukpDirectory);
216
428
  const lines = [
217
429
  `registered: ${report.service.effectiveName}`,
218
430
  ...(report.service.manifest.description ? [`description: ${report.service.manifest.description}`] : []),
219
431
  `location: ${report.service.folder}`,
432
+ renderUkpIgnorePostureLine(posture, ukpDirectory),
220
433
  // Register never edits Client Config (guide client owns that); this is
221
434
  // a pointer for the cross-workspace consumption case, which is the
222
435
  // standard follow-up after registering a Service others will reference.
@@ -225,7 +438,7 @@ export function executeRegisterCommand(
225
438
  return {
226
439
  exitCode: 0,
227
440
  stdout: lines.join("\n"),
228
- stderr: "",
441
+ stderr: posture === "operator-owned-star" ? ukpIgnoreStarWarning(ukpDirectory) : "",
229
442
  };
230
443
  } catch (error) {
231
444
  if (error instanceof HelpRequestError) {
@@ -541,14 +754,58 @@ export function executeListCommand(
541
754
  }
542
755
 
543
756
  try {
544
- parseKitArgs(LIST_SPEC, args);
545
- const endpoints = readRegistry(context.registryPath);
757
+ const parsed = parseKitArgs<{ json?: boolean; global?: boolean }>(LIST_SPEC, args);
758
+ // Scope semantics (owner ruling 2026-10-28 / ADR 0031, restoring the
759
+ // 13ae382 clause): list eats the workspace scope like every consumer
760
+ // command — the nearest client.toml's default_endpoints by default,
761
+ // full-registry fallback when there is none (bare machines unchanged) —
762
+ // and -g lists the full Host Registry without reading the config. No
763
+ // machine-face exemption: --json rides the same resolution (the
764
+ // exemption pattern is exactly what let the 09-21 contract ratify the
765
+ // implementation drift); ukp.list.v1's fields are unchanged, only the
766
+ // row set is selected.
767
+ const registry = readRegistry(context.registryPath);
768
+ let endpoints: readonly RegistryBinding[];
769
+ let scopeWarnings: string[] = [];
770
+ if (parsed.options.global === true) {
771
+ endpoints = registry;
772
+ } else {
773
+ let scope: ResolvedScope;
774
+ try {
775
+ scope = resolveScope({ currentDirectory: context.currentDirectory, registry });
776
+ } catch (error) {
777
+ // Same law as the scope family, no list exemption: an invalid
778
+ // client.toml or one that resolves to nothing fails the listing —
779
+ // with the -g escape taught in place (it never reads the config).
780
+ const message = error instanceof Error ? error.message : String(error);
781
+ return {
782
+ exitCode: 1,
783
+ stdout: "",
784
+ stderr: `ukp list: ${message}; use 'ukp list -g' to list every registered endpoint\n`,
785
+ };
786
+ }
787
+ endpoints = scope.bindings;
788
+ scopeWarnings = scope.warnings;
789
+ }
790
+ if (parsed.options.json === true) {
791
+ // Machine contract (ukp.list.v1 / TODO 20261028-0053): the offline
792
+ // snapshot of the SELECTED endpoints. Returns before any transport
793
+ // pool exists — see renderListJsonSnapshot; the sync return with
794
+ // remote bindings present is the structural pin of the no-network
795
+ // promise. Scope-level dangling warnings ride stderr (same strings
796
+ // as the text face); row-level degradation truth stays in rows.
797
+ const snapshot = renderListJsonSnapshot(endpoints);
798
+ return {
799
+ ...snapshot,
800
+ stderr: scopeWarnings.length > 0 ? `${scopeWarnings.join("\n")}\n` : "",
801
+ };
802
+ }
546
803
  if (endpoints.length === 0) {
547
804
  return { exitCode: 0, stdout: "No endpoints registered.", stderr: "" };
548
805
  }
549
806
  const remotes = endpoints.filter(isRemoteBinding);
550
807
  if (remotes.length === 0) {
551
- return renderListOutput(endpoints.map((endpoint) => renderLocalListRow({ name: endpoint.name, path: endpoint.path! })));
808
+ return renderListOutput(endpoints.map((endpoint) => renderLocalListRow({ name: endpoint.name, path: endpoint.path! })), scopeWarnings);
552
809
  }
553
810
  // Remote rows fetch the discovery document (fresh per invocation); the
554
811
  // same conditional-async seam as search/read keeps all-local listings
@@ -640,7 +897,9 @@ export function executeListCommand(
640
897
  // drift fetch may be the slowest leg and a spinner under a finished
641
898
  // table would be noise.
642
899
  clearProgress();
643
- const stderrLines = [...warnings, ...collectSameInstanceNotes(endpoints), ...(await drift)];
900
+ // Scope dangling warnings lead (they qualify the selection itself),
901
+ // then row degradation, then the same-instance/drift notes.
902
+ const stderrLines = [...scopeWarnings, ...warnings, ...collectSameInstanceNotes(endpoints), ...(await drift)];
644
903
  return {
645
904
  exitCode: 0,
646
905
  stdout: context.emitStdout === undefined ? lines.join("\n") : "",
@@ -663,9 +922,10 @@ export function executeListCommand(
663
922
 
664
923
  function renderListOutput(
665
924
  rows: ReadonlyArray<InventoryListRow>,
925
+ leadingNotes: readonly string[] = [],
666
926
  extraNotes: readonly string[] = [],
667
927
  ): InventoryCommandResult {
668
- const warnings = [...rows.flatMap((row) => (row.warning !== undefined ? [row.warning] : [])), ...extraNotes];
928
+ const warnings = [...leadingNotes, ...rows.flatMap((row) => (row.warning !== undefined ? [row.warning] : [])), ...extraNotes];
669
929
  const stdout = [listHeaderLine(), ...rows.map((row) => renderListLine(row, listRowGeometry(rows)))].join("\n");
670
930
  return {
671
931
  exitCode: 0,
@@ -58,7 +58,7 @@ export const NAV_SPEC: UkpCommandSpec = {
58
58
  options: [
59
59
  {
60
60
  flags: "--depth <n>",
61
- help: "how many directory levels to expand from the route root (0-10, default 0; deeper folders appear as [truncated: N], where N is that folder's total recursive .md count)",
61
+ help: "how many directory levels to expand from the route root (0-10, default 0; deeper folders appear as folder/ (+N .md), where N is that folder's total recursive .md count)",
62
62
  },
63
63
  { flags: "--json", help: "emit the structured response envelope" },
64
64
  ],
@@ -110,10 +110,10 @@ export function parseNavArgs(args: readonly string[]): NavRequest {
110
110
  };
111
111
  }
112
112
 
113
- export function executeNavCommand(
113
+ export async function executeNavCommand(
114
114
  args: readonly string[],
115
115
  context: NavContext,
116
- ): NavCommandResult | Promise<NavCommandResult> {
116
+ ): Promise<NavCommandResult> {
117
117
  if (isHelpRequest(args)) {
118
118
  return { exitCode: 0, stdout: renderNavHelp(), stderr: "" };
119
119
  }
@@ -132,15 +132,15 @@ export function executeNavCommand(
132
132
  }
133
133
 
134
134
  // Remote branch (ukp_remote W6): a remote binding routes through the
135
- // remote transport; the sync local contract is unchanged
136
- // (conditional-async seam, same as search/read).
135
+ // remote transport; local and remote now share one async contract (the
136
+ // scan-engine delegation made the local branch async, 2026-10-22).
137
137
  const binding = readRegistry(context.registryPath).find((entry) => entry.name === request.endpoint);
138
138
  if (binding !== undefined && isRemoteBinding(binding)) {
139
139
  return executeRemoteNav(request, binding, context);
140
140
  }
141
141
 
142
142
  try {
143
- const outcome = runNav(request, context);
143
+ const outcome = await runNav(request, context);
144
144
  if (!outcome.ok) {
145
145
  // Single render source for failures too: the factual message is
146
146
  // capability data; only the prefix, exit code, and hint wording are
@@ -153,18 +153,23 @@ export function executeNavCommand(
153
153
  }
154
154
 
155
155
  const envelope = projectNavEnvelope(outcome.result);
156
- // Diagnostics go to stderr in BOTH render modes (read's channel
157
- // discipline: stdout is the payload, stderr is the operational
158
- // channel). Without this, a Human-mode budget hit would be silent —
159
- // violating ADR 0018's loud contract. JSON keeps the diagnostics in
160
- // the envelope too.
156
+ // Consumer diagnostics go to stderr in BOTH render modes (read's channel
157
+ // discipline: stdout is the payload, stderr is the operational channel);
158
+ // JSON keeps them in the envelope too. Maintainer findings (E1,
159
+ // 2026-10-22) are human-face stderr only — the JSON face stays silent.
161
160
  const diagnosticLines = envelope.diagnostics
162
161
  .map((diagnostic) => `ukp nav: ${diagnostic.code}: ${diagnostic.message}`)
163
162
  .join("\n");
163
+ const maintainerLines = request.json
164
+ ? ""
165
+ : outcome.result.maintainerDiagnostics
166
+ .map((diagnostic) => `ukp nav: ${diagnostic.code}: ${diagnostic.message}`)
167
+ .join("\n");
168
+ const operational = [diagnosticLines, maintainerLines].filter((block) => block.length > 0).join("\n");
164
169
  return {
165
170
  exitCode: 0,
166
171
  stdout: request.json ? renderNavJson(envelope) : renderNavHuman(envelope),
167
- stderr: diagnosticLines.length > 0 ? `${diagnosticLines}\n` : "",
172
+ stderr: operational.length > 0 ? `${operational}\n` : "",
168
173
  };
169
174
  } catch (error) {
170
175
  if (error instanceof NavUsageError) {
@@ -66,7 +66,7 @@ const READ_EXIT_BY_ERROR_CLASS: Record<ReadErrorClass, number> = {
66
66
  * dispatch and its precedence stay command-side. */
67
67
  export const READ_SPEC: UkpCommandSpec = {
68
68
  name: "read",
69
- summary: "read an endpoint-scoped resource (exact path, ukp:// URI, or a docid from search results)",
69
+ summary: "read an endpoint-scoped resource (exact path, ukp:// URI, a docid from search results, or document-relative via --from)",
70
70
  group: "endpoint",
71
71
  description:
72
72
  "Read an endpoint-scoped resource reference from one Service endpoint (endpoint names come from 'ukp list'). "
@@ -91,8 +91,8 @@ export const RG_SPEC: UkpCommandSpec = {
91
91
  "",
92
92
  "Visibility:",
93
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",
94
+ " wiring) by default; pass -- --hidden after '--' to include them, and",
95
+ " -- --no-ignore to also lift .gitignore rules. --files lists every",
96
96
  " non-hidden file, including empty and binary ones.",
97
97
  "",
98
98
  ].join("\n"),
@@ -51,14 +51,15 @@ export const SEARCH_SPEC: UkpCommandSpec = {
51
51
  name: "search",
52
52
  summary: "search a Service's indexed content (provider-backed; use 'ukp rg' to grep raw files)",
53
53
  group: "endpoint",
54
- description: "Run atomic lexical search against the selected Service endpoints.",
55
- usage: "<query> [--limit <1-1000>] [--endpoint <name> ... | -g] [--recursive] [--json]",
54
+ description: "Run a lexical (text) search across the selected Service endpoints through each endpoint's configured provider.",
55
+ usage: "<query> [--limit <1-1000>] [--endpoint <name> ... | -g] [--recursive] [--timeout <seconds>] [--json]",
56
56
  arguments: [{ name: "query", help: "one non-empty search query; quote multi-word queries" }],
57
57
  options: [
58
58
  { flags: "--limit <1-1000>", help: "maximum results requested from each endpoint (default: 20)" },
59
59
  { flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
60
60
  { flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
61
61
  { flags: "--recursive", help: "include direct authority and context dependencies" },
62
+ { flags: "--timeout <seconds>", help: "provider spawn timeout for this search in whole seconds; 0 disables the guard (wait indefinitely - interrupt is then the only cancel); default 60 (UKP_PROVIDER_TIMEOUT_MS applies when the flag is absent)" },
62
63
  { flags: "--json", help: "write provider-native results to artifacts and print an envelope" },
63
64
  ],
64
65
  helpSuffix: [
@@ -86,6 +87,7 @@ interface SearchCommandOptions extends Record<string, unknown> {
86
87
  endpoint?: string[];
87
88
  g?: boolean;
88
89
  recursive?: boolean;
90
+ timeout?: string;
89
91
  json?: boolean;
90
92
  }
91
93
 
@@ -106,6 +108,20 @@ export function parseSearchArgs(args: readonly string[]): ParsedSearch {
106
108
  }
107
109
  }
108
110
 
111
+ // Owner ruling 2026-10-28 (TODO 20261028-1113 option 3): whole seconds,
112
+ // 0 = disable the guard (the classic-interactive "zero disables"
113
+ // grammar), positive wins over the env override.
114
+ let timeoutSeconds: number | undefined;
115
+ if (parsed.options.timeout !== undefined) {
116
+ if (!/^[0-9]+$/.test(parsed.options.timeout)) {
117
+ throw new KitUsageError("--timeout must be a non-negative integer number of seconds (0 disables the guard)");
118
+ }
119
+ timeoutSeconds = Number(parsed.options.timeout);
120
+ if (!Number.isSafeInteger(timeoutSeconds)) {
121
+ throw new KitUsageError("--timeout is out of range");
122
+ }
123
+ }
124
+
109
125
  for (const endpoint of parsed.options.endpoint ?? []) {
110
126
  if (explicit.includes(endpoint)) warnings.push(`duplicate endpoint '${endpoint}' ignored`);
111
127
  else explicit.push(endpoint);
@@ -123,7 +139,7 @@ export function parseSearchArgs(args: readonly string[]): ParsedSearch {
123
139
  throw new KitUsageError("--endpoint and -g cannot be used together");
124
140
  }
125
141
  return {
126
- request: { query, limit },
142
+ request: { query, limit, ...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}) },
127
143
  options: {
128
144
  explicitEndpoints: explicit.length > 0 ? explicit : undefined,
129
145
  global: parsed.options.g ?? false,