@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.
@@ -0,0 +1,178 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ENDPOINT_NAME } from "../config/manifest.ts";
4
+ import { findNearestClientConfig, loadClientConfig } from "../config/client.ts";
5
+ import { readRegistry } from "../registry.ts";
6
+ import {
7
+ assertUkpResidents,
8
+ ensureVersionVisibleUkpIgnore,
9
+ renderUkpIgnorePostureLine,
10
+ ukpIgnoreStarWarning,
11
+ } from "./init.ts";
12
+ import {
13
+ KitUsageError,
14
+ parseKitArgs,
15
+ renderKitHelp,
16
+ renderKitUsageError,
17
+ type UkpCommandSpec,
18
+ } from "./kit.ts";
19
+ import { HelpRequestError, isHelpRequest } from "./flags.ts";
20
+
21
+ export interface UseCommandContext {
22
+ currentDirectory: string;
23
+ registryPath: string;
24
+ }
25
+
26
+ export interface UseCommandResult {
27
+ exitCode: number;
28
+ stdout: string;
29
+ stderr: string;
30
+ }
31
+
32
+ /** `ukp use` (inbox client-portability-and-skill-coverage, owner-endorsed
33
+ * 2026-10-28): the one-step consumer onboarding gesture `register` is
34
+ * deliberately not (register never edits Client Config — the guide owned
35
+ * that journey until now, which left hand-writing the file plus a
36
+ * version-control carve dance). Named `use` rather than `init client`
37
+ * because the lifecycle is create-or-update and idempotent re-runs are the
38
+ * point, while the init family is create-only and refuses re-runs. */
39
+ export const USE_SPEC: UkpCommandSpec = {
40
+ name: "use",
41
+ summary: "set this workspace's default endpoints in one step",
42
+ group: "registry",
43
+ description: "Write the workspace Client Config (default_endpoints) and keep it version-visible; idempotent.",
44
+ usage: "<endpoint> [endpoint...]",
45
+ arguments: [
46
+ { name: "endpoint...", required: true, help: "registered endpoint name(s); the whole list becomes default_endpoints (explicit replace, no append)" },
47
+ ],
48
+ helpSuffix: [
49
+ "",
50
+ "Semantics:",
51
+ " The list REPLACES default_endpoints: re-running with the same names",
52
+ " changes nothing; running with new names rewrites the list (the old",
53
+ " list is reported). The nearest existing .ukp/client.toml found",
54
+ " walking up from here is updated; with none found, one is created in",
55
+ " the current folder. Every name must already be registered",
56
+ " ('ukp list -g'); registering stays a separate explicit gesture.",
57
+ "",
58
+ "Version control rides the same step: .ukp/.gitignore is created with",
59
+ " machine-local ignores only (declarations stay committable), or",
60
+ " migrated off the retired '*' template; an operator-owned file is",
61
+ " left untouched. 'ukp inspect' shows the active scope afterwards.",
62
+ "",
63
+ ].join("\n"),
64
+ };
65
+
66
+ /** Posture of `.ukp/.gitignore` after `ukp use` ran — the ladder and its
67
+ * mechanism live in init.ts (the template's home); `use` is the client-face
68
+ * carrier, register's local path the service-face one. */
69
+
70
+ function renderClientConfig(names: readonly string[]): string {
71
+ // Hand-rendered rather than smol-toml stringify: the schema is exactly
72
+ // one string-array field, and the serializer's `[ "a", "b" ]` spacing
73
+ // would diverge from every documented example (`["a", "b"]`). A slug
74
+ // name needs no escapes, so JSON.stringify is a valid TOML basic string.
75
+ return `default_endpoints = [${names.map((name) => JSON.stringify(name)).join(", ")}]\n`;
76
+ }
77
+
78
+ export function executeUseCommand(
79
+ args: readonly string[],
80
+ context: UseCommandContext,
81
+ ): UseCommandResult {
82
+ if (isHelpRequest(args)) {
83
+ return { exitCode: 0, stdout: renderKitHelp(USE_SPEC), stderr: "" };
84
+ }
85
+
86
+ try {
87
+ const parsed = parseKitArgs(USE_SPEC, args);
88
+ const names = parsed.positionals;
89
+ if (names.length === 0) {
90
+ throw new KitUsageError("use requires at least one endpoint name");
91
+ }
92
+ for (const name of names) {
93
+ if (!ENDPOINT_NAME.test(name)) {
94
+ throw new KitUsageError(`invalid endpoint name '${name}'. Endpoint names use the lowercase slug grammar ([a-z0-9-]).`);
95
+ }
96
+ }
97
+ // The written file must satisfy the Client Config schema on the spot —
98
+ // duplicates are refused here, not left for every later load to warn.
99
+ const seen = new Set<string>();
100
+ for (const name of names) {
101
+ if (seen.has(name)) {
102
+ throw new KitUsageError(`duplicate endpoint '${name}' - default_endpoints cannot repeat`);
103
+ }
104
+ seen.add(name);
105
+ }
106
+ // Admission against the registry (the file stores names only; names
107
+ // must resolve): catches typos at write time instead of as dangling
108
+ // warnings on every later command. Registering stays separate.
109
+ const registered = new Set(readRegistry(context.registryPath).map((binding) => binding.name));
110
+ const unknown = names.filter((name) => !registered.has(name));
111
+ if (unknown.length > 0) {
112
+ throw new Error(
113
+ `unknown endpoint '${unknown[0]}'; run 'ukp list -g' to see every registered endpoint, or 'ukp register' first`,
114
+ );
115
+ }
116
+
117
+ // Nearest-wins, the same lookup rule scope resolution applies: the
118
+ // config updated is the one actually in effect from here; a workspace
119
+ // without one gets it created at the current folder.
120
+ const existingPath = findNearestClientConfig(context.currentDirectory);
121
+ const targetPath = existingPath ?? join(context.currentDirectory, ".ukp", "client.toml");
122
+ const ukpDirectory = dirname(targetPath);
123
+ // INV-21 admission gate before any write: the whole gesture refuses
124
+ // atomically when .ukp/ holds foreign residents (no half-written
125
+ // client.toml, no guard rewritten over material it no longer covers).
126
+ assertUkpResidents(ukpDirectory);
127
+ let status: "created" | "updated" | "unchanged";
128
+ let previous: string[] | undefined;
129
+ if (existingPath !== undefined) {
130
+ // Structural refusal, never a clobber: an invalid config is the
131
+ // operator's to fix or delete; overwriting it would silently discard
132
+ // whatever they were trying to express.
133
+ try {
134
+ const current = loadClientConfig(existingPath);
135
+ previous = current.default_endpoints;
136
+ } catch (error) {
137
+ throw new Error(
138
+ `existing Client Config is invalid (${existingPath}): ${error instanceof Error ? error.message : String(error)}; fix or delete the file, then re-run 'ukp use'`,
139
+ );
140
+ }
141
+ status = previous.length === names.length && previous.every((name, index) => name === names[index])
142
+ ? "unchanged"
143
+ : "updated";
144
+ } else {
145
+ status = "created";
146
+ }
147
+ if (status !== "unchanged") {
148
+ mkdirSync(ukpDirectory, { recursive: true });
149
+ // Fixed temp name + atomic rename: readers (this CLI included) never
150
+ // see a torn file; a crash residue is one file, overwritten next run.
151
+ const tempPath = join(ukpDirectory, "client.toml.ukp-tmp");
152
+ writeFileSync(tempPath, renderClientConfig(names), "utf8");
153
+ renameSync(tempPath, targetPath);
154
+ }
155
+ const posture = ensureVersionVisibleUkpIgnore(ukpDirectory);
156
+
157
+ const lines = [
158
+ `default_endpoints: ${names.join(", ")}`,
159
+ ...(previous !== undefined && status === "updated" ? [`replaced: ${previous.join(", ")}`] : []),
160
+ `client_config: ${status}: ${targetPath}`,
161
+ renderUkpIgnorePostureLine(posture, ukpDirectory),
162
+ "next: ukp inspect (see the active scope)",
163
+ ];
164
+ return {
165
+ exitCode: 0,
166
+ stdout: lines.join("\n"),
167
+ stderr: posture === "operator-owned-star" ? ukpIgnoreStarWarning(ukpDirectory) : "",
168
+ };
169
+ } catch (error) {
170
+ if (error instanceof HelpRequestError) {
171
+ return { exitCode: 0, stdout: renderKitHelp(USE_SPEC), stderr: "" };
172
+ }
173
+ if (error instanceof KitUsageError) {
174
+ return { exitCode: 2, stdout: "", stderr: renderKitUsageError(USE_SPEC, error.message) };
175
+ }
176
+ return { exitCode: 1, stdout: "", stderr: `ukp use: ${error instanceof Error ? error.message : String(error)}\n` };
177
+ }
178
+ }
@@ -0,0 +1,136 @@
1
+ // ukp_session (ADR-SESSION-001): agent-backed capability tier — the table
2
+ // governing capabilities whose substrate is an external agent harness
3
+ // declared in the Host Registry. Mirrors the file-native (ADR 0016) and
4
+ // external-tool (ADR-RG-003) tables: set membership, the provider marker,
5
+ // and flat declaration keys live here and nowhere else. Unlike those tiers
6
+ // an agent-backed capability is never derived — absence of the declaration
7
+ // means the endpoint explicitly does not host sessions, and spawn treats
8
+ // that as a hard stop, not a warning (line spec §1 / §5).
9
+
10
+ import type { ManifestCapability } from "./manifest.ts";
11
+
12
+ /** Provider marker for the agent-backed tier. A bare
13
+ * `[capabilities.<name>]` declaration defaults to this value; any other
14
+ * provider is a hard error (the tier does not plugin alternative bases). */
15
+ export const AGENT_PROVIDER = "agent";
16
+
17
+ export type AgentBackedCapabilityName = "session";
18
+
19
+ /** Write permission tier for a hosted session (owner ruling 2026-10-18: the
20
+ * grant lives in the endpoint's own declaration, not in the adapter default
21
+ * and not in runtime approval). `read-only` is the scout default; `workspace`
22
+ * maps to the substrate's workspace-write sandbox (writes confined to the
23
+ * endpoint folder). */
24
+ export type SessionWriteTier = "read-only" | "workspace";
25
+
26
+ interface AgentBackedCapabilitySpec {
27
+ /** Always false: hosting is opt-in per endpoint (ADR-SESSION-001). */
28
+ readonly derived: false;
29
+ /** Flat keys accepted directly under `[capabilities.<name>]`. */
30
+ readonly flatConfigKeys: readonly string[];
31
+ }
32
+
33
+ export const AGENT_BACKED_CAPABILITIES: Readonly<
34
+ Record<AgentBackedCapabilityName, AgentBackedCapabilitySpec>
35
+ > = {
36
+ session: { derived: false, flatConfigKeys: ["agents", "write"] },
37
+ };
38
+
39
+ export function isAgentBackedCapability(name: string): name is AgentBackedCapabilityName {
40
+ return Object.hasOwn(AGENT_BACKED_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`, the
48
+ * same discipline as the file-native pass (flat UX without the `.config`
49
+ * hop; the same key both places is a declaration conflict). Pure. */
50
+ export function normalizeAgentBackedFlatKeys(
51
+ raw: unknown,
52
+ fail: (message: string) => never,
53
+ ): unknown {
54
+ if (!isRecord(raw) || !isRecord(raw.capabilities)) return raw;
55
+ const output: Record<string, unknown> = { ...raw.capabilities };
56
+ let changed = false;
57
+ for (const [name, declaration] of Object.entries(raw.capabilities)) {
58
+ if (!isAgentBackedCapability(name) || !isRecord(declaration)) continue;
59
+ const flatKeys = AGENT_BACKED_CAPABILITIES[name].flatConfigKeys;
60
+ if (!flatKeys.some((key) => Object.hasOwn(declaration, key))) continue;
61
+ const config = isRecord(declaration.config) ? { ...declaration.config } : {};
62
+ const next: Record<string, unknown> = { ...declaration };
63
+ for (const key of flatKeys) {
64
+ if (!Object.hasOwn(next, key)) continue;
65
+ if (Object.hasOwn(config, key)) {
66
+ fail(`[capabilities.${name}] '${key}' is declared both directly and under [capabilities.${name}.config]`);
67
+ }
68
+ config[key] = next[key];
69
+ delete next[key];
70
+ }
71
+ next.config = config;
72
+ output[name] = next;
73
+ changed = true;
74
+ }
75
+ return changed ? { ...raw, capabilities: output } : raw;
76
+ }
77
+
78
+ /** Handle grammar for agent handles and allowlist entries — the full
79
+ * ENDPOINT_NAME constraint (manifest.ts), length bound included, kept
80
+ * textual here so the governance table owns its own surface. */
81
+ export const AGENT_HANDLE = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
82
+
83
+ /** Declaration-level validation of the allowlist (shape only — whether a
84
+ * handle has a Host Registry substrate is use-time, spec §2). An empty
85
+ * allowlist is a valid "off" declaration (spec §1). */
86
+ export function validateAgentBackedDeclaration(
87
+ name: AgentBackedCapabilityName,
88
+ declaration: ManifestCapability,
89
+ fail: (message: string) => never,
90
+ ): void {
91
+ if (name !== "session") return;
92
+ const agents = declaration.config?.agents;
93
+ if (agents === undefined) {
94
+ fail(`[capabilities.session] must declare 'agents' (an allowlist of agent handles; empty = hosting off)`);
95
+ }
96
+ if (!Array.isArray(agents)) {
97
+ fail("[capabilities.session] 'agents' must be an array of agent handles");
98
+ }
99
+ const seen = new Set<string>();
100
+ for (const item of agents) {
101
+ if (typeof item !== "string" || !AGENT_HANDLE.test(item)) {
102
+ fail(`[capabilities.session] 'agents' contains an invalid handle: ${JSON.stringify(item)}`);
103
+ }
104
+ if (seen.has(item)) {
105
+ fail(`[capabilities.session] 'agents' lists handle '${item}' more than once`);
106
+ }
107
+ seen.add(item);
108
+ }
109
+ const write = declaration.config?.write;
110
+ if (write !== undefined && write !== "workspace") {
111
+ fail(`[capabilities.session] 'write' must be "workspace" when declared (got ${JSON.stringify(write)}); absent = read-only`);
112
+ }
113
+ }
114
+
115
+ /** The effective hosting allowlist: handles declared under
116
+ * `[capabilities.session]`. Absent declaration or empty list = hosting
117
+ * off (spawn refuses; never a warning). */
118
+ export function resolveSessionAgents(manifest: {
119
+ capabilities: Record<string, ManifestCapability>;
120
+ }): readonly string[] {
121
+ const declaration = manifest.capabilities.session;
122
+ if (declaration === undefined) return [];
123
+ const agents = declaration.config?.agents;
124
+ if (!Array.isArray(agents)) return [];
125
+ return agents.filter((item): item is string => typeof item === "string");
126
+ }
127
+
128
+ /** The effective write tier: `workspace` only when the endpoint declared it;
129
+ * anything else (absent declaration included) is the read-only scout default.
130
+ * Load-time validation already rejected foreign values — this reads what
131
+ * passed, it does not re-decide. */
132
+ export function resolveSessionWriteTier(manifest: {
133
+ capabilities: Record<string, ManifestCapability>;
134
+ }): SessionWriteTier {
135
+ return manifest.capabilities.session?.config?.write === "workspace" ? "workspace" : "read-only";
136
+ }
@@ -4,6 +4,12 @@ import { parse } from "smol-toml";
4
4
  import { z } from "zod";
5
5
  import { isExternalToolCapability, EXTERNAL_PROVIDER } from "./external-tool.ts";
6
6
  import { isFileNativeCapability, normalizeFileNativeFlatKeys } from "./file-native.ts";
7
+ import {
8
+ AGENT_PROVIDER,
9
+ isAgentBackedCapability,
10
+ normalizeAgentBackedFlatKeys,
11
+ validateAgentBackedDeclaration,
12
+ } from "./agent-backed.ts";
7
13
 
8
14
  export const ENDPOINT_NAME = /^(?=.{1,63}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
15
 
@@ -58,6 +64,20 @@ export class ManifestError extends Error {
58
64
  }
59
65
  }
60
66
 
67
+ /** Dead-binding classification (registry-commands dead-binding work item 1,
68
+ * 2026-10-24): the Service folder itself is unreachable — the binding routes
69
+ * to a path that no longer exists. Subclasses ManifestError so broad
70
+ * `instanceof ManifestError` catches keep working; consumers that need the
71
+ * distinction (the list face frames this as a binding-level failure with the
72
+ * one-step rebind remedy, not a capability degradation) branch on the class
73
+ * instead of matching message text. */
74
+ export class ServiceFolderInaccessibleError extends ManifestError {
75
+ constructor(message: string, options?: ErrorOptions) {
76
+ super(message, options);
77
+ this.name = "ServiceFolderInaccessibleError";
78
+ }
79
+ }
80
+
61
81
  export function assertRestrictedToml(value: unknown, path = "root"): void {
62
82
  if (value === null || value === undefined || value instanceof Date) {
63
83
  throw new ManifestError(`${path}: unsupported TOML value`);
@@ -122,7 +142,7 @@ export function loadManifest(serviceFolder: string): LoadedManifest {
122
142
  folder = realpathSync(serviceFolder);
123
143
  if (!statSync(folder).isDirectory()) throw new Error("not a directory");
124
144
  } catch (error) {
125
- throw new ManifestError(`Service folder is not accessible: ${serviceFolder}`, { cause: error });
145
+ throw new ServiceFolderInaccessibleError(`Service folder is not accessible: ${serviceFolder}`, { cause: error });
126
146
  }
127
147
 
128
148
  const manifestPath = join(folder, ".ukp", "service.toml");
@@ -143,8 +163,12 @@ export function loadManifest(serviceFolder: string): LoadedManifest {
143
163
 
144
164
  const withoutLegacyGet = removeLegacyGetCapability(raw);
145
165
  // Flat file-native declaration keys (table-driven, ADR 0016) fold into
146
- // `config` before the strict schema parse.
147
- const normalized = normalizeFileNativeFlatKeys(withoutLegacyGet, (message) => {
166
+ // `config` before the strict schema parse; agent-backed declarations
167
+ // (ukp_session) get the identical pass from their own governance table.
168
+ const normalizedFileNative = normalizeFileNativeFlatKeys(withoutLegacyGet, (message) => {
169
+ throw new ManifestError(message);
170
+ });
171
+ const normalized = normalizeAgentBackedFlatKeys(normalizedFileNative, (message) => {
148
172
  throw new ManifestError(message);
149
173
  });
150
174
  if (isRecord(normalized) && !Object.hasOwn(normalized, "capabilities")) {
@@ -180,6 +204,8 @@ export function loadManifest(serviceFolder: string): LoadedManifest {
180
204
  manifest.capabilities[name] = { ...declaration, provider: "file" };
181
205
  } else if (isExternalToolCapability(name)) {
182
206
  manifest.capabilities[name] = { ...declaration, provider: EXTERNAL_PROVIDER };
207
+ } else if (isAgentBackedCapability(name)) {
208
+ manifest.capabilities[name] = { ...declaration, provider: AGENT_PROVIDER };
183
209
  } else {
184
210
  throw new ManifestError(`[capabilities.${name}] must declare a provider`);
185
211
  }
@@ -188,6 +214,16 @@ export function loadManifest(serviceFolder: string): LoadedManifest {
188
214
  `[capabilities.${name}] provider must be '${EXTERNAL_PROVIDER}' or omitted `
189
215
  + `(external-tool base tier: the declaration only overrides config)`,
190
216
  );
217
+ } else if (isAgentBackedCapability(name) && declaration.provider !== AGENT_PROVIDER) {
218
+ throw new ManifestError(
219
+ `[capabilities.${name}] provider must be '${AGENT_PROVIDER}' or omitted `
220
+ + `(agent-backed tier: the substrate is a Host Registry agent, not a provider plugin)`,
221
+ );
222
+ }
223
+ if (isAgentBackedCapability(name)) {
224
+ validateAgentBackedDeclaration(name, manifest.capabilities[name]!, (message) => {
225
+ throw new ManifestError(message);
226
+ });
191
227
  }
192
228
  }
193
229
  return { folder, manifestPath, manifest, effectiveName, nameSource };
@@ -0,0 +1,15 @@
1
+ schema_version: 1
2
+ kind: directory
3
+
4
+ metadata:
5
+ name: ukp-src
6
+ description: The ukp source publish face - the shipped src subtree (package.json files whitelist); design records live in the dev-side control plane (line ADRs) and do not ship, so src comments citing them are publish-face vocabulary for the write-time tip layer
7
+
8
+ publication:
9
+ enabled: false
10
+ mode: closed
11
+ root: .
12
+ include:
13
+ - "**/*"
14
+ exclude:
15
+ - "manifest.yml"
@@ -27,14 +27,19 @@ export type EndpointRelativeSegments =
27
27
  | { ok: false; reason: "absolute" | "empty-segment" | "dot-segment" };
28
28
 
29
29
  /** Segment-level validation of an endpoint-relative reference: rejects
30
- * absolute shapes, empty segments (`a//b`), and `.`/`..` segments. Accepts
31
- * both `/` and `\` separators; callers own separator normalization. The
32
- * reason field lets each capability map its own error wording. */
30
+ * absolute shapes, interior empty segments (`a//b`), and `.`/`..` segments.
31
+ * Trailing separators are address-encoding noise, not a segment claim —
32
+ * POSIX resolves `docs/` as `docs` — so they are stripped and stat decides
33
+ * the outcome (a directory tail lands on the caller's friendly
34
+ * is-a-directory failure, never on the grammar error that used to run
35
+ * before it). Accepts both `/` and `\` separators; callers own separator
36
+ * normalization. The reason field lets each capability map its own error
37
+ * wording. */
33
38
  export function splitEndpointRelativeSegments(reference: string): EndpointRelativeSegments {
34
39
  if (isAbsoluteShapedPath(reference)) {
35
40
  return { ok: false, reason: "absolute" };
36
41
  }
37
- const segments = reference.split(/[\\/]/);
42
+ const segments = reference.replace(/[\\/]+$/, "").split(/[\\/]/);
38
43
  if (segments.some((segment) => segment.length === 0)) {
39
44
  return { ok: false, reason: "empty-segment" };
40
45
  }