@ornncompute/cli 0.1.8 → 0.2.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.
@@ -44,10 +44,9 @@ export async function openBrowser(url, { platform = process.platform, spawnImpl
44
44
  }
45
45
  }
46
46
 
47
- export async function startDeviceFlow({ authBaseUrl, fetchImpl = fetch } = {}) {
48
- const data = await postJson(fetchImpl, `${authBaseUrl}/api/cli/auth/device/start`, {
49
- client: "ornn-cli",
50
- });
47
+ export async function startDeviceFlow({ apiBaseUrl, authBaseUrl, fetchImpl = fetch } = {}) {
48
+ const requestBaseUrl = apiBaseUrl || authBaseUrl;
49
+ const data = await postJson(fetchImpl, `${requestBaseUrl}/v1/cli/device/start`, {});
51
50
 
52
51
  const deviceCode = pickString(data, "deviceCode", "device_code");
53
52
  const userCode = pickString(data, "userCode", "user_code");
@@ -70,14 +69,16 @@ export async function startDeviceFlow({ authBaseUrl, fetchImpl = fetch } = {}) {
70
69
  };
71
70
  }
72
71
 
73
- export async function pollDeviceFlow({ authBaseUrl, deviceCode, fetchImpl = fetch } = {}) {
74
- return postJson(fetchImpl, `${authBaseUrl}/api/cli/auth/device/poll`, {
75
- deviceCode,
72
+ export async function pollDeviceFlow({ apiBaseUrl, authBaseUrl, deviceCode, fetchImpl = fetch } = {}) {
73
+ const requestBaseUrl = apiBaseUrl || authBaseUrl;
74
+ return postJson(fetchImpl, `${requestBaseUrl}/v1/cli/device/poll`, {
75
+ device_code: deviceCode,
76
76
  });
77
77
  }
78
78
 
79
79
  export async function waitForDeviceApproval({
80
80
  authBaseUrl,
81
+ apiBaseUrl,
81
82
  deviceCode,
82
83
  expiresIn,
83
84
  fetchImpl = fetch,
@@ -92,7 +93,7 @@ export async function waitForDeviceApproval({
92
93
 
93
94
  while (Date.now() < deadline) {
94
95
  await sleep(pollIntervalMs);
95
- const data = await pollDeviceFlow({ authBaseUrl, deviceCode, fetchImpl });
96
+ const data = await pollDeviceFlow({ apiBaseUrl, authBaseUrl, deviceCode, fetchImpl });
96
97
  const status = pickString(data, "status") || "pending";
97
98
 
98
99
  if (status === "pending") {
@@ -110,6 +111,7 @@ export async function waitForDeviceApproval({
110
111
 
111
112
  return {
112
113
  accessToken: token,
114
+ apiBaseUrl: apiBaseUrl || authBaseUrl,
113
115
  authBaseUrl,
114
116
  tokenType: pickString(data, "tokenType", "token_type") || "bearer",
115
117
  user: {
@@ -0,0 +1,2 @@
1
+ export declare const slurmCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
2
+ export declare const kubernetesCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -0,0 +1,99 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ const reservationInput = z.object({
4
+ reservation_id: z.string().min(1),
5
+ reservationId: z.string().optional(),
6
+ });
7
+ const launchInput = reservationInput.extend({
8
+ network_mode: z.enum(["public", "private"]).optional(),
9
+ node_count: z.number().int().positive().optional(),
10
+ node_ids: z.array(z.string()).optional(),
11
+ });
12
+ function reservationId(input) {
13
+ return (input.reservation_id || input.reservationId || "").trim();
14
+ }
15
+ function clusterPath(kind, id, suffix) {
16
+ return `/${kind}/reservations/${encodeURIComponent(id)}/${suffix}`;
17
+ }
18
+ function clusterPair(kind, domain) {
19
+ const label = kind === "slurm" ? "Slurm" : "Kubernetes";
20
+ return [
21
+ defineCapability({
22
+ id: `${kind}.eligible`,
23
+ domain,
24
+ roles: ["user"],
25
+ description: `${label} launch is blocked only when the reservation's nodes already belong to a different controller. Returns eligible=false with reason other_cluster in that case.`,
26
+ mutation: "read",
27
+ http: { method: "GET", path: `/${kind}/reservations/{id}/cluster/eligible` },
28
+ input: reservationInput,
29
+ execute: async (ctx, input) => {
30
+ const parsed = reservationInput.parse(input);
31
+ return ctx.fetch(clusterPath(kind, reservationId(parsed), "cluster/eligible"), {
32
+ method: "GET",
33
+ });
34
+ },
35
+ }),
36
+ defineCapability({
37
+ id: `${kind}.launch`,
38
+ domain,
39
+ roles: ["user"],
40
+ description: `Launch a ${label} controller on a reservation. Blocked when ${kind}.eligible reports other_cluster.`,
41
+ mutation: "preview-confirm",
42
+ http: { method: "POST", path: `/${kind}/reservations/{id}/cluster/launch` },
43
+ input: launchInput,
44
+ execute: async (ctx, input) => {
45
+ const parsed = launchInput.parse(input);
46
+ const id = reservationId(parsed);
47
+ const eligibility = (await ctx.fetch(clusterPath(kind, id, "cluster/eligible"), {
48
+ method: "GET",
49
+ }));
50
+ if (eligibility?.eligible === false && eligibility.reason === "other_cluster") {
51
+ return { ok: false, error: "other_cluster", reason: "other_cluster" };
52
+ }
53
+ if (!ctx.confirmed) {
54
+ return { ok: true, action: `${kind}.launch`, reservation_id: id };
55
+ }
56
+ return ctx.fetch(clusterPath(kind, id, "cluster/launch"), {
57
+ method: "POST",
58
+ body: {
59
+ network_mode: parsed.network_mode ?? "public",
60
+ ...(parsed.node_count != null ? { node_count: parsed.node_count } : {}),
61
+ ...(parsed.node_ids?.length ? { node_ids: parsed.node_ids } : {}),
62
+ },
63
+ });
64
+ },
65
+ }),
66
+ defineCapability({
67
+ id: `${kind}.teardown`,
68
+ domain,
69
+ roles: ["user"],
70
+ description: `Tear down the ${label} controller for a reservation.`,
71
+ mutation: "preview-confirm",
72
+ http: { method: "POST", path: `/${kind}/reservations/{id}/cluster/teardown` },
73
+ input: reservationInput,
74
+ execute: async (ctx, input) => {
75
+ const parsed = reservationInput.parse(input);
76
+ const id = reservationId(parsed);
77
+ if (!ctx.confirmed) {
78
+ return { ok: true, action: `${kind}.teardown`, reservation_id: id };
79
+ }
80
+ return ctx.fetch(clusterPath(kind, id, "cluster/teardown"), { method: "POST" });
81
+ },
82
+ }),
83
+ defineCapability({
84
+ id: `${kind}.show`,
85
+ domain,
86
+ roles: ["user"],
87
+ description: `Show the ${label} controller for a reservation.`,
88
+ mutation: "read",
89
+ http: { method: "GET", path: `/${kind}/reservations/{id}/cluster` },
90
+ input: reservationInput,
91
+ execute: async (ctx, input) => {
92
+ const parsed = reservationInput.parse(input);
93
+ return ctx.fetch(clusterPath(kind, reservationId(parsed), "cluster"), { method: "GET" });
94
+ },
95
+ }),
96
+ ];
97
+ }
98
+ export const slurmCapabilities = clusterPair("slurm", "slurm");
99
+ export const kubernetesCapabilities = clusterPair("kubernetes", "kubernetes");
@@ -0,0 +1,5 @@
1
+ export declare const listOperatorsCapability: import("../capability.ts").Capability<unknown, unknown>;
2
+ export declare const listFacilitiesCapability: import("../capability.ts").Capability<unknown, unknown>;
3
+ export declare const listNodesCapability: import("../capability.ts").Capability<unknown, unknown>;
4
+ export declare const nodesConsoleCapability: import("../capability.ts").Capability<unknown, unknown>;
5
+ export declare const fleetCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -0,0 +1,130 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ function queryPath(path, params) {
4
+ const parts = [];
5
+ for (const [key, value] of Object.entries(params)) {
6
+ if (value?.trim())
7
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.trim())}`);
8
+ }
9
+ return parts.length ? `${path}?${parts.join("&")}` : path;
10
+ }
11
+ const emptyInput = z.object({});
12
+ const facilitiesInput = z.object({
13
+ operator_id: z.string().optional(),
14
+ operator: z.string().optional(),
15
+ });
16
+ const nodesInput = z.object({
17
+ operator_id: z.string().optional(),
18
+ operatorId: z.string().optional(),
19
+ operator: z.string().optional(),
20
+ facility_id: z.string().optional(),
21
+ facilityId: z.string().optional(),
22
+ status: z.string().optional(),
23
+ email: z.string().optional(),
24
+ });
25
+ const consoleInput = z.object({
26
+ node_id: z.string().min(1),
27
+ nodeId: z.string().optional(),
28
+ });
29
+ export const listOperatorsCapability = defineCapability({
30
+ id: "operator.list",
31
+ domain: "fleet",
32
+ roles: ["reviewer", "admin"],
33
+ description: "List GPU operators (id, slug, display name, status). Resolve operator names with this before nodes.list.",
34
+ mutation: "read",
35
+ http: { method: "GET", path: "/provisioning/operators" },
36
+ slack: { parameters: { type: "object", properties: {} } },
37
+ input: emptyInput,
38
+ execute: async (ctx) => ctx.fetch("/provisioning/operators", { method: "GET" }),
39
+ });
40
+ export const listFacilitiesCapability = defineCapability({
41
+ id: "facilities.list",
42
+ domain: "fleet",
43
+ roles: ["reviewer", "admin"],
44
+ description: "List facilities (id, display name, region, operator). Optional operator_id filter. Resolve facility names with this before nodes.list.",
45
+ mutation: "read",
46
+ http: { method: "GET", path: "/provisioning/facilities" },
47
+ slack: {
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ operator_id: { type: "string", description: "Commerce operator UUID." },
52
+ },
53
+ },
54
+ },
55
+ input: facilitiesInput,
56
+ execute: async (ctx, input) => {
57
+ const parsed = facilitiesInput.parse(input);
58
+ return ctx.fetch(queryPath("/provisioning/facilities", {
59
+ operator_id: parsed.operator_id ?? parsed.operator,
60
+ }), { method: "GET" });
61
+ },
62
+ });
63
+ export const listNodesCapability = defineCapability({
64
+ id: "nodes.list",
65
+ domain: "fleet",
66
+ roles: ["user", "reviewer", "admin"],
67
+ description: "List nodes. Users see their own reserved machines. Reviewers and admins see enrolled, ghost, and off-grid nodes. Optional email lists nodes for that user's organizations. Optional operator_id, facility_id, and status (ghost, off-grid, bare-metal). Never invent nodes. Never print private keys.",
68
+ mutation: "read",
69
+ http: { method: "GET", path: "/internal/nodes" },
70
+ slack: {
71
+ parameters: {
72
+ type: "object",
73
+ properties: {
74
+ operator_id: { type: "string", description: "Commerce operator UUID from operator.list." },
75
+ facility_id: { type: "string", description: "Commerce facility UUID from facilities.list." },
76
+ email: { type: "string", description: "List nodes for this user's organizations." },
77
+ status: {
78
+ type: "string",
79
+ enum: ["ghost", "off-grid", "bare-metal"],
80
+ description: "Optional orchestrator status filter. Default is all statuses.",
81
+ },
82
+ },
83
+ },
84
+ },
85
+ input: nodesInput,
86
+ execute: async (ctx, input) => {
87
+ const parsed = nodesInput.parse(input);
88
+ if (ctx.role === "user") {
89
+ return ctx.fetch("/nodes", { method: "GET" });
90
+ }
91
+ return ctx.fetch(queryPath("/internal/nodes", {
92
+ operator_id: parsed.operator_id ?? parsed.operatorId ?? parsed.operator,
93
+ facility_id: parsed.facility_id ?? parsed.facilityId,
94
+ status: parsed.status,
95
+ email: parsed.email,
96
+ }), { method: "GET" });
97
+ },
98
+ });
99
+ export const nodesConsoleCapability = defineCapability({
100
+ id: "nodes.console",
101
+ domain: "fleet",
102
+ roles: ["user", "reviewer", "admin"],
103
+ description: "Open an authenticated WebSocket into the node's real host shell (PTY), not ssh(1) and not the agent packet bus. Returns a short-lived wss URL plus ticket. CLI attaches an interactive TTY. MCP and Slack return a command loop over the same socket.",
104
+ mutation: "read",
105
+ http: { method: "POST", path: "/v1/nodes/{id}/console" },
106
+ slack: {
107
+ parameters: {
108
+ type: "object",
109
+ properties: {
110
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
111
+ },
112
+ required: ["node_id"],
113
+ },
114
+ },
115
+ input: consoleInput,
116
+ execute: async (ctx, input) => {
117
+ const parsed = consoleInput.parse(input);
118
+ const id = (parsed.node_id || parsed.nodeId || "").trim();
119
+ return ctx.fetch(`/v1/nodes/${encodeURIComponent(id)}/console`, {
120
+ method: "POST",
121
+ body: { node_id: id },
122
+ });
123
+ },
124
+ });
125
+ export const fleetCapabilities = [
126
+ listOperatorsCapability,
127
+ listFacilitiesCapability,
128
+ listNodesCapability,
129
+ nodesConsoleCapability,
130
+ ];
@@ -0,0 +1,3 @@
1
+ export declare const whoamiCapability: import("../capability.ts").Capability<unknown, unknown>;
2
+ export declare const statusCapability: import("../capability.ts").Capability<unknown, unknown>;
3
+ export declare const identityCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ const emptyInput = z.object({});
4
+ export const whoamiCapability = defineCapability({
5
+ id: "identity.whoami",
6
+ domain: "identity",
7
+ roles: ["user", "reviewer", "admin"],
8
+ description: "Show the signed-in Ornn user and organization.",
9
+ mutation: "read",
10
+ http: { method: "GET", path: "/v1/cli/session" },
11
+ input: emptyInput,
12
+ execute: async (ctx) => ctx.fetch("/v1/cli/session", { method: "GET" }),
13
+ });
14
+ export const statusCapability = defineCapability({
15
+ id: "identity.status",
16
+ domain: "identity",
17
+ roles: ["user", "reviewer", "admin"],
18
+ description: "Show Ornn platform status.",
19
+ mutation: "read",
20
+ http: { method: "GET", path: "/v1/cli/status" },
21
+ input: emptyInput,
22
+ execute: async (ctx) => ctx.fetch("/v1/cli/status", { method: "GET" }),
23
+ });
24
+ export const identityCapabilities = [whoamiCapability, statusCapability];
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ export declare const listingCreateInput: z.ZodObject<{
3
+ kind: z.ZodOptional<z.ZodEnum<{
4
+ link: "link";
5
+ gpu: "gpu";
6
+ cpu: "cpu";
7
+ node: "node";
8
+ }>>;
9
+ gpu: z.ZodOptional<z.ZodString>;
10
+ cpu: z.ZodOptional<z.ZodString>;
11
+ site: z.ZodOptional<z.ZodString>;
12
+ fabric: z.ZodOptional<z.ZodString>;
13
+ min_nodes: z.ZodOptional<z.ZodNumber>;
14
+ starts_after: z.ZodOptional<z.ZodString>;
15
+ starts_before: z.ZodOptional<z.ZodString>;
16
+ ends_after: z.ZodOptional<z.ZodString>;
17
+ ends_before: z.ZodOptional<z.ZodString>;
18
+ gpu_type: z.ZodOptional<z.ZodString>;
19
+ node_model: z.ZodOptional<z.ZodString>;
20
+ fabric_type: z.ZodOptional<z.ZodString>;
21
+ node_count: z.ZodOptional<z.ZodNumber>;
22
+ gpus_per_node: z.ZodOptional<z.ZodNumber>;
23
+ site_nickname: z.ZodOptional<z.ZodString>;
24
+ site_operator: z.ZodOptional<z.ZodString>;
25
+ ram: z.ZodOptional<z.ZodString>;
26
+ storage: z.ZodOptional<z.ZodString>;
27
+ internet: z.ZodOptional<z.ZodString>;
28
+ network_hardware: z.ZodOptional<z.ZodString>;
29
+ available_start_at: z.ZodOptional<z.ZodString>;
30
+ available_end_at: z.ZodOptional<z.ZodString>;
31
+ available_from: z.ZodOptional<z.ZodString>;
32
+ available_to: z.ZodOptional<z.ZodString>;
33
+ buy_now_price_per_gpu_hour: z.ZodOptional<z.ZodNumber>;
34
+ cost_per_gpu_hour: z.ZodOptional<z.ZodNumber>;
35
+ deposit_percent: z.ZodOptional<z.ZodNumber>;
36
+ price_omitted: z.ZodOptional<z.ZodBoolean>;
37
+ announce: z.ZodOptional<z.ZodBoolean>;
38
+ text: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$strip>;
40
+ export declare const listCatalogTermsCapability: import("../capability.ts").Capability<unknown, unknown>;
41
+ export declare const listListingsCapability: import("../capability.ts").Capability<unknown, unknown>;
42
+ export declare const createListingCapability: import("../capability.ts").Capability<unknown, unknown>;
43
+ export declare const listingCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
44
+ /** @deprecated Use createListingCapability. */
45
+ export declare const proposeListingCapability: import("../capability.ts").Capability<unknown, unknown>;
@@ -0,0 +1,161 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ import { bindHardwareFields, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, projectCatalogTerms, } from "../spec-catalog.js";
4
+ export const listingCreateInput = z.object({
5
+ kind: z.enum(["gpu", "cpu", "node", "link"]).optional(),
6
+ gpu: z.string().optional(),
7
+ cpu: z.string().optional(),
8
+ site: z.string().optional(),
9
+ fabric: z.string().optional(),
10
+ min_nodes: z.number().int().optional(),
11
+ starts_after: z.string().optional(),
12
+ starts_before: z.string().optional(),
13
+ ends_after: z.string().optional(),
14
+ ends_before: z.string().optional(),
15
+ gpu_type: z.string().optional(),
16
+ node_model: z.string().optional(),
17
+ fabric_type: z.string().optional(),
18
+ node_count: z.number().int().optional(),
19
+ gpus_per_node: z.number().int().optional(),
20
+ site_nickname: z.string().optional(),
21
+ site_operator: z.string().optional(),
22
+ ram: z.string().optional(),
23
+ storage: z.string().optional(),
24
+ internet: z.string().optional(),
25
+ network_hardware: z.string().optional(),
26
+ available_start_at: z.string().optional(),
27
+ available_end_at: z.string().optional(),
28
+ available_from: z.string().optional(),
29
+ available_to: z.string().optional(),
30
+ buy_now_price_per_gpu_hour: z.number().optional(),
31
+ cost_per_gpu_hour: z.number().optional(),
32
+ deposit_percent: z.number().optional(),
33
+ price_omitted: z.boolean().optional(),
34
+ announce: z.boolean().optional(),
35
+ text: z.string().optional(),
36
+ });
37
+ const catalogTermsInput = z.object({ kind: z.enum(["gpu", "cpu", "node", "link"]).optional() });
38
+ const slackListingCreateParams = {
39
+ type: "object",
40
+ properties: {
41
+ gpu_type: { type: "string" },
42
+ cpu: { type: "string" },
43
+ node_model: { type: "string" },
44
+ fabric_type: { type: "string" },
45
+ node_count: { type: "integer" },
46
+ gpus_per_node: { type: "integer" },
47
+ site_nickname: { type: "string" },
48
+ site_operator: { type: "string" },
49
+ ram: { type: "string" },
50
+ storage: { type: "string" },
51
+ available_start_at: { type: "string" },
52
+ available_end_at: { type: "string" },
53
+ buy_now_price_per_gpu_hour: { type: "number" },
54
+ cost_per_gpu_hour: { type: "number" },
55
+ deposit_percent: { type: "number" },
56
+ price_omitted: { type: "boolean" },
57
+ announce: { type: "boolean" },
58
+ text: { type: "string" },
59
+ },
60
+ };
61
+ export const listCatalogTermsCapability = defineCapability({
62
+ id: "listings.catalog_terms",
63
+ domain: "listings",
64
+ roles: ["reviewer", "admin"],
65
+ description: "Dump the orchestrator hardware dictionary (display name + aliases). Call once and pick from it. Hardware fields on listings must match a term. Optional kind filter: gpu, cpu, node, or link.",
66
+ mutation: "read",
67
+ http: { method: "GET", path: "/v1/orchestrator/catalog" },
68
+ slack: {
69
+ parameters: {
70
+ type: "object",
71
+ properties: {
72
+ kind: { type: "string", enum: ["gpu", "cpu", "node", "link"] },
73
+ },
74
+ },
75
+ },
76
+ input: catalogTermsInput,
77
+ execute: async (ctx, input) => {
78
+ const parsed = catalogTermsInput.parse(input);
79
+ const hardware = await loadHardwareCatalog(ctx.fetch);
80
+ return projectCatalogTerms(hardware.terms, parsed.kind);
81
+ },
82
+ });
83
+ export const listListingsCapability = defineCapability({
84
+ id: "listings.available",
85
+ domain: "listings",
86
+ roles: ["reviewer", "admin"],
87
+ description: "List currently available marketplace listings. GPU, CPU, and fabric filters must be orchestrator catalog display names, canonicals, or aliases — not raw slang or spec-sheet text. Site may be a facility nickname. Returns spec-shaped JSON plus public reserve/OG/PDF URLs. Filters combine as AND. Date filters are inclusive YYYY-MM-DD.",
88
+ mutation: "read",
89
+ slack: {
90
+ parameters: {
91
+ type: "object",
92
+ properties: {
93
+ gpu: { type: "string", description: "Catalog GPU term such as H100 or NVIDIA H100." },
94
+ cpu: { type: "string", description: "Catalog CPU term such as Xeon or EPYC." },
95
+ site: { type: "string", description: "Location / facility filter such as Dallas." },
96
+ fabric: { type: "string", description: "Catalog link term such as XDR or NDR." },
97
+ min_nodes: { type: "integer", description: "Minimum total nodes." },
98
+ starts_after: {
99
+ type: "string",
100
+ description: "YYYY-MM-DD. Keep listings that start on or after this date.",
101
+ },
102
+ starts_before: {
103
+ type: "string",
104
+ description: "YYYY-MM-DD. Keep listings that start on or before this date.",
105
+ },
106
+ ends_after: {
107
+ type: "string",
108
+ description: "YYYY-MM-DD. Keep listings that end on or after this date.",
109
+ },
110
+ ends_before: {
111
+ type: "string",
112
+ description: "YYYY-MM-DD. Keep listings that end on or before this date.",
113
+ },
114
+ },
115
+ },
116
+ },
117
+ input: listingCreateInput,
118
+ execute: async (ctx, input) => {
119
+ const parsed = listingCreateInput.parse(input);
120
+ const hardware = await loadHardwareCatalog(ctx.fetch);
121
+ const bound = bindHardwareFields(hardware.terms, parsed, HARDWARE_LISTING_FILTERS);
122
+ if (!bound.ok)
123
+ return bound;
124
+ return ctx.fetch("/listings/available", { method: "GET", body: bound.payload });
125
+ },
126
+ });
127
+ export const createListingCapability = defineCapability({
128
+ id: "listings.create",
129
+ domain: "listings",
130
+ roles: ["reviewer", "admin"],
131
+ description: "Create a marketplace listing. gpu_type, cpu, node_model, and fabric_type must exact-match an orchestrator catalog display name, canonical, or alias. CLI asks y/N. Slack never POSTs until a human replies confirm. MCP creates on this tool call.",
132
+ mutation: "preview-confirm",
133
+ http: { method: "POST", path: "/inventory" },
134
+ slack: { parameters: slackListingCreateParams },
135
+ input: listingCreateInput,
136
+ execute: async (ctx, input) => {
137
+ const parsed = listingCreateInput.parse(input);
138
+ const hardware = await loadHardwareCatalog(ctx.fetch);
139
+ const { gpu, fabric, ...rest } = parsed;
140
+ const normalized = {
141
+ ...rest,
142
+ gpu_type: parsed.gpu_type ?? gpu,
143
+ fabric_type: parsed.fabric_type ?? fabric,
144
+ };
145
+ const bound = bindHardwareFields(hardware.terms, normalized, HARDWARE_LISTING_FIELDS);
146
+ if (!bound.ok)
147
+ return bound;
148
+ const payload = { backing_mode: "forward", ...bound.payload };
149
+ if (!ctx.confirmed) {
150
+ return { ok: true, action: "create_listing", payload };
151
+ }
152
+ return ctx.fetch("/inventory", { method: "POST", body: payload });
153
+ },
154
+ });
155
+ export const listingCapabilities = [
156
+ listCatalogTermsCapability,
157
+ listListingsCapability,
158
+ createListingCapability,
159
+ ];
160
+ /** @deprecated Use createListingCapability. */
161
+ export const proposeListingCapability = createListingCapability;
@@ -0,0 +1,2 @@
1
+ export declare const usersQueryCapability: import("../capability.ts").Capability<unknown, unknown>;
2
+ export declare const userCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ function queryPath(path, params) {
4
+ const parts = [];
5
+ for (const [key, value] of Object.entries(params)) {
6
+ if (value?.trim())
7
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.trim())}`);
8
+ }
9
+ return parts.length ? `${path}?${parts.join("&")}` : path;
10
+ }
11
+ const usersQueryInput = z.object({
12
+ name: z.string().optional(),
13
+ email: z.string().optional(),
14
+ organization: z.string().optional(),
15
+ q: z.string().optional(),
16
+ max: z.number().int().positive().max(1000).optional(),
17
+ limit: z.number().int().positive().max(1000).optional(),
18
+ });
19
+ export const usersQueryCapability = defineCapability({
20
+ id: "users.query",
21
+ domain: "users",
22
+ roles: ["admin"],
23
+ description: "Admin-only directory search. Filter by name, email, or organization. Optional max (default 100, cap 1000). Returns matching users and organizations.",
24
+ mutation: "read",
25
+ http: { method: "GET", path: "/internal/users" },
26
+ slack: {
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ name: { type: "string" },
31
+ email: { type: "string" },
32
+ organization: { type: "string" },
33
+ max: { type: "integer", description: "Maximum rows to return (1-1000)." },
34
+ },
35
+ },
36
+ },
37
+ input: usersQueryInput,
38
+ execute: async (ctx, input) => {
39
+ const parsed = usersQueryInput.parse(input);
40
+ const q = [parsed.q, parsed.name, parsed.email, parsed.organization].find((value) => value?.trim());
41
+ return ctx.fetch(queryPath("/internal/users", {
42
+ q,
43
+ email: parsed.email,
44
+ organization: parsed.organization,
45
+ limit: String(parsed.max ?? parsed.limit ?? 100),
46
+ }), { method: "GET" });
47
+ },
48
+ });
49
+ export const userCapabilities = [usersQueryCapability];
@@ -0,0 +1,61 @@
1
+ import type { ZodType } from "zod";
2
+ import type { ClientKind, Role } from "./role.ts";
3
+ export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing"];
4
+ export type Domain = (typeof DOMAINS)[number];
5
+ export type Mutation = "read" | "preview-confirm" | "local";
6
+ export type HttpSpec = {
7
+ method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
8
+ path: string;
9
+ };
10
+ export type CapabilityTransport = (path: string, init?: {
11
+ method?: string;
12
+ body?: unknown;
13
+ headers?: Record<string, string>;
14
+ }) => Promise<unknown>;
15
+ export type CapabilityContext = {
16
+ actor: string;
17
+ role: Role;
18
+ fetch: CapabilityTransport;
19
+ /** Human (or MCP) confirmed a preview-confirm mutation. Execute never POSTs without this. */
20
+ confirmed?: boolean;
21
+ };
22
+ export type JsonSchema = {
23
+ type: "object";
24
+ properties?: Record<string, unknown>;
25
+ required?: string[];
26
+ };
27
+ export type CapabilityBindings = {
28
+ cli?: {
29
+ command: string[];
30
+ };
31
+ mcp?: {
32
+ name: string;
33
+ };
34
+ slack?: {
35
+ name: string;
36
+ parameters?: JsonSchema;
37
+ };
38
+ };
39
+ export type Capability<I = unknown, O = unknown> = CapabilityBindings & {
40
+ id: string;
41
+ domain: Domain;
42
+ roles: readonly Role[];
43
+ description: string;
44
+ mutation: Mutation;
45
+ http?: HttpSpec;
46
+ input: ZodType<I>;
47
+ execute: (ctx: CapabilityContext, input: I) => Promise<O>;
48
+ };
49
+ export type CapabilitySpec<I = unknown, O = unknown> = Omit<Capability<I, O>, "cli" | "mcp" | "slack"> & {
50
+ slack?: {
51
+ parameters?: JsonSchema;
52
+ };
53
+ };
54
+ /** Fill CLI/MCP/Slack names from `id`. Slack parameters stay optional. */
55
+ export declare function defineCapability<I, O>(spec: CapabilitySpec<I, O>): Capability;
56
+ export declare function slackStaffVisible(capability: Pick<Capability, "roles">): boolean;
57
+ export declare function visibleToClient(capability: Pick<Capability, "roles" | "cli" | "mcp" | "slack">, client: ClientKind, role: Role): boolean;
58
+ export declare function forRole(catalog: readonly Capability[], role: Role, client: ClientKind): Capability[];
59
+ export declare function byMcpName(catalog: readonly Capability[], name: string): Capability | undefined;
60
+ export declare function bySlackName(catalog: readonly Capability[], name: string): Capability | undefined;
61
+ export declare function byCliCommand(catalog: readonly Capability[], command: string[]): Capability | undefined;