@metaobjectsdev/cli 0.9.0-rc.1 → 0.10.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 (52) hide show
  1. package/dist/src/commands/docs.d.ts +2 -0
  2. package/dist/src/commands/docs.d.ts.map +1 -0
  3. package/dist/src/commands/docs.js +395 -0
  4. package/dist/src/commands/docs.js.map +1 -0
  5. package/dist/src/commands/gen.d.ts.map +1 -1
  6. package/dist/src/commands/gen.js +41 -1
  7. package/dist/src/commands/gen.js.map +1 -1
  8. package/dist/src/commands/init.d.ts +6 -0
  9. package/dist/src/commands/init.d.ts.map +1 -1
  10. package/dist/src/commands/init.js +102 -29
  11. package/dist/src/commands/init.js.map +1 -1
  12. package/dist/src/commands/verify.d.ts.map +1 -1
  13. package/dist/src/commands/verify.js +69 -15
  14. package/dist/src/commands/verify.js.map +1 -1
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/index.js +20 -32
  17. package/dist/src/index.js.map +1 -1
  18. package/dist/src/lib/agent-context-staleness.d.ts +7 -0
  19. package/dist/src/lib/agent-context-staleness.d.ts.map +1 -0
  20. package/dist/src/lib/agent-context-staleness.js +26 -0
  21. package/dist/src/lib/agent-context-staleness.js.map +1 -0
  22. package/dist/src/lib/args.d.ts +14 -0
  23. package/dist/src/lib/args.d.ts.map +1 -1
  24. package/dist/src/lib/args.js +22 -0
  25. package/dist/src/lib/args.js.map +1 -1
  26. package/dist/src/lib/codegen-drift.d.ts +21 -0
  27. package/dist/src/lib/codegen-drift.d.ts.map +1 -0
  28. package/dist/src/lib/codegen-drift.js +142 -0
  29. package/dist/src/lib/codegen-drift.js.map +1 -0
  30. package/dist/src/lib/detect-stack.d.ts +7 -0
  31. package/dist/src/lib/detect-stack.d.ts.map +1 -0
  32. package/dist/src/lib/detect-stack.js +38 -0
  33. package/dist/src/lib/detect-stack.js.map +1 -0
  34. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  35. package/dist/src/lib/load-metaobjects-config.js +8 -0
  36. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  37. package/dist/src/lib/version.d.ts +7 -0
  38. package/dist/src/lib/version.d.ts.map +1 -0
  39. package/dist/src/lib/version.js +30 -0
  40. package/dist/src/lib/version.js.map +1 -0
  41. package/package.json +56 -45
  42. package/src/commands/docs.ts +438 -0
  43. package/src/commands/gen.ts +44 -1
  44. package/src/commands/init.ts +106 -31
  45. package/src/commands/verify.ts +81 -15
  46. package/src/index.ts +20 -30
  47. package/src/lib/agent-context-staleness.ts +24 -0
  48. package/src/lib/args.ts +41 -0
  49. package/src/lib/codegen-drift.ts +173 -0
  50. package/src/lib/detect-stack.ts +41 -0
  51. package/src/lib/load-metaobjects-config.ts +8 -0
  52. package/src/lib/version.ts +27 -0
@@ -0,0 +1,438 @@
1
+ // `meta docs <metadata> --out <dir>` — STANDALONE neutral metadata docs.
2
+ //
3
+ // Emits one neutral page per entity (`<Entity>.md`) and one per
4
+ // `template.output` (`<Template>.md`) from metadata ALONE — no gen config, no
5
+ // codegen pipeline. It is the Tier-2 delivery (like `migrate`): runnable from
6
+ // the compiled `meta` binary.
7
+ //
8
+ // DRY: this command does NOT re-walk objects/templates. It builds the same
9
+ // GenContext the codegen runner builds for the `docsFile()` generator and
10
+ // calls that generator, then writes the returned files. The neutrality of the
11
+ // output is therefore guaranteed — it is byte-for-byte the same generator the
12
+ // `meta gen` pipeline runs (gated by the docs conformance fixture).
13
+
14
+ import { resolve as resolvePath } from "node:path";
15
+ import { mkdir, writeFile } from "node:fs/promises";
16
+ import { log } from "../lib/log.js";
17
+ import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
18
+ import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
19
+ import { existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import {
22
+ makeRenderContext,
23
+ buildPkMap,
24
+ buildRelationMap,
25
+ resolveDocsConfig,
26
+ apiLabel,
27
+ } from "@metaobjectsdev/codegen-ts";
28
+ import type {
29
+ GenContext,
30
+ EmittedFile,
31
+ ResolvedDocsConfig,
32
+ DocsSurface,
33
+ } from "@metaobjectsdev/codegen-ts";
34
+ import { docsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators";
35
+ import { composeRegistry, coreProviders, renderCoreMetamodelDocs } from "@metaobjectsdev/metadata";
36
+
37
+ type DocsLayout = "flat" | "package";
38
+
39
+ interface DocsFlags {
40
+ /** Project root holding `metaobjects/` (the metadata to document). */
41
+ metadata: string;
42
+ /** Output directory for the rendered pages. */
43
+ out: string;
44
+ /** Page-placement layout. `flat` (default) writes `<Name>.md` at the out
45
+ * root; `package` folds pages under package-path subdirs (collision-proof
46
+ * for multi-package models with repeated short names). */
47
+ layout: DocsLayout;
48
+ /** Optional override for the project root used to resolve adopter
49
+ * `templates/` overrides. Defaults to the metadata root. */
50
+ templates?: string;
51
+ /** Which doc surfaces to emit, when overridden on the CLI. `--model` ⇒
52
+ * ["model"], `--api` ⇒ ["api"], both ⇒ ["model","api"]. Unset ⇒ defer to the
53
+ * resolved `docs:` config (default both). */
54
+ surfaces?: DocsSurface[];
55
+ /** Optional base URL override for cross-surface links (resolveDocsConfig). */
56
+ baseUrl?: string;
57
+ /** Whether `--out` was explicitly passed (so the resolver knows to override
58
+ * the config's `docs.outDir` rather than fall back to the parse default). */
59
+ outProvided: boolean;
60
+ /** Whether `--layout` was explicitly passed (same override semantics). */
61
+ layoutProvided: boolean;
62
+ /** FR-033 S3 — document the METAMODEL ITSELF (the built-in type/subtype/attr
63
+ * vocabulary) instead of a user's entities. Needs NO metadata + NO config. */
64
+ metamodel: boolean;
65
+ }
66
+
67
+ function parseLayout(v: string | undefined, flag: string): DocsLayout {
68
+ if (v === undefined) throw new Error(`${flag} requires flat|package`);
69
+ if (v !== "flat" && v !== "package") {
70
+ throw new Error(`${flag} must be "flat" or "package" (got "${v}")`);
71
+ }
72
+ return v;
73
+ }
74
+
75
+ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
76
+ let metadata: string | undefined;
77
+ let out: string | undefined;
78
+ let templates: string | undefined;
79
+ let layout: DocsLayout | undefined;
80
+ let baseUrl: string | undefined;
81
+ let wantModel = false;
82
+ let wantApi = false;
83
+ let wantMetamodel = false;
84
+ let outProvided = false;
85
+ let layoutProvided = false;
86
+ for (let i = 0; i < argv.length; i++) {
87
+ const a = argv[i]!;
88
+ if (a === "--out" || a === "-o") {
89
+ const v = argv[++i];
90
+ if (v === undefined) throw new Error(`${a} requires a directory argument`);
91
+ out = v;
92
+ outProvided = true;
93
+ } else if (a.startsWith("--out=")) {
94
+ out = a.slice("--out=".length);
95
+ outProvided = true;
96
+ } else if (a === "--layout") {
97
+ layout = parseLayout(argv[++i], a);
98
+ layoutProvided = true;
99
+ } else if (a.startsWith("--layout=")) {
100
+ layout = parseLayout(a.slice("--layout=".length), "--layout");
101
+ layoutProvided = true;
102
+ } else if (a === "--model") {
103
+ wantModel = true;
104
+ } else if (a === "--api") {
105
+ wantApi = true;
106
+ } else if (a === "--metamodel") {
107
+ wantMetamodel = true;
108
+ } else if (a === "--base-url") {
109
+ const v = argv[++i];
110
+ if (v === undefined) throw new Error(`${a} requires a URL argument`);
111
+ baseUrl = v;
112
+ } else if (a.startsWith("--base-url=")) {
113
+ baseUrl = a.slice("--base-url=".length);
114
+ } else if (a === "--templates") {
115
+ const v = argv[++i];
116
+ if (v === undefined) throw new Error(`${a} requires a directory argument`);
117
+ templates = v;
118
+ } else if (a.startsWith("--templates=")) {
119
+ templates = a.slice("--templates=".length);
120
+ } else if (a.startsWith("-")) {
121
+ throw new Error(`unknown flag: ${a}`);
122
+ } else if (metadata === undefined) {
123
+ metadata = a;
124
+ } else {
125
+ throw new Error(`unexpected argument: ${a}`);
126
+ }
127
+ }
128
+ // --model and/or --api narrow the surfaces; both flags (or neither) leave the
129
+ // surfaces unset so the resolved `docs:` config decides (default both).
130
+ const surfaces: DocsSurface[] = [];
131
+ if (wantModel) surfaces.push("model");
132
+ if (wantApi) surfaces.push("api");
133
+ return {
134
+ // `<metadata>` is the project root that contains metaobjects/; default cwd
135
+ // (mirrors how migrate/gen treat the working directory as the root).
136
+ metadata: metadata ?? cwd,
137
+ // Default out dir, resolved against the metadata root below. In --metamodel
138
+ // mode the renderer writes under <out>/metamodel/, default ./docs/metamodel.
139
+ out: out ?? (wantMetamodel ? "./docs/metamodel" : "./docs"),
140
+ // Default flat preserves today's single-package output (+ existing goldens).
141
+ layout: layout ?? "flat",
142
+ metamodel: wantMetamodel,
143
+ outProvided,
144
+ layoutProvided,
145
+ ...(surfaces.length > 0 ? { surfaces } : {}),
146
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
147
+ ...(templates !== undefined ? { templates } : {}),
148
+ };
149
+ }
150
+
151
+ export async function docsCommand(args: string[], cwd: string): Promise<number> {
152
+ let flags: DocsFlags;
153
+ try {
154
+ flags = parseDocsArgs(args, cwd);
155
+ } catch (err) {
156
+ log.error(`docs: ${(err as Error).message}`);
157
+ return 2;
158
+ }
159
+
160
+ // FR-033 S3 — `--metamodel`: document the BUILT-IN metamodel (type/subtype/
161
+ // attr vocabulary) from the strict registry. Unlike --model/--api this needs
162
+ // NEITHER a user's metadata NOR a config — there is nothing to load. It writes
163
+ // the renderer's files under <out>/metamodel/ (default ./docs/metamodel).
164
+ if (flags.metamodel) {
165
+ return metamodelDocsCommand(cwd, flags.out);
166
+ }
167
+
168
+ const metaRoot = resolvePath(cwd, flags.metadata);
169
+ // The project root used to resolve adopter `templates/` overrides; the
170
+ // framework defaults sit underneath via projectProvider's chain.
171
+ const projectRoot = flags.templates !== undefined
172
+ ? resolvePath(cwd, flags.templates)
173
+ : metaRoot;
174
+
175
+ // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
176
+ // providers (e.g. a project's custom field/object subtypes). Unlike `gen`,
177
+ // docs does NOT require a config — the Tier-2 "metadata alone" promise must
178
+ // hold for config-less projects. If the config is absent or invalid, fall
179
+ // back to defaults; the loader still surfaces a stable unknown-subtype error
180
+ // if the metadata genuinely uses an unregistered type.
181
+ let loadedConfig: Awaited<ReturnType<typeof loadMetaobjectsConfig>> | undefined;
182
+ let configProviders: NonNullable<Awaited<ReturnType<typeof loadMetaobjectsConfig>>["providers"]> | undefined;
183
+ // hasConfig gates the api surface: api docs describe the GENERATED REST
184
+ // surface, which only exists when there is a (loadable) gen config. A config
185
+ // that EXISTS but fails to load degrades to model-only with a warning.
186
+ const hasConfig = existsSync(join(metaRoot, "metaobjects.config.ts"));
187
+ // The config lives alongside metaobjects/ at the metadata root (metaRoot);
188
+ // projectRoot only diverges when --templates overrides the template lookup.
189
+ // Only attempt the load when the file is actually present: absence is the
190
+ // expected config-less case (stay silent), but a config that EXISTS yet fails
191
+ // to load is surfaced as a warning rather than silently degrading to
192
+ // provider-less docs — otherwise a custom-type project would later fail with a
193
+ // cryptic unknown-subtype error instead of the real config error.
194
+ if (hasConfig) {
195
+ try {
196
+ loadedConfig = await loadMetaobjectsConfig(metaRoot);
197
+ configProviders = loadedConfig.providers;
198
+ } catch (err) {
199
+ log.warn(
200
+ `docs: metaobjects.config.ts failed to load (${(err as Error).message}); ` +
201
+ `generating docs without its providers`,
202
+ );
203
+ loadedConfig = undefined;
204
+ configProviders = undefined;
205
+ }
206
+ }
207
+
208
+ // Merge the config `docs:` block with CLI overrides over documented defaults.
209
+ // CLI --out/--layout only override when explicitly passed; surfaces/baseUrl
210
+ // override whenever present. The resolver supplies defaults (outDir ./docs,
211
+ // layout = fallback, surfaces = both) so config-less + flag-less runs are
212
+ // unchanged.
213
+ const cliOverrides: Partial<ResolvedDocsConfig> = {
214
+ ...(flags.outProvided ? { outDir: flags.out } : {}),
215
+ ...(flags.layoutProvided ? { layout: flags.layout } : {}),
216
+ ...(flags.surfaces ? { surfaces: flags.surfaces } : {}),
217
+ ...(flags.baseUrl !== undefined ? { baseUrl: flags.baseUrl } : {}),
218
+ };
219
+ // Layout fallback chain: --layout (override, gated above) → docs.layout block →
220
+ // the project's top-level outputLayout → flat. So docs default to the SAME page
221
+ // placement as codegen when neither the docs block nor the CLI sets it.
222
+ const docsCfg = resolveDocsConfig(
223
+ loadedConfig?.docs,
224
+ cliOverrides,
225
+ loadedConfig?.outputLayout ?? "flat",
226
+ );
227
+ const outDir = resolvePath(metaRoot, docsCfg.outDir);
228
+
229
+ // Load metadata standalone — same loader path as migrate/gen. Threads any
230
+ // consumer providers from the config so custom types resolve.
231
+ let root;
232
+ try {
233
+ root = await loadMemory(metaRoot, {
234
+ ...(configProviders !== undefined ? { providers: configProviders } : {}),
235
+ });
236
+ } catch (err) {
237
+ const msg = (err as Error).message;
238
+ if (!existsSync(join(metaRoot, DEFAULT_METADATA_DIR))) {
239
+ log.error(`docs: no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
240
+ } else {
241
+ log.error(`docs: failed to load metadata: ${msg}`);
242
+ }
243
+ return 2;
244
+ }
245
+
246
+ // Build the same GenContext the codegen runner builds for docsFile(). The
247
+ // dialect only affects column-type hints on the entity page; "sqlite" is the
248
+ // neutral default (migrate's offline path uses the same fallback chain).
249
+ const renderContext = makeRenderContext({
250
+ dialect: "sqlite",
251
+ loadedRoot: root,
252
+ outDir,
253
+ dbImport: "",
254
+ pkMap: buildPkMap(root),
255
+ relationMap: buildRelationMap(root),
256
+ });
257
+ const ctx: GenContext = {
258
+ entities: root.objects(),
259
+ loadedRoot: root,
260
+ matches: () => true,
261
+ config: {
262
+ outDir,
263
+ extStyle: "none",
264
+ dbImport: "",
265
+ dialect: "sqlite",
266
+ outputLayout: docsCfg.layout,
267
+ // api-docs reads this to decide whether to document the opt-in Hono CRUD
268
+ // surface. Aggregate it from the generator set exactly as the gen runner
269
+ // does (a generator opts in via emitsHonoRoutes), so `meta docs` auto-detects
270
+ // routesFileHono() rather than relying on a field users don't normally set.
271
+ includeHonoRoutes:
272
+ loadedConfig?.includeHonoRoutes ??
273
+ (loadedConfig?.generators?.some(
274
+ (g) => typeof g !== "string" && g.emitsHonoRoutes === true,
275
+ ) ??
276
+ false),
277
+ } as never,
278
+ renderContext,
279
+ projectRoot,
280
+ warn: (msg) => log.warn(`docs: ${msg}`),
281
+ };
282
+
283
+ const emit: EmittedFile[] = [];
284
+ let modelFiles: EmittedFile[] = [];
285
+
286
+ // The declared api surfaces, each tagged with its human label (apiLabel maps
287
+ // the language key → label; never hardcode labels). The model page links one
288
+ // reference per declared surface — across ALL ports, not just the surfaces
289
+ // THIS command emits — so a polyglot model page points at every port's docs.
290
+ const labeled = docsCfg.apiSurfaces.map((s) => ({ ...s, label: apiLabel(s.lang) }));
291
+
292
+ // The api surface only materializes with a loadable gen config (there is
293
+ // nothing generated to document otherwise), so gate api emit + cross-linking
294
+ // on that. When false, the model surface emits its historical standalone form.
295
+ const apiSelected = docsCfg.surfaces.includes("api") && loadedConfig !== undefined;
296
+
297
+ // MODEL surface — the neutral metadata pages (<Entity>.md / <Template>.md +
298
+ // README.md). Keep the render-error handling tight around docsFile() only.
299
+ if (docsCfg.surfaces.includes("model")) {
300
+ // When api is selected, link EVERY declared surface from the model page (so a
301
+ // polyglot model page references each port's api docs). `labeled` already
302
+ // carries {label, subDir, baseUrl?} (docsFile ignores the extra `lang`), so
303
+ // pass it straight through. Otherwise no api refs.
304
+ const modelOpts = apiSelected ? { apiSurfaces: labeled } : {};
305
+ try {
306
+ modelFiles = await docsFile(modelOpts).generate(ctx);
307
+ } catch (err) {
308
+ const msg = (err as Error).message;
309
+ // Duplicate output path (silent-overwrite backstop): the generator already
310
+ // names both colliding FQNs + the path and starts with "docs:". Surface it
311
+ // verbatim as a clean non-zero exit (no double prefix, no stack trace).
312
+ if (msg.startsWith("docs: duplicate output path")) {
313
+ log.error(msg);
314
+ return 1;
315
+ }
316
+ // The framework templates resolve from disk; inside the compiled `meta`
317
+ // binary they live on a virtual fs the provider cannot read. Surface that
318
+ // as an actionable message rather than a cryptic render failure.
319
+ if (/entity-page|template-page|failed rendering|ENOENT|not found/i.test(msg)) {
320
+ log.error(
321
+ `docs: failed to render — templates not found. ` +
322
+ `Run 'meta docs' from an installed package layout (with on-disk ` +
323
+ `templates/), not the standalone binary, OR drop your own ` +
324
+ `templates/docs/entity-page.md.mustache + template-page.md.mustache. (${msg})`,
325
+ );
326
+ } else {
327
+ log.error(`docs: ${msg}`);
328
+ }
329
+ return 1;
330
+ }
331
+ emit.push(...modelFiles);
332
+ }
333
+
334
+ // API surface — the SDK reference for the GENERATED REST surface, side by side
335
+ // under each surface's subDir. THIS command only OWNS the surfaces it can
336
+ // generate — i.e. its own port (lang "ts"). Surfaces owned by other ports are
337
+ // linked (above) but produced by running that port's docs command; we just log
338
+ // a pointer. Only meaningful with a loadable gen config, so skip when absent.
339
+ let apiFiles: EmittedFile[] = [];
340
+ if (docsCfg.surfaces.includes("api")) {
341
+ if (loadedConfig !== undefined) {
342
+ try {
343
+ // Emit every surface THIS port owns (loop so it generalizes beyond the
344
+ // single ts surface owned today).
345
+ for (const s of labeled.filter((s) => s.lang === "ts")) {
346
+ apiFiles.push(
347
+ ...(await apiDocsFile({
348
+ subDir: s.subDir,
349
+ modelSurface: docsCfg.surfaces.includes("model"),
350
+ }).generate(ctx)),
351
+ );
352
+ }
353
+ } catch (err) {
354
+ const msg = (err as Error).message;
355
+ if (msg.startsWith("docs: duplicate output path")) {
356
+ log.error(msg);
357
+ return 1;
358
+ }
359
+ log.error(`docs: ${msg}`);
360
+ return 1;
361
+ }
362
+ emit.push(...apiFiles);
363
+ // Surfaces owned by other ports: link only, with a pointer to where they
364
+ // get produced.
365
+ for (const s of labeled.filter((s) => s.lang !== "ts")) {
366
+ log.info(
367
+ `meta docs: api surface '${s.lang}' (${s.subDir}) is produced by that port's docs command — run it to populate those pages.`,
368
+ );
369
+ }
370
+ } else if (hasConfig) {
371
+ // Config present but failed to load — already warned above; don't claim an
372
+ // api surface we couldn't build.
373
+ log.info("meta docs: api surface skipped — metaobjects.config.ts failed to load.");
374
+ } else {
375
+ log.info("meta docs: api surface skipped — no metaobjects.config.ts (nothing generated to document).");
376
+ }
377
+ }
378
+
379
+ try {
380
+ await mkdir(outDir, { recursive: true });
381
+ for (const f of emit) {
382
+ const path = resolvePath(outDir, f.path);
383
+ await mkdir(resolvePath(path, ".."), { recursive: true });
384
+ await writeFile(path, f.content, "utf8");
385
+ }
386
+ } catch (err) {
387
+ log.error(`docs: failed to write pages: ${(err as Error).message}`);
388
+ return 1;
389
+ }
390
+
391
+ // Summary: docsFile() emits ONE overview/index page (README.md) plus one page
392
+ // per entity and one per template.output. The entity count is the matched
393
+ // object count; the remaining non-overview model pages are template pages.
394
+ const entityCount = root.objects().filter(ctx.matches).length;
395
+ const modelOverview = modelFiles.filter((f) => f.path === "README.md").length;
396
+ const modelTemplates = modelFiles.length > 0
397
+ ? modelFiles.length - entityCount - modelOverview
398
+ : 0;
399
+ const modelSummary = docsCfg.surfaces.includes("model")
400
+ ? `${modelOverview} overview + ${entityCount} entity page(s) + ${modelTemplates} template page(s)`
401
+ : "model surface skipped";
402
+ const apiSummary = apiFiles.length > 0
403
+ ? `${apiFiles.length} api page(s)`
404
+ : "no api pages";
405
+ log.info(`meta docs — wrote ${modelSummary}; ${apiSummary} → ${outDir}`);
406
+ return 0;
407
+ }
408
+
409
+ /**
410
+ * FR-033 S3 — emit the metamodel reference docs (INDEX.md + per-type pages +
411
+ * providers.md) from the BUILT-IN strict registry. No metadata, no config:
412
+ * `composeRegistry(coreProviders)` IS the source. Files land under
413
+ * `<out>` (which defaults to `./docs/metamodel`), preserving the renderer's
414
+ * `types/<family>.md` subtree.
415
+ */
416
+ async function metamodelDocsCommand(cwd: string, out: string): Promise<number> {
417
+ const outDir = resolvePath(cwd, out);
418
+ let docs: Map<string, string>;
419
+ try {
420
+ docs = renderCoreMetamodelDocs(composeRegistry(coreProviders));
421
+ } catch (err) {
422
+ log.error(`docs: failed to render metamodel docs: ${(err as Error).message}`);
423
+ return 1;
424
+ }
425
+ try {
426
+ await mkdir(outDir, { recursive: true });
427
+ for (const [rel, content] of docs) {
428
+ const path = resolvePath(outDir, rel);
429
+ await mkdir(resolvePath(path, ".."), { recursive: true });
430
+ await writeFile(path, content, "utf8");
431
+ }
432
+ } catch (err) {
433
+ log.error(`docs: failed to write metamodel pages: ${(err as Error).message}`);
434
+ return 1;
435
+ }
436
+ log.info(`meta docs --metamodel — wrote ${docs.size} page(s) → ${outDir}`);
437
+ return 0;
438
+ }
@@ -5,8 +5,9 @@ import { resolveGenConfig } from "../lib/config.js";
5
5
  import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
6
6
  import { formatGenResult, type GenFileEntry, type GenFileStatus } from "../lib/output.js";
7
7
  import { log } from "../lib/log.js";
8
+ import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
8
9
  import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
9
- import { runGen } from "@metaobjectsdev/codegen-ts";
10
+ import { runGen, listGenerators } from "@metaobjectsdev/codegen-ts";
10
11
  import type { WriteStatus } from "@metaobjectsdev/codegen-ts";
11
12
 
12
13
  function mapStatus(s: WriteStatus): GenFileStatus {
@@ -26,6 +27,15 @@ export async function genCommand(args: string[], cwd: string): Promise<number> {
26
27
  try { flags = parseGenArgs(args); }
27
28
  catch (err) { log.error((err as Error).message); return 2; }
28
29
 
30
+ // ADR-0021 D3 — `meta gen --list`: print the stable-name generator registry
31
+ // and exit 0 WITHOUT running codegen (no config/metadata required).
32
+ if (flags.list) {
33
+ return listGeneratorsCommand();
34
+ }
35
+
36
+ // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
37
+ warnIfAgentContextStale(cwd);
38
+
29
39
  const projectRoot = cwd;
30
40
  const cliConfig = resolveGenConfig(flags);
31
41
 
@@ -112,3 +122,36 @@ export async function genCommand(args: string[], cwd: string): Promise<number> {
112
122
  const hasFailure = files.some((f) => f.status === "conflict" || f.status === "refused");
113
123
  return hasFailure ? 1 : 0;
114
124
  }
125
+
126
+ /**
127
+ * `meta gen --list` — print the stable-name generator registry (ADR-0021 D3).
128
+ *
129
+ * Generators are grouped by tier: the recommended native `meta gen` suite
130
+ * first, then neutral artifacts (owned by `meta docs` per D1). Each line is
131
+ * `<stable-name> — <description>` plus an options summary and, for neutral
132
+ * entries, a note pointing at the canonical door. Exits 0; no codegen runs.
133
+ */
134
+ function listGeneratorsCommand(): number {
135
+ const entries = listGenerators();
136
+ const native = entries.filter((e) => e.tier === "native");
137
+ const neutral = entries.filter((e) => e.tier === "neutral");
138
+ const width = Math.max(...entries.map((e) => e.name.length));
139
+
140
+ const lines: string[] = [];
141
+ lines.push("Available generators (select by stable name):");
142
+ lines.push("");
143
+ lines.push("Native (recommended `meta gen` suite):");
144
+ for (const e of native) {
145
+ lines.push(` ${e.name.padEnd(width)} — ${e.description}`);
146
+ if (e.options) lines.push(` ${" ".repeat(width)} options: ${e.options}`);
147
+ }
148
+ lines.push("");
149
+ lines.push("Neutral (owned by `meta docs`; not part of the native suite):");
150
+ for (const e of neutral) {
151
+ lines.push(` ${e.name.padEnd(width)} — ${e.description}`);
152
+ if (e.note) lines.push(` ${" ".repeat(width)} ${e.note}`);
153
+ }
154
+
155
+ log.info(lines.join("\n"));
156
+ return 0;
157
+ }