@montytools/cli 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/monty.mjs CHANGED
@@ -15,16 +15,15 @@ import { basename, dirname, join, relative, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { CATALOG, REGISTRIES } from "./catalog.mjs";
18
- import { CompileError, compileAppConfig } from "../lib/compile.mjs";
18
+ import { compileAppConfig } from "../lib/compile.mjs";
19
19
  import { manifestHash } from "../lib/schemaCodegen.mjs";
20
20
  import { readSchemaState, schemaPull, writeSchemaState } from "../lib/schemaPull.mjs";
21
- import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseViewSort, validateViewColumns } from "../lib/views.mjs";
21
+ import { mergeViewConfig, normalizeViewFilters, parseHiddenColumns, parseKanbanFlag, parseViewSort, validateViewColumns } from "../lib/views.mjs";
22
22
 
23
23
  // MONTY_HOME overrides the state root (default ~/.monty): config.json,
24
24
  // apps/, and desktop.json all live under it. This is how a second, isolated
25
25
  // Monty state coexists on one machine — the desktop's dev channel points it
26
- // at ~/.monty-dev so platform development never touches the real state. A
27
- // custom root is a sandbox: the legacy visible home (~/Monty) is not scanned.
26
+ // at ~/.monty-dev so platform development never touches the real state.
28
27
  const CONFIG_DIR = process.env.MONTY_HOME
29
28
  ? resolve(process.env.MONTY_HOME)
30
29
  : join(homedir(), ".monty");
@@ -50,11 +49,9 @@ const ANSI_RE = new RegExp(
50
49
  // Every app's source lives in one predictable, hidden place: `monty create`
51
50
  // registers the app first and the server-minted id names the folder
52
51
  // (~/.monty/apps/<id>) — id-keyed because slugs may be renamed later; the
53
- // `id` stamped into monty.config.ts is the durable identity. `monty login`
54
- // provisions the home; --dir overrides per create. Pre-id apps in the legacy
55
- // visible home (~/Monty) keep working — every scan reads both.
52
+ // `id` stamped into .monty/app.json is the durable identity. `monty login`
53
+ // provisions the home; --dir overrides per create.
56
54
  const MONTY_HOME = join(CONFIG_DIR, "apps");
57
- const LEGACY_MONTY_HOME = join(homedir(), "Monty");
58
55
 
59
56
  const [, , command, ...rest] = process.argv;
60
57
 
@@ -116,22 +113,16 @@ function findMontyrcHost(startDir) {
116
113
  return null;
117
114
  }
118
115
 
119
- // Reads config.json in either shape: legacy { host, key } (migrated on the
120
- // next login) or { defaultHost, profiles: { [host]: { key } } }.
116
+ // Reads config.json: { defaultHost, profiles: { [host]: { key } } }.
121
117
  function normalizedConfig() {
122
118
  let raw = null;
123
119
  try {
124
120
  raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
125
121
  } catch { /* not logged in anywhere yet */ }
126
- if (!raw) return { defaultHost: null, profiles: {} };
127
- if (raw.profiles && typeof raw.profiles === "object") {
128
- return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
122
+ if (!raw?.profiles || typeof raw.profiles !== "object") {
123
+ return { defaultHost: null, profiles: {} };
129
124
  }
130
- const legacyHost = (raw.host ?? DEFAULT_HOST).replace(/\/+$/, "");
131
- return {
132
- defaultHost: legacyHost,
133
- profiles: raw.key ? { [legacyHost]: { key: raw.key } } : {},
134
- };
125
+ return { defaultHost: raw.defaultHost ?? null, profiles: raw.profiles };
135
126
  }
136
127
 
137
128
  function resolveHost() {
@@ -344,11 +335,9 @@ function installSkills({ appDir = null, silent = true, force = false } = {}) {
344
335
  }
345
336
  }
346
337
 
347
- // ── monty current / select / apps ───────────────────────────────────────────
348
- // Folder management users never think about: every app lives in ~/Monty,
349
- // `current` says where you are, `select` prints the folder for cd $(...).
338
+ // ── monty current ──────────────────────────────────────────────────────────
350
339
  // The app-root marker is the IDENTITY STAMP (.monty/app.json — written by
351
- // create and pull) or, for older folders, monty.config.ts.
340
+ // create and connect) or, transitionally, monty.config.ts.
352
341
  function isAppRoot(dir) {
353
342
  return existsSync(join(dir, ".monty", "app.json")) || existsSync(join(dir, "monty.config.ts"));
354
343
  }
@@ -400,58 +389,15 @@ function readIdFromConfig(dir) {
400
389
  }
401
390
  }
402
391
 
403
- function scanAppsHome(root) {
404
- if (!existsSync(root)) return [];
405
- return readdirSync(root)
406
- .map((name) => join(root, name))
407
- .filter((p) => isAppRoot(p))
408
- .map((p) => ({ path: p, slug: readSlugFromConfig(p) ?? basename(p), id: readIdFromConfig(p) }));
409
- }
410
-
411
- function listLocalApps() {
412
- // A MONTY_HOME sandbox lists only its own apps — leaking the legacy home
413
- // into an isolated root would defeat the isolation.
414
- return process.env.MONTY_HOME
415
- ? scanAppsHome(MONTY_HOME)
416
- : [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
417
- }
418
-
419
392
  function current() {
420
393
  const root = findAppRoot(process.cwd());
421
394
  if (!root) {
422
- fail("NOT_IN_APP", `You are not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one.`);
395
+ fail("NOT_IN_APP", "You are not inside a Monty app. cd into the app's folder (`monty connect <slug>` pulls a copy into any folder).");
423
396
  }
424
397
  console.log(`app: ${readSlugFromConfig(root) ?? "?"}`);
425
398
  const id = readIdFromConfig(root);
426
399
  if (id) console.log(`id: ${id}`);
427
400
  console.log(`path: ${root}`);
428
- if (!root.startsWith(MONTY_HOME) && !root.startsWith(LEGACY_MONTY_HOME)) {
429
- console.log(`note: outside ${MONTY_HOME} (fine, but apps normally live there)`);
430
- }
431
- }
432
-
433
- function select() {
434
- const slug = rest.find((a) => !a.startsWith("--"));
435
- if (!slug) {
436
- fail("MISSING_SLUG", `Usage: cd "$(monty select <slug>)" — prints the app's folder.`);
437
- }
438
- const apps = listLocalApps();
439
- const hit = apps.find((a) => a.slug === slug || a.id === slug || basename(a.path) === slug);
440
- if (!hit) {
441
- const known = apps.map((a) => a.slug).join(", ") || "(none)";
442
- fail("APP_NOT_LOCAL", `No local source for "${slug}" on this machine. Local apps: ${known}. Create it with \`monty create ${slug}\`.`);
443
- }
444
- // Bare path on stdout so command substitution works: cd "$(monty select x)"
445
- console.log(hit.path);
446
- }
447
-
448
- function apps() {
449
- const local = listLocalApps();
450
- if (!local.length) {
451
- console.log(`no local apps in ${MONTY_HOME} — create one with \`monty create <slug>\``);
452
- return;
453
- }
454
- for (const a of local) console.log(`${a.slug}\t${a.path}`);
455
401
  }
456
402
 
457
403
  // Pack the app's source tree (node_modules/dist/.monty/.git excluded) into a
@@ -488,7 +434,7 @@ function readSlug(appDir) {
488
434
  // (Not `monty logs` — that tails the dev shell.)
489
435
  async function versionsLog() {
490
436
  const slug = rest.find((a) => !a.startsWith("-")) ?? readSlug(process.cwd());
491
- if (!slug) fail("NO_SLUG", "Usage: monty log [slug] — or run it inside an app folder.");
437
+ if (!slug) fail("NO_SLUG", "Usage: monty history [slug] — or run it inside an app folder.");
492
438
  const { host, key } = loadConfig();
493
439
  if (!key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
494
440
  const res = await fetch(`${host}/api/source?slug=${slug}&list=1`, {
@@ -531,7 +477,7 @@ async function pull() {
531
477
  }
532
478
  const app = appsBody.apps.find((a) => a.slug === slug);
533
479
  if (!app) {
534
- fail("APP_NOT_FOUND", `No app "${slug}" in this workspace. \`monty apps\` lists what exists.`);
480
+ fail("APP_NOT_FOUND", `No app "${slug}" in this workspace check the app list at ${host}.`);
535
481
  }
536
482
  // --version <hash-prefix>: restore a specific snapshot from `monty log`
537
483
  // instead of the newest one. Prefixes resolve against the history list.
@@ -548,7 +494,7 @@ async function pull() {
548
494
  }
549
495
  const matches = vbody.versions.filter((v) => v.hash.startsWith(versionFlag));
550
496
  if (matches.length === 0) {
551
- fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty log ${slug}\` lists what exists.`);
497
+ fail("VERSION_NOT_FOUND", `No version of "${slug}" matches "${versionFlag}". \`monty history ${slug}\` lists what exists.`);
552
498
  }
553
499
  if (matches.length > 1) {
554
500
  fail("VERSION_AMBIGUOUS", `"${versionFlag}" matches ${matches.length} versions — use more characters of the hash.`);
@@ -558,7 +504,10 @@ async function pull() {
558
504
  } else if (!app.sourceHash) {
559
505
  fail("NO_SOURCE", `"${slug}" has no source snapshot yet — snapshots ride each \`monty save\`. Run it once from the machine that has the source, then pull works everywhere.`);
560
506
  }
561
- const target = app.id ? join(MONTY_HOME, app.id) : join(LEGACY_MONTY_HOME, slug);
507
+ if (!app.id) {
508
+ fail("APP_ID_MISSING", `"${slug}" has no app id in the workspace registry — save it once from a machine that has its source, then pull works.`);
509
+ }
510
+ const target = join(MONTY_HOME, app.id);
562
511
  if (existsSync(target) && !rest.includes("--force")) {
563
512
  fail("DIR_EXISTS", `${target} already exists and may hold unsaved work. Compare it with the saved version first; re-run with --force to REPLACE it with the snapshot.`);
564
513
  }
@@ -620,6 +569,130 @@ async function pull() {
620
569
  console.log("next: `monty install`, then `monty dev`.");
621
570
  }
622
571
 
572
+ // ── monty connect ──────────────────────────────────────────────────────────
573
+ // The one command that turns ANY folder into a working copy of a cloud app:
574
+ // `monty connect <slug> [dir]` (dir defaults to the current folder) pulls
575
+ // the app's files, installs dependencies, stamps identity, and registers
576
+ // the checkout so the desktop's picker sees it. Apps without a source
577
+ // snapshot but with a manifest get the config-only scaffold synced from the
578
+ // registry. Multiple copies of one app on a machine are fine.
579
+ async function connect() {
580
+ const [slugArg, dirArg] = rest.filter((a) => !a.startsWith("--"));
581
+ if (!slugArg || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slugArg)) {
582
+ fail("INVALID_SLUG", "Usage: monty connect <slug> [dir] — pulls the app into dir (default: the current folder, which must be empty).");
583
+ }
584
+ const slug = slugArg;
585
+ const { host, key } = loadConfig();
586
+ if (!key) {
587
+ fail("NOT_LOGGED_IN", `Connecting needs your workspace (${host}). Run \`monty login\` first.`);
588
+ }
589
+ const appsRes = await fetch(`${host}/api/apps`, { headers: { authorization: `Bearer ${key}` } });
590
+ const appsBody = await appsRes.json().catch(() => null);
591
+ if (!appsRes.ok || !appsBody?.ok) {
592
+ fail(appsBody?.code ?? `HTTP_${appsRes.status}`, appsBody?.fix ?? "Could not list workspace apps — check the connection and `monty login`.");
593
+ }
594
+ const app = appsBody.apps.find((a) => a.slug === slug);
595
+ if (!app) {
596
+ fail("APP_NOT_FOUND", `No app "${slug}" in this workspace — check the app list at ${host}.`);
597
+ }
598
+
599
+ const target = resolve(process.cwd(), dirArg ?? ".");
600
+ if (existsSync(target)) {
601
+ if (!statSync(target).isDirectory()) {
602
+ fail("NOT_A_DIRECTORY", `${target} is a file. Point \`monty connect\` at a folder.`);
603
+ }
604
+ if (readdirSync(target).filter((n) => n !== ".DS_Store").length > 0) {
605
+ // A folder that already holds THIS app needs nothing — the working
606
+ // copy is untouched.
607
+ const stamp = readAppJson(target);
608
+ if (stamp && ((app.id && stamp.id === app.id) || stamp.slug === slug)) {
609
+ console.log(`connected: ${slug} -> ${target} (already a copy of this app)`);
610
+ console.log("next: `monty dev`");
611
+ return;
612
+ }
613
+ fail("DIR_NOT_EMPTY", `${target} is not empty. Connect pulls the app's files INTO the folder — run it in an empty one, or name a new folder: \`monty connect ${slug} ${slug}\`.`);
614
+ }
615
+ }
616
+
617
+ if (app.sourceHash) {
618
+ console.log(`connect: ${slug} <- ${host}`);
619
+ const res = await fetch(`${host}/api/source?slug=${slug}`, {
620
+ headers: { authorization: `Bearer ${key}` },
621
+ });
622
+ if (!res.ok) {
623
+ const b = await res.json().catch(() => null);
624
+ fail(b?.code ?? `HTTP_${res.status}`, b?.fix ?? "Downloading the snapshot failed — retry.");
625
+ }
626
+ const buf = Buffer.from(await res.arrayBuffer());
627
+ const hash = createHash("sha256").update(buf).digest("hex");
628
+ if (hash !== app.sourceHash) {
629
+ fail("SOURCE_CORRUPT", "The downloaded snapshot failed verification — retry; if it persists, run `monty save` again from a machine that has the source.");
630
+ }
631
+ // Extract into a sibling staging folder first — a failed extract never
632
+ // leaves a half-written copy. An existing (empty) target keeps its
633
+ // inode: staging contents MOVE in, so a shell sitting in it stays sane.
634
+ const staging = `${target}.connect-tmp`;
635
+ rmSync(staging, { recursive: true, force: true });
636
+ mkdirSync(staging, { recursive: true });
637
+ const tarFile = join(staging, ".source.tar.gz");
638
+ writeFileSync(tarFile, buf);
639
+ const untar = spawnSync("tar", ["-xzf", tarFile, "-C", staging], { stdio: "pipe" });
640
+ rmSync(tarFile, { force: true });
641
+ if (untar.status !== 0) {
642
+ rmSync(staging, { recursive: true, force: true });
643
+ fail("EXTRACT_FAILED", "Unpacking the snapshot failed. Retry; if it persists, run `monty save` again from a machine that has the source.");
644
+ }
645
+ if (existsSync(target)) {
646
+ for (const name of readdirSync(staging)) renameSync(join(staging, name), join(target, name));
647
+ rmSync(staging, { recursive: true, force: true });
648
+ } else {
649
+ renameSync(staging, target);
650
+ }
651
+ try {
652
+ const cfg = await fetch(`${host}/api/config`).then((r) => r.json());
653
+ writeFileSync(
654
+ join(target, ".env.local"),
655
+ `VITE_CONVEX_URL=${cfg.convexUrl}\nVITE_CLERK_PUBLISHABLE_KEY=${cfg.clerkPublishableKey}\n`,
656
+ );
657
+ } catch {
658
+ console.log(`config: WARNING — could not reach ${host}/api/config; copy .env.example to .env.local manually`);
659
+ }
660
+ mkdirSync(join(target, ".monty"), { recursive: true });
661
+ writeFileSync(
662
+ join(target, ".monty", "source.json"),
663
+ JSON.stringify({ hash, syncedAt: Date.now() }) + "\n",
664
+ );
665
+ } else {
666
+ // No snapshot: the app's config in the workspace IS the app — a
667
+ // config-only scaffold synced from it is a complete working copy.
668
+ console.log(`connect: ${slug} <- ${host} (config-only — the workspace holds the app's config)`);
669
+ if (typeof app.id !== "string" || !/^[a-z0-9]{10,64}$/i.test(app.id)) {
670
+ fail("APP_ID_MISSING", `"${slug}" has no usable app id in the workspace registry — save it once from a machine that has it, then connect works.`);
671
+ }
672
+ mkdirSync(target, { recursive: true });
673
+ writeConfigOnlyScaffold(target, { appId: app.id, slug, name: app.name, icon: app.icon });
674
+ try {
675
+ await schemaPull({ appDir: target, host, key, slug, force: true, compileAppConfig, fail });
676
+ } catch {
677
+ console.log("schema: could not sync the app's config yet — `monty schema pull` retries it");
678
+ }
679
+ }
680
+
681
+ // The snapshot excludes .monty/ — the workspace row is the authority for
682
+ // identity, so stamp it fresh.
683
+ writeAppJson(target, {
684
+ ...(app.id ? { id: app.id } : {}),
685
+ slug,
686
+ ...(app.name ? { name: app.name } : {}),
687
+ ...(app.icon ? { icon: app.icon } : {}),
688
+ });
689
+ if (existsSync(join(target, "package.json"))) {
690
+ installDepsIn(target);
691
+ }
692
+ console.log(`connected: ${slug} -> ${target}`);
693
+ console.log(target === process.cwd() ? "next: `monty dev`" : `next: cd ${dirArg ?? target} && monty dev`);
694
+ }
695
+
623
696
  // ── monty create ───────────────────────────────────────────────────────────
624
697
  // A CONFIG-ONLY app: no SPA at all — monty.config.ts is the whole app and
625
698
  // the platform shell renders it. The presence of index.html is the marker
@@ -628,6 +701,20 @@ function isConfigOnlyApp(appDir) {
628
701
  return !existsSync(join(appDir, "index.html"));
629
702
  }
630
703
 
704
+ // Literal read of the config's `publicFns: ["a", "b"]` list — the same
705
+ // light-touch parse the SDK's vite plugin uses.
706
+ function readPublicFnsLiteral(appDir) {
707
+ try {
708
+ const src = readFileSync(join(appDir, "monty.config.ts"), "utf8");
709
+ const block = /publicFns:\s*\[([^\]]*)\]/m.exec(src)?.[1];
710
+ if (!block) return undefined;
711
+ const names = [...block.matchAll(/["']([a-zA-Z][a-zA-Z0-9_]*)["']/g)].map((m) => m[1]);
712
+ return names.length > 0 ? names : undefined;
713
+ } catch {
714
+ return undefined;
715
+ }
716
+ }
717
+
631
718
  // Literal read of the config's `schedule` block ({ fn: "cron expr" }) —
632
719
  // the same light-touch parse the SDK's vite plugin uses for publicFns.
633
720
  // Registry-owned sessions read it this way so the cron ticker works
@@ -665,12 +752,12 @@ function discoverPages(appDir) {
665
752
 
666
753
  const CONFIG_ONLY_AGENTS_MD = `# This is a CONFIG-ONLY Monty app
667
754
 
668
- The app is rendered by the Monty platform from its WORKSPACE manifest
755
+ The app is rendered by the Monty platform from its config stored in the workspace
669
756
  tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
670
757
  \`settings\`, and \`pages\`. There is no src/, no React, no build.
671
758
 
672
759
  - The schema lives in the workspace, and its door is the schema API:
673
- read it with \`monty schema\`, change it with \`monty schema set <file|->\`
760
+ read it with \`monty schema\`, change it with \`monty schema set '<json>'\` (or pipe: \`monty schema set -\`)
674
761
  (validated, CAS-guarded, live within seconds). monty.config.ts edits do
675
762
  NOT change a workspace-owned app's schema.
676
763
  - Formulas are strings in the Monty expression grammar, e.g.
@@ -682,7 +769,7 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
682
769
  system record page cannot express. If the page is still one table, start
683
770
  with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
684
771
  with the record controls from \`@montytools/sdk/ui\`.
685
- - Need a bespoke page later? \`monty add page\` declares it and upgrades this
772
+ - Need a bespoke page later? \`monty page add\` declares it and upgrades this
686
773
  app with a SPA scaffold; \`monty save\` ships the code.
687
774
  `;
688
775
 
@@ -725,13 +812,6 @@ async function create() {
725
812
  if (!slug || slug.length > 64 || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
726
813
  fail("INVALID_SLUG", 'Usage: monty create <slug> — lowercase letters/digits with single hyphens, max 64 chars (e.g. "standup-notes").');
727
814
  }
728
- // An app with this slug already on disk means create is the wrong verb —
729
- // fail BEFORE registering, and never suggest deleting anything: the folder
730
- // may hold real, uncommitted work.
731
- const dupe = listLocalApps().find((a) => a.slug === slug);
732
- if (dupe) {
733
- fail("APP_EXISTS", `"${slug}" already exists on this machine at ${dupe.path}. Keep working on it there (cd "$(monty select ${slug})"); pick a different slug for a new app.`);
734
- }
735
815
  const name =
736
816
  flag("name") ??
737
817
  slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
@@ -777,7 +857,7 @@ async function create() {
777
857
  mkdirSync(dirname(target), { recursive: true });
778
858
 
779
859
  // DEFAULT: config-only — no SPA. monty.config.ts is the whole app and the
780
- // platform shell renders it; \`monty add page\` scaffolds a SPA the moment a
860
+ // platform shell renders it; \`monty page add\` scaffolds a SPA the moment a
781
861
  // bespoke page is needed. \`--spa\` keeps the old full-SPA scaffold
782
862
  // (\`--config-only\` stays accepted as the now-default no-op).
783
863
  if (!rest.includes("--spa")) {
@@ -910,7 +990,7 @@ async function create() {
910
990
  }
911
991
 
912
992
  // Build tracking: the /new screen minted an id; recording it here lets
913
- // `monty deploy` resolve it and flips the UI into "agent is working" mode.
993
+ // `monty save` resolve it and flips the UI into "agent is working" mode.
914
994
  const buildId = flag("build");
915
995
  if (buildId && /^[a-z0-9]{10,64}$/i.test(buildId)) {
916
996
  mkdirSync(join(target, ".monty"), { recursive: true });
@@ -937,14 +1017,17 @@ async function create() {
937
1017
  // The full app lifecycle goes through the CLI — agents never invoke pnpm,
938
1018
  // vite, or tsc directly. Same underlying tools, agent-shaped output, and the
939
1019
  // build-before-typecheck ordering handled for you.
940
- function installDeps() {
941
- const appDir = requireAppDir("install");
1020
+ function installDepsIn(appDir) {
942
1021
  const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
943
1022
  run(appDir, "install", [pm, "install"],
944
1023
  "Dependency install failed. Read the package manager error above; usually network or a bad package.json edit.");
945
1024
  ensureSdk(appDir);
946
1025
  }
947
1026
 
1027
+ function installDeps() {
1028
+ installDepsIn(requireAppDir("install"));
1029
+ }
1030
+
948
1031
  function buildApp() {
949
1032
  const appDir = requireAppDir("build");
950
1033
  run(appDir, "build", ["npx", "vite", "build"],
@@ -991,7 +1074,7 @@ async function freePort(start) {
991
1074
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
992
1075
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
993
1076
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
994
- const MIN_SDK = "0.2.4";
1077
+ const MIN_SDK = "0.2.5";
995
1078
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
996
1079
 
997
1080
  function installedSdkVersion(appDir) {
@@ -1451,17 +1534,16 @@ async function dev() {
1451
1534
 
1452
1535
  installSkills({ appDir });
1453
1536
  ensureSdk(appDir);
1454
- // Registry-owned apps (stamped on the first configIgnored beat): the
1455
- // platform reads NOTHING from the config compile identity comes from
1456
- // the stamp and the compile is skipped entirely. The config file is just
1457
- // code the bundle imports; `schedule` (a code-door declaration the
1537
+ // Identity comes from the .monty/app.json stamp (written by create and
1538
+ // connect) — the platform reads NOTHING from the config file, which is
1539
+ // just code the bundle imports. `schedule` (a code-door declaration the
1458
1540
  // session cron ticker needs) is read literally, the same way the vite
1459
1541
  // plugin reads `publicFns`.
1460
1542
  const stamp = readAppJson(appDir);
1461
- const registryOwned = stamp?.registryOwned === true && typeof stamp?.slug === "string";
1462
- const meta = registryOwned
1463
- ? { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) }
1464
- : await compileConfig(appDir);
1543
+ if (typeof stamp?.slug !== "string" || !stamp.slug) {
1544
+ fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here to join an existing app, or `monty create <slug>` for a new one.");
1545
+ }
1546
+ const meta = { slug: stamp.slug, name: stamp.name, icon: stamp.icon, schedule: readScheduleLiteral(appDir) };
1465
1547
  const cfg = loadConfig();
1466
1548
  const host = cfg?.host ?? DEFAULT_HOST;
1467
1549
  // CONFIG-ONLY apps run no vite and no tunnel: `monty dev` is watch +
@@ -1493,25 +1575,16 @@ async function dev() {
1493
1575
  let cronTimer = null;
1494
1576
  let ended = false;
1495
1577
  let registeredOnce = false;
1496
- // Held-config warnings print once per drift episode, not every beat.
1497
- let driftAnnounced = false;
1498
- // The registry-owned notice prints once per session.
1578
+ // The schema-lives-in-the-workspace notice prints once per session.
1499
1579
  let configIgnoredAnnounced = false;
1500
1580
  const devStartedAt = Date.now();
1501
1581
  const sessionId = `dev_${randomBytes(16).toString("hex")}`;
1502
1582
  const buildFile = join(appDir, ".monty", "build");
1503
1583
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
1504
- // The session schema channel (manifest-less apps): heartbeats carry the
1505
- // compiled schema, and edits to monty.config.ts are re-compiled (softly)
1506
- // so schema changes reach the platform within one heartbeat.
1507
- // Registry-owned apps skip all of it — their schema lives behind the
1508
- // doors, and the local config copy follows the registry (auto-pull below).
1509
- let currentMeta = meta;
1510
1584
  // Once per registry change: the manifest hash we last tried to sync the
1511
1585
  // local config copy to (successful or refused — never loop on dirty).
1512
1586
  let syncAttemptedHash = null;
1513
1587
  const configPath = join(appDir, "monty.config.ts");
1514
- let configMtime = existsSync(configPath) ? statSync(configPath).mtimeMs : 0;
1515
1588
 
1516
1589
  // Advertise this session. The touch timer (not the platform heartbeat,
1517
1590
  // which starts minutes late or never when logged out) keeps updatedAt
@@ -1522,7 +1595,7 @@ async function dev() {
1522
1595
  version: 1,
1523
1596
  cli: CLI_VERSION,
1524
1597
  pid: process.pid,
1525
- vitePid: child.pid ?? null,
1598
+ vitePid: child?.pid ?? null,
1526
1599
  tunnelPid: null,
1527
1600
  port,
1528
1601
  slug: meta.slug,
@@ -1548,12 +1621,11 @@ async function dev() {
1548
1621
  // Trigger on the app's fn-worker; here the CLI matches monty.config.ts
1549
1622
  // `schedule` entries against the UTC clock once per minute and invokes the
1550
1623
  // fn through the same /__monty/fn runtime (x-monty-schedule marks the
1551
- // lane, so ctx.viewer matches Live exactly). Config edits hot-apply via
1552
- // currentMeta. Fire-and-forget: a failing cron fn prints its instruction
1553
- // here and never blocks the loop.
1624
+ // lane, so ctx.viewer matches Live exactly). Fire-and-forget: a failing
1625
+ // cron fn prints its instruction here and never blocks the loop.
1554
1626
  let lastCronMinute = null;
1555
1627
  function cronTick() {
1556
- const sched = currentMeta?.schedule;
1628
+ const sched = meta?.schedule;
1557
1629
  if (!sched || !loggedIn) return;
1558
1630
  const now = new Date();
1559
1631
  const minute = Math.floor(now.getTime() / 60_000);
@@ -1579,22 +1651,6 @@ async function dev() {
1579
1651
  }
1580
1652
  cronTimer = setInterval(cronTick, 20_000);
1581
1653
 
1582
- async function refreshSchemaIfChanged() {
1583
- if (registryOwned) return; // the doors own the schema; nothing to push
1584
- try {
1585
- const m = statSync(configPath).mtimeMs;
1586
- if (m === configMtime) return;
1587
- configMtime = m;
1588
- const fresh = await compileConfig(appDir, { soft: true });
1589
- if (fresh) {
1590
- currentMeta = fresh;
1591
- console.log(
1592
- `schema: monty.config.ts changed — session schema updated (${Object.keys(fresh.schemaJson.tables).length} tables)`,
1593
- );
1594
- }
1595
- } catch { /* transient fs hiccup — next beat retries */ }
1596
- }
1597
-
1598
1654
  async function clearDevSession(timeoutMs = 2000) {
1599
1655
  if (cfg?.key) {
1600
1656
  try {
@@ -1639,7 +1695,6 @@ async function dev() {
1639
1695
 
1640
1696
  async function heartbeat(originUrl, { claim = false } = {}) {
1641
1697
  if (ended) return false; // shutdown already ran — no side effects
1642
- await refreshSchemaIfChanged();
1643
1698
  try {
1644
1699
  // Re-read the key EVERY beat: the desktop (or a fresh `monty login`)
1645
1700
  // may have replaced an expired key while this session runs — the
@@ -1659,23 +1714,9 @@ async function dev() {
1659
1714
  // owner. BOUNDED so a never-registering session (broken network)
1660
1715
  // can't steal the lock from a newer active session forever.
1661
1716
  claim: claim || (!registeredOnce && Date.now() - devStartedAt < 90_000),
1662
- name: currentMeta.name,
1663
- icon: currentMeta.icon,
1717
+ name: meta.name,
1718
+ icon: meta.icon,
1664
1719
  buildId,
1665
- schemaJson: currentMeta.schemaJson,
1666
- // App Manifest v2 (docs/manifest-v2.md) — present only for V2
1667
- // configs; lands only for manifest-less apps (the doors own the
1668
- // rest).
1669
- manifest: currentMeta.manifest,
1670
- // The CAS base for the manifest-less landing (see `monty schema
1671
- // pull`).
1672
- baseManifestHash:
1673
- currentMeta.manifest !== undefined ? readSchemaState(appDir)?.hash : undefined,
1674
- // The expose block rides the same compile as the schema — the dev
1675
- // visitor preview of a manifest-less app is gated on it.
1676
- exposure: currentMeta.exposure,
1677
- // Rules ride the same channel (manifest-less apps only).
1678
- rules: currentMeta.rules,
1679
1720
  }),
1680
1721
  signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
1681
1722
  });
@@ -1685,11 +1726,6 @@ async function dev() {
1685
1726
  stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
1686
1727
  return false;
1687
1728
  }
1688
- if (data?.code === "MANIFEST_DRIFT") {
1689
- // Remote schema edits (another agent, via the API) — the terminal
1690
- // the building agent is watching gets the fix, invariant #4.
1691
- console.log(`schema drift (remote changes):${data.summary ? `\n${data.summary}` : ""}`);
1692
- }
1693
1729
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
1694
1730
  // A dead key is a SIGNED-OUT session — advertise it so the desktop
1695
1731
  // (which owns the session) can surface sign-in instead of letting
@@ -1699,38 +1735,15 @@ async function dev() {
1699
1735
  }
1700
1736
  return false;
1701
1737
  }
1702
- if (data?.manifestDrift) {
1703
- // The session registered, but the config push was HELD: the stored
1704
- // schema changed since this checkout last synced (another editor).
1705
- if (!driftAnnounced) {
1706
- driftAnnounced = true;
1707
- console.log(`schema drift (remote changes):${data.manifestDrift.summary ? `\n${data.manifestDrift.summary}` : ""}`);
1708
- console.log(`config push held: ${data.manifestDrift.fix ?? "Run `monty schema pull`, merge, then save again."}`);
1709
- }
1710
- } else if (currentMeta.manifest !== undefined) {
1711
- driftAnnounced = false;
1712
- // This beat's manifest landed LIVE — the new CAS base.
1713
- try { writeSchemaState(appDir, manifestHash(currentMeta.manifest)); } catch { /* state is advisory */ }
1714
- }
1715
1738
  if (!registeredOnce) {
1716
1739
  registeredOnce = true;
1717
1740
  sf.write({ state: "online", loggedIn: true, lastHeartbeatAt: Date.now() });
1718
1741
  } else {
1719
1742
  sf.write({ loggedIn: true, lastHeartbeatAt: Date.now() });
1720
1743
  }
1721
- if (data?.configIgnored && !configIgnoredAnnounced) {
1744
+ if (!configIgnoredAnnounced) {
1722
1745
  configIgnoredAnnounced = true;
1723
- console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set <file>`.");
1724
- // Stamp registry ownership: from the next session on, the config
1725
- // compile is skipped entirely.
1726
- try {
1727
- writeAppJson(appDir, {
1728
- registryOwned: true,
1729
- slug: meta.slug,
1730
- ...(currentMeta.name ? { name: currentMeta.name } : {}),
1731
- ...(currentMeta.icon ? { icon: currentMeta.icon } : {}),
1732
- });
1733
- } catch { /* the stamp is a convenience; the beat decided */ }
1746
+ console.log("schema: this app's data schema lives in the workspace — monty.config.ts edits do NOT change it. Read it: `monty schema`; change it: `monty schema set '<json>'` (or pipe: `monty schema set -`).");
1734
1747
  }
1735
1748
  // The local config copy follows the registry: when the stored manifest
1736
1749
  // moved (another editor, the schema door) and the local file is clean,
@@ -1850,26 +1863,8 @@ async function dev() {
1850
1863
 
1851
1864
  if (configOnly) {
1852
1865
  sf.write({ state: "ready" });
1853
- console.log(
1854
- registryOwned
1855
- ? "ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`"
1856
- : "ready: config-only — saves land LIVE; the workspace renders them within seconds",
1857
- );
1866
+ console.log("ready: config-only — the workspace owns this app's schema; edit it with `monty schema` / `monty schema set`");
1858
1867
  void startDevSession();
1859
- if (!registryOwned) {
1860
- // Manifest-less apps only: config edits should land in seconds, not a
1861
- // heartbeat — watch the mtime and trigger an early beat (which
1862
- // recompiles + pushes). Registry-owned apps have nothing to push.
1863
- const cfgWatch = setInterval(() => {
1864
- try {
1865
- if (statSync(configPath).mtimeMs !== configMtime) void heartbeat(undefined);
1866
- } catch { /* transient fs hiccup */ }
1867
- }, 2000);
1868
- const stopWatch = () => clearInterval(cfgWatch);
1869
- process.on("SIGINT", stopWatch);
1870
- process.on("SIGTERM", stopWatch);
1871
- process.on("SIGHUP", stopWatch);
1872
- }
1873
1868
  }
1874
1869
 
1875
1870
  let announced = false;
@@ -2124,7 +2119,7 @@ function startTunnel(port, onUrlChange, onOutput) {
2124
2119
  });
2125
2120
  }
2126
2121
 
2127
- // ── monty add / components / docs ──────────────────────────────────────────
2122
+ // ── monty components / page ────────────────────────────────────────────────
2128
2123
  // Wraps the shadcn CLI behind the curated catalog: agents ask for a
2129
2124
  // capability by plain name ("kanban") and get the blessed, theme-compatible
2130
2125
  // implementation. Only catalog registries and core shadcn resolve.
@@ -2132,7 +2127,7 @@ function startTunnel(port, onUrlChange, onOutput) {
2132
2127
  function requireAppDir(cmd) {
2133
2128
  const appDir = findAppRoot(process.cwd());
2134
2129
  if (!appDir) {
2135
- fail("NOT_A_MONTY_APP", `Not inside a Monty app. \`monty apps\` lists local apps; cd "$(monty select <slug>)" jumps to one then run \`monty ${cmd}\`.`);
2130
+ fail("NOT_A_MONTY_APP", `Not inside a Monty app. cd into the app's folder (\`monty connect <slug>\` pulls a copy into any folder), then run \`monty ${cmd}\`.`);
2136
2131
  }
2137
2132
  return appDir;
2138
2133
  }
@@ -2150,7 +2145,7 @@ function resolveComponent(name) {
2150
2145
  return name;
2151
2146
  }
2152
2147
 
2153
- // ── monty add page <name> ──────────────────────────────────────────────────
2148
+ // ── monty page add <name> ──────────────────────────────────────────────────
2154
2149
  // Upgrades an app with a bespoke (custom) page: scaffolds the SPA on first
2155
2150
  // use (config-only apps gain src/ + vite from the template — their
2156
2151
  // monty.config.ts and AGENTS.md stay untouched), declares
@@ -2204,7 +2199,7 @@ async function declarePageThroughDoor(appDir, pageName) {
2204
2199
 
2205
2200
  async function addPage(appDir, pageName) {
2206
2201
  if (!pageName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(pageName) || pageName.length > 32) {
2207
- fail("INVALID_PAGE", 'Usage: monty add page <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2202
+ fail("INVALID_PAGE", 'Usage: monty page add <name> — lowercase letters/digits with single hyphens, max 32 chars (e.g. "reports").');
2208
2203
  }
2209
2204
  const configPath = join(appDir, "monty.config.ts");
2210
2205
  if (!existsSync(configPath)) {
@@ -2291,7 +2286,7 @@ async function addPage(appDir, pageName) {
2291
2286
  if (existsSync(starter)) rmSync(starter);
2292
2287
  appendFileSync(
2293
2288
  join(appDir, "AGENTS.md"),
2294
- `\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` declaration.\nDECLARE-FIRST: \`monty add page <name>\` declares the entry (through the\nschema door on workspace-owned apps) before writing the route — a save\ncarrying an undeclared route refuses. System pages (table views) stay\nshell-rendered — only build bespoke UI here. \`monty dev\` serves both.\nAfter every meaningful change verified in dev, run\n\`monty save "<what changed>"\` — it pushes the work to the cloud copy,\nlike \`git push main\`.\n\nEvery custom page opens with \`PageHeader\` from \`@montytools/sdk/ui\` —\nthe same bar the shell renders on system pages (page actions go in it as\n\`PageHeaderButton\`s, \`primary\` for the one main action). Also there:\n\`FloatingBar\`/\`FloatingBarButton\` and the Lyra table classes\n\`SURFACE\`/\`THEAD\`/\`TH\`/\`ROW\`/\`CHIP\`.\n`,
2289
+ `\n---\n\n## Custom pages\n\nThis app now has a SPA half (src/) serving CUSTOM pages inside the\nplatform shell. Each page = a route in src/routes/ + a\n\`pages.<name>: { kind: "custom", path: "/<name>" }\` declaration.\nDECLARE-FIRST: \`monty page add <name>\` declares the entry (through the\nschema door on workspace-owned apps) before writing the route — a save\ncarrying an undeclared route refuses. System pages (table views) stay\nshell-rendered — only build bespoke UI here. \`monty dev\` serves both.\nAfter every meaningful change verified in dev, run\n\`monty save "<what changed>"\` — it pushes the work to the cloud copy,\nlike \`git push main\`.\n\nEvery custom page opens with \`PageHeader\` from \`@montytools/sdk/ui\` —\nthe same bar the shell renders on system pages (page actions go in it as\n\`PageHeaderButton\`s, \`primary\` for the one main action). Also there:\n\`FloatingBar\`/\`FloatingBarButton\` and the Lyra table classes\n\`SURFACE\`/\`THEAD\`/\`TH\`/\`ROW\`/\`CHIP\`.\n`,
2295
2290
  );
2296
2291
  }
2297
2292
 
@@ -2372,16 +2367,17 @@ function pageComponentName(pageName) {
2372
2367
  return pageName.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("") + "Page";
2373
2368
  }
2374
2369
 
2375
- async function add() {
2376
- const appDir = requireAppDir("add");
2377
- const names = rest.filter((a) => !a.startsWith("--"));
2378
- if (names.length === 0) {
2379
- fail("NO_COMPONENT", "Usage: monty add <name...> — run `monty components` to see what's available (or `monty add page <name>` for a custom page).");
2380
- }
2381
- if (names[0] === "page") {
2382
- await addPage(appDir, names[1]);
2383
- return;
2370
+ // The page namespace: `monty page add <name>` scaffolds a bespoke page
2371
+ // (declare-first through the schema door).
2372
+ async function pageCmd() {
2373
+ const [sub, name] = rest.filter((a) => !a.startsWith("--"));
2374
+ if (sub !== "add") {
2375
+ fail("INVALID_PAGE", 'Usage: monty page add <name> — scaffold a custom page (e.g. `monty page add reports`).');
2384
2376
  }
2377
+ await addPage(requireAppDir("page"), name);
2378
+ }
2379
+
2380
+ async function addComponents(appDir, names) {
2385
2381
  const items = names.flatMap((n) => [resolveComponent(n), ...(CATALOG[n]?.also ?? [])]);
2386
2382
 
2387
2383
  // --overwrite so shadcn never halts on a per-file prompt (an aborted prompt
@@ -2423,31 +2419,50 @@ async function add() {
2423
2419
  console.log(`added: ${items.join(", ")} -> src/components (already themed; import and compose)`);
2424
2420
  }
2425
2421
 
2426
- function components() {
2427
- const query = rest.filter((a) => !a.startsWith("--")).join(" ").toLowerCase();
2422
+ // The component namespace: `monty components` lists/searches the curated
2423
+ // catalog, `components add <name...>` installs, `components docs <name>`
2424
+ // prints a component's source. (`search` is the explicit search subcommand;
2425
+ // a bare query searches too.)
2426
+ async function componentsCmd() {
2427
+ const args = rest.filter((a) => !a.startsWith("--"));
2428
+ const [sub, ...tail] = args;
2429
+ if (sub === "add") {
2430
+ if (tail.length === 0) {
2431
+ fail("NO_COMPONENT", "Usage: monty components add <name...> — `monty components` lists the catalog; core shadcn components install by bare name.");
2432
+ }
2433
+ await addComponents(requireAppDir("components"), tail);
2434
+ return;
2435
+ }
2436
+ if (sub === "docs") {
2437
+ if (!tail[0]) {
2438
+ fail("NO_COMPONENT", "Usage: monty components docs <name> — `monty components` lists the catalog.");
2439
+ }
2440
+ componentDocs(requireAppDir("components"), tail[0]);
2441
+ return;
2442
+ }
2443
+ listComponents((sub === "search" ? tail : args).join(" ").toLowerCase());
2444
+ }
2445
+
2446
+ function listComponents(query) {
2428
2447
  const entries = Object.entries(CATALOG).filter(
2429
2448
  ([name, { item, use }]) => !query || `${name} ${item} ${use}`.toLowerCase().includes(query),
2430
2449
  );
2431
2450
  const width = Math.max(...Object.keys(CATALOG).map((n) => n.length));
2432
2451
  const itemWidth = Math.max(...Object.values(CATALOG).map((c) => c.item.length));
2433
- console.log(`components: ${entries.length} curated (install with \`monty add <name>\`)`);
2452
+ console.log(`components: ${entries.length} curated (install with \`monty components add <name>\`)`);
2434
2453
  for (const [name, { item, use }] of entries) {
2435
2454
  console.log(` ${name.padEnd(width)} ${item.padEnd(itemWidth)} ${use}`);
2436
2455
  }
2437
2456
  console.log("core: any shadcn component installs by bare name (dialog, tabs, dropdown-menu, popover, tooltip, sheet, checkbox, textarea, ...)");
2438
- console.log("docs: `monty docs <name>` shows a component's source before installing");
2457
+ console.log("docs: `monty components docs <name>` shows a component's source before installing");
2439
2458
  }
2440
2459
 
2441
- async function docs() {
2442
- const appDir = requireAppDir("docs");
2443
- const name = rest.find((a) => !a.startsWith("--"));
2444
- if (!name) {
2445
- fail("NO_COMPONENT", "Usage: monty docs <name> — run `monty components` to see what's available.");
2446
- }
2460
+ function componentDocs(appDir, name) {
2447
2461
  run(appDir, "docs", ["pnpm", "dlx", "shadcn@latest", "view", resolveComponent(name)],
2448
2462
  `Could not view ${name}. Run \`monty components\` to see the curated catalog.`);
2449
2463
  }
2450
2464
 
2465
+
2451
2466
  // ── monty secret ─────────────────────────────────────────────────────────
2452
2467
  // `monty secret set KEY [value]` / `monty secret rm KEY` — per-app
2453
2468
  // server-function secrets. The value goes to Cloudflare's per-script secrets
@@ -2455,12 +2470,11 @@ async function docs() {
2455
2470
  // readable back. Read from the arg, then a TTY prompt, then stdin (piping).
2456
2471
  async function secret() {
2457
2472
  const appDir = requireAppDir("secret");
2458
- // Identity comes from the stamp when it exists — no config compile for
2459
- // one slug read.
2460
2473
  const stamped = readAppJson(appDir);
2461
- const meta = typeof stamped?.slug === "string" && stamped.slug
2462
- ? { slug: stamped.slug }
2463
- : await compileConfig(appDir);
2474
+ if (typeof stamped?.slug !== "string" || !stamped.slug) {
2475
+ fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here first.");
2476
+ }
2477
+ const meta = { slug: stamped.slug };
2464
2478
  const config = loadConfig();
2465
2479
  if (!config?.key) fail("NOT_LOGGED_IN", "Run `monty login` first.");
2466
2480
  const [sub, name] = rest.filter((a) => !a.startsWith("-"));
@@ -2498,10 +2512,11 @@ async function secret() {
2498
2512
  }
2499
2513
 
2500
2514
  // ── monty save ─────────────────────────────────────────────────────────────
2501
- // Push the working copy to the cloud copy, like `git push main`. `deploy` is
2502
- // the compat alias; both run the same pipeline (build + typecheck gate every
2503
- // save, then one multipart POST). The optional message rides the deploy meta
2504
- // so the platform can narrate the save later.
2515
+ // Push the working copy to the cloud copy, like `git push main` (build +
2516
+ // typecheck gate every save, then one multipart POST). A save ships
2517
+ // IMPLEMENTATION ONLY the app's config lives in the workspace and lands
2518
+ // through the doors; the config file is never compiled here. The optional
2519
+ // message rides the meta so the platform can narrate the save later.
2505
2520
  async function deploy() {
2506
2521
  const appDir = requireAppDir(command);
2507
2522
  ensureSdk(appDir);
@@ -2521,40 +2536,36 @@ async function deploy() {
2521
2536
  fail("NOT_LOGGED_IN", "Run `monty login` first (create a key at /cli-auth in the Monty host).");
2522
2537
  }
2523
2538
 
2524
- // 1) Compile monty.config.ts { slug, name, icon, schemaJson } using the
2525
- // app's OWN zod/sdk instances (esbuild-bundled, run in a subprocess).
2526
- console.log("compile: monty.config.ts");
2527
- const meta = await compileConfig(appDir);
2528
- console.log(`compile: ok (app "${meta.slug}", ${Object.keys(meta.schemaJson.tables).length} tables)`);
2539
+ // 1) Identity from the stamp; code-door declarations (publicFns,
2540
+ // schedule) are read literally from the config file, the same way the
2541
+ // vite plugin reads them.
2542
+ const stamp = readAppJson(appDir);
2543
+ if (typeof stamp?.slug !== "string" || !stamp.slug) {
2544
+ fail("NOT_CONNECTED", "This folder carries no app identity (.monty/app.json). Run `monty connect <slug>` here to join an existing app, or `monty create <slug>` for a new one.");
2545
+ }
2546
+ const meta = {
2547
+ slug: stamp.slug,
2548
+ name: stamp.name ?? stamp.slug,
2549
+ icon: stamp.icon,
2550
+ publicFns: readPublicFnsLiteral(appDir),
2551
+ schedule: readScheduleLiteral(appDir),
2552
+ };
2529
2553
  // `monty save "what changed"` — the message rides the meta for the
2530
2554
  // platform to render as this save's Activity row.
2531
2555
  if (message) meta.message = message;
2532
2556
 
2533
- // Registry-owned apps ship IMPLEMENTATION ONLY: manifest, schema, rules,
2534
- // and exposure are door-owned (the server holds them regardless — not
2535
- // sending them keeps the wire honest). Code-door declarations
2536
- // (publicFns, schedule, pages, functions) still ride.
2537
- const registryOwned = readAppJson(appDir)?.registryOwned === true;
2538
- if (registryOwned) {
2539
- delete meta.schemaJson;
2540
- delete meta.manifest;
2541
- delete meta.exposure;
2542
- delete meta.rules;
2543
- }
2544
-
2545
- // CONFIG-ONLY apps publish the manifest alone: no vite build, no bundle —
2546
- // the platform shell renders the app.
2557
+ // CONFIG-ONLY apps have no bundle the platform shell renders them; the
2558
+ // save carries the source snapshot only.
2547
2559
  const configOnly = isConfigOnlyApp(appDir);
2548
- if (!configOnly) {
2560
+ if (configOnly) {
2561
+ meta.configOnly = true;
2562
+ } else {
2549
2563
  // 2) Fail fast locally before any upload. Build FIRST — it also generates
2550
2564
  // src/routeTree.gen.ts, without which tsc fails on a fresh checkout.
2551
2565
  run(appDir, "build", ["npx", "vite", "build"],
2552
2566
  "The production build failed. Read the vite error above; it names the file to fix.");
2553
2567
  run(appDir, "typecheck", ["npx", "tsc", "--noEmit"],
2554
2568
  "TypeScript errors above. Fix them in the listed files; `monty save` never uploads code that does not compile.");
2555
- } else if (!registryOwned && !meta.manifest) {
2556
- fail("MANIFEST_MISSING",
2557
- "This config-only app compiled without a manifest — that should be impossible (forceManifest). Re-run `monty install` to refresh the SDK, then retry.");
2558
2569
  }
2559
2570
 
2560
2571
  // 3) Multipart POST to the host.
@@ -2588,9 +2599,7 @@ async function deploy() {
2588
2599
  `schedule targets "${name}" but server/index.ts exports no such function. Export it (e.g. \`export async function ${name}(args, ctx) {…}\`) or remove the entry from monty.config.ts.`);
2589
2600
  }
2590
2601
  }
2591
- // Wire compat: meta.fns stays the UNION (older hosts gate dispatch on
2592
- // it); meta.datasets is the additive split newer hosts classify with.
2593
- meta.fns = [...serverBundle.fns, ...serverBundle.datasets];
2602
+ if (serverBundle.fns.length > 0) meta.fns = serverBundle.fns;
2594
2603
  if (serverBundle.datasets.length > 0) meta.datasets = serverBundle.datasets;
2595
2604
  form.set("server-worker", new Blob([serverBundle.code]), "server-worker.mjs");
2596
2605
  const bundled = [];
@@ -2618,7 +2627,7 @@ async function deploy() {
2618
2627
  // only the minified bundle and the sole copy of the app's code is this
2619
2628
  // folder — delete it and the source is gone forever. The snapshot is what
2620
2629
  // `monty pull <slug>` restores on any machine, and the publish lands in
2621
- // the same version history as `monty commit`.
2630
+ // the same version history as `monty save`.
2622
2631
  let sourceHash = null;
2623
2632
  {
2624
2633
  const packed = packSource(appDir);
@@ -2633,13 +2642,6 @@ async function deploy() {
2633
2642
  console.log(`source: ${(packed.buf.byteLength / 1024).toFixed(0)} KB snapshot rides this save (restore anywhere: monty pull ${meta.slug})`);
2634
2643
  }
2635
2644
  }
2636
- // V2 schema CAS: prove which stored manifest this checkout last synced,
2637
- // so an API/MCP edit made meanwhile surfaces as MANIFEST_DRIFT instead of
2638
- // being clobbered (fix: `monty schema pull`).
2639
- if (meta.manifest !== undefined) {
2640
- const state = readSchemaState(appDir);
2641
- if (state?.hash) meta.baseManifestHash = state.hash;
2642
- }
2643
2645
  form.set("monty", JSON.stringify(meta));
2644
2646
  let total = 0;
2645
2647
  for (const file of files) {
@@ -2650,7 +2652,7 @@ async function deploy() {
2650
2652
  }
2651
2653
  console.log(
2652
2654
  configOnly
2653
- ? `upload: config-only (manifest, no bundle) -> ${config.host}/api/deploy`
2655
+ ? `upload: config-only (source snapshot, no bundle) -> ${config.host}/api/deploy`
2654
2656
  : `upload: ${files.length} files, ${(total / 1024).toFixed(0)} KB -> ${config.host}/api/deploy`,
2655
2657
  );
2656
2658
  const res = await fetch(`${config.host}/api/deploy`, {
@@ -2660,22 +2662,10 @@ async function deploy() {
2660
2662
  });
2661
2663
  const body = await res.json().catch(() => null);
2662
2664
  if (!res.ok || !body?.ok) {
2663
- if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
2664
- console.log(`schema drift (remote changes):\n${body.summary}`);
2665
- }
2666
2665
  fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Unexpected server response — is the Monty host reachable?");
2667
2666
  }
2668
2667
  console.log(`origin: ${body.origin}`);
2669
2668
  console.log(`saved: ${body.url} (version ${body.version})`);
2670
- if (body.manifestDrift) {
2671
- // The code shipped; the config was HELD — the stored schema changed
2672
- // since this checkout last synced (another editor).
2673
- console.log(`schema drift (remote changes):${body.manifestDrift.summary ? `\n${body.manifestDrift.summary}` : ""}`);
2674
- console.log(`config push held: ${body.manifestDrift.fix ?? "Run `monty schema pull`, merge, then deploy again."}`);
2675
- } else if (meta.manifest !== undefined) {
2676
- // The manifest just published IS the new CAS base.
2677
- writeSchemaState(appDir, manifestHash(meta.manifest));
2678
- }
2679
2669
  // Stamp what was published — pull uses this to tell "unchanged since last
2680
2670
  // sync" from "locally modified".
2681
2671
  if (sourceHash) {
@@ -2777,25 +2767,6 @@ function discoverFnExports(serverEntry) {
2777
2767
  return [...names];
2778
2768
  }
2779
2769
 
2780
- // Thin wrapper over the shared pipeline (lib/compile.mjs): `soft` keeps the
2781
- // the session heartbeat's last good schema through transient config breakage.
2782
- async function compileConfig(appDir, { soft = false } = {}) {
2783
- try {
2784
- // Config-only apps (no SPA) always compile a manifest: the platform
2785
- // shell is their only renderer.
2786
- return await compileAppConfig(appDir, { forceManifest: isConfigOnlyApp(appDir) });
2787
- } catch (e) {
2788
- if (e instanceof CompileError) {
2789
- if (soft) {
2790
- console.log("schema: monty.config.ts doesn't compile right now — keeping the last good schema");
2791
- return null;
2792
- }
2793
- fail(e.code, e.fix);
2794
- }
2795
- throw e;
2796
- }
2797
- }
2798
-
2799
2770
  function run(cwd, label, argv, fixOnFail) {
2800
2771
  console.log(`${label}: ${argv.join(" ")}`);
2801
2772
  // stdin ignored: interactive prompts (e.g. shadcn's per-file overwrite
@@ -2917,10 +2888,7 @@ function resolveDataApp() {
2917
2888
  const root = findAppRoot(process.cwd());
2918
2889
  const slug = explicit ?? (root ? readSlugFromConfig(root) : null);
2919
2890
  if (!slug) {
2920
- fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
2921
- }
2922
- if (rest.includes("--studio")) {
2923
- fail("STUDIO_REMOVED", "The session sandbox is gone — every app has one set of records, and data verbs always target it. Drop --studio.");
2891
+ fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder.");
2924
2892
  }
2925
2893
  return slug;
2926
2894
  }
@@ -3056,33 +3024,17 @@ async function data() {
3056
3024
  const [verb, table, id] = dataPositionals();
3057
3025
 
3058
3026
  if (verb === "schema") {
3059
- // Shape comes from the LOCAL source checkout (compiled through the real
3060
- // pipeline) the same monty.config.ts that defines what the app stores.
3061
- const explicit = flag("app");
3062
- const root = explicit
3063
- ? (listLocalApps().find((a) => a.slug === explicit || a.id === explicit)?.path ?? null)
3064
- : findAppRoot(process.cwd());
3065
- if (!root) {
3066
- fail("APP_NOT_LOCAL", explicit
3067
- ? `No local source for "${explicit}" on this machine — run \`monty pull ${explicit}\` first, or cd into the app folder.`
3068
- : "Not inside an app folder. Pass --app <slug> (needs the source pulled locally) or cd into the app.");
3069
- }
3070
- let meta;
3071
- try {
3072
- meta = await compileAppConfig(root);
3073
- } catch (e) {
3074
- if (e instanceof CompileError) fail(e.code, e.fix);
3075
- throw e;
3076
- }
3077
- const tables = meta.schemaJson?.tables ?? {};
3078
- if (table !== undefined) {
3079
- if (!tables[table]) {
3080
- fail("NO_SUCH_TABLE", `App "${meta.slug}" has no table "${table}". Tables: ${Object.keys(tables).join(", ") || "(none)"}.`);
3081
- }
3082
- printJson({ app: meta.slug, table, schema: tables[table] });
3083
- } else {
3084
- printJson({ app: meta.slug, tables });
3085
- }
3027
+ // Shape comes from the WORKSPACE the stored schema the platform
3028
+ // validates against (derived from the app's config).
3029
+ const app = resolveDataApp();
3030
+ const auth = await workspaceAuth();
3031
+ const result = await callRecords(
3032
+ "query",
3033
+ "schema",
3034
+ table !== undefined ? { app, table } : { app },
3035
+ auth,
3036
+ );
3037
+ printJson({ app, ...result });
3086
3038
  return;
3087
3039
  }
3088
3040
 
@@ -3267,7 +3219,7 @@ async function data() {
3267
3219
  // The CLI accepts concise filters, then stores the shell's existing
3268
3220
  // { filters, sort, hidden } ViewConfig shape unchanged.
3269
3221
 
3270
- const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name"]);
3222
+ const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name", "kanban"]);
3271
3223
 
3272
3224
  function viewPositionals() {
3273
3225
  const out = [];
@@ -3285,10 +3237,11 @@ function viewPositionals() {
3285
3237
  function viewsUsage() {
3286
3238
  console.log("usage: monty views <list|set|update|remove> <table> [name] [flags]");
3287
3239
  console.log(" list <table> [--app slug]");
3288
- console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--app slug]");
3289
- console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--app slug]");
3240
+ console.log(" set <table> <name> [--filter '<json>'] [--sort field:asc|desc] [--hide field,...] [--kanban field|none] [--app slug]");
3241
+ console.log(" update <table> <name> [--name new-name] [--filter '<json>'] [--sort field:asc|desc|none] [--hide field,...] [--kanban field|none] [--app slug]");
3290
3242
  console.log(" remove <table> <name> [--app slug]");
3291
3243
  console.log("filter values: null = empty; scalar = exact; array = any listed value; {\"contains\":\"text\"}; {\"min\":0,\"max\":100}");
3244
+ console.log("kanban: the view renders as lanes over the select field's values; \"none\" makes it a table");
3292
3245
  console.log("example: monty views set leads \"Evaluate\" --filter '{\"pipelineId\":null}'");
3293
3246
  process.exit(1);
3294
3247
  }
@@ -3313,10 +3266,9 @@ function viewName(input) {
3313
3266
  async function viewContext(table) {
3314
3267
  const app = resolveDataApp();
3315
3268
  const auth = await workspaceAuth();
3316
- const manifest = await callConvex("query", "platform:appManifest", { slug: app }, auth);
3317
- if (!manifest) {
3318
- fail("NO_MANIFEST", `App "${app}" has no stored manifest, so it has no system table views.`);
3319
- }
3269
+ // Nothing stored yet is just an empty config the table lookup below
3270
+ // says what's actually missing.
3271
+ const manifest = (await callConvex("query", "platform:appManifest", { slug: app }, auth)) ?? { tables: {} };
3320
3272
  const tableSpec = manifest.tables?.[table];
3321
3273
  if (!tableSpec) {
3322
3274
  fail("NO_SUCH_TABLE", `App "${app}" has no table "${table}". Tables: ${Object.keys(manifest.tables ?? {}).join(", ") || "(none)"}.`);
@@ -3343,6 +3295,8 @@ function requestedViewConfig(fields) {
3343
3295
  sort: parseViewSort(flag("sort")),
3344
3296
  hidden: parseHiddenColumns(flag("hide")),
3345
3297
  };
3298
+ const kanban = parseKanbanFlag(flag("kanban"));
3299
+ if (kanban) Object.assign(config, kanban);
3346
3300
  return validateViewColumns(config, fields);
3347
3301
  } catch (error) {
3348
3302
  fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
@@ -3355,6 +3309,7 @@ function requestedViewPatch() {
3355
3309
  ...(rest.includes("--filter") ? { filters: normalizeViewFilters(parseJsonFlag("filter")) } : {}),
3356
3310
  ...(rest.includes("--sort") ? { sort: parseViewSort(flag("sort")) } : {}),
3357
3311
  ...(rest.includes("--hide") ? { hidden: parseHiddenColumns(flag("hide")) } : {}),
3312
+ ...(rest.includes("--kanban") ? { kanban: parseKanbanFlag(flag("kanban")) } : {}),
3358
3313
  };
3359
3314
  } catch (error) {
3360
3315
  fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
@@ -3405,7 +3360,7 @@ async function views() {
3405
3360
  }
3406
3361
  const patch = requestedViewPatch();
3407
3362
  if (nextName === name && Object.keys(patch).length === 0) {
3408
- fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, or --hide. Omitted properties stay unchanged.");
3363
+ fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, --hide, or --kanban. Omitted properties stay unchanged.");
3409
3364
  }
3410
3365
  const config = checkedViewConfig(mergeViewConfig(existing.config, patch), fields);
3411
3366
  await callConvex("mutation", "platform:saveView", { app, page: table, name: nextName, config }, auth);
@@ -3635,7 +3590,7 @@ if (command !== "dev" && command !== "logs" && command !== "support") {
3635
3590
  // The app's data half (tables, field algebra, metrics, settings, pages)
3636
3591
  // lives ONLY in the workspace. `monty schema [slug]` prints the stored
3637
3592
  // manifest as JSON (and stamps the CAS base); edit that JSON and
3638
- // `monty schema set <file|->` writes it back through the one landing —
3593
+ // `monty schema set '<json>'` (or `set -` piped) writes it back|->` writes it back through the one landing —
3639
3594
  // validated server-side, additive-only by default, CAS against what you
3640
3595
  // read. `monty schema pull` (legacy) regenerates monty.config.ts.
3641
3596
  async function schemaCmd() {
@@ -3664,24 +3619,28 @@ async function schemaCmd() {
3664
3619
  }
3665
3620
 
3666
3621
  if (verb === "set") {
3622
+ // The config is passed as JSON, never a file — nothing to leave behind
3623
+ // in the app folder. `-` reads stdin for configs too big for an arg.
3667
3624
  const target = rest[1];
3668
3625
  if (!target) {
3669
- fail("SCHEMA_USAGE", "Usage: monty schema set <file.json|-> [--allow-breaking] — the JSON is a full manifest (start from `monty schema`).");
3626
+ fail("SCHEMA_USAGE", "Usage: monty schema set '<json>' [--allow-breaking] — the app's full config as JSON (start from `monty schema`; pipe with `monty schema set -`).");
3670
3627
  }
3671
- let raw;
3672
- try {
3673
- raw = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
3674
- } catch {
3675
- fail("SCHEMA_USAGE", `Could not read ${target === "-" ? "stdin" : target}. Pass a manifest JSON file, or - for stdin.`);
3628
+ let raw = target;
3629
+ if (target === "-") {
3630
+ try {
3631
+ raw = readFileSync(0, "utf8");
3632
+ } catch {
3633
+ fail("SCHEMA_USAGE", "Could not read stdin. Pipe the config JSON in: `monty schema | <edit> | monty schema set -`.");
3634
+ }
3676
3635
  }
3677
3636
  let manifest;
3678
3637
  try {
3679
3638
  manifest = JSON.parse(raw);
3680
3639
  } catch {
3681
- fail("BAD_MANIFEST_JSON", "That is not valid JSON. Start from `monty schema` output, edit, and set the whole document back.");
3640
+ fail("BAD_CONFIG_JSON", "That is not valid JSON. Pass the whole config as one JSON argument (start from `monty schema` output), or pipe it with `monty schema set -`.");
3682
3641
  }
3683
3642
  const slug = typeof manifest?.slug === "string" && manifest.slug ? manifest.slug : (appDir ? readSlug(appDir) : null);
3684
- if (!slug) fail("INVALID_SLUG", "The manifest carries no slug and this is not an app folder — set `slug` in the JSON.");
3643
+ if (!slug) fail("INVALID_SLUG", "The config carries no slug and this is not an app folder — set `slug` in the JSON.");
3685
3644
  // CAS: prove which stored manifest this edit was based on (stamped by
3686
3645
  // the last `monty schema` read in this folder). Absent = trusting push.
3687
3646
  const base = appDir && readSlug(appDir) === slug ? readSchemaState(appDir)?.hash : undefined;
@@ -3700,7 +3659,7 @@ async function schemaCmd() {
3700
3659
  if (body?.code === "MANIFEST_DRIFT" && body?.summary) {
3701
3660
  console.log(`schema drift (remote changes):\n${body.summary}`);
3702
3661
  }
3703
- fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the manifest failed — is the Monty host reachable?");
3662
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Setting the config failed — is the Monty host reachable?");
3704
3663
  }
3705
3664
  if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3706
3665
  console.log(`schema: set — "${slug}" is live now (hash ${String(body.hash).slice(0, 12)})`);
@@ -3708,7 +3667,7 @@ async function schemaCmd() {
3708
3667
  }
3709
3668
 
3710
3669
  // Default: SHOW. `monty schema [slug]` — stdout is the pure manifest
3711
- // JSON (pipe it to a file, edit, `monty schema set` it back).
3670
+ // JSON (edit it, then `monty schema set` the whole document back).
3712
3671
  const slug = (verb && !verb.startsWith("-") ? verb : null) ?? (appDir ? readSlug(appDir) : null);
3713
3672
  if (!slug) fail("INVALID_SLUG", "Usage: monty schema [slug] — or run it inside an app folder.");
3714
3673
  const res = await fetch(`${host}/api/schema?slug=${slug}`, {
@@ -3716,13 +3675,17 @@ async function schemaCmd() {
3716
3675
  });
3717
3676
  const body = await res.json().catch(() => null);
3718
3677
  if (!res.ok || !body?.ok) {
3719
- fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the manifest — check the connection and `monty login`.");
3678
+ fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not read the config — check the connection and `monty login`.");
3720
3679
  }
3721
3680
  if (body.manifest === null) {
3722
- fail("NO_MANIFEST", `"${slug}" has no stored manifest yet it's a code-only app. Declare one by setting a full manifest JSON: monty schema set <file>.`);
3681
+ // Nothing stored yet is just an empty config hand it back so the
3682
+ // agent can fill it in and `monty schema set` it.
3683
+ console.error(`# ${slug} — empty config (nothing stored yet; "monty schema set" declares it)`);
3684
+ console.log(JSON.stringify({ slug, tables: {} }, null, 2));
3685
+ return;
3723
3686
  }
3724
3687
  if (appDir && readSlug(appDir) === slug) writeSchemaState(appDir, body.hash);
3725
- console.error(`# ${slug} — manifest hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3688
+ console.error(`# ${slug} — config hash ${String(body.hash).slice(0, 12)}${appDir ? " (CAS base stamped for `monty schema set`)" : ""}`);
3726
3689
  console.log(JSON.stringify(body.manifest, null, 2));
3727
3690
  }
3728
3691
 
@@ -3733,41 +3696,30 @@ switch (command) {
3733
3696
  case "create":
3734
3697
  await create();
3735
3698
  break;
3699
+ case "connect":
3700
+ await connect();
3701
+ break;
3736
3702
  case "pull":
3737
3703
  await pull();
3738
3704
  break;
3739
- case "commit":
3740
- fail("COMMIT_REMOVED", "`monty commit` is gone — `monty save` is the one verb (every save records a history row; `monty log` lists them).");
3741
- break;
3742
- case "log":
3743
- case "versions":
3705
+ case "history":
3744
3706
  await versionsLog();
3745
3707
  break;
3708
+ case "page":
3709
+ await pageCmd();
3710
+ break;
3746
3711
  case "dev":
3747
3712
  await dev();
3748
3713
  break;
3749
3714
  case "logs":
3750
3715
  await logs();
3751
3716
  break;
3752
- case "add":
3753
- await add();
3754
- break;
3755
3717
  case "components":
3756
- case "search":
3757
- components();
3758
- break;
3759
- case "docs":
3760
- await docs();
3718
+ await componentsCmd();
3761
3719
  break;
3762
3720
  case "current":
3763
3721
  current();
3764
3722
  break;
3765
- case "select":
3766
- select();
3767
- break;
3768
- case "apps":
3769
- apps();
3770
- break;
3771
3723
  case "skills":
3772
3724
  installSkills({ appDir: findAppRoot(process.cwd()), silent: false, force: true });
3773
3725
  console.log("skills: up to date");
@@ -3782,7 +3734,6 @@ switch (command) {
3782
3734
  typecheckApp();
3783
3735
  break;
3784
3736
  case "save":
3785
- case "deploy":
3786
3737
  await deploy();
3787
3738
  break;
3788
3739
  case "data":
@@ -3800,29 +3751,42 @@ switch (command) {
3800
3751
  case "secret":
3801
3752
  await secret();
3802
3753
  break;
3754
+ case "help":
3755
+ case "--help":
3756
+ case "-h":
3757
+ printHelp();
3758
+ process.exit(0);
3759
+ break;
3803
3760
  default:
3804
- console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|schema|views|support|skills>");
3805
- console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
3806
- console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
3807
- console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
3808
- console.log(" log [slug] the app's source version history (one row per save)");
3809
- console.log(" dev [--port N] [--no-tunnel] [--takeover] run the app's session, or attach to a running one (live data, auto-auth)");
3810
- console.log(" logs [-n N] [-f] read/follow the dev shell log (vite output, browser errors, save results)");
3811
- console.log(" add <name...> | page <name> install UI components, or scaffold a custom page (`monty add page reports`)");
3812
- console.log(" components [query] list the curated component catalog");
3813
- console.log(" docs <name> view a component's source before installing");
3814
- console.log(" current which app folder am I in?");
3815
- console.log(" select <slug> print an app's folder — cd \"$(monty select x)\"");
3816
- console.log(" apps list local apps (~/.monty/apps + legacy ~/Monty)");
3817
- console.log(" install install app dependencies");
3818
- console.log(" build production build (vite, via monty)");
3819
- console.log(" typecheck typecheck (builds first if needed)");
3820
- console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main` (build + typecheck gate it)");
3821
- console.log(" deploy alias of save");
3822
- console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
3823
- console.log(" schema [slug] | set <file|-> read the app's stored manifest (JSON on stdout) / write it back (validated, CAS)");
3824
- console.log(" views <list|set|update|remove> <table> manage shared saved views on a system record page");
3825
- console.log(" support <status|enable|disable|submit> opt in and send agent-authored platform reports to Monty");
3826
- console.log(" skills install/refresh the agent build skill");
3761
+ printHelp();
3827
3762
  process.exit(command ? 1 : 0);
3828
3763
  }
3764
+
3765
+ function printHelp() {
3766
+ console.log("usage: monty <command> (`monty help` shows this)");
3767
+ console.log("");
3768
+ console.log("start here:");
3769
+ console.log(" login [--host <url>] sign in (opens your browser to authorize)");
3770
+ console.log(" connect <slug> [dir] pull a cloud app into any folder, ready for `monty dev`");
3771
+ console.log(" create <slug> [--name N] [--spa] register a brand-new app (config-only by default)");
3772
+ console.log(" dev [--port N] [--takeover] run the app's session — live data, the workspace follows it");
3773
+ console.log(" save [\"what changed\"] push the working copy to the cloud copy, like `git push main`");
3774
+ console.log(" logs [-n N] [-f] read/follow the running session's log");
3775
+ console.log("");
3776
+ console.log("toolbelt:");
3777
+ console.log(" current which app folder am I in?");
3778
+ console.log(" history [slug] saved-version history, one row per save (like `git log`)");
3779
+ console.log(" pull <slug> [--version H] [--force] restore a source snapshot into the managed home");
3780
+ console.log(" install / build / typecheck app lifecycle (pnpm/npm picked for you)");
3781
+ console.log(" data <verb> [table] [flags] read/write an app's records from the terminal");
3782
+ console.log(" schema [slug] | set <json|-> read/write the app's config, stored in the workspace (validated, CAS)");
3783
+ console.log(" views <list|set|update|remove> <table> manage shared saved views on a record page");
3784
+ console.log(" secret <set|rm> <KEY> per-app server-function secrets (write-only)");
3785
+ console.log(" support <status|enable|disable|submit> opt in to agent-authored platform reports");
3786
+ console.log(" skills install/refresh the agent build skill");
3787
+ console.log("");
3788
+ console.log("custom pages & components (apps with src/):");
3789
+ console.log(" page add <name> scaffold a custom (bespoke) page");
3790
+ console.log(" components [query] | search <q> the curated shadcn catalog — list or search it");
3791
+ console.log(" components add <name...> | docs <name> install curated components / read one's source first");
3792
+ }