@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
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@ bunx cortex apply # make it match ./cortex.manifest.ts
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
Commands are noun-first, `cortex <resource> <procedure>`, except `new`, which acts on no existing
|
|
13
|
-
resource, `apply`, which
|
|
14
|
-
on your profiles. Run `cortex --help`, then `cortex <resource> --help`.
|
|
13
|
+
resource, `setup` and `apply`, which act on a whole deployment, and `login`, `logout` and `status`,
|
|
14
|
+
which act on your profiles. Run `cortex --help`, then `cortex <resource> --help`.
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
bun add -d @m6d/cortex-cli
|
|
@@ -28,15 +28,16 @@ Scaffolds `./<name>`: a backend for Cortex agents, and Cortex itself to run them
|
|
|
28
28
|
(`userName`) and one native tool (`rollDice`), mounted at `/cortex` by `index.ts`.
|
|
29
29
|
- `cortex.manifest.ts` declares a `helper` agent on that server, for `cortex apply`.
|
|
30
30
|
- `docker-compose.yml` runs Cortex's app, worker and engine on the one `syscodeia/cortex` image,
|
|
31
|
-
with Postgres, Redis and MinIO. Only the app and the engine publish host ports
|
|
32
|
-
time from what was free.
|
|
33
|
-
- `.env` holds
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
clone gets its own secrets and a stack that never shares the
|
|
37
|
-
keeps
|
|
38
|
-
- `
|
|
39
|
-
|
|
31
|
+
with Postgres, Redis and MinIO. Only the app and the engine publish host ports. They and the
|
|
32
|
+
backend's port are picked at scaffold time from what was free.
|
|
33
|
+
- `.env` holds every setting: Cortex's secrets, the backend's key and name, the model provider, and
|
|
34
|
+
the `CORTEX_URL` and `CORTEX_KEY` the CLI reaches this checkout's Cortex with. It is not
|
|
35
|
+
committed: `setup.ts` writes it from `.env.example` on every `bun install`, generating each
|
|
36
|
+
secret left blank, so a fresh clone gets its own secrets and a stack that never shares the
|
|
37
|
+
original's volumes. `.dockerignore` keeps both out of images.
|
|
38
|
+
- `bun run dev` starts Cortex (`docker compose up`), the backend and `bun cortex setup --wait --yes` in
|
|
39
|
+
an mprocs dashboard. `bun run dev:agent` runs the same through `concurrently`, one labelled log for
|
|
40
|
+
coding agents and CI.
|
|
40
41
|
- eslint, prettier, cspell, fallow and tsconfig are inlined, so `bun run verify` works standalone.
|
|
41
42
|
|
|
42
43
|
It then runs `bun install`, and `git init` unless the target is already inside a work tree. An
|
|
@@ -47,20 +48,48 @@ manifest takes its id from the name and Cortex keeps the id `cortex` for its own
|
|
|
47
48
|
bunx @m6d/cortex-cli new shop --datasets
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
On a TTY it asks only what the flags left open: the name,
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
On a TTY it asks only what the flags left open: the name, whether to set up the `helper` agent on
|
|
52
|
+
the first `bun run dev`, and if so the model provider (name, base URL, API key, model). It checks
|
|
53
|
+
the provider with one chat message to the model, and reuses it for embeddings when it serves the
|
|
54
|
+
stack's embedding model. With no TTY it never prompts and behaves as `--yes`, so a missing name is a
|
|
55
|
+
hard error rather than a hang.
|
|
53
56
|
|
|
54
|
-
| Flag
|
|
55
|
-
|
|
|
56
|
-
| `--datasets`
|
|
57
|
-
| `--
|
|
57
|
+
| Flag | Default | Writes |
|
|
58
|
+
| ------------------------ | ------- | ------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `--datasets` | off | `datasets.ts`, an in-memory `orders` table served as a dataset host, and its manifest entry |
|
|
60
|
+
| `--setup` / `--no-setup` | on | `cortex setup` among the processes `bun run dev` starts |
|
|
61
|
+
| `--yes` | | Accept the defaults instead of asking; setup then runs with no model provider |
|
|
58
62
|
|
|
59
63
|
The image runs at the Cortex release that was current when this CLI was published
|
|
60
64
|
(`CORTEX_VERSION` in the shell picks another); the backend depends on the `@m6d/cortex` release of
|
|
61
65
|
that time. A server name stops at Cortex's 100 characters, so a longer project name is
|
|
62
66
|
registered cut to that length.
|
|
63
67
|
|
|
68
|
+
## `cortex setup`
|
|
69
|
+
|
|
70
|
+
Registers a `cortex new` project's backend in its Cortex, then applies its manifest. It reads the
|
|
71
|
+
backend's name, URL and key (`CORTEX_SERVER_*`) and the model provider (`MODEL_PROVIDER_*`) from
|
|
72
|
+
`.env`, and goes in the one order Cortex accepts: the server key's secret, the provider's key and
|
|
73
|
+
the provider, the server on the provider's model, a sync of the server, then the apply. Each step
|
|
74
|
+
creates what is missing and keeps what exists, so a second run changes nothing. A key's secret is
|
|
75
|
+
named for its value (`openai-api-key-1a2b3c4d`), so a changed key gets a new secret the provider or
|
|
76
|
+
server moves to, and whatever else presents the old one keeps it. A provider or server under
|
|
77
|
+
`.env`'s name that calls another URL belongs to something else: setup leaves it alone and stops. So
|
|
78
|
+
does a `MODEL_PROVIDER_*` set only in part, or a provider URL on this machine or a private network
|
|
79
|
+
that `EGRESS_ALLOWED_HOSTS` in `.env` does not admit. A server with no model yet gets the provider's.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
bun cortex setup # asks for a model provider when .env has none
|
|
83
|
+
bun cortex setup --wait --yes # what `bun run dev` runs
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`--wait` waits up to 10 minutes for Cortex to answer, long enough for a first start to pull the
|
|
87
|
+
image. Without `-p`, setup sets up the Cortex `.env` names: a different `CORTEX_URL` exported in the
|
|
88
|
+
shell, which Bun lets win over `.env`, ends the run. The retried sync, a minute while Cortex cannot
|
|
89
|
+
reach the backend, gives up at once on any other refusal. With no model provider and `--yes`, it
|
|
90
|
+
registers the server and applies nothing: Cortex publishes no agent without a model. The run that
|
|
91
|
+
first creates something says how to sign in, with the password only on a terminal.
|
|
92
|
+
|
|
64
93
|
## Management commands
|
|
65
94
|
|
|
66
95
|
Manage a Cortex deployment from the terminal. The commands come from
|
|
@@ -80,9 +109,9 @@ cortex logout
|
|
|
80
109
|
|
|
81
110
|
Profiles live in `~/.cortex/profiles.json` (owner-only). `-p, --profile <name>`,
|
|
82
111
|
a global option, picks one for any command, and names the one `login` saves
|
|
83
|
-
and `logout` forgets (`default` otherwise)
|
|
84
|
-
|
|
85
|
-
the directory.
|
|
112
|
+
and `logout` forgets (`default` otherwise). `CORTEX_URL` and `CORTEX_KEY`, both
|
|
113
|
+
set, win over the current profile, for CI and for a project's `.env`; a
|
|
114
|
+
profile `-p` names wins over them. `CORTEX_HOME` relocates the directory.
|
|
86
115
|
|
|
87
116
|
The grammar is `cortex <router> <procedure> [id] [flags]`, with routers as
|
|
88
117
|
the API names them (`datasetHosts`, `modelProviders`). Each top-level input
|
|
@@ -144,6 +173,36 @@ export default defineManifest({
|
|
|
144
173
|
});
|
|
145
174
|
```
|
|
146
175
|
|
|
176
|
+
An `openapi` source makes every operation of an OpenAPI document a tool the
|
|
177
|
+
manifest owns, so a backend with many endpoints needs no entry per tool:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
openapi: [
|
|
181
|
+
{
|
|
182
|
+
name: "shop",
|
|
183
|
+
url: "https://shop.example.com/openapi.json",
|
|
184
|
+
credentials: [{ header: "Authorization", prefix: "Bearer ", credential: null }],
|
|
185
|
+
pinned: ["findOrder"],
|
|
186
|
+
// A GET runs at once; any other method waits for the end user's approval.
|
|
187
|
+
approval: { never: ["addOrderNote"], always: ["exportOrders"] },
|
|
188
|
+
audience: { agents: ["support"] },
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Every apply reads the document first, without the source's credentials. An
|
|
194
|
+
operation it adds is created, a changed one updated, and a dropped one
|
|
195
|
+
deleted. A source with credentials is read only over https, or from a host
|
|
196
|
+
the deployment allows, since the document says where they go. A source needs the `tools` write permission even when nothing
|
|
197
|
+
changes. Each tool is named after its `operationId`, made an identifier of at
|
|
198
|
+
most 64 characters; an operation Cortex cannot import is left
|
|
199
|
+
out. Two operations that come out with one name are told apart by a number
|
|
200
|
+
(`getOrders`, `getOrders2`) in the order of their paths. The apply fails
|
|
201
|
+
when `pinned` or `approval` names a tool no operation has (`unknown_tool`)
|
|
202
|
+
or any name such a collision decided (`ambiguous_tool`), since a path the
|
|
203
|
+
document adds or drops could move it to another endpoint. It also fails when an
|
|
204
|
+
operation's name is already a listed tool's or another source's.
|
|
205
|
+
|
|
147
206
|
The manifest is checked against the contract before anything is sent, then
|
|
148
207
|
sent as written, so Cortex fills its own defaults. It goes to the
|
|
149
208
|
management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
|
|
@@ -173,8 +232,9 @@ it; drop it first, then apply again.
|
|
|
173
232
|
|
|
174
233
|
## Exit codes
|
|
175
234
|
|
|
176
|
-
| Command | 0
|
|
177
|
-
| ------- |
|
|
178
|
-
| `new` | Files written — even if `bun install` failed
|
|
179
|
-
| `
|
|
180
|
-
|
|
|
235
|
+
| Command | 0 | 1 |
|
|
236
|
+
| ------- | ---------------------------------------------------------- | --------------------------------------------------- |
|
|
237
|
+
| `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
|
|
238
|
+
| `setup` | Set up, or the server registered with no model to apply on | Not a project, or Cortex refused or was unreachable |
|
|
239
|
+
| `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or refused |
|
|
240
|
+
| others | The procedure ran | It failed, or couldn't run |
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import pkg from "../package.json";
|
|
|
4
4
|
import { registerApply } from "@/commands/apply";
|
|
5
5
|
import { registerManagement } from "@/commands/management";
|
|
6
6
|
import { registerNew } from "@/commands/new";
|
|
7
|
+
import { registerSetup } from "@/commands/setup";
|
|
7
8
|
import * as report from "@/ui/report";
|
|
8
9
|
|
|
9
10
|
const program = new Command()
|
|
@@ -34,6 +35,7 @@ const program = new Command()
|
|
|
34
35
|
// Each command owns its help group, so the two headings survive these files being
|
|
35
36
|
// rewritten. Registration order fixes the order the groups print in.
|
|
36
37
|
registerNew(program);
|
|
38
|
+
registerSetup(program);
|
|
37
39
|
registerApply(program);
|
|
38
40
|
// The management commands come from Cortex's API, so this one may fetch
|
|
39
41
|
// before parsing; it looks at argv to decide whether it needs to, which it
|
package/src/commands/apply.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as report from "@/ui/report";
|
|
|
12
12
|
|
|
13
13
|
// Discovery is this exact name in the cwd, with no upward walk, so the
|
|
14
14
|
// argument's default is the discovery rule.
|
|
15
|
-
const DEFAULT_MANIFEST = "./cortex.manifest.ts";
|
|
15
|
+
export const DEFAULT_MANIFEST = "./cortex.manifest.ts";
|
|
16
16
|
|
|
17
17
|
const CHANGES = ["create", "update", "delete"] as const;
|
|
18
18
|
const MARKS = { create: "+", update: "~", delete: "-" } satisfies Record<
|
|
@@ -40,39 +40,13 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
40
40
|
const profile = requireProfile(profileName);
|
|
41
41
|
|
|
42
42
|
report.header(dryRun ? "cortex apply --dry-run" : "cortex apply");
|
|
43
|
-
const manifest = await loadManifest(file, verbose);
|
|
44
43
|
report.input("Manifest", label(file));
|
|
45
44
|
report.input("Cortex", profile.url);
|
|
46
45
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
52
|
-
manifest,
|
|
53
|
-
),
|
|
54
|
-
);
|
|
55
|
-
// The secrets are read apart from the changes: a sync has committed by
|
|
56
|
-
// now, and a summary this CLI is too old to read must not lose them.
|
|
57
|
-
const { triggerSecrets = {} } = appliedManifestSchema
|
|
58
|
-
.pick({ triggerSecrets: true })
|
|
59
|
-
.partial()
|
|
60
|
-
.parse(answer);
|
|
61
|
-
const applied = manifestChangesSchema.safeParse(answer);
|
|
62
|
-
if (!applied.success) {
|
|
63
|
-
printTriggerSecrets(triggerSecrets);
|
|
64
|
-
report.fail({
|
|
65
|
-
what: `${profile.url} answered with a summary this CLI cannot read`,
|
|
66
|
-
blocks: [
|
|
67
|
-
`The ${dryRun ? "preview" : "apply"} went through. Update the CLI to read its summary:`,
|
|
68
|
-
" bun add -d @m6d/cortex-cli@latest",
|
|
69
|
-
],
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
printChanges(applied.data);
|
|
73
|
-
const created = countNames(applied.data.create);
|
|
74
|
-
const updated = countNames(applied.data.update);
|
|
75
|
-
const deleted = countNames(applied.data.delete);
|
|
46
|
+
const { changes, triggerSecrets } = await applyManifest({ profile, file, dryRun, verbose });
|
|
47
|
+
const created = countNames(changes.create);
|
|
48
|
+
const updated = countNames(changes.update);
|
|
49
|
+
const deleted = countNames(changes.delete);
|
|
76
50
|
report.footer(
|
|
77
51
|
created + updated + deleted === 0
|
|
78
52
|
? "no changes"
|
|
@@ -84,6 +58,49 @@ export function registerApply(program: Command<[], { verbose?: true; profile?: s
|
|
|
84
58
|
});
|
|
85
59
|
}
|
|
86
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Loads the manifest at `file` and sends it, a preview under `dryRun`, then
|
|
63
|
+
* prints its changes one row per entry. Ends the run on an invalid file, a
|
|
64
|
+
* refusal, or a summary this CLI cannot read. `cortex apply` and `cortex
|
|
65
|
+
* setup` both apply through it; each prints the trigger secrets it returns.
|
|
66
|
+
*/
|
|
67
|
+
export async function applyManifest(options: {
|
|
68
|
+
profile: ReturnType<typeof requireProfile>;
|
|
69
|
+
file: string;
|
|
70
|
+
dryRun: boolean;
|
|
71
|
+
verbose: boolean;
|
|
72
|
+
}) {
|
|
73
|
+
const { profile, file, dryRun, verbose } = options;
|
|
74
|
+
const manifest = await loadManifest(file, verbose);
|
|
75
|
+
const answer = await callOrFail(profile, () =>
|
|
76
|
+
callManagement(
|
|
77
|
+
profile,
|
|
78
|
+
"post",
|
|
79
|
+
dryRun ? "/manifests/preview" : "/manifests/sync",
|
|
80
|
+
manifest,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
// The secrets are read apart from the changes: a sync has committed by
|
|
84
|
+
// now, and a summary this CLI is too old to read must not lose them.
|
|
85
|
+
const { triggerSecrets = {} } = appliedManifestSchema
|
|
86
|
+
.pick({ triggerSecrets: true })
|
|
87
|
+
.partial()
|
|
88
|
+
.parse(answer);
|
|
89
|
+
const applied = manifestChangesSchema.safeParse(answer);
|
|
90
|
+
if (!applied.success) {
|
|
91
|
+
printTriggerSecrets(triggerSecrets);
|
|
92
|
+
report.fail({
|
|
93
|
+
what: `${profile.url} answered with a summary this CLI cannot read`,
|
|
94
|
+
blocks: [
|
|
95
|
+
`The ${dryRun ? "preview" : "apply"} went through. Update the CLI to read its summary:`,
|
|
96
|
+
" bun add -d @m6d/cortex-cli@latest",
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
printChanges(applied.data);
|
|
101
|
+
return { changes: applied.data, triggerSecrets };
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
/** How a manifest path prints: relative to the cwd, `./`-prefixed. */
|
|
88
105
|
function label(path: string) {
|
|
89
106
|
const relativePath = relative(process.cwd(), path);
|
|
@@ -91,7 +108,7 @@ function label(path: string) {
|
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
/** The manifest's absolute path; a missing file ends the run before anything prints. */
|
|
94
|
-
function findManifest(path: string) {
|
|
111
|
+
export function findManifest(path: string) {
|
|
95
112
|
const absolute = resolve(process.cwd(), path);
|
|
96
113
|
if (existsSync(absolute)) return absolute;
|
|
97
114
|
return report.fail({
|
|
@@ -156,7 +173,7 @@ function countNames(byKind: Record<string, string[]>) {
|
|
|
156
173
|
* The fire secrets this apply minted, for each trigger it created or whose
|
|
157
174
|
* `fireCredential` it dropped; nothing shows them again.
|
|
158
175
|
*/
|
|
159
|
-
function printTriggerSecrets(secrets: Record<string, string>) {
|
|
176
|
+
export function printTriggerSecrets(secrets: Record<string, string>) {
|
|
160
177
|
const entries = Object.entries(secrets);
|
|
161
178
|
if (entries.length === 0) return;
|
|
162
179
|
report.note(
|
|
@@ -4,7 +4,13 @@ import { type Command, Option } from "@commander-js/extra-typings";
|
|
|
4
4
|
import { canCarrySecret } from "@cortex/contracts/egress-policy";
|
|
5
5
|
import { isRecord } from "@cortex/contracts/json";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
callManagement,
|
|
9
|
+
inputSchemaOf,
|
|
10
|
+
loadOpenApi,
|
|
11
|
+
ManagementError,
|
|
12
|
+
UnreachableError,
|
|
13
|
+
} from "@/management/client";
|
|
8
14
|
import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
|
|
9
15
|
import { flagsFor, parseAnswer, toOption } from "@/management/flags";
|
|
10
16
|
import type { Flag } from "@/management/flags";
|
|
@@ -383,30 +389,35 @@ async function publishAfterWrite(
|
|
|
383
389
|
}
|
|
384
390
|
}
|
|
385
391
|
|
|
386
|
-
/**
|
|
387
|
-
* A management refusal, or a Cortex that cannot be reached, is the
|
|
388
|
-
* run's outcome, in the error grammar, with whatever the refusal names;
|
|
389
|
-
* anything else is a bug.
|
|
390
|
-
*/
|
|
392
|
+
/** `call`'s result, or the run ended by `failCall`. */
|
|
391
393
|
export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promise<T>) {
|
|
392
394
|
try {
|
|
393
395
|
return await call();
|
|
394
396
|
} catch (error) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
397
|
+
return failCall(profile, error);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A management refusal, or a Cortex that cannot be reached, is the
|
|
403
|
+
* run's outcome, in the error grammar, with whatever the refusal names;
|
|
404
|
+
* anything else is a bug, and rethrown.
|
|
405
|
+
*/
|
|
406
|
+
export function failCall(profile: { url: string }, error: unknown): never {
|
|
407
|
+
if (error instanceof ManagementError) {
|
|
408
|
+
report.fail({
|
|
409
|
+
what: `${error.status} ${error.code}: ${error.message}`,
|
|
410
|
+
blocks: refusalBlocks(error.detail ?? {}),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (error instanceof UnreachableError) {
|
|
414
|
+
report.fail({
|
|
415
|
+
what: `Could not reach ${profile.url}`,
|
|
416
|
+
error,
|
|
417
|
+
blocks: ["Check the URL and that Cortex is up."],
|
|
418
|
+
});
|
|
409
419
|
}
|
|
420
|
+
throw error;
|
|
410
421
|
}
|
|
411
422
|
|
|
412
423
|
/**
|
|
@@ -527,7 +538,7 @@ function readJsonBody(source: string) {
|
|
|
527
538
|
return parsed;
|
|
528
539
|
}
|
|
529
540
|
|
|
530
|
-
async function walkPages(
|
|
541
|
+
export async function walkPages(
|
|
531
542
|
profile: ResolvedProfile,
|
|
532
543
|
method: "get" | "post",
|
|
533
544
|
path: string,
|
package/src/commands/new.ts
CHANGED
|
@@ -6,7 +6,8 @@ import type { Command } from "@commander-js/extra-typings";
|
|
|
6
6
|
import { CORTEX_MANIFEST_ID } from "@cortex/contracts/manifest";
|
|
7
7
|
|
|
8
8
|
import pkg from "../../package.json";
|
|
9
|
-
import {
|
|
9
|
+
import { askProvider } from "@/commands/setup";
|
|
10
|
+
import { AGENT_SLUG, manifestIdOf, scaffoldFiles } from "@/scaffold/files";
|
|
10
11
|
import { allocatePorts, type Ports } from "@/scaffold/ports";
|
|
11
12
|
import { askConfirm, askName, intro, log, outro, runSteps } from "@/ui/prompts";
|
|
12
13
|
import * as report from "@/ui/report";
|
|
@@ -24,12 +25,13 @@ export function registerNew(program: Command<[], { verbose?: true }>) {
|
|
|
24
25
|
.helpGroup("Project:")
|
|
25
26
|
.description("Scaffold a backend, its agent, and Cortex to run it on")
|
|
26
27
|
.argument("[name]", "Directory to create, and the generated package name")
|
|
27
|
-
// Both halves of the toggle, so the flag can answer its question either
|
|
28
|
-
// way and `--yes` is not the only way to say "no". Declaring the positive
|
|
29
|
-
// first leaves the value `undefined` when neither is passed, which is
|
|
30
|
-
// what marks the question as still unanswered.
|
|
31
28
|
.option("--datasets", "Serve an example table as a dataset host")
|
|
32
|
-
|
|
29
|
+
// Both halves of the toggle, so the flag can answer its question either
|
|
30
|
+
// way and `--yes` is not the only way to say "yes". Declaring the
|
|
31
|
+
// positive first leaves the value `undefined` when neither is passed,
|
|
32
|
+
// which is what marks the question as still unanswered.
|
|
33
|
+
.option("--setup", `Set up the ${AGENT_SLUG} agent on the first bun run dev`)
|
|
34
|
+
.option("--no-setup", "Leave Cortex empty; you register the backend yourself")
|
|
33
35
|
.option("--yes", "Accept the defaults instead of asking");
|
|
34
36
|
|
|
35
37
|
command.action(async function (name, options) {
|
|
@@ -39,7 +41,7 @@ export function registerNew(program: Command<[], { verbose?: true }>) {
|
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
type Options = { datasets?: boolean; yes?:
|
|
44
|
+
type Options = { datasets?: true; setup?: boolean; yes?: true; verbose: boolean };
|
|
43
45
|
|
|
44
46
|
async function scaffold(name: string | undefined, options: Options) {
|
|
45
47
|
const { verbose } = options;
|
|
@@ -61,17 +63,15 @@ async function scaffold(name: string | undefined, options: Options) {
|
|
|
61
63
|
|
|
62
64
|
const project = name ?? (await askName(checkName)).trim();
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
options.datasets ??
|
|
67
|
-
(interactive && (await askConfirm("Serve an example table as a dataset host?")));
|
|
66
|
+
const datasets = options.datasets === true;
|
|
67
|
+
const { setup, provider } = await askSetup(options.setup, interactive);
|
|
68
68
|
|
|
69
69
|
const target = resolve(process.cwd(), project);
|
|
70
70
|
|
|
71
71
|
// Drawn here rather than inside the generators, which stay pure: picking a
|
|
72
72
|
// port means probing the machine for a free one.
|
|
73
73
|
const ports = allocatePorts();
|
|
74
|
-
const files = scaffoldFiles({ name: project, datasets, ports });
|
|
74
|
+
const files = scaffoldFiles({ name: project, datasets, setup, ports, env: provider?.env });
|
|
75
75
|
let failedInstall: string | undefined;
|
|
76
76
|
|
|
77
77
|
await runSteps([
|
|
@@ -81,7 +81,8 @@ async function scaffold(name: string | undefined, options: Options) {
|
|
|
81
81
|
for (const [path, contents] of Object.entries(files)) {
|
|
82
82
|
const file = join(target, path);
|
|
83
83
|
mkdirSync(dirname(file), { recursive: true });
|
|
84
|
-
|
|
84
|
+
// .env holds the provider's key before setup.ts adds the rest.
|
|
85
|
+
writeFileSync(file, contents, { mode: path === ".env" ? 0o600 : 0o644 });
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
const summary = [
|
|
@@ -89,6 +90,7 @@ async function scaffold(name: string | undefined, options: Options) {
|
|
|
89
90
|
...(datasets ? ["dataset host"] : []),
|
|
90
91
|
"manifest",
|
|
91
92
|
"Cortex",
|
|
93
|
+
...(setup ? ["first-run setup"] : []),
|
|
92
94
|
];
|
|
93
95
|
return `Wrote ${Object.keys(files).length} files to ./${project}\n${summary.join(" · ")}`;
|
|
94
96
|
},
|
|
@@ -136,7 +138,25 @@ async function scaffold(name: string | undefined, options: Options) {
|
|
|
136
138
|
if (failedInstall !== undefined) warnInstall(project, failedInstall, verbose);
|
|
137
139
|
|
|
138
140
|
outro(`Done in ${seconds(started)}`);
|
|
139
|
-
process.stdout.write(nextSteps(project, ports));
|
|
141
|
+
process.stdout.write(nextSteps(project, ports, setup, provider !== undefined));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether the first `bun run dev` sets up the agent, and the model provider
|
|
146
|
+
* it runs on. A flag settles the first question; otherwise it is asked, or
|
|
147
|
+
* under `--yes` answered yes. The provider has no default, so only a terminal
|
|
148
|
+
* asks for one; without it, setup registers the server and stops.
|
|
149
|
+
*/
|
|
150
|
+
async function askSetup(flag: boolean | undefined, interactive: boolean) {
|
|
151
|
+
const setup =
|
|
152
|
+
flag ??
|
|
153
|
+
(!interactive ||
|
|
154
|
+
(await askConfirm(
|
|
155
|
+
`Set up the ${AGENT_SLUG} agent when you first run bun run dev?`,
|
|
156
|
+
true,
|
|
157
|
+
)));
|
|
158
|
+
const provider = setup && interactive ? await askProvider() : undefined;
|
|
159
|
+
return { setup, provider };
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
/**
|
|
@@ -156,17 +176,20 @@ function warnInstall(project: string, output: string, verbose: boolean) {
|
|
|
156
176
|
);
|
|
157
177
|
}
|
|
158
178
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
step(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
/** What the first `bun run dev` sets up: the agent, the server alone (no model yet), or nothing. */
|
|
180
|
+
function nextSteps(project: string, ports: Ports, setup: boolean, provider: boolean) {
|
|
181
|
+
const runs = {
|
|
182
|
+
agent: step("bun run dev", `→ sets up ${AGENT_SLUG}, then prints where to chat`),
|
|
183
|
+
server:
|
|
184
|
+
step("bun run dev", "→ registers the server") +
|
|
185
|
+
step("bun cortex setup", "adds a model provider, then applies"),
|
|
186
|
+
none:
|
|
187
|
+
step("bun run dev", `→ Cortex at http://localhost:${ports.cortex}`) +
|
|
188
|
+
step("README.md", "register the server, then bun cortex apply"),
|
|
189
|
+
};
|
|
190
|
+
const run = setup ? (provider ? runs.agent : runs.server) : runs.none;
|
|
191
|
+
|
|
192
|
+
return `\nNext steps:\n\n${step(`cd ${project}`)}${run}\n`;
|
|
170
193
|
}
|
|
171
194
|
|
|
172
195
|
async function install(cwd: string) {
|