@montytools/cli 0.5.1 → 0.5.3

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
@@ -18,6 +18,7 @@ import { CATALOG, REGISTRIES } from "./catalog.mjs";
18
18
  import { CompileError, 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
22
 
22
23
  // MONTY_HOME overrides the state root (default ~/.monty): config.json,
23
24
  // apps/, and desktop.json all live under it. This is how a second, isolated
@@ -675,6 +676,12 @@ tables (zod), derived fields (\`rollup\`/\`lookup\`/\`formula\`), \`metrics\`,
675
676
  - Formulas are strings in the Monty expression grammar, e.g.
676
677
  \`formula(montyMoney(), "monthlySales * commissionRate")\` — fields declared
677
678
  ABOVE the formula and \`metrics.<name>\` are in scope.
679
+ - A filtered or sorted slice of one table is a shared saved view. Use
680
+ \`monty views set <table> <name> ...\` before adding custom code. A custom
681
+ page is for interactions, multi-table layouts, or visualizations that a
682
+ system record page cannot express. If the page is still one table, start
683
+ with \`RecordPage\` from \`@montytools/sdk/react\` and add typed actions
684
+ with the record controls from \`@montytools/sdk/ui\`.
678
685
  - Need a bespoke page later? \`monty add page\` declares it and upgrades this
679
686
  app with a SPA scaffold; \`monty save\` ships the code.
680
687
  `;
@@ -984,7 +991,7 @@ async function freePort(start) {
984
991
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
985
992
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
986
993
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
987
- const MIN_SDK = "0.2.1";
994
+ const MIN_SDK = "0.2.3";
988
995
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
989
996
 
990
997
  function installedSdkVersion(appDir) {
@@ -1312,7 +1319,7 @@ async function performTakeover(appDir, s) {
1312
1319
  spawnSync("taskkill", ["/pid", String(s.pid), "/T", "/F"], { stdio: "ignore" });
1313
1320
  if (Number.isInteger(s.vitePid)) spawnSync("taskkill", ["/pid", String(s.vitePid), "/T", "/F"], { stdio: "ignore" });
1314
1321
  } else if (!signalPid(s.pid, "SIGTERM")) {
1315
- fail("TAKEOVER_FAILED", `The running session (pid ${s.pid}) belongs to another user. Stop it manually, then rerun \`monty dev\`.`);
1322
+ fail("TAKEOVER_FAILED", `The running session (pid ${s.pid}) belongs to another user. Stop it from the terminal or Desktop instance that started it, then rerun \`monty dev\`. If that owner is unavailable, check \`monty support status\` and submit the incident when sharing is enabled.`);
1316
1323
  }
1317
1324
  let ok = await waitGone(TAKEOVER_WAIT_MS);
1318
1325
  if (!ok && process.platform !== "win32") {
@@ -2874,7 +2881,7 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
2874
2881
  // API. `app` is the plain slug — every app has ONE set of records.
2875
2882
  // Results are ONE JSON document on stdout so agents can pipe.
2876
2883
 
2877
- const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
2884
+ const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host", "name", "type", "out", "table", "record", "field"]);
2878
2885
 
2879
2886
  // rest, minus flags AND their values — `monty data list leads --app crm`
2880
2887
  // must not read "crm" as a positional. Boolean flags have no value and
@@ -2920,9 +2927,9 @@ function resolveDataApp() {
2920
2927
 
2921
2928
  // mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
2922
2929
  // in 5 minutes — minted fresh per invocation, never stored.
2923
- async function dataAuth() {
2930
+ async function workspaceAuth() {
2924
2931
  const { host, key } = loadConfig();
2925
- if (!key) fail("NOT_LOGGED_IN", `Reading and writing app data needs your workspace (${host}). Run \`monty login\` first.`);
2932
+ if (!key) fail("NOT_LOGGED_IN", `This command needs your workspace (${host}). Run \`monty login\` first.`);
2926
2933
  let convexUrl = null;
2927
2934
  try {
2928
2935
  convexUrl = (await fetch(`${host}/api/config`).then((r) => r.json()))?.convexUrl;
@@ -2936,20 +2943,20 @@ async function dataAuth() {
2936
2943
  if (!r.ok || !body?.token) {
2937
2944
  fail(body?.code ?? `HTTP_${r.status}`, body?.fix ?? "Minting a workspace token failed. Run `monty login`, then retry.");
2938
2945
  }
2939
- return { convexUrl, token: body.token };
2946
+ return { host, convexUrl, token: body.token };
2940
2947
  }
2941
2948
 
2942
2949
  // One records function over Convex's public HTTP API (plain-JSON format —
2943
2950
  // app data is JSON by construction, no convex encoding needed). ConvexError
2944
2951
  // payloads carry { code, fix } and surface verbatim: errors stay
2945
2952
  // instructions in the terminal the agent is watching.
2946
- async function callRecords(kind, fn, args, auth) {
2953
+ async function callConvex(kind, path, args, auth) {
2947
2954
  let body = null;
2948
2955
  try {
2949
2956
  const r = await fetch(`${auth.convexUrl}/api/${kind}`, {
2950
2957
  method: "POST",
2951
2958
  headers: { "content-type": "application/json", authorization: `Bearer ${auth.token}` },
2952
- body: JSON.stringify({ path: `records:${fn}`, args, format: "json" }),
2959
+ body: JSON.stringify({ path, args, format: "json" }),
2953
2960
  });
2954
2961
  body = await r.json().catch(() => null);
2955
2962
  } catch { /* handled below */ }
@@ -2962,6 +2969,10 @@ async function callRecords(kind, fn, args, auth) {
2962
2969
  return body.value;
2963
2970
  }
2964
2971
 
2972
+ async function callRecords(kind, fn, args, auth) {
2973
+ return callConvex(kind, `records:${fn}`, args, auth);
2974
+ }
2975
+
2965
2976
  function printJson(value) {
2966
2977
  console.log(JSON.stringify(value, null, 2));
2967
2978
  }
@@ -2990,10 +3001,57 @@ function dataUsage() {
2990
3001
  console.log(" update <table> <id> --data '<json>' [--unset field,field]");
2991
3002
  console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
2992
3003
  console.log(" remove <table> <id>");
3004
+ console.log(" upload <path> [--name N] [--type mime] store a file (10MB cap); prints the descriptor to put in a `file` field");
3005
+ console.log(" [--table <t> --record <id> --field <f>] …and set that row's field in the same command");
3006
+ console.log(" download <file-id> [--out path] fetch a stored file's bytes (id from a row's file field)");
2993
3007
  console.log("target: --app <slug> (or run inside the app folder)");
2994
3008
  process.exit(1);
2995
3009
  }
2996
3010
 
3011
+ // Uploads without --type get the MIME their extension implies; unknown
3012
+ // extensions stay application/octet-stream (the server stores, never sniffs).
3013
+ const MIME_BY_EXT = {
3014
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
3015
+ webp: "image/webp", svg: "image/svg+xml", ico: "image/x-icon",
3016
+ pdf: "application/pdf", json: "application/json", csv: "text/csv",
3017
+ txt: "text/plain", md: "text/markdown", html: "text/html",
3018
+ zip: "application/zip", mp3: "audio/mpeg", wav: "audio/wav",
3019
+ mp4: "video/mp4", webm: "video/webm",
3020
+ };
3021
+
3022
+ function inferContentType(name) {
3023
+ const ext = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase();
3024
+ return (ext && MIME_BY_EXT[ext]) || "application/octet-stream";
3025
+ }
3026
+
3027
+ const DATA_MAX_FILE_BYTES = 10 * 1024 * 1024;
3028
+
3029
+ // POST/GET on the host's /api/files — the same rail the SDK file helpers
3030
+ // ride, authorized with the invocation's 5-minute workspace token.
3031
+ async function callFiles(method, params, auth, app, opts = {}) {
3032
+ const url = new URL("/api/files", auth.host);
3033
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
3034
+ let r;
3035
+ try {
3036
+ r = await fetch(url, {
3037
+ method,
3038
+ headers: {
3039
+ authorization: `Bearer ${auth.token}`,
3040
+ "x-monty-app": app,
3041
+ ...(opts.contentType ? { "content-type": opts.contentType } : {}),
3042
+ },
3043
+ ...(opts.body !== undefined ? { body: opts.body } : {}),
3044
+ });
3045
+ } catch {
3046
+ fail("HOST_UNREACHABLE", `The file endpoint at ${auth.host} did not answer — check the network and retry.`);
3047
+ }
3048
+ if (!r.ok) {
3049
+ const err = await r.json().catch(() => null);
3050
+ fail(err?.code ?? `HTTP_${r.status}`, err?.fix ?? "The file request failed. Retry; if it persists, report it.");
3051
+ }
3052
+ return r;
3053
+ }
3054
+
2997
3055
  async function data() {
2998
3056
  const [verb, table, id] = dataPositionals();
2999
3057
 
@@ -3028,12 +3086,88 @@ async function data() {
3028
3086
  return;
3029
3087
  }
3030
3088
 
3089
+ if (verb === "upload") {
3090
+ // The positional is a file PATH, not a table — the descriptor this
3091
+ // prints is what a `file`-typed field stores; --table/--record/--field
3092
+ // set it on an existing row in the same command.
3093
+ const path = table;
3094
+ if (!path) fail("MISSING_PATH", "Usage: monty data upload <path> [--name N] [--type mime] [--table <t> --record <id> --field <f>].");
3095
+ const attachTable = flag("table");
3096
+ const attachRecord = flag("record");
3097
+ const attachField = flag("field");
3098
+ const attachFlags = [attachTable, attachRecord, attachField].filter((f) => f !== undefined);
3099
+ if (attachFlags.length > 0 && attachFlags.length < 3) {
3100
+ fail("BAD_ATTACH", "Attaching needs all three of --table, --record, and --field — or none (then put the printed descriptor in a file field yourself).");
3101
+ }
3102
+ let bytes;
3103
+ try {
3104
+ bytes = readFileSync(path);
3105
+ } catch {
3106
+ fail("NO_SUCH_FILE", `Could not read "${path}" — check the path.`);
3107
+ }
3108
+ if (bytes.byteLength === 0) fail("EMPTY_FILE", `"${path}" is empty — nothing to upload.`);
3109
+ if (bytes.byteLength > DATA_MAX_FILE_BYTES) {
3110
+ fail("FILE_TOO_LARGE", `Files are limited to ${DATA_MAX_FILE_BYTES} bytes; "${path}" is ${bytes.byteLength}. Compress it first.`);
3111
+ }
3112
+ const name = flag("name") ?? basename(path);
3113
+ const contentType = flag("type") ?? inferContentType(name);
3114
+ const app = resolveDataApp();
3115
+ const auth = await workspaceAuth();
3116
+ if (attachFlags.length === 3) {
3117
+ // Prove the target row exists BEFORE storing bytes — a failed attach
3118
+ // after the upload would orphan the file.
3119
+ const row = await callRecords("query", "get", { app, table: attachTable, id: attachRecord }, auth);
3120
+ if (!row) fail("NOT_FOUND", `No record "${attachRecord}" in table "${attachTable}" — ids come from \`monty data list ${attachTable}\`.`);
3121
+ }
3122
+ const res = await callFiles("POST", { name }, auth, app, { body: bytes, contentType });
3123
+ const file = await res.json();
3124
+ if (attachFlags.length === 3) {
3125
+ await callRecords("mutation", "update", {
3126
+ app,
3127
+ table: attachTable,
3128
+ id: attachRecord,
3129
+ data: { [attachField]: file },
3130
+ }, auth);
3131
+ printJson({ ok: true, id: attachRecord, field: attachField, file });
3132
+ } else {
3133
+ printJson({ file });
3134
+ }
3135
+ return;
3136
+ }
3137
+
3138
+ if (verb === "download") {
3139
+ // The positional is the file id (a row's file-field descriptor carries
3140
+ // it), not a table.
3141
+ const fileId = table;
3142
+ if (!fileId) fail("MISSING_ID", "Usage: monty data download <file-id> [--out path] — ids come from a row's file field (its `id` key).");
3143
+ const app = resolveDataApp();
3144
+ const auth = await workspaceAuth();
3145
+ const res = await callFiles("GET", { id: fileId }, auth, app);
3146
+ const disposition = res.headers.get("content-disposition") ?? "";
3147
+ const remoteName = /filename="([^"]+)"/.exec(disposition)?.[1] ?? fileId;
3148
+ const out = flag("out") ?? remoteName;
3149
+ const body = Buffer.from(await res.arrayBuffer());
3150
+ try {
3151
+ writeFileSync(out, body);
3152
+ } catch {
3153
+ fail("WRITE_FAILED", `Could not write to "${out}" — check the path and permissions.`);
3154
+ }
3155
+ printJson({
3156
+ ok: true,
3157
+ path: out,
3158
+ name: remoteName,
3159
+ contentType: res.headers.get("content-type") ?? "application/octet-stream",
3160
+ size: body.byteLength,
3161
+ });
3162
+ return;
3163
+ }
3164
+
3031
3165
  const VERBS = new Set(["list", "get", "insert", "update", "upsert", "remove"]);
3032
3166
  if (!verb || !VERBS.has(verb)) dataUsage();
3033
3167
  if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
3034
3168
 
3035
3169
  const app = resolveDataApp();
3036
- const auth = await dataAuth();
3170
+ const auth = await workspaceAuth();
3037
3171
 
3038
3172
  switch (verb) {
3039
3173
  case "list": {
@@ -3127,12 +3261,373 @@ async function data() {
3127
3261
  }
3128
3262
  }
3129
3263
 
3264
+ // ── monty views ────────────────────────────────────────────────────────────
3265
+ // Shared saved views are shell state, not app code. Agents manage them
3266
+ // through the same workspace-scoped platform functions the record page uses.
3267
+ // The CLI accepts concise filters, then stores the shell's existing
3268
+ // { filters, sort, hidden } ViewConfig shape unchanged.
3269
+
3270
+ const VIEW_VALUE_FLAGS = new Set(["app", "filter", "sort", "hide", "name"]);
3271
+
3272
+ function viewPositionals() {
3273
+ const out = [];
3274
+ for (let i = 0; i < rest.length; i++) {
3275
+ const arg = rest[i];
3276
+ if (arg.startsWith("--")) {
3277
+ if (VIEW_VALUE_FLAGS.has(arg.slice(2))) i++;
3278
+ continue;
3279
+ }
3280
+ out.push(arg);
3281
+ }
3282
+ return out;
3283
+ }
3284
+
3285
+ function viewsUsage() {
3286
+ console.log("usage: monty views <list|set|update|remove> <table> [name] [flags]");
3287
+ 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]");
3290
+ console.log(" remove <table> <name> [--app slug]");
3291
+ console.log("filter values: null = empty; scalar = exact; array = any listed value; {\"contains\":\"text\"}; {\"min\":0,\"max\":100}");
3292
+ console.log("example: monty views set leads \"Evaluate\" --filter '{\"pipelineId\":null}'");
3293
+ process.exit(1);
3294
+ }
3295
+
3296
+ function validateViewFlagValues() {
3297
+ for (const name of VIEW_VALUE_FLAGS) {
3298
+ const index = rest.indexOf(`--${name}`);
3299
+ if (index >= 0 && (rest[index + 1] === undefined || rest[index + 1].startsWith("--"))) {
3300
+ fail("MISSING_FLAG_VALUE", `--${name} needs a value.`);
3301
+ }
3302
+ }
3303
+ }
3304
+
3305
+ function viewName(input) {
3306
+ const name = input?.trim() ?? "";
3307
+ if (name === "" || name.length > 60) {
3308
+ fail("BAD_VIEW_NAME", "A saved view name must be 1–60 characters.");
3309
+ }
3310
+ return name;
3311
+ }
3312
+
3313
+ async function viewContext(table) {
3314
+ const app = resolveDataApp();
3315
+ 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
+ }
3320
+ const tableSpec = manifest.tables?.[table];
3321
+ if (!tableSpec) {
3322
+ fail("NO_SUCH_TABLE", `App "${app}" has no table "${table}". Tables: ${Object.keys(manifest.tables ?? {}).join(", ") || "(none)"}.`);
3323
+ }
3324
+ const declaredPages = Object.values(manifest.pages ?? {});
3325
+ const hasTableView =
3326
+ declaredPages.length === 0 ||
3327
+ declaredPages.some((page) => page?.kind === "view" && page.table === table);
3328
+ if (!hasTableView) {
3329
+ fail("NO_TABLE_VIEW", `Table "${table}" is not shown by a system record page. Add a { kind: "view", table: "${table}" } page through \`monty schema\`, then save the view.`);
3330
+ }
3331
+ return {
3332
+ app,
3333
+ auth,
3334
+ fields: Object.keys(tableSpec.fields ?? {}),
3335
+ };
3336
+ }
3337
+
3338
+ function requestedViewConfig(fields) {
3339
+ let config;
3340
+ try {
3341
+ config = {
3342
+ filters: normalizeViewFilters(parseJsonFlag("filter")),
3343
+ sort: parseViewSort(flag("sort")),
3344
+ hidden: parseHiddenColumns(flag("hide")),
3345
+ };
3346
+ return validateViewColumns(config, fields);
3347
+ } catch (error) {
3348
+ fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
3349
+ }
3350
+ }
3351
+
3352
+ function requestedViewPatch() {
3353
+ try {
3354
+ return {
3355
+ ...(rest.includes("--filter") ? { filters: normalizeViewFilters(parseJsonFlag("filter")) } : {}),
3356
+ ...(rest.includes("--sort") ? { sort: parseViewSort(flag("sort")) } : {}),
3357
+ ...(rest.includes("--hide") ? { hidden: parseHiddenColumns(flag("hide")) } : {}),
3358
+ };
3359
+ } catch (error) {
3360
+ fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
3361
+ }
3362
+ }
3363
+
3364
+ function checkedViewConfig(config, fields) {
3365
+ try {
3366
+ return validateViewColumns(config, fields);
3367
+ } catch (error) {
3368
+ fail(error?.code ?? "BAD_VIEW_CONFIG", error?.fix ?? error?.message ?? "The saved view config is invalid.");
3369
+ }
3370
+ }
3371
+
3372
+ async function views() {
3373
+ validateViewFlagValues();
3374
+ const [verb, table, rawName] = viewPositionals();
3375
+ if (!verb || !table || rest.includes("--help") || rest.includes("-h")) viewsUsage();
3376
+ if (!["list", "set", "update", "remove"].includes(verb)) viewsUsage();
3377
+ const { app, auth, fields } = await viewContext(table);
3378
+
3379
+ if (verb === "list") {
3380
+ const saved = await callConvex("query", "platform:savedViews", { app, page: table }, auth);
3381
+ printJson({ app, table, views: saved });
3382
+ return;
3383
+ }
3384
+
3385
+ const name = viewName(rawName);
3386
+ const saved = await callConvex("query", "platform:savedViews", { app, page: table }, auth);
3387
+ const existing = saved.find((view) => view.name === name);
3388
+
3389
+ if (verb === "remove") {
3390
+ if (!existing) {
3391
+ fail("VIEW_NOT_FOUND", `No saved view named "${name}" exists for ${table}. Run \`monty views list ${table}\` to see the names.`);
3392
+ }
3393
+ await callConvex("mutation", "platform:deleteView", { app, page: table, name }, auth);
3394
+ printJson({ ok: true, app, table, name, removed: true });
3395
+ return;
3396
+ }
3397
+
3398
+ if (verb === "update") {
3399
+ if (!existing) {
3400
+ fail("VIEW_NOT_FOUND", `No saved view named "${name}" exists for ${table}. Run \`monty views list ${table}\` to see the names.`);
3401
+ }
3402
+ const nextName = flag("name") === undefined ? name : viewName(flag("name"));
3403
+ if (nextName !== name && saved.some((view) => view.name === nextName)) {
3404
+ fail("VIEW_ALREADY_EXISTS", `A saved view named "${nextName}" already exists for ${table}. Choose another name or update that view directly.`);
3405
+ }
3406
+ const patch = requestedViewPatch();
3407
+ if (nextName === name && Object.keys(patch).length === 0) {
3408
+ fail("NO_VIEW_CHANGES", "Provide --name, --filter, --sort, or --hide. Omitted properties stay unchanged.");
3409
+ }
3410
+ const config = checkedViewConfig(mergeViewConfig(existing.config, patch), fields);
3411
+ await callConvex("mutation", "platform:saveView", { app, page: table, name: nextName, config }, auth);
3412
+ if (nextName !== name) {
3413
+ await callConvex("mutation", "platform:deleteView", { app, page: table, name }, auth);
3414
+ }
3415
+ printJson({
3416
+ ok: true,
3417
+ app,
3418
+ table,
3419
+ name: nextName,
3420
+ updated: true,
3421
+ renamed: nextName !== name,
3422
+ ...(nextName !== name ? { previousName: name } : {}),
3423
+ config,
3424
+ });
3425
+ return;
3426
+ }
3427
+
3428
+ const config = requestedViewConfig(fields);
3429
+ await callConvex("mutation", "platform:saveView", { app, page: table, name, config }, auth);
3430
+ printJson({ ok: true, app, table, name, created: !existing, config });
3431
+ }
3432
+
3433
+ // ── monty support ─────────────────────────────────────────────────────────
3434
+ // Workspace admins opt in once. After that, agents may send one structured
3435
+ // report for a persistent platform failure or missing platform capability.
3436
+ // The CLI adds selected local diagnostics, but never reads source files,
3437
+ // environment variables, app records, or a raw conversation transcript.
3438
+ function supportUsage() {
3439
+ console.log("usage: monty support <status|enable|disable|submit>");
3440
+ console.log(" status show whether agent support reports are allowed");
3441
+ console.log(" enable [--yes] allow agents to send bounded reports to Monty");
3442
+ console.log(" disable stop all future reports immediately");
3443
+ console.log(" submit --data '<json|->' send an incident, unexpected behavior, or missing feature");
3444
+ console.log('report JSON: {"kind":"incident|unexpected_behavior|missing_feature","title":"…","summary":"…","problem":"…","expected":"…","attempted":["…"],"error":"…"}');
3445
+ process.exit(1);
3446
+ }
3447
+
3448
+ function redactSupportText(value) {
3449
+ return value
3450
+ .replaceAll(homedir(), "~")
3451
+ .replace(/mk_[0-9a-f]{48}/gi, "[REDACTED_CLI_KEY]")
3452
+ .replace(/\bBearer\s+[^\s"']+/gi, "Bearer [REDACTED]")
3453
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED_JWT]")
3454
+ .replace(/([?&](?:ticket|token|key)=)[^&\s]+/gi, "$1[REDACTED]")
3455
+ .replace(/\b([A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY))=\S+/g, "$1=[REDACTED]");
3456
+ }
3457
+
3458
+ async function supportDiagnostics(appDir, host, includeLogs) {
3459
+ let session;
3460
+ let errorLog;
3461
+ if (appDir) {
3462
+ const probe = await checkDevSession(appDir);
3463
+ const recorded = probe.session;
3464
+ if (recorded) {
3465
+ session = JSON.stringify({
3466
+ live: probe.live,
3467
+ ...(probe.reason ? { reason: probe.reason } : {}),
3468
+ state: recorded.state ?? null,
3469
+ cli: recorded.cli ?? null,
3470
+ pid: Number.isInteger(recorded.pid) ? recorded.pid : null,
3471
+ port: Number.isInteger(recorded.port) ? recorded.port : null,
3472
+ sessionId: typeof recorded.sessionId === "string" ? recorded.sessionId : null,
3473
+ startedAt: recorded.startedAt ?? null,
3474
+ updatedAt: recorded.updatedAt ?? null,
3475
+ loggedIn: recorded.loggedIn ?? null,
3476
+ lastHeartbeatAt: recorded.lastHeartbeatAt ?? null,
3477
+ });
3478
+ }
3479
+ if (includeLogs) {
3480
+ const diagnosticLines = readLogTail(appDir, 240)
3481
+ .filter((line) => /\b(error|warn|failed|failure|takeover|superseded|drift)\b|\[MontyError\]/i.test(line))
3482
+ .slice(-80);
3483
+ if (diagnosticLines.length) {
3484
+ errorLog = redactSupportText(diagnosticLines.join("\n")).slice(-12_000);
3485
+ }
3486
+ }
3487
+ }
3488
+ return {
3489
+ cliVersion: CLI_VERSION,
3490
+ nodeVersion: process.version,
3491
+ platform: process.platform,
3492
+ arch: process.arch,
3493
+ host,
3494
+ ...(session ? { session: redactSupportText(session) } : {}),
3495
+ ...(errorLog ? { errorLog } : {}),
3496
+ };
3497
+ }
3498
+
3499
+ async function supportRequest(method, payload) {
3500
+ const { host, key } = loadConfig();
3501
+ if (!key) {
3502
+ fail("NOT_LOGGED_IN", `Support settings and reports need your workspace (${host}). Run \`monty login\` first.`);
3503
+ }
3504
+ let response;
3505
+ try {
3506
+ response = await fetch(`${host}/api/support`, {
3507
+ method,
3508
+ headers: {
3509
+ authorization: `Bearer ${key}`,
3510
+ ...(payload === undefined ? {} : { "content-type": "application/json" }),
3511
+ },
3512
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
3513
+ signal: AbortSignal.timeout(15_000),
3514
+ });
3515
+ } catch {
3516
+ fail("SUPPORT_UNREACHABLE", `Could not reach ${host}/api/support. Check the network and retry.`);
3517
+ }
3518
+ const body = await response.json().catch(() => null);
3519
+ if (!response.ok || !body?.ok) {
3520
+ fail(body?.code ?? `HTTP_${response.status}`, body?.fix ?? "The support request failed. Retry once.");
3521
+ }
3522
+ return { host, body };
3523
+ }
3524
+
3525
+ async function support() {
3526
+ const verb = rest[0];
3527
+ if (!verb || rest.includes("--help") || rest.includes("-h")) supportUsage();
3528
+
3529
+ if (verb === "status") {
3530
+ const { body } = await supportRequest("GET");
3531
+ console.log(`support sharing: ${body.enabled ? "enabled" : "disabled"}`);
3532
+ if (body.enabledAt) console.log(`enabled at: ${new Date(body.enabledAt).toISOString()}`);
3533
+ return;
3534
+ }
3535
+
3536
+ if (verb === "enable") {
3537
+ console.log("Monty support sharing lets coding agents report persistent Monty platform errors and missing platform features.");
3538
+ console.log("Reports include: an agent-written summary, tool identity, CLI and session metadata, and selected recent error or warning lines with known secrets redacted.");
3539
+ console.log("Error lines may contain names or values that a tool printed. Agents can omit them per report with includeLogs: false.");
3540
+ console.log("The command does not read source files, environment variables, tool records, or the full conversation.");
3541
+ let approved = rest.includes("--yes");
3542
+ if (!approved && process.stdin.isTTY && process.stdout.isTTY) {
3543
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3544
+ approved = /^y(?:es)?$/i.test((await rl.question("Allow these reports for this workspace? [y/N] ")).trim());
3545
+ rl.close();
3546
+ }
3547
+ if (!approved) {
3548
+ fail("SUPPORT_CONSENT_REQUIRED", "A person must approve support sharing. Review the notice above, ask the workspace admin, then rerun `monty support enable --yes` only after they agree.");
3549
+ }
3550
+ await supportRequest("PUT", { enabled: true });
3551
+ console.log("support sharing: enabled");
3552
+ return;
3553
+ }
3554
+
3555
+ if (verb === "disable") {
3556
+ await supportRequest("PUT", { enabled: false });
3557
+ console.log("support sharing: disabled");
3558
+ return;
3559
+ }
3560
+
3561
+ if (verb !== "submit") supportUsage();
3562
+
3563
+ const rawData = flag("data");
3564
+ if (rawData === undefined) {
3565
+ fail("SUPPORT_REPORT_REQUIRED", "Pass the agent summary with `monty support submit --data '<json>'`. Use `--data -` to read JSON from stdin.");
3566
+ }
3567
+ let input;
3568
+ try {
3569
+ const raw = rawData === "-" ? readFileSync(0, "utf8") : rawData;
3570
+ input = JSON.parse(raw);
3571
+ } catch {
3572
+ fail("BAD_SUPPORT_REPORT", "The support report is not valid JSON. Pass one object with kind, title, summary, problem, expected, and optional attempted/error fields.");
3573
+ }
3574
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
3575
+ fail("BAD_SUPPORT_REPORT", "The support report must be one JSON object.");
3576
+ }
3577
+ const kinds = new Set(["incident", "unexpected_behavior", "missing_feature"]);
3578
+ if (!kinds.has(input.kind)) {
3579
+ fail("BAD_SUPPORT_REPORT", "kind must be `incident`, `unexpected_behavior`, or `missing_feature`.");
3580
+ }
3581
+ for (const field of ["title", "summary", "problem", "expected"]) {
3582
+ if (typeof input[field] !== "string" || !input[field].trim()) {
3583
+ fail("BAD_SUPPORT_REPORT", `${field} must be a non-empty string.`);
3584
+ }
3585
+ }
3586
+ if (input.attempted !== undefined && (!Array.isArray(input.attempted) || input.attempted.some((item) => typeof item !== "string"))) {
3587
+ fail("BAD_SUPPORT_REPORT", "attempted must be an array of short strings.");
3588
+ }
3589
+ if (input.error !== undefined && typeof input.error !== "string") {
3590
+ fail("BAD_SUPPORT_REPORT", "error must be a string when provided.");
3591
+ }
3592
+ if (input.includeLogs !== undefined && typeof input.includeLogs !== "boolean") {
3593
+ fail("BAD_SUPPORT_REPORT", "includeLogs must be true or false when provided.");
3594
+ }
3595
+
3596
+ const appDir = findAppRoot(process.cwd());
3597
+ const { host } = loadConfig();
3598
+ const appSlug = typeof input.appSlug === "string"
3599
+ ? input.appSlug
3600
+ : appDir
3601
+ ? readSlugFromConfig(appDir)
3602
+ : undefined;
3603
+ const report = {
3604
+ ...(appSlug ? { appSlug } : {}),
3605
+ kind: input.kind,
3606
+ title: redactSupportText(input.title.trim()),
3607
+ threadSummary: redactSupportText(input.summary.trim()),
3608
+ problem: redactSupportText(input.problem.trim()),
3609
+ expected: redactSupportText(input.expected.trim()),
3610
+ attempted: (input.attempted ?? [])
3611
+ .map((item) => redactSupportText(item.trim()))
3612
+ .filter(Boolean),
3613
+ ...(input.error?.trim() ? { error: redactSupportText(input.error.trim()) } : {}),
3614
+ diagnostics: await supportDiagnostics(
3615
+ appDir,
3616
+ host,
3617
+ input.includeLogs ?? input.kind !== "missing_feature",
3618
+ ),
3619
+ };
3620
+ const { body } = await supportRequest("POST", report);
3621
+ console.log(`support ticket: ${body.ticketId}`);
3622
+ console.log("support: submitted to Monty");
3623
+ }
3624
+
3130
3625
  // ── dispatch ───────────────────────────────────────────────────────────────
3131
3626
  // Keep agent skills fresh on every invocation (user level + current app).
3132
3627
  // `dev` and `logs` skip the refresh here: attach and log reads must stay
3133
3628
  // fast (a skills refresh can shell out to npx for two minutes) — dev's
3134
3629
  // fresh-start path installs skills itself once it owns the session.
3135
- if (command !== "dev" && command !== "logs") {
3630
+ if (command !== "dev" && command !== "logs" && command !== "support") {
3136
3631
  installSkills({ appDir: findAppRoot(process.cwd()) });
3137
3632
  }
3138
3633
 
@@ -3296,11 +3791,17 @@ switch (command) {
3296
3791
  case "schema":
3297
3792
  await schemaCmd();
3298
3793
  break;
3794
+ case "views":
3795
+ await views();
3796
+ break;
3797
+ case "support":
3798
+ await support();
3799
+ break;
3299
3800
  case "secret":
3300
3801
  await secret();
3301
3802
  break;
3302
3803
  default:
3303
- console.log("usage: monty <login|create|pull|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|save|data|skills>");
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>");
3304
3805
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
3305
3806
  console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
3306
3807
  console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
@@ -3320,6 +3821,8 @@ switch (command) {
3320
3821
  console.log(" deploy alias of save");
3321
3822
  console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
3322
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");
3323
3826
  console.log(" skills install/refresh the agent build skill");
3324
3827
  process.exit(command ? 1 : 0);
3325
3828
  }
@@ -32,7 +32,8 @@ export function manifestHash(manifest) {
32
32
 
33
33
  const IMPORTABLE = [
34
34
  "defineApp", "formula", "lookup", "montyDate", "montyFileSchema",
35
- "montyMember", "montyMoney", "montyPercent", "montyRef", "rollup", "self",
35
+ "montyMember", "montyMoney", "montyMultiSelect", "montyPercent",
36
+ "montyPhone", "montyRating", "montyRef", "montySelect", "rollup", "self",
36
37
  ];
37
38
 
38
39
  function fieldsInOrder(section) {
@@ -56,10 +57,20 @@ function storedSource(spec, used) {
56
57
  src = `montyRef(${JSON.stringify(spec.table)})`;
57
58
  break;
58
59
  case "enum":
59
- src = `z.enum(${JSON.stringify(spec.values)})`;
60
+ if (spec.valueDescriptions) {
61
+ used.add("montySelect");
62
+ src = `montySelect(${JSON.stringify(spec.values)}, ${JSON.stringify(spec.valueDescriptions)})`;
63
+ } else {
64
+ src = `z.enum(${JSON.stringify(spec.values)})`;
65
+ }
60
66
  break;
61
67
  case "multiSelect":
62
- src = `z.array(z.enum(${JSON.stringify(spec.values)}))`;
68
+ if (spec.valueDescriptions) {
69
+ used.add("montyMultiSelect");
70
+ src = `montyMultiSelect(${JSON.stringify(spec.values)}, ${JSON.stringify(spec.valueDescriptions)})`;
71
+ } else {
72
+ src = `z.array(z.enum(${JSON.stringify(spec.values)}))`;
73
+ }
63
74
  break;
64
75
  case "email":
65
76
  src = "z.email()";
@@ -81,6 +92,7 @@ function storedSource(spec, used) {
81
92
  src = "z.any()";
82
93
  }
83
94
  if (spec.optional) src += ".optional()";
95
+ if (spec.description) src += `.describe(${JSON.stringify(spec.description)})`;
84
96
  return src;
85
97
  }
86
98
 
@@ -115,6 +127,7 @@ function rollupSource(spec, used, indent) {
115
127
  else lines.push("count: true,");
116
128
  if (spec.over !== undefined) lines.push(`over: ${JSON.stringify(spec.over)},`);
117
129
  if (spec.range !== undefined) lines.push(`range: ${JSON.stringify(spec.range)},`);
130
+ if (spec.description !== undefined) lines.push(`description: ${JSON.stringify(spec.description)},`);
118
131
  const body = lines.map((l) => `${pad} ${l}`).join("\n");
119
132
  return `rollup(${outputSource(spec.output, used)}, {\n${body}\n${pad}})`;
120
133
  }
@@ -125,10 +138,10 @@ function fieldSource(spec, used, indent) {
125
138
  return storedSource(spec, used);
126
139
  case "formula":
127
140
  used.add("formula");
128
- return `formula(${outputSource(spec.output, used)}, ${JSON.stringify(spec.expr)})`;
141
+ return `formula(${outputSource(spec.output, used)}, ${JSON.stringify(spec.expr)}${spec.description !== undefined ? `, ${JSON.stringify(spec.description)}` : ""})`;
129
142
  case "lookup":
130
143
  used.add("lookup");
131
- return `lookup(${outputSource(spec.output, used)}, { ref: ${JSON.stringify(spec.ref)}, field: ${JSON.stringify(spec.field)} })`;
144
+ return `lookup(${outputSource(spec.output, used)}, { ref: ${JSON.stringify(spec.ref)}, field: ${JSON.stringify(spec.field)}${spec.description !== undefined ? `, description: ${JSON.stringify(spec.description)}` : ""} })`;
132
145
  case "rollup":
133
146
  return rollupSource(spec, used, indent);
134
147
  default:
package/lib/views.mjs ADDED
@@ -0,0 +1,138 @@
1
+ function badViewConfig(fix, code = "BAD_VIEW_CONFIG") {
2
+ const error = new Error(fix);
3
+ error.code = code;
4
+ error.fix = fix;
5
+ throw error;
6
+ }
7
+
8
+ function isObject(value) {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ function valueToken(value, field) {
13
+ if (value === null || value === "") return "(empty)";
14
+ if (["string", "number", "boolean"].includes(typeof value)) return String(value);
15
+ badViewConfig(
16
+ `Filter "${field}" must be null, a string, number, boolean, an array of those values, { contains }, { min/max }, or a full saved-view filter.`,
17
+ );
18
+ }
19
+
20
+ function valuesFilter(values, field) {
21
+ if (values.length === 0) {
22
+ badViewConfig(`Filter "${field}" has no values. Remove it or provide at least one value.`);
23
+ }
24
+ return { kind: "values", values: [...new Set(values.map((value) => valueToken(value, field)))] };
25
+ }
26
+
27
+ function rangeFilter(input, field) {
28
+ const min = input.min;
29
+ const max = input.max;
30
+ if (min === undefined && max === undefined) {
31
+ badViewConfig(`Range filter "${field}" needs min, max, or both.`);
32
+ }
33
+ if (min !== undefined && (typeof min !== "number" || !Number.isFinite(min))) {
34
+ badViewConfig(`Range filter "${field}" has a non-numeric min.`);
35
+ }
36
+ if (max !== undefined && (typeof max !== "number" || !Number.isFinite(max))) {
37
+ badViewConfig(`Range filter "${field}" has a non-numeric max.`);
38
+ }
39
+ if (min !== undefined && max !== undefined && min > max) {
40
+ badViewConfig(`Range filter "${field}" has min greater than max.`);
41
+ }
42
+ return {
43
+ kind: "range",
44
+ ...(min !== undefined ? { min } : {}),
45
+ ...(max !== undefined ? { max } : {}),
46
+ };
47
+ }
48
+
49
+ function normalizeFilter(input, field) {
50
+ if (Array.isArray(input)) return valuesFilter(input, field);
51
+ if (!isObject(input)) return valuesFilter([input], field);
52
+
53
+ if (input.kind === "values") {
54
+ if (!Array.isArray(input.values)) {
55
+ badViewConfig(`Values filter "${field}" needs a values array.`);
56
+ }
57
+ return valuesFilter(input.values, field);
58
+ }
59
+ if (input.kind === "text" || (input.kind === undefined && "contains" in input)) {
60
+ if (typeof input.contains !== "string" || input.contains === "") {
61
+ badViewConfig(`Text filter "${field}" needs a non-empty contains string.`);
62
+ }
63
+ return { kind: "text", contains: input.contains };
64
+ }
65
+ if (input.kind === "range" || (input.kind === undefined && ("min" in input || "max" in input))) {
66
+ return rangeFilter(input, field);
67
+ }
68
+ badViewConfig(
69
+ `Filter "${field}" is not recognized. Use a value, an array, { contains }, { min/max }, or a full saved-view filter.`,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Agent-facing filters stay concise while the stored value is exactly the
75
+ * shell's existing ViewConfig shape.
76
+ *
77
+ * { pipelineId: null } -> empty values filter
78
+ * { status: ["Evaluate", "New"] } -> values filter
79
+ * { title: { contains: "Director" } } -> text filter
80
+ * { amount: { min: 100 } } -> range filter
81
+ */
82
+ export function normalizeViewFilters(input) {
83
+ if (input === undefined) return {};
84
+ if (!isObject(input)) {
85
+ badViewConfig("--filter must be one JSON object keyed by field name.");
86
+ }
87
+ return Object.fromEntries(
88
+ Object.entries(input).map(([field, filter]) => {
89
+ if (field.trim() === "") badViewConfig("View filter field names cannot be empty.");
90
+ return [field, normalizeFilter(filter, field)];
91
+ }),
92
+ );
93
+ }
94
+
95
+ export function parseViewSort(input) {
96
+ if (input === undefined) return null;
97
+ if (input === "none") return null;
98
+ const match = /^([^:]+):(asc|desc)$/.exec(input);
99
+ if (!match) {
100
+ badViewConfig('--sort must be shaped like "field:asc" or "field:desc", or be "none" to clear it.');
101
+ }
102
+ return { column: match[1], dir: match[2] };
103
+ }
104
+
105
+ export function parseHiddenColumns(input) {
106
+ if (input === undefined || input.trim() === "") return [];
107
+ return [...new Set(input.split(",").map((field) => field.trim()).filter(Boolean))];
108
+ }
109
+
110
+ export function mergeViewConfig(existing, patch) {
111
+ const base = isObject(existing) ? existing : {};
112
+ return {
113
+ filters: Object.hasOwn(patch, "filters")
114
+ ? patch.filters
115
+ : (isObject(base.filters) ? base.filters : {}),
116
+ sort: Object.hasOwn(patch, "sort") ? patch.sort : (base.sort ?? null),
117
+ hidden: Object.hasOwn(patch, "hidden")
118
+ ? patch.hidden
119
+ : (Array.isArray(base.hidden) ? base.hidden : []),
120
+ };
121
+ }
122
+
123
+ export function validateViewColumns(config, fieldNames) {
124
+ const known = new Set(fieldNames);
125
+ const used = [
126
+ ...Object.keys(config.filters),
127
+ ...(config.sort ? [config.sort.column] : []),
128
+ ...config.hidden,
129
+ ];
130
+ const unknown = [...new Set(used.filter((field) => !known.has(field)))];
131
+ if (unknown.length > 0) {
132
+ badViewConfig(
133
+ `Unknown view field${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}. Available fields: ${fieldNames.join(", ") || "(none)"}.`,
134
+ "UNKNOWN_VIEW_FIELD",
135
+ );
136
+ }
137
+ return config;
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/TomasMonty/monty-v2.git",
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "scripts": {
23
23
  "prepack": "node scripts/bundle-template.mjs",
24
- "typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs && node --check lib/schemaCodegen.mjs && node --check lib/schemaPull.mjs && node --check scripts/schema-roundtrip.mjs",
24
+ "typecheck": "node --check bin/monty.mjs && node --check lib/compile.mjs && node --check lib/schemaCodegen.mjs && node --check lib/schemaPull.mjs && node --check lib/views.mjs && node --check scripts/schema-roundtrip.mjs",
25
+ "test": "node --test test/views.test.mjs",
25
26
  "postinstall": "node bin/postinstall.mjs",
26
27
  "test:roundtrip": "node scripts/schema-roundtrip.mjs"
27
28
  },
@@ -65,20 +65,44 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
65
65
  `monty schema` (JSON on stdout); change it by editing that JSON and
66
66
  running `monty schema set <file>` — validated server-side, additive by
67
67
  default. On workspace-owned apps, `monty.config.ts` edits do NOT change
68
- the schema. Declare a page in the manifest BEFORE shipping its route —
69
- a save with an undeclared route refuses with the fix. Field names `_*`,
70
- `updatedAt`, `createdBy` are reserved.
71
- 6. **UI is stock shadcn** (preset already wired). Add curated components with
68
+ the schema. Give every field a `description` and every enum/multiSelect
69
+ a `valueDescriptions` map saying WHEN each option applies that's the
70
+ guidance later record-writing agents follow. Declare a page in the
71
+ manifest BEFORE shipping its route a save with an undeclared route
72
+ refuses with the fix. Field names `_*`, `updatedAt`, `createdBy` are
73
+ reserved.
74
+ 6. **Prefer a saved view for filtered tables.** If the request is
75
+ one table with different filters, sorting, or hidden columns, use
76
+ `monty views set <table> <name> ...`. For example, an evaluation queue is
77
+ `monty views set leads "Evaluate" --filter '{"pipelineId":null}'`.
78
+ Change selected properties or rename it later with
79
+ `monty views update <table> <name> ...`; omitted properties stay unchanged.
80
+ If that table needs actions, compose `RecordPage` from
81
+ `@montytools/sdk/react` and add typed row/header/selection/record actions
82
+ with the controls from `@montytools/sdk/ui`. Write the whole page yourself
83
+ only when the shared components no longer fit. Custom pages remain the
84
+ escape hatch.
85
+ 7. **UI is stock shadcn** (preset already wired). Add curated components with
72
86
  `monty add <name>`; browse with `monty components` / `monty docs <name>`.
73
87
  How pages should LOOK — Lyra surfaces, dark-only, the chart language — is
74
88
  the `monty-design` skill; read it before styling any page.
75
- 7. **Errors are instructions.** Every failure prints
89
+ 8. **Errors are instructions.** Every failure prints
76
90
  `[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
77
91
  Typecheck failures block the save by design.
78
- 8. **Edits are real.** The dev shell reads and writes the app's LIVE
92
+ 9. **Edits are real.** The dev shell reads and writes the app's LIVE
79
93
  records — there is one set of data, and every write journals into the
80
94
  app's Activity. Exercise the app for real; clean up test rows you
81
95
  create; never seed junk into a team's working tables.
96
+ 10. **Report platform failures when sharing is enabled.** `monty support
97
+ status` shows whether a workspace admin has allowed agent support
98
+ reports. If enabled, submit one report when a Monty platform error still
99
+ blocks the task after you followed its `Fix` and retried, or when the
100
+ requested work needs a missing Monty platform feature. Summarize the
101
+ thread. State the observed problem, expected behavior, attempted fixes,
102
+ and exact error. Do not paste the full conversation. If sharing is off,
103
+ ask the user before running `monty support enable --yes`; never grant
104
+ consent yourself. Do not file reports for ordinary app-code bugs or send
105
+ duplicates for the same incident.
82
106
 
83
107
  ## CLI reference
84
108
 
@@ -93,5 +117,7 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
93
117
  | `monty add <name…>` | install curated shadcn components |
94
118
  | `monty schema [slug]` | print the app's stored manifest (tables, pages, metrics) as JSON |
95
119
  | `monty schema set <file\|->` | write an edited manifest back (validated, CAS, additive by default) |
120
+ | `monty views <list\|set\|update\|remove> <table>` | manage the shared saved views on a system record page |
96
121
  | `monty save ["what changed"]` | push the working copy to the cloud copy, like `git push main` (build + typecheck gate it) |
122
+ | `monty support <status\|enable\|disable\|submit>` | manage consent and send a bounded agent-authored platform report |
97
123
  | `monty skills` | (re)install this skill for your agent |
@@ -40,7 +40,16 @@ normal operating work; that is what this command is for.
40
40
  values come from each row, and re-running never duplicates.
41
41
  2. **Schema first, rows second.** Field names come from
42
42
  `monty data schema`, exact spelling. The server stores what you send —
43
- a misspelled field is silently a new field, not an error.
43
+ a misspelled field is silently a new field, not an error. The schema's
44
+ `description` and `enumDescriptions` annotations are the app's own
45
+ instructions for filling a field in: where a field has options (an
46
+ `enum`, alone or as array items), pick the option whose description
47
+ matches the situation — never your own reading of the option's name,
48
+ and never a value outside the list. A field without options has no
49
+ fixed vocabulary, so its `description` plus the conventions visible in
50
+ existing rows (`list` first) are the contract — write explicit,
51
+ consistently formatted values, not free-hand variants of the same
52
+ thing.
44
53
  3. **Every write is real** — operating work targets the team's data, and
45
54
  the app's Activity journals it. Destructive verbs (`remove`, bulk
46
55
  `update`) deserve a confirmation with the user unless they clearly
@@ -57,6 +66,13 @@ normal operating work; that is what this command is for.
57
66
  7. **Rows come back flattened**: your fields at the top level plus system
58
67
  fields `_id`, `_creationTime`, `updatedAt`, `createdBy`. System fields
59
68
  are read-only — never send them in `--data`.
69
+ 8. **File fields hold descriptors, never bytes or paths.** A `file`-typed
70
+ field stores exactly the object `monty data upload` prints
71
+ (`{id, name, contentType, size, uploadedAt, uploadedBy}`). Upload
72
+ first, then reference: use the attach flags to set an existing row's
73
+ field in one command, or paste the printed descriptor into `--data`
74
+ when inserting. Never hand-write a descriptor — an id that no upload
75
+ produced 404s on download.
60
76
 
61
77
  ## Command reference
62
78
 
@@ -69,6 +85,8 @@ normal operating work; that is what this command is for.
69
85
  | `monty data upsert <table> --key <field[,field]> --data '<json\|[json,…]>'` | find-or-create matched on the key fields — the idempotent write |
70
86
  | `monty data update <table> <id> --data '<json>' [--unset a,b]` | shallow-merge onto one row; `--unset` deletes fields |
71
87
  | `monty data remove <table> <id>` | delete one row |
88
+ | `monty data upload <path> [--name N] [--type mime] [--table <t> --record <id> --field <f>]` | store a file (10MB cap); prints the descriptor a `file` field holds — the attach flags set it on an existing row in the same command |
89
+ | `monty data download <file-id> [--out path]` | fetch a stored file's bytes to disk; the id is the `id` key of a row's file descriptor |
72
90
 
73
91
  All verbs take `--app <slug>`. Output is one JSON document on
74
92
  stdout.
@@ -36,7 +36,10 @@ Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current`
36
36
  rectangles on real axes. The `monty-design` skill is the full spec.
37
37
  4. **Schema changes = edit `monty.config.ts` and save.** Types update
38
38
  immediately. Prefer additive changes; give new fields `.optional()` or
39
- `.default(...)` so existing records stay readable.
39
+ `.default(...)` so existing records stay readable. Describe fields for
40
+ the next agent that fills them in: `.describe("what this holds")` on any
41
+ field, and `montySelect`/`montyMultiSelect` instead of bare `z.enum` so
42
+ every option says WHEN it applies, not what the word means.
40
43
  5. **You are not done until the work is saved.** After every meaningful
41
44
  change verified in dev, run `monty save "<what changed>"` — it builds,
42
45
  typechecks, and pushes the working copy to the cloud copy, like
@@ -46,7 +49,7 @@ Folders are managed for you: this app lives in `~/Monty/<slug>`. `monty current`
46
49
 
47
50
  ```ts
48
51
  // monty.config.ts
49
- import { defineApp, montyFileSchema } from "@montytools/sdk";
52
+ import { defineApp, montyFileSchema, montySelect } from "@montytools/sdk";
50
53
  import { z } from "zod";
51
54
 
52
55
  export const app = defineApp({
@@ -54,8 +57,12 @@ export const app = defineApp({
54
57
  tables: {
55
58
  expenses: z.object({
56
59
  title: z.string().min(1),
57
- amount: z.number().positive(),
58
- status: z.enum(["draft", "submitted", "approved"]).default("draft"),
60
+ amount: z.number().positive().describe("Receipt total in USD, tax included."),
61
+ status: montySelect(["draft", "submitted", "approved"], {
62
+ draft: "Still being filled in, not yet sent for review.",
63
+ submitted: "Awaiting a manager's decision.",
64
+ approved: "Cleared for reimbursement.",
65
+ }).default("draft"),
59
66
  assigneeId: z.string().optional(), // userId from useMembers()
60
67
  receipt: montyFileSchema.optional(),
61
68
  }),
@@ -146,6 +153,12 @@ export const app = defineApp({
146
153
 
147
154
  Rules that matter:
148
155
 
156
+ - **A filtered or sorted slice of one table is a saved view.** Use
157
+ `monty views set <table> <name> ...` before writing a route. For example,
158
+ `monty views set leads "Evaluate" --filter '{"pipelineId":null}'` creates
159
+ a shared record view without React. Use `monty views update` to change only
160
+ selected properties or rename it. Custom pages remain available when the
161
+ requested interaction, multi-table layout, or visualization needs code.
149
162
  - **Custom pages wear the platform chrome from `@montytools/sdk/ui`** — the
150
163
  SAME components the shell renders its own pages with, so your page is
151
164
  indistinguishable from a system table view. Every custom page opens with
@@ -172,6 +185,13 @@ Rules that matter:
172
185
  mode strips) and the Lyra table classes `SURFACE`/`THEAD`/`TH`/`ROW`/`CHIP`
173
186
  for record-like tables (`<div className={SURFACE}><table>…`), identical to
174
187
  the app's Configuration surfaces.
188
+ - **Extend records before rebuilding them.** For a custom page that is still
189
+ one table, use `RecordPage` from `@montytools/sdk/react`. Add behavior with
190
+ its typed `headerActions`, `rowActions`, `selectionActions`, and
191
+ `recordActions` callbacks. Use `RecordActionButton` and the lower-level
192
+ `RecordPage*`/`RecordTable*` components from `@montytools/sdk/ui` when you
193
+ need a different composition. Drop to a fully custom page only when those
194
+ components no longer fit.
175
195
  - **Formulas are strings**, not functions — the Monty expression grammar
176
196
  (`+ - * / %`, comparisons, `&& || !`, `IF/ROUND/ABS/MIN/MAX`,
177
197
  `metrics.<name>`). A formula sees only fields declared ABOVE it. Type
@@ -216,9 +236,12 @@ secret keys, webhooks, clocks), create `server/index.ts` with named async
216
236
  exports. Web-standard APIs only (`fetch`, `crypto`, `URL`, …) — no `node:`
217
237
  imports; it runs on Cloudflare Workers when Live and inside `monty dev` in
218
238
  a session. Every export gets `ctx`: `ctx.records` (full CRUD on all your
219
- tables, including ones the UI never exposes), `ctx.secrets` (see below),
220
- `ctx.viewer` (who called: member session/visitor/schedule/none), and
221
- `ctx.track()` (emit an event).
239
+ tables, including ones the UI never exposes), `ctx.files` (platform file
240
+ storage: `upload(data, {name?, contentType?}) → MontyFile`,
241
+ `download(fileOrId) → Blob`, `remove(fileOrId)` same descriptors as the
242
+ `useUploadFile` hook, 10MB cap), `ctx.secrets` (see below), `ctx.viewer`
243
+ (who called: member session/visitor/schedule/none), and `ctx.track()`
244
+ (emit an event).
222
245
 
223
246
  ```ts
224
247
  // server/index.ts
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.2.1",
13
+ "@montytools/sdk": "^0.2.3",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",