@nifrajs/cli 0.1.0-alpha.1 → 0.1.0-alpha.3

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/src/cli.ts CHANGED
@@ -1,7 +1,4 @@
1
1
  #!/usr/bin/env bun
2
- import { inProcessClient } from "@nifrajs/client"
3
- import { createWebApp, type RenderAdapter } from "@nifrajs/web"
4
- import { discoverRoutes } from "@nifrajs/web/fs"
5
2
  /**
6
3
  * `nifra` — the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
7
4
  * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
@@ -12,24 +9,52 @@ import { discoverRoutes } from "@nifrajs/web/fs"
12
9
  *
13
10
  * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
14
11
  */
12
+ import { existsSync } from "node:fs"
13
+ import { resolve } from "node:path"
14
+ import { inProcessClient } from "@nifrajs/client"
15
+ import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
16
+ import { discoverRoutes } from "@nifrajs/web/fs"
15
17
  import type { BunPlugin } from "bun"
16
- import { describeProject } from "./introspect.ts"
18
+ import { describeProject, describeRoutes } from "./introspect.ts"
17
19
  import { type LoadedApp, loadApp, resolvePlugins } from "./load.ts"
18
20
 
19
- interface Flags {
21
+ export interface Flags {
20
22
  readonly port: number
21
23
  readonly out: string
22
24
  readonly poll: boolean
25
+ /** `nifra build --target <t>`: emit a full deploy dir for a target (bun/node/deno/cf-pages/vercel/
26
+ * static) instead of just the client bundle. Undefined ⇒ the legacy client-only build. */
27
+ readonly target?: string
28
+ /** `nifra build --report`: print a per-chunk size + gzip table after the build. */
29
+ readonly report: boolean
23
30
  }
24
31
 
25
32
  const HELP = `nifra — zero-config dev/build/start for a nifra app
26
33
 
27
34
  Usage:
28
- nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite).
35
+ nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
29
36
  nifra build [--out <dir>] Bundle the client + write manifest.json.
30
- nifra start [--port <n>] [--out <dir>] Serve the built app (client + SSR) on Bun.
31
- nifra context Print this project's API + page routes + conventions
32
- (for piping into an AI coding agent's prompt).
37
+ [--target <t>] [--report] With --target, emit a FULL deploy dir for <t>:
38
+ bun | node | deno | cf-pages | vercel | static. Packages
39
+ buildClient + buildServer (+ prerender for static) so an app
40
+ no longer hand-writes build-<target>.ts + _worker.ts +
41
+ _routes.json. The server entry is generated from your
42
+ framework.ts (adapter) + backend.ts + routes/. --report prints
43
+ a per-chunk size + gzip table (biggest first).
44
+ nifra start [--port <n>] [--out <dir>] Serve the built app (client + SSR) on Bun. Default port ${DEFAULT_DEV_PORT}.
45
+ nifra context Print this project's route INDEX (API + page routes) + conventions
46
+ for an AI agent's prompt. Per-route schemas: nifra mcp's
47
+ nifra_context (path/kind slice) or nifra routes --json.
48
+ nifra routes [--json] List every route the app serves with its method(s): page
49
+ routes (routes/) + the in-process backend's API routes,
50
+ marking which API routes are auto-mounted under apiPrefix.
51
+ --json for agents (see POST /api/x is/isn't served, not via 405).
52
+ nifra init-agents [--force] [--json] Retrofit an EXISTING app with the agent-discovery files a new
53
+ app ships: .mcp.json + .cursor/mcp.json (register this project's
54
+ nifra MCP), CLAUDE.md (MCP-first preamble + @AGENTS.md import),
55
+ and a "## MCP server" section in AGENTS.md. No-clobber by
56
+ default (skips a file you've customized); --force overwrites the
57
+ owned files. AGENTS.md is only appended to, never overwritten.
33
58
  nifra mcp Start an MCP server (stdio) exposing this project to a coding
34
59
  agent: nifra_context, nifra_routes (routes+schemas as JSON),
35
60
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
@@ -43,9 +68,18 @@ Usage:
43
68
  nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
44
69
  (resolve at Bun runtime, break tsc + standalone install);
45
70
  --auto-fix writes safe local-version dependency fixes.
71
+ nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
72
+ [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
73
+ globals, node: builtins) with file:line evidence. --target auto-
74
+ detected from build/deploy scripts, wrangler.toml, or vercel config;
75
+ --ci (or any --target) exits non-zero when a used feature is
76
+ unsupported on the target; --strict also fails on caveats.
46
77
 
47
78
  Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backend.ts (optional), and
48
- routes/ from the current directory. Run from your project root.`
79
+ routes/ from the current directory. Run from your project root.
80
+
81
+ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
82
+ \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
49
83
 
50
84
  const CLI_VERSION = "0.1.0-alpha.1"
51
85
 
@@ -97,6 +131,68 @@ async function build(app: LoadedApp): Promise<void> {
97
131
  console.log(`nifra build → ${outDir} (entry ${manifest.entry}${manifest.css ? ", + css" : ""})`)
98
132
  }
99
133
 
134
+ /**
135
+ * `nifra build --target <t>` — package the engine (buildClient + buildServer + prerender) into one
136
+ * command that emits a full deploy dir, so an app no longer hand-writes build-bun.ts + _worker.ts +
137
+ * _routes.json per target. The adapter is imported from `framework.ts` (the edge-bundlable file — never
138
+ * `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
139
+ * `buildTarget` generates the per-target server entry from those + the app's `routes/`.
140
+ */
141
+ async function buildForTarget(app: LoadedApp, target: string, report: boolean): Promise<void> {
142
+ const { buildTarget, isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import(
143
+ "@nifrajs/web/build"
144
+ )
145
+ if (!isBuildTarget(target)) {
146
+ throw new Error(`[nifra] unknown --target "${target}". Valid: ${BUILD_TARGETS.join(", ")}.`)
147
+ }
148
+ const { framework: fw, routesDir, outDir, cwd, backend } = app
149
+ // The server entry must import the adapter from `framework.ts` (edge-safe), not the loaded config.
150
+ // `loadApp` guarantees one of them exists; prefer framework.ts so a multi-target app's Vite-plugin
151
+ // config never reaches the edge bundle (see load.ts module header).
152
+ const frameworkFile = existsSync(resolve(cwd, "framework.ts"))
153
+ ? resolve(cwd, "framework.ts")
154
+ : existsSync(resolve(cwd, "nifra.config.ts"))
155
+ ? resolve(cwd, "nifra.config.ts")
156
+ : resolve(cwd, "framework.ts")
157
+ const backendFile = resolve(cwd, "backend.ts")
158
+ const result = await buildTarget(target, {
159
+ routesDir,
160
+ outDir,
161
+ workDir: resolve(cwd, ".nifra-build"),
162
+ clientModule: fw.clientModule,
163
+ adapterImport: frameworkFile,
164
+ ...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
165
+ clientPlugins: asBunPlugins(await resolvePlugins(fw.clientPlugins)),
166
+ serverPlugins: asBunPlugins(await resolvePlugins(fw.serverPlugins)),
167
+ ...(fw.conditions ? { conditions: fw.conditions } : {}),
168
+ ...(fw.define ? { define: fw.define } : {}),
169
+ // The static target needs a built app to drive prerendering — only build it when targeting static.
170
+ ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
171
+ })
172
+ console.log(`nifra build (${target}) → ${result.run}`)
173
+ if (report) console.log(`\n${renderSizeReport(result.size)}`)
174
+ }
175
+
176
+ /** Build a fetch-capable app instance for the `static` target's prerender pass. Mirrors `nifra start`:
177
+ * register the framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import), discover routes,
178
+ * and wire the in-process backend so build-time loaders can read data. The returned `Server` already
179
+ * satisfies `buildTarget`'s `PrerenderAppLike` (a `fetch(req)` yielding a `Response`). */
180
+ async function buildPrerenderApp(
181
+ app: LoadedApp,
182
+ ): Promise<{ fetch(req: Request): Response | Promise<Response> }> {
183
+ const { plugin } = await import("bun")
184
+ const { framework: fw, routesDir, backend } = app
185
+ for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
186
+ return createWebApp({
187
+ adapter: asAdapter(fw.adapter),
188
+ manifest: discoverRoutes(routesDir),
189
+ // The client entry is irrelevant to the static HTML (it's a hydration script tag) — a placeholder
190
+ // is fine; the real hashed bundle is emitted to /assets by buildTarget's client build.
191
+ clientEntry: "/assets/_nifra-entry.js",
192
+ ...apiOf(backend),
193
+ })
194
+ }
195
+
100
196
  interface BuiltManifest {
101
197
  readonly entry: string
102
198
  readonly routes?: Readonly<Record<string, readonly string[]>>
@@ -144,20 +240,27 @@ async function start(app: LoadedApp, flags: Flags): Promise<void> {
144
240
  console.log(`nifra start → http://localhost:${running.port}`)
145
241
  }
146
242
 
147
- function parseFlags(args: readonly string[]): Flags {
148
- let port = Number(Bun.env.PORT ?? 3000)
243
+ export function parseFlags(args: readonly string[]): Flags {
244
+ // Port precedence: `--port`/`-p` (most specific) > `PORT` env > the framework default. The default is
245
+ // the SAME uncommon port for `nifra dev` and `nifra start` (DEFAULT_DEV_PORT) so a project's URL is
246
+ // stable across commands and doesn't collide with the usual 3000/5173/8080 crowd.
247
+ let port = Number(Bun.env.PORT ?? DEFAULT_DEV_PORT)
149
248
  let out = "dist"
150
249
  let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
250
+ let target: string | undefined
251
+ let report = false
151
252
  for (let i = 0; i < args.length; i++) {
152
253
  const a = args[i]
153
254
  if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
154
255
  else if (a === "--out" && args[i + 1]) out = args[++i] as string
155
256
  else if (a === "--poll") poll = true
257
+ else if (a === "--target" && args[i + 1]) target = args[++i]
258
+ else if (a === "--report") report = true
156
259
  }
157
260
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
158
261
  throw new Error(`[nifra] invalid --port: ${port}`)
159
262
  }
160
- return { port, out, poll }
263
+ return { port, out, poll, report, ...(target !== undefined ? { target } : {}) }
161
264
  }
162
265
 
163
266
  async function main(): Promise<void> {
@@ -204,6 +307,17 @@ async function main(): Promise<void> {
204
307
  process.exitCode = 1
205
308
  return
206
309
  }
310
+ // `init-agents` retrofits the agent-discovery files (.mcp.json, CLAUDE.md, …) into the cwd. It's a
311
+ // pure file-writing command independent of the app loading, so dispatch it before the eager `loadApp`
312
+ // (an existing app might be API-only or not yet built). It always succeeds unless a write throws.
313
+ if (command === "init-agents") {
314
+ const { runInitAgents } = await import("./init-agents.ts")
315
+ await runInitAgents(process.cwd(), {
316
+ json: argv.includes("--json"),
317
+ force: argv.includes("--force"),
318
+ })
319
+ return
320
+ }
207
321
  // `doctor` is a pure cwd check (imports vs declared deps) — like `check`, dispatch before `loadApp`
208
322
  // so it runs on an API-only / not-yet-built project.
209
323
  if (command === "doctor") {
@@ -217,7 +331,38 @@ async function main(): Promise<void> {
217
331
  process.exitCode = 1
218
332
  return
219
333
  }
220
- if (command !== "dev" && command !== "build" && command !== "start" && command !== "context") {
334
+ // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
335
+ // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
336
+ if (command === "port") {
337
+ const { runPort } = await import("./port.ts")
338
+ const targetIdx = argv.indexOf("--target")
339
+ const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
340
+ if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
341
+ console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
342
+ process.exitCode = 1
343
+ return
344
+ }
345
+ try {
346
+ const passed = await runPort(process.cwd(), {
347
+ ...(target !== undefined ? { target } : {}),
348
+ json: argv.includes("--json"),
349
+ ci: argv.includes("--ci"),
350
+ strict: argv.includes("--strict"),
351
+ })
352
+ if (!passed) process.exitCode = 1
353
+ } catch (err) {
354
+ console.error(err instanceof Error ? err.message : String(err))
355
+ process.exitCode = 1
356
+ }
357
+ return
358
+ }
359
+ if (
360
+ command !== "dev" &&
361
+ command !== "build" &&
362
+ command !== "start" &&
363
+ command !== "context" &&
364
+ command !== "routes"
365
+ ) {
221
366
  console.error(`[nifra] unknown command: ${command}\n`)
222
367
  console.error(HELP)
223
368
  process.exitCode = 1
@@ -226,12 +371,22 @@ async function main(): Promise<void> {
226
371
  const flags = parseFlags(argv.slice(1))
227
372
  const app = await loadApp(process.cwd(), flags.out)
228
373
  if (command === "dev") await dev(app, flags)
229
- else if (command === "build") await build(app)
230
- else if (command === "context") console.log(describeProject(app))
231
- else await start(app, flags)
374
+ else if (command === "build") {
375
+ // `--target` (or `--report`) → the full per-target deploy build; otherwise the legacy client build.
376
+ if (flags.target !== undefined || flags.report) {
377
+ await buildForTarget(app, flags.target ?? "cf-pages", flags.report)
378
+ } else await build(app)
379
+ } else if (command === "context") console.log(describeProject(app))
380
+ else if (command === "routes") {
381
+ console.log(await describeRoutes(app, { json: argv.includes("--json") }))
382
+ } else await start(app, flags)
232
383
  }
233
384
 
234
- main().catch((err) => {
235
- console.error(err instanceof Error ? err.message : String(err))
236
- process.exitCode = 1
237
- })
385
+ // Only run the CLI when invoked as the entry (`bun cli.ts …`), not when a test imports it for the
386
+ // exported `parseFlags`. `import.meta.main` is true only for the process's entry module.
387
+ if (import.meta.main) {
388
+ main().catch((err) => {
389
+ console.error(err instanceof Error ? err.message : String(err))
390
+ process.exitCode = 1
391
+ })
392
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `nifra init-agents` — retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
3
+ * app ships, so an already-built project adopts the nifra MCP in one command:
4
+ *
5
+ * .mcp.json — Claude Code's project MCP registry (launches `bunx @nifrajs/cli mcp`)
6
+ * .cursor/mcp.json — Cursor's MCP registry (same server config)
7
+ * CLAUDE.md — Claude's MCP-first preamble + `@AGENTS.md` import
8
+ * AGENTS.md — a `## MCP server` section appended (or a minimal file if none exists)
9
+ *
10
+ * The generators are imported from `create-nifra/agent-files` — the SAME source of truth `create-nifra`
11
+ * uses at scaffold time — so a retrofitted app and a freshly scaffolded one get byte-identical configs.
12
+ *
13
+ * Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
14
+ * customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
15
+ * notice; `--force` overwrites. `AGENTS.md` is special-cased — if it already has the MCP section it's
16
+ * left alone, otherwise the section is APPENDED (never overwriting the user's conventions), and `--force`
17
+ * is not needed for that append since it's additive. Every write path is resolved + confined under the
18
+ * cwd (no `..` traversal escaping the project root).
19
+ */
20
+
21
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
22
+ import { isAbsolute, relative, resolve } from "node:path"
23
+ import {
24
+ AGENTS_MD_PATH,
25
+ agentsMcpSection,
26
+ CLAUDE_MD_PATH,
27
+ CURSOR_MCP_JSON_PATH,
28
+ claudeMd,
29
+ MCP_JSON_PATH,
30
+ mcpJson,
31
+ } from "create-nifra/agent-files"
32
+
33
+ /** What happened to one file during the retrofit, for the printed report + the `--json` shape. */
34
+ export interface InitAgentsFileResult {
35
+ /** Project-root-relative POSIX path. */
36
+ readonly path: string
37
+ /** `wrote` — created or (with --force) overwrote; `appended` — added the MCP section to an existing
38
+ * AGENTS.md; `skipped` — already present and not forced; `present` — MCP section already there. */
39
+ readonly action: "wrote" | "appended" | "skipped" | "present"
40
+ /** Why it was skipped/left, for the notice (e.g. "exists — pass --force to overwrite"). */
41
+ readonly note?: string
42
+ }
43
+
44
+ export interface InitAgentsResult {
45
+ readonly cwd: string
46
+ readonly files: readonly InitAgentsFileResult[]
47
+ }
48
+
49
+ export interface InitAgentsOptions {
50
+ /** Overwrite an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` instead of skipping it. */
51
+ readonly force?: boolean
52
+ }
53
+
54
+ /** Confine a project-relative path under `cwd` and return the absolute path. Rejects a spec that escapes
55
+ * the root (defense-in-depth — these specs are constants, but the cwd-confinement invariant is enforced
56
+ * at the seam, not assumed). Exported so the invariant is directly testable. */
57
+ export function safeJoin(cwd: string, rel: string): string {
58
+ const abs = resolve(cwd, rel)
59
+ const within = relative(cwd, abs)
60
+ if (within.startsWith("..") || isAbsolute(within)) {
61
+ throw new Error(`[nifra] refusing to write outside the project root: ${rel}`)
62
+ }
63
+ return abs
64
+ }
65
+
66
+ const fileExists = (path: string): Promise<boolean> =>
67
+ readFile(path)
68
+ .then(() => true)
69
+ .catch(() => false)
70
+
71
+ /**
72
+ * Write a whole-file generator's output to `path` unless it already exists and `force` is false.
73
+ * Returns the per-file result for the report.
74
+ */
75
+ async function writeOwned(
76
+ cwd: string,
77
+ rel: string,
78
+ content: string,
79
+ force: boolean,
80
+ ): Promise<InitAgentsFileResult> {
81
+ const abs = safeJoin(cwd, rel)
82
+ if (!force && (await fileExists(abs))) {
83
+ return { path: rel, action: "skipped", note: "exists — pass --force to overwrite" }
84
+ }
85
+ // `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
86
+ await mkdir(resolve(abs, ".."), { recursive: true })
87
+ await writeFile(abs, content)
88
+ return { path: rel, action: "wrote" }
89
+ }
90
+
91
+ /**
92
+ * AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
93
+ * without the section, append the section (preserving the user's conventions); if it's absent, write a
94
+ * minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here — we never
95
+ * overwrite the user's existing guidance.
96
+ */
97
+ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
98
+ const abs = safeJoin(cwd, AGENTS_MD_PATH)
99
+ const section = agentsMcpSection()
100
+ let existing: string | undefined
101
+ try {
102
+ existing = await readFile(abs, "utf8")
103
+ } catch {
104
+ existing = undefined // no AGENTS.md yet
105
+ }
106
+ if (existing === undefined) {
107
+ await writeFile(
108
+ abs,
109
+ `# AGENTS.md\n\nGuidance for AI coding agents working in this repo.\n\n${section}\n`,
110
+ )
111
+ return { path: AGENTS_MD_PATH, action: "wrote" }
112
+ }
113
+ // Match on the section heading, not the full body, so a reformatted-but-present section still counts.
114
+ if (existing.includes("## MCP server")) {
115
+ return { path: AGENTS_MD_PATH, action: "present", note: "already has an MCP section" }
116
+ }
117
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n"
118
+ await writeFile(abs, `${existing}${sep}${section}\n`)
119
+ return { path: AGENTS_MD_PATH, action: "appended" }
120
+ }
121
+
122
+ /**
123
+ * Retrofit `cwd` with the four agent-discovery files. Pure enough to unit-test (no argv, no process.exit,
124
+ * no console) — the CLI wrapper handles printing + the exit code.
125
+ */
126
+ export async function initAgents(
127
+ cwd: string,
128
+ opts: InitAgentsOptions = {},
129
+ ): Promise<InitAgentsResult> {
130
+ const force = opts.force ?? false
131
+ // Order: the two MCP registries + CLAUDE.md (owned, no-clobber), then AGENTS.md (additive).
132
+ const files: InitAgentsFileResult[] = []
133
+ files.push(await writeOwned(cwd, MCP_JSON_PATH, mcpJson(), force))
134
+ files.push(await writeOwned(cwd, CURSOR_MCP_JSON_PATH, mcpJson(), force))
135
+ files.push(await writeOwned(cwd, CLAUDE_MD_PATH, claudeMd(), force))
136
+ files.push(await ensureAgentsMd(cwd))
137
+ return { cwd, files }
138
+ }
139
+
140
+ const ACTION_GLYPH: Readonly<Record<InitAgentsFileResult["action"], string>> = {
141
+ wrote: "✓ wrote",
142
+ appended: "✓ appended MCP section to",
143
+ skipped: "• skipped",
144
+ present: "• kept",
145
+ }
146
+
147
+ /** Format the retrofit result for the terminal. */
148
+ export function renderInitAgents(result: InitAgentsResult): string {
149
+ const lines = result.files.map((f) => {
150
+ const tail = f.note ? ` (${f.note})` : ""
151
+ return ` ${ACTION_GLYPH[f.action]} ${f.path}${tail}`
152
+ })
153
+ const wroteAny = result.files.some((f) => f.action === "wrote" || f.action === "appended")
154
+ const footer = wroteAny
155
+ ? "\nThe nifra MCP is now registered. Restart your agent so it picks up .mcp.json, then prefer nifra_docs / nifra_example and gate on nifra check."
156
+ : "\nNothing to do — every file was already present (use --force to overwrite the owned ones)."
157
+ return `nifra init-agents\n\n${lines.join("\n")}\n${footer}`
158
+ }
159
+
160
+ /**
161
+ * CLI entry: run the retrofit at `cwd` and print the result. Returns `true` (the command always succeeds
162
+ * unless a write throws — which propagates as a non-zero exit via the dispatcher's catch). `--json`
163
+ * emits the structured result for agents/CI.
164
+ */
165
+ export async function runInitAgents(
166
+ cwd: string,
167
+ opts: { readonly json?: boolean; readonly force?: boolean } = {},
168
+ ): Promise<boolean> {
169
+ const result = await initAgents(cwd, { force: opts.force ?? false })
170
+ if (opts.json) {
171
+ console.log(JSON.stringify(result, null, 2))
172
+ } else {
173
+ console.log(renderInitAgents(result))
174
+ }
175
+ return true
176
+ }