@montytools/cli 0.4.2 → 0.5.1
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 +893 -209
- package/lib/compile.mjs +2 -2
- package/lib/schemaCodegen.mjs +222 -0
- package/lib/schemaPull.mjs +83 -0
- package/package.json +4 -3
- package/skills/monty-build/SKILL.md +47 -28
- package/skills/monty-design/SKILL.md +123 -0
- package/skills/monty-operate/SKILL.md +7 -9
- package/template/AGENTS.md +114 -16
- package/template/package.json +1 -1
- package/template/src/index.css +35 -31
package/lib/compile.mjs
CHANGED
|
@@ -19,7 +19,7 @@ export class CompileError extends Error {
|
|
|
19
19
|
// Throws CompileError:
|
|
20
20
|
// - CONFIG_BUNDLE_FAILED — esbuild could not bundle (usually unlinked deps)
|
|
21
21
|
// - CONFIG_COMPILE_FAILED — the config threw while loading
|
|
22
|
-
export async function compileAppConfig(appDir) {
|
|
22
|
+
export async function compileAppConfig(appDir, { forceManifest = false } = {}) {
|
|
23
23
|
appDir = resolve(appDir); // esbuild requires an absolute absWorkingDir
|
|
24
24
|
// esbuild is a dependency of THIS package (@montytools/cli), so resolution
|
|
25
25
|
// from here works for any caller — no per-script resolution dance.
|
|
@@ -31,7 +31,7 @@ export async function compileAppConfig(appDir) {
|
|
|
31
31
|
writeFileSync(entry, [
|
|
32
32
|
`import { app } from "../monty.config";`,
|
|
33
33
|
`import { compileApp } from "@montytools/sdk/compile";`,
|
|
34
|
-
`process.stdout.write(JSON.stringify(compileApp(app)));`,
|
|
34
|
+
`process.stdout.write(JSON.stringify(compileApp(app, { forceManifest: ${forceManifest} })));`,
|
|
35
35
|
].join("\n"));
|
|
36
36
|
try {
|
|
37
37
|
await build({
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Manifest → monty.config.ts codegen + the canonical manifest hash.
|
|
2
|
+
//
|
|
3
|
+
// `monty schema pull` regenerates the whole config from the app's stored
|
|
4
|
+
// App Manifest v2. Regeneration is LOSSLESS for config-only apps because
|
|
5
|
+
// every V2 surface — including formulas, which are expression STRINGS — is
|
|
6
|
+
// data in the manifest; there is no code in a config that the manifest
|
|
7
|
+
// doesn't carry. The emitted file uses the same SDK constructors an agent
|
|
8
|
+
// would write by hand, so pulled configs and authored configs are the same
|
|
9
|
+
// dialect.
|
|
10
|
+
//
|
|
11
|
+
// The hash mirrors packages/backend/convex/lib/manifestValidate.ts exactly:
|
|
12
|
+
// sha256 over a SORTED-KEY stringify (representation-independent — Convex
|
|
13
|
+
// does not guarantee object key order; declaration order travels in the
|
|
14
|
+
// explicit `order` arrays).
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export function stableStringify(v) {
|
|
19
|
+
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
|
|
20
|
+
if (typeof v === "object" && v !== null) {
|
|
21
|
+
const keys = Object.keys(v).sort();
|
|
22
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(",")}}`;
|
|
23
|
+
}
|
|
24
|
+
return JSON.stringify(v) ?? "null";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function manifestHash(manifest) {
|
|
28
|
+
return createHash("sha256").update(stableStringify(manifest)).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── zod/SDK source emission ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const IMPORTABLE = [
|
|
34
|
+
"defineApp", "formula", "lookup", "montyDate", "montyFileSchema",
|
|
35
|
+
"montyMember", "montyMoney", "montyPercent", "montyRef", "rollup", "self",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function fieldsInOrder(section) {
|
|
39
|
+
const order = Array.isArray(section.order) ? section.order : Object.keys(section.fields);
|
|
40
|
+
return order.map((name) => [name, section.fields[name]]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function storedSource(spec, used) {
|
|
44
|
+
let src;
|
|
45
|
+
switch (spec.type) {
|
|
46
|
+
case "string": src = "z.string()"; break;
|
|
47
|
+
case "number": src = "z.number()"; break;
|
|
48
|
+
case "boolean": src = "z.boolean()"; break;
|
|
49
|
+
case "money": used.add("montyMoney"); src = "montyMoney()"; break;
|
|
50
|
+
case "percent": used.add("montyPercent"); src = "montyPercent()"; break;
|
|
51
|
+
case "date": used.add("montyDate"); src = "montyDate()"; break;
|
|
52
|
+
case "member": used.add("montyMember"); src = "montyMember()"; break;
|
|
53
|
+
case "file": used.add("montyFileSchema"); src = "montyFileSchema"; break;
|
|
54
|
+
case "ref":
|
|
55
|
+
used.add("montyRef");
|
|
56
|
+
src = `montyRef(${JSON.stringify(spec.table)})`;
|
|
57
|
+
break;
|
|
58
|
+
case "enum":
|
|
59
|
+
src = `z.enum(${JSON.stringify(spec.values)})`;
|
|
60
|
+
break;
|
|
61
|
+
case "multiSelect":
|
|
62
|
+
src = `z.array(z.enum(${JSON.stringify(spec.values)}))`;
|
|
63
|
+
break;
|
|
64
|
+
case "email":
|
|
65
|
+
src = "z.email()";
|
|
66
|
+
break;
|
|
67
|
+
case "url":
|
|
68
|
+
src = "z.url()";
|
|
69
|
+
break;
|
|
70
|
+
case "phone":
|
|
71
|
+
used.add("montyPhone");
|
|
72
|
+
src = "montyPhone()";
|
|
73
|
+
break;
|
|
74
|
+
case "rating":
|
|
75
|
+
used.add("montyRating");
|
|
76
|
+
src = "montyRating()";
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
// json: the manifest carries no deep shape (schemaJson owns storage
|
|
80
|
+
// validation) — z.any() keeps the field writable; refine by hand.
|
|
81
|
+
src = "z.any()";
|
|
82
|
+
}
|
|
83
|
+
if (spec.optional) src += ".optional()";
|
|
84
|
+
return src;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function outputSource(type, used) {
|
|
88
|
+
switch (type) {
|
|
89
|
+
case "money": used.add("montyMoney"); return "montyMoney()";
|
|
90
|
+
case "percent": used.add("montyPercent"); return "montyPercent()";
|
|
91
|
+
case "date": used.add("montyDate"); return "montyDate()";
|
|
92
|
+
case "member": used.add("montyMember"); return "montyMember()";
|
|
93
|
+
case "number": return "z.number()";
|
|
94
|
+
case "string": return "z.string()";
|
|
95
|
+
case "boolean": return "z.boolean()";
|
|
96
|
+
default: return "z.string()";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rollupSource(spec, used, indent) {
|
|
101
|
+
used.add("rollup");
|
|
102
|
+
const pad = " ".repeat(indent);
|
|
103
|
+
const lines = [`from: ${JSON.stringify(spec.from)},`];
|
|
104
|
+
if (spec.where !== undefined) {
|
|
105
|
+
const entries = Object.entries(spec.where).map(([k, v]) => {
|
|
106
|
+
if (typeof v === "object" && v !== null && typeof v.self === "string") {
|
|
107
|
+
used.add("self");
|
|
108
|
+
return `${JSON.stringify(k)}: self(${JSON.stringify(v.self)})`;
|
|
109
|
+
}
|
|
110
|
+
return `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
|
|
111
|
+
});
|
|
112
|
+
lines.push(`where: { ${entries.join(", ")} },`);
|
|
113
|
+
}
|
|
114
|
+
if (spec.op === "sum") lines.push(`sum: ${JSON.stringify(spec.sumField)},`);
|
|
115
|
+
else lines.push("count: true,");
|
|
116
|
+
if (spec.over !== undefined) lines.push(`over: ${JSON.stringify(spec.over)},`);
|
|
117
|
+
if (spec.range !== undefined) lines.push(`range: ${JSON.stringify(spec.range)},`);
|
|
118
|
+
const body = lines.map((l) => `${pad} ${l}`).join("\n");
|
|
119
|
+
return `rollup(${outputSource(spec.output, used)}, {\n${body}\n${pad}})`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fieldSource(spec, used, indent) {
|
|
123
|
+
switch (spec.kind) {
|
|
124
|
+
case "stored":
|
|
125
|
+
return storedSource(spec, used);
|
|
126
|
+
case "formula":
|
|
127
|
+
used.add("formula");
|
|
128
|
+
return `formula(${outputSource(spec.output, used)}, ${JSON.stringify(spec.expr)})`;
|
|
129
|
+
case "lookup":
|
|
130
|
+
used.add("lookup");
|
|
131
|
+
return `lookup(${outputSource(spec.output, used)}, { ref: ${JSON.stringify(spec.ref)}, field: ${JSON.stringify(spec.field)} })`;
|
|
132
|
+
case "rollup":
|
|
133
|
+
return rollupSource(spec, used, indent);
|
|
134
|
+
default:
|
|
135
|
+
return "z.any()";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function settingsFieldSource(spec, defaultValue, used) {
|
|
140
|
+
let src = storedSource({ ...spec, optional: undefined }, used);
|
|
141
|
+
if (defaultValue !== undefined) src += `.default(${JSON.stringify(defaultValue)})`;
|
|
142
|
+
else if (spec.optional) src += ".optional()";
|
|
143
|
+
return src;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The generated monty.config.ts source for a manifest document. */
|
|
147
|
+
export function manifestToConfig(manifest, { name, icon } = {}) {
|
|
148
|
+
const used = new Set(["defineApp"]);
|
|
149
|
+
const out = [];
|
|
150
|
+
|
|
151
|
+
out.push(" tables: {");
|
|
152
|
+
for (const [tableName, table] of Object.entries(manifest.tables)) {
|
|
153
|
+
out.push(` ${JSON.stringify(tableName)}: z.object({`);
|
|
154
|
+
for (const [field, spec] of fieldsInOrder(table)) {
|
|
155
|
+
out.push(` ${JSON.stringify(field)}: ${fieldSource(spec, used, 6)},`);
|
|
156
|
+
}
|
|
157
|
+
out.push(" }),");
|
|
158
|
+
}
|
|
159
|
+
out.push(" },");
|
|
160
|
+
|
|
161
|
+
if (manifest.metrics !== undefined) {
|
|
162
|
+
out.push(" metrics: {");
|
|
163
|
+
for (const [name2, spec] of Object.entries(manifest.metrics)) {
|
|
164
|
+
out.push(` ${JSON.stringify(name2)}: ${rollupSource(spec, used, 4)},`);
|
|
165
|
+
}
|
|
166
|
+
out.push(" },");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (manifest.settings !== undefined) {
|
|
170
|
+
out.push(" settings: z.object({");
|
|
171
|
+
const defaults = manifest.settings.defaults ?? {};
|
|
172
|
+
for (const [field, spec] of fieldsInOrder(manifest.settings)) {
|
|
173
|
+
out.push(` ${JSON.stringify(field)}: ${settingsFieldSource(spec, defaults[field], used)},`);
|
|
174
|
+
}
|
|
175
|
+
out.push(" }),");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (manifest.pages !== undefined) {
|
|
179
|
+
// Emit in declared nav order — the config's declaration order becomes
|
|
180
|
+
// pagesOrder on the next compile, so emission order must match it.
|
|
181
|
+
const orderedPages = {};
|
|
182
|
+
for (const name of manifest.pagesOrder ?? Object.keys(manifest.pages)) {
|
|
183
|
+
if (name in manifest.pages) orderedPages[name] = manifest.pages[name];
|
|
184
|
+
}
|
|
185
|
+
for (const [name, page] of Object.entries(manifest.pages)) {
|
|
186
|
+
if (!(name in orderedPages)) orderedPages[name] = page;
|
|
187
|
+
}
|
|
188
|
+
out.push(` pages: ${JSON.stringify(orderedPages, null, 2).replace(/\n/g, "\n ")},`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (manifest.datasets !== undefined) {
|
|
192
|
+
out.push(" datasets: {");
|
|
193
|
+
for (const [name2, ds] of Object.entries(manifest.datasets)) {
|
|
194
|
+
out.push(` ${JSON.stringify(name2)}: z.object({`);
|
|
195
|
+
for (const [field, spec] of fieldsInOrder(ds)) {
|
|
196
|
+
out.push(` ${JSON.stringify(field)}: ${fieldSource(spec, used, 6)},`);
|
|
197
|
+
}
|
|
198
|
+
out.push(" }),");
|
|
199
|
+
}
|
|
200
|
+
out.push(" },");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const sdkImports = IMPORTABLE.filter((n) => used.has(n));
|
|
204
|
+
return [
|
|
205
|
+
`import { z } from "zod";`,
|
|
206
|
+
`import {`,
|
|
207
|
+
...sdkImports.map((n) => ` ${n},`),
|
|
208
|
+
`} from "@montytools/sdk";`,
|
|
209
|
+
``,
|
|
210
|
+
`// Regenerated by \`monty schema pull\` from the app's stored manifest.`,
|
|
211
|
+
`// Edit freely — \`monty dev\` / \`monty deploy\` push changes back; another`,
|
|
212
|
+
`// editor's remote changes surface as MANIFEST_DRIFT (then pull again).`,
|
|
213
|
+
`export const app = defineApp({`,
|
|
214
|
+
` slug: ${JSON.stringify(manifest.slug)},`,
|
|
215
|
+
...(name ? [` name: ${JSON.stringify(name)},`] : []),
|
|
216
|
+
...(icon ? [` icon: ${JSON.stringify(icon)},`] : []),
|
|
217
|
+
...out,
|
|
218
|
+
`});`,
|
|
219
|
+
`export type App = typeof app;`,
|
|
220
|
+
``,
|
|
221
|
+
].join("\n");
|
|
222
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `monty schema pull` — regenerate monty.config.ts from the app's stored
|
|
2
|
+
// Live manifest (the schema-as-data flow: another agent may have edited the
|
|
3
|
+
// schema via the API/MCP; this brings the code checkout back in sync).
|
|
4
|
+
// Distinct from `monty pull`, which restores the whole SOURCE SNAPSHOT.
|
|
5
|
+
//
|
|
6
|
+
// Safety: refuses when the local config has schema changes that never
|
|
7
|
+
// reached the registry (compile-and-compare against the base hash recorded
|
|
8
|
+
// in .monty/schema.json) — "deploy or discard", like git with a dirty tree.
|
|
9
|
+
// The old file is backed up beside the new one on every overwrite.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { manifestHash, manifestToConfig } from "./schemaCodegen.mjs";
|
|
14
|
+
|
|
15
|
+
const STATE_FILE = ["schema.json"]; // .monty/schema.json
|
|
16
|
+
|
|
17
|
+
export function readSchemaState(appDir) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(join(appDir, ".monty", ...STATE_FILE), "utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Record the hash of the manifest this checkout last synced with the
|
|
26
|
+
* registry (written after every successful push and every pull — the CAS
|
|
27
|
+
* base for the next push). */
|
|
28
|
+
export function writeSchemaState(appDir, hash) {
|
|
29
|
+
const dir = join(appDir, ".monty");
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
writeFileSync(join(dir, ...STATE_FILE), JSON.stringify({ hash, syncedAt: Date.now() }) + "\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function schemaPull({ appDir, host, key, slug, force, compileAppConfig, fail }) {
|
|
35
|
+
const res = await fetch(`${host}/api/schema?slug=${encodeURIComponent(slug)}`, {
|
|
36
|
+
headers: { authorization: `Bearer ${key}` },
|
|
37
|
+
});
|
|
38
|
+
const body = await res.json().catch(() => null);
|
|
39
|
+
if (!res.ok || !body?.ok) {
|
|
40
|
+
fail(body?.code ?? `HTTP_${res.status}`, body?.fix ?? "Could not fetch the app's schema — check the connection and `monty login`.");
|
|
41
|
+
}
|
|
42
|
+
if (!body.manifest) {
|
|
43
|
+
fail(
|
|
44
|
+
"NO_MANIFEST",
|
|
45
|
+
`"${slug}" has no stored App Manifest (it is a V1 app or has never pushed one). Author monty.config.ts with V2 features and run \`monty dev\` or \`monty deploy\` first.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const configPath = join(appDir, "monty.config.ts");
|
|
50
|
+
if (existsSync(configPath) && !force) {
|
|
51
|
+
// Dirty check: does the local config compile to the manifest this
|
|
52
|
+
// checkout last synced? If not, pulling would clobber local edits.
|
|
53
|
+
const state = readSchemaState(appDir);
|
|
54
|
+
let localHash = null;
|
|
55
|
+
try {
|
|
56
|
+
const compiled = await compileAppConfig(appDir);
|
|
57
|
+
localHash = compiled.manifest ? manifestHash(compiled.manifest) : null;
|
|
58
|
+
} catch {
|
|
59
|
+
// A config that doesn't compile can't be proven clean — refuse without
|
|
60
|
+
// --force rather than silently discarding whatever it holds.
|
|
61
|
+
fail(
|
|
62
|
+
"SCHEMA_DIRTY",
|
|
63
|
+
"monty.config.ts does not compile, so local schema edits cannot be verified against the registry. Fix it and deploy, or re-run with --force to REPLACE it (a .bak is kept).",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const cleanAgainst = state?.hash ?? body.hash;
|
|
67
|
+
if (localHash !== null && localHash !== cleanAgainst && localHash !== body.hash) {
|
|
68
|
+
fail(
|
|
69
|
+
"SCHEMA_DIRTY",
|
|
70
|
+
"monty.config.ts has schema changes that never reached the registry. Push them first (`monty dev` save or `monty deploy`), or discard them with --force (a .bak is kept).",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (existsSync(configPath)) {
|
|
76
|
+
renameSync(configPath, `${configPath}.bak`);
|
|
77
|
+
console.log(`backup: monty.config.ts.bak`);
|
|
78
|
+
}
|
|
79
|
+
writeFileSync(configPath, manifestToConfig(body.manifest, { name: body.name, icon: body.icon ?? undefined }));
|
|
80
|
+
writeSchemaState(appDir, body.hash);
|
|
81
|
+
console.log(`schema: pulled "${slug}" (${Object.keys(body.manifest.tables).length} tables) -> monty.config.ts`);
|
|
82
|
+
console.log(`base: ${body.hash.slice(0, 12)} (.monty/schema.json — the CAS base for the next push)`);
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@montytools/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/TomasMonty/monty-v2.git",
|
|
@@ -21,8 +21,9 @@
|
|
|
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",
|
|
25
|
-
"postinstall": "node bin/postinstall.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",
|
|
25
|
+
"postinstall": "node bin/postinstall.mjs",
|
|
26
|
+
"test:roundtrip": "node scripts/schema-roundtrip.mjs"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"esbuild": "^0.28.1"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: monty-build
|
|
3
|
-
description: Build, run, and
|
|
3
|
+
description: Build, run, and save Monty workspace apps. Use whenever the task involves a Monty app, monty.config.ts, the monty CLI (create/dev/logs/save/add), the @montytools/sdk, or a prompt mentioning usemonty.dev. Covers folder discipline, the build loop, data/auth rules, and error handling.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Building Monty apps
|
|
@@ -11,10 +11,11 @@ platform's job. The complete contract lives in the app's own `AGENTS.md`
|
|
|
11
11
|
(nearest-file-wins — read it before writing code). This skill is the map, not
|
|
12
12
|
the territory.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
There is ONE copy of every app: **Live**, the cloud copy the team uses
|
|
15
|
+
(`monty save` updates it). While a dev **session** runs (a dev shell on
|
|
16
|
+
the app's LIVE records, often already started for you by the Monty desktop),
|
|
17
|
+
workspace admins see the session's version automatically — no publish, no
|
|
18
|
+
channel switch. "dev"/"prod" mean platform environments, never app states.
|
|
18
19
|
|
|
19
20
|
## Rules
|
|
20
21
|
|
|
@@ -23,45 +24,61 @@ names for them; "dev"/"prod" mean something else on this platform.
|
|
|
23
24
|
`monty login` first). `monty current` tells you where you are;
|
|
24
25
|
`cd "$(monty select <slug>)"` jumps to an app; `monty apps` lists local
|
|
25
26
|
ones. Never mkdir app folders by hand, and never edit the `id:` line in
|
|
26
|
-
`monty.config.ts`.
|
|
27
|
+
`monty.config.ts`. The folder IS the app's source: every file in it
|
|
28
|
+
rides the source snapshot on `monty save`, so never leave scratch files
|
|
29
|
+
here (manifest edits, notes, one-off scripts). Work in the OS temp dir
|
|
30
|
+
instead, or pipe — `monty schema | <edit> | monty schema set -` needs
|
|
31
|
+
no file at all — and delete anything temporary before saving.
|
|
27
32
|
2. **The loop:** `monty create <slug> --name "Name" --icon <tabler-icon>` →
|
|
28
33
|
(if the prompt includes a `build id`, pass it: `--build <id>` — the
|
|
29
34
|
workspace's New app screen tracks your progress live) →
|
|
30
|
-
`monty install` →
|
|
31
|
-
|
|
35
|
+
`monty install` → shape the schema through `monty schema` /
|
|
36
|
+
`monty schema set` (a brand-new app's very first session lands its
|
|
37
|
+
monty.config.ts once; after that the workspace owns the schema) + edit
|
|
38
|
+
`src/routes/` →
|
|
39
|
+
verify in the session: run `monty dev` once — if the dev shell is already
|
|
32
40
|
running (the Monty desktop usually runs it for you) it prints the status,
|
|
33
|
-
|
|
41
|
+
app URL, and recent log lines, then **exits immediately**; if nothing
|
|
34
42
|
is running it starts the shell (start it in the background and move on).
|
|
35
43
|
Then iterate: edit code → vite hot-reloads → `monty logs -n 50` shows
|
|
36
|
-
whether it compiled and any browser errors.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
whether it compiled and any browser errors. Re-running `monty dev` is
|
|
45
|
+
always safe — it attaches, prints status, and exits. Never try to run a
|
|
46
|
+
second dev *server* for the same app (attach handles this for you) and
|
|
47
|
+
never kill a dev shell you didn't start; `monty dev --takeover` is the
|
|
48
|
+
only sanctioned restart when a session is wedged. **You are not done
|
|
49
|
+
until you've saved: once the work is verified in the session, run
|
|
50
|
+
`monty save "<what changed>"`** — it builds, typechecks, and pushes the
|
|
51
|
+
working copy to the cloud copy, like `git push main`. Save after every
|
|
52
|
+
meaningful change, not just at the end; unsaved work exists only on this
|
|
53
|
+
machine.
|
|
45
54
|
3. **Everything through the CLI.** `monty install`, `monty build`,
|
|
46
|
-
`monty typecheck`, `monty dev`, `monty
|
|
55
|
+
`monty typecheck`, `monty dev`, `monty save` — never run vite, tsc,
|
|
47
56
|
pnpm, or npm scripts directly. `monty dev` auto-picks a free port and
|
|
48
57
|
prints it; `monty typecheck` builds first when needed. `monty logs`
|
|
49
58
|
(add `-f` to follow) is how you read the dev shell's output — vite build
|
|
50
|
-
errors, browser errors, and
|
|
59
|
+
errors, browser errors, and save results all land there.
|
|
51
60
|
4. **One import surface:** `@montytools/sdk` (`defineApp`, zod) and
|
|
52
61
|
`@montytools/sdk/react` (hooks: `useList`, `useInsert`, …). Never import
|
|
53
62
|
Clerk or Convex directly; never fetch external APIs from app code — the
|
|
54
63
|
platform CSP blocks them.
|
|
55
|
-
5. **
|
|
56
|
-
`
|
|
64
|
+
5. **The data schema lives in the WORKSPACE, not in code.** Read it with
|
|
65
|
+
`monty schema` (JSON on stdout); change it by editing that JSON and
|
|
66
|
+
running `monty schema set <file>` — validated server-side, additive by
|
|
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.
|
|
57
71
|
6. **UI is stock shadcn** (preset already wired). Add curated components with
|
|
58
72
|
`monty add <name>`; browse with `monty components` / `monty docs <name>`.
|
|
73
|
+
How pages should LOOK — Lyra surfaces, dark-only, the chart language — is
|
|
74
|
+
the `monty-design` skill; read it before styling any page.
|
|
59
75
|
7. **Errors are instructions.** Every failure prints
|
|
60
76
|
`[MontyError CODE] Fix: …` — do exactly what the Fix says; don't guess.
|
|
61
|
-
Typecheck failures block
|
|
62
|
-
8. **
|
|
63
|
-
|
|
64
|
-
real
|
|
77
|
+
Typecheck failures block the save by design.
|
|
78
|
+
8. **Edits are real.** The dev shell reads and writes the app's LIVE
|
|
79
|
+
records — there is one set of data, and every write journals into the
|
|
80
|
+
app's Activity. Exercise the app for real; clean up test rows you
|
|
81
|
+
create; never seed junk into a team's working tables.
|
|
65
82
|
|
|
66
83
|
## CLI reference
|
|
67
84
|
|
|
@@ -71,8 +88,10 @@ names for them; "dev"/"prod" mean something else on this platform.
|
|
|
71
88
|
| `monty create <slug>` | register the app in the workspace + stamp it into `~/.monty/apps/<id>` (needs login) |
|
|
72
89
|
| `monty current` / `select` / `apps` | where am I / jump to app / list local |
|
|
73
90
|
| `monty install` / `build` / `typecheck` | full lifecycle via the CLI — no raw pnpm/vite/tsc |
|
|
74
|
-
| `monty dev` | run the app
|
|
91
|
+
| `monty dev` | run the app's session, or attach to an already-running one (auto-port, live data, auto-auth) |
|
|
75
92
|
| `monty logs [-n N] [-f]` | read/follow the dev shell log — the debugging window after every edit |
|
|
76
93
|
| `monty add <name…>` | install curated shadcn components |
|
|
77
|
-
| `monty
|
|
94
|
+
| `monty schema [slug]` | print the app's stored manifest (tables, pages, metrics) as JSON |
|
|
95
|
+
| `monty schema set <file\|->` | write an edited manifest back (validated, CAS, additive by default) |
|
|
96
|
+
| `monty save ["what changed"]` | push the working copy to the cloud copy, like `git push main` (build + typecheck gate it) |
|
|
78
97
|
| `monty skills` | (re)install this skill for your agent |
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: monty-design
|
|
3
|
+
description: Design Monty app pages — Lyra shadcn styling (borderless, sharp-cornered, stock components only), dark-only theming, and the Monty chart language (square marks on real axes). Use whenever building or restyling UI in a Monty app; pages, dashboards, charts, stat tiles, KPI rows, tables, or any prompt about how a Monty app should look.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Designing Monty pages
|
|
7
|
+
|
|
8
|
+
Monty is a B2B work OS. Apps render inside the platform shell, so a page is
|
|
9
|
+
"designed" when it looks native to Monty: quiet, rectilinear, data-forward.
|
|
10
|
+
The design system is already installed — your job is to NOT fight it.
|
|
11
|
+
|
|
12
|
+
## The one law: stock Lyra, nothing invented
|
|
13
|
+
|
|
14
|
+
Every app ships shadcn preset `radix-lyra` (see `components.json`). Lyra
|
|
15
|
+
surfaces are **borderless and sharp-cornered**: `Card` is `rounded-none`,
|
|
16
|
+
no border, a `bg-card` fill with a hairline `ring-1 ring-foreground/10`.
|
|
17
|
+
|
|
18
|
+
- Use the components as they come: `Card`/`CardHeader`/`CardTitle`/
|
|
19
|
+
`CardDescription`/`CardContent`, `Table`, `Badge`, … Never rebuild a
|
|
20
|
+
surface as a styled `div` — a hand-rolled `rounded-xl border bg-…` card is
|
|
21
|
+
the canonical mistake.
|
|
22
|
+
- Never invent tokens or raw colors. Semantic tokens only (`bg-background`,
|
|
23
|
+
`text-muted-foreground`, `border-border`, `var(--chart-2)`, …).
|
|
24
|
+
- Sanctioned overrides are content-level only: e.g. `text-2xl tabular-nums`
|
|
25
|
+
on a stat value, a width on a label column. If an override styles a
|
|
26
|
+
SURFACE, you are off the system.
|
|
27
|
+
|
|
28
|
+
## Dark-only, in the shell's palette
|
|
29
|
+
|
|
30
|
+
The Monty shell is dark-only; a light page inside it reads as broken. The
|
|
31
|
+
app theme's `.dark` tokens mirror the shell palette (#101112 ground,
|
|
32
|
+
#17181A cards, #266DF0 primary) so embedded pages are seamless — never
|
|
33
|
+
retheme or hand-pick your own dark colors. Until
|
|
34
|
+
the template ships dark by default: `class="dark"` on `<html>` in
|
|
35
|
+
`index.html`, and any boot-splash background set to the dark `--background`
|
|
36
|
+
value. Verify your page against the shell, not in isolation.
|
|
37
|
+
|
|
38
|
+
## Page anatomy
|
|
39
|
+
|
|
40
|
+
Every page opens with the platform chrome from `@montytools/sdk/ui` — the
|
|
41
|
+
SAME components the shell renders system table views with, so a custom page
|
|
42
|
+
is indistinguishable from a record page. Never hand-roll the header bar.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { PageHeader, PageHeaderButton } from "@montytools/sdk/ui";
|
|
46
|
+
|
|
47
|
+
<div className="flex h-full min-h-dvh flex-col bg-background">
|
|
48
|
+
<PageHeader icon={ChartColumn} title="Page title" meta="context (count, filter)">
|
|
49
|
+
<PageHeaderButton onClick={secondary}>Export</PageHeaderButton>
|
|
50
|
+
<PageHeaderButton primary onClick={main}><Plus className="size-3.5" /> New</PageHeaderButton>
|
|
51
|
+
</PageHeader>
|
|
52
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
53
|
+
<div className="flex flex-col gap-3 p-6 pt-4">
|
|
54
|
+
{/* KPI row */}
|
|
55
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-3">…</div>
|
|
56
|
+
{/* section cards */}
|
|
57
|
+
<div className="grid gap-3 lg:grid-cols-2">…</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Also in `@montytools/sdk/ui`: `FloatingBar` + `FloatingBarButton` (the
|
|
64
|
+
bottom-center bar for bulk-selection actions and mode strips) and the Lyra
|
|
65
|
+
table classes `SURFACE`/`THEAD`/`TH`/`ROW`/`CHIP` — record-like tables are
|
|
66
|
+
`<div className={SURFACE}><table>…` with those classes, identical to the
|
|
67
|
+
shell's Configuration surfaces.
|
|
68
|
+
|
|
69
|
+
A stat tile is a stock Card, nothing more:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
<Card size="sm">
|
|
73
|
+
<CardHeader>
|
|
74
|
+
<CardDescription>Leads added</CardDescription>
|
|
75
|
+
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
|
76
|
+
<div className="text-xs text-muted-foreground">{delta or context}</div>
|
|
77
|
+
</CardHeader>
|
|
78
|
+
</Card>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Charts: sharp marks on real axes
|
|
82
|
+
|
|
83
|
+
Chart marks are **sharp rectangles — no rounded corners, ever** (matches the
|
|
84
|
+
Lyra rectilinear look). The three failure modes to avoid: pill "track+fill"
|
|
85
|
+
bars (read as progress bars), rounded caps (misstate where a value ends),
|
|
86
|
+
and floating bars with no axis (nothing anchors the eye).
|
|
87
|
+
|
|
88
|
+
- **Horizontal bars**: grow from a left hairline baseline
|
|
89
|
+
(`border-l border-foreground/25`) over quarter gridlines
|
|
90
|
+
(`absolute left-1/4|1/2|3/4 border-l border-foreground/10` —
|
|
91
|
+
foreground-alpha so hairlines read on any surface), bar `h-4`,
|
|
92
|
+
fill `var(--chart-2)`. Label left in `text-muted-foreground`
|
|
93
|
+
(fixed-width, truncate); count right in `font-medium tabular-nums`,
|
|
94
|
+
zero values muted with no fill.
|
|
95
|
+
- **Columns**: a value axis with a baseline (`border-foreground/25`) plus
|
|
96
|
+
hairline gridlines (`border-foreground/10`) and tiny tabular tick labels (10px, muted,
|
|
97
|
+
right-aligned in a left gutter). Round the axis top to a "nice" integer
|
|
98
|
+
(≤4 exact; else the next multiple of 5/10/50) so ticks stay honest.
|
|
99
|
+
Direct value labels above non-zero columns.
|
|
100
|
+
- **Color**: one accent ramp from the Monty palette — `var(--chart-2)` for
|
|
101
|
+
the emphasized series (today, the selection), `var(--chart-5)` for
|
|
102
|
+
context. The values are the platform's (Attio-blue family, matching the
|
|
103
|
+
shell); never restate them as hex, never a hue per category, and text
|
|
104
|
+
never wears the data color.
|
|
105
|
+
- **Numbers**: `tabular-nums` everywhere values align; money via
|
|
106
|
+
`toLocaleString(undefined, { style: "currency", currency: "USD" })`
|
|
107
|
+
(compact notation on tiles, full in tables).
|
|
108
|
+
- **Empty states**: keep the axis and gridlines rendered with an honest
|
|
109
|
+
muted line ("No activity logged on this day.") — structure stays, zeros
|
|
110
|
+
carry meaning.
|
|
111
|
+
|
|
112
|
+
Reference implementations: `ColumnChart` and `BarRow` in
|
|
113
|
+
`demos/crm/src/routes/stats.tsx` (day-selector columns, status bars) and
|
|
114
|
+
`demos/crm-v2/src/routes/stats.tsx` (money detail on bars, top-deals table).
|
|
115
|
+
|
|
116
|
+
## Checklist before you call a page done
|
|
117
|
+
|
|
118
|
+
1. No `border`/`rounded-*` on any surface you authored — surfaces are stock
|
|
119
|
+
Lyra components.
|
|
120
|
+
2. No rounded corners on any chart mark; every bar/column sits on an axis.
|
|
121
|
+
3. Dark: the page blends into the shell with no light seams.
|
|
122
|
+
4. Values in tabular figures; labels in text tokens; one accent hue.
|
|
123
|
+
5. Screenshot it inside the shell (`/apps/<slug>/…`), not just the dev port.
|
|
@@ -11,10 +11,8 @@ and write that data directly from the terminal: no browser session, no dev
|
|
|
11
11
|
server. You do the work (browse, scrape, decide, transform); the app is
|
|
12
12
|
where the results land, and open app tabs update live as you write.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
seen by the team). Writing to Live is normal operating work; that is what
|
|
17
|
-
this command is for.
|
|
14
|
+
One set of records per app — the team's real data. Writing to it is
|
|
15
|
+
normal operating work; that is what this command is for.
|
|
18
16
|
|
|
19
17
|
## The loop
|
|
20
18
|
|
|
@@ -43,10 +41,10 @@ this command is for.
|
|
|
43
41
|
2. **Schema first, rows second.** Field names come from
|
|
44
42
|
`monty data schema`, exact spelling. The server stores what you send —
|
|
45
43
|
a misspelled field is silently a new field, not an error.
|
|
46
|
-
3. **
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
3. **Every write is real** — operating work targets the team's data, and
|
|
45
|
+
the app's Activity journals it. Destructive verbs (`remove`, bulk
|
|
46
|
+
`update`) deserve a confirmation with the user unless they clearly
|
|
47
|
+
asked for the cleanup.
|
|
50
48
|
4. **Batch with arrays.** `--data` accepts a single object or an array;
|
|
51
49
|
arrays write row by row and return all ids in order.
|
|
52
50
|
5. **Paginate honestly.** `list` returns `"cursor": null` when complete; a
|
|
@@ -72,7 +70,7 @@ this command is for.
|
|
|
72
70
|
| `monty data update <table> <id> --data '<json>' [--unset a,b]` | shallow-merge onto one row; `--unset` deletes fields |
|
|
73
71
|
| `monty data remove <table> <id>` | delete one row |
|
|
74
72
|
|
|
75
|
-
All verbs take `--app <slug
|
|
73
|
+
All verbs take `--app <slug>`. Output is one JSON document on
|
|
76
74
|
stdout.
|
|
77
75
|
|
|
78
76
|
## Example: import scraped leads into a CRM app
|