@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,221 @@
1
+ import {
2
+ executeKitCommand,
3
+ parseKitArgs,
4
+ renderKitHelp,
5
+ KitUsageError,
6
+ type KitCommandResult,
7
+ type KitParsed,
8
+ type UkpCommandSpec,
9
+ } from "./kit.ts";
10
+ import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
11
+
12
+ export interface ServeCommandContext {
13
+ currentDirectory: string;
14
+ registryPath: string;
15
+ qmdCommand?: readonly string[];
16
+ /** Overrides `process.env.UKP_SERVE_TOKEN` for tests. */
17
+ tokens?: readonly string[];
18
+ }
19
+
20
+ /** `UKP_SERVE_TOKEN=a,b,c` (RQ-16): comma-separated, trimmed, empty entries
21
+ * dropped — any listed token authorizes. */
22
+ function parseServeTokens(raw: string | undefined): string[] {
23
+ if (raw === undefined) return [];
24
+ return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
25
+ }
26
+
27
+ /** Single-source command spec (ADR 0024): long-running host command — the
28
+ * Bun.serve listener keeps the process alive after runCli returns its exit
29
+ * code; SIGINT/SIGTERM stop the listener so the process exits with it.
30
+ * `--endpoint` is optional (W7 / ADR-REM-004): present = the original
31
+ * single-endpoint mode; absent = host door mode serving the whole registry
32
+ * over `/e/<name>/` routing. */
33
+ export const SERVE_SPEC: UkpCommandSpec = {
34
+ name: "serve",
35
+ summary: "serve one endpoint — or the whole registry as a host door — over HTTP for remote UKP clients",
36
+ group: "operations",
37
+ description:
38
+ "Expose a registered endpoint over HTTP using the ukp-remote wire, or — without --endpoint — serve every local endpoint as one host door routed by name.",
39
+ usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls | --tls-cert <pem> --tls-key <pem>]",
40
+ options: [
41
+ { flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
42
+ { flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
43
+ { flags: "--port <n>", help: "listen port (default 8570)" },
44
+ { flags: "--tls", help: "serve HTTPS with a self-signed identity (auto-generated under .ukp/tls/, SAN covers this host's addresses; clients pin it at registration)" },
45
+ { flags: "--tls-cert <pem>", help: "TLS certificate (chain) PEM path — Let's Encrypt, mkcert, or a private CA; pair with --tls-key" },
46
+ { flags: "--tls-key <pem>", help: "TLS private key PEM path; pair with --tls-cert" },
47
+ { flags: "--allow-anonymous", help: "permit tokenless access on loopback (local testing, or an ssh-forwarded host door where SSH carries encryption and auth; reverse-proxy deployments still require UKP_SERVE_TOKEN)" },
48
+ ],
49
+ helpSuffix: [
50
+ "",
51
+ "Wire (ukp-remote v1), single endpoint (--endpoint <name>):",
52
+ ` GET ${DISCOVERY_PATH} discovery document (manifest projection,`,
53
+ " protocol version, instance identity)",
54
+ " POST /v1/search {query, limit} -> ukp.search.v1 envelope",
55
+ " GET /v1/read?ref=…|uri=… endpoint-relative ref or ukp:// URI",
56
+ " GET /v1/nav?path=&depth= markdown route view (ukp.nav.v1)",
57
+ " GET /v1/rg?query=… lexical search (ukp.rg.v1, ukp_uri handoff)",
58
+ "",
59
+ "Wire (ukp-remote v1), host door (no --endpoint; ADR-REM-004):",
60
+ ` GET ${DISCOVERY_PATH} door document (scope:"host", endpoint roster)`,
61
+ " GET /e/<name>/.well-known/ukp.json per-endpoint document (same shape",
62
+ " as single-endpoint mode; TOFU pins anchor here)",
63
+ " POST /e/<name>/v1/search capabilities routed by endpoint name",
64
+ " GET /e/<name>/v1/read|nav|rg",
65
+ "",
66
+ "Auth (deny by default, RQ-18):",
67
+ " Serving requires UKP_SERVE_TOKEN (comma-separate several: alice,bob;",
68
+ " any listed token authorizes). Tokenless serving is an explicit opt-in:",
69
+ " --allow-anonymous, loopback binds only. The discovery document stays",
70
+ " public. A reverse proxy on the same host forwards from the public side",
71
+ " to the loopback bind, so proxy deployments treat the token as",
72
+ " mandatory — serve cannot see past its own bind address.",
73
+ "",
74
+ " Loopback-without-token covers testing/dogfood AND the ssh door",
75
+ " deployment: bind the door on the remote host's loopback and let ssh",
76
+ " forwarding carry encryption and authentication (clients register",
77
+ " 'ukp register --url ssh://<host>' — zero tokens, docker",
78
+ " DOCKER_HOST=ssh:// posture). It is not the way to consume a",
79
+ " same-machine endpoint — register its local path instead.",
80
+ " TLS (W5'): pass --tls to serve HTTPS with a self-signed identity",
81
+ " (generated under .ukp/tls/ — under the registry directory in door",
82
+ " mode —, SAN covers this host's addresses; remote clients TOFU-pin it",
83
+ " at registration and refresh by re-registering), or --tls-cert/--tls-key",
84
+ " for your own certificate (Let's Encrypt — IP certs available since",
85
+ " 2026-01 —, mkcert, a private CA). Plain HTTP remains loopback-only by",
86
+ " admission; public exposure needs TLS or the ssh:// transport.",
87
+ "",
88
+ ].join("\n"),
89
+ };
90
+
91
+ export interface ParsedServe {
92
+ /** Present = single-endpoint mode; absent = host door mode (W7). */
93
+ endpoint?: string;
94
+ host: string;
95
+ port: number;
96
+ }
97
+
98
+ function toParsedServe(parsed: KitParsed): ParsedServe {
99
+ const options = parsed.options as { host?: string; port?: string; endpoint?: string };
100
+ const endpoint = options.endpoint;
101
+ if (endpoint !== undefined && endpoint.length === 0) {
102
+ throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
103
+ }
104
+ const host = options.host ?? "127.0.0.1";
105
+ const port = Number(options.port ?? "8570");
106
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
107
+ throw new KitUsageError("--port must be an integer between 1 and 65535");
108
+ }
109
+ return { ...(endpoint !== undefined ? { endpoint } : {}), host, port };
110
+ }
111
+
112
+ export function parseServeArgs(args: readonly string[]): ParsedServe {
113
+ return toParsedServe(parseKitArgs(SERVE_SPEC, args));
114
+ }
115
+
116
+ function isLoopback(host: string): boolean {
117
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
118
+ }
119
+
120
+ export function renderServeBanner(info: ServeInfo): string {
121
+ if (info.mode === "door") {
122
+ return [
123
+ `serving host door (ukp-remote v1)`,
124
+ ` listening: ${info.url}`,
125
+ ` discovery: ${info.url}${DISCOVERY_PATH} (host door)`,
126
+ ` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none — register endpoints on this host)"}`,
127
+ ` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
128
+ ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
129
+ ...(info.tls !== undefined
130
+ ? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
131
+ : []),
132
+ "",
133
+ ].join("\n");
134
+ }
135
+ return [
136
+ `serving endpoint '${info.endpoint}' (ukp-remote v1)`,
137
+ ` listening: ${info.url}`,
138
+ ` discovery: ${info.url}${DISCOVERY_PATH}`,
139
+ ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
140
+ ...(info.tls !== undefined
141
+ ? [` tls: ${info.tls.source === "operator" ? "operator certificate" : `self-signed identity (${info.tls.source})`} ${info.tls.pin} (SAN: ${info.tls.san})`]
142
+ : []),
143
+ "",
144
+ ].join("\n");
145
+ }
146
+
147
+ /** Auth admission (RQ-18, web-standard deny-by-default): a token is required
148
+ * to start serving; tokenless access is an explicit, loopback-only opt-in.
149
+ * A reverse proxy on the same host forwards from the public side to the
150
+ * loopback bind, so the proxy recipes treat UKP_SERVE_TOKEN as mandatory —
151
+ * serve itself cannot see past its own bind address. */
152
+ export function serveAuthDecision(
153
+ host: string,
154
+ tokens: readonly string[],
155
+ allowAnonymous: boolean,
156
+ ): { ok: true; authRequired: boolean } | { ok: false; reason: string } {
157
+ if (tokens.length > 0) return { ok: true, authRequired: true };
158
+ if (!isLoopback(host)) {
159
+ return {
160
+ ok: false,
161
+ reason: `refusing to serve on non-loopback ${host} without a token: set UKP_SERVE_TOKEN`,
162
+ };
163
+ }
164
+ if (allowAnonymous) return { ok: true, authRequired: false };
165
+ return {
166
+ ok: false,
167
+ reason: "serve requires authentication: set UKP_SERVE_TOKEN, or pass --allow-anonymous for tokenless loopback testing (a reverse proxy on the same host still requires UKP_SERVE_TOKEN)",
168
+ };
169
+ }
170
+
171
+ export function executeServeCommand(
172
+ args: readonly string[],
173
+ context: ServeCommandContext,
174
+ ): KitCommandResult {
175
+ return executeKitCommand(SERVE_SPEC, args, (parsed) => {
176
+ const { endpoint, host, port } = toParsedServe(parsed);
177
+ const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
178
+ const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
179
+ // TLS flag family (W5'): --tls and --tls-cert/--tls-key are mutually
180
+ // exclusive; the explicit pair must arrive complete.
181
+ if (options.tls === true && (options.tlsCert !== undefined || options.tlsKey !== undefined)) {
182
+ throw new KitUsageError("--tls and --tls-cert/--tls-key are mutually exclusive");
183
+ }
184
+ if (options.tls !== true && (options.tlsCert !== undefined) !== (options.tlsKey !== undefined)) {
185
+ throw new KitUsageError("--tls-cert and --tls-key are used together");
186
+ }
187
+ const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
188
+ const decision = serveAuthDecision(host, tokens, allowAnonymous);
189
+ if (!decision.ok) {
190
+ const target = endpoint === undefined ? "host door" : `'${endpoint}'`;
191
+ throw new Error(`refusing to serve ${target}: ${decision.reason}`);
192
+ }
193
+ const { server, info } = startUkpServer({
194
+ currentDirectory: context.currentDirectory,
195
+ registryPath: context.registryPath,
196
+ qmdCommand: context.qmdCommand,
197
+ host,
198
+ port,
199
+ ...(endpoint !== undefined ? { endpointName: endpoint } : {}),
200
+ ...(tokens.length > 0 ? { tokens } : {}),
201
+ ...(options.tls === true
202
+ ? { tls: { mode: "self-signed" as const } }
203
+ : options.tlsCert !== undefined && options.tlsKey !== undefined
204
+ ? { tls: { mode: "certificates" as const, certPath: options.tlsCert, keyPath: options.tlsKey } }
205
+ : {}),
206
+ });
207
+ const stop = () => {
208
+ console.error(`ukp serve: stopped (${info.url})`);
209
+ server.stop(true);
210
+ };
211
+ process.once("SIGINT", stop);
212
+ process.once("SIGTERM", stop);
213
+ // Exit code 0 flows out through runCli; the server listener keeps the
214
+ // process alive until the signal handler stops it.
215
+ return { exitCode: 0, stdout: renderServeBanner(info), stderr: "" };
216
+ });
217
+ }
218
+
219
+ export function renderServeHelp(): string {
220
+ return renderKitHelp(SERVE_SPEC);
221
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ executeKitCommand,
3
+ parseKitArgs,
4
+ renderKitHelp,
5
+ type KitCommandResult,
6
+ type KitParsed,
7
+ type UkpCommandSpec,
8
+ } from "./kit.ts";
9
+ import {
10
+ runUpdate,
11
+ renderUpdateHuman,
12
+ type ParsedUpdate,
13
+ type UpdateAggregateStatus,
14
+ type UpdateContext,
15
+ } from "../capabilities/update.ts";
16
+
17
+ export type { UpdateContext } from "../capabilities/update.ts";
18
+
19
+ /** CLI-owned command result shape (ADR 0021). */
20
+ export interface UpdateCommandResult extends KitCommandResult {}
21
+
22
+ /** Aggregate classification → exit code (ADR 0021). */
23
+ const UPDATE_EXIT_BY_AGGREGATE: Record<UpdateAggregateStatus, number> = {
24
+ "succeeded": 0,
25
+ "failed": 1,
26
+ "no-success": 1,
27
+ "cancelled": 130,
28
+ };
29
+
30
+ /** Single-source command spec (ADR 0024 pilot command): `summary` feeds the
31
+ * root help via cli.ts; `usage` feeds both the help header and the
32
+ * usage-error line; the scope family, singleton detection, help-intent
33
+ * triage, and catch chain live in kit.ts. */
34
+ export const UPDATE_SPEC: UkpCommandSpec = {
35
+ name: "update",
36
+ summary: "trigger provider-owned Service maintenance",
37
+ group: "operations",
38
+ description: "Trigger provider-owned maintenance for selected Service endpoints.",
39
+ usage: "[--endpoint <name> ... | -g]",
40
+ scope: {
41
+ endpointHelp: "update one endpoint; repeat to update multiple endpoints",
42
+ globalHelp: "update every endpoint in the Host Registry; takes no value",
43
+ },
44
+ helpSuffix: [
45
+ "",
46
+ "Scope:",
47
+ " With no --endpoint or -g, the workspace default scope applies: the",
48
+ " Client Config's default endpoints ('ukp inspect' shows the resolved",
49
+ " scope). Without a Client Config, update needs an explicit scope:",
50
+ " pass --endpoint <name> or -g. -g selects every endpoint in the Host",
51
+ " Registry and cannot be combined with --endpoint. Maintenance runs",
52
+ " the provider's own update step (QMD: 'qmd update'), updating what",
53
+ " its index covers; the endpoint selector never maps to a provider",
54
+ " collection.",
55
+ "",
56
+ ].join("\n"),
57
+ };
58
+
59
+ function toParsedUpdate(parsed: KitParsed): ParsedUpdate {
60
+ return {
61
+ options: {
62
+ explicitEndpoints: parsed.scope.explicitEndpoints,
63
+ global: parsed.scope.global,
64
+ },
65
+ warnings: parsed.scope.warnings,
66
+ };
67
+ }
68
+
69
+ export function parseUpdateArgs(args: readonly string[]): ParsedUpdate {
70
+ return toParsedUpdate(parseKitArgs(UPDATE_SPEC, args));
71
+ }
72
+
73
+ /** CLI composition of the structured outcome (ADR 0021); kept as the test
74
+ * entry so suites exercise the exact adapter path the bin takes. */
75
+ export function executeUpdate(parsed: ParsedUpdate, context: UpdateContext): UpdateCommandResult {
76
+ const result = runUpdate(parsed, context);
77
+ const view = renderUpdateHuman(result);
78
+ return {
79
+ exitCode: UPDATE_EXIT_BY_AGGREGATE[result.aggregate],
80
+ stdout: view.body,
81
+ stderr: view.diagnostics,
82
+ };
83
+ }
84
+
85
+ export function executeUpdateCommand(args: readonly string[], context: UpdateContext): UpdateCommandResult {
86
+ return executeKitCommand(
87
+ UPDATE_SPEC,
88
+ args,
89
+ (parsed) => executeUpdate(toParsedUpdate(parsed), context),
90
+ {
91
+ scopeErrorHint:
92
+ "Hint: run 'ukp list' to inspect registrations, 'ukp register' from a Service folder to add one, or pass '-g' to explicitly update every registered endpoint.",
93
+ },
94
+ );
95
+ }
96
+
97
+ export function renderUpdateHelp(): string {
98
+ return renderKitHelp(UPDATE_SPEC);
99
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "smol-toml";
4
+ import { z } from "zod";
5
+ import { assertRestrictedToml, ENDPOINT_NAME, ManifestError } from "./manifest.ts";
6
+
7
+ const clientConfigSchema = z.object({
8
+ default_endpoints: z.array(z.string().min(1)).min(1),
9
+ }).strict();
10
+
11
+ export interface ClientConfig {
12
+ default_endpoints: string[];
13
+ }
14
+
15
+ export function loadClientConfig(path: string): ClientConfig {
16
+ let source: string;
17
+ try {
18
+ source = readFileSync(path, "utf8");
19
+ } catch (error) {
20
+ throw new ManifestError(`Client Config is not readable: ${path}`, { cause: error });
21
+ }
22
+
23
+ let raw: unknown;
24
+ try {
25
+ raw = parse(source);
26
+ assertRestrictedToml(raw);
27
+ } catch (error) {
28
+ if (error instanceof ManifestError) throw error;
29
+ throw new ManifestError(`Client Config TOML is invalid: ${path}`, { cause: error });
30
+ }
31
+
32
+ const result = clientConfigSchema.safeParse(raw);
33
+ if (!result.success) {
34
+ throw new ManifestError(`Client Config schema is invalid: ${result.error.message}`);
35
+ }
36
+ const names = result.data.default_endpoints;
37
+ if (names.some((name) => !ENDPOINT_NAME.test(name))) {
38
+ throw new ManifestError(`${path}: default_endpoints contains an invalid endpoint name`);
39
+ }
40
+ if (new Set(names).size !== names.length) {
41
+ throw new ManifestError(`${path}: default_endpoints contains duplicate endpoint names`);
42
+ }
43
+ return result.data;
44
+ }
45
+
46
+ export function findNearestClientConfig(currentDirectory: string): string | undefined {
47
+ let directory = currentDirectory;
48
+ while (true) {
49
+ const candidate = join(directory, ".ukp", "client.toml");
50
+ if (existsSync(candidate)) return candidate;
51
+ const parent = join(directory, "..");
52
+ if (parent === directory) return undefined;
53
+ directory = parent;
54
+ }
55
+ }
@@ -0,0 +1,58 @@
1
+ // ADR-RG-003: external-tool base tier — the table governing capabilities
2
+ // backed by a ubiquitous external tool with graceful degradation. Mirrors
3
+ // the file-native table (ADR 0016): the set membership, the base-tier rule
4
+ // (available on every registered local Service; a declaration only
5
+ // overrides), and the provider marker live here and nowhere else.
6
+ //
7
+ // Tier criteria (stricter than file-native): the capability semantics must
8
+ // be fully defined by UKP (thin shaping, no tool-semantic wrap), read-only,
9
+ // dependent on a *ubiquitous* base tool (ripgrep-class), and degrade to an
10
+ // availability classification when the tool is missing — never a fault.
11
+
12
+ import type { Manifest, ManifestCapability } from "./manifest.ts";
13
+
14
+ /** Provider marker for the external-tool base tier. Manifest load defaults a
15
+ * bare `[capabilities.<name>]` declaration to this value; any other provider
16
+ * is a hard error (the tier does not plugin alternative tools). */
17
+ export const EXTERNAL_PROVIDER = "external";
18
+
19
+ export type ExternalToolCapabilityName = "rg";
20
+
21
+ export interface ExternalToolCapabilitySpec {
22
+ /** Base tier (ADR-RG-003 rule): available on every registered local
23
+ * Service without declaration; a declaration only overrides config. */
24
+ readonly baseTier: boolean;
25
+ /** Flat keys accepted directly under `[capabilities.<name>]`. */
26
+ readonly flatConfigKeys: readonly string[];
27
+ }
28
+
29
+ export const EXTERNAL_TOOL_CAPABILITIES: Readonly<
30
+ Record<ExternalToolCapabilityName, ExternalToolCapabilitySpec>
31
+ > = {
32
+ rg: { baseTier: true, flatConfigKeys: [] },
33
+ };
34
+
35
+ export function isExternalToolCapability(name: string): name is ExternalToolCapabilityName {
36
+ return Object.hasOwn(EXTERNAL_TOOL_CAPABILITIES, name);
37
+ }
38
+
39
+ export interface ExternalToolResolution {
40
+ readonly capability: ManifestCapability;
41
+ readonly source: "manifest" | "base-tier";
42
+ }
43
+
44
+ /** Resolves the effective external-tool capability declaration for a
45
+ * Service: the Manifest declaration when present, otherwise the base-tier
46
+ * default (`{ provider: "external" }`). Never `undefined` for base-tier
47
+ * members — unlike the file-native set there is no write-side member that
48
+ * requires explicit declaration. */
49
+ export function resolveExternalToolCapability(
50
+ manifest: Manifest,
51
+ name: ExternalToolCapabilityName,
52
+ ): ExternalToolResolution {
53
+ const declared = manifest.capabilities[name];
54
+ if (declared !== undefined) {
55
+ return { capability: declared, source: "manifest" };
56
+ }
57
+ return { capability: { provider: EXTERNAL_PROVIDER }, source: "base-tier" };
58
+ }
@@ -0,0 +1,111 @@
1
+ // ADR 0016: file-native capability set — the single table governing every
2
+ // file-native capability. Three rules live here and nowhere else:
3
+ //
4
+ // 1. the set membership (`read`, `nav`, `propose`);
5
+ // 2. which members are derived defaults of every registered local Service
6
+ // (read-only, exposure within the read/file baseline) vs. which still
7
+ // require an explicit declaration (write-side: `propose`);
8
+ // 3. which flat declaration keys each capability accepts directly under
9
+ // `[capabilities.<name>]` (normalized into `config` at Manifest load).
10
+ //
11
+ // Capability implementations and command surfaces resolve their declaration
12
+ // through this module instead of re-implementing per-capability fallbacks,
13
+ // so a new file-native capability joins by editing this table only.
14
+
15
+ import type { Manifest, ManifestCapability } from "./manifest.ts";
16
+
17
+ export type FileNativeCapabilityName = "read" | "nav" | "propose";
18
+
19
+ export interface FileNativeCapabilitySpec {
20
+ /** Derived default of every registered local Service (ADR 0016 rule 2):
21
+ * present without any declaration; a declaration only overrides defaults. */
22
+ readonly derived: boolean;
23
+ /** Flat keys accepted directly under `[capabilities.<name>]` (flat UX, no
24
+ * `.config` hop). Normalized into `config` at load; a key present both
25
+ * flat and under `.config` is a declaration conflict. */
26
+ readonly flatConfigKeys: readonly string[];
27
+ }
28
+
29
+ export const FILE_NATIVE_CAPABILITIES: Readonly<Record<FileNativeCapabilityName, FileNativeCapabilitySpec>> = {
30
+ // `read` is in the set for uniformity (rule 1); its bare-declaration arm is
31
+ // unreachable in practice because a declared [capabilities.get] is stripped
32
+ // as legacy before provider defaulting — read's derived baseline has no
33
+ // Manifest override surface at all (ADR 0007 / ADR 0016).
34
+ read: { derived: true, flatConfigKeys: [] },
35
+ nav: { derived: true, flatConfigKeys: ["exclude_files", "exclude_dirs"] },
36
+ propose: { derived: false, flatConfigKeys: [] },
37
+ };
38
+
39
+ export function isFileNativeCapability(name: string): name is FileNativeCapabilityName {
40
+ return Object.hasOwn(FILE_NATIVE_CAPABILITIES, name);
41
+ }
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ /** Load-time normalization of flat declaration keys into `config`, driven by
48
+ * the table above. `fail` lets the Manifest loader keep its own error type
49
+ * (single ManifestError surface); the message text is owned here. Pure: does
50
+ * not mutate the input. */
51
+ export function normalizeFileNativeFlatKeys(
52
+ raw: unknown,
53
+ fail: (message: string) => never,
54
+ ): unknown {
55
+ if (!isRecord(raw) || !isRecord(raw.capabilities)) return raw;
56
+ const output: Record<string, unknown> = { ...raw.capabilities };
57
+ let changed = false;
58
+ for (const [name, declaration] of Object.entries(raw.capabilities)) {
59
+ if (!isFileNativeCapability(name) || !isRecord(declaration)) continue;
60
+ const flatKeys = FILE_NATIVE_CAPABILITIES[name].flatConfigKeys;
61
+ if (!flatKeys.some((key) => Object.hasOwn(declaration, key))) continue;
62
+ const config = isRecord(declaration.config) ? { ...declaration.config } : {};
63
+ const next: Record<string, unknown> = { ...declaration };
64
+ for (const key of flatKeys) {
65
+ if (!Object.hasOwn(next, key)) continue;
66
+ if (Object.hasOwn(config, key)) {
67
+ fail(`[capabilities.${name}] '${key}' is declared both directly and under [capabilities.${name}.config]`);
68
+ }
69
+ config[key] = next[key];
70
+ delete next[key];
71
+ }
72
+ next.config = config;
73
+ output[name] = next;
74
+ changed = true;
75
+ }
76
+ return changed ? { ...raw, capabilities: output } : raw;
77
+ }
78
+
79
+ export interface FileNativeResolution {
80
+ readonly capability: ManifestCapability;
81
+ readonly source: "manifest" | "derived-local";
82
+ }
83
+
84
+ /** Resolves the effective file-native capability declaration for a Service:
85
+ * the Manifest declaration when present, otherwise the derived default
86
+ * (`{ provider: "file" }`) for derived members. `undefined` means the
87
+ * capability is not effectively present — today only an undeclared write-side
88
+ * capability (`propose`), whose caller reports the missing declaration. */
89
+ export function resolveFileNativeCapability(
90
+ manifest: Manifest,
91
+ name: FileNativeCapabilityName,
92
+ ): FileNativeResolution | undefined {
93
+ const declared = manifest.capabilities[name];
94
+ if (declared !== undefined) {
95
+ return { capability: declared, source: "manifest" };
96
+ }
97
+ return FILE_NATIVE_CAPABILITIES[name].derived
98
+ ? { capability: { provider: "file" }, source: "derived-local" }
99
+ : undefined;
100
+ }
101
+
102
+ /** Uniform unsupported-provider message for every file-native capability
103
+ * (only the `file` provider exists; a different provider is a hard error at
104
+ * every surface). The `(none)` arm is defensive depth only — Manifest load
105
+ * already defaults a bare declaration to `"file"` and fails fast elsewhere. */
106
+ export function unsupportedFileNativeProviderMessage(
107
+ name: FileNativeCapabilityName,
108
+ provider: string | undefined,
109
+ ): string {
110
+ return `unsupported ${name} provider '${provider ?? "(none)"}' (supported: file)`;
111
+ }