@catheadowl/ukp 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,456 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { basename, isAbsolute, join, resolve, sep } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+ import { ENDPOINT_NAME, loadManifest, type ManifestCapability } from "../config/manifest.ts";
15
+ import { resolveFileNativeCapability, unsupportedFileNativeProviderMessage } from "../config/file-native.ts";
16
+ import { localPathOf, readRegistry } from "../registry.ts";
17
+ import { resolveScope } from "../scope.ts";
18
+ import { acquireLock, LockBusyError, releaseLock } from "../fslock.ts";
19
+
20
+ // The slug profile (1–63 chars, strict lowercase ASCII slug) shares the
21
+ // endpoint-name grammar, so the Manifest pattern is reused deliberately.
22
+ export const PROPOSE_SLUG = ENDPOINT_NAME;
23
+
24
+ export const DEFAULT_PROPOSE_FOLDER = "inbox";
25
+
26
+ // Service-maintained frontmatter keys: always owned by the Service side of
27
+ // the upsert; never accepted from submitted content (injected values are
28
+ // stripped — see spec/file-provider.md, W1 decision).
29
+ const SERVICE_KEYS = new Set(["id", "status", "revision", "created", "updated"]);
30
+
31
+ export type ProposeStatus = "created" | "unchanged" | "updated";
32
+
33
+ export interface ProposeResult {
34
+ id: string;
35
+ status: ProposeStatus;
36
+ revision: number;
37
+ }
38
+
39
+ export class ProposeProviderError extends Error {
40
+ constructor(message: string, options?: ErrorOptions) {
41
+ super(message, options);
42
+ this.name = "ProposeProviderError";
43
+ }
44
+ }
45
+
46
+ export class ProposeBusyError extends ProposeProviderError {
47
+ constructor(id: string) {
48
+ // No provider location on any client-facing surface (probe 20260906).
49
+ super(`proposal '${id}' is locked by another submission; retry shortly`);
50
+ this.name = "ProposeBusyError";
51
+ }
52
+ }
53
+
54
+ export function assertProposeSlug(id: string): void {
55
+ if (!PROPOSE_SLUG.test(id)) {
56
+ throw new ProposeProviderError(
57
+ `invalid proposal id '${id}': expected 1-63 lowercase ASCII slug characters ([a-z0-9-])`,
58
+ );
59
+ }
60
+ }
61
+
62
+ // Resolves the file-provider folder for a propose capability declaration.
63
+ // The folder is Service-owned config, relative to the Service folder.
64
+ export function resolveProposeFolder(serviceFolder: string, capability: ManifestCapability): string {
65
+ if (capability.provider !== "file") {
66
+ throw new ProposeProviderError(unsupportedFileNativeProviderMessage("propose", capability.provider));
67
+ }
68
+ const rawFolder = capability.config?.folder;
69
+ let folder = DEFAULT_PROPOSE_FOLDER;
70
+ if (rawFolder !== undefined) {
71
+ if (typeof rawFolder !== "string" || rawFolder.length === 0) {
72
+ throw new ProposeProviderError("propose config 'folder' must be a non-empty string");
73
+ }
74
+ if (isAbsolute(rawFolder) || rawFolder.includes("\\") || rawFolder.includes(":")) {
75
+ throw new ProposeProviderError(
76
+ `propose config 'folder' must be a relative path inside the Service folder: ${rawFolder}`,
77
+ );
78
+ }
79
+ const segments = rawFolder.split("/");
80
+ // Segment charset = POSIX Portable Filename Character Set
81
+ // (A-Z a-z 0-9 . _ -) — one cited standard, deliberately no extra
82
+ // structural guards: a closed accepted set converges in one rename
83
+ // (the error message carries the full standard), and keeps the
84
+ // diagnose prompt one line. The folder is Service-owned config
85
+ // (trust domain); this check is lexical, not an identity profile.
86
+ if (
87
+ segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")
88
+ || !segments.every((segment) => /^[A-Za-z0-9._-]+$/.test(segment))
89
+ ) {
90
+ throw new ProposeProviderError(
91
+ `propose config 'folder' has unsafe path segments (each segment must use POSIX portable filename characters A-Z a-z 0-9 . _ -): ${rawFolder}`,
92
+ );
93
+ }
94
+ folder = segments.join(sep);
95
+ }
96
+ return join(serviceFolder, folder);
97
+ }
98
+
99
+ interface ParsedDocument {
100
+ submitterLines: string[];
101
+ body: string;
102
+ }
103
+
104
+ function unquote(value: string): string {
105
+ const trimmed = value.trim();
106
+ if (
107
+ (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2)
108
+ || (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
109
+ ) {
110
+ return trimmed.slice(1, -1);
111
+ }
112
+ return trimmed;
113
+ }
114
+
115
+ // Parses a proposal document. Submitted content may carry a frontmatter
116
+ // block; service-maintained keys are stripped (the submission never owns
117
+ // them), submitter-owned display keys pass through verbatim.
118
+ function parseDocument(content: string): ParsedDocument {
119
+ const lines = content.split("\n");
120
+ if (lines[0] === undefined || lines[0].trim() !== "---") {
121
+ return { submitterLines: [], body: content };
122
+ }
123
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
124
+ if (closing < 0) {
125
+ return { submitterLines: [], body: content };
126
+ }
127
+ const submitterLines: string[] = [];
128
+ for (const line of lines.slice(1, closing)) {
129
+ const key = line.split(":", 1)[0]?.trim() ?? "";
130
+ if (key !== "" && SERVICE_KEYS.has(key)) continue;
131
+ submitterLines.push(line);
132
+ }
133
+ const body = lines.slice(closing + 1).join("\n");
134
+ return { submitterLines, body };
135
+ }
136
+
137
+ function parseStoredDocument(content: string): { revision: number; created: string; document: ParsedDocument } {
138
+ const lines = content.split("\n");
139
+ if (lines[0]?.trim() !== "---") {
140
+ throw new ProposeProviderError("stored proposal is missing its frontmatter block");
141
+ }
142
+ const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
143
+ if (closing < 0) {
144
+ throw new ProposeProviderError("stored proposal has an unterminated frontmatter block");
145
+ }
146
+ let revision: number | undefined;
147
+ let created: string | undefined;
148
+ const submitterLines: string[] = [];
149
+ for (const line of lines.slice(1, closing)) {
150
+ const separator = line.indexOf(":");
151
+ if (separator < 0) continue;
152
+ const key = line.slice(0, separator).trim();
153
+ const value = unquote(line.slice(separator + 1));
154
+ if (key === "revision") {
155
+ const parsed = Number(value);
156
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
157
+ throw new ProposeProviderError(`stored proposal has an invalid revision: ${value}`);
158
+ }
159
+ revision = parsed;
160
+ } else if (key === "created") {
161
+ created = value;
162
+ } else if (key !== "" && !SERVICE_KEYS.has(key)) {
163
+ submitterLines.push(line);
164
+ }
165
+ }
166
+ if (revision === undefined || created === undefined) {
167
+ throw new ProposeProviderError("stored proposal is missing service-maintained frontmatter fields");
168
+ }
169
+ return { revision, created, document: { submitterLines, body: lines.slice(closing + 1).join("\n") } };
170
+ }
171
+
172
+ function renderDocument(
173
+ id: string,
174
+ submitterLines: readonly string[],
175
+ revision: number,
176
+ created: string,
177
+ updated: string,
178
+ body: string,
179
+ ): string {
180
+ const frontmatter = [
181
+ "---",
182
+ `id: ${id}`,
183
+ ...submitterLines,
184
+ "status: proposed",
185
+ `revision: ${revision}`,
186
+ `created: ${created}`,
187
+ `updated: ${updated}`,
188
+ "---",
189
+ ];
190
+ return `${frontmatter.join("\n")}\n${body}`;
191
+ }
192
+
193
+ function sameSubmission(left: ParsedDocument, right: ParsedDocument): boolean {
194
+ return left.body === right.body && left.submitterLines.join("\n") === right.submitterLines.join("\n");
195
+ }
196
+
197
+ function writeAtomic(targetPath: string, content: string): void {
198
+ const tempPath = `${targetPath}.tmp.${randomUUID()}`;
199
+ try {
200
+ const descriptor = openSync(tempPath, "wx", 0o600);
201
+ try {
202
+ writeFileSync(descriptor, content, "utf8");
203
+ fsyncSync(descriptor);
204
+ } finally {
205
+ closeSync(descriptor);
206
+ }
207
+ renameSync(tempPath, targetPath);
208
+ } catch (error) {
209
+ if (existsSync(tempPath)) unlinkSync(tempPath);
210
+ throw error;
211
+ }
212
+ }
213
+
214
+ export interface ProposeUpsertContext {
215
+ now?: () => Date;
216
+ }
217
+
218
+ // The file-provider upsert: sibling lock, in-lock re-read, three-state
219
+ // classification, temp-file + atomic replace discipline.
220
+ export function proposeUpsert(
221
+ serviceFolder: string,
222
+ capability: ManifestCapability,
223
+ id: string,
224
+ content: string,
225
+ context: ProposeUpsertContext = {},
226
+ ): ProposeResult {
227
+ assertProposeSlug(id);
228
+ const folder = resolveProposeFolder(serviceFolder, capability);
229
+ mkdirSync(folder, { recursive: true });
230
+ const targetPath = join(folder, `${id}.md`);
231
+ const lockPath = `${targetPath}.lock`;
232
+
233
+ let lockDescriptor: number;
234
+ try {
235
+ lockDescriptor = acquireLock(lockPath);
236
+ } catch (error) {
237
+ if (error instanceof LockBusyError) throw new ProposeBusyError(id);
238
+ throw new ProposeProviderError(`cannot acquire proposal lock: ${lockPath}`, { cause: error });
239
+ }
240
+
241
+ try {
242
+ const submission = parseDocument(content);
243
+ const timestamp = (context.now ?? (() => new Date()))().toISOString();
244
+
245
+ if (!existsSync(targetPath)) {
246
+ const document = renderDocument(id, submission.submitterLines, 1, timestamp, timestamp, submission.body);
247
+ writeAtomic(targetPath, document);
248
+ return { id, status: "created", revision: 1 };
249
+ }
250
+
251
+ const stored = parseStoredDocument(readFileSync(targetPath, "utf8"));
252
+ if (sameSubmission(submission, stored.document)) {
253
+ return { id, status: "unchanged", revision: stored.revision };
254
+ }
255
+
256
+ const document = renderDocument(
257
+ id,
258
+ submission.submitterLines,
259
+ stored.revision + 1,
260
+ stored.created,
261
+ timestamp,
262
+ submission.body,
263
+ );
264
+ writeAtomic(targetPath, document);
265
+ return { id, status: "updated", revision: stored.revision + 1 };
266
+ } finally {
267
+ releaseLock(lockPath, lockDescriptor);
268
+ }
269
+ }
270
+
271
+ export interface ProposeRequest {
272
+ endpoint: string;
273
+ id?: string;
274
+ file?: string;
275
+ json: boolean;
276
+ }
277
+
278
+ export interface ProposeContext {
279
+ currentDirectory: string;
280
+ registryPath: string;
281
+ now?: () => Date;
282
+ }
283
+
284
+ /** ADR 0021 error classification — factual messages; the `ukp propose:`
285
+ * prefix and exit codes are adapter renderings. */
286
+ export type ProposeErrorClass =
287
+ | "no-endpoint"
288
+ | "endpoint-name-mismatch"
289
+ | "capability-undeclared"
290
+ | "submission-file-unreadable"
291
+ | "provider-unsupported";
292
+
293
+ export interface ProposeFailure {
294
+ errorClass: ProposeErrorClass;
295
+ message: string;
296
+ }
297
+
298
+ /** ADR 0021 structured outcome. The upsert core (`ProposeResult`) was
299
+ * already structured; this wraps the endpoint/capability/file prelude. */
300
+ export type ProposeOutcome =
301
+ | { ok: true; result: ProposeResult }
302
+ | { ok: false; failure: ProposeFailure };
303
+
304
+ export class ProposeUsageError extends Error {
305
+ constructor(message: string) {
306
+ super(message);
307
+ this.name = "ProposeUsageError";
308
+ }
309
+ }
310
+
311
+ // Derives the default proposal id from a --file basename (extension
312
+ // stripped). The derived value still has to satisfy the slug profile.
313
+ export function deriveIdFromFileName(filePath: string): string {
314
+ const name = basename(filePath);
315
+ const dotIndex = name.lastIndexOf(".");
316
+ const id = dotIndex > 0 ? name.slice(0, dotIndex) : name;
317
+ if (!PROPOSE_SLUG.test(id)) {
318
+ throw new ProposeUsageError(
319
+ `proposal id derived from '${name}' is not a valid slug (1-63 lowercase ASCII [a-z0-9-]); pass --id explicitly`,
320
+ );
321
+ }
322
+ return id;
323
+ }
324
+
325
+ export function renderProposeHuman(result: ProposeResult): string {
326
+ // Probe 20260906-promoted shape: no provider location on the human
327
+ // surface; the --json envelope carries the endpoint name instead.
328
+ return `proposal ${result.id} ${result.status} (revision ${result.revision})\n`;
329
+ }
330
+
331
+ /** The `ukp.propose.v1` envelope object — single source for the CLI
332
+ * `--json` rendering and the serve `PUT /v1/propose/{id}` response (W8):
333
+ * the wire response IS the local envelope. */
334
+ export function proposeEnvelope(endpoint: string, result: ProposeResult): {
335
+ schema: string;
336
+ command: string;
337
+ capability: string;
338
+ endpoint: string;
339
+ id: string;
340
+ status: ProposeStatus;
341
+ revision: number;
342
+ } {
343
+ return {
344
+ schema: "ukp.propose.v1",
345
+ command: "propose",
346
+ capability: "propose",
347
+ endpoint,
348
+ id: result.id,
349
+ status: result.status,
350
+ revision: result.revision,
351
+ };
352
+ }
353
+
354
+ export function renderProposeJson(endpoint: string, result: ProposeResult): string {
355
+ return `${JSON.stringify(proposeEnvelope(endpoint, result), null, 2)}\n`;
356
+ }
357
+
358
+ /** Resolves the proposal id for a request (explicit `--id` or derived from
359
+ * the --file basename) — shared by the local run and the remote branch
360
+ * (W8). Usage violations throw `ProposeUsageError`. */
361
+ export function proposalIdOf(request: Pick<ProposeRequest, "id" | "file">): string {
362
+ // Single stable channel (decision 2026-09-06): --file is the only content
363
+ // source. A stdin channel would need unreliable isTTY-based selection
364
+ // (agent harnesses spawn with piped stdin), and the canonical propose
365
+ // loop is read → edit local file → resubmit anyway.
366
+ if (request.file === undefined) {
367
+ throw new ProposeUsageError("propose requires --file <path> (the proposal content source)");
368
+ }
369
+ const id = request.id ?? deriveIdFromFileName(request.file);
370
+ if (!PROPOSE_SLUG.test(id)) {
371
+ throw new ProposeUsageError(
372
+ `invalid proposal id '${id}': expected 1-63 lowercase ASCII slug characters ([a-z0-9-])`,
373
+ );
374
+ }
375
+ return id;
376
+ }
377
+
378
+ /** Reads the --file submission content relative to the caller's working
379
+ * directory — shared by the local run and the remote branch (W8); an
380
+ * unreadable file classifies as submission-file-unreadable. */
381
+ export function readSubmissionFile(
382
+ file: string,
383
+ currentDirectory: string,
384
+ ): { content: string } | { failure: ProposeFailure } {
385
+ const filePath = resolve(currentDirectory, file);
386
+ try {
387
+ return { content: readFileSync(filePath, "utf8") };
388
+ } catch (error) {
389
+ const detail = error instanceof Error && "code" in error && error.code === "ENOENT"
390
+ ? "file not found"
391
+ : error instanceof Error ? error.message : String(error);
392
+ return {
393
+ failure: {
394
+ errorClass: "submission-file-unreadable",
395
+ message: `cannot read --file '${file}': ${detail}`,
396
+ },
397
+ };
398
+ }
399
+ }
400
+
401
+ /** ADR 0021 core entry: validates the prelude (id slug, scope, capability
402
+ * declaration, submission file) and runs the idempotent upsert. Usage
403
+ * violations throw `ProposeUsageError`; provider/config violations throw
404
+ * `ProposeProviderError`; scope/manifest failures throw their typed errors —
405
+ * all mapped by the surface adapter. The `json` request flag is a surface
406
+ * concern: the outcome is always structured. */
407
+ export function runPropose(request: ProposeRequest, context: ProposeContext): ProposeOutcome {
408
+ const id = proposalIdOf(request);
409
+
410
+ const registry = readRegistry(context.registryPath);
411
+ const scope = resolveScope({
412
+ currentDirectory: context.currentDirectory,
413
+ registry,
414
+ explicitEndpoints: [request.endpoint],
415
+ global: false,
416
+ });
417
+ const [binding] = scope.bindings;
418
+ if (!binding) {
419
+ return { ok: false, failure: { errorClass: "no-endpoint", message: "no endpoint selected" } };
420
+ }
421
+ // Remote bindings never reach this local path in the CLI composition (the
422
+ // command adapter routes them to the remote branch, W8); localPathOf
423
+ // throwing for a remote binding is the backstop (W6 nav/rg precedent).
424
+
425
+ const service = loadManifest(localPathOf(binding));
426
+ if (service.effectiveName !== binding.name) {
427
+ return {
428
+ ok: false,
429
+ failure: {
430
+ errorClass: "endpoint-name-mismatch",
431
+ message: `endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
432
+ },
433
+ };
434
+ }
435
+
436
+ // Propose is file-native but write-side (ADR 0016): an explicit
437
+ // declaration is mandatory; resolution goes through the shared table.
438
+ const resolved = resolveFileNativeCapability(service.manifest, "propose");
439
+ if (!resolved) {
440
+ return {
441
+ ok: false,
442
+ failure: {
443
+ errorClass: "capability-undeclared",
444
+ message: `endpoint '${binding.name}' does not declare the propose capability`,
445
+ },
446
+ };
447
+ }
448
+
449
+ const submission = readSubmissionFile(request.file!, context.currentDirectory);
450
+ if ("failure" in submission) {
451
+ return { ok: false, failure: submission.failure };
452
+ }
453
+
454
+ const result = proposeUpsert(service.folder, resolved.capability, id, submission.content, { now: context.now });
455
+ return { ok: true, result };
456
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Shared QMD provider helpers for UKP adapters.
3
+ *
4
+ * QMD owns collection, index, ranking, ignore, and config. UKP only locates the
5
+ * executable, invokes provider-owned commands, and normalizes output to UKP's
6
+ * output discipline. These helpers are the single place for that plumbing.
7
+ */
8
+
9
+ /**
10
+ * A bare docid handoff key is a 6-hex content fingerprint — the first 6 chars of
11
+ * QMD's content SHA-256 — optionally carrying a `:line` suffix (ADR 0011). QMD
12
+ * emits the fingerprint with a leading `#` (`#abc123`); `search` strips it on
13
+ * the surface so the token is verbatim-copyable, and `read` re-adds it before
14
+ * constructing `qmd get #docid[:line]`. A leading `#` is a shell comment and
15
+ * would silently truncate the reference, so it never appears on the
16
+ * UKP surface.
17
+ */
18
+ export function isBareDocidReference(reference: string): boolean {
19
+ return /^[a-f0-9]{6}(:\d+)?$/.test(reference);
20
+ }
21
+
22
+ /** Strip QMD's leading `#` from a docid token (`#abc123` → `abc123`). */
23
+ export function stripDocidHash(docid: string): string {
24
+ return docid.startsWith("#") ? docid.slice(1) : docid;
25
+ }
26
+
27
+ /** Test whether a token is a bare 6-hex QMD docid body (`[a-f0-9]{6}`, ADR 0011). */
28
+ export function isDocidBody(token: string): boolean {
29
+ return /^[a-f0-9]{6}$/.test(token);
30
+ }
31
+
32
+ /**
33
+ * Build the `qmd get` argument for a reference.
34
+ *
35
+ * A bare docid handoff key has its `#` re-added so QMD resolves it by content
36
+ * fingerprint exactly. Any other reference — a weak name/path suffix or a
37
+ * `qmd://` provider reference — is forwarded verbatim. An optional line range is
38
+ * appended as `:start[:count]`, matching QMD's `path:from:count` suffix.
39
+ */
40
+ export function toQmdGetArgument(
41
+ reference: string,
42
+ lines?: { start: number; count?: number },
43
+ ): string {
44
+ const base = isBareDocidReference(reference) ? `#${reference}` : reference;
45
+ if (!lines) return base;
46
+ return lines.count === undefined
47
+ ? `${base}:${lines.start}`
48
+ : `${base}:${lines.start}:${lines.count}`;
49
+ }
50
+
51
+ export function defaultQmdCommand(): string[] | undefined {
52
+ const executable = Bun.which("qmd") ?? Bun.which("qmd.ps1") ?? Bun.which("qmd.cmd");
53
+ if (!executable) return undefined;
54
+ const lower = executable.toLowerCase();
55
+ if (lower.endsWith(".ps1")) {
56
+ return [Bun.which("powershell.exe") ?? "powershell.exe", "-NoProfile", "-File", executable];
57
+ }
58
+ // npm's global bin shims on Windows (qmd.cmd) are batch scripts,
59
+ // not executable images — CreateProcess cannot start them directly and
60
+ // spawnSync returns result.error. Route them through cmd.exe, same wrapper
61
+ // precedent as the .ps1 branch above. The wrapper prefix is detected by
62
+ // buildQmdInvocation, which re-assembles the whole provider call as one
63
+ // cmd-escaped /c payload — callers must never append raw arguments after a
64
+ // cmd.exe wrapper themselves (cmd re-parses the joined command line: an
65
+ // unquoted `&` in a search query would split the command).
66
+ if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
67
+ return [Bun.which("cmd.exe") ?? "cmd.exe", "/d", "/s", "/c", executable];
68
+ }
69
+ return [executable];
70
+ }
71
+
72
+ function envTimeoutMs(name: string, fallbackMs: number): number {
73
+ const raw = process.env[name];
74
+ if (raw !== undefined && /^\d+$/.test(raw) && Number(raw) >= 1_000) return Number(raw);
75
+ return fallbackMs;
76
+ }
77
+
78
+ /**
79
+ * Zero-output hang guard (miss-path-scan performance audit, 2026-09-10): every
80
+ * provider spawn must carry a timeout so a hung provider lands in a classified
81
+ * failure instead of silence — agent consumers have zero tolerance for
82
+ * unresponsive commands (BB-004/BB-006 measured two 120s timeouts → detour).
83
+ * `UKP_PROVIDER_TIMEOUT_MS` overrides for tests and slow machines; minimum
84
+ * 1s, default 60s. spawnSync kills with SIGTERM on expiry — callers classify
85
+ * `signal === "SIGTERM"` alongside their failure branches.
86
+ */
87
+ export function providerTimeoutMs(): number {
88
+ return envTimeoutMs("UKP_PROVIDER_TIMEOUT_MS", 60_000);
89
+ }
90
+
91
+ /**
92
+ * Update runs `qmd update`, which may legitimately index a large corpus —
93
+ * its ceiling is correspondingly higher (default 600s, overridable via
94
+ * `UKP_UPDATE_TIMEOUT_MS`).
95
+ */
96
+ export function updateTimeoutMs(): number {
97
+ return envTimeoutMs("UKP_UPDATE_TIMEOUT_MS", 600_000);
98
+ }
99
+
100
+ /** Detect a defaultQmdCommand cmd.exe wrapper prefix (…, "/c", <exe>). */
101
+ function isCmdWrapper(command: readonly string[]): boolean {
102
+ if (command.length < 5) return false;
103
+ const first = command[0]!.toLowerCase();
104
+ return (first.endsWith("cmd.exe") || first === "cmd")
105
+ && command.slice(1, -1).includes("/c");
106
+ }
107
+
108
+ /**
109
+ * Build the spawn file/args (and Windows quoting mode) for one provider
110
+ * invocation.
111
+ *
112
+ * For a plain command the provider arguments are appended verbatim. For a
113
+ * cmd.exe wrapper the whole call after `/c` must become ONE argv
114
+ * entry carrying the canonical cmd pattern: every element quoted with
115
+ * internal quotes doubled, the whole payload wrapped in one extra outer
116
+ * quote pair, spawned with `windowsVerbatimArguments: true` so the runtime
117
+ * does not re-quote/re-escape it. With `/s`, cmd strips exactly the first and
118
+ * last (outer) quote, leaving `"exe" "arg1" "arg2"…` — metacharacters
119
+ * (`& | < > ^ ( )`), spaces, and quotes in arbitrary arguments (search
120
+ * queries carry user text) are neutralized. Verified empirically against the
121
+ * real npm shim: without verbatim mode Bun re-quotes the payload and cmd
122
+ * sees a mangled command name.
123
+ *
124
+ * Known residual: cmd expands `%VAR%` for existing variables even inside
125
+ * double quotes; a literal percent query on a machine defining that variable
126
+ * is altered — accepted edge, no reliable cmd escaping exists for it.
127
+ */
128
+ export function buildQmdInvocation(
129
+ command: readonly string[],
130
+ providerArgs: readonly string[],
131
+ ): { file: string; args: string[]; verbatim: boolean } {
132
+ if (!isCmdWrapper(command)) {
133
+ return { file: command[0]!, args: [...command.slice(1), ...providerArgs], verbatim: false };
134
+ }
135
+ const cIndex = command.lastIndexOf("/c");
136
+ const inner = [command[cIndex + 1]!, ...providerArgs]
137
+ .map((part) => `"${part.replace(/"/g, '""')}"`)
138
+ .join(" ");
139
+ return {
140
+ file: command[0]!,
141
+ args: [...command.slice(1, cIndex + 1), `"${inner}"`],
142
+ verbatim: true,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Strip QMD `get` provider header so `ukp read` stdout starts at the body.
148
+ *
149
+ * QMD prints a provider location header (first line, `qmd://<collection>/<path>`),
150
+ * zero or more metadata lines, a `---` separator, then the body. The adapter
151
+ * strips that prefix only when the leading region really is a QMD header — the
152
+ * first non-empty line before the separator starts with `qmd://`. A body-only
153
+ * output that merely contains a `---` divider is left intact. CRLF line endings
154
+ * are normalized to LF so `ukp read` stdout is line-ending-stable.
155
+ */
156
+ export function stripQmdHeader(output: string): string {
157
+ const normalized = output.replace(/\r\n/g, "\n");
158
+ const lines = normalized.split("\n");
159
+ let sepIndex = -1;
160
+ for (let i = 0; i < lines.length; i++) {
161
+ if (lines[i]!.trim() === "---") {
162
+ sepIndex = i;
163
+ break;
164
+ }
165
+ }
166
+ if (sepIndex <= 0) return normalized;
167
+ const firstLine = lines.slice(0, sepIndex).find((line) => line.trim() !== "");
168
+ if (!firstLine?.trimStart().startsWith("qmd://")) return normalized;
169
+ let start = sepIndex + 1;
170
+ while (start < lines.length && lines[start]!.trim() === "") start++;
171
+ const body = lines.slice(start).join("\n").replace(/\s+$/, "");
172
+ if (!body) return "";
173
+ return `${body}\n`;
174
+ }