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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sizmo
|
|
2
2
|
|
|
3
|
-
**Unofficial read-only GoHighLevel CLI
|
|
3
|
+
**Unofficial read-only GoHighLevel CLI.** Your GoHighLevel CRM — leads, bookings, pipeline, receivables, payments, and a money-ranked to-do list — from the terminal, in one command.
|
|
4
4
|
|
|
5
5
|
> Not affiliated with, endorsed by, or supported by HighLevel. This is an independent open-source tool.
|
|
6
6
|
|
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
Requires Node.js 20+.
|
|
12
12
|
|
|
13
|
-
**Option A —
|
|
13
|
+
**Option A — npm (recommended):**
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx sizmo brief # run with no install
|
|
17
|
+
# or install globally:
|
|
18
|
+
npm install -g sizmo
|
|
19
|
+
sizmo brief
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Option B — clone + install (puts `sizmo` on your PATH from source):**
|
|
14
23
|
|
|
15
24
|
```sh
|
|
16
25
|
git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli
|
|
@@ -20,12 +29,6 @@ bash install.sh
|
|
|
20
29
|
|
|
21
30
|
`install.sh` symlinks `bin/sizmo.mjs` into `~/.local/bin/sizmo`. Add `~/.local/bin` to `$PATH` if not already present (the script will warn you if needed).
|
|
22
31
|
|
|
23
|
-
**Option B — run with no install, straight from GitHub:**
|
|
24
|
-
|
|
25
|
-
```sh
|
|
26
|
-
npx github:csalamida07-cyber/sizmo-ghl-cli brief
|
|
27
|
-
```
|
|
28
|
-
|
|
29
32
|
**Option C — clone + run directly:**
|
|
30
33
|
|
|
31
34
|
```sh
|
|
@@ -33,8 +36,6 @@ git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli && cd sizmo-ghl-cli
|
|
|
33
36
|
node bin/sizmo.mjs brief
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
> `npx sizmo` (npm install) is coming once the package is published to npm.
|
|
37
|
-
|
|
38
39
|
Then configure a profile:
|
|
39
40
|
|
|
40
41
|
```sh
|
|
@@ -43,6 +44,17 @@ echo "pit-yourtoken..." | sizmo config set --profile myclient --loc YOUR_LOCATIO
|
|
|
43
44
|
|
|
44
45
|
PIT = Private Integration Token. Find it under GoHighLevel Settings > Integrations > Private Integrations. Never pass it as a command-line argument — always pipe it via stdin.
|
|
45
46
|
|
|
47
|
+
When creating the Private Integration, grant these scopes for the full `brief`:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
contacts.readonly · conversations.readonly · opportunities.readonly
|
|
51
|
+
calendars.readonly · invoices.readonly · payments/transactions.readonly
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Granting fewer is fine — missing scopes show as ⚠ in affected metrics rather than failing the whole command. Run `sizmo auth check` after setup to see a per-lane scope report.
|
|
55
|
+
|
|
56
|
+
**Auth: PIT vs MCP** — `sizmo` uses a Private Integration Token (PIT), not the GoHighLevel MCP server. See [`docs/how-to/auth-pit-vs-mcp.md`](docs/how-to/auth-pit-vs-mcp.md) for the comparison and when you'd want each.
|
|
57
|
+
|
|
46
58
|
Verify auth:
|
|
47
59
|
|
|
48
60
|
```sh
|
|
@@ -66,6 +78,8 @@ Command list generated from `sizmo schema` (authoritative — pulled directly fr
|
|
|
66
78
|
| `sizmo booked-not-paid` | Sessions with no invoice or payment — the money leak | `--days N` (default 30), `--top N` (default 15) |
|
|
67
79
|
| `sizmo focus` | One ranked to-do queue by money at stake | `--top N` (default 15), `--stuck-days N` (default 7) |
|
|
68
80
|
| `sizmo segment` | Find contacts by criteria — tag, phone, age, etc. | `--tag X`, `--without-tag X`, `--no-tags`, `--created-days N`, `--has-phone`, `--no-phone`, `--top N` (default 20) |
|
|
81
|
+
| `sizmo crm` | Query the local CRM model — counts, lists, staleness | `--all` (show all items) |
|
|
82
|
+
| `sizmo sync` | Refresh the local CRM model (pipelines, calendars, tags, fields, users, location) | `[entity]` (sync one) |
|
|
69
83
|
|
|
70
84
|
### Utility commands
|
|
71
85
|
|
|
@@ -107,6 +121,42 @@ Every command supports `--json`. The envelope shape is stable:
|
|
|
107
121
|
|
|
108
122
|
`degraded: true` means at least one data source was blocked (scope or auth). Read `warnings`. A blocked source is not zero — treat it as unknown.
|
|
109
123
|
|
|
124
|
+
## Your CRM model
|
|
125
|
+
|
|
126
|
+
`sizmo` caches the slow-changing structure of your CRM — pipelines + stages, calendars, tags, custom fields, users, and location — in a local file (`~/.config/sizmo/model/<locationId>.json`). Recipes read from this cache instead of re-fetching structure on every run.
|
|
127
|
+
|
|
128
|
+
**What it stores:** pipeline/stage names + IDs, calendar list, tag list, custom fields, user roster, and location info (timezone, currency, country). Structure only — no contacts, no conversations, no payments.
|
|
129
|
+
|
|
130
|
+
**Sync once, read fast.** The model is synced automatically on first use. After that, recipes use the cached copy. Run `sizmo sync` after you change your pipeline stages or add calendars:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
sizmo sync # full refresh (all 6 entities)
|
|
134
|
+
sizmo sync tags # refresh one entity only
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Age is always shown.** `sizmo crm` shows how old each entity is. Stale entries (past TTL: 24h for pipelines/calendars/users/location; 12h for tags/fields) show a warning. The CLI never silently serves stale structure as current.
|
|
138
|
+
|
|
139
|
+
**Model never auto-syncs when stale.** It serves the cached data with a loud age banner. This avoids surprise network calls mid-recipe. Use `sizmo sync` or `--fresh` to force a refresh.
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
sizmo crm # overview: counts + age per entity
|
|
143
|
+
sizmo crm pipelines # list pipelines + stages
|
|
144
|
+
sizmo crm calendars # list calendars
|
|
145
|
+
sizmo crm tags [--all] # list tags (truncated at 20 by default)
|
|
146
|
+
sizmo crm fields # list custom fields
|
|
147
|
+
sizmo crm users # list users
|
|
148
|
+
sizmo crm location # timezone / currency / country
|
|
149
|
+
sizmo crm pipelines --json # machine output with _meta.source/syncedAt/ageMs/stale
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The JSON `_meta` block in every `crm` response lets agents branch on staleness without parsing prose:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
"_meta": { "source": "cache", "syncedAt": 1718000000000, "ageMs": 3600000, "stale": false, "offline": false }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Scope requirements** for a full sync: `opportunities.readonly`, `calendars.readonly`, `locations/tags.readonly`, `locations/customFields.readonly`, `users.readonly`, `locations.readonly`. A 401/403 on one entity marks it blocked; the rest still store. `sizmo crm` shows `✖ needs <scope>` for blocked entities.
|
|
159
|
+
|
|
110
160
|
## Read-only + safety promise
|
|
111
161
|
|
|
112
162
|
- **Never writes to GoHighLevel.** No contacts created, no messages sent, no invoices issued, no payments charged.
|
|
@@ -149,4 +199,4 @@ MIT. See LICENSE.
|
|
|
149
199
|
|
|
150
200
|
---
|
|
151
201
|
|
|
152
|
-
Built by Sizmo —
|
|
202
|
+
Built by [Sizmo](https://github.com/csalamida07-cyber/sizmo-ghl-cli) — GHL CRM systems & automation. Unofficial; not affiliated with HighLevel.
|
package/SKILL.md
CHANGED
|
@@ -28,4 +28,4 @@ Read-only GoHighLevel ops. Every command takes `--json` (stable envelope: `{sche
|
|
|
28
28
|
- No location resolved → exit 3. Pass `--profile` or set `GHL_LOCATION_ID`; there is no default location.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
|
-
Built by Sizmo —
|
|
31
|
+
Built by Sizmo — GHL CRM systems & automation. Unofficial; not affiliated with HighLevel.
|
package/commands/crm.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// commands/crm.mjs — query surface for the local CRM model.
|
|
2
|
+
// Overview counts + per-entity lists. Honest staleness on every read.
|
|
3
|
+
// Missing model → auto-sync once (first run). Stale → serve + banner, no auto-sync.
|
|
4
|
+
// READ-ONLY. No writes to GoHighLevel.
|
|
5
|
+
import { loadModel, syncModel, isStale, ageMs, ENTITY_SPECS, DEFAULT_MODEL_DIR } from '../lib/model.mjs';
|
|
6
|
+
|
|
7
|
+
export const meta = {
|
|
8
|
+
name: 'crm',
|
|
9
|
+
summary: 'Query the local CRM model — counts, lists, staleness',
|
|
10
|
+
flags: [
|
|
11
|
+
{ name: '--all', type: 'bool', desc: 'show all items (overrides high-cardinality truncation)' },
|
|
12
|
+
],
|
|
13
|
+
readOnly: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TRUNCATE_ABOVE = 20; // default max items for high-cardinality entities (tags, fields)
|
|
17
|
+
|
|
18
|
+
// Alias map for subcommands
|
|
19
|
+
const ALIAS = { fields: 'customFields' };
|
|
20
|
+
const VALID_SUBS = ['pipelines', 'calendars', 'tags', 'fields', 'users', 'location'];
|
|
21
|
+
|
|
22
|
+
export async function run(args, ctx) {
|
|
23
|
+
const dir = ctx._modelDir ?? DEFAULT_MODEL_DIR;
|
|
24
|
+
const loc = ctx.cfg.loc;
|
|
25
|
+
const nowMs = typeof ctx.now === 'function' ? ctx.now() : ctx.now;
|
|
26
|
+
const showAll = !!(args.all || args['--all']);
|
|
27
|
+
|
|
28
|
+
// Sub-command from positional args
|
|
29
|
+
const sub = args._?.[0] || null;
|
|
30
|
+
|
|
31
|
+
// 1. Load model (auto-sync if missing)
|
|
32
|
+
let model = loadModel(loc, dir);
|
|
33
|
+
if (!model) {
|
|
34
|
+
// First run — auto-sync. syncModel throws if cold+offline.
|
|
35
|
+
ctx.out.warn('model not found — running first-time sync...');
|
|
36
|
+
try {
|
|
37
|
+
model = await syncModel({ http: ctx.http, loc, dir, now: typeof ctx.now === 'function' ? ctx.now : () => ctx.now });
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e.offline) {
|
|
40
|
+
ctx.out.warn("⚠ OFFLINE — can't reach GoHighLevel — check your connection; run `sizmo sync` when online");
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Build overall model meta
|
|
48
|
+
const modelAgeMs = nowMs - model.syncedAt;
|
|
49
|
+
// Determine overall staleness: any entity past its TTL
|
|
50
|
+
const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
|
|
51
|
+
let anyStale = false;
|
|
52
|
+
for (const [name, ent] of Object.entries(model.entities)) {
|
|
53
|
+
if (!ent.blocked && !ent.networkError && specMap[name] && isStale(ent, nowMs, specMap[name].ttlMs)) {
|
|
54
|
+
anyStale = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Determine offline: model was last sync'd while offline, OR the model itself has the offline flag.
|
|
59
|
+
// Also detect if a model exists but a refresh just failed (model.offline = true from the last sync).
|
|
60
|
+
const offline = !!(model.offline);
|
|
61
|
+
const meta = { source: 'cache', syncedAt: model.syncedAt, ageMs: modelAgeMs, stale: anyStale, offline };
|
|
62
|
+
|
|
63
|
+
// 3. Warn if offline (showing stale/cached data) or just stale
|
|
64
|
+
if (offline) {
|
|
65
|
+
const cacheAge = fmtAge(modelAgeMs);
|
|
66
|
+
ctx.out.warn(`⚠ OFFLINE — showing cache from ${new Date(model.syncedAt).toISOString()} (${cacheAge} old) — run \`sizmo sync\` when online`);
|
|
67
|
+
} else if (anyStale) {
|
|
68
|
+
ctx.out.warn(`⚠ model is stale (${fmtAge(modelAgeMs)} old) — run sizmo sync to refresh`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 4. Dispatch sub-command or overview
|
|
72
|
+
if (!sub) {
|
|
73
|
+
return overview(model, meta, nowMs, specMap, ctx);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const resolved = ALIAS[sub] ?? sub;
|
|
77
|
+
if (!VALID_SUBS.includes(sub) && !VALID_SUBS.includes(resolved)) {
|
|
78
|
+
ctx.out.warn(`unknown crm subcommand "${sub}" — valid: ${VALID_SUBS.join(', ')}`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (resolved === 'location') return locationCmd(model, meta, ctx);
|
|
83
|
+
return entityList(resolved, model, meta, nowMs, specMap, showAll, ctx);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── overview ──────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function overview(model, modelMeta, nowMs, specMap, ctx) {
|
|
89
|
+
const counts = {};
|
|
90
|
+
const blocked = {};
|
|
91
|
+
const networkErrors = {};
|
|
92
|
+
for (const spec of ENTITY_SPECS) {
|
|
93
|
+
const ent = model.entities[spec.name];
|
|
94
|
+
if (!ent) { counts[spec.name] = 0; continue; }
|
|
95
|
+
if (ent.networkError) {
|
|
96
|
+
counts[spec.name] = null;
|
|
97
|
+
networkErrors[spec.name] = ent.error ?? 'network error';
|
|
98
|
+
} else if (ent.blocked) {
|
|
99
|
+
counts[spec.name] = null;
|
|
100
|
+
blocked[spec.name] = ent.scope;
|
|
101
|
+
} else if (spec.name === 'location') {
|
|
102
|
+
counts[spec.name] = ent.item ? 1 : 0;
|
|
103
|
+
} else {
|
|
104
|
+
counts[spec.name] = Array.isArray(ent.items) ? ent.items.length : 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build human-friendly output structure
|
|
109
|
+
const data = {
|
|
110
|
+
pipelines: counts.pipelines,
|
|
111
|
+
calendars: counts.calendars,
|
|
112
|
+
tags: counts.tags,
|
|
113
|
+
customFields: counts.customFields,
|
|
114
|
+
users: counts.users,
|
|
115
|
+
location: counts.location,
|
|
116
|
+
_meta: modelMeta,
|
|
117
|
+
};
|
|
118
|
+
// Add blocked/networkError flags so agents can branch
|
|
119
|
+
for (const [name, scope] of Object.entries(blocked)) {
|
|
120
|
+
data[`${name}Blocked`] = true;
|
|
121
|
+
data[`${name}Scope`] = scope;
|
|
122
|
+
}
|
|
123
|
+
for (const [name] of Object.entries(networkErrors)) {
|
|
124
|
+
data[`${name}NetworkError`] = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ctx.out.data(data);
|
|
128
|
+
|
|
129
|
+
ctx.out.card(() => {
|
|
130
|
+
const age = fmtAge(modelMeta.ageMs);
|
|
131
|
+
const staleNote = modelMeta.offline ? ` ⚠ OFFLINE` : (modelMeta.stale ? ` ⚠ STALE — run sizmo sync` : '');
|
|
132
|
+
ctx.out.line(`\n CRM MODEL · loc ${model.locationId} · synced ${age}${staleNote}`);
|
|
133
|
+
ctx.out.line(' ' + '─'.repeat(50));
|
|
134
|
+
for (const spec of ENTITY_SPECS) {
|
|
135
|
+
if (spec.name === 'location') continue; // shown separately
|
|
136
|
+
const ent = model.entities[spec.name];
|
|
137
|
+
if (!ent) { ctx.out.line(` ${spec.name.padEnd(16)} 0`); continue; }
|
|
138
|
+
if (ent.networkError) {
|
|
139
|
+
ctx.out.line(` ${spec.name.padEnd(16)} ⚠ couldn't reach GHL`);
|
|
140
|
+
} else if (ent.blocked) {
|
|
141
|
+
ctx.out.line(` ${spec.name.padEnd(16)} ✖ needs ${ent.scope}`);
|
|
142
|
+
} else {
|
|
143
|
+
const count = Array.isArray(ent.items) ? ent.items.length : 0;
|
|
144
|
+
const entAge = ageMs(ent, nowMs);
|
|
145
|
+
const entStale = isStale(ent, nowMs, specMap[spec.name]?.ttlMs ?? Infinity);
|
|
146
|
+
const ageNote = entAge !== null ? ` · ${fmtAge(entAge)}${entStale ? ' ⚠' : ''}` : '';
|
|
147
|
+
ctx.out.line(` ${spec.name.padEnd(16)} ${count}${ageNote}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Location line
|
|
151
|
+
const locEnt = model.entities.location;
|
|
152
|
+
if (locEnt && !locEnt.blocked && locEnt.item) {
|
|
153
|
+
const loc = locEnt.item;
|
|
154
|
+
const cur = loc.business?.currency || loc.currency || 'PHP';
|
|
155
|
+
ctx.out.line(` ${'location'.padEnd(16)} ${loc.name || model.locationId} · ${cur} · ${loc.timezone || ''}`);
|
|
156
|
+
}
|
|
157
|
+
ctx.out.line(' ' + '─'.repeat(50));
|
|
158
|
+
ctx.out.line(' sizmo crm <pipelines|calendars|tags|fields|users|location> for details\n');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── entity list ───────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function entityList(entityName, model, modelMeta, nowMs, specMap, showAll, ctx) {
|
|
167
|
+
const ent = model.entities[entityName];
|
|
168
|
+
const spec = specMap[entityName];
|
|
169
|
+
|
|
170
|
+
if (ent?.networkError) {
|
|
171
|
+
ctx.out.warn(`⚠ ${entityName} — couldn't reach GHL during last sync`);
|
|
172
|
+
ctx.out.data({ entity: entityName, networkError: true, _meta: modelMeta });
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!ent || ent.blocked) {
|
|
177
|
+
const scope = ent?.scope ?? 'unknown';
|
|
178
|
+
ctx.out.warn(`✖ ${entityName} blocked — needs ${scope}`);
|
|
179
|
+
ctx.out.data({ entity: entityName, blocked: true, scope, _meta: modelMeta });
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const items = Array.isArray(ent.items) ? ent.items : [];
|
|
184
|
+
const entAge = ageMs(ent, nowMs);
|
|
185
|
+
const stale = spec ? isStale(ent, nowMs, spec.ttlMs) : false;
|
|
186
|
+
const entMeta = { ...modelMeta, entityFetchedAt: ent.fetchedAt, entityAgeMs: entAge, entityStale: stale };
|
|
187
|
+
|
|
188
|
+
// High-cardinality truncation (tags, customFields)
|
|
189
|
+
const highCard = entityName === 'tags' || entityName === 'customFields';
|
|
190
|
+
const shown = (highCard && !showAll) ? items.slice(0, TRUNCATE_ABOVE) : items;
|
|
191
|
+
const truncated = shown.length < items.length;
|
|
192
|
+
|
|
193
|
+
ctx.out.data({ entity: entityName, items: shown, total: items.length, truncated, _meta: entMeta });
|
|
194
|
+
|
|
195
|
+
ctx.out.card(() => {
|
|
196
|
+
const ageNote = entAge !== null ? `synced ${fmtAge(entAge)} ago` : '';
|
|
197
|
+
const staleNote = stale ? ' ⚠ STALE' : '';
|
|
198
|
+
ctx.out.line(`\n ${entityName.toUpperCase()} · ${items.length} item(s) · ${ageNote}${staleNote}`);
|
|
199
|
+
ctx.out.line(' ' + '─'.repeat(50));
|
|
200
|
+
if (entityName === 'pipelines') {
|
|
201
|
+
for (const pl of shown) {
|
|
202
|
+
ctx.out.line(` ${(pl.name || pl.id).slice(0, 40)}`);
|
|
203
|
+
for (const s of (pl.stages || [])) {
|
|
204
|
+
ctx.out.line(` [${String(s.position ?? '').padStart(2)}] ${(s.name || s.id).slice(0, 36)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} else if (entityName === 'users') {
|
|
208
|
+
for (const u of shown) {
|
|
209
|
+
const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.name || u.id;
|
|
210
|
+
ctx.out.line(` ${name.slice(0, 30).padEnd(30)} ${(u.email || '').slice(0, 36)}`);
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
for (const item of shown) {
|
|
214
|
+
const label = item.name || item.id;
|
|
215
|
+
const extra = item.fieldKey ? ` key: ${item.fieldKey}` : (item.calendarType ? ` ${item.calendarType}` : '');
|
|
216
|
+
ctx.out.line(` ${label.slice(0, 40)}${extra}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (truncated) ctx.out.line(` … ${items.length - shown.length} more — --all to show all`);
|
|
220
|
+
ctx.out.line(' ' + '─'.repeat(50) + '\n');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── location subcommand ───────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function locationCmd(model, modelMeta, ctx) {
|
|
229
|
+
const ent = model.entities.location;
|
|
230
|
+
if (!ent || ent.blocked) {
|
|
231
|
+
const scope = ent?.scope ?? 'locations.readonly';
|
|
232
|
+
ctx.out.warn(`✖ location blocked — needs ${scope}`);
|
|
233
|
+
ctx.out.data({ blocked: true, scope, _meta: modelMeta });
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const item = ent.item ?? {};
|
|
237
|
+
ctx.out.data({ item, location: item, _meta: modelMeta });
|
|
238
|
+
ctx.out.card(() => {
|
|
239
|
+
ctx.out.line(`\n LOCATION · ${item.name || model.locationId}`);
|
|
240
|
+
ctx.out.line(' ' + '─'.repeat(40));
|
|
241
|
+
ctx.out.line(` id ${item.id || model.locationId}`);
|
|
242
|
+
ctx.out.line(` timezone ${item.timezone || '—'}`);
|
|
243
|
+
ctx.out.line(` currency ${item.business?.currency || item.currency || '—'}`);
|
|
244
|
+
ctx.out.line(` country ${item.country || '—'}`);
|
|
245
|
+
ctx.out.line(' ' + '─'.repeat(40) + '\n');
|
|
246
|
+
});
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function fmtAge(ms) {
|
|
253
|
+
if (ms == null || ms < 0) return '?';
|
|
254
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
255
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
256
|
+
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
|
|
257
|
+
return `${Math.round(ms / 86_400_000)}d`;
|
|
258
|
+
}
|
package/commands/noshow.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// commands/noshow.mjs — No-show recovery: surfaces who no-showed to re-book.
|
|
2
2
|
// Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
|
|
3
|
+
// v0.5.0: calendar list from CRM model (no per-run /calendars/ re-fetch); events still live.
|
|
4
|
+
// v0.6.0 (C2): modelMeta emitted in JSON envelope; staleness note in TTY.
|
|
3
5
|
// READ-ONLY. Never messages, never books.
|
|
4
6
|
import { mapLimit } from '../lib/pool.mjs';
|
|
7
|
+
import { ENTITY_SPECS } from '../lib/model.mjs';
|
|
5
8
|
export const meta = {
|
|
6
9
|
name: 'noshow',
|
|
7
10
|
summary: 'No-show recovery — who to re-book',
|
|
@@ -24,12 +27,39 @@ export async function collect(args, ctx) {
|
|
|
24
27
|
const NOW = ctx.now;
|
|
25
28
|
const START = NOW - DAYS * 24 * 60 * 60 * 1000;
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// Get calendar list from the CRM model if available; fall back to live fetch.
|
|
31
|
+
let cals = null;
|
|
32
|
+
let modelLoaded = null;
|
|
33
|
+
let modelMeta = null;
|
|
34
|
+
if (ctx.ensureModel) {
|
|
35
|
+
try {
|
|
36
|
+
modelLoaded = await ctx.ensureModel();
|
|
37
|
+
if (modelLoaded?.entities?.calendars && !modelLoaded.entities.calendars.blocked && !modelLoaded.entities.calendars.networkError) {
|
|
38
|
+
cals = modelLoaded.entities.calendars.items ?? [];
|
|
39
|
+
}
|
|
40
|
+
} catch { /* fall through to live fetch */ }
|
|
41
|
+
}
|
|
42
|
+
// Build modelMeta for the JSON envelope (C2)
|
|
43
|
+
if (modelLoaded) {
|
|
44
|
+
const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
|
|
45
|
+
const calEnt = modelLoaded.entities?.calendars;
|
|
46
|
+
const calSpec = specMap.calendars;
|
|
47
|
+
const calStale = calEnt && calSpec ? (NOW - (calEnt.fetchedAt ?? 0)) > calSpec.ttlMs : false;
|
|
48
|
+
modelMeta = {
|
|
49
|
+
syncedAt: modelLoaded.syncedAt,
|
|
50
|
+
ageMs: NOW - modelLoaded.syncedAt,
|
|
51
|
+
stale: calStale,
|
|
52
|
+
offline: !!(modelLoaded.offline),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (cals === null) {
|
|
56
|
+
const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
|
|
57
|
+
if (!cr.ok) {
|
|
58
|
+
ctx.out.warn(`can't see calendars → HTTP ${cr.code}`, { degraded: true });
|
|
59
|
+
return { location: LOC, calendars: 0, noshows: 0, shown: 0, list: [], ...(modelMeta ? { modelMeta } : {}) };
|
|
60
|
+
}
|
|
61
|
+
cals = cr.j.calendars || [];
|
|
31
62
|
}
|
|
32
|
-
const cals = cr.j.calendars || [];
|
|
33
63
|
const noshows = [];
|
|
34
64
|
let skippedCalendars = 0;
|
|
35
65
|
// Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
|
|
@@ -82,6 +112,7 @@ export async function collect(args, ctx) {
|
|
|
82
112
|
when: new Date(n.when).toISOString(),
|
|
83
113
|
calendar: n.cal,
|
|
84
114
|
})),
|
|
115
|
+
...(modelMeta ? { modelMeta } : {}),
|
|
85
116
|
};
|
|
86
117
|
}
|
|
87
118
|
|
|
@@ -100,6 +131,16 @@ export async function run(args, ctx) {
|
|
|
100
131
|
|
|
101
132
|
ctx.out.card(() => {
|
|
102
133
|
ctx.out.line(`\n NO-SHOW RECOVERY — ${data.noshows} no-show(s) · last ${DAYS}d · ${data.calendars} calendars · loc ${data.location}`);
|
|
134
|
+
// C2: staleness note when model is old/offline
|
|
135
|
+
if (data.modelMeta) {
|
|
136
|
+
const mm = data.modelMeta;
|
|
137
|
+
if (mm.offline) {
|
|
138
|
+
ctx.out.line(` · CRM model OFFLINE — calendar list from cache`);
|
|
139
|
+
} else if (mm.stale) {
|
|
140
|
+
const ageD = Math.round(mm.ageMs / 86400000);
|
|
141
|
+
ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
103
144
|
ctx.out.line(' ' + '─'.repeat(70));
|
|
104
145
|
if (!data.list.length) {
|
|
105
146
|
ctx.out.line(' No no-shows in window. ✅\n');
|
package/commands/pipeline.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// commands/pipeline.mjs — Pipeline health: value by stage + stuck sweep.
|
|
2
2
|
// Trust-fix #1: LOC from ctx.cfg.loc.
|
|
3
3
|
// Trust-fix #2: opps paginate to completion.
|
|
4
|
+
// v0.5.0: stage/pipeline names sourced from ctx CRM model (no per-run structure re-fetch).
|
|
5
|
+
// v0.6.0 (C2): names resolved via ctx.resolve (never fabricated); modelMeta emitted with staleness signal.
|
|
6
|
+
// I1 fix: stage sort uses model position (not undefined .sid).
|
|
4
7
|
// READ-ONLY.
|
|
5
8
|
import { paginate } from '../lib/paginate.mjs';
|
|
9
|
+
import { ENTITY_SPECS } from '../lib/model.mjs';
|
|
6
10
|
|
|
7
11
|
export const meta = {
|
|
8
12
|
name: 'pipeline',
|
|
@@ -31,19 +35,75 @@ export async function collect(args, ctx) {
|
|
|
31
35
|
return d >= 1 ? d + 'd' : Math.max(1, Math.floor((NOW - t) / 3600000)) + 'h';
|
|
32
36
|
};
|
|
33
37
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Build stage/pipeline name resolution from the CRM model (no per-run structure re-fetch).
|
|
39
|
+
// Falls back to a live fetch only when model is genuinely unavailable.
|
|
40
|
+
// C2: names go through ctx.resolve when available — miss → '<unknown:id — run sizmo sync>'
|
|
41
|
+
// I1: stagePosition map keyed by stage-id carries model position (not a discarded .sid).
|
|
42
|
+
|
|
43
|
+
let modelLoaded = null; // the raw model blob (for modelMeta)
|
|
44
|
+
let resolver = null; // ctx.resolve (makeResolver instance)
|
|
45
|
+
const stagePosition = {}; // sid → model position (for sort)
|
|
46
|
+
const stageName = {}, pipeName = {}; // fallback maps (live-fetch path)
|
|
47
|
+
|
|
48
|
+
// Try model first (via ctx.ensureModel / ctx.resolve)
|
|
49
|
+
const usingModelPath = !!(ctx.ensureModel || ctx.resolve);
|
|
50
|
+
if (usingModelPath) {
|
|
51
|
+
try {
|
|
52
|
+
if (ctx.ensureModel) modelLoaded = await ctx.ensureModel();
|
|
53
|
+
resolver = ctx.resolve ?? null;
|
|
54
|
+
// Build stagePosition from model for sort (I1 fix)
|
|
55
|
+
if (modelLoaded?.entities?.pipelines && !modelLoaded.entities.pipelines.blocked && !modelLoaded.entities.pipelines.networkError) {
|
|
56
|
+
for (const pl of (modelLoaded.entities.pipelines.items ?? [])) {
|
|
57
|
+
for (const s of (pl.stages || [])) {
|
|
58
|
+
stagePosition[s.id] = s.position ?? 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch { /* fall through to live fetch */ }
|
|
39
63
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
64
|
+
|
|
65
|
+
// modelMeta for staleness signal (C2)
|
|
66
|
+
const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
|
|
67
|
+
let modelMeta = null;
|
|
68
|
+
if (modelLoaded) {
|
|
69
|
+
const plEnt = modelLoaded.entities?.pipelines;
|
|
70
|
+
const plSpec = specMap.pipelines;
|
|
71
|
+
const plFetchedAt = plEnt?.fetchedAt ?? null;
|
|
72
|
+
const plAgeMs = plFetchedAt != null ? NOW - plFetchedAt : null;
|
|
73
|
+
const plStale = plEnt && plSpec ? (NOW - (plEnt.fetchedAt ?? 0)) > plSpec.ttlMs : false;
|
|
74
|
+
modelMeta = {
|
|
75
|
+
syncedAt: modelLoaded.syncedAt,
|
|
76
|
+
ageMs: NOW - modelLoaded.syncedAt,
|
|
77
|
+
stale: plStale,
|
|
78
|
+
offline: !!(modelLoaded.offline),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!resolver) {
|
|
83
|
+
// Fallback: live fetch (model genuinely unavailable)
|
|
84
|
+
const p = await ctx.http.get('/opportunities/pipelines', { query: { locationId: LOC } });
|
|
85
|
+
if (!p.ok) {
|
|
86
|
+
ctx.out.warn(`can't see pipelines → HTTP ${p.code}`, { degraded: true });
|
|
87
|
+
return { location: LOC, totalValue: 0, openCount: 0, pipelines: [], stuck: [], modelMeta };
|
|
88
|
+
}
|
|
89
|
+
const pipelines = p.j.pipelines || [];
|
|
90
|
+
for (const pl of pipelines) {
|
|
91
|
+
pipeName[pl.id] = pl.name;
|
|
92
|
+
(pl.stages || []).forEach((s) => { stageName[s.id] = s.name; stagePosition[s.id] = s.position ?? 0; });
|
|
93
|
+
}
|
|
45
94
|
}
|
|
46
95
|
|
|
96
|
+
// Helper: resolve pipeline name (via resolver or fallback map)
|
|
97
|
+
const resolvePipeName = (pid) => {
|
|
98
|
+
if (resolver) return resolver.label('pipeline', pid);
|
|
99
|
+
return pipeName[pid] || pid;
|
|
100
|
+
};
|
|
101
|
+
// Helper: resolve stage name (via resolver or fallback map)
|
|
102
|
+
const resolveStageName = (sid) => {
|
|
103
|
+
if (resolver) return resolver.label('stage', sid);
|
|
104
|
+
return stageName[sid] || sid;
|
|
105
|
+
};
|
|
106
|
+
|
|
47
107
|
// all open opps paginated to completion (trust-fix #2)
|
|
48
108
|
const opps = [];
|
|
49
109
|
let firstOppErr = null;
|
|
@@ -98,19 +158,22 @@ export async function collect(args, ctx) {
|
|
|
98
158
|
totalValue: total,
|
|
99
159
|
openCount: opps.length,
|
|
100
160
|
pipelines: Object.entries(byPipe).map(([pid, stages]) => ({
|
|
101
|
-
pipeline:
|
|
161
|
+
pipeline: resolvePipeName(pid),
|
|
162
|
+
// I1 fix: carry sid onto mapped object; sort by model stagePosition (never undefined)
|
|
102
163
|
stages: Object.entries(stages)
|
|
103
|
-
.map(([sid, v]) => ({ stage:
|
|
104
|
-
.sort((a, b) =>
|
|
164
|
+
.map(([sid, v]) => ({ sid, stage: resolveStageName(sid), position: stagePosition[sid] ?? Infinity, ...v }))
|
|
165
|
+
.sort((a, b) => a.position - b.position)
|
|
166
|
+
.map(({ sid: _sid, position: _pos, ...rest }) => rest), // strip internal keys from output
|
|
105
167
|
})),
|
|
106
168
|
stuck: stuck.map(x => ({
|
|
107
169
|
name: x.o.name,
|
|
108
170
|
value: x.o.monetaryValue,
|
|
109
|
-
stage:
|
|
171
|
+
stage: resolveStageName(x.o.pipelineStageId || x.o.stageId || ''),
|
|
110
172
|
idle: ago(x.t),
|
|
111
173
|
oppId: x.o.id,
|
|
112
174
|
contactId: x.o.contactId,
|
|
113
175
|
})),
|
|
176
|
+
...(modelMeta ? { modelMeta } : {}),
|
|
114
177
|
};
|
|
115
178
|
}
|
|
116
179
|
|
|
@@ -124,6 +187,16 @@ export async function run(args, ctx) {
|
|
|
124
187
|
|
|
125
188
|
ctx.out.card(() => {
|
|
126
189
|
ctx.out.line(`\n PIPELINE HEALTH · ${money2(data.totalValue)} across ${data.openCount} open deal(s) · loc ${data.location}`);
|
|
190
|
+
// C2: staleness note when model is old/offline
|
|
191
|
+
if (data.modelMeta) {
|
|
192
|
+
const mm = data.modelMeta;
|
|
193
|
+
if (mm.offline) {
|
|
194
|
+
ctx.out.line(` · CRM model OFFLINE — stage names from cache`);
|
|
195
|
+
} else if (mm.stale) {
|
|
196
|
+
const ageD = Math.round(mm.ageMs / 86400000);
|
|
197
|
+
ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
127
200
|
for (const pl of data.pipelines) {
|
|
128
201
|
ctx.out.line(`\n ${pl.pipeline}`);
|
|
129
202
|
for (const s of pl.stages) {
|