@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/README.md +8 -0
- package/dist/check.d.ts +82 -3
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +326 -11
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +178 -17
- package/dist/cli.js.map +1 -1
- package/dist/init-agents.d.ts +58 -0
- package/dist/init-agents.d.ts.map +1 -0
- package/dist/init-agents.js +126 -0
- package/dist/init-agents.js.map +1 -0
- package/dist/introspect.d.ts +77 -2
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +138 -9
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +99 -8
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +49 -21
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +12 -3
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js +21 -4
- package/dist/openapi-tool.js.map +1 -1
- package/dist/port.d.ts +144 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +498 -0
- package/dist/port.js.map +1 -0
- package/docs/examples.json +91 -133
- package/docs/llms-full.txt +1090 -35
- package/package.json +6 -5
- package/src/check.ts +398 -14
- package/src/cli.ts +176 -21
- package/src/init-agents.ts +176 -0
- package/src/introspect.ts +208 -8
- package/src/mcp-render.ts +106 -11
- package/src/mcp.ts +56 -20
- package/src/openapi-tool.ts +24 -4
- package/src/port.ts +663 -0
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
|
-
|
|
31
|
-
|
|
32
|
-
(for
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
}
|