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.
- package/README.md +61 -11
- package/SKILL.md +1 -1
- package/commands/crm.mjs +258 -0
- package/commands/noshow.mjs +46 -5
- package/commands/pipeline.mjs +87 -14
- package/commands/reconcile.mjs +46 -4
- package/commands/snapshot.mjs +67 -9
- package/commands/sync.mjs +84 -0
- package/docs/how-to/auth-pit-vs-mcp.md +77 -0
- package/docs/how-to/configure-a-client-profile.md +14 -1
- package/docs/how-to/crm-model.md +105 -0
- package/lib/cli.mjs +63 -9
- package/lib/context.mjs +36 -1
- package/lib/model.mjs +213 -0
- package/lib/registry.mjs +3 -1
- package/lib/resolver.mjs +137 -0
- package/package.json +4 -4
package/commands/reconcile.mjs
CHANGED
|
@@ -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:
|
|
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 ||
|
|
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] ||
|
|
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 ||
|
|
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);
|
package/commands/snapshot.mjs
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 ||
|
|
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,
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
}
|