@nifrajs/cli 2.3.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 (45) hide show
  1. package/dist/check.d.ts.map +1 -1
  2. package/dist/check.js +4 -1
  3. package/dist/check.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +17 -4
  6. package/dist/cli.js.map +1 -1
  7. package/dist/doctor.d.ts.map +1 -1
  8. package/dist/doctor.js +4 -0
  9. package/dist/doctor.js.map +1 -1
  10. package/dist/learn.d.ts +32 -0
  11. package/dist/learn.d.ts.map +1 -0
  12. package/dist/learn.js +127 -0
  13. package/dist/learn.js.map +1 -0
  14. package/dist/mcp-app-tool.d.ts +14 -0
  15. package/dist/mcp-app-tool.d.ts.map +1 -0
  16. package/dist/mcp-app-tool.js +80 -0
  17. package/dist/mcp-app-tool.js.map +1 -0
  18. package/dist/mcp-docs-tools.d.ts +1 -1
  19. package/dist/mcp-docs-tools.d.ts.map +1 -1
  20. package/dist/mcp-docs-tools.js +17 -1
  21. package/dist/mcp-docs-tools.js.map +1 -1
  22. package/dist/mcp-http.d.ts +10 -6
  23. package/dist/mcp-http.d.ts.map +1 -1
  24. package/dist/mcp-http.js +26 -12
  25. package/dist/mcp-http.js.map +1 -1
  26. package/dist/mcp.d.ts +14 -0
  27. package/dist/mcp.d.ts.map +1 -1
  28. package/dist/mcp.js +188 -0
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/upgrade.d.ts +13 -2
  31. package/dist/upgrade.d.ts.map +1 -1
  32. package/dist/upgrade.js +105 -7
  33. package/dist/upgrade.js.map +1 -1
  34. package/docs/llms-full.txt +75 -1
  35. package/docs/types.json +162 -11
  36. package/package.json +9 -9
  37. package/src/check.ts +5 -1
  38. package/src/cli.ts +21 -4
  39. package/src/doctor.ts +8 -0
  40. package/src/learn.ts +152 -0
  41. package/src/mcp-app-tool.ts +86 -0
  42. package/src/mcp-docs-tools.ts +18 -1
  43. package/src/mcp-http.ts +27 -14
  44. package/src/mcp.ts +237 -0
  45. package/src/upgrade.ts +125 -7
package/docs/types.json CHANGED
@@ -128,13 +128,13 @@
128
128
  "name": "AdversarialContractOptions",
129
129
  "kind": "interface",
130
130
  "package": "@nifrajs/testing",
131
- "signature": "export interface AdversarialContractOptions {\n /** Base origin for generated requests. Default `http://nifra.contract`. */\n readonly origin?: string;\n /** Replayable seed used for deterministic witness generation. */\n readonly seed?: number;\n /** Known-good request values keyed by `\"METHOD /path\"`. Required for opaque Standard Schemas. */\n readonly witnesses?: Readonly<Record<string, ContractWitness>>;\n /** Select a subset of reflected routes. */\n readonly include?: (route: ReflectedRoute) => boolean;\n /** Execute every case against these targets. Default: the supplied app, named `in-process`. */\n readonly runtimes?: readonly ContractRuntime[];\n /** Maximum validator-proven mutations per body/query schema. Default 32. */\n readonly maxMutationsPerInput?: number;\n /** Maximum serialized witness size. Default 256 KiB. */\n readonly maxWitnessBytes?: number;\n /** Statuses that prove an invalid input was rejected. Default `[422]`. */\n readonly expectedValidationStatuses?: readonly number[];\n /**\n * Attach auth, tenant, or runtime-specific state. Return a new Request; the input Request is already\n * fresh for this case and runtime.\n */\n readonly prepareRequest?: (request: Request, context: ContractCaseContext) => Request | Promise<Request>;\n /** Override rejection semantics (for apps with a custom validation response). */\n readonly isRejected?: (response: Response, context: ContractCaseContext) => boolean | Promise<boolean>;\n /** Validate a real successful response for every declared response schema. Default true. */\n readonly validateResponses?: boolean;\n /** Fail the report when a contracted target cannot be exercised. Default true. */\n readonly requireCoverage?: boolean;\n /** Greedily minimize an invalid input that unexpectedly reaches the app. Default true. */\n readonly shrinkFailures?: boolean;\n /** Cap shrink probes for one failure. Default 12. */\n readonly maxShrinkAttempts?: number;\n /** Replay only these stable case IDs. Coverage gaps are advisory when this is set. */\n readonly only?: string | readonly string[];\n}"
131
+ "signature": "export interface AdversarialContractOptions {\n /** Base origin for generated requests. Default `http://nifra.contract`. */\n readonly origin?: string;\n /** Replayable seed used for deterministic witness generation. */\n readonly seed?: number;\n /** Known-good request values keyed by `\"METHOD /path\"`. Required for opaque Standard Schemas. */\n readonly witnesses?: Readonly<Record<string, ContractWitness>>;\n /** Select a subset of reflected routes. */\n readonly include?: (route: ReflectedRoute) => boolean;\n /** Execute every case against these targets. Default: the supplied app, named `in-process`. */\n readonly runtimes?: readonly ContractRuntime[];\n /** Maximum validator-proven mutations per body/query schema. Default 32. */\n readonly maxMutationsPerInput?: number;\n /** Maximum serialized witness size. Default 256 KiB. */\n readonly maxWitnessBytes?: number;\n /** Statuses that prove an invalid input was rejected. Default `[422]`. */\n readonly expectedValidationStatuses?: readonly number[];\n /**\n * Attach auth, tenant, or runtime-specific state. Return a new Request; the input Request is already\n * fresh for this case and runtime.\n */\n readonly prepareRequest?: (request: Request, context: ContractCaseContext) => Request | Promise<Request>;\n /** Override rejection semantics (for apps with a custom validation response). */\n readonly isRejected?: (response: Response, context: ContractCaseContext) => boolean | Promise<boolean>;\n /** Validate a real successful response for every declared response schema. Default true. */\n readonly validateResponses?: boolean;\n /** Fail the report when a contracted target cannot be exercised. Default true. */\n readonly requireCoverage?: boolean;\n /** Greedily minimize an invalid input that unexpectedly reaches the app. Default true. */\n readonly shrinkFailures?: boolean;\n /** Cap shrink probes for one failure. Default 12. */\n readonly maxShrinkAttempts?: number;\n /** Replay only these stable case IDs. Coverage gaps are advisory when this is set. */\n readonly only?: string | readonly string[];\n /**\n * Derive an inspectable JSON Schema from a validator that carries none (zod/valibot/arktype expose a\n * Standard Schema validator but no `.jsonSchema`). Called for any body/query schema whose `jsonSchema`\n * is undefined; a non-undefined return turns on witness synthesis AND constraint-driven mutation\n * generation for that route. Fail-safe: throwing or returning undefined leaves the schema opaque\n * (NO_WITNESS, as today). Ready-made for zod: `import { zodJsonSchema } from \"@nifrajs/testing/zod\"`.\n */\n readonly reflectJsonSchema?: (schema: unknown) => JsonSchema | undefined;\n}"
132
132
  },
133
133
  {
134
134
  "name": "AdversarialContractReport",
135
135
  "kind": "interface",
136
136
  "package": "@nifrajs/testing",
137
- "signature": "export interface AdversarialContractReport {\n readonly ok: boolean;\n readonly seed: number;\n readonly routeCount: number;\n readonly targetCount: number;\n readonly runtimeCount: number;\n readonly results: readonly AdversarialContractResult[];\n readonly failures: readonly AdversarialContractResult[];\n readonly gaps: readonly ContractCoverageGap[];\n readonly counts: {\n readonly passed: number;\n readonly failed: number;\n readonly gaps: number;\n };\n}"
137
+ "signature": "export interface AdversarialContractReport {\n readonly ok: boolean;\n readonly seed: number;\n readonly routeCount: number;\n readonly targetCount: number;\n readonly runtimeCount: number;\n readonly results: readonly AdversarialContractResult[];\n readonly failures: readonly AdversarialContractResult[];\n readonly gaps: readonly ContractCoverageGap[];\n /** Non-fatal notes about coverage that is legitimately absent (does not affect `ok`). */\n readonly advisories: readonly string[];\n readonly counts: {\n readonly passed: number;\n readonly failed: number;\n readonly gaps: number;\n };\n}"
138
138
  },
139
139
  {
140
140
  "name": "AdversarialContractResult",
@@ -931,6 +931,26 @@
931
931
  "signature": "export interface BuildClientViteOptions extends Omit<BuildClientOptions, \"plugins\"> {\n /** Vite plugins for the app's client transforms (e.g. `react()`, `vue()`). */\n readonly vitePlugins?: readonly unknown[];\n /** Vite project root (default: the parent of `routesDir`). Manifest keys are relative to it. */\n readonly root?: string;\n}",
932
932
  "doc": "/** Options for {@link buildClientVite} - the Bun {@link BuildClientOptions} minus Bun-plugin specifics,\n * plus the Vite plugin list the app injects for its transforms. */"
933
933
  },
934
+ {
935
+ "name": "buildCodeframe",
936
+ "kind": "function",
937
+ "package": "@nifrajs/web",
938
+ "signature": "export declare function buildCodeframe(file: string, line: number, column: number | undefined, read?: SourceReader, radius?: number): Codeframe | undefined;",
939
+ "doc": "/**\n * Build a source codeframe: `radius` lines either side of `line`, each tagged with its 1-based number\n * and whether it is the offending line. Returns undefined if the source can't be read or the line is\n * out of range - a diagnostic without a codeframe is still useful, so this never throws.\n */"
940
+ },
941
+ {
942
+ "name": "buildDiagnostic",
943
+ "kind": "function",
944
+ "package": "@nifrajs/web",
945
+ "signature": "export declare function buildDiagnostic(err: unknown, options?: BuildDiagnosticOptions): Diagnostic;",
946
+ "doc": "/**\n * Resolve any thrown value into a `Diagnostic`: parse the (already source-mapped) stack, locate the top\n * user frame, attach a codeframe, and classify the failure for a cause/fix. The caller is responsible\n * for running Vite's `ssrFixStacktrace` first so the frames point at real source.\n */"
947
+ },
948
+ {
949
+ "name": "BuildDiagnosticOptions",
950
+ "kind": "interface",
951
+ "package": "@nifrajs/web",
952
+ "signature": "export interface BuildDiagnosticOptions {\n readonly request?: {\n readonly method: string;\n readonly url: string;\n };\n /** Project root; frames outside it are treated as non-user. Defaults to `process.cwd()`. */\n readonly root?: string;\n /** Injectable source reader (tests pass a fake; production reads the filesystem). */\n readonly read?: SourceReader;\n}"
953
+ },
934
954
  {
935
955
  "name": "buildManifest",
936
956
  "kind": "function",
@@ -1718,6 +1738,13 @@
1718
1738
  "package": "@nifrajs/core",
1719
1739
  "signature": "export type ClassifiedSchema<S extends object> = S & {\n readonly [CLASSIFICATION]: DataClassification;\n};"
1720
1740
  },
1741
+ {
1742
+ "name": "classify",
1743
+ "kind": "function",
1744
+ "package": "@nifrajs/web",
1745
+ "signature": "export declare function classify(name: string, message: string): {\n code: string;\n cause?: string;\n fix?: string;\n docsAnchor?: string;\n};",
1746
+ "doc": "/** Classify an error name+message against the catalog; falls back to the generic unhandled code. */"
1747
+ },
1721
1748
  {
1722
1749
  "name": "CLAUDE_MD_PATH",
1723
1750
  "kind": "const",
@@ -1823,6 +1850,13 @@
1823
1850
  "package": "@nifrajs/web",
1824
1851
  "signature": "export interface CloudflareRouteRules {\n readonly include: readonly string[];\n readonly exclude: readonly string[];\n /** Public files the budget could not name, so the build can say so instead of capping silently. */\n readonly omitted: readonly string[];\n}"
1825
1852
  },
1853
+ {
1854
+ "name": "Codeframe",
1855
+ "kind": "interface",
1856
+ "package": "@nifrajs/web",
1857
+ "signature": "export interface Codeframe {\n readonly file: string;\n readonly line: number;\n readonly column?: number | undefined;\n readonly lines: ReadonlyArray<{\n readonly number: number;\n readonly text: string;\n readonly caret: boolean;\n }>;\n}",
1858
+ "doc": "/** A source window around the offending line. `caret` marks the exact line the top frame points at. */"
1859
+ },
1826
1860
  {
1827
1861
  "name": "Collection",
1828
1862
  "kind": "interface",
@@ -2931,8 +2965,7 @@
2931
2965
  "name": "devtools",
2932
2966
  "kind": "function",
2933
2967
  "package": "@nifrajs/devtools",
2934
- "signature": "export declare function devtools(options?: DevToolsOptions | undefined): import(\"@nifrajs/core/server\").NifraPlugin<import(\"@nifrajs/core/server\").AnyServer, import(\"@nifrajs/core/server\").AnyServer>;",
2935
- "doc": "/**\n * DevTools plugin. Its observation adapter projects the single request span into a\n * `DevToolsEvent`; its middleware only owns the secured SSE transport.\n * When configuring `tracing()` yourself, register it before this plugin so DevTools attaches to\n * that request owner.\n */"
2968
+ "signature": "export declare function devtools(options?: DevToolsOptions | undefined): import(\"@nifrajs/core/server\").NifraPlugin<import(\"@nifrajs/core/server\").AnyServer, import(\"@nifrajs/core/server\").AnyServer>;"
2936
2969
  },
2937
2970
  {
2938
2971
  "name": "DevToolsClientOptions",
@@ -2960,6 +2993,34 @@
2960
2993
  "package": "@nifrajs/devtools",
2961
2994
  "signature": "export interface DevToolsOptions {\n /** Maximum events to retain in the ring buffer (default 200). */\n readonly maxEvents?: number | undefined;\n /** Maximum simultaneous SSE connections (default 5). */\n readonly maxConnections?: number | undefined;\n /** SSE path (default `/_nifra/devtools`). */\n readonly path?: string | undefined;\n /** Explicit enable switch. Defaults to true only when `NODE_ENV === \"development\"`. */\n readonly enabled?: boolean | undefined;\n /** Permit non-loopback URL hosts. Default false. */\n readonly allowRemote?: boolean | undefined;\n /** Additional origins permitted to open the stream. Same-origin requests are always allowed. */\n readonly allowedOrigins?: readonly string[] | undefined;\n /** Optional request authorization for the stream. */\n readonly authorize?: ((request: Request) => boolean | Promise<boolean>) | undefined;\n /**\n * Interval for SSE keep-alive comment frames, in milliseconds (default 15000). Pings stop\n * idle proxies/dev tunnels from dropping the stream and evict dead connections so they no\n * longer count against `maxConnections`.\n */\n readonly pingIntervalMs?: number | undefined;\n}"
2962
2995
  },
2996
+ {
2997
+ "name": "DevToolsQuery",
2998
+ "kind": "interface",
2999
+ "package": "@nifrajs/devtools",
3000
+ "signature": "export interface DevToolsQuery {\n readonly path?: string | undefined;\n readonly limit?: number | undefined;\n}",
3001
+ "doc": "/** Query for a DevTools state snapshot: an optional `path` prefix and a most-recent-`limit`. */"
3002
+ },
3003
+ {
3004
+ "name": "Diagnostic",
3005
+ "kind": "interface",
3006
+ "package": "@nifrajs/web",
3007
+ "signature": "export interface Diagnostic {\n /** Stable, greppable identifier, e.g. `NIFRA_SERVER_ONLY_IN_CLIENT`. `NIFRA_UNHANDLED` when unrecognised. */\n readonly code: string;\n readonly name: string;\n readonly message: string;\n readonly request?: {\n readonly method: string;\n readonly url: string;\n } | undefined;\n readonly frames: readonly DiagnosticFrame[];\n readonly codeframe?: Codeframe | undefined;\n /** Plain-language \"why this happened\", when the failure is recognised. */\n readonly cause?: string | undefined;\n /** Plain-language \"do this\", when the failure is recognised. */\n readonly fix?: string | undefined;\n /** Docs section anchor for the code, e.g. `errors#server-only-in-client`. */\n readonly docsAnchor?: string | undefined;\n}",
3008
+ "doc": "/** The structured failure. Serialisable as-is to JSON for the agent surfaces. */"
3009
+ },
3010
+ {
3011
+ "name": "DIAGNOSTIC_CATALOG",
3012
+ "kind": "const",
3013
+ "package": "@nifrajs/web",
3014
+ "signature": "export declare const DIAGNOSTIC_CATALOG: readonly CatalogEntry[];",
3015
+ "doc": "/**\n * The recognised-failure catalog. Seeded with the highest-signal nifra failures; extend it as new\n * classes of error earn a stable code. Order matters only in that the first match wins.\n */"
3016
+ },
3017
+ {
3018
+ "name": "DiagnosticFrame",
3019
+ "kind": "interface",
3020
+ "package": "@nifrajs/web",
3021
+ "signature": "export interface DiagnosticFrame {\n readonly raw: string;\n readonly file?: string;\n readonly line?: number;\n readonly column?: number;\n}",
3022
+ "doc": "/** One parsed stack frame. `file`/`line`/`column` are present only when the frame could be located. */"
3023
+ },
2963
3024
  {
2964
3025
  "name": "diffManifestRoutes",
2965
3026
  "kind": "function",
@@ -3526,6 +3587,20 @@
3526
3587
  "signature": "export interface Example {\n readonly name: string;\n /** The doc page's title, e.g. \"Nifra - Auth & sessions\". */\n readonly topic: string;\n /** The doc page slug, e.g. \"auth\" - a short, matchable tag. */\n readonly slug: string;\n readonly code: string;\n}",
3527
3588
  "doc": "/**\n * `nifra_example` - verified, copy-pasteable code examples for agents. The corpus is `examples.json`\n * (written into this package by `bun run gen:llms`): the same doc snippets `check:docs` typechecks\n * against the live `@nifrajs/*` API. So every example this returns is GUARANTEED to compile against the\n * installed version - the antidote to an agent hallucinating a framework API from stale training.\n *\n * Keyword-scored over each example's name/topic/slug (curated identifiers, heavily weighted) and its\n * code body. No query → a grouped index of what's available (the cheap discovery call).\n */"
3528
3589
  },
3590
+ {
3591
+ "name": "examplesAppTool",
3592
+ "kind": "function",
3593
+ "package": "@nifrajs/cli",
3594
+ "signature": "export declare function examplesAppTool(loadExamples: () => Promise<Example[] | undefined>): McpTool;",
3595
+ "doc": "/** The widget-backed tool. `loadExamples` is injected (disk on the CLI, cached fetch on the edge). */"
3596
+ },
3597
+ {
3598
+ "name": "examplesWidget",
3599
+ "kind": "const",
3600
+ "package": "@nifrajs/cli",
3601
+ "signature": "export declare const examplesWidget: import(\"@nifrajs/mcp\").McpWidget;",
3602
+ "doc": "/** The widget: renders `structuredContent.examples` as a filterable list of example cards. */"
3603
+ },
3529
3604
  {
3530
3605
  "name": "executeCapability",
3531
3606
  "kind": "function",
@@ -3732,6 +3807,27 @@
3732
3807
  "signature": "export interface FileType {\n /** Detected MIME type, e.g. `\"image/png\"`. */\n readonly mime: string;\n /** Canonical extension (no dot), e.g. `\"png\"`. */\n readonly ext: string;\n}",
3733
3808
  "doc": "/**\n * Magic-byte file-type detection - trust the bytes, not the `Content-Type` header (which a client\n * sets freely). Reads only the leading bytes; dependency-free + edge-safe. Covers the common upload\n * types; returns `null` for anything unrecognized (incl. text formats like SVG/CSV that have no magic\n * number - handle those explicitly if you accept them).\n */"
3734
3809
  },
3810
+ {
3811
+ "name": "filterDevToolsEvents",
3812
+ "kind": "function",
3813
+ "package": "@nifrajs/devtools",
3814
+ "signature": "export declare function filterDevToolsEvents(events: readonly DevToolsEvent[], query?: DevToolsQuery): DevToolsEvent[];",
3815
+ "doc": "/**\n * Filter a DevTools event buffer for a snapshot query - by `path` prefix and/or the most recent\n * `limit`. Pure, so the `/state` endpoint and its tests (and the `nifra_inspect` MCP tool that reads\n * that endpoint) share ONE definition of what a query returns.\n */"
3816
+ },
3817
+ {
3818
+ "name": "findRoutePathLiteral",
3819
+ "kind": "function",
3820
+ "package": "@nifrajs/ts-plugin",
3821
+ "signature": "export declare function findRoutePathLiteral(tsm: typeof ts, source: ts.SourceFile, position: number): {\n text: string;\n span: ts.TextSpan;\n} | undefined;",
3822
+ "doc": "/**\n * If `position` sits on a string-literal route path (`\"/...\"`), return its text and the span INSIDE the\n * quotes (so the editor highlights the path, not the quotes). Exported for unit testing against a\n * `SourceFile` built with `ts.createSourceFile`.\n */"
3823
+ },
3824
+ {
3825
+ "name": "findRoutesDir",
3826
+ "kind": "function",
3827
+ "package": "@nifrajs/ts-plugin",
3828
+ "signature": "export declare function findRoutesDir(fromFile: string, exists?: (p: string) => boolean): string | undefined;",
3829
+ "doc": "/** Walk up from a source file to the nearest directory that has a `routes/` folder - the app root. */"
3830
+ },
3735
3831
  {
3736
3832
  "name": "fixedBackoff",
3737
3833
  "kind": "const",
@@ -4054,7 +4150,7 @@
4054
4150
  "kind": "function",
4055
4151
  "package": "@nifrajs/cli",
4056
4152
  "signature": "export declare function handleMcpHttp(request: Request): Promise<Response>;",
4057
- "doc": "/** The CLI HTTP handler: serves the disk-backed corpus tools. (`nifra docs-mcp` / `bun run` this file.) */"
4153
+ "doc": "/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`\n * resource so MCP Apps hosts can render `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */"
4058
4154
  },
4059
4155
  {
4060
4156
  "name": "Handler",
@@ -5071,6 +5167,13 @@
5071
5167
  "package": "@nifrajs/middleware",
5072
5168
  "signature": "export interface LanguageOptions<L extends readonly string[]> {\n readonly supported: L;\n readonly defaultLanguage: L[number];\n /** Emit `Content-Language`. Default `true`. */\n readonly header?: boolean;\n}"
5073
5169
  },
5170
+ {
5171
+ "name": "LAST_ERROR_PATH",
5172
+ "kind": "const",
5173
+ "package": "@nifrajs/web",
5174
+ "signature": "export declare const LAST_ERROR_PATH = \"/__nifra/last-error\";",
5175
+ "doc": "/** Shared endpoint name used by both dev pipelines and the agent-facing MCP tools. */"
5176
+ },
5074
5177
  {
5075
5178
  "name": "LAYOUT_DATA_GLOBAL",
5076
5179
  "kind": "const",
@@ -5831,7 +5934,7 @@
5831
5934
  "name": "MockServerOptions",
5832
5935
  "kind": "interface",
5833
5936
  "package": "@nifrajs/mock",
5834
- "signature": "export interface MockServerOptions {\n /** Seed for deterministic mock generation. */\n readonly seed?: number | undefined;\n /** Log mock requests to console. */\n readonly verbose?: boolean | undefined;\n}"
5937
+ "signature": "export interface MockServerOptions {\n /** Seed for deterministic mock generation. */\n readonly seed?: number | undefined;\n /** Log mock requests to console. */\n readonly verbose?: boolean | undefined;\n /**\n * Derive a JSON Schema from a response validator that carries none (zod/valibot expose a Standard Schema\n * validator but no `.jsonSchema`), so their routes mock real data instead of `{}`. Fail-safe: throwing or\n * returning undefined falls back to `{}`, as today. Ready-made for zod: `@nifrajs/testing/zod`.\n */\n readonly reflectJsonSchema?: (schema: unknown) => JsonSchema | undefined;\n}"
5835
5938
  },
5836
5939
  {
5837
5940
  "name": "mountIslands",
@@ -6542,6 +6645,13 @@
6542
6645
  "package": "@nifrajs/content",
6543
6646
  "signature": "export interface ParseEntryOptions<S extends StandardSchemaV1> {\n readonly raw: string;\n readonly slug: string;\n /** Standard Schema validating the frontmatter - a typo'd/missing field throws (a build-time failure,\n * never a broken page). */\n readonly schema: S;\n}"
6544
6647
  },
6648
+ {
6649
+ "name": "parseFrames",
6650
+ "kind": "function",
6651
+ "package": "@nifrajs/web",
6652
+ "signature": "export declare function parseFrames(stack: string): DiagnosticFrame[];",
6653
+ "doc": "/**\n * Parse a V8/Node stack into structured frames. Handles the `at fn (path:line:col)`, bare\n * `at path:line:col`, and `at async fn (...)` shapes; a frame that doesn't match keeps its raw text\n * with no location (so nothing is silently dropped).\n */"
6654
+ },
6545
6655
  {
6546
6656
  "name": "parseFrontmatter",
6547
6657
  "kind": "function",
@@ -6933,7 +7043,7 @@
6933
7043
  "kind": "function",
6934
7044
  "package": "@nifrajs/cli",
6935
7045
  "signature": "export declare function publicDocsTools(): McpTool[];",
6936
- "doc": "/** The two project-independent tools, reading the package's bundled corpus from disk (CLI use). */"
7046
+ "doc": "/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text\n * docs tools plus the `nifra_examples_app` MCP Apps widget tool. */"
6937
7047
  },
6938
7048
  {
6939
7049
  "name": "publicEnvDefines",
@@ -7601,6 +7711,13 @@
7601
7711
  "signature": "export declare function resolvePublicPath(root: string, pathname: string): string | undefined;",
7602
7712
  "doc": "/**\n * Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.\n *\n * This is a user-path-to-filesystem sink, which makes it the one part of this feature with a security\n * consequence. Decode first (`%2e%2e%2f` is `../`), then normalize, then verify the result is still\n * under `root` by prefix - checking the *resolved* path rather than scanning the input for `..`,\n * because a blocklist over encodings is exactly the kind of check that gets bypassed.\n */"
7603
7713
  },
7714
+ {
7715
+ "name": "resolveRouteFile",
7716
+ "kind": "function",
7717
+ "package": "@nifrajs/ts-plugin",
7718
+ "signature": "export declare function resolveRouteFile(path: string, routes: readonly RouteLocation[]): string | undefined;",
7719
+ "doc": "/**\n * Resolve a route path literal to the first route whose pattern matches it. Query/hash are ignored\n * (a link's `?tab=x` does not change which route file serves it). Returns the route's `file`, or\n * undefined when the value is not a path or matches no route.\n */"
7720
+ },
7604
7721
  {
7605
7722
  "name": "respondMcpHttp",
7606
7723
  "kind": "function",
@@ -7920,6 +8037,13 @@
7920
8037
  "signature": "export interface RouteLike {\n readonly method: string;\n readonly path: string;\n readonly schema?: {\n readonly body?: unknown;\n readonly query?: unknown;\n readonly response?: unknown;\n } | undefined;\n}",
7921
8038
  "doc": "/** A registered route as seen by {@link buildOpenApiDocument} - structurally a `@nifrajs/core`\n * `RouteDescriptor` (so `app.routes()` is passed straight through). */"
7922
8039
  },
8040
+ {
8041
+ "name": "RouteLocation",
8042
+ "kind": "interface",
8043
+ "package": "@nifrajs/ts-plugin",
8044
+ "signature": "export interface RouteLocation {\n readonly pattern: string;\n readonly file: string;\n}",
8045
+ "doc": "/** A route as the plugin needs it: its URL pattern and the source file that serves it. */"
8046
+ },
7923
8047
  {
7924
8048
  "name": "RouteManifest",
7925
8049
  "kind": "interface",
@@ -9130,6 +9254,20 @@
9130
9254
  "signature": "export declare function solidSvgComponentBunPlugin(generate: \"dom\" | \"ssr\"): BunPlugin;",
9131
9255
  "doc": "/** The Solid SVG-component plugin. `generate` selects the Solid `\"dom\"`/`\"ssr\"` output, matching\n * `solidBunPlugin`. A plain `import \"./icon.svg\"` (asset URL) is untouched - only `?component` matches. */"
9132
9256
  },
9257
+ {
9258
+ "name": "sortRoutesBySpecificity",
9259
+ "kind": "function",
9260
+ "package": "@nifrajs/core",
9261
+ "signature": "export declare function sortRoutesBySpecificity<T extends {\n readonly pattern: CompiledRoutePattern;\n}>(routes: T[]): T[];",
9262
+ "doc": "/**\n * Sort compiled routes most-specific-first - a static segment beats a dynamic one, the order the router\n * resolves a path in. The single home for that precedence: the web router, the mock server, and the\n * editor plugin all order routes through this one comparator, so which file a path resolves to can never\n * diverge between runtime, client, and editor. Sorts in place and returns the same array.\n */"
9263
+ },
9264
+ {
9265
+ "name": "SourceReader",
9266
+ "kind": "type",
9267
+ "package": "@nifrajs/web",
9268
+ "signature": "export type SourceReader = (file: string) => string | undefined;",
9269
+ "doc": "/** Reads a source file's text, or returns undefined if it cannot (missing, binary, permission). */"
9270
+ },
9133
9271
  {
9134
9272
  "name": "SpanStatus",
9135
9273
  "kind": "type",
@@ -9855,6 +9993,13 @@
9855
9993
  "signature": "export declare class TopicRegistry {\n private readonly topics;\n private readonly memberships;\n subscribe(topic: string, ws: NifraWebSocket): void;\n unsubscribe(topic: string, ws: NifraWebSocket): void;\n /** Drop every subscription for a connection - called on close so a registry never leaks dead sockets. */\n unsubscribeAll(ws: NifraWebSocket): void;\n /** Send `data` to every connection subscribed to `topic`. Per-socket send errors are isolated. */\n publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void;\n}",
9856
9994
  "doc": "/**\n * In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** -\n * topics live in this process's memory, so a multi-instance deploy (multiple servers behind a load\n * balancer) needs an external fan-out (Redis pub/sub, a Cloudflare Durable Object, NATS, …) bridged to\n * `app.publish`. The same registry instance is shared by an app's connections and its `publish`.\n */"
9857
9995
  },
9996
+ {
9997
+ "name": "topUserFrame",
9998
+ "kind": "function",
9999
+ "package": "@nifrajs/web",
10000
+ "signature": "export declare function topUserFrame(frames: readonly DiagnosticFrame[], root: string | undefined): DiagnosticFrame | undefined;",
10001
+ "doc": "/** The first frame that points at the user's own source - what the codeframe should show. */"
10002
+ },
9858
10003
  {
9859
10004
  "name": "TraceContext",
9860
10005
  "kind": "type",
@@ -10048,8 +10193,7 @@
10048
10193
  "name": "UnsupportedMockSchemaError",
10049
10194
  "kind": "class",
10050
10195
  "package": "@nifrajs/mock",
10051
- "signature": "export declare class UnsupportedMockSchemaError extends Error {\n readonly keyword: string;\n constructor(keyword: string, message: string);\n}",
10052
- "doc": "/**\n * `@nifrajs/mock` - contract-based mock server.\n *\n * Reads a Nifra app's registered routes and their Standard Schema / JSON Schema\n * response definitions, then generates fake responses from supported JSON Schema keywords. Use it\n * for frontend-first development, agent testing, or CI smoke tests.\n */"
10196
+ "signature": "export declare class UnsupportedMockSchemaError extends Error {\n readonly keyword: string;\n constructor(keyword: string, message: string);\n}"
10053
10197
  },
10054
10198
  {
10055
10199
  "name": "UploadResult",
@@ -10631,14 +10775,14 @@
10631
10775
  "name": "VERSION",
10632
10776
  "kind": "const",
10633
10777
  "package": "@nifrajs/core",
10634
- "signature": "export declare const VERSION: \"2.3.0\";",
10778
+ "signature": "export declare const VERSION: \"2.4.0\";",
10635
10779
  "doc": "/**\n * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't\n * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and\n * `check:publish` asserts it equals `@nifrajs/core`'s package version.\n */"
10636
10780
  },
10637
10781
  {
10638
10782
  "name": "VERSION",
10639
10783
  "kind": "const",
10640
10784
  "package": "nifra",
10641
- "signature": "export declare const VERSION: \"2.3.0\";",
10785
+ "signature": "export declare const VERSION: \"2.4.0\";",
10642
10786
  "doc": "/**\n * Current package version. A hardcoded literal on purpose - core runs on the edge (no fs), so it can't\n * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and\n * `check:publish` asserts it equals `@nifrajs/core`'s package version.\n */"
10643
10787
  },
10644
10788
  {
@@ -10933,5 +11077,12 @@
10933
11077
  "package": "@nifrajs/client",
10934
11078
  "signature": "export interface WsHandle<In, Out> {\n /** Send one typed frame (JSON-encoded). Queued until the socket opens, so it never throws. */\n send(message: In): void;\n /** Async-iterate incoming typed frames. Ends when the socket closes (or `signal` aborts). */\n messages(options?: {\n signal?: AbortSignal;\n }): AsyncIterableIterator<Out>;\n /** Callback form of {@link messages}; returns an unsubscribe. */\n onMessage(callback: (message: Out) => void): () => void;\n /** Resolves when the socket is open (rejects on a connect failure). */\n readonly opened: Promise<void>;\n close(code?: number, reason?: string): void;\n /** The underlying socket, for anything off the typed contract (binary frames, bufferedAmount…). */\n readonly raw: WebSocket;\n}",
10935
11079
  "doc": "/**\n * A live typed WebSocket connection to an `app.ws()` route. `send` accepts the route's\n * `messageSchema` input type (validated server-side at the trust boundary); received frames are\n * typed from its `sendSchema` and JSON-parsed. Binary frames are not part of the typed contract\n * and are ignored by `messages()`/`onMessage` - use `raw` for them.\n */"
11080
+ },
11081
+ {
11082
+ "name": "zodJsonSchema",
11083
+ "kind": "function",
11084
+ "package": "@nifrajs/testing",
11085
+ "signature": "export declare function zodJsonSchema(schema: unknown): JsonSchema | undefined;",
11086
+ "doc": "/**\n * Convert a zod schema to a JSON Schema the mock/mutation generators understand, or `undefined` when the\n * value is not a convertible zod schema. Options chosen for this consumer:\n * - target \"draft-7\" → the keyword set `@nifrajs/mock` + the laboratory's `candidateMutations` read\n * - io \"input\" → the request-side constraints a hostile payload must violate (pre-transform)\n * - reused \"inline\" → no `$ref`/`$defs`; the mock generator fails closed on `$ref`\n *\n * Fail-safe: a non-zod value, or an unconvertible schema, returns `undefined` → the schema stays opaque\n * (NO_WITNESS / `{}`), exactly as before this bridge existed.\n */"
10936
11087
  }
10937
11088
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,14 +41,14 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "2.3.0",
45
- "@nifrajs/core": "2.3.0",
46
- "@nifrajs/mcp": "2.3.0",
47
- "@nifrajs/runner": "2.3.0",
48
- "@nifrajs/schema": "2.3.0",
49
- "@nifrajs/testing": "2.3.0",
50
- "@nifrajs/web": "2.3.0",
51
- "create-nifra": "2.3.0"
44
+ "@nifrajs/client": "2.4.0",
45
+ "@nifrajs/core": "2.4.0",
46
+ "@nifrajs/mcp": "2.4.0",
47
+ "@nifrajs/runner": "2.4.0",
48
+ "@nifrajs/schema": "2.4.0",
49
+ "@nifrajs/testing": "2.4.0",
50
+ "@nifrajs/web": "2.4.0",
51
+ "create-nifra": "2.4.0"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
package/src/check.ts CHANGED
@@ -1709,10 +1709,14 @@ export async function collectCheckResult(
1709
1709
  }
1710
1710
  for (const finding of dr.duplicateInstalls) {
1711
1711
  const copies = finding.copies.map((copy) => `${copy.version} at ${copy.path}`).join("; ")
1712
+ const identityHint =
1713
+ finding.package === "@nifrajs/core"
1714
+ ? " (a second @nifrajs/core collapses `typeof backend` to `any` at `.merge()` - usually that error's root cause)"
1715
+ : ""
1712
1716
  diagnostics.push({
1713
1717
  rule: "duplicate-install",
1714
1718
  severity: "error",
1715
- message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe`,
1719
+ message: `${finding.package} resolves to multiple physical copies (${copies}) - module identity is unsafe${identityHint}`,
1716
1720
  fix: "align dependency ranges and reinstall from the workspace root",
1717
1721
  suggestion: {
1718
1722
  kind: "manual",
package/src/cli.ts CHANGED
@@ -89,9 +89,12 @@ Usage:
89
89
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
90
90
  nifra_example (verified snippets), nifra_scaffold (route→file),
91
91
  nifra_check (drift gate + fixes), nifra_levels (verification
92
- ladder), nifra_doctor (deps + duplicate installs).
92
+ ladder), nifra_doctor (deps), nifra_explain (structured errors),
93
+ nifra_inspect (request traces), nifra_learn (guided build path).
93
94
  nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) -
94
95
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
96
+ nifra learn [<step>] Print the guided build-an-app path (the human view of nifra_learn):
97
+ no arg for the step index, a number for one step's goal/do/verify.
95
98
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
96
99
  server-only imports in routes/). Run as "done"; --json for agents;
97
100
  --lints-only skips tsc for a near-instant inner-loop pass.
@@ -133,9 +136,10 @@ Usage:
133
136
  nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
134
137
  [--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
135
138
  [--list] [--json] packages, apply exact imports, then verify with nifra check.
136
- Dry-run by default; --write applies then verifies (--no-verify
139
+ [--allow-downgrade] Dry-run by default; --write applies then verifies (--no-verify
137
140
  to skip). --list shows available targets. Fail-closed on an
138
- unknown version. Deterministic + idempotent.
141
+ unknown version or a rollback (--allow-downgrade overrides).
142
+ Deterministic + idempotent.
139
143
  nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
140
144
  [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
141
145
  globals, node: builtins) with file:line evidence. --target auto-
@@ -150,7 +154,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
150
154
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
151
155
 
152
156
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
153
- const CLI_VERSION = "2.3.0"
157
+ const CLI_VERSION = "2.4.0"
154
158
 
155
159
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
156
160
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -532,6 +536,18 @@ async function main(): Promise<void> {
532
536
  console.log(`nifra docs MCP (HTTP) → ${server.url}`)
533
537
  return
534
538
  }
539
+ // `learn` prints the guided build-an-app path (the human head of `nifra_learn`). Project-independent:
540
+ // the path is static, so no project needs to load. `nifra learn` for the index, `nifra learn 3` for a step.
541
+ if (command === "learn") {
542
+ const { renderLearnResult } = await import("./learn.ts")
543
+ const stepArg = argv[1]
544
+ console.log(
545
+ renderLearnResult(
546
+ stepArg !== undefined && /^\d+$/.test(stepArg) ? Number(stepArg) : undefined,
547
+ ),
548
+ )
549
+ return
550
+ }
535
551
  // `check` is a pure cwd-based gate (typecheck + lint) - it must run even when the project doesn't
536
552
  // load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
537
553
  if (command === "check") {
@@ -768,6 +784,7 @@ async function main(): Promise<void> {
768
784
  json: argv.includes("--json"),
769
785
  list: argv.includes("--list"),
770
786
  verify: !argv.includes("--no-verify"),
787
+ allowDowngrade: argv.includes("--allow-downgrade"),
771
788
  })
772
789
  if (!ok) process.exitCode = 1
773
790
  } catch (err) {
package/src/doctor.ts CHANGED
@@ -668,6 +668,14 @@ export async function runDoctor(
668
668
  for (const copy of finding.copies) {
669
669
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
670
670
  }
671
+ if (finding.package === "@nifrajs/core") {
672
+ console.log(
673
+ " ↳ a second @nifrajs/core breaks module identity: `typeof backend` collapses to `any`",
674
+ )
675
+ console.log(
676
+ " at `.merge()` and route-type inference silently degrades - usually that error's root cause.",
677
+ )
678
+ }
671
679
  }
672
680
  console.log(
673
681
  "\n Align dependency ranges and reinstall from the workspace root; doctor never deletes installs.",
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
+ }