@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 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 acts on a whole deployment, and `login`, `logout` and `status`, which act
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, picked at scaffold
32
- time from what was free.
33
- - `.env` holds the backend's own key and the checkout's Compose project name, and `cortex.env`
34
- Cortex's secrets, which only Compose reads. Neither is committed: `setup.ts` writes both from
35
- their `.example` copies on every `bun install`, generating each value left blank, so a fresh
36
- clone gets its own secrets and a stack that never shares the original's volumes. `.dockerignore`
37
- keeps all four out of images.
38
- - `README.md` lists the steps: start the stack and the backend, register the server in the
39
- console, then `cortex apply`.
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, and whether to add the dataset host. With
51
- no TTY it never prompts and behaves as `--yes`, so a missing name is a hard error rather than a
52
- hang.
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 | Default | Writes |
55
- | ------------------------------ | ------- | ------------------------------------------------------------------------------------------- |
56
- | `--datasets` / `--no-datasets` | off | `datasets.ts`, an in-memory `orders` table served as a dataset host, and its manifest entry |
57
- | `--yes` | | Accept the defaults instead of asking |
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); `CORTEX_URL` and
84
- `CORTEX_KEY`, both set, win over the file for CI. `CORTEX_HOME` relocates
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 | 1 |
177
- | ------- | -------------------------------------------- | ----------------------------------- |
178
- | `new` | Files written — even if `bun install` failed | Bad target, missing name, cancelled |
179
- | `apply` | Applied, or previewed with `--dry-run` | Invalid manifest, or refused |
180
- | others | The procedure ran | It failed, or couldn't run |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
@@ -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 answer = await callOrFail(profile, () =>
48
- callManagement(
49
- profile,
50
- "post",
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 { callManagement, inputSchemaOf, loadOpenApi, ManagementError } from "@/management/client";
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
- if (error instanceof ManagementError) {
396
- report.fail({
397
- what: `${error.status} ${error.code}: ${error.message}`,
398
- blocks: refusalBlocks(error.detail ?? {}),
399
- });
400
- }
401
- if (error instanceof TypeError) {
402
- report.fail({
403
- what: `Could not reach ${profile.url}`,
404
- error,
405
- blocks: ["Check the URL and that Cortex is up."],
406
- });
407
- }
408
- throw error;
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,
@@ -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 { manifestIdOf, scaffoldFiles } from "@/scaffold/files";
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
- .option("--no-datasets", "Leave the dataset host out")
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?: boolean; verbose: boolean };
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
- // A flag settles the question; otherwise it is asked, or under `--yes` left out.
65
- const datasets =
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
- writeFileSync(file, contents);
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
- function nextSteps(project: string, ports: Ports) {
160
- return (
161
- `\nNext steps:\n\n` +
162
- step(`cd ${project}`) +
163
- step("edit cortex.env", "fill in EMBEDDING_API_KEY") +
164
- step("docker compose up -d", `→ http://localhost:${ports.cortex}`) +
165
- // The port the generated index.ts listens on; only Cortex's ports move.
166
- step("bun run dev", "→ http://localhost:3331") +
167
- step("README.md", "register the server, then cortex apply") +
168
- `\n`
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) {