markform 0.1.6 → 0.1.8

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 (47) hide show
  1. package/README.md +464 -207
  2. package/dist/ai-sdk.d.mts +1 -2
  3. package/dist/ai-sdk.mjs +3 -2
  4. package/dist/{apply-DMQl-VVd.mjs → apply-BUU2QcJ2.mjs} +130 -23
  5. package/dist/bin.d.mts +0 -1
  6. package/dist/bin.mjs +3 -6
  7. package/dist/{cli-CXjkdym_.mjs → cli-BZh25bvy.mjs} +1380 -632
  8. package/dist/cli.d.mts +0 -1
  9. package/dist/cli.mjs +2 -6
  10. package/dist/coreTypes-BSPJ9H27.d.mts +3253 -0
  11. package/dist/{coreTypes-pyctKRgc.mjs → coreTypes-DJtu8OOp.mjs} +26 -4
  12. package/dist/index.d.mts +112 -5
  13. package/dist/index.mjs +6 -5
  14. package/dist/{session-uF0e6m6k.mjs → session-CmHdAPyg.mjs} +3 -2
  15. package/dist/session-DSTNiHza.mjs +4 -0
  16. package/dist/{shared-u22MtBRo.mjs → shared-C9yW5FLZ.mjs} +2 -1
  17. package/dist/{shared-DRlgu2ZJ.mjs → shared-DQ6y3Ggc.mjs} +3 -1
  18. package/dist/{src-o_5TSoHQ.mjs → src-kUggXhN1.mjs} +519 -98
  19. package/docs/markform-apis.md +30 -1
  20. package/docs/markform-reference.md +65 -6
  21. package/docs/markform-spec.md +2 -2
  22. package/examples/earnings-analysis/earnings-analysis.form.md +1 -0
  23. package/examples/movie-research/movie-research-basic.form.md +1 -0
  24. package/examples/movie-research/movie-research-deep.form.md +16 -56
  25. package/examples/movie-research/movie-research-demo.form.md +60 -0
  26. package/examples/rejection-test/rejection-test-mock-filled.form.md +41 -0
  27. package/examples/rejection-test/rejection-test-mock-filled.report.md +15 -0
  28. package/examples/rejection-test/rejection-test-mock-filled.schema.json +59 -0
  29. package/examples/rejection-test/rejection-test-mock-filled.yml +13 -0
  30. package/examples/rejection-test/rejection-test.form.md +35 -0
  31. package/examples/rejection-test/rejection-test.session.yaml +88 -0
  32. package/examples/simple/simple-mock-filled.report.md +96 -0
  33. package/examples/simple/simple-mock-filled.schema.json +374 -0
  34. package/examples/simple/simple-mock-filled.yml +87 -0
  35. package/examples/simple/simple-skipped-filled.report.md +90 -0
  36. package/examples/simple/simple-skipped-filled.schema.json +374 -0
  37. package/examples/simple/simple-skipped-filled.yml +77 -0
  38. package/examples/simple/simple-with-skips.session.yaml +3 -3
  39. package/examples/simple/simple.form.md +1 -0
  40. package/examples/simple/simple.schema.json +374 -0
  41. package/examples/simple/simple.session.yaml +3 -3
  42. package/examples/startup-deep-research/startup-deep-research.form.md +1 -0
  43. package/examples/startup-research/startup-research.form.md +1 -0
  44. package/package.json +11 -9
  45. package/dist/coreTypes-9XZSNOv6.d.mts +0 -8951
  46. package/dist/session-B_stoXQn.mjs +0 -4
  47. package/examples/movie-research/movie-research-minimal.form.md +0 -68
@@ -1,19 +1,66 @@
1
- import { L as PatchSchema } from "./coreTypes-pyctKRgc.mjs";
2
- import { A as SUGGESTED_LLMS, D as deriveReportPath, E as deriveExportPath, F as parseModelIdForDisplay, M as formatSuggestedLlms, O as detectFileType, P as hasWebSearchSupport, T as USER_ROLE, _ as DEFAULT_MAX_TURNS, b as DEFAULT_RESEARCH_MAX_ISSUES_PER_TURN, d as serializeRawMarkdown, f as serializeReportMarkdown, g as DEFAULT_MAX_PATCHES_PER_TURN, h as DEFAULT_MAX_ISSUES_PER_TURN, j as WEB_SEARCH_CONFIG, k as parseRolesFlag, m as DEFAULT_FORMS_DIR, p as AGENT_ROLE, r as inspect, t as applyPatches, u as serialize, v as DEFAULT_PORT, w as REPORT_EXTENSION, x as DEFAULT_RESEARCH_MAX_PATCHES_PER_TURN } from "./apply-DMQl-VVd.mjs";
3
- import { a as resolveHarnessConfig, c as getProviderNames, f as createMockAgent, i as runResearch, l as resolveModel, m as createHarness, s as getProviderInfo, t as VERSION, u as createLiveAgent, w as parseForm } from "./src-o_5TSoHQ.mjs";
4
- import { n as serializeSession } from "./session-uF0e6m6k.mjs";
5
- import { a as formatPath, c as logError, d as logTiming, f as logVerbose, g as writeFile, i as formatOutput, l as logInfo, m as readFile$1, n as createSpinner, o as getCommandContext, p as logWarn, r as ensureFormsDir, s as logDryRun, t as OUTPUT_FORMATS, u as logSuccess } from "./shared-DRlgu2ZJ.mjs";
1
+
2
+ import { L as PatchSchema } from "./coreTypes-DJtu8OOp.mjs";
3
+ import { A as deriveSchemaPath, D as USER_ROLE, E as REPORT_EXTENSION, F as formatSuggestedLlms, L as hasWebSearchSupport, M as parseRolesFlag, N as SUGGESTED_LLMS, O as deriveExportPath, P as WEB_SEARCH_CONFIG, R as parseModelIdForDisplay, S as DEFAULT_RESEARCH_MAX_PATCHES_PER_TURN, T as MAX_FORMS_IN_MENU, _ as DEFAULT_MAX_PATCHES_PER_TURN, d as serialize, f as serializeRawMarkdown, g as DEFAULT_MAX_ISSUES_PER_TURN, h as DEFAULT_FORMS_DIR, i as inspect, j as detectFileType, k as deriveReportPath, m as AGENT_ROLE, n as getAllFields, p as serializeReportMarkdown, t as applyPatches, v as DEFAULT_MAX_TURNS, x as DEFAULT_RESEARCH_MAX_ISSUES_PER_TURN, y as DEFAULT_PORT } from "./apply-BUU2QcJ2.mjs";
4
+ import { E as parseForm, T as formToJsonSchema, a as resolveHarnessConfig, c as getProviderNames, f as createMockAgent, i as runResearch, l as resolveModel, m as createHarness, n as isResearchForm, o as fillForm, s as getProviderInfo, t as VERSION, u as createLiveAgent } from "./src-kUggXhN1.mjs";
5
+ import { n as serializeSession } from "./session-CmHdAPyg.mjs";
6
+ import { a as formatPath, c as logError, d as logTiming, f as logVerbose, g as writeFile, i as formatOutput, l as logInfo, m as readFile$1, n as createSpinner, o as getCommandContext, p as logWarn, r as ensureFormsDir, s as logDryRun, t as OUTPUT_FORMATS, u as logSuccess } from "./shared-DQ6y3Ggc.mjs";
6
7
  import YAML from "yaml";
7
8
  import { Command } from "commander";
8
9
  import pc from "picocolors";
10
+ import { exec, execSync, spawn } from "node:child_process";
9
11
  import { basename, dirname, join, resolve } from "node:path";
10
- import { existsSync, readFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
11
13
  import { fileURLToPath } from "node:url";
12
14
  import { readFile } from "node:fs/promises";
13
15
  import * as p from "@clack/prompts";
14
- import { exec, spawn } from "node:child_process";
15
16
  import { createServer } from "node:http";
16
17
 
18
+ //#region src/cli/lib/cliVersion.ts
19
+ /**
20
+ * CLI version helper that computes git version in development mode.
21
+ *
22
+ * When running via tsx (development), __MARKFORM_VERSION__ is not injected,
23
+ * so VERSION from index.ts returns 'development'. This module computes the
24
+ * actual git version dynamically for consistent version display.
25
+ */
26
+ /**
27
+ * Get version from git tags with format: X.Y.Z-dev.N.hash
28
+ * Only called when running in development mode (via tsx).
29
+ */
30
+ function getGitVersion() {
31
+ try {
32
+ const git = (args) => execSync(`git ${args}`, {
33
+ encoding: "utf-8",
34
+ stdio: [
35
+ "ignore",
36
+ "pipe",
37
+ "ignore"
38
+ ]
39
+ }).trim();
40
+ const tag = git("describe --tags --abbrev=0");
41
+ const tagVersion = tag.replace(/^v/, "");
42
+ const [major, minor, patch] = tagVersion.split(".").map(Number);
43
+ const commitsSinceTag = parseInt(git(`rev-list ${tag}..HEAD --count`), 10);
44
+ const hash = git("rev-parse --short=7 HEAD");
45
+ let dirty = false;
46
+ try {
47
+ git("diff --quiet");
48
+ git("diff --cached --quiet");
49
+ } catch {
50
+ dirty = true;
51
+ }
52
+ if (commitsSinceTag === 0 && !dirty) return tagVersion;
53
+ return `${major}.${minor}.${(patch ?? 0) + 1}-dev.${commitsSinceTag}.${dirty ? `${hash}-dirty` : hash}`;
54
+ } catch {
55
+ return "development";
56
+ }
57
+ }
58
+ /**
59
+ * CLI version - uses build-time VERSION if available, otherwise computes from git.
60
+ */
61
+ const CLI_VERSION = VERSION === "development" ? getGitVersion() : VERSION;
62
+
63
+ //#endregion
17
64
  //#region src/cli/lib/paths.ts
18
65
  /**
19
66
  * Path utilities for CLI commands.
@@ -145,7 +192,7 @@ function formatState$2(state, useColors) {
145
192
  /**
146
193
  * Format apply report for console output.
147
194
  */
148
- function formatConsoleReport$2(report, useColors) {
195
+ function formatConsoleReport$3(report, useColors) {
149
196
  const lines = [];
150
197
  const bold = useColors ? pc.bold : (s) => s;
151
198
  const dim = useColors ? pc.dim : (s) => s;
@@ -227,7 +274,7 @@ function registerApplyCommand(program) {
227
274
  structure: result.structureSummary,
228
275
  progress: result.progressSummary,
229
276
  issues: result.issues
230
- }, (data, useColors) => formatConsoleReport$2(data, useColors));
277
+ }, (data, useColors) => formatConsoleReport$3(data, useColors));
231
278
  console.error(output);
232
279
  process.exit(1);
233
280
  }
@@ -239,7 +286,7 @@ function registerApplyCommand(program) {
239
286
  structure: result.structureSummary,
240
287
  progress: result.progressSummary,
241
288
  issues: result.issues
242
- }, (data, useColors) => formatConsoleReport$2(data, useColors));
289
+ }, (data, useColors) => formatConsoleReport$3(data, useColors));
243
290
  if (options.output) {
244
291
  await writeFile(options.output, output);
245
292
  logSuccess(ctx, `Report written to ${options.output}`);
@@ -258,6 +305,357 @@ function registerApplyCommand(program) {
258
305
  });
259
306
  }
260
307
 
308
+ //#endregion
309
+ //#region src/cli/lib/fileViewer.ts
310
+ /**
311
+ * File viewer utility for displaying files with colorization and pagination.
312
+ *
313
+ * Provides a modern console experience:
314
+ * - Syntax highlighting for markdown and YAML
315
+ * - Pagination using system pager (less) when available
316
+ * - Fallback to console output when not interactive
317
+ */
318
+ /**
319
+ * Check if stdout is an interactive terminal.
320
+ */
321
+ function isInteractive$3() {
322
+ return process.stdout.isTTY === true;
323
+ }
324
+ /**
325
+ * Apply terminal formatting to markdown content.
326
+ * Colorizes headers, code blocks, and other elements.
327
+ */
328
+ function formatMarkdown$3(content) {
329
+ const lines = content.split("\n");
330
+ const formatted = [];
331
+ let inCodeBlock = false;
332
+ for (const line of lines) {
333
+ if (line.startsWith("```")) {
334
+ inCodeBlock = !inCodeBlock;
335
+ formatted.push(pc.dim(line));
336
+ continue;
337
+ }
338
+ if (inCodeBlock) {
339
+ formatted.push(pc.cyan(line));
340
+ continue;
341
+ }
342
+ if (line.startsWith("# ")) {
343
+ formatted.push(pc.bold(pc.magenta(line)));
344
+ continue;
345
+ }
346
+ if (line.startsWith("## ")) {
347
+ formatted.push(pc.bold(pc.blue(line)));
348
+ continue;
349
+ }
350
+ if (line.startsWith("### ")) {
351
+ formatted.push(pc.bold(pc.cyan(line)));
352
+ continue;
353
+ }
354
+ if (line.startsWith("#### ")) {
355
+ formatted.push(pc.bold(line));
356
+ continue;
357
+ }
358
+ let formattedLine = line.replace(/`([^`]+)`/g, (_match, code) => {
359
+ return pc.yellow(code);
360
+ });
361
+ formattedLine = formattedLine.replace(/\*\*([^*]+)\*\*/g, (_match, text) => {
362
+ return pc.bold(text);
363
+ });
364
+ formattedLine = formattedLine.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
365
+ return `${pc.cyan(text)} ${pc.dim(`(${url})`)}`;
366
+ });
367
+ formattedLine = formattedLine.replace(/\{%\s*(\w+)\s*([^%]*)\s*%\}/g, (_match, tag, attrs) => {
368
+ const attrsPart = attrs.trim() ? ` ${pc.dim(attrs.trim())}` : "";
369
+ return `${pc.dim("{% ")}${pc.green(tag)}${attrsPart} ${pc.dim("%}")}`;
370
+ });
371
+ formatted.push(formattedLine);
372
+ }
373
+ return formatted.join("\n");
374
+ }
375
+ /**
376
+ * Apply terminal formatting to YAML content.
377
+ */
378
+ function formatYaml(content) {
379
+ const lines = content.split("\n");
380
+ const formatted = [];
381
+ for (const line of lines) {
382
+ if (line.trim().startsWith("#")) {
383
+ formatted.push(pc.dim(line));
384
+ continue;
385
+ }
386
+ const match = /^(\s*)([^:]+)(:)(.*)$/.exec(line);
387
+ if (match) {
388
+ const [, indent, key, colon, value] = match;
389
+ formatted.push(`${indent}${pc.cyan(key)}${pc.dim(colon)}${pc.yellow(value)}`);
390
+ continue;
391
+ }
392
+ if (line.trim().startsWith("-")) {
393
+ formatted.push(pc.green(line));
394
+ continue;
395
+ }
396
+ formatted.push(line);
397
+ }
398
+ return formatted.join("\n");
399
+ }
400
+ /**
401
+ * Format file content based on extension.
402
+ */
403
+ function formatContent(content, filename) {
404
+ if (filename.endsWith(".yml") || filename.endsWith(".yaml")) return formatYaml(content);
405
+ if (filename.endsWith(".md")) return formatMarkdown$3(content);
406
+ return content;
407
+ }
408
+ /**
409
+ * Display content using system pager (less) if available.
410
+ * Falls back to console.log if not interactive or pager unavailable.
411
+ *
412
+ * @returns Promise that resolves when viewing is complete
413
+ */
414
+ async function displayWithPager(content, title) {
415
+ if (!isInteractive$3()) {
416
+ console.log(content);
417
+ return;
418
+ }
419
+ const header = `${pc.bgCyan(pc.black(` ${title} `))}`;
420
+ return new Promise((resolve$1) => {
421
+ const pager = spawn("less", [
422
+ "-R",
423
+ "-S",
424
+ "-X",
425
+ "-F",
426
+ "-K"
427
+ ], { stdio: [
428
+ "pipe",
429
+ "inherit",
430
+ "inherit"
431
+ ] });
432
+ pager.on("error", () => {
433
+ console.log(header);
434
+ console.log("");
435
+ console.log(content);
436
+ console.log("");
437
+ resolve$1();
438
+ });
439
+ pager.on("close", () => {
440
+ resolve$1();
441
+ });
442
+ pager.stdin.write(header + "\n\n");
443
+ pager.stdin.write(content);
444
+ pager.stdin.end();
445
+ });
446
+ }
447
+ /**
448
+ * Load and display a file with formatting and pagination.
449
+ */
450
+ async function viewFile(filePath) {
451
+ const content = readFileSync(filePath, "utf-8");
452
+ const filename = basename(filePath);
453
+ await displayWithPager(formatContent(content, filename), filename);
454
+ }
455
+ /**
456
+ * Show an interactive file viewer chooser.
457
+ *
458
+ * Presents a list of files to view:
459
+ * - "Show report:" for the report output (.report.md) at the top
460
+ * - "Show source:" for other files (.form.md, .raw.md, .yml)
461
+ * - "Quit" at the bottom
462
+ *
463
+ * Loops until the user selects Quit.
464
+ *
465
+ * @param files Array of file options to display
466
+ */
467
+ async function showFileViewerChooser(files) {
468
+ if (!isInteractive$3()) return;
469
+ console.log("");
470
+ const reportFile = files.find((f) => f.path.endsWith(".report.md"));
471
+ const sourceFiles = files.filter((f) => !f.path.endsWith(".report.md"));
472
+ while (true) {
473
+ const options = [];
474
+ if (reportFile) options.push({
475
+ value: reportFile.path,
476
+ label: `Show report: ${pc.green(basename(reportFile.path))}`,
477
+ hint: reportFile.hint ?? ""
478
+ });
479
+ for (const file of sourceFiles) options.push({
480
+ value: file.path,
481
+ label: `Show source: ${pc.green(basename(file.path))}`,
482
+ hint: file.hint ?? ""
483
+ });
484
+ options.push({
485
+ value: "quit",
486
+ label: "Quit",
487
+ hint: ""
488
+ });
489
+ const selection = await p.select({
490
+ message: "View files:",
491
+ options
492
+ });
493
+ if (p.isCancel(selection) || selection === "quit") break;
494
+ await viewFile(selection);
495
+ console.log("");
496
+ }
497
+ }
498
+
499
+ //#endregion
500
+ //#region src/cli/commands/browse.ts
501
+ /**
502
+ * Browse command - Interactive file browser for the forms directory.
503
+ *
504
+ * Provides a menu-based interface for viewing files with syntax highlighting
505
+ * and pagination. Shows .form.md, .report.md, .yml, and .schema.json files.
506
+ *
507
+ * Usage:
508
+ * markform browse # Browse all files in forms/
509
+ * markform browse --filter=foo # Only show files matching pattern
510
+ */
511
+ /** File extensions we support viewing */
512
+ const VIEWABLE_EXTENSIONS = [
513
+ ".form.md",
514
+ ".report.md",
515
+ ".yml",
516
+ ".yaml",
517
+ ".schema.json",
518
+ ".raw.md"
519
+ ];
520
+ /**
521
+ * Check if a file has a viewable extension.
522
+ */
523
+ function isViewableFile(filename) {
524
+ return VIEWABLE_EXTENSIONS.some((ext) => filename.endsWith(ext));
525
+ }
526
+ /**
527
+ * Get the display extension for sorting/grouping.
528
+ */
529
+ function getExtension(filename) {
530
+ for (const ext of VIEWABLE_EXTENSIONS) if (filename.endsWith(ext)) return ext;
531
+ return "";
532
+ }
533
+ /**
534
+ * Scan forms directory for viewable files.
535
+ */
536
+ function scanFormsDirectory$1(formsDir, filter) {
537
+ const entries = [];
538
+ try {
539
+ const files = readdirSync(formsDir);
540
+ for (const file of files) {
541
+ if (!isViewableFile(file)) continue;
542
+ if (filter && !file.toLowerCase().includes(filter.toLowerCase())) continue;
543
+ const fullPath = join(formsDir, file);
544
+ try {
545
+ const stat = statSync(fullPath);
546
+ if (stat.isFile()) entries.push({
547
+ path: fullPath,
548
+ filename: file,
549
+ mtime: stat.mtime,
550
+ extension: getExtension(file)
551
+ });
552
+ } catch {}
553
+ }
554
+ } catch {}
555
+ entries.sort((a, b) => {
556
+ const timeDiff = b.mtime.getTime() - a.mtime.getTime();
557
+ if (timeDiff !== 0) return timeDiff;
558
+ return a.filename.localeCompare(b.filename);
559
+ });
560
+ return entries;
561
+ }
562
+ /**
563
+ * Get extension hint for display.
564
+ */
565
+ function getExtensionHint(ext) {
566
+ switch (ext) {
567
+ case ".form.md": return "markform source";
568
+ case ".report.md": return "output report";
569
+ case ".yml":
570
+ case ".yaml": return "YAML values";
571
+ case ".schema.json": return "JSON Schema";
572
+ case ".raw.md": return "raw markdown";
573
+ default: return "";
574
+ }
575
+ }
576
+ /**
577
+ * Format file entry for menu display.
578
+ */
579
+ function formatFileLabel(entry) {
580
+ return `${entry.extension === ".report.md" ? pc.green("*") : " "} ${entry.filename}`;
581
+ }
582
+ /**
583
+ * Browse specific output files after a form run.
584
+ *
585
+ * This is a convenience function for the examples workflow that shows
586
+ * the standard output files (report, yml, form, schema) for a completed form.
587
+ *
588
+ * @param basePath - Base path of the output (e.g., "forms/movie-research-demo-filled1")
589
+ */
590
+ async function browseOutputFiles(basePath) {
591
+ const outputExtensions = [
592
+ ".report.md",
593
+ ".yml",
594
+ ".form.md",
595
+ ".schema.json"
596
+ ];
597
+ const files = [];
598
+ for (const ext of outputExtensions) {
599
+ const fullPath = basePath + ext;
600
+ try {
601
+ statSync(fullPath);
602
+ files.push({
603
+ path: fullPath,
604
+ label: basename(fullPath),
605
+ hint: getExtensionHint(ext)
606
+ });
607
+ } catch {}
608
+ }
609
+ if (files.length === 0) return;
610
+ await showFileViewerChooser(files);
611
+ }
612
+ /**
613
+ * Register the browse command.
614
+ */
615
+ function registerBrowseCommand(program) {
616
+ program.command("browse").description("Browse and view files in the forms directory").option("--filter <pattern>", "Only show files matching pattern").action(async (options, cmd) => {
617
+ const ctx = getCommandContext(cmd);
618
+ try {
619
+ const formsDir = getFormsDir(ctx.formsDir);
620
+ p.intro(pc.bgCyan(pc.black(" markform browse ")));
621
+ const entries = scanFormsDirectory$1(formsDir, options.filter);
622
+ if (entries.length === 0) {
623
+ if (options.filter) p.log.warn(`No files matching "${options.filter}" found in ${formatPath(formsDir)}`);
624
+ else p.log.warn(`No viewable files found in ${formatPath(formsDir)}`);
625
+ console.log("");
626
+ console.log(`Run ${pc.cyan("'markform examples'")} to get started.`);
627
+ p.outro("");
628
+ return;
629
+ }
630
+ console.log(pc.dim(`Found ${entries.length} file(s) in ${formatPath(formsDir)}`));
631
+ const menuOptions = entries.map((entry) => ({
632
+ value: entry.path,
633
+ label: formatFileLabel(entry),
634
+ hint: getExtensionHint(entry.extension)
635
+ }));
636
+ menuOptions.push({
637
+ value: "quit",
638
+ label: "Quit",
639
+ hint: ""
640
+ });
641
+ while (true) {
642
+ const selection = await p.select({
643
+ message: "Select a file to view:",
644
+ options: menuOptions
645
+ });
646
+ if (p.isCancel(selection)) break;
647
+ if (selection === "quit") break;
648
+ await viewFile(selection);
649
+ console.log("");
650
+ }
651
+ p.outro("");
652
+ } catch (error) {
653
+ logError(error instanceof Error ? error.message : String(error));
654
+ process.exit(1);
655
+ }
656
+ });
657
+ }
658
+
261
659
  //#endregion
262
660
  //#region src/cli/commands/docs.ts
263
661
  /**
@@ -285,7 +683,7 @@ function loadDocs() {
285
683
  * Apply basic terminal formatting to markdown content.
286
684
  * Colorizes headers, code blocks, and other elements for better readability.
287
685
  */
288
- function formatMarkdown$3(content, useColors) {
686
+ function formatMarkdown$2(content, useColors) {
289
687
  if (!useColors) return content;
290
688
  const lines = content.split("\n");
291
689
  const formatted = [];
@@ -328,7 +726,7 @@ function formatMarkdown$3(content, useColors) {
328
726
  /**
329
727
  * Check if stdout is an interactive terminal.
330
728
  */
331
- function isInteractive$3() {
729
+ function isInteractive$2() {
332
730
  return process.stdout.isTTY === true;
333
731
  }
334
732
  /**
@@ -344,7 +742,7 @@ function registerDocsCommand(program) {
344
742
  program.command("docs").description("Display concise Markform syntax reference (agent-friendly)").option("--raw", "Output raw markdown without formatting").action((options, cmd) => {
345
743
  const ctx = getCommandContext(cmd);
346
744
  try {
347
- displayContent$2(formatMarkdown$3(loadDocs(), !options.raw && isInteractive$3() && !ctx.quiet));
745
+ displayContent$2(formatMarkdown$2(loadDocs(), !options.raw && isInteractive$2() && !ctx.quiet));
348
746
  } catch (error) {
349
747
  logError(error instanceof Error ? error.message : String(error));
350
748
  process.exit(1);
@@ -361,6 +759,7 @@ function registerDocsCommand(program) {
361
759
  * - Markform format (.form.md) - canonical form with directives
362
760
  * - Raw markdown (.raw.md) - plain readable markdown
363
761
  * - YAML values (.yml) - extracted field values
762
+ * - JSON Schema (.schema.json) - form structure for validation/tooling
364
763
  */
365
764
  /**
366
765
  * Convert field responses to structured format for export (markform-218).
@@ -426,6 +825,23 @@ function toStructuredValues(form) {
426
825
  case "url_list":
427
826
  exportValue = value.items;
428
827
  break;
828
+ case "date":
829
+ exportValue = value.value ?? null;
830
+ break;
831
+ case "year":
832
+ exportValue = value.value ?? null;
833
+ break;
834
+ case "table":
835
+ exportValue = value.rows.map((row) => {
836
+ const rowObj = {};
837
+ for (const [colId, cellResp] of Object.entries(row)) rowObj[colId] = cellResp.value ?? null;
838
+ return rowObj;
839
+ });
840
+ break;
841
+ default: {
842
+ const _exhaustive = value;
843
+ throw new Error(`Unhandled field value kind: ${_exhaustive.kind}`);
844
+ }
429
845
  }
430
846
  result[fieldId] = {
431
847
  state: "answered",
@@ -449,7 +865,7 @@ function toNotesArray(form) {
449
865
  * Derive export paths from a base form path.
450
866
  * Uses centralized extension constants from settings.ts.
451
867
  *
452
- * Standard exports: report, values (yaml), form.
868
+ * Standard exports: report, values (yaml), form, schema.
453
869
  * Raw markdown is available via CLI but not in standard exports.
454
870
  *
455
871
  * @param basePath - Path to the .form.md file
@@ -459,7 +875,8 @@ function deriveExportPaths(basePath) {
459
875
  return {
460
876
  reportPath: deriveReportPath(basePath),
461
877
  yamlPath: deriveExportPath(basePath, "yaml"),
462
- formPath: deriveExportPath(basePath, "form")
878
+ formPath: deriveExportPath(basePath, "form"),
879
+ schemaPath: deriveSchemaPath(basePath)
463
880
  };
464
881
  }
465
882
  /**
@@ -469,6 +886,7 @@ function deriveExportPaths(basePath) {
469
886
  * - Report format (.report.md) - filtered markdown (excludes instructions, report=false)
470
887
  * - YAML values (.yml) - structured format with state and notes
471
888
  * - Markform format (.form.md) - canonical form with directives
889
+ * - JSON Schema (.schema.json) - form structure for validation/tooling
472
890
  *
473
891
  * Note: Raw markdown (.raw.md) is available via CLI `markform export --raw`
474
892
  * but is not included in standard multi-format export.
@@ -491,6 +909,9 @@ async function exportMultiFormat(form, basePath) {
491
909
  await writeFile(paths.yamlPath, yamlContent);
492
910
  const formContent = serialize(form);
493
911
  await writeFile(paths.formPath, formContent);
912
+ const schemaResult = formToJsonSchema(form);
913
+ const schemaContent = JSON.stringify(schemaResult.schema, null, 2) + "\n";
914
+ await writeFile(paths.schemaPath, schemaContent);
494
915
  return paths;
495
916
  }
496
917
 
@@ -566,96 +987,6 @@ function registerDumpCommand(program) {
566
987
  });
567
988
  }
568
989
 
569
- //#endregion
570
- //#region src/cli/lib/patchFormat.ts
571
- /** Maximum characters for a patch value display before truncation */
572
- const PATCH_VALUE_MAX_LENGTH = 1e3;
573
- /**
574
- * Truncate a string to max length with ellipsis if needed.
575
- */
576
- function truncate(value, maxLength = PATCH_VALUE_MAX_LENGTH) {
577
- if (value.length <= maxLength) return value;
578
- return value.slice(0, maxLength) + "…";
579
- }
580
- /**
581
- * Format a patch value for display with truncation.
582
- */
583
- function formatPatchValue(patch) {
584
- switch (patch.op) {
585
- case "set_string": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
586
- case "set_number": return patch.value !== null ? String(patch.value) : "(empty)";
587
- case "set_string_list": return patch.items.length > 0 ? truncate(`[${patch.items.join(", ")}]`) : "(empty)";
588
- case "set_single_select": return patch.selected ?? "(none)";
589
- case "set_multi_select": return patch.selected.length > 0 ? truncate(`[${patch.selected.join(", ")}]`) : "(none)";
590
- case "set_checkboxes": return truncate(Object.entries(patch.values).map(([k, v]) => `${k}:${v}`).join(", "));
591
- case "clear_field": return "(cleared)";
592
- case "skip_field": return patch.reason ? truncate(`(skipped: ${patch.reason})`) : "(skipped)";
593
- case "abort_field": return patch.reason ? truncate(`(aborted: ${patch.reason})`) : "(aborted)";
594
- case "set_url": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
595
- case "set_url_list": return patch.items.length > 0 ? truncate(`[${patch.items.join(", ")}]`) : "(empty)";
596
- case "set_date": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
597
- case "set_year": return patch.value !== null ? String(patch.value) : "(empty)";
598
- case "set_table": return patch.rows.length > 0 ? truncate(`[${patch.rows.length} rows]`) : "(empty)";
599
- case "add_note": return truncate(`note: ${patch.text}`);
600
- case "remove_note": return `(remove note ${patch.noteId})`;
601
- }
602
- }
603
- /**
604
- * Get a short display name for the patch operation type.
605
- */
606
- function formatPatchType(patch) {
607
- switch (patch.op) {
608
- case "set_string": return "string";
609
- case "set_number": return "number";
610
- case "set_string_list": return "string_list";
611
- case "set_single_select": return "select";
612
- case "set_multi_select": return "multi_select";
613
- case "set_checkboxes": return "checkboxes";
614
- case "clear_field": return "clear";
615
- case "skip_field": return "skip";
616
- case "abort_field": return "abort";
617
- case "set_url": return "url";
618
- case "set_url_list": return "url_list";
619
- case "set_date": return "date";
620
- case "set_year": return "year";
621
- case "set_table": return "table";
622
- case "add_note": return "note";
623
- case "remove_note": return "remove_note";
624
- }
625
- }
626
-
627
- //#endregion
628
- //#region src/cli/lib/formatting.ts
629
- /**
630
- * Get a short status word from an issue reason.
631
- */
632
- function issueReasonToStatus(reason) {
633
- switch (reason) {
634
- case "required_missing": return "missing";
635
- case "validation_error": return "invalid";
636
- case "checkbox_incomplete": return "incomplete";
637
- case "min_items_not_met": return "too-few";
638
- case "optional_empty": return "empty";
639
- default: return "issue";
640
- }
641
- }
642
- /**
643
- * Format a single issue as "fieldId (status)".
644
- */
645
- function formatIssueBrief(issue) {
646
- const status = issueReasonToStatus(issue.reason);
647
- return `${issue.ref} (${status})`;
648
- }
649
- /**
650
- * Format issues for turn logging - shows count and brief field list.
651
- * Example: "5 issue(s): company_name (missing), revenue (invalid), ..."
652
- */
653
- function formatTurnIssues(issues, maxShow = 5) {
654
- const count = issues.length;
655
- if (count === 0) return "0 issues";
656
- return `${count} issue(s): ${issues.slice(0, maxShow).map(formatIssueBrief).join(", ")}${count > maxShow ? `, +${count - maxShow} more` : ""}`;
657
- }
658
-
659
990
  //#endregion
660
991
  //#region src/cli/examples/exampleRegistry.ts
661
992
  /**
@@ -670,18 +1001,18 @@ function formatTurnIssues(issues, maxShow = 5) {
670
1001
  * Title and description are loaded dynamically from frontmatter.
671
1002
  */
672
1003
  const EXAMPLE_DEFINITIONS = [
1004
+ {
1005
+ id: "movie-research-demo",
1006
+ filename: "movie-research-demo.form.md",
1007
+ path: "movie-research/movie-research-demo.form.md",
1008
+ type: "research"
1009
+ },
673
1010
  {
674
1011
  id: "simple",
675
1012
  filename: "simple.form.md",
676
1013
  path: "simple/simple.form.md",
677
1014
  type: "fill"
678
1015
  },
679
- {
680
- id: "movie-research-minimal",
681
- filename: "movie-research-minimal.form.md",
682
- path: "movie-research/movie-research-minimal.form.md",
683
- type: "research"
684
- },
685
1016
  {
686
1017
  id: "movie-research-basic",
687
1018
  filename: "movie-research-basic.form.md",
@@ -694,19 +1025,29 @@ const EXAMPLE_DEFINITIONS = [
694
1025
  path: "movie-research/movie-research-deep.form.md",
695
1026
  type: "research"
696
1027
  },
697
- {
698
- id: "earnings-analysis",
699
- filename: "earnings-analysis.form.md",
700
- path: "earnings-analysis/earnings-analysis.form.md",
701
- type: "research"
702
- },
703
1028
  {
704
1029
  id: "startup-deep-research",
705
1030
  filename: "startup-deep-research.form.md",
706
1031
  path: "startup-deep-research/startup-deep-research.form.md",
707
1032
  type: "research"
1033
+ },
1034
+ {
1035
+ id: "earnings-analysis",
1036
+ filename: "earnings-analysis.form.md",
1037
+ path: "earnings-analysis/earnings-analysis.form.md",
1038
+ type: "research"
708
1039
  }
709
1040
  ];
1041
+ /** Default example ID for menus (movie-research-demo, index 0) */
1042
+ const DEFAULT_EXAMPLE_ID = "movie-research-demo";
1043
+ /**
1044
+ * Get the canonical order index for an example by filename.
1045
+ * Returns -1 if not found (unknown files sort to the end).
1046
+ */
1047
+ function getExampleOrder(filename) {
1048
+ const index = EXAMPLE_DEFINITIONS.findIndex((e) => e.filename === filename);
1049
+ return index >= 0 ? index : EXAMPLE_DEFINITIONS.length;
1050
+ }
710
1051
  /**
711
1052
  * Get the path to the examples directory.
712
1053
  * Works both during development and when installed as a package.
@@ -792,6 +1133,160 @@ function getAllExamplesWithMetadata() {
792
1133
  });
793
1134
  }
794
1135
 
1136
+ //#endregion
1137
+ //#region src/cli/lib/formatting.ts
1138
+ /**
1139
+ * Color and output formatting utilities for CLI.
1140
+ */
1141
+ /**
1142
+ * Get a short status word from an issue reason.
1143
+ */
1144
+ function issueReasonToStatus(reason) {
1145
+ switch (reason) {
1146
+ case "required_missing": return "missing";
1147
+ case "validation_error": return "invalid";
1148
+ case "checkbox_incomplete": return "incomplete";
1149
+ case "min_items_not_met": return "too-few";
1150
+ case "optional_empty": return "empty";
1151
+ default: return "issue";
1152
+ }
1153
+ }
1154
+ /**
1155
+ * Format a single issue as "fieldId (status)".
1156
+ */
1157
+ function formatIssueBrief(issue) {
1158
+ const status = issueReasonToStatus(issue.reason);
1159
+ return `${issue.ref} (${status})`;
1160
+ }
1161
+ /**
1162
+ * Format issues for turn logging - shows count and brief field list.
1163
+ * Example: "5 issue(s): company_name (missing), revenue (invalid), ..."
1164
+ */
1165
+ function formatTurnIssues(issues, maxShow = 5) {
1166
+ const count = issues.length;
1167
+ if (count === 0) return "0 issues";
1168
+ return `${count} issue(s): ${issues.slice(0, maxShow).map(formatIssueBrief).join(", ")}${count > maxShow ? `, +${count - maxShow} more` : ""}`;
1169
+ }
1170
+ /**
1171
+ * Format form info for menu label display.
1172
+ * Format: "filename - Title [runMode]"
1173
+ * Example: "movie-research-deep.form.md - Movie Research (Deep) [research]"
1174
+ */
1175
+ function formatFormLabel(info) {
1176
+ const titlePart = info.title ? ` - ${info.title}` : "";
1177
+ const runModePart = info.runMode ? ` [${info.runMode}]` : "";
1178
+ return `${info.filename}${titlePart}${runModePart}`;
1179
+ }
1180
+ /**
1181
+ * Format form info for menu hint display.
1182
+ * Returns description without parentheses (prompts library adds them).
1183
+ */
1184
+ function formatFormHint(info) {
1185
+ return info.description ?? "";
1186
+ }
1187
+ /**
1188
+ * Format form info for log line (e.g., after copying).
1189
+ * Format: "filename - Title" (dimmed title)
1190
+ * Example: "✓ movie-research-deep.form.md - Movie Research (Deep)"
1191
+ */
1192
+ function formatFormLogLine(info, prefix) {
1193
+ const titlePart = info.title ? ` - ${info.title}` : "";
1194
+ return `${prefix} ${info.filename}${pc.dim(titlePart)}`;
1195
+ }
1196
+
1197
+ //#endregion
1198
+ //#region src/cli/lib/runMode.ts
1199
+ /**
1200
+ * Get the set of unique roles present in a form's fields.
1201
+ */
1202
+ function getFieldRoles(form) {
1203
+ const allFields = getAllFields(form);
1204
+ return new Set(allFields.map((field) => field.role));
1205
+ }
1206
+ /**
1207
+ * Validate that run_mode is consistent with form structure.
1208
+ *
1209
+ * Rules:
1210
+ * - interactive: Form MUST have at least one role="user" field
1211
+ * - fill: Form MUST have at least one role="agent" field
1212
+ * - research: Form MUST have at least one role="agent" field
1213
+ */
1214
+ function validateRunMode(form, runMode) {
1215
+ const roles = getFieldRoles(form);
1216
+ switch (runMode) {
1217
+ case "interactive":
1218
+ if (!roles.has(USER_ROLE)) return {
1219
+ valid: false,
1220
+ error: `run_mode="interactive" but form has no user-role fields. Available roles: ${[...roles].join(", ") || "(none)"}`
1221
+ };
1222
+ break;
1223
+ case "fill":
1224
+ case "research":
1225
+ if (!roles.has(AGENT_ROLE)) return {
1226
+ valid: false,
1227
+ error: `run_mode="${runMode}" but form has no agent-role fields. Available roles: ${[...roles].join(", ") || "(none)"}`
1228
+ };
1229
+ break;
1230
+ }
1231
+ return { valid: true };
1232
+ }
1233
+ /**
1234
+ * Determine the run mode for a form.
1235
+ *
1236
+ * 1. If explicit run_mode in frontmatter, validate and use it
1237
+ * 2. Otherwise, infer from field roles:
1238
+ * - All user fields → interactive
1239
+ * - All agent fields → fill (or research if isResearchForm)
1240
+ * - Mixed roles → error (require explicit run_mode)
1241
+ */
1242
+ function determineRunMode(form) {
1243
+ const explicitMode = form.metadata?.runMode;
1244
+ if (explicitMode) {
1245
+ const validation = validateRunMode(form, explicitMode);
1246
+ if (!validation.valid) return {
1247
+ success: false,
1248
+ error: validation.error
1249
+ };
1250
+ return {
1251
+ success: true,
1252
+ runMode: explicitMode,
1253
+ source: "explicit"
1254
+ };
1255
+ }
1256
+ const roles = getFieldRoles(form);
1257
+ if (roles.size === 0) return {
1258
+ success: false,
1259
+ error: "Form has no fields"
1260
+ };
1261
+ if (roles.size === 1 && roles.has(USER_ROLE)) return {
1262
+ success: true,
1263
+ runMode: "interactive",
1264
+ source: "inferred"
1265
+ };
1266
+ if (roles.size === 1 && roles.has(AGENT_ROLE)) {
1267
+ if (isResearchForm(form)) return {
1268
+ success: true,
1269
+ runMode: "research",
1270
+ source: "inferred"
1271
+ };
1272
+ return {
1273
+ success: true,
1274
+ runMode: "fill",
1275
+ source: "inferred"
1276
+ };
1277
+ }
1278
+ return {
1279
+ success: false,
1280
+ error: `Cannot determine run mode. Form has roles: ${[...roles].join(", ")}. Add 'run_mode' to frontmatter: interactive, fill, or research.`
1281
+ };
1282
+ }
1283
+ /**
1284
+ * Get a human-readable description of the run mode source.
1285
+ */
1286
+ function formatRunModeSource(source) {
1287
+ return source === "explicit" ? "from frontmatter" : "inferred from field roles";
1288
+ }
1289
+
795
1290
  //#endregion
796
1291
  //#region src/cli/lib/versioning.ts
797
1292
  /**
@@ -1432,277 +1927,216 @@ function showInteractiveOutro(patchCount, cancelled) {
1432
1927
  }
1433
1928
 
1434
1929
  //#endregion
1435
- //#region src/cli/lib/fileViewer.ts
1436
- /**
1437
- * File viewer utility for displaying files with colorization and pagination.
1438
- *
1439
- * Provides a modern console experience:
1440
- * - Syntax highlighting for markdown and YAML
1441
- * - Pagination using system pager (less) when available
1442
- * - Fallback to console output when not interactive
1443
- */
1930
+ //#region src/cli/lib/patchFormat.ts
1931
+ /** Maximum characters for a patch value display before truncation */
1932
+ const PATCH_VALUE_MAX_LENGTH = 1e3;
1444
1933
  /**
1445
- * Check if stdout is an interactive terminal.
1934
+ * Truncate a string to max length with ellipsis if needed.
1446
1935
  */
1447
- function isInteractive$2() {
1448
- return process.stdout.isTTY === true;
1936
+ function truncate(value, maxLength = PATCH_VALUE_MAX_LENGTH) {
1937
+ if (value.length <= maxLength) return value;
1938
+ return value.slice(0, maxLength) + "…";
1449
1939
  }
1450
1940
  /**
1451
- * Apply terminal formatting to markdown content.
1452
- * Colorizes headers, code blocks, and other elements.
1941
+ * Format a patch value for display with truncation.
1453
1942
  */
1454
- function formatMarkdown$2(content) {
1455
- const lines = content.split("\n");
1456
- const formatted = [];
1457
- let inCodeBlock = false;
1458
- for (const line of lines) {
1459
- if (line.startsWith("```")) {
1460
- inCodeBlock = !inCodeBlock;
1461
- formatted.push(pc.dim(line));
1462
- continue;
1463
- }
1464
- if (inCodeBlock) {
1465
- formatted.push(pc.cyan(line));
1466
- continue;
1467
- }
1468
- if (line.startsWith("# ")) {
1469
- formatted.push(pc.bold(pc.magenta(line)));
1470
- continue;
1471
- }
1472
- if (line.startsWith("## ")) {
1473
- formatted.push(pc.bold(pc.blue(line)));
1474
- continue;
1475
- }
1476
- if (line.startsWith("### ")) {
1477
- formatted.push(pc.bold(pc.cyan(line)));
1478
- continue;
1479
- }
1480
- if (line.startsWith("#### ")) {
1481
- formatted.push(pc.bold(line));
1482
- continue;
1483
- }
1484
- let formattedLine = line.replace(/`([^`]+)`/g, (_match, code) => {
1485
- return pc.yellow(code);
1486
- });
1487
- formattedLine = formattedLine.replace(/\*\*([^*]+)\*\*/g, (_match, text) => {
1488
- return pc.bold(text);
1489
- });
1490
- formattedLine = formattedLine.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
1491
- return `${pc.cyan(text)} ${pc.dim(`(${url})`)}`;
1492
- });
1493
- formattedLine = formattedLine.replace(/\{%\s*(\w+)\s*([^%]*)\s*%\}/g, (_match, tag, attrs) => {
1494
- return `${pc.dim("{% ")}${pc.green(tag)}${pc.dim(attrs)} ${pc.dim("%}")}`;
1495
- });
1496
- formatted.push(formattedLine);
1943
+ function formatPatchValue(patch) {
1944
+ switch (patch.op) {
1945
+ case "set_string": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
1946
+ case "set_number": return patch.value !== null ? String(patch.value) : "(empty)";
1947
+ case "set_string_list": return patch.items.length > 0 ? truncate(`[${patch.items.join(", ")}]`) : "(empty)";
1948
+ case "set_single_select": return patch.selected ?? "(none)";
1949
+ case "set_multi_select": return patch.selected.length > 0 ? truncate(`[${patch.selected.join(", ")}]`) : "(none)";
1950
+ case "set_checkboxes": return truncate(Object.entries(patch.values).map(([k, v]) => `${k}:${v}`).join(", "));
1951
+ case "clear_field": return "(cleared)";
1952
+ case "skip_field": return patch.reason ? truncate(`(skipped: ${patch.reason})`) : "(skipped)";
1953
+ case "abort_field": return patch.reason ? truncate(`(aborted: ${patch.reason})`) : "(aborted)";
1954
+ case "set_url": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
1955
+ case "set_url_list": return patch.items.length > 0 ? truncate(`[${patch.items.join(", ")}]`) : "(empty)";
1956
+ case "set_date": return patch.value ? truncate(`"${patch.value}"`) : "(empty)";
1957
+ case "set_year": return patch.value !== null ? String(patch.value) : "(empty)";
1958
+ case "set_table": return patch.rows.length > 0 ? truncate(`[${patch.rows.length} rows]`) : "(empty)";
1959
+ case "add_note": return truncate(`note: ${patch.text}`);
1960
+ case "remove_note": return `(remove note ${patch.noteId})`;
1497
1961
  }
1498
- return formatted.join("\n");
1499
1962
  }
1500
1963
  /**
1501
- * Apply terminal formatting to YAML content.
1964
+ * Get a short display name for the patch operation type.
1502
1965
  */
1503
- function formatYaml(content) {
1504
- const lines = content.split("\n");
1505
- const formatted = [];
1506
- for (const line of lines) {
1507
- if (line.trim().startsWith("#")) {
1508
- formatted.push(pc.dim(line));
1509
- continue;
1510
- }
1511
- const match = /^(\s*)([^:]+)(:)(.*)$/.exec(line);
1512
- if (match) {
1513
- const [, indent, key, colon, value] = match;
1514
- formatted.push(`${indent}${pc.cyan(key)}${pc.dim(colon)}${pc.yellow(value)}`);
1515
- continue;
1516
- }
1517
- if (line.trim().startsWith("-")) {
1518
- formatted.push(pc.green(line));
1519
- continue;
1520
- }
1521
- formatted.push(line);
1966
+ function formatPatchType(patch) {
1967
+ switch (patch.op) {
1968
+ case "set_string": return "string";
1969
+ case "set_number": return "number";
1970
+ case "set_string_list": return "string_list";
1971
+ case "set_single_select": return "select";
1972
+ case "set_multi_select": return "multi_select";
1973
+ case "set_checkboxes": return "checkboxes";
1974
+ case "clear_field": return "clear";
1975
+ case "skip_field": return "skip";
1976
+ case "abort_field": return "abort";
1977
+ case "set_url": return "url";
1978
+ case "set_url_list": return "url_list";
1979
+ case "set_date": return "date";
1980
+ case "set_year": return "year";
1981
+ case "set_table": return "table";
1982
+ case "add_note": return "note";
1983
+ case "remove_note": return "remove_note";
1522
1984
  }
1523
- return formatted.join("\n");
1524
- }
1525
- /**
1526
- * Format file content based on extension.
1527
- */
1528
- function formatContent(content, filename) {
1529
- if (filename.endsWith(".yml") || filename.endsWith(".yaml")) return formatYaml(content);
1530
- if (filename.endsWith(".md")) return formatMarkdown$2(content);
1531
- return content;
1532
1985
  }
1986
+
1987
+ //#endregion
1988
+ //#region src/cli/lib/fillLogging.ts
1533
1989
  /**
1534
- * Display content using system pager (less) if available.
1535
- * Falls back to console.log if not interactive or pager unavailable.
1990
+ * Fill Logging Callbacks - Create FillCallbacks for unified CLI logging.
1536
1991
  *
1537
- * @returns Promise that resolves when viewing is complete
1538
- */
1539
- async function displayWithPager(content, title) {
1540
- if (!isInteractive$2()) {
1541
- console.log(content);
1542
- return;
1543
- }
1544
- const header = `${pc.bgCyan(pc.black(` ${title} `))}`;
1545
- return new Promise((resolve$1) => {
1546
- const pager = spawn("less", [
1547
- "-R",
1548
- "-S",
1549
- "-X",
1550
- "-F",
1551
- "-K"
1552
- ], { stdio: [
1553
- "pipe",
1554
- "inherit",
1555
- "inherit"
1556
- ] });
1557
- pager.on("error", () => {
1558
- console.log(header);
1559
- console.log("");
1560
- console.log(content);
1561
- console.log("");
1562
- resolve$1();
1563
- });
1564
- pager.on("close", () => {
1565
- resolve$1();
1566
- });
1567
- pager.stdin.write(header + "\n\n");
1568
- pager.stdin.write(content);
1569
- pager.stdin.end();
1570
- });
1571
- }
1572
- /**
1573
- * Load and display a file with formatting and pagination.
1992
+ * Provides consistent turn-by-turn logging across all CLI commands that
1993
+ * run form-filling (fill, run, examples). API consumers can also use
1994
+ * these callbacks or implement their own.
1995
+ *
1996
+ * Default output (always shown unless --quiet):
1997
+ * - Turn numbers with issues list (field IDs + issue types)
1998
+ * - Patches per turn (field ID + value)
1999
+ * - Completion status
2000
+ *
2001
+ * Verbose output (--verbose flag):
2002
+ * - Token counts per turn
2003
+ * - Tool call start/end with timing
2004
+ * - Detailed stats and LLM metadata
1574
2005
  */
1575
- async function viewFile(filePath) {
1576
- const content = readFileSync(filePath, "utf-8");
1577
- const filename = basename(filePath);
1578
- await displayWithPager(formatContent(content, filename), filename);
1579
- }
1580
2006
  /**
1581
- * Show an interactive file viewer chooser.
2007
+ * Create FillCallbacks that produce standard CLI logging output.
1582
2008
  *
1583
- * Presents a list of files to view:
1584
- * - "Show report:" for the report output (.report.md) at the top
1585
- * - "Show source:" for other files (.form.md, .raw.md, .yml)
1586
- * - "Quit" at the bottom
2009
+ * Default output (always shown unless --quiet):
2010
+ * - Turn numbers with issues list (field IDs + issue types)
2011
+ * - Patches per turn (field ID + value)
2012
+ * - Completion status
1587
2013
  *
1588
- * Loops until the user selects Quit.
2014
+ * Verbose output (--verbose flag):
2015
+ * - Token counts per turn
2016
+ * - Tool call start/end with timing
2017
+ * - Detailed stats and LLM metadata
1589
2018
  *
1590
- * @param files Array of file options to display
2019
+ * This is used by fill, run, and examples commands for consistent output.
2020
+ *
2021
+ * @param ctx - Command context for verbose/quiet flags
2022
+ * @param options - Optional spinner for tool progress
2023
+ * @returns FillCallbacks with all logging implemented
2024
+ *
2025
+ * @example
2026
+ * ```typescript
2027
+ * const callbacks = createFillLoggingCallbacks(ctx, { spinner });
2028
+ * const result = await fillForm({
2029
+ * form: formMarkdown,
2030
+ * model: 'anthropic/claude-sonnet-4-5',
2031
+ * enableWebSearch: true,
2032
+ * callbacks,
2033
+ * });
2034
+ * ```
1591
2035
  */
1592
- async function showFileViewerChooser(files) {
1593
- if (!isInteractive$2()) return;
1594
- console.log("");
1595
- const reportFile = files.find((f) => f.path.endsWith(".report.md"));
1596
- const sourceFiles = files.filter((f) => !f.path.endsWith(".report.md"));
1597
- while (true) {
1598
- const options = [];
1599
- if (reportFile) options.push({
1600
- value: reportFile.path,
1601
- label: `Show report: ${pc.green(basename(reportFile.path))}`,
1602
- hint: reportFile.hint ?? ""
1603
- });
1604
- for (const file of sourceFiles) options.push({
1605
- value: file.path,
1606
- label: `Show source: ${pc.green(basename(file.path))}`,
1607
- hint: file.hint ?? ""
1608
- });
1609
- options.push({
1610
- value: "quit",
1611
- label: "Quit",
1612
- hint: ""
1613
- });
1614
- const selection = await p.select({
1615
- message: "View files:",
1616
- options
1617
- });
1618
- if (p.isCancel(selection) || selection === "quit") break;
1619
- await viewFile(selection);
1620
- console.log("");
1621
- }
2036
+ function createFillLoggingCallbacks(ctx, options = {}) {
2037
+ return {
2038
+ onIssuesIdentified: ({ turnNumber, issues }) => {
2039
+ logInfo(ctx, `${pc.bold(`Turn ${turnNumber}:`)} ${formatTurnIssues(issues)}`);
2040
+ },
2041
+ onPatchesGenerated: ({ patches, stats }) => {
2042
+ logInfo(ctx, ` -> ${pc.yellow(String(patches.length))} patch(es):`);
2043
+ for (const patch of patches) {
2044
+ const typeName = formatPatchType(patch);
2045
+ const value = formatPatchValue(patch);
2046
+ const fieldId = "fieldId" in patch ? patch.fieldId : patch.op === "add_note" ? patch.ref : "";
2047
+ if (fieldId) logInfo(ctx, ` ${pc.cyan(fieldId)} ${pc.dim(`(${typeName})`)} = ${pc.green(value)}`);
2048
+ else logInfo(ctx, ` ${pc.dim(`(${typeName})`)} = ${pc.green(value)}`);
2049
+ }
2050
+ if (stats && ctx.verbose) {
2051
+ logVerbose(ctx, ` Tokens: in=${stats.inputTokens ?? 0} out=${stats.outputTokens ?? 0}`);
2052
+ if (stats.toolCalls && stats.toolCalls.length > 0) logVerbose(ctx, ` Tools: ${stats.toolCalls.map((t) => `${t.name}(${t.count})`).join(", ")}`);
2053
+ }
2054
+ },
2055
+ onTurnComplete: ({ isComplete }) => {
2056
+ if (isComplete) logInfo(ctx, pc.green(` ✓ Complete`));
2057
+ },
2058
+ onToolStart: ({ name }) => {
2059
+ if (name.includes("search")) options.spinner?.message(`Web search...`);
2060
+ logVerbose(ctx, ` Tool started: ${name}`);
2061
+ },
2062
+ onToolEnd: ({ name, durationMs, error }) => {
2063
+ if (error) logVerbose(ctx, ` Tool ${name} failed: ${error} (${durationMs}ms)`);
2064
+ else logVerbose(ctx, ` Tool ${name} completed (${durationMs}ms)`);
2065
+ },
2066
+ onLlmCallStart: ({ model }) => {
2067
+ logVerbose(ctx, ` LLM call: ${model}`);
2068
+ },
2069
+ onLlmCallEnd: ({ model, inputTokens, outputTokens }) => {
2070
+ logVerbose(ctx, ` LLM response: ${model} (in=${inputTokens} out=${outputTokens})`);
2071
+ }
2072
+ };
1622
2073
  }
1623
2074
 
1624
2075
  //#endregion
1625
- //#region src/cli/commands/examples.ts
2076
+ //#region src/cli/commands/run.ts
1626
2077
  /**
1627
- * Print non-interactive list of examples.
1628
- */
1629
- function printExamplesList() {
1630
- console.log(pc.bold("Available examples:\n"));
1631
- const examples = getAllExamplesWithMetadata();
1632
- for (const example of examples) {
1633
- const typeLabel = example.type === "research" ? pc.magenta("[research]") : pc.blue("[fill]");
1634
- console.log(` ${pc.cyan(example.id)} ${typeLabel}`);
1635
- console.log(` ${pc.bold(example.title ?? example.id)}`);
1636
- console.log(` ${example.description ?? "No description"}`);
1637
- console.log(` Source: ${formatPath(getExamplePath(example.id))}`);
1638
- console.log("");
1639
- }
1640
- }
1641
- /**
1642
- * Display API availability status at startup.
2078
+ * Run command - Interactive launcher for running forms.
2079
+ *
2080
+ * Provides a menu-based interface for selecting and running forms
2081
+ * from the forms directory. Automatically detects run mode based
2082
+ * on frontmatter or field roles.
2083
+ *
2084
+ * Usage:
2085
+ * markform run # Browse forms, select, run
2086
+ * markform run movie.form.md # Run specific form directly
2087
+ * markform run --limit=50 # Override menu limit
1643
2088
  */
1644
- function showApiStatus() {
1645
- console.log("API Status:");
1646
- for (const [provider, _models] of Object.entries(SUGGESTED_LLMS)) {
1647
- const info = getProviderInfo(provider);
1648
- const hasKey = !!process.env[info.envVar];
1649
- const status = hasKey ? pc.green("✓") : "○";
1650
- const envVar = hasKey ? info.envVar : pc.yellow(info.envVar);
1651
- console.log(` ${status} ${provider} (${envVar})`);
1652
- }
1653
- console.log("");
1654
- }
1655
2089
  /**
1656
- * Build model options for the select prompt.
2090
+ * Scan forms directory for .form.md files.
1657
2091
  */
1658
- function buildModelOptions() {
1659
- const options = [];
1660
- for (const [provider, models] of Object.entries(SUGGESTED_LLMS)) {
1661
- const info = getProviderInfo(provider);
1662
- const keyStatus = !!process.env[info.envVar] ? pc.green("✓") : "○";
1663
- for (const model of models) options.push({
1664
- value: `${provider}/${model}`,
1665
- label: `${provider}/${model}`,
1666
- hint: `${keyStatus} ${info.envVar}`
1667
- });
1668
- }
1669
- options.push({
1670
- value: "custom",
1671
- label: "Enter custom model ID...",
1672
- hint: "provider/model-id format"
2092
+ function scanFormsDirectory(formsDir) {
2093
+ const entries = [];
2094
+ try {
2095
+ const files = readdirSync(formsDir);
2096
+ for (const file of files) {
2097
+ if (!file.endsWith(".form.md")) continue;
2098
+ const fullPath = join(formsDir, file);
2099
+ try {
2100
+ const stat = statSync(fullPath);
2101
+ if (stat.isFile()) entries.push({
2102
+ path: fullPath,
2103
+ filename: file,
2104
+ mtime: stat.mtime
2105
+ });
2106
+ } catch {}
2107
+ }
2108
+ } catch {}
2109
+ entries.sort((a, b) => {
2110
+ const orderDiff = getExampleOrder(a.filename) - getExampleOrder(b.filename);
2111
+ if (orderDiff !== 0) return orderDiff;
2112
+ return a.filename.localeCompare(b.filename);
1673
2113
  });
1674
- return options;
2114
+ return entries;
1675
2115
  }
1676
2116
  /**
1677
- * Prompt user to select a model for agent fill.
1678
- */
1679
- async function promptForModel() {
1680
- const modelOptions = buildModelOptions();
1681
- const selection = await p.select({
1682
- message: "Select LLM model:",
1683
- options: modelOptions
1684
- });
1685
- if (p.isCancel(selection)) return null;
1686
- if (selection === "custom") {
1687
- const customModel = await p.text({
1688
- message: "Model ID (provider/model-id):",
1689
- placeholder: "anthropic/claude-sonnet-4-20250514",
1690
- validate: (value) => {
1691
- if (!value.includes("/")) return "Format: provider/model-id (e.g., anthropic/claude-sonnet-4-20250514)";
1692
- }
1693
- });
1694
- if (p.isCancel(customModel)) return null;
1695
- return customModel;
2117
+ * Load form metadata for menu display.
2118
+ */
2119
+ async function enrichFormEntry(entry) {
2120
+ try {
2121
+ const form = parseForm(await readFile$1(entry.path));
2122
+ const runModeResult = determineRunMode(form);
2123
+ return {
2124
+ ...entry,
2125
+ title: form.schema.title,
2126
+ description: form.schema.description,
2127
+ runMode: runModeResult.success ? runModeResult.runMode : void 0
2128
+ };
2129
+ } catch {
2130
+ return entry;
1696
2131
  }
1697
- return selection;
1698
2132
  }
1699
2133
  /**
1700
- * Build model options filtered to providers with web search support.
2134
+ * Build model options for the select prompt.
1701
2135
  */
1702
- function buildWebSearchModelOptions() {
2136
+ function buildModelOptions(webSearchOnly) {
1703
2137
  const options = [];
1704
2138
  for (const [provider, models] of Object.entries(SUGGESTED_LLMS)) {
1705
- if (!hasWebSearchSupport(provider)) continue;
2139
+ if (webSearchOnly && !hasWebSearchSupport(provider)) continue;
1706
2140
  const info = getProviderInfo(provider);
1707
2141
  const keyStatus = !!process.env[info.envVar] ? pc.green("✓") : "○";
1708
2142
  for (const model of models) options.push({
@@ -1719,22 +2153,23 @@ function buildWebSearchModelOptions() {
1719
2153
  return options;
1720
2154
  }
1721
2155
  /**
1722
- * Prompt user to select a model with web search capability for research workflow.
2156
+ * Prompt user to select a model.
1723
2157
  */
1724
- async function promptForWebSearchModel() {
1725
- const modelOptions = buildWebSearchModelOptions();
1726
- if (modelOptions.length === 1) p.log.warn("No web-search-capable providers found. OpenAI, Google, or xAI API key required.");
2158
+ async function promptForModel(webSearchRequired) {
2159
+ const modelOptions = buildModelOptions(webSearchRequired);
2160
+ if (webSearchRequired && modelOptions.length === 1) p.log.warn("No web-search-capable providers found. OpenAI, Google, or xAI API key required.");
2161
+ const message = webSearchRequired ? "Select LLM model (web search required):" : "Select LLM model:";
1727
2162
  const selection = await p.select({
1728
- message: "Select LLM model (web search required):",
2163
+ message,
1729
2164
  options: modelOptions
1730
2165
  });
1731
2166
  if (p.isCancel(selection)) return null;
1732
2167
  if (selection === "custom") {
1733
2168
  const customModel = await p.text({
1734
2169
  message: "Model ID (provider/model-id):",
1735
- placeholder: "openai/gpt-5-mini",
2170
+ placeholder: "anthropic/claude-sonnet-4-20250514",
1736
2171
  validate: (value) => {
1737
- if (!value.includes("/")) return "Format: provider/model-id (e.g., openai/gpt-5-mini)";
2172
+ if (!value.includes("/")) return "Format: provider/model-id (e.g., anthropic/claude-sonnet-4-20250514)";
1738
2173
  }
1739
2174
  });
1740
2175
  if (p.isCancel(customModel)) return null;
@@ -1743,305 +2178,396 @@ async function promptForWebSearchModel() {
1743
2178
  return selection;
1744
2179
  }
1745
2180
  /**
1746
- * Run the agent fill workflow.
1747
- * Accepts optional harness config overrides - research uses different defaults.
2181
+ * Collect user input interactively (without exporting).
2182
+ * Returns true if successful, false if cancelled.
1748
2183
  */
1749
- async function runAgentFill(form, modelId, _outputPath, configOverrides) {
1750
- const { provider: providerName, model: modelName } = parseModelIdForDisplay(modelId);
1751
- const resolveSpinner = createSpinner({
1752
- type: "compute",
1753
- operation: `Resolving model: ${modelId}`
1754
- });
1755
- let model, provider;
1756
- try {
1757
- const result = await resolveModel(modelId);
1758
- model = result.model;
1759
- provider = result.provider;
1760
- resolveSpinner.stop(`✓ Model resolved: ${modelId}`);
1761
- } catch (error) {
1762
- resolveSpinner.error("Model resolution failed");
1763
- throw error;
1764
- }
1765
- const harnessConfig = {
1766
- maxTurns: configOverrides?.maxTurns ?? DEFAULT_MAX_TURNS,
1767
- maxPatchesPerTurn: configOverrides?.maxPatchesPerTurn ?? DEFAULT_MAX_PATCHES_PER_TURN,
1768
- maxIssuesPerTurn: configOverrides?.maxIssuesPerTurn ?? DEFAULT_MAX_ISSUES_PER_TURN,
1769
- targetRoles: [AGENT_ROLE],
1770
- fillMode: "continue"
1771
- };
1772
- console.log("");
1773
- console.log(`Config: max_turns=${harnessConfig.maxTurns}, max_issues_per_turn=${harnessConfig.maxIssuesPerTurn}, max_patches_per_turn=${harnessConfig.maxPatchesPerTurn}`);
1774
- const harness = createHarness(form, harnessConfig);
1775
- const agent = createLiveAgent({
1776
- model,
1777
- provider,
1778
- targetRole: AGENT_ROLE,
1779
- enableWebSearch: true
1780
- });
1781
- p.log.step(pc.bold("Agent fill in progress..."));
1782
- let stepResult = harness.step();
1783
- while (!stepResult.isComplete && !harness.hasReachedMaxTurns()) {
1784
- console.log(` ${pc.bold(`Turn ${stepResult.turnNumber}:`)} ${formatTurnIssues(stepResult.issues)}`);
1785
- const llmSpinner = createSpinner({
1786
- type: "api",
1787
- provider: providerName,
1788
- model: modelName,
1789
- turnNumber: stepResult.turnNumber
1790
- });
1791
- let response;
1792
- try {
1793
- response = await agent.generatePatches(stepResult.issues, harness.getForm(), harnessConfig.maxPatchesPerTurn);
1794
- llmSpinner.stop();
1795
- } catch (error) {
1796
- llmSpinner.error("LLM call failed");
1797
- throw error;
1798
- }
1799
- const { patches, stats } = response;
1800
- for (const patch of patches) {
1801
- const typeName = formatPatchType(patch);
1802
- const value = formatPatchValue(patch);
1803
- const fieldId = "fieldId" in patch ? patch.fieldId : patch.op === "add_note" ? patch.ref : "";
1804
- if (fieldId) console.log(` ${pc.cyan(fieldId)} (${typeName}) = ${pc.green(value)}`);
1805
- else console.log(` (${typeName}) = ${pc.green(value)}`);
1806
- }
1807
- stepResult = harness.apply(patches, stepResult.issues);
1808
- const tokenInfo = stats ? ` ${pc.dim(`(tokens: ↓${stats.inputTokens ?? 0} ↑${stats.outputTokens ?? 0})`)}` : "";
1809
- console.log(` ${patches.length} patch(es) applied, ${stepResult.issues.length} remaining${tokenInfo}`);
1810
- if (!stepResult.isComplete && !harness.hasReachedMaxTurns()) stepResult = harness.step();
2184
+ async function collectUserInput(form) {
2185
+ const targetRoles = [USER_ROLE];
2186
+ const inspectResult = inspect(form, { targetRoles });
2187
+ const fieldIssues = inspectResult.issues.filter((i) => i.scope === "field");
2188
+ const uniqueFieldIds = new Set(fieldIssues.map((i) => i.ref));
2189
+ if (uniqueFieldIds.size === 0) return true;
2190
+ showInteractiveIntro(form.schema.title ?? form.schema.id, targetRoles.join(", "), uniqueFieldIds.size);
2191
+ const { patches, cancelled } = await runInteractiveFill(form, inspectResult.issues);
2192
+ if (cancelled) {
2193
+ showInteractiveOutro(0, true);
2194
+ return false;
1811
2195
  }
1812
- if (stepResult.isComplete) p.log.success(pc.green(`Form completed in ${harness.getTurnNumber()} turn(s)`));
1813
- else p.log.warn(pc.yellow(`Max turns reached (${harnessConfig.maxTurns})`));
1814
- Object.assign(form, harness.getForm());
1815
- return {
1816
- success: stepResult.isComplete,
1817
- turnCount: harness.getTurnNumber()
1818
- };
2196
+ if (patches.length > 0) applyPatches(form, patches);
2197
+ showInteractiveOutro(patches.length, false);
2198
+ return true;
1819
2199
  }
1820
2200
  /**
1821
- * Run the interactive example scaffolding and filling flow.
1822
- *
1823
- * @param preselectedId Optional example ID to pre-select
1824
- * @param formsDirOverride Optional forms directory override from CLI option
2201
+ * Run interactive fill workflow.
2202
+ * @returns ExportResult with paths to output files, or undefined if cancelled/no fields
1825
2203
  */
1826
- async function runInteractiveFlow(preselectedId, formsDirOverride) {
2204
+ async function runInteractiveWorkflow(form, filePath, formsDir) {
2205
+ const startTime = Date.now();
2206
+ const targetRoles = [USER_ROLE];
2207
+ const inspectResult = inspect(form, { targetRoles });
2208
+ const fieldIssues = inspectResult.issues.filter((i) => i.scope === "field");
2209
+ const uniqueFieldIds = new Set(fieldIssues.map((i) => i.ref));
2210
+ if (uniqueFieldIds.size === 0) {
2211
+ p.log.info("No user-role fields to fill.");
2212
+ return;
2213
+ }
2214
+ showInteractiveIntro(form.schema.title ?? form.schema.id, targetRoles.join(", "), uniqueFieldIds.size);
2215
+ const { patches, cancelled } = await runInteractiveFill(form, inspectResult.issues);
2216
+ if (cancelled) {
2217
+ showInteractiveOutro(0, true);
2218
+ return;
2219
+ }
2220
+ if (patches.length > 0) applyPatches(form, patches);
2221
+ await ensureFormsDir(formsDir);
2222
+ const exportResult = await exportMultiFormat(form, generateVersionedPathInFormsDir(filePath, formsDir));
2223
+ showInteractiveOutro(patches.length, false);
2224
+ console.log("");
2225
+ p.log.success("Outputs:");
2226
+ console.log(` ${formatPath(exportResult.reportPath)} ${pc.dim("(output report)")}`);
2227
+ console.log(` ${formatPath(exportResult.yamlPath)} ${pc.dim("(output values)")}`);
2228
+ console.log(` ${formatPath(exportResult.formPath)} ${pc.dim("(filled markform source)")}`);
2229
+ console.log(` ${formatPath(exportResult.schemaPath)} ${pc.dim("(JSON Schema)")}`);
2230
+ logTiming({
2231
+ verbose: false,
2232
+ format: "console",
2233
+ dryRun: false,
2234
+ quiet: false,
2235
+ overwrite: false
2236
+ }, "Fill time", Date.now() - startTime);
2237
+ return exportResult;
2238
+ }
2239
+ /**
2240
+ * Run agent fill workflow using fillForm with logging callbacks.
2241
+ * @returns ExportResult with paths to output files
2242
+ */
2243
+ async function runAgentFillWorkflow(form, modelId, formsDir, filePath, isResearch, overwrite, ctx) {
1827
2244
  const startTime = Date.now();
1828
- p.intro(pc.bgCyan(pc.black(" markform examples ")));
1829
- const formsDir = getFormsDir(formsDirOverride);
2245
+ const maxTurns = DEFAULT_MAX_TURNS;
2246
+ const maxPatchesPerTurn = isResearch ? DEFAULT_RESEARCH_MAX_PATCHES_PER_TURN : DEFAULT_MAX_PATCHES_PER_TURN;
2247
+ const maxIssuesPerTurn = isResearch ? DEFAULT_RESEARCH_MAX_ISSUES_PER_TURN : DEFAULT_MAX_ISSUES_PER_TURN;
2248
+ logVerbose(ctx, `Config: max_turns=${maxTurns}, max_issues_per_turn=${maxIssuesPerTurn}, max_patches_per_turn=${maxPatchesPerTurn}`);
2249
+ const callbacks = createFillLoggingCallbacks(ctx);
2250
+ const workflowLabel = isResearch ? "Research" : "Agent fill";
2251
+ p.log.step(pc.bold(`${workflowLabel} in progress...`));
2252
+ const result = await fillForm({
2253
+ form,
2254
+ model: modelId,
2255
+ maxTurns,
2256
+ maxPatchesPerTurn,
2257
+ maxIssuesPerTurn,
2258
+ targetRoles: [AGENT_ROLE],
2259
+ fillMode: overwrite ? "overwrite" : "continue",
2260
+ enableWebSearch: isResearch,
2261
+ callbacks
2262
+ });
2263
+ if (result.status.ok) p.log.success(pc.green(`Form completed in ${result.turns} turn(s)`));
2264
+ else if (result.status.reason === "max_turns") p.log.warn(pc.yellow(`Max turns reached (${maxTurns})`));
2265
+ else throw new Error(result.status.message ?? `Fill failed: ${result.status.reason}`);
1830
2266
  await ensureFormsDir(formsDir);
1831
- showApiStatus();
1832
- let selectedId = preselectedId;
1833
- if (!selectedId) {
1834
- const examples = getAllExamplesWithMetadata();
1835
- const selection = await p.select({
1836
- message: "Select an example form to scaffold:",
1837
- options: examples.map((example$1) => ({
1838
- value: example$1.id,
1839
- label: example$1.title ?? example$1.id,
1840
- hint: example$1.description
1841
- }))
1842
- });
1843
- if (p.isCancel(selection)) {
1844
- p.cancel("Cancelled.");
1845
- process.exit(0);
2267
+ const outputPath = generateVersionedPathInFormsDir(filePath, formsDir);
2268
+ const exportResult = await exportMultiFormat(result.form, outputPath);
2269
+ console.log("");
2270
+ p.log.success(`${workflowLabel} complete. Outputs:`);
2271
+ console.log(` ${formatPath(exportResult.reportPath)} ${pc.dim("(output report)")}`);
2272
+ console.log(` ${formatPath(exportResult.yamlPath)} ${pc.dim("(output values)")}`);
2273
+ console.log(` ${formatPath(exportResult.formPath)} ${pc.dim("(filled markform source)")}`);
2274
+ console.log(` ${formatPath(exportResult.schemaPath)} ${pc.dim("(JSON Schema)")}`);
2275
+ logTiming(ctx, isResearch ? "Research time" : "Fill time", Date.now() - startTime);
2276
+ return exportResult;
2277
+ }
2278
+ /**
2279
+ * Run a form directly (callable from other commands).
2280
+ * This executes the same workflow as `markform run <file>`.
2281
+ *
2282
+ * @param selectedPath - Path to the form file
2283
+ * @param formsDir - Directory for output files
2284
+ * @param overwrite - Whether to overwrite existing field values
2285
+ * @param ctx - Optional command context for logging (defaults to non-verbose/quiet)
2286
+ * @returns ExportResult with paths to output files, or undefined if cancelled/no output
2287
+ */
2288
+ async function runForm(selectedPath, formsDir, overwrite, ctx) {
2289
+ const effectiveCtx = ctx ?? {
2290
+ verbose: false,
2291
+ quiet: false,
2292
+ dryRun: false,
2293
+ format: "console",
2294
+ overwrite
2295
+ };
2296
+ const form = parseForm(await readFile$1(selectedPath));
2297
+ const runModeResult = determineRunMode(form);
2298
+ if (!runModeResult.success) throw new Error(runModeResult.error);
2299
+ const { runMode } = runModeResult;
2300
+ switch (runMode) {
2301
+ case "interactive": return runInteractiveWorkflow(form, selectedPath, formsDir);
2302
+ case "fill":
2303
+ case "research": {
2304
+ const isResearch = runMode === "research";
2305
+ if (!await collectUserInput(form)) {
2306
+ p.cancel("Cancelled.");
2307
+ return;
2308
+ }
2309
+ const modelId = await promptForModel(isResearch);
2310
+ if (!modelId) {
2311
+ p.cancel("Cancelled.");
2312
+ return;
2313
+ }
2314
+ return runAgentFillWorkflow(form, modelId, formsDir, selectedPath, isResearch, overwrite, effectiveCtx);
1846
2315
  }
1847
- selectedId = selection;
1848
2316
  }
1849
- const example = getExampleById(selectedId);
1850
- if (!example) {
1851
- p.cancel(`Unknown example: ${selectedId}`);
1852
- process.exit(1);
1853
- }
1854
- const defaultFilename = basename(generateVersionedPathInFormsDir(example.filename, formsDir));
1855
- const filenameResult = await p.text({
1856
- message: `Output filename (in ${formatPath(formsDir)}):`,
1857
- initialValue: defaultFilename,
1858
- validate: (value) => {
1859
- if (!value.trim()) return "Filename is required";
1860
- if (!value.endsWith(".form.md") && !value.endsWith(".md")) return "Filename should end with .form.md or .md";
2317
+ }
2318
+ /**
2319
+ * Register the run command.
2320
+ */
2321
+ function registerRunCommand(program) {
2322
+ program.command("run [file]").description("Browse and run forms from the forms directory").option("--limit <n>", `Maximum forms to show in menu (default: ${MAX_FORMS_IN_MENU})`, String(MAX_FORMS_IN_MENU)).action(async (file, options, cmd) => {
2323
+ const ctx = getCommandContext(cmd);
2324
+ try {
2325
+ const formsDir = getFormsDir(ctx.formsDir);
2326
+ const limit = options.limit ? parseInt(options.limit, 10) : MAX_FORMS_IN_MENU;
2327
+ let selectedPath;
2328
+ if (file) {
2329
+ selectedPath = file.startsWith("/") ? file : join(formsDir, file);
2330
+ if (!selectedPath.endsWith(".form.md") && !selectedPath.endsWith(".md")) selectedPath = `${selectedPath}.form.md`;
2331
+ } else {
2332
+ p.intro(pc.bgCyan(pc.black(" markform run ")));
2333
+ const entries = scanFormsDirectory(formsDir);
2334
+ if (entries.length === 0) {
2335
+ p.log.warn(`No forms found in ${formatPath(formsDir)}`);
2336
+ console.log("");
2337
+ console.log(`Run ${pc.cyan("'markform examples'")} to get started.`);
2338
+ p.outro("");
2339
+ return;
2340
+ }
2341
+ const entriesToShow = entries.slice(0, limit);
2342
+ const enrichedEntries = await Promise.all(entriesToShow.map(enrichFormEntry));
2343
+ const menuOptions = enrichedEntries.map((entry) => ({
2344
+ value: entry.path,
2345
+ label: formatFormLabel(entry),
2346
+ hint: formatFormHint(entry)
2347
+ }));
2348
+ const defaultExample = getExampleById(DEFAULT_EXAMPLE_ID);
2349
+ const initialValue = enrichedEntries.find((e) => e.filename === defaultExample?.filename)?.path;
2350
+ if (entries.length > limit) console.log(pc.dim(`Showing ${limit} of ${entries.length} forms`));
2351
+ const selection = await p.select({
2352
+ message: "Select a form to run:",
2353
+ options: menuOptions,
2354
+ initialValue
2355
+ });
2356
+ if (p.isCancel(selection)) {
2357
+ p.cancel("Cancelled.");
2358
+ process.exit(0);
2359
+ }
2360
+ selectedPath = selection;
2361
+ }
2362
+ logVerbose(ctx, `Reading form: ${selectedPath}`);
2363
+ const form = parseForm(await readFile$1(selectedPath));
2364
+ const runModeResult = determineRunMode(form);
2365
+ if (!runModeResult.success) {
2366
+ logError(runModeResult.error);
2367
+ process.exit(1);
2368
+ }
2369
+ const { runMode, source } = runModeResult;
2370
+ logInfo(ctx, `Run mode: ${runMode} (${formatRunModeSource(source)})`);
2371
+ switch (runMode) {
2372
+ case "interactive":
2373
+ await runInteractiveWorkflow(form, selectedPath, formsDir);
2374
+ break;
2375
+ case "fill":
2376
+ case "research": {
2377
+ const isResearch = runMode === "research";
2378
+ if (!await collectUserInput(form)) {
2379
+ p.cancel("Cancelled.");
2380
+ process.exit(0);
2381
+ }
2382
+ const modelId = await promptForModel(isResearch);
2383
+ if (!modelId) {
2384
+ p.cancel("Cancelled.");
2385
+ process.exit(0);
2386
+ }
2387
+ await runAgentFillWorkflow(form, modelId, formsDir, selectedPath, isResearch, ctx.overwrite, ctx);
2388
+ break;
2389
+ }
2390
+ }
2391
+ if (!file) p.outro("Happy form filling!");
2392
+ } catch (error) {
2393
+ logError(error instanceof Error ? error.message : String(error));
2394
+ process.exit(1);
1861
2395
  }
1862
2396
  });
1863
- if (p.isCancel(filenameResult)) {
1864
- p.cancel("Cancelled.");
1865
- process.exit(0);
2397
+ }
2398
+
2399
+ //#endregion
2400
+ //#region src/cli/commands/examples.ts
2401
+ /**
2402
+ * Examples command - Copy bundled example forms to the forms directory.
2403
+ *
2404
+ * This command provides a simple way to get started with markform by
2405
+ * copying bundled example forms to your local forms directory.
2406
+ *
2407
+ * Usage:
2408
+ * markform examples # Copy all bundled examples to ./forms/
2409
+ * markform examples --list # List bundled examples (no copy)
2410
+ * markform examples --name=foo # Copy specific example only
2411
+ */
2412
+ /**
2413
+ * Print non-interactive list of examples.
2414
+ */
2415
+ function printExamplesList() {
2416
+ console.log(pc.bold("Available examples:\n"));
2417
+ const examples = getAllExamplesWithMetadata();
2418
+ for (const example of examples) {
2419
+ const typeLabel = example.type === "research" ? pc.magenta("[research]") : pc.blue("[fill]");
2420
+ console.log(` ${pc.cyan(example.id)} ${typeLabel}`);
2421
+ console.log(` ${pc.bold(example.title ?? example.id)}`);
2422
+ console.log(` ${example.description ?? "No description"}`);
2423
+ console.log(` Source: ${formatPath(getExamplePath(example.id))}`);
2424
+ console.log("");
1866
2425
  }
1867
- const filename = filenameResult;
1868
- const outputPath = join(formsDir, filename);
2426
+ }
2427
+ /**
2428
+ * Copy an example form to the forms directory.
2429
+ *
2430
+ * @returns true if copied, false if skipped
2431
+ */
2432
+ async function copyExample(exampleId, formsDir, overwrite, _quiet) {
2433
+ const example = getExampleById(exampleId);
2434
+ if (!example) throw new Error(`Unknown example: ${exampleId}`);
2435
+ const outputPath = join(formsDir, example.filename);
1869
2436
  if (existsSync(outputPath)) {
1870
- const overwrite = await p.confirm({
1871
- message: `${filename} already exists. Overwrite?`,
1872
- initialValue: false
1873
- });
1874
- if (p.isCancel(overwrite) || !overwrite) {
1875
- p.cancel("Cancelled.");
1876
- process.exit(0);
1877
- }
2437
+ if (!overwrite) return {
2438
+ copied: false,
2439
+ skipped: true,
2440
+ path: outputPath
2441
+ };
1878
2442
  }
1879
- let content;
2443
+ await writeFile(outputPath, loadExampleContent(exampleId));
2444
+ return {
2445
+ copied: true,
2446
+ skipped: false,
2447
+ path: outputPath
2448
+ };
2449
+ }
2450
+ /**
2451
+ * Scan forms directory and enrich entries with metadata.
2452
+ */
2453
+ async function getFormEntries(formsDir) {
2454
+ const entries = [];
1880
2455
  try {
1881
- content = loadExampleContent(selectedId);
1882
- await writeFile(outputPath, content);
1883
- } catch (error) {
1884
- const message = error instanceof Error ? error.message : String(error);
1885
- p.cancel(`Failed to write file: ${message}`);
1886
- process.exit(1);
1887
- }
1888
- p.log.success(`Created ${formatPath(outputPath)}`);
1889
- const form = parseForm(content);
1890
- const targetRoles = [USER_ROLE];
1891
- let userFillOutputs = null;
1892
- const inspectResult = inspect(form, { targetRoles });
1893
- const fieldIssues = inspectResult.issues.filter((i) => i.scope === "field");
1894
- const uniqueFieldIds = new Set(fieldIssues.map((i) => i.ref));
1895
- if (uniqueFieldIds.size === 0) {
1896
- p.log.info("No user-role fields to fill in this example.");
1897
- if (inspect(form, { targetRoles: [AGENT_ROLE] }).issues.filter((i) => i.scope === "field").length === 0) {
1898
- logTiming({
1899
- verbose: false,
1900
- format: "console",
1901
- dryRun: false,
1902
- quiet: false
1903
- }, "Total time", Date.now() - startTime);
1904
- p.outro("Form scaffolded with no fields to fill.");
1905
- return;
1906
- }
1907
- } else {
1908
- showInteractiveIntro(form.schema.title ?? form.schema.id, targetRoles.join(", "), uniqueFieldIds.size);
1909
- const { patches, cancelled } = await runInteractiveFill(form, inspectResult.issues);
1910
- if (cancelled) {
1911
- showInteractiveOutro(0, true);
1912
- process.exit(1);
2456
+ const files = readdirSync(formsDir);
2457
+ for (const file of files) {
2458
+ if (!file.endsWith(".form.md")) continue;
2459
+ const fullPath = join(formsDir, file);
2460
+ try {
2461
+ if (statSync(fullPath).isFile()) {
2462
+ const form = parseForm(await readFile$1(fullPath));
2463
+ const runModeResult = determineRunMode(form);
2464
+ entries.push({
2465
+ path: fullPath,
2466
+ filename: file,
2467
+ title: form.schema.title,
2468
+ description: form.schema.description,
2469
+ runMode: runModeResult.success ? runModeResult.runMode : void 0
2470
+ });
2471
+ }
2472
+ } catch {}
1913
2473
  }
1914
- if (patches.length > 0) applyPatches(form, patches);
1915
- userFillOutputs = await exportMultiFormat(form, outputPath);
1916
- showInteractiveOutro(patches.length, false);
2474
+ } catch {}
2475
+ return entries;
2476
+ }
2477
+ /**
2478
+ * Copy all examples to the forms directory.
2479
+ * Returns { copied, skipped } counts for the caller to handle prompts.
2480
+ */
2481
+ async function copyAllExamples(formsDir, overwrite, quiet) {
2482
+ const examples = getAllExamplesWithMetadata();
2483
+ const total = examples.length;
2484
+ if (!quiet) {
2485
+ console.log(`Copying ${total} example forms to ${formatPath(formsDir)}...`);
1917
2486
  console.log("");
1918
- p.log.success("Outputs:");
1919
- console.log(` ${formatPath(userFillOutputs.reportPath)} ${pc.dim("(output report)")}`);
1920
- console.log(` ${formatPath(userFillOutputs.yamlPath)} ${pc.dim("(output values)")}`);
1921
- console.log(` ${formatPath(userFillOutputs.formPath)} ${pc.dim("(filled markform source)")}`);
1922
- logTiming({
1923
- verbose: false,
1924
- format: "console",
1925
- dryRun: false,
1926
- quiet: false
1927
- }, "Fill time", Date.now() - startTime);
1928
2487
  }
1929
- const agentFieldIssues = inspect(form, { targetRoles: [AGENT_ROLE] }).issues.filter((i) => i.scope === "field");
1930
- const isResearchExample = example.type === "research";
1931
- if (agentFieldIssues.length > 0) {
2488
+ let copied = 0;
2489
+ let skipped = 0;
2490
+ for (const example of examples) {
2491
+ const result = await copyExample(example.id, formsDir, overwrite, quiet);
2492
+ if (result.copied) {
2493
+ copied++;
2494
+ if (!quiet) console.log(formatFormLogLine(example, ` ${pc.green("✓")}`));
2495
+ } else if (result.skipped) {
2496
+ skipped++;
2497
+ if (!quiet) console.log(`${formatFormLogLine(example, ` ${pc.yellow("○")}`)} ${pc.dim("(exists, skipped)")}`);
2498
+ }
2499
+ }
2500
+ if (!quiet) {
1932
2501
  console.log("");
1933
- const workflowLabel = isResearchExample ? "research" : "agent fill";
1934
- p.log.info(`This form has ${agentFieldIssues.length} agent-role field(s) remaining.`);
1935
- const confirmMessage = isResearchExample ? "Run research now? (requires web search)" : "Run agent fill now?";
1936
- const runAgent = await p.confirm({
1937
- message: confirmMessage,
1938
- initialValue: true
1939
- });
1940
- if (p.isCancel(runAgent) || !runAgent) {
2502
+ if (skipped > 0) console.log(pc.yellow(`Skipped ${skipped} existing file(s). Use --overwrite to replace them.`));
2503
+ console.log(pc.green(`Done! Copied ${copied} example form(s) to ${formatPath(formsDir)}`));
2504
+ }
2505
+ return {
2506
+ copied,
2507
+ skipped
2508
+ };
2509
+ }
2510
+ /**
2511
+ * Show form selection menu and return the selected path.
2512
+ */
2513
+ async function showFormMenu(formsDir) {
2514
+ const entries = await getFormEntries(formsDir);
2515
+ if (entries.length === 0) return null;
2516
+ const sortedEntries = [...entries].sort((a, b) => {
2517
+ return getExampleOrder(a.filename) - getExampleOrder(b.filename);
2518
+ });
2519
+ const defaultExample = getExampleById(DEFAULT_EXAMPLE_ID);
2520
+ const defaultIndex = sortedEntries.findIndex((e) => e.filename === defaultExample?.filename);
2521
+ const menuOptions = sortedEntries.map((entry) => ({
2522
+ value: entry.path,
2523
+ label: formatFormLabel(entry),
2524
+ hint: formatFormHint(entry)
2525
+ }));
2526
+ const initialValue = (defaultIndex >= 0 ? sortedEntries[defaultIndex] : void 0)?.path;
2527
+ const selection = await p.select({
2528
+ message: "Select a form to run:",
2529
+ options: menuOptions,
2530
+ initialValue
2531
+ });
2532
+ if (p.isCancel(selection)) return null;
2533
+ return selection;
2534
+ }
2535
+ /**
2536
+ * Copy a specific example to the forms directory.
2537
+ */
2538
+ async function copySingleExample(exampleId, formsDir, overwrite, quiet) {
2539
+ const example = getExampleById(exampleId);
2540
+ if (!example) throw new Error(`Unknown example: ${exampleId}`);
2541
+ if (!quiet) console.log(`Copying ${example.filename} to ${formatPath(formsDir)}...`);
2542
+ const result = await copyExample(exampleId, formsDir, overwrite, quiet);
2543
+ if (result.copied) {
2544
+ if (!quiet) {
2545
+ console.log(formatFormLogLine(example, ` ${pc.green("✓")}`));
1941
2546
  console.log("");
1942
- const cliCommand = isResearchExample ? ` markform research ${formatPath(outputPath)} --model=<provider/model>` : ` markform fill ${formatPath(outputPath)} --model=<provider/model>`;
1943
- console.log(`You can run ${workflowLabel} later with:`);
1944
- console.log(cliCommand);
1945
- if (userFillOutputs) await showFileViewerChooser([
1946
- {
1947
- path: userFillOutputs.reportPath,
1948
- label: "Report",
1949
- hint: "output report"
1950
- },
1951
- {
1952
- path: userFillOutputs.yamlPath,
1953
- label: "Values",
1954
- hint: "output values"
1955
- },
1956
- {
1957
- path: userFillOutputs.formPath,
1958
- label: "Form",
1959
- hint: "filled markform source"
1960
- }
1961
- ]);
1962
- p.outro("Happy form filling!");
1963
- return;
1964
- }
1965
- const modelId = isResearchExample ? await promptForWebSearchModel() : await promptForModel();
1966
- if (!modelId) {
1967
- p.cancel("Cancelled.");
1968
- process.exit(0);
2547
+ console.log(pc.green("Done!"));
2548
+ console.log(`Run ${pc.cyan(`'markform run ${example.filename}'`)} to try it.`);
1969
2549
  }
1970
- const agentDefaultFilename = basename(generateVersionedPathInFormsDir(outputPath, formsDir));
1971
- const agentFilenameResult = await p.text({
1972
- message: `Agent output filename (in ${formatPath(formsDir)}):`,
1973
- initialValue: agentDefaultFilename,
1974
- validate: (value) => {
1975
- if (!value.trim()) return "Filename is required";
1976
- }
1977
- });
1978
- if (p.isCancel(agentFilenameResult)) {
1979
- p.cancel("Cancelled.");
1980
- process.exit(0);
1981
- }
1982
- const agentOutputPath = join(formsDir, agentFilenameResult);
1983
- const agentStartTime = Date.now();
1984
- const timingLabel = isResearchExample ? "Research time" : "Agent fill time";
1985
- const configOverrides = isResearchExample ? {
1986
- maxIssuesPerTurn: DEFAULT_RESEARCH_MAX_ISSUES_PER_TURN,
1987
- maxPatchesPerTurn: DEFAULT_RESEARCH_MAX_PATCHES_PER_TURN
1988
- } : void 0;
1989
- try {
1990
- const { success } = await runAgentFill(form, modelId, agentOutputPath, configOverrides);
1991
- logTiming({
1992
- verbose: false,
1993
- format: "console",
1994
- dryRun: false,
1995
- quiet: false
1996
- }, timingLabel, Date.now() - agentStartTime);
1997
- const { reportPath, yamlPath, formPath } = await exportMultiFormat(form, agentOutputPath);
1998
- console.log("");
1999
- const successMessage = isResearchExample ? "Research complete. Outputs:" : "Agent fill complete. Outputs:";
2000
- p.log.success(successMessage);
2001
- console.log(` ${formatPath(reportPath)} ${pc.dim("(output report)")}`);
2002
- console.log(` ${formatPath(yamlPath)} ${pc.dim("(output values)")}`);
2003
- console.log(` ${formatPath(formPath)} ${pc.dim("(filled markform source)")}`);
2004
- if (!success) p.log.warn("Agent did not complete all fields. You may need to run it again.");
2005
- await showFileViewerChooser([
2006
- {
2007
- path: reportPath,
2008
- label: "Report",
2009
- hint: "output report"
2010
- },
2011
- {
2012
- path: yamlPath,
2013
- label: "Values",
2014
- hint: "output values"
2015
- },
2016
- {
2017
- path: formPath,
2018
- label: "Form",
2019
- hint: "filled markform source"
2020
- }
2021
- ]);
2022
- } catch (error) {
2023
- const message = error instanceof Error ? error.message : String(error);
2024
- const failMessage = isResearchExample ? "Research failed" : "Agent fill failed";
2025
- p.log.error(`${failMessage}: ${message}`);
2550
+ } else if (result.skipped) {
2551
+ if (!quiet) {
2552
+ console.log(`${formatFormLogLine(example, ` ${pc.yellow("○")}`)} ${pc.dim("(exists, skipped)")}`);
2026
2553
  console.log("");
2027
- console.log("You can try again with:");
2028
- const retryCommand = isResearchExample ? ` markform research ${formatPath(outputPath)} --model=${modelId}` : ` markform fill ${formatPath(outputPath)} --model=${modelId}`;
2029
- console.log(retryCommand);
2554
+ console.log(pc.yellow(`File already exists. Use --overwrite to replace it.`));
2030
2555
  }
2031
2556
  }
2032
- p.outro("Happy form filling!");
2033
2557
  }
2034
2558
  /**
2035
2559
  * Register the examples command.
2036
2560
  */
2037
2561
  function registerExamplesCommand(program) {
2038
- program.command("examples").description("Try out some example forms interactively using the console").option("--list", "List available examples without interactive selection").option("--name <example>", "Select example by ID (still prompts for filename)").action(async (options, cmd) => {
2562
+ program.command("examples").description("Copy bundled example forms to the forms directory").option("--list", "List available examples without copying").option("--name <example>", "Copy specific example by ID").action(async (options, cmd) => {
2039
2563
  const ctx = getCommandContext(cmd);
2040
2564
  try {
2041
2565
  if (options.list) {
2042
2566
  printExamplesList();
2043
2567
  return;
2044
2568
  }
2569
+ const formsDir = getFormsDir(ctx.formsDir);
2570
+ await ensureFormsDir(formsDir);
2045
2571
  if (options.name) {
2046
2572
  if (!getExampleById(options.name)) {
2047
2573
  logError(`Unknown example: ${options.name}`);
@@ -2049,8 +2575,35 @@ function registerExamplesCommand(program) {
2049
2575
  for (const ex of EXAMPLE_DEFINITIONS) console.log(` ${ex.id}`);
2050
2576
  process.exit(1);
2051
2577
  }
2578
+ await copySingleExample(options.name, formsDir, ctx.overwrite, ctx.quiet);
2579
+ } else {
2580
+ const { copied, skipped } = await copyAllExamples(formsDir, ctx.overwrite, ctx.quiet);
2581
+ if (!ctx.quiet && (copied > 0 || skipped > 0)) {
2582
+ console.log("");
2583
+ const wantToRun = await p.confirm({
2584
+ message: "Do you want to try running a form?",
2585
+ initialValue: true
2586
+ });
2587
+ if (p.isCancel(wantToRun) || !wantToRun) {
2588
+ console.log("");
2589
+ console.log(`Run ${pc.cyan("'markform run'")} to select and run a form later.`);
2590
+ } else {
2591
+ const selectedPath = await showFormMenu(formsDir);
2592
+ if (selectedPath) {
2593
+ console.log("");
2594
+ const exportResult = await runForm(selectedPath, formsDir, ctx.overwrite);
2595
+ if (exportResult) {
2596
+ console.log("");
2597
+ const wantToBrowse = await p.confirm({
2598
+ message: "Would you like to view the output files?",
2599
+ initialValue: true
2600
+ });
2601
+ if (!p.isCancel(wantToBrowse) && wantToBrowse) await browseOutputFiles(exportResult.formPath.replace(/\.form\.md$/, ""));
2602
+ }
2603
+ }
2604
+ }
2605
+ }
2052
2606
  }
2053
- await runInteractiveFlow(options.name, ctx.formsDir);
2054
2607
  } catch (error) {
2055
2608
  logError(error instanceof Error ? error.message : String(error));
2056
2609
  process.exit(1);
@@ -2256,13 +2809,14 @@ function registerFillCommand(program) {
2256
2809
  logInfo(ctx, `[DRY RUN] Would write form to: ${outputPath$1}`);
2257
2810
  showInteractiveOutro(patches.length, false);
2258
2811
  } else {
2259
- const { reportPath, yamlPath, formPath } = await exportMultiFormat(form, outputPath$1);
2812
+ const { reportPath, yamlPath, formPath, schemaPath } = await exportMultiFormat(form, outputPath$1);
2260
2813
  showInteractiveOutro(patches.length, false);
2261
2814
  console.log("");
2262
2815
  p.log.success("Outputs:");
2263
2816
  console.log(` ${formatPath(reportPath)} ${pc.dim("(output report)")}`);
2264
2817
  console.log(` ${formatPath(yamlPath)} ${pc.dim("(output values)")}`);
2265
2818
  console.log(` ${formatPath(formPath)} ${pc.dim("(filled markform source)")}`);
2819
+ console.log(` ${formatPath(schemaPath)} ${pc.dim("(JSON Schema)")}`);
2266
2820
  }
2267
2821
  logTiming(ctx, "Fill time", durationMs$1);
2268
2822
  if (patches.length > 0) {
@@ -2543,7 +3097,7 @@ function formatFieldValue(value, useColors) {
2543
3097
  /**
2544
3098
  * Format inspect report for console output.
2545
3099
  */
2546
- function formatConsoleReport$1(report, useColors) {
3100
+ function formatConsoleReport$2(report, useColors) {
2547
3101
  const lines = [];
2548
3102
  const bold = useColors ? pc.bold : (s) => s;
2549
3103
  const dim = useColors ? pc.dim : (s) => s;
@@ -2656,7 +3210,7 @@ function registerInspectCommand(program) {
2656
3210
  severity: issue.severity,
2657
3211
  blockedBy: issue.blockedBy
2658
3212
  }))
2659
- }, (data, useColors) => formatConsoleReport$1(data, useColors));
3213
+ }, (data, useColors) => formatConsoleReport$2(data, useColors));
2660
3214
  console.log(output);
2661
3215
  } catch (error) {
2662
3216
  logError(error instanceof Error ? error.message : String(error));
@@ -4097,15 +4651,16 @@ function registerResearchCommand(program) {
4097
4651
  logInfo(ctx, `Status: ${(result.status === "completed" ? pc.green : result.status === "max_turns_reached" ? pc.yellow : pc.red)(result.status)}`);
4098
4652
  logInfo(ctx, `Turns: ${result.totalTurns}`);
4099
4653
  if (result.inputTokens || result.outputTokens) logVerbose(ctx, `Tokens: ${result.inputTokens ?? 0} in, ${result.outputTokens ?? 0} out`);
4100
- const { reportPath, yamlPath, formPath } = await exportMultiFormat(result.form, outputPath);
4654
+ const { reportPath, yamlPath, formPath, schemaPath } = await exportMultiFormat(result.form, outputPath);
4101
4655
  logSuccess(ctx, "Outputs:");
4102
4656
  console.log(` ${reportPath} ${pc.dim("(output report)")}`);
4103
4657
  console.log(` ${yamlPath} ${pc.dim("(output values)")}`);
4104
4658
  console.log(` ${formPath} ${pc.dim("(filled markform source)")}`);
4659
+ console.log(` ${schemaPath} ${pc.dim("(JSON Schema)")}`);
4105
4660
  if (options.transcript && result.transcript) {
4106
- const { serializeSession: serializeSession$1 } = await import("./session-B_stoXQn.mjs");
4661
+ const { serializeSession: serializeSession$1 } = await import("./session-DSTNiHza.mjs");
4107
4662
  const transcriptPath = outputPath.replace(/\.form\.md$/, ".session.yaml");
4108
- const { writeFile: writeFile$1 } = await import("./shared-u22MtBRo.mjs");
4663
+ const { writeFile: writeFile$1 } = await import("./shared-C9yW5FLZ.mjs");
4109
4664
  await writeFile$1(transcriptPath, serializeSession$1(result.transcript));
4110
4665
  logInfo(ctx, `Transcript: ${transcriptPath}`);
4111
4666
  }
@@ -4117,6 +4672,195 @@ function registerResearchCommand(program) {
4117
4672
  });
4118
4673
  }
4119
4674
 
4675
+ //#endregion
4676
+ //#region src/cli/commands/schema.ts
4677
+ const VALID_DRAFTS = [
4678
+ "2020-12",
4679
+ "2019-09",
4680
+ "draft-07"
4681
+ ];
4682
+ /**
4683
+ * Register the schema command.
4684
+ */
4685
+ function registerSchemaCommand(program) {
4686
+ program.command("schema <file>").description("Export form structure as JSON Schema").option("--pure", "Exclude x-markform extension properties").option("--draft <version>", `JSON Schema draft version: ${VALID_DRAFTS.join(", ")}`, "2020-12").option("--compact", "Output compact JSON (no formatting)").action(async (file, options, cmd) => {
4687
+ const ctx = getCommandContext(cmd);
4688
+ try {
4689
+ const draft = options.draft ?? "2020-12";
4690
+ if (!VALID_DRAFTS.includes(draft)) throw new Error(`Invalid draft version: ${options.draft}. Valid options: ${VALID_DRAFTS.join(", ")}`);
4691
+ logVerbose(ctx, `Reading file: ${file}`);
4692
+ const content = await readFile$1(file);
4693
+ logVerbose(ctx, "Parsing form...");
4694
+ const form = parseForm(content);
4695
+ logVerbose(ctx, "Generating JSON Schema...");
4696
+ const result = formToJsonSchema(form, {
4697
+ includeExtensions: !options.pure,
4698
+ draft
4699
+ });
4700
+ if (ctx.format === "yaml") console.log(YAML.stringify(result.schema));
4701
+ else if (options.compact) console.log(JSON.stringify(result.schema));
4702
+ else console.log(JSON.stringify(result.schema, null, 2));
4703
+ } catch (error) {
4704
+ logError(error instanceof Error ? error.message : String(error));
4705
+ process.exit(1);
4706
+ }
4707
+ });
4708
+ }
4709
+
4710
+ //#endregion
4711
+ //#region src/cli/commands/status.ts
4712
+ /**
4713
+ * Status command - Display form fill status with per-role breakdown.
4714
+ *
4715
+ * Provides:
4716
+ * - Overall progress counts
4717
+ * - Per-role fill statistics
4718
+ * - Run mode (explicit or inferred)
4719
+ * - Suggested next command
4720
+ */
4721
+ /**
4722
+ * Compute field statistics from a list of fields.
4723
+ */
4724
+ function computeFieldStats(form, fields) {
4725
+ let answered = 0;
4726
+ let skipped = 0;
4727
+ let aborted = 0;
4728
+ let unanswered = 0;
4729
+ for (const field of fields) switch (form.responsesByFieldId[field.id]?.state ?? "unanswered") {
4730
+ case "answered":
4731
+ answered++;
4732
+ break;
4733
+ case "skipped":
4734
+ skipped++;
4735
+ break;
4736
+ case "aborted":
4737
+ aborted++;
4738
+ break;
4739
+ case "unanswered":
4740
+ default:
4741
+ unanswered++;
4742
+ break;
4743
+ }
4744
+ return {
4745
+ total: fields.length,
4746
+ answered,
4747
+ skipped,
4748
+ aborted,
4749
+ unanswered
4750
+ };
4751
+ }
4752
+ /**
4753
+ * Compute statistics grouped by role.
4754
+ */
4755
+ function computeStatsByRole(form) {
4756
+ const allFields = getAllFields(form);
4757
+ const roles = getFieldRoles(form);
4758
+ const result = {};
4759
+ for (const role of roles) result[role] = computeFieldStats(form, allFields.filter((f) => f.role === role));
4760
+ return result;
4761
+ }
4762
+ /**
4763
+ * Format percentage with one decimal place.
4764
+ */
4765
+ function formatPercent(numerator, denominator) {
4766
+ if (denominator === 0) return "0%";
4767
+ return `${Math.round(numerator / denominator * 100)}%`;
4768
+ }
4769
+ /**
4770
+ * Format status report for console output.
4771
+ */
4772
+ function formatConsoleReport$1(report, useColors) {
4773
+ const lines = [];
4774
+ const bold = useColors ? pc.bold : (s) => s;
4775
+ const dim = useColors ? pc.dim : (s) => s;
4776
+ const cyan = useColors ? pc.cyan : (s) => s;
4777
+ const green = useColors ? pc.green : (s) => s;
4778
+ const yellow = useColors ? pc.yellow : (s) => s;
4779
+ const red = useColors ? pc.red : (s) => s;
4780
+ lines.push(bold(cyan(`Form Status: ${basename(report.path)}`)));
4781
+ lines.push("");
4782
+ const overall = report.overall;
4783
+ const overallPercent = formatPercent(overall.answered, overall.total);
4784
+ lines.push(`${bold("Overall:")} ${overall.answered}/${overall.total} fields filled (${overallPercent})`);
4785
+ lines.push(` ${green("✓")} Complete: ${overall.answered}`);
4786
+ lines.push(` ${dim("○")} Empty: ${overall.unanswered}`);
4787
+ if (overall.skipped > 0) lines.push(` ${yellow("⊘")} Skipped: ${overall.skipped}`);
4788
+ if (overall.aborted > 0) lines.push(` ${red("✗")} Aborted: ${overall.aborted}`);
4789
+ lines.push("");
4790
+ lines.push(bold("By Role:"));
4791
+ const roles = Object.keys(report.byRole).sort((a, b) => {
4792
+ if (a === USER_ROLE) return -1;
4793
+ if (b === USER_ROLE) return 1;
4794
+ if (a === AGENT_ROLE) return -1;
4795
+ if (b === AGENT_ROLE) return 1;
4796
+ return a.localeCompare(b);
4797
+ });
4798
+ for (const role of roles) {
4799
+ const stats = report.byRole[role];
4800
+ if (!stats) continue;
4801
+ const percent = formatPercent(stats.answered, stats.total);
4802
+ const needsAttention = role === USER_ROLE && stats.unanswered > 0 ? yellow(" ← needs attention") : "";
4803
+ lines.push(` ${role}: ${stats.answered}/${stats.total} filled (${percent})${needsAttention}`);
4804
+ }
4805
+ lines.push("");
4806
+ if (report.runMode) {
4807
+ const source = report.runModeSource === "explicit" ? "explicit" : "inferred";
4808
+ lines.push(`${bold("Run Mode:")} ${report.runMode} (${source})`);
4809
+ } else lines.push(`${bold("Run Mode:")} ${dim("unknown")}`);
4810
+ if (report.suggestedCommand) lines.push(`${bold("Suggested:")} ${cyan(report.suggestedCommand)}`);
4811
+ return lines.join("\n");
4812
+ }
4813
+ /**
4814
+ * Generate a suggested command based on status.
4815
+ */
4816
+ function getSuggestedCommand(report) {
4817
+ const { overall, byRole, runMode, path } = report;
4818
+ const filename = basename(path);
4819
+ if (overall.total > 0 && overall.answered === overall.total) return null;
4820
+ const userStats = byRole[USER_ROLE];
4821
+ if (userStats && userStats.unanswered > 0) return `markform fill ${filename} --interactive`;
4822
+ if (runMode === "research") return `markform research ${filename}`;
4823
+ return `markform run ${filename}`;
4824
+ }
4825
+ /**
4826
+ * Register the status command.
4827
+ */
4828
+ function registerStatusCommand(program) {
4829
+ program.command("status <file>").description("Display form fill status with per-role breakdown").action(async (file, _options, cmd) => {
4830
+ const ctx = getCommandContext(cmd);
4831
+ try {
4832
+ logVerbose(ctx, `Reading file: ${file}`);
4833
+ const content = await readFile$1(file);
4834
+ logVerbose(ctx, "Parsing form...");
4835
+ const form = parseForm(content);
4836
+ logVerbose(ctx, "Computing status...");
4837
+ const overall = computeFieldStats(form, getAllFields(form));
4838
+ const byRole = computeStatsByRole(form);
4839
+ const runModeResult = determineRunMode(form);
4840
+ let runMode = null;
4841
+ let runModeSource = "unknown";
4842
+ if (runModeResult.success) {
4843
+ runMode = runModeResult.runMode;
4844
+ runModeSource = runModeResult.source;
4845
+ }
4846
+ const report = {
4847
+ path: file,
4848
+ runMode,
4849
+ runModeSource,
4850
+ overall,
4851
+ byRole,
4852
+ suggestedCommand: null
4853
+ };
4854
+ report.suggestedCommand = getSuggestedCommand(report);
4855
+ const output = formatOutput(ctx, report, (data, useColors) => formatConsoleReport$1(data, useColors));
4856
+ console.log(output);
4857
+ } catch (error) {
4858
+ logError(error instanceof Error ? error.message : String(error));
4859
+ process.exit(1);
4860
+ }
4861
+ });
4862
+ }
4863
+
4120
4864
  //#endregion
4121
4865
  //#region src/cli/commands/validate.ts
4122
4866
  /**
@@ -4251,12 +4995,13 @@ function withColoredHelp(cmd) {
4251
4995
  */
4252
4996
  function createProgram() {
4253
4997
  const program = withColoredHelp(new Command());
4254
- program.name("markform").description("Agent-friendly, human-readable, editable forms").version(VERSION, "--version", "output the version number").showHelpAfterError().option("--verbose", "Enable verbose output").option("--quiet", "Suppress non-essential output").option("--dry-run", "Show what would be done without making changes").option("--format <format>", `Output format: ${OUTPUT_FORMATS.join(", ")}`, "console").option("--forms-dir <dir>", `Directory for form output (default: ${DEFAULT_FORMS_DIR})`);
4998
+ program.name("markform").description("Agent-friendly, human-readable, editable forms").version(CLI_VERSION, "--version", "output the version number").showHelpAfterError().option("--verbose", "Enable verbose output").option("--quiet", "Suppress non-essential output").option("--dry-run", "Show what would be done without making changes").option("--format <format>", `Output format: ${OUTPUT_FORMATS.join(", ")}`, "console").option("--forms-dir <dir>", `Directory for form output (default: ${DEFAULT_FORMS_DIR})`).option("--overwrite", "Overwrite existing field values (default: continue/skip filled)");
4255
4999
  registerReadmeCommand(program);
4256
5000
  registerDocsCommand(program);
4257
5001
  registerSpecCommand(program);
4258
5002
  registerApisCommand(program);
4259
5003
  registerApplyCommand(program);
5004
+ registerBrowseCommand(program);
4260
5005
  registerDumpCommand(program);
4261
5006
  registerExamplesCommand(program);
4262
5007
  registerExportCommand(program);
@@ -4266,7 +5011,10 @@ function createProgram() {
4266
5011
  registerRenderCommand(program);
4267
5012
  registerReportCommand(program);
4268
5013
  registerResearchCommand(program);
5014
+ registerRunCommand(program);
5015
+ registerSchemaCommand(program);
4269
5016
  registerServeCommand(program);
5017
+ registerStatusCommand(program);
4270
5018
  registerValidateCommand(program);
4271
5019
  return program;
4272
5020
  }