@montytools/cli 0.4.0 → 0.4.2

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
@@ -11,13 +11,20 @@ import { StringDecoder } from "node:string_decoder";
11
11
  import { createServer } from "node:http";
12
12
  import { connect as netConnect } from "node:net";
13
13
  import { homedir } from "node:os";
14
- import { basename, dirname, join, relative } from "node:path";
14
+ import { basename, dirname, join, relative, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { createInterface } from "node:readline/promises";
17
17
  import { CATALOG, REGISTRIES } from "./catalog.mjs";
18
18
  import { CompileError, compileAppConfig } from "../lib/compile.mjs";
19
19
 
20
- const CONFIG_DIR = join(homedir(), ".monty");
20
+ // MONTY_HOME overrides the state root (default ~/.monty): config.json,
21
+ // apps/, and desktop.json all live under it. This is how a second, isolated
22
+ // Monty state coexists on one machine — the desktop's dev channel points it
23
+ // at ~/.monty-dev so platform development never touches the real state. A
24
+ // custom root is a sandbox: the legacy visible home (~/Monty) is not scanned.
25
+ const CONFIG_DIR = process.env.MONTY_HOME
26
+ ? resolve(process.env.MONTY_HOME)
27
+ : join(homedir(), ".monty");
21
28
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
22
29
  const DEFAULT_HOST = "https://usemonty.dev";
23
30
  const DEV_SESSION_HEARTBEAT_MS = 30_000;
@@ -336,7 +343,11 @@ function scanAppsHome(root) {
336
343
  }
337
344
 
338
345
  function listLocalApps() {
339
- return [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
346
+ // A MONTY_HOME sandbox lists only its own apps — leaking the legacy home
347
+ // into an isolated root would defeat the isolation.
348
+ return process.env.MONTY_HOME
349
+ ? scanAppsHome(MONTY_HOME)
350
+ : [...scanAppsHome(MONTY_HOME), ...scanAppsHome(LEGACY_MONTY_HOME)];
340
351
  }
341
352
 
342
353
  function current() {
@@ -661,6 +672,19 @@ async function create() {
661
672
  },
662
673
  });
663
674
 
675
+ // The bundled template ships `.gitignore` as `gitignore` (npm-packlist
676
+ // hard-excludes the real name from tarballs); the in-repo template has the
677
+ // real file. Normalize, and backfill for bundles that carried neither —
678
+ // without a .gitignore, tailwind v4's content scan includes .monty/ and
679
+ // full-reloads Studio on every dev.json touch.
680
+ const gitignorePath = join(target, ".gitignore");
681
+ if (existsSync(join(target, "gitignore"))) {
682
+ renameSync(join(target, "gitignore"), gitignorePath);
683
+ }
684
+ if (!existsSync(gitignorePath)) {
685
+ writeFileSync(gitignorePath, "node_modules/\ndist/\n.monty/\n.env.local\n.env\n");
686
+ }
687
+
664
688
  // Stamp identity into the copied files. The id line is INSERTED (the
665
689
  // template ships without one — only real creates have a server id).
666
690
  const configPath = join(target, "monty.config.ts");
@@ -795,7 +819,7 @@ async function freePort(start) {
795
819
  // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
796
820
  // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
797
821
  // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
798
- const MIN_SDK = "0.1.3";
822
+ const MIN_SDK = "0.1.5";
799
823
  const SDK_VITE_CACHE_STAMP = "sdk-vite-cache-version";
800
824
 
801
825
  function installedSdkVersion(appDir) {
@@ -2260,6 +2284,265 @@ function cronFieldMatches(field, value, [lo, hi], idx) {
2260
2284
  return false;
2261
2285
  }
2262
2286
 
2287
+ // ── monty data ─────────────────────────────────────────────────────────────
2288
+ // The agent verbs for OPERATING an app: read and write its records from any
2289
+ // terminal — no browser, no dev session. Auth is the mk_ key exchanged at
2290
+ // /api/dev-token for a 5-minute workspace token (member lane, org_id from
2291
+ // the verified JWT), then the 6 public records functions over Convex's HTTP
2292
+ // API. `app` picks the records namespace: the plain slug is Live; --studio
2293
+ // targets "{slug}#dev" (the Studio sandbox — same wire literal the dev
2294
+ // shell uses). Results are ONE JSON document on stdout so agents can pipe.
2295
+
2296
+ const DATA_VALUE_FLAGS = new Set(["app", "filter", "order", "limit", "cursor", "data", "key", "unset", "host"]);
2297
+
2298
+ // rest, minus flags AND their values — `monty data list leads --app crm`
2299
+ // must not read "crm" as a positional. Boolean flags (--studio) have no
2300
+ // value and are skipped alone.
2301
+ function dataPositionals() {
2302
+ const out = [];
2303
+ for (let i = 0; i < rest.length; i++) {
2304
+ const a = rest[i];
2305
+ if (a.startsWith("--")) {
2306
+ if (DATA_VALUE_FLAGS.has(a.slice(2))) i++;
2307
+ continue;
2308
+ }
2309
+ out.push(a);
2310
+ }
2311
+ return out;
2312
+ }
2313
+
2314
+ function parseJsonFlag(name) {
2315
+ const raw = flag(name);
2316
+ if (raw === undefined) return undefined;
2317
+ try {
2318
+ return JSON.parse(raw);
2319
+ } catch {
2320
+ fail("BAD_JSON", `--${name} is not valid JSON. Quote the whole value in single quotes, e.g. --${name} '{"field":"value"}'. Got: ${raw.slice(0, 120)}`);
2321
+ }
2322
+ }
2323
+
2324
+ // Which records namespace a data verb targets. Slug from --app or the
2325
+ // surrounding app folder; existence is decided server-side (an unknown slug
2326
+ // simply has zero records — list makes that visible immediately).
2327
+ function resolveDataApp() {
2328
+ const explicit = flag("app");
2329
+ const root = findAppRoot(process.cwd());
2330
+ const slug = explicit ?? (root ? readSlugFromConfig(root) : null);
2331
+ if (!slug) {
2332
+ fail("NO_APP", "Say which app: pass --app <slug>, or run inside an app folder. `monty apps` lists local apps.");
2333
+ }
2334
+ return rest.includes("--studio") ? `${slug}#dev` : slug;
2335
+ }
2336
+
2337
+ // mk_ key → { convexUrl, token }. The token is workspace-scoped and expires
2338
+ // in 5 minutes — minted fresh per invocation, never stored.
2339
+ async function dataAuth() {
2340
+ const { host, key } = loadConfig();
2341
+ if (!key) fail("NOT_LOGGED_IN", `Reading and writing app data needs your workspace (${host}). Run \`monty login\` first.`);
2342
+ let convexUrl = null;
2343
+ try {
2344
+ convexUrl = (await fetch(`${host}/api/config`).then((r) => r.json()))?.convexUrl;
2345
+ } catch { /* handled below */ }
2346
+ if (!convexUrl) fail("HOST_UNREACHABLE", `Could not read ${host}/api/config — is the host up and the network reachable?`);
2347
+ const r = await fetch(`${host}/api/dev-token`, {
2348
+ method: "POST",
2349
+ headers: { authorization: `Bearer ${key}` },
2350
+ });
2351
+ const body = await r.json().catch(() => null);
2352
+ if (!r.ok || !body?.token) {
2353
+ fail(body?.code ?? `HTTP_${r.status}`, body?.fix ?? "Minting a workspace token failed. Run `monty login`, then retry.");
2354
+ }
2355
+ return { convexUrl, token: body.token };
2356
+ }
2357
+
2358
+ // One records function over Convex's public HTTP API (plain-JSON format —
2359
+ // app data is JSON by construction, no convex encoding needed). ConvexError
2360
+ // payloads carry { code, fix } and surface verbatim: errors stay
2361
+ // instructions in the terminal the agent is watching.
2362
+ async function callRecords(kind, fn, args, auth) {
2363
+ let body = null;
2364
+ try {
2365
+ const r = await fetch(`${auth.convexUrl}/api/${kind}`, {
2366
+ method: "POST",
2367
+ headers: { "content-type": "application/json", authorization: `Bearer ${auth.token}` },
2368
+ body: JSON.stringify({ path: `records:${fn}`, args, format: "json" }),
2369
+ });
2370
+ body = await r.json().catch(() => null);
2371
+ } catch { /* handled below */ }
2372
+ if (!body) fail("CONVEX_UNREACHABLE", `The data backend at ${auth.convexUrl} did not answer — check the network and retry.`);
2373
+ if (body.status !== "success") {
2374
+ const d = body.errorData;
2375
+ if (d && typeof d === "object" && d.code) fail(d.code, d.fix ?? body.errorMessage ?? "See the error code.");
2376
+ fail("CONVEX_ERROR", body.errorMessage ?? "Unknown data-layer error — retry; if it persists, report it.");
2377
+ }
2378
+ return body.value;
2379
+ }
2380
+
2381
+ function printJson(value) {
2382
+ console.log(JSON.stringify(value, null, 2));
2383
+ }
2384
+
2385
+ // The documented row shape everywhere on the platform (SDK hooks, server
2386
+ // functions) is FLATTENED: app fields at the top level + the four system
2387
+ // fields. Mirror it here — and don't echo workspaceId/appId/table back;
2388
+ // scope is the caller's own arguments, not row payload.
2389
+ function flattenRow(doc) {
2390
+ if (!doc) return null;
2391
+ return {
2392
+ ...doc.data,
2393
+ _id: doc._id,
2394
+ _creationTime: doc._creationTime,
2395
+ updatedAt: doc.updatedAt,
2396
+ createdBy: doc.createdBy,
2397
+ };
2398
+ }
2399
+
2400
+ function dataUsage() {
2401
+ console.log("usage: monty data <verb> [table] [flags] read/write an app's Live records (add --studio for the Studio sandbox)");
2402
+ console.log(" schema [table] the app's table shapes (from local monty.config.ts — `monty pull` first if needed)");
2403
+ console.log(" list <table> [--filter '{\"k\":\"v\"}'] [--order asc|desc] [--limit N] [--cursor C]");
2404
+ console.log(" get <table> <id>");
2405
+ console.log(" insert <table> --data '<json|[json,…]>'");
2406
+ console.log(" update <table> <id> --data '<json>' [--unset field,field]");
2407
+ console.log(" upsert <table> --key <field[,field]> --data '<json|[json,…]>' find-or-create matched on the key fields (idempotent)");
2408
+ console.log(" remove <table> <id>");
2409
+ console.log("target: --app <slug> (or run inside the app folder); default is LIVE data — --studio targets the sandbox");
2410
+ process.exit(1);
2411
+ }
2412
+
2413
+ async function data() {
2414
+ const [verb, table, id] = dataPositionals();
2415
+
2416
+ if (verb === "schema") {
2417
+ // Shape comes from the LOCAL source checkout (compiled through the real
2418
+ // pipeline) — the same monty.config.ts that defines what the app stores.
2419
+ const explicit = flag("app");
2420
+ const root = explicit
2421
+ ? (listLocalApps().find((a) => a.slug === explicit || a.id === explicit)?.path ?? null)
2422
+ : findAppRoot(process.cwd());
2423
+ if (!root) {
2424
+ fail("APP_NOT_LOCAL", explicit
2425
+ ? `No local source for "${explicit}" on this machine — run \`monty pull ${explicit}\` first, or cd into the app folder.`
2426
+ : "Not inside an app folder. Pass --app <slug> (needs the source pulled locally) or cd into the app.");
2427
+ }
2428
+ let meta;
2429
+ try {
2430
+ meta = await compileAppConfig(root);
2431
+ } catch (e) {
2432
+ if (e instanceof CompileError) fail(e.code, e.fix);
2433
+ throw e;
2434
+ }
2435
+ const tables = meta.schemaJson?.tables ?? {};
2436
+ if (table !== undefined) {
2437
+ if (!tables[table]) {
2438
+ fail("NO_SUCH_TABLE", `App "${meta.slug}" has no table "${table}". Tables: ${Object.keys(tables).join(", ") || "(none)"}.`);
2439
+ }
2440
+ printJson({ app: meta.slug, table, schema: tables[table] });
2441
+ } else {
2442
+ printJson({ app: meta.slug, tables });
2443
+ }
2444
+ return;
2445
+ }
2446
+
2447
+ const VERBS = new Set(["list", "get", "insert", "update", "upsert", "remove"]);
2448
+ if (!verb || !VERBS.has(verb)) dataUsage();
2449
+ if (!table) fail("MISSING_TABLE", `\`monty data ${verb}\` needs a table name: monty data ${verb} <table> … (\`monty data schema\` lists tables).`);
2450
+
2451
+ const app = resolveDataApp();
2452
+ const auth = await dataAuth();
2453
+
2454
+ switch (verb) {
2455
+ case "list": {
2456
+ const filter = parseJsonFlag("filter");
2457
+ const order = flag("order");
2458
+ if (order !== undefined && order !== "asc" && order !== "desc") {
2459
+ fail("BAD_ORDER", `--order must be "asc" or "desc" (default desc, newest first). Got: ${order}`);
2460
+ }
2461
+ const limit = Math.min(Math.max(parseInt(flag("limit") ?? "100", 10) || 100, 1), 1024);
2462
+ const value = await callRecords("query", "list", {
2463
+ app,
2464
+ table,
2465
+ ...(filter !== undefined ? { filter } : {}),
2466
+ ...(order !== undefined ? { order } : {}),
2467
+ paginationOpts: { numItems: limit, cursor: flag("cursor") ?? null },
2468
+ }, auth);
2469
+ printJson({
2470
+ rows: value.page.map(flattenRow),
2471
+ count: value.page.length,
2472
+ // A non-null cursor means MORE rows exist: repeat with --cursor <c>.
2473
+ cursor: value.isDone ? null : value.continueCursor,
2474
+ });
2475
+ return;
2476
+ }
2477
+ case "get": {
2478
+ if (!id) fail("MISSING_ID", "Usage: monty data get <table> <id> — ids come from list on the same table.");
2479
+ printJson(flattenRow(await callRecords("query", "get", { app, table, id }, auth)));
2480
+ return;
2481
+ }
2482
+ case "insert": {
2483
+ const input = parseJsonFlag("data");
2484
+ if (input === undefined) fail("MISSING_DATA", `Usage: monty data insert ${table} --data '{"field":"value"}' — an array inserts each element.`);
2485
+ const rows = Array.isArray(input) ? input : [input];
2486
+ const ids = [];
2487
+ for (const row of rows) {
2488
+ ids.push(await callRecords("mutation", "insert", { app, table, data: row }, auth));
2489
+ }
2490
+ printJson(Array.isArray(input) ? { ids } : { id: ids[0] });
2491
+ return;
2492
+ }
2493
+ case "update": {
2494
+ if (!id) fail("MISSING_ID", "Usage: monty data update <table> <id> --data '{…}' — ids come from list/get on the same table.");
2495
+ const patch = parseJsonFlag("data");
2496
+ if (patch === undefined) fail("MISSING_DATA", `Usage: monty data update ${table} ${id} --data '{"field":"newValue"}' (shallow-merged; --unset a,b removes fields).`);
2497
+ const unsetRaw = flag("unset");
2498
+ await callRecords("mutation", "update", {
2499
+ app,
2500
+ table,
2501
+ id,
2502
+ data: patch,
2503
+ ...(unsetRaw ? { unset: unsetRaw.split(",").map((s) => s.trim()).filter(Boolean) } : {}),
2504
+ }, auth);
2505
+ printJson({ ok: true, id });
2506
+ return;
2507
+ }
2508
+ case "upsert": {
2509
+ // --key names the FIELDS that identify a row (e.g. --key linkedinUrl);
2510
+ // values come from each data row, so one flag serves single and batch
2511
+ // writes alike — and re-running an import never duplicates.
2512
+ const keyFields = (flag("key") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2513
+ if (keyFields.length === 0) {
2514
+ fail("MISSING_KEY", `Usage: monty data upsert ${table} --key <field[,field]> --data '{…}' — key fields identify the row to match on.`);
2515
+ }
2516
+ const input = parseJsonFlag("data");
2517
+ if (input === undefined) fail("MISSING_DATA", `Usage: monty data upsert ${table} --key ${keyFields.join(",")} --data '{"${keyFields[0]}":"…", …}' — an array upserts each element.`);
2518
+ const rows = Array.isArray(input) ? input : [input];
2519
+ const ids = [];
2520
+ for (const row of rows) {
2521
+ const key = {};
2522
+ for (const f of keyFields) {
2523
+ if (row[f] === undefined) {
2524
+ fail("MISSING_KEY_FIELD", `A data row is missing key field "${f}" — every row must carry its own key values. Row: ${JSON.stringify(row).slice(0, 120)}`);
2525
+ }
2526
+ key[f] = row[f];
2527
+ }
2528
+ // patch = the caller's fields (merged over an existing row); full =
2529
+ // the stored row on insert. The CLI applies no zod defaults, so they
2530
+ // are the same object here — `monty data schema` shows what a
2531
+ // complete row needs.
2532
+ ids.push(await callRecords("mutation", "upsert", { app, table, key, patch: row, full: row }, auth));
2533
+ }
2534
+ printJson(Array.isArray(input) ? { ids } : { id: ids[0] });
2535
+ return;
2536
+ }
2537
+ case "remove": {
2538
+ if (!id) fail("MISSING_ID", "Usage: monty data remove <table> <id> — ids come from list/get on the same table.");
2539
+ await callRecords("mutation", "remove", { app, table, id }, auth);
2540
+ printJson({ ok: true, id, removed: true });
2541
+ return;
2542
+ }
2543
+ }
2544
+ }
2545
+
2263
2546
  // ── dispatch ───────────────────────────────────────────────────────────────
2264
2547
  // Keep agent skills fresh on every invocation (user level + current app).
2265
2548
  // `dev` and `logs` skip the refresh here: attach and log reads must stay
@@ -2327,11 +2610,14 @@ switch (command) {
2327
2610
  case "deploy":
2328
2611
  await deploy();
2329
2612
  break;
2613
+ case "data":
2614
+ await data();
2615
+ break;
2330
2616
  case "secret":
2331
2617
  await secret();
2332
2618
  break;
2333
2619
  default:
2334
- console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|skills>");
2620
+ console.log("usage: monty <login|create|pull|commit|log|current|select|apps|install|dev|logs|build|typecheck|add|components|docs|deploy|data|skills>");
2335
2621
  console.log(" login [--host <url>] [--key <mk_...>] sign in (opens your browser to authorize)");
2336
2622
  console.log(" create <slug> [--name N] [--icon I] [--build ID] register + stamp a new app into ~/.monty/apps/<id> (needs login)");
2337
2623
  console.log(" pull <slug> [--version H] [--force] restore the app's source snapshot (latest, or one from `monty log`)");
@@ -2349,6 +2635,7 @@ switch (command) {
2349
2635
  console.log(" build production build (vite, via monty)");
2350
2636
  console.log(" typecheck typecheck (builds first if needed)");
2351
2637
  console.log(" deploy build + upload this app straight to Live");
2638
+ console.log(" data <verb> [table] [flags] read/write an app's records from the terminal (`monty data` for verbs)");
2352
2639
  console.log(" skills install/refresh the agent build skill");
2353
2640
  process.exit(command ? 1 : 0);
2354
2641
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/TomasMonty/monty-v2.git",
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: monty-operate
3
+ description: Operate a team's Monty workspace apps from the terminal — read and write a Live app's records with `monty data`. Use whenever the task is to put data INTO an existing Monty app or read data from one: add leads to a CRM, import scraped or external data, update or clean up records, check what an app currently stores, or automate recurring work against apps on usemonty.dev. Building or changing an app itself is monty-build, not this.
4
+ ---
5
+
6
+ # Operating Monty apps
7
+
8
+ A Monty workspace runs internal apps (CRMs, trackers, dashboards) whose data
9
+ lives on the platform. `monty data` is how you — the operating agent — read
10
+ and write that data directly from the terminal: no browser session, no dev
11
+ server. You do the work (browse, scrape, decide, transform); the app is
12
+ where the results land, and open app tabs update live as you write.
13
+
14
+ Two data channels per app: **Live** (the team's real records — the default
15
+ target) and the **Studio sandbox** (`--studio` — development data, never
16
+ seen by the team). Writing to Live is normal operating work; that is what
17
+ this command is for.
18
+
19
+ ## The loop
20
+
21
+ 1. **Find the app.** `monty apps` lists local apps; run inside the app
22
+ folder (`cd "$(monty select <slug>)"`) or pass `--app <slug>` to every
23
+ command.
24
+ 2. **Learn the shape — never guess.** `monty data schema` prints every
25
+ table; `monty data schema <table>` one table. (Schema compiles from the
26
+ local source — if the app isn't on this machine, `monty pull <slug>`
27
+ first.)
28
+ 3. **See what's there.** `monty data list <table> --limit 20` before
29
+ writing: learn the field conventions from real rows and avoid duplicating
30
+ what exists.
31
+ 4. **Write.** Prefer `upsert` with a `--key` for anything imported or
32
+ scraped; `insert` only for rows that are genuinely new by construction.
33
+ 5. **Verify.** `list`/`get` the rows you touched; report counts (added,
34
+ updated, skipped) with specifics.
35
+
36
+ ## Rules
37
+
38
+ 1. **Imports are idempotent.** Any repeatable job (scraping, syncing,
39
+ importing) writes with
40
+ `monty data upsert <table> --key <identityField> --data '[…]'` — the key
41
+ names the field(s) that identify a row (a URL, an email, an external id),
42
+ values come from each row, and re-running never duplicates.
43
+ 2. **Schema first, rows second.** Field names come from
44
+ `monty data schema`, exact spelling. The server stores what you send —
45
+ a misspelled field is silently a new field, not an error.
46
+ 3. **Live is the default and that's correct** — operating work targets the
47
+ team's real data. Use `--studio` only when explicitly rehearsing.
48
+ Destructive verbs (`remove`, bulk `update`) on Live deserve a
49
+ confirmation with the user unless they clearly asked for the cleanup.
50
+ 4. **Batch with arrays.** `--data` accepts a single object or an array;
51
+ arrays write row by row and return all ids in order.
52
+ 5. **Paginate honestly.** `list` returns `"cursor": null` when complete; a
53
+ non-null cursor means more rows exist — repeat with `--cursor <c>`
54
+ before claiming totals.
55
+ 6. **Errors are instructions.** Every failure prints
56
+ `[MontyError CODE] Fix: …` — do what the Fix says. `NOT_LOGGED_IN` means
57
+ run `monty login` (once per machine); `NOT_FOUND` on an id means it came
58
+ from the wrong table or a stale listing — re-`list`, never retry blind.
59
+ 7. **Rows come back flattened**: your fields at the top level plus system
60
+ fields `_id`, `_creationTime`, `updatedAt`, `createdBy`. System fields
61
+ are read-only — never send them in `--data`.
62
+
63
+ ## Command reference
64
+
65
+ | command | purpose |
66
+ |---|---|
67
+ | `monty data schema [table]` | table shapes from the app's config — read this before any write |
68
+ | `monty data list <table> [--filter '{"k":"v"}'] [--order asc\|desc] [--limit N] [--cursor C]` | newest-first rows; filter is exact-match on fields |
69
+ | `monty data get <table> <id>` | one row (null if the id isn't in this table) |
70
+ | `monty data insert <table> --data '<json\|[json,…]>'` | create rows |
71
+ | `monty data upsert <table> --key <field[,field]> --data '<json\|[json,…]>'` | find-or-create matched on the key fields — the idempotent write |
72
+ | `monty data update <table> <id> --data '<json>' [--unset a,b]` | shallow-merge onto one row; `--unset` deletes fields |
73
+ | `monty data remove <table> <id>` | delete one row |
74
+
75
+ All verbs take `--app <slug>` and `--studio`. Output is one JSON document on
76
+ stdout.
77
+
78
+ ## Example: import scraped leads into a CRM app
79
+
80
+ ```sh
81
+ monty data schema leads --app crm # shape: name, company, title, linkedinUrl, status…
82
+ monty data list leads --app crm --limit 50 # what's already in the pipeline
83
+ # …browse/scrape/decide, then land the batch idempotently:
84
+ monty data upsert leads --app crm --key linkedinUrl --data '[
85
+ {"name":"Dana Reeve","company":"Acme","title":"VP Ops","linkedinUrl":"https://linkedin.com/in/dana-r","status":"new"},
86
+ {"name":"Sam Ortiz","company":"Initech","title":"Head of RevOps","linkedinUrl":"https://linkedin.com/in/sam-o","status":"new"}
87
+ ]'
88
+ monty data list leads --app crm --filter '{"status":"new"}' # verify, then report counts
89
+ ```
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ .monty/
4
+ .env.local
5
+ .env
@@ -6,7 +6,18 @@
6
6
  <title>New app</title>
7
7
  </head>
8
8
  <body>
9
- <div id="root"></div>
9
+ <div id="root">
10
+ <!-- Boot splash: the first paint, shown while the JS loads. React
11
+ replaces it with the SDK's identical MontySplash, so a cold open
12
+ is one continuous visual. If you theme the app dark-only, change
13
+ the background below to match. -->
14
+ <style>
15
+ @keyframes monty-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .25 } }
16
+ </style>
17
+ <div style="display: grid; place-items: center; min-height: 100vh; background: oklch(1 0 0)">
18
+ <div style="width: 10px; height: 10px; border-radius: 999px; background: #999; animation: monty-pulse 1.2s ease-in-out infinite"></div>
19
+ </div>
20
+ </div>
10
21
  <script type="module" src="/src/main.tsx"></script>
11
22
  </body>
12
23
  </html>
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.1.4",
13
+ "@montytools/sdk": "^0.1.5",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",
@@ -1,13 +1,15 @@
1
1
  import { StrictMode } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { RouterProvider, createRouter } from "@tanstack/react-router";
4
- import { MontyProvider } from "@montytools/sdk/react";
4
+ import { MontyProvider, MontySplash } from "@montytools/sdk/react";
5
5
 
6
6
  import "./index.css";
7
7
  import { app } from "../monty.config";
8
8
  import { routeTree } from "./routeTree.gen";
9
9
 
10
- const router = createRouter({ routeTree });
10
+ // defaultPendingComponent: lazy route chunks show the same splash the rest
11
+ // of the boot uses instead of flashing blank.
12
+ const router = createRouter({ routeTree, defaultPendingComponent: MontySplash });
11
13
 
12
14
  declare module "@tanstack/react-router" {
13
15
  interface Register {