@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 +391 -11
- package/lib/views.mjs +138 -0
- package/package.json +3 -2
- package/skills/monty-build/SKILL.md +30 -4
- package/template/AGENTS.md +26 -0
- package/template/package.json +1 -1
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.
|
|
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
|
|
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
|
|
2930
|
+
async function workspaceAuth() {
|
|
2924
2931
|
const { host, key } = loadConfig();
|
|
2925
|
-
if (!key) fail("NOT_LOGGED_IN", `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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. **
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|
package/template/AGENTS.md
CHANGED
|
@@ -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
|
package/template/package.json
CHANGED