sizmo 0.4.0 → 0.5.0

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.
@@ -2,8 +2,11 @@
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc.
3
3
  // Trust-fix #2: transactions + subscriptions paginate to completion.
4
4
  // Trust-fix #3: collected-by-source per currency (never cross-sums).
5
+ // v0.5.0: default currency from CRM model location (not hardcoded PHP).
6
+ // v0.6.0 (C2): modelMeta emitted in JSON envelope; TTY staleness note.
5
7
  // READ-ONLY. NEVER charges, refunds, or collects.
6
8
  import { paginate } from '../lib/paginate.mjs';
9
+ import { ENTITY_SPECS } from '../lib/model.mjs';
7
10
 
8
11
  export const meta = {
9
12
  name: 'reconcile',
@@ -33,6 +36,34 @@ export async function collect(args, ctx) {
33
36
  return t >= START && t <= NOW;
34
37
  };
35
38
 
39
+ // Location currency from CRM model (fallback PHP if model missing/blocked)
40
+ let locationCurrency = 'PHP';
41
+ let _reconcileModelLoaded = null;
42
+ let modelMeta = null;
43
+ if (ctx.ensureModel) {
44
+ try {
45
+ _reconcileModelLoaded = await ctx.ensureModel();
46
+ const locCur = _reconcileModelLoaded?.entities?.location?.item?.business?.currency
47
+ || _reconcileModelLoaded?.entities?.location?.item?.currency;
48
+ if (locCur) locationCurrency = locCur.toUpperCase();
49
+ } catch { /* use default */ }
50
+ } else if (ctx.cfg.currency) {
51
+ locationCurrency = ctx.cfg.currency;
52
+ }
53
+ // Build modelMeta for the JSON envelope (C2)
54
+ if (_reconcileModelLoaded) {
55
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
56
+ const locEnt = _reconcileModelLoaded.entities?.location;
57
+ const locSpec = specMap.location;
58
+ const locStale = locEnt && locSpec ? (NOW - (locEnt.fetchedAt ?? 0)) > locSpec.ttlMs : false;
59
+ modelMeta = {
60
+ syncedAt: _reconcileModelLoaded.syncedAt,
61
+ ageMs: NOW - _reconcileModelLoaded.syncedAt,
62
+ stale: locStale,
63
+ offline: !!(_reconcileModelLoaded.offline),
64
+ };
65
+ }
66
+
36
67
  // transactions paginated to completion (trust-fix #2)
37
68
  const txns = [];
38
69
  let txnErr = null;
@@ -61,7 +92,7 @@ export async function collect(args, ctx) {
61
92
  if (txnErr && txns.length === 0) {
62
93
  ctx.out.warn(`can't see transactions → HTTP ${txnErr}`, { degraded: true });
63
94
  return {
64
- location: LOC, days: DAYS, scanned: 0, inWindow: 0, collected: 0, currency: 'PHP',
95
+ location: LOC, days: DAYS, scanned: 0, inWindow: 0, collected: 0, currency: locationCurrency,
65
96
  bySource: {}, byStatus: {}, flags: { refunds: 0, failed: 0, orphans: 0 }, subscriptions: null,
66
97
  };
67
98
  }
@@ -78,7 +109,7 @@ export async function collect(args, ctx) {
78
109
  const st = (t.status || t.paymentStatus || '').toLowerCase();
79
110
  byStatus[st] = (byStatus[st] || 0) + 1;
80
111
  const amt = Number(t.amount) || 0;
81
- const cur = (t.currency || 'PHP').toUpperCase();
112
+ const cur = (t.currency || locationCurrency).toUpperCase();
82
113
  if (SUCCESS.has(st)) {
83
114
  const s = srcOf(t);
84
115
  byCur[cur] ??= { bySource: {}, total: 0 };
@@ -97,7 +128,7 @@ export async function collect(args, ctx) {
97
128
  // flatten for output — single currency → backward-compat flat shape; multi → byCurrency map
98
129
  const currencies = Object.keys(byCur);
99
130
  const isSingle = currencies.length <= 1;
100
- const currency = isSingle ? (currencies[0] || 'PHP') : (currencies[0] || 'PHP');
131
+ const currency = isSingle ? (currencies[0] || locationCurrency) : (currencies[0] || locationCurrency);
101
132
  const collected = isSingle ? (byCur[currency]?.total ?? 0) : null;
102
133
  const byCurrency = isSingle ? null : Object.fromEntries(currencies.map(c => [c, byCur[c].total]));
103
134
  // bySource: when single currency keep flat {src:{c,v}} for backward compat; multi-currency not surfaced at top level
@@ -136,7 +167,7 @@ export async function collect(args, ctx) {
136
167
  // MRR per-currency — same treatment as transactions (never cross-sum currencies)
137
168
  const mrrByCur = {};
138
169
  for (const x of active) {
139
- const cur = (x.currency || 'PHP').toUpperCase();
170
+ const cur = (x.currency || locationCurrency).toUpperCase();
140
171
  mrrByCur[cur] = (mrrByCur[cur] || 0) + (Number(x.amount) || 0);
141
172
  }
142
173
  const mrrCurrencies = Object.keys(mrrByCur);
@@ -164,6 +195,7 @@ export async function collect(args, ctx) {
164
195
  byStatus,
165
196
  flags: { refunds: refunds.length, failed: failed.length, orphans: orphans.length },
166
197
  subscriptions: subs,
198
+ ...(modelMeta ? { modelMeta } : {}),
167
199
  };
168
200
  }
169
201
 
@@ -179,6 +211,16 @@ export async function run(args, ctx) {
179
211
 
180
212
  ctx.out.card(() => {
181
213
  ctx.out.line(`\n RECONCILE — ${collectedLine} collected · last ${data.days}d · ${data.inWindow} txn in window · loc ${data.location}`);
214
+ // C2: model staleness note
215
+ if (data.modelMeta) {
216
+ const mm = data.modelMeta;
217
+ if (mm.offline) {
218
+ ctx.out.line(` · CRM model OFFLINE — currency from cache`);
219
+ } else if (mm.stale) {
220
+ const ageD = Math.round(mm.ageMs / 86400000);
221
+ ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
222
+ }
223
+ }
182
224
  ctx.out.line(' ' + '─'.repeat(64));
183
225
  ctx.out.line(' BY SOURCE (succeeded)');
184
226
  const srcs = Object.entries(data.bySource).sort((a, b) => b[1].v - a[1].v);
@@ -2,8 +2,11 @@
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
3
  // Trust-fix #2: leads() and revenue() paginate to completion.
4
4
  // Trust-fix #3: revenue tracks per-currency (never cross-sums).
5
+ // v0.5.0: calendar list from CRM model; location currency from model.
6
+ // v0.6.0 (C2): modelMeta emitted in JSON envelope; TTY staleness note.
5
7
  import { paginate } from '../lib/paginate.mjs';
6
8
  import { mapLimit } from '../lib/pool.mjs';
9
+ import { ENTITY_SPECS } from '../lib/model.mjs';
7
10
 
8
11
  export const meta = {
9
12
  name: 'snapshot',
@@ -31,6 +34,35 @@ export async function collect(args, ctx) {
31
34
  return Number.isFinite(t) && t >= START && t <= NOW;
32
35
  };
33
36
 
37
+ // Location currency from CRM model (fallback PHP if model missing/blocked)
38
+ let locationCurrency = 'PHP';
39
+ let _snapshotModelLoaded = null;
40
+ let modelMeta = null;
41
+ if (ctx.ensureModel) {
42
+ try {
43
+ _snapshotModelLoaded = await ctx.ensureModel();
44
+ const locCur = _snapshotModelLoaded?.entities?.location?.item?.business?.currency
45
+ || _snapshotModelLoaded?.entities?.location?.item?.currency;
46
+ if (locCur) locationCurrency = locCur.toUpperCase();
47
+ } catch { /* use default */ }
48
+ } else if (ctx.cfg.currency) {
49
+ locationCurrency = ctx.cfg.currency;
50
+ }
51
+ // Build modelMeta for the JSON envelope (C2)
52
+ if (_snapshotModelLoaded) {
53
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
54
+ // Check calendars staleness (the main model-sourced entity in snapshot)
55
+ const calEnt = _snapshotModelLoaded.entities?.calendars;
56
+ const calSpec = specMap.calendars;
57
+ const calStale = calEnt && calSpec ? (NOW - (calEnt.fetchedAt ?? 0)) > calSpec.ttlMs : false;
58
+ modelMeta = {
59
+ syncedAt: _snapshotModelLoaded.syncedAt,
60
+ ageMs: NOW - _snapshotModelLoaded.syncedAt,
61
+ stale: calStale,
62
+ offline: !!(_snapshotModelLoaded.offline),
63
+ };
64
+ }
65
+
34
66
  // ── LEADS: paginate contacts newest→older, stop past window ──
35
67
  async function leads() {
36
68
  let count = 0, pages = 0, oldest = null;
@@ -78,12 +110,27 @@ export async function collect(args, ctx) {
78
110
  // Full fix = date-window splitting; tracked as follow-up. Cheap mitigation: warn + degrade.
79
111
  const EVENTS_CAP = 100;
80
112
  async function bookings() {
81
- const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
82
- if (!cr.ok) return [
83
- metric('Bookings', null, { blocked: true, blocker: `calendars list HTTP ${cr.code}` }),
84
- metric('Show rate', null, { blocked: true, blocker: 'no calendars' }),
85
- ];
86
- const cals = cr.j.calendars || [];
113
+ // Get calendar list from the CRM model if available; fall back to live fetch.
114
+ // Use _snapshotModelLoaded already loaded above — no second ensureModel call.
115
+ let cals = null;
116
+ if (_snapshotModelLoaded?.entities?.calendars && !_snapshotModelLoaded.entities.calendars.blocked && !_snapshotModelLoaded.entities.calendars.networkError) {
117
+ cals = _snapshotModelLoaded.entities.calendars.items ?? [];
118
+ } else if (!_snapshotModelLoaded && ctx.ensureModel) {
119
+ try {
120
+ const model = await ctx.ensureModel();
121
+ if (model?.entities?.calendars && !model.entities.calendars.blocked && !model.entities.calendars.networkError) {
122
+ cals = model.entities.calendars.items ?? [];
123
+ }
124
+ } catch { /* fall through to live fetch */ }
125
+ }
126
+ if (cals === null) {
127
+ const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
128
+ if (!cr.ok) return [
129
+ metric('Bookings', null, { blocked: true, blocker: `calendars list HTTP ${cr.code}` }),
130
+ metric('Show rate', null, { blocked: true, blocker: 'no calendars' }),
131
+ ];
132
+ cals = cr.j.calendars || [];
133
+ }
87
134
  let booked = 0, showed = 0, noshow = 0, calsHit = 0, skippedCalendars = 0;
88
135
  // Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
89
136
  // ONLY the independent per-calendar fetches are parallelized — pagination pages stay sequential.
@@ -152,7 +199,7 @@ export async function collect(args, ctx) {
152
199
  const when = t.createdAt || t.created_at || t.dateAdded;
153
200
  const ok = (t.status || t.paymentStatus || '').toLowerCase();
154
201
  if (inWindow(when) && (ok === 'succeeded' || ok === 'success' || ok === 'paid' || ok === 'completed' || ok === 'captured')) {
155
- const cur = (t.currency || 'PHP').toUpperCase();
202
+ const cur = (t.currency || locationCurrency).toUpperCase();
156
203
  byCur[cur] = byCur[cur] || { sum: 0, n: 0 };
157
204
  byCur[cur].sum += Number(t.amount) || 0;
158
205
  byCur[cur].n++;
@@ -163,7 +210,7 @@ export async function collect(args, ctx) {
163
210
  // If only one currency, match original format; multi-currency → list all
164
211
  const entries = Object.entries(byCur);
165
212
  if (entries.length === 0)
166
- return metric('Collected', money(0, 'PHP'), { note: `0 payment(s) · ${totalScanned} txns scanned` });
213
+ return metric('Collected', money(0, locationCurrency), { note: `0 payment(s) · ${totalScanned} txns scanned` });
167
214
  if (entries.length === 1) {
168
215
  const [cur, { sum, n }] = entries[0];
169
216
  return metric('Collected', money(sum, cur), { note: `${n} payment(s) · ${totalScanned} txns scanned` });
@@ -195,7 +242,7 @@ export async function collect(args, ctx) {
195
242
  sum += Number(o.monetaryValue || o.monetary_value || 0) || 0;
196
243
  n++;
197
244
  }
198
- return metric('Pipeline value', money(sum), { note: `${n} open deal(s)` });
245
+ return metric('Pipeline value', money(sum, locationCurrency), { note: `${n} open deal(s)` });
199
246
  }
200
247
 
201
248
  // ── REPLY RATE ──
@@ -223,6 +270,7 @@ export async function collect(args, ctx) {
223
270
  location: LOC,
224
271
  window: { days: DAYS, startISO, endISO: new Date(NOW).toISOString() },
225
272
  metrics: rows,
273
+ ...(modelMeta ? { modelMeta } : {}),
226
274
  };
227
275
  }
228
276
 
@@ -239,6 +287,16 @@ export async function run(args, ctx) {
239
287
  const line = (l, v) => '│ ' + l.padEnd(16) + ' ' + String(v).padEnd(W - 20) + '│';
240
288
  ctx.out.line('┌' + '─'.repeat(W - 2) + '┐');
241
289
  ctx.out.line(line('SNAPSHOT', winLabel.slice(0, W - 20)));
290
+ // C2: model staleness note
291
+ if (data.modelMeta) {
292
+ const mm = data.modelMeta;
293
+ if (mm.offline) {
294
+ ctx.out.line(line('· model', 'OFFLINE — calendar list from cache'));
295
+ } else if (mm.stale) {
296
+ const ageD = Math.round(mm.ageMs / 86400000);
297
+ ctx.out.line(line('· model', `${ageD}d old — run sizmo sync`));
298
+ }
299
+ }
242
300
  ctx.out.line('├' + '─'.repeat(W - 2) + '┤');
243
301
  for (const m of data.metrics) {
244
302
  if (m.blocked) {
@@ -0,0 +1,84 @@
1
+ // commands/sync.mjs — force-refresh the local CRM model.
2
+ // Fetches all 6 structure entities (or a subset) and stores the blob.
3
+ // READ-ONLY. Only writes to the local model file; never writes to GoHighLevel.
4
+ import { syncModel, ENTITY_SPECS, DEFAULT_MODEL_DIR } from '../lib/model.mjs';
5
+
6
+ export const meta = {
7
+ name: 'sync',
8
+ summary: 'Refresh the local CRM model (pipelines, calendars, tags, fields, users, location)',
9
+ flags: [],
10
+ readOnly: true,
11
+ };
12
+
13
+ export async function run(args, ctx) {
14
+ const dir = ctx._modelDir ?? DEFAULT_MODEL_DIR;
15
+ const loc = ctx.cfg.loc;
16
+ const now = typeof ctx.now === 'function' ? ctx.now : () => ctx.now;
17
+
18
+ // Optional: sync only one entity — `sizmo sync tags`
19
+ const entityArg = args._?.[0];
20
+ const validNames = ENTITY_SPECS.map(s => s.name);
21
+ // Also accept 'fields' as alias for 'customFields'
22
+ const alias = { fields: 'customFields' };
23
+ const resolvedArg = entityArg ? (alias[entityArg] ?? entityArg) : null;
24
+ const only = resolvedArg ? [resolvedArg] : null;
25
+
26
+ if (resolvedArg && !validNames.includes(resolvedArg)) {
27
+ ctx.out.warn(`unknown entity "${entityArg}" — valid: ${validNames.join(', ')}`);
28
+ return 1;
29
+ }
30
+
31
+ let model;
32
+ try {
33
+ model = await syncModel({ http: ctx.http, loc, dir, now, only });
34
+ } catch (e) {
35
+ if (e.offline) {
36
+ ctx.out.warn("⚠ OFFLINE — can't reach GoHighLevel — check your connection; run `sizmo sync` when online");
37
+ return 1;
38
+ }
39
+ // Write failure (disk full, permissions, etc.)
40
+ ctx.out.warn(`sync failed — model not written: ${e.message}`);
41
+ return 1;
42
+ }
43
+
44
+ // Count results
45
+ let synced = 0, blocked = 0, networkErrors = 0;
46
+ for (const [, ent] of Object.entries(model.entities)) {
47
+ if (ent.networkError) networkErrors++;
48
+ else if (ent.blocked) blocked++;
49
+ else synced++;
50
+ }
51
+
52
+ ctx.out.data({ synced, blocked, networkErrors: networkErrors || undefined, offline: model.offline || undefined,
53
+ locationId: loc, syncedAt: model.syncedAt, entities: Object.fromEntries(
54
+ Object.entries(model.entities).map(([name, ent]) => [
55
+ name,
56
+ ent.networkError
57
+ ? { networkError: true, error: ent.error }
58
+ : ent.blocked
59
+ ? { blocked: true, scope: ent.scope }
60
+ : { fetchedAt: ent.fetchedAt, count: ent.items ? ent.items.length : (ent.item ? 1 : 0) },
61
+ ])
62
+ )});
63
+
64
+ ctx.out.card(() => {
65
+ const blockedNote = blocked > 0 ? ` (${blocked} scope-blocked)` : '';
66
+ const netNote = networkErrors > 0 ? ` (${networkErrors} network-error — check connection)` : '';
67
+ ctx.out.line(`synced ${synced} of ${synced + blocked + networkErrors} entities${blockedNote}${netNote} · loc ${loc}`);
68
+ for (const [name, ent] of Object.entries(model.entities)) {
69
+ if (ent.networkError) {
70
+ ctx.out.line(` ⚠ ${name.padEnd(14)} couldn't reach GHL`);
71
+ } else if (ent.blocked) {
72
+ ctx.out.line(` ✖ ${name.padEnd(14)} needs ${ent.scope}`);
73
+ } else {
74
+ const count = ent.items ? ent.items.length : (ent.item ? 1 : 0);
75
+ ctx.out.line(` ✔ ${name.padEnd(14)} ${count} item(s)`);
76
+ }
77
+ }
78
+ if (model.offline) {
79
+ ctx.out.line(' ⚠ some entities could not be reached — model may be partially stale');
80
+ }
81
+ });
82
+
83
+ return 0;
84
+ }
@@ -0,0 +1,77 @@
1
+ # Auth: PIT vs GoHighLevel MCP
2
+
3
+ Two ways to authenticate with GoHighLevel from external tooling. `sizmo` uses one; here is why, and when you might want the other.
4
+
5
+ ---
6
+
7
+ ## What sizmo uses: PIT (Private Integration Token)
8
+
9
+ A Private Integration Token is a scoped API credential you create inside your GoHighLevel account under **Settings → Integrations → Private Integrations**. When you create one you choose exactly which API scopes it carries. The token is a long string starting with `pit-`.
10
+
11
+ Why `sizmo` uses it:
12
+
13
+ - **Precise scope control.** You decide which scopes are granted — `sizmo` requests read-only scopes only (`contacts.readonly`, `conversations.readonly`, etc.). The token cannot do anything beyond what you explicitly allowed.
14
+ - **Deterministic.** The token is stable and usable in any headless environment — scripts, cron jobs, CI, terminal aliases. No browser, no interactive login flow, no per-session refresh.
15
+ - **Least-privilege.** A read-only PIT cannot create contacts, send messages, charge invoices, or modify workflows. If the token is ever compromised, the blast radius is limited to read access.
16
+ - **No token server required.** The CLI resolves the token from a local profile file or an environment variable. There is no OAuth callback server to run.
17
+
18
+ **PIT = more control** over what the credential can do.
19
+
20
+ ---
21
+
22
+ ## GoHighLevel also offers an official MCP server
23
+
24
+ GoHighLevel provides an official MCP (Model Context Protocol) server — the LeadConnector MCP server — as a sanctioned, public feature you can enable inside your GoHighLevel account. Once enabled, MCP clients (such as AI assistants like Claude) can connect to it and perform CRM operations through the MCP protocol.
25
+
26
+ Key characteristics of GHL's MCP server:
27
+
28
+ - It is OAuth-connected. Authorization goes through GoHighLevel's standard OAuth flow.
29
+ - It is designed for AI agent and LLM use cases — giving an AI assistant the ability to read and write CRM data through natural language requests.
30
+ - It exposes a broader surface area of CRM operations compared to a scoped read-only PIT.
31
+
32
+ For information on enabling GoHighLevel's MCP server, refer to [GoHighLevel's official MCP documentation](https://help.gohighlevel.com) (search for "MCP" or "Model Context Protocol" in their help center).
33
+
34
+ ---
35
+
36
+ ## When to use which
37
+
38
+ | | PIT (what sizmo uses) | GHL MCP server |
39
+ |---|---|---|
40
+ | **Use case** | CLI tools, shell scripts, cron jobs, precise read-only reporting | AI assistants / LLM agents that need to read or write CRM data via natural language |
41
+ | **Auth mechanism** | Static token in local profile or env var | OAuth flow |
42
+ | **Scope control** | Explicit, per-scope — grant only what you need | Managed by the MCP server and OAuth grant |
43
+ | **Headless / scriptable** | Yes — no browser required | Requires initial OAuth browser flow |
44
+ | **Write operations** | Not applicable for sizmo (read-only tool) | Yes — MCP can expose write operations |
45
+ | **Setup** | Create PIT in GHL settings, save with `sizmo config set` | Enable MCP in GHL settings, connect an MCP-capable client |
46
+
47
+ **Use a PIT** when you want a CLI tool, shell automation, or any situation where you need deterministic, headless, read-only access with explicit scope control. That is what `sizmo auth check` validates.
48
+
49
+ **Use GHL's MCP server** when you are wiring GoHighLevel into an AI assistant or agent that communicates via the MCP protocol and you want the AI to be able to interact with your CRM through natural language.
50
+
51
+ The two are not mutually exclusive — you can use `sizmo` for terminal-based reporting alongside an MCP-connected AI assistant in the same GoHighLevel location.
52
+
53
+ ---
54
+
55
+ ## Checking which scopes your PIT has
56
+
57
+ After setting up a profile, run:
58
+
59
+ ```sh
60
+ sizmo auth check
61
+ ```
62
+
63
+ This probes all six read lanes and reports which scopes are present and which are missing:
64
+
65
+ ```
66
+ auth check: probing 6 GoHighLevel API scopes...
67
+ ✅ contacts
68
+ ✅ conversations
69
+ ✖ opportunities — add scope opportunities.readonly
70
+ ✅ calendars
71
+ ✅ invoices
72
+ ✅ payments
73
+
74
+ 5/6 lanes readable — `brief` will show ⚠ on opportunities until you add: opportunities.readonly
75
+ ```
76
+
77
+ Add the missing scope in your GoHighLevel Private Integration settings, then re-run `sizmo auth check` to confirm.
@@ -4,7 +4,20 @@ A profile stores a PIT (Private Integration Token) and Location ID under a name.
4
4
 
5
5
  ## Step 1 — get your PIT
6
6
 
7
- In GoHighLevel: Settings → Integrations → Private Integrations → Create. Grant read-only scopes. Copy the token (starts with `pit-`).
7
+ In GoHighLevel: Settings → Integrations → Private Integrations → Create. Copy the token (starts with `pit-`).
8
+
9
+ Grant the following scopes when creating the integration (minimum set for a full `brief`):
10
+
11
+ ```
12
+ contacts.readonly
13
+ conversations.readonly
14
+ opportunities.readonly
15
+ calendars.readonly
16
+ invoices.readonly
17
+ payments/transactions.readonly
18
+ ```
19
+
20
+ Grant all six for the complete `brief`. Granting fewer is fine — any missing scope shows as ⚠ in the affected metric rather than hard-failing. Run `sizmo auth check` after setup to see exactly which lanes are readable and which scopes are still needed.
8
21
 
9
22
  ## Step 2 — find your Location ID
10
23
 
@@ -0,0 +1,105 @@
1
+ # CRM model — how it works
2
+
3
+ `sizmo` keeps a local copy of your CRM's structure — pipelines + stages, calendars, tags, custom fields, users, and location info. Recipes read from this cache instead of re-fetching on every run.
4
+
5
+ ## What is cached
6
+
7
+ | Entity | Endpoint | TTL | Key fields |
8
+ |--------|----------|-----|-----------|
9
+ | pipelines + stages | `GET /opportunities/pipelines` | 24h | pipeline `{id,name}`, stages `[{id,name,position}]` |
10
+ | calendars | `GET /calendars/` | 24h | `{id,name,calendarType,isActive}` |
11
+ | tags | `GET /locations/{loc}/tags` | 12h | `{id,name}` |
12
+ | customFields | `GET /locations/{loc}/customFields` | 12h | `{id,name,fieldKey,dataType}` |
13
+ | users | `GET /users/` | 24h | `{id,firstName,lastName,email}` |
14
+ | location | `GET /locations/{loc}` | 24h | `{name,timezone,currency,country}` |
15
+
16
+ Stored at `~/.config/sizmo/model/<locationId>.json`. Written atomically (temp + rename), permissions 0600.
17
+
18
+ ## First run
19
+
20
+ The model is synced automatically the first time a command needs it. Nothing to do.
21
+
22
+ ## Keeping the model fresh
23
+
24
+ Run `sizmo sync` after:
25
+ - Adding or renaming a pipeline stage
26
+ - Adding or removing a calendar
27
+ - Adding custom fields or tags
28
+ - Onboarding a new user
29
+
30
+ ```sh
31
+ sizmo sync # full refresh (6 single-page calls, rate-safe)
32
+ sizmo sync tags # refresh one entity
33
+ sizmo sync customFields
34
+ ```
35
+
36
+ Valid entity names: `pipelines`, `calendars`, `tags`, `customFields` (or `fields`), `users`, `location`.
37
+
38
+ ## Querying the model
39
+
40
+ ```sh
41
+ sizmo crm # overview: count + age per entity
42
+ sizmo crm pipelines # list pipelines + stages
43
+ sizmo crm calendars # list calendars
44
+ sizmo crm tags # list tags (first 20)
45
+ sizmo crm tags --all # list all tags
46
+ sizmo crm fields # list custom fields
47
+ sizmo crm users # list users
48
+ sizmo crm location # timezone / currency / country
49
+
50
+ # Machine output:
51
+ sizmo crm pipelines --json # includes _meta with source/syncedAt/ageMs/stale
52
+ ```
53
+
54
+ ## Honest staleness
55
+
56
+ Every model read shows the age of the data. Stale means the entity has passed its TTL (24h or 12h).
57
+
58
+ - Fresh (under TTL): no special marker
59
+ - Stale (past TTL): `⚠ STALE — run sizmo sync` banner; served anyway
60
+ - Blocked (scope missing): `✖ needs <scope>`; other entities still served
61
+
62
+ The CLI **never** auto-syncs mid-recipe when stale — it serves the cache and warns. This avoids surprise network calls and rate-limit hits. Use `sizmo sync` to force a refresh.
63
+
64
+ The `--json` output includes `_meta` so agents can branch without parsing prose:
65
+
66
+ ```json
67
+ {
68
+ "_meta": {
69
+ "source": "cache",
70
+ "syncedAt": 1718000000000,
71
+ "ageMs": 3600000,
72
+ "stale": false,
73
+ "offline": false
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## Name resolution
79
+
80
+ Recipes use names (not IDs) in output when the model is available. If an ID is not found in the model (renamed stage, deleted calendar), the output shows:
81
+
82
+ ```
83
+ <unknown:<id> — run sizmo sync>
84
+ ```
85
+
86
+ This is the resolver's miss path. It never fabricates a name. Run `sizmo sync` to refresh the model after structural changes.
87
+
88
+ ## Partial sync (missing scopes)
89
+
90
+ A 401 or 403 on one entity marks it blocked and stores what succeeded. `sizmo crm` shows blocked entities with `✖ needs <scope>`. Other entities are unaffected.
91
+
92
+ Required scopes for a full sync:
93
+
94
+ ```
95
+ opportunities.readonly
96
+ calendars.readonly
97
+ locations/tags.readonly
98
+ locations/customFields.readonly
99
+ users.readonly
100
+ locations.readonly
101
+ ```
102
+
103
+ ## Schema versioning
104
+
105
+ The model blob has a `schemaVersion` field. A format change bumps this version and invalidates the cached file (treated as missing, triggering a re-sync on next use).
package/lib/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { registry } from './registry.mjs';
5
5
  import { resolve, loadProfiles, saveProfiles, validateToken, mask, pitAgeDays } from './config.mjs';
6
6
  import { makeHttp } from './http.mjs';
7
+ import { mapLimit } from './pool.mjs';
7
8
  import { buildCtx } from './context.mjs';
8
9
  import { buildSchema } from './schema.mjs';
9
10
  import { GhlError, EXIT } from './errors.mjs';
@@ -92,24 +93,77 @@ async function routerVerb(cmd, args, io) {
92
93
  // ── auth ────────────────────────────────────────────────────────────────────
93
94
  if (cmd === 'auth') {
94
95
  if (verb === 'check') {
95
- // auth check: verify PIT scopes by probing the API directly
96
+ // auth check: per-lane scope diagnostic probes all fleet read lanes concurrently.
97
+ // Rule: 401/403 = scope MISSING; 200 or 4xx param error (400/422) = scope PRESENT.
98
+ // Exit 0 if contacts lane is readable (tool is usable); per-lane ✖ lines guide the user.
96
99
  const creds = resolve(profile);
97
100
  if (!creds.pit) return die('no credentials found', EXIT.AUTH, 'set GHL_PIT, or: sizmo config set --profile <name> --pit-stdin');
98
101
  if (!creds.loc) return die('no location resolved', EXIT.AUTH, 'pass --profile <name>, or set GHL_LOCATION_ID');
99
- write('auth check: probing GoHighLevel API scopes...\n');
102
+
103
+ const loc = creds.loc;
104
+ const LANES = [
105
+ { name: 'contacts', scope: 'contacts.readonly', path: `/contacts/?locationId=${loc}&limit=1` },
106
+ { name: 'conversations', scope: 'conversations.readonly', path: `/conversations/search?locationId=${loc}&limit=1` },
107
+ { name: 'opportunities', scope: 'opportunities.readonly', path: `/opportunities/search?location_id=${loc}&limit=1` },
108
+ { name: 'calendars', scope: 'calendars.readonly', path: `/calendars/?locationId=${loc}` },
109
+ { name: 'invoices', scope: 'invoices.readonly', path: `/invoices/?altId=${loc}&altType=location&limit=1` },
110
+ { name: 'payments', scope: 'payments/transactions.readonly', path: `/payments/transactions?altId=${loc}&altType=location&limit=1` },
111
+ ];
112
+
113
+ if (!json) write(`auth check: probing ${LANES.length} GoHighLevel API scopes...\n`);
114
+
115
+ let lanes;
100
116
  try {
101
117
  const http = makeHttp({ pit: creds.pit });
102
- const result = await validateToken(http, creds.loc);
103
- if (!result.ok) {
104
- writeErr(`WARN: token check failed — ${result.reason}\n`);
105
- return EXIT.AUTH;
106
- }
107
- write(`auth check: PIT accepted for location ${creds.loc}\n`);
108
- return EXIT.OK;
118
+ lanes = await mapLimit(LANES, 5, async (lane) => {
119
+ try {
120
+ const r = await http.get(lane.path);
121
+ // 401/403 = scope blocked; 200 or param-error (400/422) = authorized
122
+ const ok = r.code !== 401 && r.code !== 403;
123
+ return { name: lane.name, scope: lane.scope, ok, code: r.code };
124
+ } catch (e) {
125
+ return { name: lane.name, scope: lane.scope, ok: false, code: 0, error: e?.message ?? 'error' };
126
+ }
127
+ });
109
128
  } catch (e) {
110
129
  writeErr(`auth check: could not reach GoHighLevel (${e?.message ?? 'error'})\n`);
111
130
  return EXIT.API;
112
131
  }
132
+
133
+ const okCount = lanes.filter(l => l.ok).length;
134
+ const total = lanes.length;
135
+
136
+ if (json) {
137
+ const contactsOk = lanes.find(l => l.name === 'contacts')?.ok ?? false;
138
+ write(JSON.stringify({
139
+ schemaVersion: 1,
140
+ location: loc,
141
+ lanes: lanes.map(l => ({ name: l.name, scope: l.scope, ok: l.ok, httpCode: l.code })),
142
+ summary: `${okCount}/${total} lanes readable`,
143
+ usable: contactsOk,
144
+ }) + '\n');
145
+ return contactsOk ? EXIT.OK : EXIT.AUTH;
146
+ }
147
+
148
+ // human output — per-lane lines then summary
149
+ for (const l of lanes) {
150
+ if (l.ok) {
151
+ write(` ✅ ${l.name}\n`);
152
+ } else {
153
+ writeErr(` ✖ ${l.name} — add scope ${l.scope}\n`);
154
+ }
155
+ }
156
+
157
+ const missing = lanes.filter(l => !l.ok).map(l => l.scope);
158
+ if (missing.length === 0) {
159
+ write(`\n${okCount}/${total} lanes readable — full brief available\n`);
160
+ } else {
161
+ const missingNames = lanes.filter(l => !l.ok).map(l => l.name).join(', ');
162
+ writeErr(`\n${okCount}/${total} lanes readable — \`brief\` will show ⚠ on ${missingNames} until you add: ${missing.join(', ')}\n`);
163
+ }
164
+
165
+ const contactsOk = lanes.find(l => l.name === 'contacts')?.ok ?? false;
166
+ return contactsOk ? EXIT.OK : EXIT.AUTH;
113
167
  }
114
168
  if (verb !== 'status') return die('usage: sizmo auth status|check', EXIT.USAGE);
115
169
 
package/lib/context.mjs CHANGED
@@ -3,6 +3,8 @@ import { makeHttp } from './http.mjs';
3
3
  import { makeOut } from './output.mjs';
4
4
  import { makeCache } from './cache.mjs';
5
5
  import { GhlError, EXIT } from './errors.mjs';
6
+ import { loadModel, syncModel, DEFAULT_MODEL_DIR } from './model.mjs';
7
+ import { makeResolver } from './resolver.mjs';
6
8
  import { homedir } from 'node:os';
7
9
  import { join } from 'node:path';
8
10
  import { env as processEnv } from 'node:process';
@@ -29,5 +31,38 @@ export function buildCtx({ creds, globals, now = Date.now(), httpFactory = makeH
29
31
  return r;
30
32
  },
31
33
  };
32
- return { http, cfg: creds, out, now };
34
+
35
+ // CRM model — lazy, loaded once per ctx. Auto-syncs if missing.
36
+ // Recipes read ctx.model (the raw blob) and ctx.resolve (the resolver).
37
+ // We expose a lazy getter so commands that don't need the model pay nothing.
38
+ let _model = undefined; // undefined = not yet loaded; null = load attempted, none found + sync ran
39
+ let _resolver = undefined;
40
+
41
+ async function ensureModel() {
42
+ if (_model !== undefined) return _model;
43
+ _model = loadModel(creds.loc, DEFAULT_MODEL_DIR);
44
+ if (!_model) {
45
+ // Auto-sync on first use (model missing)
46
+ try {
47
+ _model = await syncModel({ http: rawHttp, loc: creds.loc, now: () => now });
48
+ } catch {
49
+ _model = null;
50
+ }
51
+ }
52
+ _resolver = makeResolver(_model, { now: () => now });
53
+ return _model;
54
+ }
55
+
56
+ return {
57
+ http,
58
+ cfg: creds,
59
+ out,
60
+ now,
61
+ // Expose model access for recipes
62
+ get model() { return _model; },
63
+ // ensureModel() → async lazy-loads + returns model
64
+ ensureModel,
65
+ // resolve(kind, id) → sync resolver call (after ensureModel was awaited)
66
+ get resolve() { return _resolver; },
67
+ };
33
68
  }