@nifrajs/cli 2.2.0 → 2.4.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.
Files changed (135) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +61 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +336 -87
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +30 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +148 -39
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +3 -3
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +12 -8
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/learn.d.ts +32 -0
  37. package/dist/learn.d.ts.map +1 -0
  38. package/dist/learn.js +127 -0
  39. package/dist/learn.js.map +1 -0
  40. package/dist/levels-tool.d.ts +8 -8
  41. package/dist/levels-tool.d.ts.map +1 -1
  42. package/dist/levels-tool.js +33 -30
  43. package/dist/levels-tool.js.map +1 -1
  44. package/dist/load.d.ts +6 -6
  45. package/dist/load.d.ts.map +1 -1
  46. package/dist/load.js +15 -7
  47. package/dist/load.js.map +1 -1
  48. package/dist/manifest-tool.d.ts +1 -1
  49. package/dist/manifest-tool.d.ts.map +1 -1
  50. package/dist/manifest-tool.js +4 -2
  51. package/dist/manifest-tool.js.map +1 -1
  52. package/dist/mcp-app-tool.d.ts +14 -0
  53. package/dist/mcp-app-tool.d.ts.map +1 -0
  54. package/dist/mcp-app-tool.js +80 -0
  55. package/dist/mcp-app-tool.js.map +1 -0
  56. package/dist/mcp-docs-tools.d.ts +1 -1
  57. package/dist/mcp-docs-tools.d.ts.map +1 -1
  58. package/dist/mcp-docs-tools.js +25 -9
  59. package/dist/mcp-docs-tools.js.map +1 -1
  60. package/dist/mcp-http.d.ts +14 -10
  61. package/dist/mcp-http.d.ts.map +1 -1
  62. package/dist/mcp-http.js +30 -16
  63. package/dist/mcp-http.js.map +1 -1
  64. package/dist/mcp-protocol.d.ts +2 -2
  65. package/dist/mcp-protocol.js +2 -2
  66. package/dist/mcp-render.d.ts +3 -3
  67. package/dist/mcp-render.js +6 -6
  68. package/dist/mcp-run.d.ts +2 -2
  69. package/dist/mcp-run.js +2 -2
  70. package/dist/mcp.d.ts +28 -14
  71. package/dist/mcp.d.ts.map +1 -1
  72. package/dist/mcp.js +263 -45
  73. package/dist/mcp.js.map +1 -1
  74. package/dist/openapi-tool.d.ts +1 -1
  75. package/dist/openapi-tool.js +2 -2
  76. package/dist/pipeline-guard.d.ts +1 -1
  77. package/dist/pipeline-guard.js +1 -1
  78. package/dist/port.d.ts +9 -9
  79. package/dist/port.js +27 -27
  80. package/dist/recipes/1.8.0.d.ts +2 -2
  81. package/dist/recipes/1.8.0.js +5 -5
  82. package/dist/recipes/index.d.ts +2 -2
  83. package/dist/recipes/index.js +1 -1
  84. package/dist/scaffold.d.ts +3 -3
  85. package/dist/scaffold.js +8 -8
  86. package/dist/sync-routes.d.ts +28 -0
  87. package/dist/sync-routes.d.ts.map +1 -0
  88. package/dist/sync-routes.js +55 -0
  89. package/dist/sync-routes.js.map +1 -0
  90. package/dist/types-search.d.ts +5 -5
  91. package/dist/types-search.js +7 -7
  92. package/dist/upgrade.d.ts +20 -9
  93. package/dist/upgrade.d.ts.map +1 -1
  94. package/dist/upgrade.js +117 -19
  95. package/dist/upgrade.js.map +1 -1
  96. package/dist/verification.d.ts +51 -0
  97. package/dist/verification.d.ts.map +1 -0
  98. package/dist/verification.js +80 -0
  99. package/dist/verification.js.map +1 -0
  100. package/docs/examples.json +174 -90
  101. package/docs/llms-full.txt +1771 -748
  102. package/docs/types.json +2027 -3089
  103. package/package.json +22 -11
  104. package/src/assure.ts +17 -18
  105. package/src/capabilities-tool.ts +82 -16
  106. package/src/check.ts +410 -104
  107. package/src/cli.ts +161 -41
  108. package/src/diff-tool.ts +8 -8
  109. package/src/docs-search.ts +6 -6
  110. package/src/doctor.ts +18 -10
  111. package/src/examples.ts +7 -7
  112. package/src/init-agents.ts +17 -17
  113. package/src/internal/typescript-import.ts +33 -0
  114. package/src/introspect.ts +28 -28
  115. package/src/learn.ts +152 -0
  116. package/src/levels-tool.ts +37 -31
  117. package/src/load.ts +21 -12
  118. package/src/manifest-tool.ts +4 -2
  119. package/src/mcp-app-tool.ts +86 -0
  120. package/src/mcp-docs-tools.ts +26 -9
  121. package/src/mcp-http.ts +31 -18
  122. package/src/mcp-protocol.ts +2 -2
  123. package/src/mcp-render.ts +6 -6
  124. package/src/mcp-run.ts +2 -2
  125. package/src/mcp.ts +319 -45
  126. package/src/openapi-tool.ts +2 -2
  127. package/src/pipeline-guard.ts +1 -1
  128. package/src/port.ts +31 -31
  129. package/src/recipes/1.8.0.ts +5 -5
  130. package/src/recipes/index.ts +2 -2
  131. package/src/scaffold.ts +8 -8
  132. package/src/sync-routes.ts +69 -0
  133. package/src/types-search.ts +8 -8
  134. package/src/upgrade.ts +137 -19
  135. package/src/verification.ts +123 -0
package/src/examples.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `nifra_example` — verified, copy-pasteable code examples for agents. The corpus is `examples.json`
2
+ * `nifra_example` - verified, copy-pasteable code examples for agents. The corpus is `examples.json`
3
3
  * (written into this package by `bun run gen:llms`): the same doc snippets `check:docs` typechecks
4
4
  * against the live `@nifrajs/*` API. So every example this returns is GUARANTEED to compile against the
5
- * installed version — the antidote to an agent hallucinating a framework API from stale training.
5
+ * installed version - the antidote to an agent hallucinating a framework API from stale training.
6
6
  *
7
7
  * Keyword-scored over each example's name/topic/slug (curated identifiers, heavily weighted) and its
8
8
  * code body. No query → a grouped index of what's available (the cheap discovery call).
@@ -12,9 +12,9 @@ import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-
12
12
 
13
13
  export interface Example {
14
14
  readonly name: string
15
- /** The doc page's title, e.g. "Nifra — Auth & sessions". */
15
+ /** The doc page's title, e.g. "Nifra - Auth & sessions". */
16
16
  readonly topic: string
17
- /** The doc page slug, e.g. "auth" — a short, matchable tag. */
17
+ /** The doc page slug, e.g. "auth" - a short, matchable tag. */
18
18
  readonly slug: string
19
19
  readonly code: string
20
20
  }
@@ -63,19 +63,19 @@ export function renderExamplesResult(
63
63
  const index = [...byTopic.entries()]
64
64
  .map(([topic, names]) => `- **${topic}**: ${names.join(", ")}`)
65
65
  .join("\n")
66
- return `# nifra examples — index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version — prefer these over recalling an API.\n\n${index}`
66
+ return `# nifra examples - index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version - prefer these over recalling an API.\n\n${index}`
67
67
  }
68
68
  const matches = searchExamples(examples, query, limit)
69
69
  if (matches.length === 0) {
70
70
  return `No examples matched ${JSON.stringify(query)}. Call without a query for the index, or try a broader term (e.g. "auth", "upload", "isr", "client").`
71
71
  }
72
72
  return matches
73
- .map((m) => `## ${m.name} — ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
73
+ .map((m) => `## ${m.name} - ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
74
74
  .join("\n\n---\n\n")
75
75
  }
76
76
 
77
77
  /** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
78
- * and `dist/` (published) alike — `docs/examples.json` sits at the package root next to both. */
78
+ * and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
79
79
  export async function loadExamplesCorpus(): Promise<Example[] | undefined> {
80
80
  const url = new URL("../docs/examples.json", import.meta.url)
81
81
  try {
@@ -1,18 +1,18 @@
1
1
  /**
2
- * `nifra init-agents` — retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
2
+ * `nifra init-agents` - retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
3
3
  * app ships, so an already-built project adopts the nifra MCP in one command:
4
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)
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
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.
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
12
  *
13
13
  * Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
14
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
15
+ * notice; `--force` overwrites. `AGENTS.md` is special-cased - if it already has the MCP section it's
16
16
  * left alone, otherwise the section is APPENDED (never overwriting the user's conventions), and `--force`
17
17
  * is not needed for that append since it's additive. Every write path is resolved + confined under the
18
18
  * cwd (no `..` traversal escaping the project root).
@@ -34,10 +34,10 @@ import {
34
34
  export interface InitAgentsFileResult {
35
35
  /** Project-root-relative POSIX path. */
36
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. */
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
39
  readonly action: "wrote" | "appended" | "skipped" | "present"
40
- /** Why it was skipped/left, for the notice (e.g. "exists — pass --force to overwrite"). */
40
+ /** Why it was skipped/left, for the notice (e.g. "exists - pass --force to overwrite"). */
41
41
  readonly note?: string
42
42
  }
43
43
 
@@ -52,7 +52,7 @@ export interface InitAgentsOptions {
52
52
  }
53
53
 
54
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
55
+ * the root (defense-in-depth - these specs are constants, but the cwd-confinement invariant is enforced
56
56
  * at the seam, not assumed). Exported so the invariant is directly testable. */
57
57
  export function safeJoin(cwd: string, rel: string): string {
58
58
  const abs = resolve(cwd, rel)
@@ -80,7 +80,7 @@ async function writeOwned(
80
80
  ): Promise<InitAgentsFileResult> {
81
81
  const abs = safeJoin(cwd, rel)
82
82
  if (!force && (await fileExists(abs))) {
83
- return { path: rel, action: "skipped", note: "exists — pass --force to overwrite" }
83
+ return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
84
84
  }
85
85
  // `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
86
86
  await mkdir(resolve(abs, ".."), { recursive: true })
@@ -91,7 +91,7 @@ async function writeOwned(
91
91
  /**
92
92
  * AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
93
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
94
+ * minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here - we never
95
95
  * overwrite the user's existing guidance.
96
96
  */
97
97
  async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
@@ -121,7 +121,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
121
121
 
122
122
  /**
123
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.
124
+ * no console) - the CLI wrapper handles printing + the exit code.
125
125
  */
126
126
  export async function initAgents(
127
127
  cwd: string,
@@ -153,13 +153,13 @@ export function renderInitAgents(result: InitAgentsResult): string {
153
153
  const wroteAny = result.files.some((f) => f.action === "wrote" || f.action === "appended")
154
154
  const footer = wroteAny
155
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)."
156
+ : "\nNothing to do - every file was already present (use --force to overwrite the owned ones)."
157
157
  return `nifra init-agents\n\n${lines.join("\n")}\n${footer}`
158
158
  }
159
159
 
160
160
  /**
161
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`
162
+ * unless a write throws - which propagates as a non-zero exit via the dispatcher's catch). `--json`
163
163
  * emits the structured result for agents/CI.
164
164
  */
165
165
  export async function runInitAgents(
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The one place the CLI imports TypeScript from.
3
+ *
4
+ * The interpolated-SQL rule parses source with the TypeScript compiler, because the regex it replaced
5
+ * could not tell `sql\`… ${id} …\`` (bound) from a plain template (injectable) without guessing at
6
+ * string and comment boundaries. That is the right tool, but it is a ~25 MB dependency, and the CLI's
7
+ * own typecheck step already treats `tsc` as something the PROJECT provides rather than something the
8
+ * CLI ships. Making it a hard dependency would have contradicted that for every install.
9
+ *
10
+ * So it is an optional peer, resolved at the moment the rule runs. Every Nifra project has TypeScript
11
+ * - the templates all ship a `typecheck` script - so this resolves in practice; the point is that an
12
+ * install does not pay for it.
13
+ */
14
+
15
+ /** The slice of the compiler the SQL scanner uses. Structural, so the peer stays optional. */
16
+ export type TypeScriptApi = typeof import("typescript")
17
+
18
+ /**
19
+ * Import the project's TypeScript, or `undefined` when it is not installed.
20
+ *
21
+ * Only a RESOLUTION failure is absence. A compiler that resolves and then fails while evaluating is a
22
+ * broken install, not a missing one, and telling that user to install what they already have sends
23
+ * them the wrong way - so that error is rethrown for the caller to report.
24
+ */
25
+ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
26
+ try {
27
+ return (await import("typescript")) as TypeScriptApi
28
+ } catch (cause) {
29
+ const message = cause instanceof Error ? cause.message : String(cause)
30
+ if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)) return undefined
31
+ throw cause
32
+ }
33
+ }
package/src/introspect.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Introspect a loaded nifra project into a Markdown brief — the API routes (`backend.routes()`), the
3
- * page routes (`discoverRoutes`), and a conventions summary — for piping into an AI coding agent's
2
+ * Introspect a loaded nifra project into a Markdown brief - the API routes (`backend.routes()`), the
3
+ * page routes (`discoverRoutes`), and a conventions summary - for piping into an AI coding agent's
4
4
  * prompt. Used by `nifra context` (stdout) and the `nifra mcp` server (a tool result), so an agent sees
5
5
  * *this* project's actual surface, not just generic docs.
6
6
  */
@@ -28,7 +28,7 @@ interface JsonSchemaNode {
28
28
  }
29
29
 
30
30
  /**
31
- * Render a JSON Schema as a compact TypeScript-like type (`{ id: string, name?: string }`) — the
31
+ * Render a JSON Schema as a compact TypeScript-like type (`{ id: string, name?: string }`) - the
32
32
  * shape agents already think in, at a fraction of the raw JSON Schema's tokens (~70-80% smaller).
33
33
  * Anything the renderer doesn't model falls back to the raw JSON for that subtree, so the output
34
34
  * is always faithful, just not always minimal.
@@ -74,7 +74,7 @@ export function tsTypeOf(schema: unknown, depth = 0): string {
74
74
  }
75
75
 
76
76
  /** One route's request/response field shapes, indented under its line. Reads the declared schemas off the
77
- * descriptor so an agent gets the exact input + output contract — not just "this route is validated". */
77
+ * descriptor so an agent gets the exact input + output contract - not just "this route is validated". */
78
78
  function schemaLines(schema: ReflectedRoute["schema"]): string[] {
79
79
  const s = schema
80
80
  if (!s) return []
@@ -95,11 +95,11 @@ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
95
95
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
96
96
 
97
97
  /**
98
- * The typed-client call form for a route — the exact `client<typeof app>` proxy chain an agent should
98
+ * The typed-client call form for a route - the exact `client<typeof app>` proxy chain an agent should
99
99
  * write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
100
100
  * client tests to learn it): a static segment is a property (`.users`), a path param/wildcard is a call
101
101
  * that appends the value (`({ id })`), the root path is `.index`, and the HTTP verb is the terminal call.
102
- * Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first —
102
+ * Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first -
103
103
  * so the `{ query }` argument lands in the right slot for each.
104
104
  */
105
105
  export function clientCall(method: string, path: string, schema: unknown): string {
@@ -140,10 +140,10 @@ export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
140
140
  ...schemaLines(r.schema),
141
141
  ` - call: \`${clientCall(r.method, r.path, r.schema)}\` → \`{ ok, status, data, error }\``,
142
142
  ])
143
- return `## API routes (backend.ts)\n\nEach route's \`body\`/\`query\`/\`response\` shape is its contract — the typed client derives request inputs and \`res.data\` from these, so a screen built on \`client<typeof app>\` stays in sync automatically. The \`call\` line is the exact \`client<typeof app>\` form: static path segments are properties, a path param is a call (\`({ id })\`), the verb is the terminal call (body first for POST/PUT/PATCH), and every call returns the never-throwing \`{ ok, status, data, error }\` Result.\n\n${lines.join("\n")}`
143
+ return `## API routes (backend.ts)\n\nEach route's \`body\`/\`query\`/\`response\` shape is its contract - the typed client derives request inputs and \`res.data\` from these, so a screen built on \`client<typeof app>\` stays in sync automatically. The \`call\` line is the exact \`client<typeof app>\` form: static path segments are properties, a path param is a call (\`({ id })\`), the verb is the terminal call (body first for POST/PUT/PATCH), and every call returns the never-throwing \`{ ok, status, data, error }\` Result.\n\n${lines.join("\n")}`
144
144
  }
145
145
 
146
- /** Compact API-routes INDEX for the no-arg `nifra_context` call — `METHOD path` per route, WITHOUT the
146
+ /** Compact API-routes INDEX for the no-arg `nifra_context` call - `METHOD path` per route, WITHOUT the
147
147
  * body/query/response shapes or the per-route `call` line. Bounds the first-call payload on a big backend:
148
148
  * an agent gets the full route list (so it knows what's mounted) plus a pointer to fetch any route's full
149
149
  * contract via the `path`/`kind` slice, instead of every schema up front. */
@@ -154,7 +154,7 @@ export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string
154
154
  const lines = [...routes]
155
155
  .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
156
156
  .map((r) => `- \`${r.method} ${r.path}\``)
157
- return `## API routes (backend.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form — or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
157
+ return `## API routes (backend.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
158
158
  }
159
159
 
160
160
  /** Markdown section listing the file-routed pages (URL pattern → source file). */
@@ -172,25 +172,25 @@ const CONVENTIONS = `## Conventions (summary)
172
172
 
173
173
  - **Backend:** \`server()\` from \`@nifrajs/core\`; returning a value sends JSON. Validate untrusted input with
174
174
  a route schema (\`{ body, query, params }\` using \`t\` from \`@nifrajs/schema\` or any Standard Schema) and read
175
- the typed \`c.body\` / \`c.query\` / \`c.params\` — never hand-parse.
175
+ the typed \`c.body\` / \`c.query\` / \`c.params\` - never hand-parse.
176
176
  - **Cross-cutting concerns** (rate limit / \`429\`, CORS, auth, CSRF, caching, security headers, body limits)
177
177
  → \`@nifrajs/middleware\`, applied with \`app.use(...)\`. Don't hand-roll them; call \`nifra_docs("middleware")\`
178
178
  for the full list + usage.
179
179
  - **Need the exact TypeScript of a \`@nifrajs/*\` symbol?** Call \`nifra_types({ name })\` (e.g. \`RateLimitStore\`,
180
- \`RouteSchema\`) — the authoritative, complete declaration. Never read \`@nifrajs\` \`.d.ts\` files.
180
+ \`RouteSchema\`) - the authoritative, complete declaration. Never read \`@nifrajs\` \`.d.ts\` files.
181
181
  - **Response contract (no drift):** declare \`{ response: t.object({...}) }\` on a route to lock its output
182
- shape — the handler is type-checked against it and the client sees exactly that shape. The contract above
182
+ shape - the handler is type-checked against it and the client sees exactly that shape. The contract above
183
183
  is the single source of truth for both sides.
184
- - **Client — ALWAYS use this for API calls:** \`client<typeof app>(url)\` derives request inputs AND
184
+ - **Client - ALWAYS use this for API calls:** \`client<typeof app>(url)\` derives request inputs AND
185
185
  \`res.data\` from the backend's route types, so the compiler catches any frontend/backend drift. Never
186
- hand-roll \`fetch\` + ad-hoc response types for an internal API — that's exactly how screens drift. It
186
+ hand-roll \`fetch\` + ad-hoc response types for an internal API - that's exactly how screens drift. It
187
187
  never throws: branch on \`res.ok ? res.data : res.error\`.
188
188
  - **Pages:** file-routed under \`routes/\`; \`loader\`/\`action\` are server-only but the module is also bundled
189
- for the browser — **never top-level-import server-only code** (DB, secrets, \`process.env\`) into a route
189
+ for the browser - **never top-level-import server-only code** (DB, secrets, \`process.env\`) into a route
190
190
  file; reach it via \`ctx.api\` / \`ctx.env\`.
191
191
  - \`app.fetch(Request)\` is the universal entry. Full reference: this repo's \`AGENTS.md\`, or \`llms-full.txt\`.`
192
192
 
193
- /** Optional narrowing for {@link describeProject} — a path prefix and/or one section. A filtered
193
+ /** Optional narrowing for {@link describeProject} - a path prefix and/or one section. A filtered
194
194
  * brief omits the conventions block (the agent already has it from its first full call), so
195
195
  * follow-up calls cost a fraction of the tokens. */
196
196
  export interface ContextFilter {
@@ -199,9 +199,9 @@ export interface ContextFilter {
199
199
  }
200
200
 
201
201
  /**
202
- * Build the project brief as Markdown. A no-arg call returns a tight **INDEX** — the route list (API
202
+ * Build the project brief as Markdown. A no-arg call returns a tight **INDEX** - the route list (API
203
203
  * routes as `METHOD path`, page routes as `pattern → file`) + conventions + a pointer to fetch full
204
- * contracts via the slice — so learning a big project is cheap, not a full-surface schema dump. Passing a
204
+ * contracts via the slice - so learning a big project is cheap, not a full-surface schema dump. Passing a
205
205
  * {@link ContextFilter} (`path` prefix and/or `kind`) returns the narrowed, FULL detail (per-route
206
206
  * body/query/response shapes + the typed-client `call` form), and omits the conventions block (the agent
207
207
  * already has it from the index call).
@@ -211,7 +211,7 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
211
211
  try {
212
212
  manifest = discoverRoutes(app.routesDir)
213
213
  } catch {
214
- // No (or unreadable) routes/ — the page-routes section reports that.
214
+ // No (or unreadable) routes/ - the page-routes section reports that.
215
215
  }
216
216
  const prefix = filter?.path
217
217
  let routes = backendRoutes(app.backend)
@@ -228,10 +228,10 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
228
228
  const sections: string[] = [`# nifra project: ${app.cwd.slice(app.cwd.lastIndexOf("/") + 1)}`]
229
229
  if (isIndex) {
230
230
  sections.push(
231
- "A full-stack nifra app — Web-standard, one app on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below).",
232
- // Point agents at the verified surface, not recalled APIs — nifra is young, so training data is the
231
+ "A full-stack nifra app - Web-standard, one app on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below).",
232
+ // Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
233
233
  // wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
234
- "> **Build against THIS surface + the verified tools — do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
234
+ "> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
235
235
  )
236
236
  }
237
237
  if (filter?.kind !== "pages") {
@@ -255,7 +255,7 @@ export interface RouteJson {
255
255
  }
256
256
 
257
257
  /**
258
- * The backend's API routes as structured JSON — what the `nifra_routes` MCP tool returns, so an agent
258
+ * The backend's API routes as structured JSON - what the `nifra_routes` MCP tool returns, so an agent
259
259
  * consumes the contract programmatically (`list_routes` / `get_route_schema`) instead of parsing the
260
260
  * Markdown brief. Optionally filtered to routes whose path starts with `pathPrefix`. Reuses the exact
261
261
  * same `call` form and TS-shaped contracts the Markdown brief shows.
@@ -292,11 +292,11 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
292
292
  }
293
293
 
294
294
  // ===================================================================================================
295
- // `nifra routes [--json]` — the focused, uniform view of every route the app serves, with methods.
295
+ // `nifra routes [--json]` - the focused, uniform view of every route the app serves, with methods.
296
296
  //
297
297
  // `nifra context` lists page routes + API routes for an agent's prompt (Markdown, with schemas).
298
298
  // `nifra routes` answers a different question: "what does this app actually serve, and with which
299
- // methods?" — so an agent sees instantly that `POST /api/explain` is (or isn't) mounted, instead of
299
+ // methods?" - so an agent sees instantly that `POST /api/explain` is (or isn't) mounted, instead of
300
300
  // discovering it via a 405. It marks which backend routes are AUTO-MOUNTED under the page router's
301
301
  // `apiPrefix` (default `/api`), and `--json` makes it machine-consumable. The table-building +
302
302
  // rendering are pure (no fs / no module loads), so they're unit-tested; the cwd gathering is the thin
@@ -313,7 +313,7 @@ export interface RouteTableEntry {
313
313
  readonly path: string
314
314
  /** Methods this route serves, sorted (page: `GET`, plus `POST` when it has an `action`). */
315
315
  readonly methods: readonly string[]
316
- /** Source file (page only — the route module path relative to `routes/`). */
316
+ /** Source file (page only - the route module path relative to `routes/`). */
317
317
  readonly file?: string
318
318
  /** True when this API route is auto-mounted under the page router's `apiPrefix` (so it's reachable
319
319
  * through the same web app, no hand-wired `if (pathname.startsWith("/api/"))`). Pages are never
@@ -345,7 +345,7 @@ function isUnderPrefix(path: string, prefix: string): boolean {
345
345
  }
346
346
 
347
347
  /**
348
- * Build the unified, sorted route table from page routes + backend API routes. Pure — the caller
348
+ * Build the unified, sorted route table from page routes + backend API routes. Pure - the caller
349
349
  * supplies page `hasAction` (resolved via a module load) and the API descriptors (`backend.routes()`).
350
350
  * Page routes serve `GET` (+ `POST` when they export an `action`); API routes carry their declared
351
351
  * method, and are flagged `autoMounted` when at/under `apiPrefix`. Sorted by path, then kind, then the
@@ -449,7 +449,7 @@ export async function describeRoutes(
449
449
  try {
450
450
  manifest = discoverRoutes(app.routesDir)
451
451
  } catch {
452
- // No (or unreadable) routes/ — fall through with no pages; the table still shows API routes.
452
+ // No (or unreadable) routes/ - fall through with no pages; the table still shows API routes.
453
453
  }
454
454
  const pages: PageRouteInput[] = []
455
455
  for (const route of manifest?.routes ?? []) {
package/src/learn.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The guided learn path - a SEQUENCE for building a nifra app end to end, as opposed to the random-access
3
+ * search of `nifra_docs`/`nifra_example`. It is deliberately thin on embedded code: each step points at the
4
+ * tool that produces the CORRECT, verified artifact (`nifra_scaffold` for a route, `nifra_example` for a
5
+ * verified snippet, `nifra_run`/`nifra_render` to see it work), so an agent walks the path by composing the
6
+ * existing tools rather than pasting code that can drift from the installed version.
7
+ *
8
+ * One source, two heads: `nifra_learn` (MCP) serves it to an agent as structured steps; `nifra learn` (CLI)
9
+ * prints the same path for a human. Add or reorder steps here and both update.
10
+ */
11
+
12
+ export interface LearnStep {
13
+ /** Stable slug, e.g. `page-route`. */
14
+ readonly id: string
15
+ readonly title: string
16
+ /** What you will have once this step is done. */
17
+ readonly goal: string
18
+ /** How to do it - prefer directing to the tool that emits the correct artifact over inline code. */
19
+ readonly do: string
20
+ /** How to confirm it worked. */
21
+ readonly verify: string
22
+ /** The `nifra_*` tools that carry out this step. */
23
+ readonly tools: readonly string[]
24
+ /** A `nifra_docs`/`nifra_example` query for going deeper. */
25
+ readonly seeAlso?: string | undefined
26
+ }
27
+
28
+ export const LEARN_PATH: readonly LearnStep[] = [
29
+ {
30
+ id: "create-app",
31
+ title: "Create the app",
32
+ goal: "A running nifra dev server with one page route.",
33
+ do: "Scaffold a new project with `npm create nifra@latest`, pick your framework adapter (React/Solid/Vue/Svelte/Preact), then `npm run dev`.",
34
+ verify: "The dev server prints a localhost URL and the default page renders.",
35
+ tools: ["nifra_context"],
36
+ seeAlso: "project structure routes backend",
37
+ },
38
+ {
39
+ id: "page-route",
40
+ title: "Add a page route",
41
+ goal: "A second page, server-rendered, reachable by URL.",
42
+ do: "Ask nifra_scaffold for the path (e.g. /about) - it returns the exact `routes/` file and a contract-correct page stub. File-based: `routes/about.tsx` serves `/about`.",
43
+ verify: "nifra_render /about returns the rendered HTML.",
44
+ tools: ["nifra_scaffold", "nifra_render"],
45
+ seeAlso: "page routes file-based routing layouts",
46
+ },
47
+ {
48
+ id: "loader",
49
+ title: "Load data for a page",
50
+ goal: "A page whose data is fetched on the server before render.",
51
+ do: "Export a `loader` from the route and read its result in the component. Get the exact shape from nifra_example ('loader').",
52
+ verify: "nifra_render the page and confirm the loaded data is in the HTML.",
53
+ tools: ["nifra_example", "nifra_render"],
54
+ seeAlso: "loader server data page",
55
+ },
56
+ {
57
+ id: "api-route",
58
+ title: "Add a typed API route",
59
+ goal: "A backend endpoint with a typed request/response contract.",
60
+ do: "Add the route to `backend.ts` with an @nifrajs/schema body/query/response schema. nifra_context shows the resulting contract and the exact typed-client call form.",
61
+ verify:
62
+ "nifra_run { method, path, body } and check the status + parsed body; nifra_check confirms the contract typechecks.",
63
+ tools: ["nifra_context", "nifra_run", "nifra_check"],
64
+ seeAlso: "api route schema validation backend",
65
+ },
66
+ {
67
+ id: "typed-client",
68
+ title: "Call the API, fully typed",
69
+ goal: "The page calls the API through the compile-time typed client - no hand-written client twin.",
70
+ do: "Use @nifrajs/client against the backend type. The call form is exactly what nifra_context prints for that route; a drifted call is a compile error, not a runtime surprise.",
71
+ verify: "nifra_check passes; nifra_run the page path and see the data flow end to end.",
72
+ tools: ["nifra_context", "nifra_check", "nifra_run"],
73
+ seeAlso: "typed client treaty end-to-end types",
74
+ },
75
+ {
76
+ id: "protect",
77
+ title: "Protect a route",
78
+ goal: "A route that requires an authenticated session.",
79
+ do: "Wire @nifrajs/auth (or @nifrajs/better-auth) and gate the route. nifra_example ('protected route') has the verified pattern; nifra_assure reports whether the gate is actually enforced.",
80
+ verify:
81
+ "nifra_run the route unauthenticated (expect 401/redirect) and authenticated (expect 200); nifra_assure shows the enforcement evidence.",
82
+ tools: ["nifra_example", "nifra_assure", "nifra_run"],
83
+ seeAlso: "auth session protected route better-auth",
84
+ },
85
+ {
86
+ id: "background-work",
87
+ title: "Do work in the background",
88
+ goal: "A request that enqueues a job instead of blocking on slow work.",
89
+ do: "Define a job with @nifrajs/jobs and enqueue it from a route. nifra_example ('job') has the shape; nifra_inspect shows the enqueue landing on the dev server.",
90
+ verify:
91
+ "nifra_run the route, then nifra_inspect the dev server to see the request completed fast and the effect fired.",
92
+ tools: ["nifra_example", "nifra_run", "nifra_inspect"],
93
+ seeAlso: "jobs queue background enqueue",
94
+ },
95
+ {
96
+ id: "ship",
97
+ title: "Build and deploy",
98
+ goal: "A production build served by your chosen runtime.",
99
+ do: "Pick an adapter (@nifrajs/node, @nifrajs/workers, @nifrajs/deno, or a static/Vercel target) and run the production build. nifra_levels shows what the app already proves before you ship.",
100
+ verify:
101
+ "The build completes and the artifact serves the app; nifra_check + nifra_levels are green.",
102
+ tools: ["nifra_levels", "nifra_check"],
103
+ seeAlso: "deploy build adapters node workers deno",
104
+ },
105
+ ]
106
+
107
+ /** The compact index: numbered steps with their goal, plus how to drill into one. */
108
+ function renderIndex(): string {
109
+ const lines = LEARN_PATH.map((step, i) => `${i + 1}. ${step.title} - ${step.goal} [${step.id}]`)
110
+ return [
111
+ `nifra learn path (${LEARN_PATH.length} steps). Call nifra_learn with step: N for the detail of step N.`,
112
+ "",
113
+ ...lines,
114
+ ].join("\n")
115
+ }
116
+
117
+ /** One step's full detail, with a pointer to the next. */
118
+ function renderStep(step: LearnStep, index: number): string {
119
+ const parts = [
120
+ `Step ${index + 1}/${LEARN_PATH.length}: ${step.title} [${step.id}]`,
121
+ "",
122
+ `Goal: ${step.goal}`,
123
+ `Do: ${step.do}`,
124
+ `Verify: ${step.verify}`,
125
+ `Tools: ${step.tools.join(", ")}`,
126
+ ]
127
+ if (step.seeAlso !== undefined) {
128
+ parts.push(`See: nifra_docs / nifra_example "${step.seeAlso}"`)
129
+ }
130
+ const next = LEARN_PATH[index + 1]
131
+ parts.push(
132
+ "",
133
+ next !== undefined
134
+ ? `Next: step ${index + 2} (${next.title})`
135
+ : "Done - you have shipped a nifra app.",
136
+ )
137
+ return parts.join("\n")
138
+ }
139
+
140
+ /**
141
+ * Render the learn path. No `step` (or an out-of-range one) returns the index; a valid 1-based `step`
142
+ * returns that step's detail. Kept pure so `nifra_learn` and `nifra learn` render identically.
143
+ */
144
+ export function renderLearnResult(step?: number): string {
145
+ if (step === undefined) return renderIndex()
146
+ const index = step - 1
147
+ const found = LEARN_PATH[index]
148
+ if (found === undefined) {
149
+ return `No step ${step}. The path has ${LEARN_PATH.length} steps.\n\n${renderIndex()}`
150
+ }
151
+ return renderStep(found, index)
152
+ }