@montytools/cli 0.5.2 → 0.5.4

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.2";
994
+ const MIN_SDK = "0.2.4";
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") {
@@ -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;
@@ -2943,13 +2950,13 @@ async function dataAuth() {
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
  }
@@ -3101,7 +3112,7 @@ async function data() {
3101
3112
  const name = flag("name") ?? basename(path);
3102
3113
  const contentType = flag("type") ?? inferContentType(name);
3103
3114
  const app = resolveDataApp();
3104
- const auth = await dataAuth();
3115
+ const auth = await workspaceAuth();
3105
3116
  if (attachFlags.length === 3) {
3106
3117
  // Prove the target row exists BEFORE storing bytes — a failed attach
3107
3118
  // after the upload would orphan the file.
@@ -3130,7 +3141,7 @@ async function data() {
3130
3141
  const fileId = table;
3131
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).");
3132
3143
  const app = resolveDataApp();
3133
- const auth = await dataAuth();
3144
+ const auth = await workspaceAuth();
3134
3145
  const res = await callFiles("GET", { id: fileId }, auth, app);
3135
3146
  const disposition = res.headers.get("content-disposition") ?? "";
3136
3147
  const remoteName = /filename="([^"]+)"/.exec(disposition)?.[1] ?? fileId;
@@ -3156,7 +3167,7 @@ async function data() {
3156
3167
  if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
3157
3168
 
3158
3169
  const app = resolveDataApp();
3159
- const auth = await dataAuth();
3170
+ const auth = await workspaceAuth();
3160
3171
 
3161
3172
  switch (verb) {
3162
3173
  case "list": {
@@ -3250,12 +3261,373 @@ async function data() {
3250
3261
  }
3251
3262
  }
3252
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
+
3253
3625
  // ── dispatch ───────────────────────────────────────────────────────────────
3254
3626
  // Keep agent skills fresh on every invocation (user level + current app).
3255
3627
  // `dev` and `logs` skip the refresh here: attach and log reads must stay
3256
3628
  // fast (a skills refresh can shell out to npx for two minutes) — dev's
3257
3629
  // fresh-start path installs skills itself once it owns the session.
3258
- if (command !== "dev" && command !== "logs") {
3630
+ if (command !== "dev" && command !== "logs" && command !== "support") {
3259
3631
  installSkills({ appDir: findAppRoot(process.cwd()) });
3260
3632
  }
3261
3633
 
@@ -3419,11 +3791,17 @@ switch (command) {
3419
3791
  case "schema":
3420
3792
  await schemaCmd();
3421
3793
  break;
3794
+ case "views":
3795
+ await views();
3796
+ break;
3797
+ case "support":
3798
+ await support();
3799
+ break;
3422
3800
  case "secret":
3423
3801
  await secret();
3424
3802
  break;
3425
3803
  default:
3426
- 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>");
3427
3805
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
3428
3806
  console.log(" create <slug> [--name N] [--icon I] [--spa] register a new app (config-only by default; --spa scaffolds the full SPA)");
3429
3807
  console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
@@ -3443,6 +3821,8 @@ switch (command) {
3443
3821
  console.log(" deploy alias of save");
3444
3822
  console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
3445
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");
3446
3826
  console.log(" skills install/refresh the agent build skill");
3447
3827
  process.exit(command ? 1 : 0);
3448
3828
  }
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.2",
3
+ "version": "0.5.4",
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
  },
@@ -60,7 +60,10 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
60
60
  4. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
61
61
  `@montytools/sdk/react` (hooks: `useList`, `useInsert`, …). Never import
62
62
  Clerk or Convex directly; never fetch external APIs from app code — the
63
- platform CSP blocks them.
63
+ platform CSP blocks them. Open external HTTPS pages with
64
+ `openExternal(url)` from `@montytools/sdk`, called synchronously from the
65
+ click or key handler; `window.open` and `target="_blank"` are blocked by
66
+ the app iframe.
64
67
  5. **The data schema lives in the WORKSPACE, not in code.** Read it with
65
68
  `monty schema` (JSON on stdout); change it by editing that JSON and
66
69
  running `monty schema set <file>` — validated server-side, additive by
@@ -71,17 +74,38 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
71
74
  manifest BEFORE shipping its route — a save with an undeclared route
72
75
  refuses with the fix. Field names `_*`, `updatedAt`, `createdBy` are
73
76
  reserved.
74
- 6. **UI is stock shadcn** (preset already wired). Add curated components with
77
+ 6. **Prefer a saved view for filtered tables.** If the request is
78
+ one table with different filters, sorting, or hidden columns, use
79
+ `monty views set <table> <name> ...`. For example, an evaluation queue is
80
+ `monty views set leads "Evaluate" --filter '{"pipelineId":null}'`.
81
+ Change selected properties or rename it later with
82
+ `monty views update <table> <name> ...`; omitted properties stay unchanged.
83
+ If that table needs actions, compose `RecordPage` from
84
+ `@montytools/sdk/react` and add typed row/header/selection/record actions
85
+ with the controls from `@montytools/sdk/ui`. Write the whole page yourself
86
+ only when the shared components no longer fit. Custom pages remain the
87
+ escape hatch.
88
+ 7. **UI is stock shadcn** (preset already wired). Add curated components with
75
89
  `monty add <name>`; browse with `monty components` / `monty docs <name>`.
76
90
  How pages should LOOK — Lyra surfaces, dark-only, the chart language — is
77
91
  the `monty-design` skill; read it before styling any page.
78
- 7. **Errors are instructions.** Every failure prints
92
+ 8. **Errors are instructions.** Every failure prints
79
93
  `[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
80
94
  Typecheck failures block the save by design.
81
- 8. **Edits are real.** The dev shell reads and writes the app's LIVE
95
+ 9. **Edits are real.** The dev shell reads and writes the app's LIVE
82
96
  records — there is one set of data, and every write journals into the
83
97
  app's Activity. Exercise the app for real; clean up test rows you
84
98
  create; never seed junk into a team's working tables.
99
+ 10. **Report platform failures when sharing is enabled.** `monty support
100
+ status` shows whether a workspace admin has allowed agent support
101
+ reports. If enabled, submit one report when a Monty platform error still
102
+ blocks the task after you followed its `Fix` and retried, or when the
103
+ requested work needs a missing Monty platform feature. Summarize the
104
+ thread. State the observed problem, expected behavior, attempted fixes,
105
+ and exact error. Do not paste the full conversation. If sharing is off,
106
+ ask the user before running `monty support enable --yes`; never grant
107
+ consent yourself. Do not file reports for ordinary app-code bugs or send
108
+ duplicates for the same incident.
85
109
 
86
110
  ## CLI reference
87
111
 
@@ -96,5 +120,7 @@ channel switch. "dev"/"prod" mean platform environments, never app states.
96
120
  | `monty add <name…>` | install curated shadcn components |
97
121
  | `monty schema [slug]` | print the app's stored manifest (tables, pages, metrics) as JSON |
98
122
  | `monty schema set <file\|->` | write an edited manifest back (validated, CAS, additive by default) |
123
+ | `monty views <list\|set\|update\|remove> <table>` | manage the shared saved views on a system record page |
99
124
  | `monty save ["what changed"]` | push the working copy to the cloud copy, like `git push main` (build + typecheck gate it) |
125
+ | `monty support <status\|enable\|disable\|submit>` | manage consent and send a bounded agent-authored platform report |
100
126
  | `monty skills` | (re)install this skill for your agent |
@@ -106,6 +106,19 @@ await update(row._id, { receipt }); // store descriptor, not
106
106
  const { url } = useFileUrl(app, row.receipt); // authenticated blob: URL for previews/downloads
107
107
  ```
108
108
 
109
+ Open an external HTTPS page with the SDK. Do not use `target="_blank"` or
110
+ `window.open`; the app iframe blocks popups by design. Call `openExternal`
111
+ synchronously from the click or key handler so the host can verify current
112
+ user activation:
113
+
114
+ ```tsx
115
+ import { openExternal } from "@montytools/sdk";
116
+
117
+ <Button onClick={() => openExternal(profile.linkedinUrl)}>
118
+ Open LinkedIn
119
+ </Button>
120
+ ```
121
+
109
122
  ## Derived fields, metrics, pages (V2 — the platform renders these)
110
123
 
111
124
  Your config can carry a whole database app the platform shell renders for
@@ -153,6 +166,12 @@ export const app = defineApp({
153
166
 
154
167
  Rules that matter:
155
168
 
169
+ - **A filtered or sorted slice of one table is a saved view.** Use
170
+ `monty views set <table> <name> ...` before writing a route. For example,
171
+ `monty views set leads "Evaluate" --filter '{"pipelineId":null}'` creates
172
+ a shared record view without React. Use `monty views update` to change only
173
+ selected properties or rename it. Custom pages remain available when the
174
+ requested interaction, multi-table layout, or visualization needs code.
156
175
  - **Custom pages wear the platform chrome from `@montytools/sdk/ui`** — the
157
176
  SAME components the shell renders its own pages with, so your page is
158
177
  indistinguishable from a system table view. Every custom page opens with
@@ -179,6 +198,13 @@ Rules that matter:
179
198
  mode strips) and the Lyra table classes `SURFACE`/`THEAD`/`TH`/`ROW`/`CHIP`
180
199
  for record-like tables (`<div className={SURFACE}><table>…`), identical to
181
200
  the app's Configuration surfaces.
201
+ - **Extend records before rebuilding them.** For a custom page that is still
202
+ one table, use `RecordPage` from `@montytools/sdk/react`. Add behavior with
203
+ its typed `headerActions`, `rowActions`, `selectionActions`, and
204
+ `recordActions` callbacks. Use `RecordActionButton` and the lower-level
205
+ `RecordPage*`/`RecordTable*` components from `@montytools/sdk/ui` when you
206
+ need a different composition. Drop to a fully custom page only when those
207
+ components no longer fit.
182
208
  - **Formulas are strings**, not functions — the Monty expression grammar
183
209
  (`+ - * / %`, comparisons, `&& || !`, `IF/ROUND/ABS/MIN/MAX`,
184
210
  `metrics.<name>`). A formula sees only fields declared ABOVE it. Type
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.2.2",
13
+ "@montytools/sdk": "^0.2.4",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",