@m6d/cortex-cli 0.13.0 → 0.14.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.
- package/README.md +86 -26
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/apply.ts +50 -33
- package/src/commands/management.ts +32 -21
- package/src/commands/new.ts +48 -25
- package/src/commands/setup.ts +610 -0
- package/src/contracts/manifest/index.ts +67 -26
- package/src/contracts/server/index.ts +108 -6
- package/src/contracts/wire/index.ts +7 -2
- package/src/management/client.ts +14 -0
- package/src/management/profiles.ts +6 -4
- package/src/scaffold/files.ts +183 -144
- package/src/scaffold/ports.ts +5 -6
- package/src/scaffold/templates/cspell.json.txt +41 -0
- package/src/scaffold/templates/dockerignore.txt +3 -4
- package/src/scaffold/templates/gitignore.txt +1 -2
- package/src/scaffold/templates/setup.ts.txt +18 -26
- package/src/ui/prompts.ts +23 -10
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
IDENTIFIER_PATTERN,
|
|
8
8
|
localeSchema,
|
|
9
9
|
SERVER_EVENTS,
|
|
10
|
+
refuseBlockedApprovalTitle,
|
|
10
11
|
serverToolSchema,
|
|
11
12
|
} from "../server/index";
|
|
12
13
|
import { MESSAGE_TRIGGER_NAME } from "../triggers/index";
|
|
@@ -573,7 +574,7 @@ const toolBaseSchema = z.strictObject({
|
|
|
573
574
|
audience: audienceSchema,
|
|
574
575
|
});
|
|
575
576
|
|
|
576
|
-
/** A tool that calls an endpoint; `
|
|
577
|
+
/** A tool that calls an endpoint; with `requiresApproval`, every call waits for the end user to approve it. */
|
|
577
578
|
const calledToolSchema = toolBaseSchema.extend({
|
|
578
579
|
inputSchema: objectSchemaOf(true).default(EMPTY_OBJECT),
|
|
579
580
|
url: upstreamUrlSchema.refine(
|
|
@@ -581,33 +582,66 @@ const calledToolSchema = toolBaseSchema.extend({
|
|
|
581
582
|
"a {parameter} outside the path or query",
|
|
582
583
|
),
|
|
583
584
|
credentials: credentialsSchema,
|
|
584
|
-
|
|
585
|
+
requiresApproval: z.boolean().default(false),
|
|
585
586
|
});
|
|
586
587
|
|
|
587
|
-
const manifestToolSchema = z
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
588
|
+
const manifestToolSchema = z
|
|
589
|
+
.discriminatedUnion("type", [
|
|
590
|
+
calledToolSchema.extend({
|
|
591
|
+
type: z.literal("http"),
|
|
592
|
+
method: z.enum(TOOL_METHODS),
|
|
593
|
+
readOnly: z.boolean(),
|
|
594
|
+
...toolConfigByType.http.shape,
|
|
595
|
+
}),
|
|
596
|
+
/** The endpoint is the initiate call that opens the page; never read-only, so a reload rejoins it. */
|
|
597
|
+
calledToolSchema.extend({
|
|
598
|
+
type: z.literal("embedded"),
|
|
599
|
+
method: z.enum(TOOL_METHODS),
|
|
600
|
+
...toolConfigByType.embedded.shape,
|
|
601
|
+
}),
|
|
602
|
+
/** Always POSTed. */
|
|
603
|
+
calledToolSchema.extend({
|
|
604
|
+
type: z.literal("soap"),
|
|
605
|
+
readOnly: z.boolean(),
|
|
606
|
+
...toolConfigByType.soap.shape,
|
|
607
|
+
}),
|
|
608
|
+
/** Answered by your app: the model calls it and the widget hands the call over. */
|
|
609
|
+
toolBaseSchema.extend({ type: z.literal("client") }),
|
|
610
|
+
])
|
|
611
|
+
.superRefine((tool, context) => {
|
|
612
|
+
if (tool.type !== "client") refuseBlockedApprovalTitle(tool, context, []);
|
|
613
|
+
});
|
|
609
614
|
export type ManifestTool = z.input<typeof manifestToolSchema>;
|
|
610
615
|
|
|
616
|
+
const toolNamesSchema = z.array(serverToolSchema.shape.name).max(500).default([]);
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* An OpenAPI document whose operations become HTTP tools this manifest owns.
|
|
620
|
+
* Each apply reads it, imports every operation Cortex supports, keeps those
|
|
621
|
+
* tools in step with the document and deletes the ones it no longer has.
|
|
622
|
+
* Every tool presents `credentials`. A GET call runs at once; any other
|
|
623
|
+
* method waits for the end user's approval, unless `approval` names the
|
|
624
|
+
* tool the other way.
|
|
625
|
+
*/
|
|
626
|
+
const manifestOpenApiSchema = z.strictObject({
|
|
627
|
+
/** Names the source in what an apply reports. */
|
|
628
|
+
name: nameSchema,
|
|
629
|
+
/** Where the document is read, with no credentials: OpenAPI 3 or Swagger 2, JSON or YAML. */
|
|
630
|
+
url: upstreamUrlSchema,
|
|
631
|
+
credentials: credentialsSchema,
|
|
632
|
+
/** Tools listed in every turn of every agent they reach, whatever the question. */
|
|
633
|
+
pinned: toolNamesSchema,
|
|
634
|
+
approval: z
|
|
635
|
+
.strictObject({ never: toolNamesSchema, always: toolNamesSchema })
|
|
636
|
+
.prefault({})
|
|
637
|
+
.refine(
|
|
638
|
+
({ never, always }) => !never.some((name) => always.includes(name)),
|
|
639
|
+
"a tool both never and always needs approval",
|
|
640
|
+
),
|
|
641
|
+
audience: audienceSchema,
|
|
642
|
+
});
|
|
643
|
+
export type ManifestOpenApi = z.input<typeof manifestOpenApiSchema>;
|
|
644
|
+
|
|
611
645
|
/** Identified by its `key`, which skills and collections scope themselves by; `name` is its label. */
|
|
612
646
|
const manifestServiceSchema = z.strictObject({
|
|
613
647
|
/** What consumers parameterize their tools with; fixed once published. */
|
|
@@ -896,6 +930,7 @@ export const manifestSchema = z.strictObject({
|
|
|
896
930
|
id: slugSchema,
|
|
897
931
|
agents: listOf(manifestAgentSchema, (agent) => agent.slug),
|
|
898
932
|
tools: listOf(manifestToolSchema, byName),
|
|
933
|
+
openapi: listOf(manifestOpenApiSchema, byName),
|
|
899
934
|
services: listOf(manifestServiceSchema, (service) => service.key),
|
|
900
935
|
skills: listOf(manifestSkillSchema, byName),
|
|
901
936
|
triggers: listOf(manifestTriggerSchema, byName),
|
|
@@ -907,8 +942,14 @@ export const manifestSchema = z.strictObject({
|
|
|
907
942
|
/** A manifest as written: every field with a default may be left out. */
|
|
908
943
|
export type Manifest = z.input<typeof manifestSchema>;
|
|
909
944
|
|
|
910
|
-
/**
|
|
911
|
-
|
|
945
|
+
/**
|
|
946
|
+
* Entry names per kind: agents by slug, services by key, everything else by
|
|
947
|
+
* name. An OpenAPI source's operations are counted among `tools`.
|
|
948
|
+
*/
|
|
949
|
+
const namesByKindSchema = z.record(
|
|
950
|
+
manifestSchema.keyof().exclude(["id", "openapi"]),
|
|
951
|
+
z.array(z.string()),
|
|
952
|
+
);
|
|
912
953
|
|
|
913
954
|
/** What an apply creates, updates and deletes, kind by kind; a preview answers the same without doing it. */
|
|
914
955
|
export const manifestChangesSchema = z.object({
|
|
@@ -2,7 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
import { REQUEST_LOCATION_TOOL } from "../client-tools/index";
|
|
3
3
|
import { backendErrorSchema } from "../errors/index";
|
|
4
4
|
import { pendingApprovalSchema, TRIGGER_ANSWER_TOOL_NAME } from "../triggers/index";
|
|
5
|
-
import {
|
|
5
|
+
import { isRecord, unescapePointerToken } from "../json/index";
|
|
6
|
+
import { APPROVAL_TITLE_FIELD, LOCALES } from "../wire/index";
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
* The server protocol: what Cortex calls on a customer's backend. `GET /`
|
|
@@ -108,11 +109,14 @@ export function isBuiltInToolName(name: string) {
|
|
|
108
109
|
return BUILT_IN_TOOL_NAMES.some((builtIn) => builtIn === name);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** The longest name a tool takes, a server's or one defined in Cortex. */
|
|
113
|
+
export const TOOL_NAME_MAX_LENGTH = 64;
|
|
114
|
+
|
|
111
115
|
/** A tool name, a server's or one defined in Cortex: an identifier no built-in tool holds. */
|
|
112
116
|
const declaredToolNameSchema = z
|
|
113
117
|
.string()
|
|
114
118
|
.regex(IDENTIFIER_PATTERN)
|
|
115
|
-
.max(
|
|
119
|
+
.max(TOOL_NAME_MAX_LENGTH)
|
|
116
120
|
.refine((name) => !isBuiltInToolName(name), "the name of a built-in tool");
|
|
117
121
|
|
|
118
122
|
const jsonSchemaSchema = z.record(z.string(), z.unknown());
|
|
@@ -135,8 +139,9 @@ export type ServerVariable = z.infer<typeof serverVariableSchema>;
|
|
|
135
139
|
|
|
136
140
|
/**
|
|
137
141
|
* A tool the server runs. Its input is an object, the `input` its calls
|
|
138
|
-
* carry.
|
|
139
|
-
*
|
|
142
|
+
* carry. `requiresApproval` parks every call for the end user's approval;
|
|
143
|
+
* such a tool's input cannot declare `APPROVAL_TITLE_FIELD`, which the
|
|
144
|
+
* model fills for the approval card.
|
|
140
145
|
*/
|
|
141
146
|
export const serverToolSchema = z.object({
|
|
142
147
|
name: declaredToolNameSchema,
|
|
@@ -147,7 +152,7 @@ export const serverToolSchema = z.object({
|
|
|
147
152
|
),
|
|
148
153
|
outputSchema: jsonSchemaSchema.optional(),
|
|
149
154
|
readOnly: z.boolean(),
|
|
150
|
-
|
|
155
|
+
requiresApproval: z.boolean().optional(),
|
|
151
156
|
});
|
|
152
157
|
export type ServerTool = z.infer<typeof serverToolSchema>;
|
|
153
158
|
|
|
@@ -179,6 +184,97 @@ export const approvalRequestedEventSchema = z.object({
|
|
|
179
184
|
});
|
|
180
185
|
export type ApprovalRequestedEvent = z.infer<typeof approvalRequestedEventSchema>;
|
|
181
186
|
|
|
187
|
+
/** A JSON Pointer token as a key: percent-decoded, then unescaped; undefined when its encoding is broken. */
|
|
188
|
+
function pointerKey(token: string) {
|
|
189
|
+
try {
|
|
190
|
+
return unescapePointerToken(decodeURIComponent(token));
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What a local `$ref` (`#/$defs/order`) points at in `root`, read as the input compiler reads it; undefined for any other. */
|
|
197
|
+
function pointedAt(root: unknown, ref: string) {
|
|
198
|
+
if (!ref.startsWith("#")) return undefined;
|
|
199
|
+
let node = root;
|
|
200
|
+
for (const token of ref.slice(1).split("/").slice(1)) {
|
|
201
|
+
const key = pointerKey(token);
|
|
202
|
+
node =
|
|
203
|
+
key !== undefined && (isRecord(node) || Array.isArray(node))
|
|
204
|
+
? new Map<string, unknown>(Object.entries(node)).get(key)
|
|
205
|
+
: undefined;
|
|
206
|
+
}
|
|
207
|
+
return node;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Whether one schema node names `APPROVAL_TITLE_FIELD` as a property or a required field. */
|
|
211
|
+
function namesApprovalTitle(node: Record<string, unknown>) {
|
|
212
|
+
const required: unknown = node.required;
|
|
213
|
+
return (
|
|
214
|
+
(isRecord(node.properties) && Object.hasOwn(node.properties, APPROVAL_TITLE_FIELD)) ||
|
|
215
|
+
(Array.isArray(required) && required.includes(APPROVAL_TITLE_FIELD))
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What describes the same object as `node`: its `allOf`, `anyOf` and `oneOf` members, and what its local `$ref` points at in `root`. */
|
|
220
|
+
function sameObjectSchemas(node: Record<string, unknown>, root: unknown) {
|
|
221
|
+
const schemas: unknown[] = typeof node.$ref === "string" ? [pointedAt(root, node.$ref)] : [];
|
|
222
|
+
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
223
|
+
const members: unknown = node[keyword];
|
|
224
|
+
if (!Array.isArray(members)) continue;
|
|
225
|
+
const listed: unknown[] = members;
|
|
226
|
+
schemas.push(...listed);
|
|
227
|
+
}
|
|
228
|
+
return schemas;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Every node that describes the same object as an input schema's root: the
|
|
233
|
+
* root, and what `sameObjectSchemas` reaches from it at any depth, each once.
|
|
234
|
+
*/
|
|
235
|
+
function sameObjectNodes(inputSchema: unknown) {
|
|
236
|
+
const nodes = new Array<Record<string, unknown>>();
|
|
237
|
+
const pending = [inputSchema];
|
|
238
|
+
while (pending.length > 0) {
|
|
239
|
+
const node = pending.pop();
|
|
240
|
+
if (!isRecord(node) || nodes.includes(node)) continue;
|
|
241
|
+
nodes.push(node);
|
|
242
|
+
pending.push(...sameObjectSchemas(node, inputSchema));
|
|
243
|
+
}
|
|
244
|
+
return nodes;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether one schema node is closed to properties it does not declare. */
|
|
248
|
+
function closed(node: Record<string, unknown>) {
|
|
249
|
+
return node.additionalProperties === false || node.unevaluatedProperties === false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether an input schema leaves no room for `APPROVAL_TITLE_FIELD`, which
|
|
254
|
+
* Cortex adds to the root of a tool that needs approval: a node describing
|
|
255
|
+
* the input names it, or one besides the root is closed to it, so a strict
|
|
256
|
+
* reading of the schema could not take the title.
|
|
257
|
+
*/
|
|
258
|
+
export function blocksApprovalTitle(inputSchema: unknown) {
|
|
259
|
+
return sameObjectNodes(inputSchema).some(
|
|
260
|
+
(node) => namesApprovalTitle(node) || (node !== inputSchema && closed(node)),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Refuses, under `path`, a tool that needs approval whose input `blocksApprovalTitle`. */
|
|
265
|
+
export function refuseBlockedApprovalTitle(
|
|
266
|
+
tool: { requiresApproval?: boolean; inputSchema: unknown },
|
|
267
|
+
context: z.RefinementCtx,
|
|
268
|
+
path: Array<string | number>,
|
|
269
|
+
) {
|
|
270
|
+
if (!tool.requiresApproval || !blocksApprovalTitle(tool.inputSchema)) return;
|
|
271
|
+
context.addIssue({
|
|
272
|
+
code: "custom",
|
|
273
|
+
path: [...path, "inputSchema"],
|
|
274
|
+
message: `a tool that needs approval cannot take an input named ${APPROVAL_TITLE_FIELD}, nor one a closed sub-schema describes: Cortex adds the approval card's title to it`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
182
278
|
function uniqueNames(entries: ReadonlyArray<{ name: string }>) {
|
|
183
279
|
return new Set(entries.map((entry) => entry.name)).size === entries.length;
|
|
184
280
|
}
|
|
@@ -187,7 +283,13 @@ function uniqueNames(entries: ReadonlyArray<{ name: string }>) {
|
|
|
187
283
|
export const serverManifestSchema = z.object({
|
|
188
284
|
version: z.string().min(1).max(128),
|
|
189
285
|
variables: z.array(serverVariableSchema).max(100).refine(uniqueNames, "variable names repeat"),
|
|
190
|
-
tools: z
|
|
286
|
+
tools: z
|
|
287
|
+
.array(serverToolSchema)
|
|
288
|
+
.max(500)
|
|
289
|
+
.refine(uniqueNames, "tool names repeat")
|
|
290
|
+
.superRefine((tools, context) => {
|
|
291
|
+
tools.forEach((tool, index) => refuseBlockedApprovalTitle(tool, context, [index]));
|
|
292
|
+
}),
|
|
191
293
|
verify: z.boolean(),
|
|
192
294
|
events: z.array(z.enum(SERVER_EVENTS)),
|
|
193
295
|
});
|
|
@@ -171,13 +171,18 @@ export type LocalizedText = Record<Locale, string>;
|
|
|
171
171
|
*/
|
|
172
172
|
export const STATEMENT_TOOLS = ["executeCode", "queryDataset", "describeDataset"] as const;
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The input Cortex adds to every tool that waits for the end user's approval:
|
|
176
|
+
* the model writes what that one call will do, and the approval card shows it
|
|
177
|
+
* as its title. Cortex removes it before the call runs.
|
|
178
|
+
*/
|
|
179
|
+
export const APPROVAL_TITLE_FIELD = "approvalTitle";
|
|
180
|
+
|
|
174
181
|
/**
|
|
175
182
|
* What Cortex stamps on a tool call's `metadata.cortex` from its first chunk,
|
|
176
183
|
* so the live part and the stored one agree.
|
|
177
184
|
*/
|
|
178
185
|
export type ToolCallMetadata = {
|
|
179
|
-
/** The action the approval card names, in the author's words rather than the function's name. */
|
|
180
|
-
approval?: LocalizedText;
|
|
181
186
|
/**
|
|
182
187
|
* Set on every call the client answers: a client tool's, the consumer's own, or a built-in
|
|
183
188
|
* input's. Cortex runs every other call, so only these take an output from a request.
|
package/src/management/client.ts
CHANGED
|
@@ -225,6 +225,8 @@ export async function callManagement(
|
|
|
225
225
|
...(method === "post" ? { "Content-Type": "application/json" } : {}),
|
|
226
226
|
},
|
|
227
227
|
body: method === "post" && input !== undefined ? JSON.stringify(input) : undefined,
|
|
228
|
+
}).catch((error: unknown) => {
|
|
229
|
+
throw new UnreachableError(error);
|
|
228
230
|
});
|
|
229
231
|
const body: unknown = await response.json().catch(() => null);
|
|
230
232
|
if (!response.ok) {
|
|
@@ -261,6 +263,18 @@ function queryString(input: Record<string, unknown>) {
|
|
|
261
263
|
return encoded === "" ? "" : `?${encoded}`;
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Cortex did not answer at all: refused, reset, unresolvable. `fetch` rejects
|
|
268
|
+
* only for these, with whatever its runtime throws (Bun's is a plain `Error`
|
|
269
|
+
* with a `code`), so `callManagement` names them once here.
|
|
270
|
+
*/
|
|
271
|
+
export class UnreachableError extends Error {
|
|
272
|
+
public constructor(cause: unknown) {
|
|
273
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
274
|
+
this.name = "UnreachableError";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
264
278
|
/** A refusal; `detail` is what the refusal names, when it names something (an entry, the row holding a name). */
|
|
265
279
|
export class ManagementError extends Error {
|
|
266
280
|
public constructor(
|
|
@@ -87,14 +87,16 @@ export function removeProfile(name: string) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* The profile a management command runs against: `
|
|
91
|
-
* when both are set (CI, containers
|
|
92
|
-
* the
|
|
90
|
+
* The profile a management command runs against: the entry `name` picks from
|
|
91
|
+
* the file, else `CORTEX_URL` + `CORTEX_KEY` when both are set (CI, containers,
|
|
92
|
+
* a project's .env), else the current entry. A name always wins, so `-p prod`
|
|
93
|
+
* inside a project whose .env points at its own Cortex still reaches prod.
|
|
94
|
+
* `undefined` means nobody has logged in.
|
|
93
95
|
*/
|
|
94
96
|
export function resolveProfile(name?: string) {
|
|
95
97
|
const url = process.env["CORTEX_URL"];
|
|
96
98
|
const key = process.env["CORTEX_KEY"];
|
|
97
|
-
if (url && key) return { name: "env", url: url.replace(/\/+$/, ""), key };
|
|
99
|
+
if (name === undefined && url && key) return { name: "env", url: url.replace(/\/+$/, ""), key };
|
|
98
100
|
const store = readStore();
|
|
99
101
|
const chosen = name ?? store.current;
|
|
100
102
|
const profile = lookup(store, chosen);
|