@catheadowl/ukp 0.1.1 → 0.2.1

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 CHANGED
@@ -70,7 +70,8 @@ predictable scope, and a single command surface. Use UKP when:
70
70
  a host door — when you want it reachable from other machines.
71
71
  - Want a read-only endpoint? Declare zero capabilities — an empty
72
72
  `[capabilities]` table still registers one (derived `read`/`nav` only).
73
- - Give agents JSON output (`--format json`), reference sidecars, and
73
+ - Give agents JSON output (`--json`; `ukp read` uses `--format json`),
74
+ reference sidecars, and
74
75
  `ukp://` handoff keys when they need machine-readable results.
75
76
 
76
77
  UKP owns endpoint names, Registry bindings, Client scope, capability
@@ -80,23 +81,24 @@ it does not rewrite provider config for you.
80
81
 
81
82
  ## Install
82
83
 
83
- ```bash
84
- npm install -g @catheadowl/ukp
85
- ukp --version
86
- ukp guide service
87
- ```
88
-
89
- One-off execution: `npx @catheadowl/ukp --version` (or `pnpm dlx` / `bunx`).
90
-
91
84
  Requirements:
92
85
 
93
- - Bun `1.3.14` or newer — the CLI runs on Bun.
86
+ - Bun `1.3.14` or newer — npm installs the `ukp` command, but the CLI runs
87
+ on Bun.
94
88
  - QMD on `PATH` only for the QMD-backed capabilities (`search/qmd`,
95
89
  `read/qmd`, `update/qmd`). QMD is an external tool maintained as a
96
90
  separate project; see its own release channel for installation. UKP
97
91
  installs and runs without it — those capabilities report as unavailable
98
92
  until `qmd` is available.
99
93
 
94
+ ```bash
95
+ npm install -g @catheadowl/ukp
96
+ ukp --version
97
+ ukp guide service
98
+ ```
99
+
100
+ One-off execution: `npx @catheadowl/ukp --version` (or `pnpm dlx` / `bunx`).
101
+
100
102
  ## First Run
101
103
 
102
104
  For a human or agent starting from a folder:
@@ -108,11 +110,12 @@ For a human or agent starting from a folder:
108
110
  4. `ukp register` adds the folder to the Host Registry (`~/.ukp/registry.toml`).
109
111
  5. `ukp inspect --endpoint <name>` shows the current binding, manifest, and
110
112
  provider availability.
111
- 6. `ukp search "<query>" --endpoint <name>` finds matches.
112
- 7. `ukp read --endpoint <name> <reference>` reads a result.
113
+ 6. `ukp nav`, `ukp rg`, and `ukp read` give the provider-free baseline.
114
+ 7. `ukp search "<query>" --endpoint <name>` finds indexed matches after
115
+ provider setup.
113
116
 
114
- No QMD installed? Skip step 6 — `nav`, `read`, `rg`, and `propose` work on any
115
- endpoint without it.
117
+ No QMD installed? Stop at step 6 — `nav`, `read`, `rg`, and `propose` work on
118
+ any endpoint without it.
116
119
 
117
120
  An agent can carry out the same flow on your behalf.
118
121
 
@@ -143,7 +146,7 @@ Registry commands:
143
146
  | Command | What it does |
144
147
  |---|---|
145
148
  | `ukp init service` | Creates a minimal `.ukp/service.toml`. |
146
- | `ukp register` / `ukp unregister --endpoint <name>` | Manages Host Registry bindings. `ukp register --url <url>` registers a remote endpoint — the name comes from its discovery document (asserted, not chosen), the identity is pinned TOFU-style (trust on first use), and self-signed certificates are pinned automatically. A **host door** url imports every endpoint behind it (`--select` narrows; re-running refreshes idempotently). |
149
+ | `ukp register` / `ukp unregister --endpoint <name>` | Manages Host Registry bindings. `ukp register --url <url>` registers a remote endpoint 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 <name>` stays an expected-name assertion on the declared name, and `--token <token>` stores the bearer credential in the binding. Identity is pinned TOFU-style (trust on first use), and self-signed certificates are pinned automatically. A **host door** url imports every endpoint behind it — the `--endpoint` assertion narrows the import to exactly that one endpoint, `--select` narrows to a subset, and re-running refreshes idempotently. |
147
150
  | `ukp list` | Lists registered endpoints with their declared capabilities. Door drift shows as a stderr note (`door <origin>: N unimported endpoint(s) …`) — importing stays an explicit gesture. |
148
151
 
149
152
  Operations commands:
@@ -163,6 +166,7 @@ Help commands:
163
166
  | `ukp guide service` | Provider-agnostic Service setup path. |
164
167
  | `ukp guide service qmd` | Provider-owned setup for the QMD provider. |
165
168
  | `ukp guide client` | How a workspace uses registered Services by default. |
169
+ | `ukp guide remote` | How to serve and consume endpoints across machines. |
166
170
  | `ukp guide propose` | The propose quickstart. |
167
171
 
168
172
  ## Remote in 60 seconds
@@ -170,16 +174,23 @@ Help commands:
170
174
  `ukp serve` exposes an endpoint over HTTP. Authentication is deny-by-default:
171
175
  serving with a `--host` beyond loopback requires `UKP_SERVE_TOKEN` (tokenless
172
176
  serving needs an explicit `--allow-anonymous` and is refused off loopback).
173
- The two fastest paths, from the consumer machine:
177
+ The two fastest paths — each block notes where its commands run:
174
178
 
175
179
  ```bash
176
- # SSH between two personal machines — an ephemeral tunnel per invocation:
177
- ukp register --url ssh://<host>:8570 --token <token> # once; TOFU + token stored
178
- ukp read --endpoint <name> notes/x.md # just works, like local
179
-
180
- # Native TLS on a bare IP — self-signed, pinned automatically at registration:
180
+ # From the consumer machine — SSH between two personal machines; the door is
181
+ # woken on demand, nothing to start on the host (prerequisites: ssh
182
+ # reachable + ukp on its PATH):
183
+ ukp register --url ssh://<host> # once; TOFU stored
184
+ ukp read --endpoint <name> notes/x.md # just works, like local
185
+
186
+ # Native TLS on a bare IP — self-signed, pinned automatically at registration.
187
+ # The serve line runs ON THE HOST (keep it under your process manager); the
188
+ # register line runs on the consumer machine:
181
189
  UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 --tls
182
- ukp register --url https://<ip>:8570 --token <token>
190
+ ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
191
+ # NAT/EIP cloud host (public IP on no NIC)? Name it explicitly — the persisted
192
+ # cert re-signs over the same key, so the pin (and every registration) survives:
193
+ # ukp serve … --tls --tls-san <public-ip>
183
194
  ```
184
195
 
185
196
  Remote endpoints take the same commands as local ones — search, read, nav,
@@ -192,8 +203,7 @@ and the hardening posture — lives in the
192
203
 
193
204
  - remote operation of `update`, and a formally specified network protocol
194
205
  beyond the current ukp-remote wire v1;
195
- - semantic search tier, an HTTP search API, query rewrite, reranking, or
196
- deduplication;
206
+ - semantic search tier, query rewrite, reranking, or deduplication;
197
207
  - full Client Scope with aliases, visibility, inheritance, or profiles;
198
208
  - automatic artifact browsing, cleanup, or "select result N" references;
199
209
  - standalone binary distribution.
package/bin/ukp.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // Self-locating launcher (D-085 completion, Windows-host wake 2026-09-20).
3
+ //
4
+ // npm's shims exec this file with node (the shebang above); bun's own bin
5
+ // links and `bunx` run it with bun itself. Either way the launcher finds a
6
+ // bun executable WITHOUT relying on the caller's PATH: non-interactive
7
+ // shells (ssh sessions, schtasks, service wrappers) see only the system
8
+ // PATH, while every standard install mode lands bun in a user-local dir.
9
+ // Candidate order: an explicit UKP_BUN override, the current interpreter
10
+ // when it already is bun, the standard install locations, then PATH.
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const cli = join(here, "..", "src", "cli.ts");
19
+ const isWindows = process.platform === "win32";
20
+ const runningUnderBun = typeof Bun !== "undefined" || Boolean(process.versions && process.versions.bun);
21
+
22
+ function bunCandidates() {
23
+ const exe = isWindows ? "bun.exe" : "bun";
24
+ const home = homedir();
25
+ const candidates = [];
26
+ if (typeof process.env.UKP_BUN === "string" && process.env.UKP_BUN.length > 0) {
27
+ candidates.push(process.env.UKP_BUN);
28
+ }
29
+ if (runningUnderBun) candidates.push(process.execPath);
30
+ // bun's official installer (same shape on every platform).
31
+ candidates.push(join(home, ".bun", "bin", exe));
32
+ if (isWindows) {
33
+ // `npm i -g bun` lands its shim in the npm user prefix — the same
34
+ // prefix root this package's own shim lives under.
35
+ candidates.push(join(here, "..", "..", "..", "..", exe));
36
+ // scoop installs stay user-local too.
37
+ candidates.push(join(home, "scoop", "shims", exe));
38
+ } else {
39
+ candidates.push(join(home, ".npm-global", "bin", exe));
40
+ // Homebrew's path is joined, not written as one literal: the release
41
+ // leak scan rightly flags literal home-directory-shaped paths, and this
42
+ // is a functional standard-location constant (same stance as the wake
43
+ // command's PATH prefix in src/capabilities/remote-client.ts).
44
+ candidates.push(join("/", "opt", "homebrew", "bin", "bun"));
45
+ candidates.push("/usr/local/bin/bun");
46
+ }
47
+ // PATH last, resolved ourselves dir-by-dir: a bare-name spawn would let
48
+ // the OS search with the PARENT's PATH on Windows (CreateProcess
49
+ // semantics), which this process cannot see or control.
50
+ const pathVar = process.env.PATH;
51
+ if (typeof pathVar === "string" && pathVar.length > 0) {
52
+ for (const dir of pathVar.split(isWindows ? ";" : ":")) {
53
+ if (dir.length > 0) candidates.push(join(dir, exe));
54
+ }
55
+ }
56
+ return candidates;
57
+ }
58
+
59
+ function fail(detail) {
60
+ process.stderr.write(
61
+ "ukp: no bun executable found (" + detail + ").\n"
62
+ + "ukp runs on bun — install it (https://bun.sh), or point UKP_BUN at the\n"
63
+ + "bun executable. Standard install locations (~/.bun/bin and the npm\n"
64
+ + "global prefix) are checked before PATH.\n",
65
+ );
66
+ process.exit(1);
67
+ }
68
+
69
+ const bunPath = bunCandidates().find((candidate) => existsSync(candidate));
70
+ if (bunPath === undefined) {
71
+ fail("looked for UKP_BUN, the standard install locations, and PATH");
72
+ }
73
+ const result = spawnSync(bunPath, [cli, ...process.argv.slice(2)], {
74
+ stdio: "inherit",
75
+ env: process.env,
76
+ });
77
+ if (result.error) {
78
+ // bunPath existed on disk a moment ago; a spawn failure here is exotic,
79
+ // but it must still land in the remedy, never a stack trace.
80
+ fail(`spawning '${bunPath}' failed: ${String(result.error.message ?? result.error)}`);
81
+ }
82
+ process.exit(result.status === null ? (result.signal !== null ? 130 : 1) : result.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catheadowl/ukp",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.14"
@@ -24,15 +24,14 @@
24
24
  "agent"
25
25
  ],
26
26
  "bin": {
27
- "ukp": "src/cli.ts"
27
+ "ukp": "bin/ukp.js"
28
28
  },
29
29
  "files": [
30
+ "bin",
30
31
  "src",
31
32
  "README.md",
32
33
  "LICENSE",
33
- "package.json",
34
- "bun.lock",
35
- "tsconfig.json"
34
+ "package.json"
36
35
  ],
37
36
  "scripts": {
38
37
  "dev": "bun run src/cli.ts",
@@ -57,7 +57,7 @@ export function resolveNavVisibility(capability: ManifestCapability): NavVisibil
57
57
  }
58
58
 
59
59
  /** Safety cap on collected route entries; the recursive truncation count
60
- * still walks everything so `[truncated: N]` totals stay exact. */
60
+ * still walks everything so the `(+N .md)` totals stay exact. */
61
61
  export const NAV_MAX_ENTRIES = 2000;
62
62
 
63
63
  /** Budget on per-entry description reads (ADR 0018): the expensive
@@ -614,7 +614,9 @@ export function renderNavHuman(envelope: NavEnvelope): string {
614
614
  }
615
615
  for (const entry of envelope.entries) {
616
616
  if (entry.kind === "folder") {
617
- const line = `[truncated: ${entry.omittedMarkdownCount}] ${entry.path}`;
617
+ // Self-explanatory unexpanded form: an unexpanded folder plus the exact
618
+ // recursive .md count inside (never "truncated" — nothing was cut).
619
+ const line = `${entry.path}/ (+${entry.omittedMarkdownCount} .md)`;
618
620
  lines.push(entry.description ? `${line} | ${entry.description}` : line);
619
621
  } else if (entry.descriptionOmitted === "budget") {
620
622
  lines.push(`${entry.path} | (description omitted: budget reached)`);
@@ -5,7 +5,7 @@ import { loadManifest } from "../config/manifest.ts";
5
5
  import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
6
6
  import { resolveScope } from "../scope.ts";
7
7
  import { buildQmdInvocation, defaultQmdCommand, isBareDocidReference, providerTimeoutMs, stripDocidHash, stripQmdHeader, toQmdGetArgument } from "./qmd.ts";
8
- import { isValidPin, pinFromSourceDocument, recoverRenamedResource, type RecoveryCandidate } from "./rename-recovery.ts";
8
+ import { isValidPin, pinFromSourceDocument, pinHashOf, recoverRenamedResource, type RecoveryCandidate } from "./rename-recovery.ts";
9
9
  import { isInsideRealRoot, splitEndpointRelativeSegments } from "../path-safety.ts";
10
10
 
11
11
  export interface ReadRequest {
@@ -30,6 +30,13 @@ export interface ReadRequest {
30
30
  * read-rename-recovery): the ukp-pin verification key for the miss-path
31
31
  * recovery descent. Absent = stale-unknown verification. */
32
32
  pin?: string;
33
+ /** Emission request: compute the ukp-pin for the read resource and return
34
+ * it as `result.pin`. The pin always covers the WHOLE file (LF-normalized)
35
+ * regardless of a `lines` window — computed before windowing in
36
+ * readTargetWithLines. Provider-tier references never carry it: the
37
+ * ukp-pin is a file-slot concept (same boundary as pin verification), and
38
+ * the command layer rejects the combination at parse time. */
39
+ emitPin?: boolean;
33
40
  /** The reference exactly as the caller wrote it before tolerant-tier
34
41
  * rewriting — used to locate the same-line ukp-pin annotation in the
35
42
  * --from source document (Q1 spelling). */
@@ -108,6 +115,10 @@ export interface ReadFailure {
108
115
  * purity); echoes, recovery metadata, and warnings are sideband fields. */
109
116
  export interface ReadSuccess {
110
117
  content: string;
118
+ /** ukp-pin for the read resource (`sha256-<64 hex>`), present only when
119
+ * the request asked for emission (`emitPin`). Whole-file content,
120
+ * LF-normalized — independent of any `lines` window. */
121
+ pin?: string;
111
122
  recovery?: ReadRecoveryMeta;
112
123
  /** Recovery-descent warnings (factual lines; adapter prefixes each). */
113
124
  recoveryWarnings: string[];
@@ -607,7 +618,13 @@ function attemptRenameRecovery(
607
618
  function formatRecoveryCandidates(candidates: readonly RecoveryCandidate[] | undefined): string {
608
619
  if (!candidates || candidates.length === 0) return "";
609
620
  const lines = candidates.map((candidate) => ` - ${candidate.path} (recovery candidate, ${candidate.verified})`);
610
- return `Rename recovery candidates:\n${lines.join("\n")}\n`;
621
+ // A mismatched candidate is the pin contract doing its job (path recycling
622
+ // guard) — the reader's next question is "what IS the current pin", so the
623
+ // emission flag answers it in place (no manual hashing, D-088).
624
+ const mismatchNote = candidates.some((candidate) => candidate.verified === "mismatch")
625
+ ? `\n (a mismatched ukp-pin means the candidate is not the pinned content; check the current content pin with 'ukp read --show-pin')`
626
+ : "";
627
+ return `Rename recovery candidates:\n${lines.join("\n")}${mismatchNote}\n`;
611
628
  }
612
629
 
613
630
  function readTargetWithLines(targetPath: string, request: ReadRequest): ReadOutcome {
@@ -629,6 +646,9 @@ function readTargetWithLines(targetPath: string, request: ReadRequest): ReadOutc
629
646
  }
630
647
  throw error;
631
648
  }
649
+ // Emission hashes the whole file BEFORE any windowing: a pin alongside a
650
+ // `--lines` read must still verify the whole resource.
651
+ const pin = request.emitPin === true ? `sha256-${pinHashOf(content)}` : undefined;
632
652
  const rangeResult = applyLineRange(content, request.lines);
633
653
  if (rangeResult.kind === "start-beyond-eof") {
634
654
  // Word by input origin: a URI #L<line> fragment never mentions --lines.
@@ -638,7 +658,10 @@ function readTargetWithLines(targetPath: string, request: ReadRequest): ReadOutc
638
658
  `${origin} ${rangeResult.start} is beyond the end of '${request.path}' (${rangeResult.lineCount} lines)`,
639
659
  );
640
660
  }
641
- return { ok: true, result: { content: rangeResult.content, recoveryWarnings: [] } };
661
+ return {
662
+ ok: true,
663
+ result: { content: rangeResult.content, ...(pin !== undefined ? { pin } : {}), recoveryWarnings: [] },
664
+ };
642
665
  }
643
666
 
644
667
  /** ADR 0021 core entry: runs the read capability and returns the structured
@@ -949,6 +972,9 @@ function renderReadSideband(result: ReadSuccess): string {
949
972
  const layerLabel = result.recovery.layer === "search-reanchor" ? "search re-anchor" : "git history";
950
973
  parts.push(`ukp read: recovered: '${result.recovery.from}' moved to '${result.recovery.recoveredTo}' (${layerLabel})`);
951
974
  }
975
+ if (result.pin !== undefined) {
976
+ parts.push(`ukp read: pin: <!-- ukp-pin: ${result.pin} -->`);
977
+ }
952
978
  for (const warning of result.recoveryWarnings) {
953
979
  parts.push(`ukp read: warning: ${warning}`);
954
980
  }
@@ -984,6 +1010,9 @@ export interface ReadEnvelope {
984
1010
  ok: boolean;
985
1011
  endpoint?: string;
986
1012
  reference: string;
1013
+ /** ukp-pin (`sha256-<64 hex>`) on success, when the request asked for
1014
+ * emission (`--show-pin`). */
1015
+ pin?: string;
987
1016
  recovered_to?: string;
988
1017
  error?: {
989
1018
  class: string;
@@ -1003,6 +1032,7 @@ export function projectReadEnvelope(request: ReadRequest, outcome: ReadOutcome):
1003
1032
  ok: true,
1004
1033
  ...(request.endpoint !== undefined ? { endpoint: request.endpoint } : {}),
1005
1034
  reference: request.path,
1035
+ ...(outcome.result.pin !== undefined ? { pin: outcome.result.pin } : {}),
1006
1036
  ...(outcome.result.recovery?.recoveredTo !== undefined
1007
1037
  ? { recovered_to: outcome.result.recovery.recoveredTo }
1008
1038
  : {}),