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/lib/model.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// lib/model.mjs — per-profile CRM structure store + sync.
|
|
2
|
+
// Caches 6 slow-changing GHL entities (pipelines, calendars, tags, customFields, users, location)
|
|
3
|
+
// in a single JSON blob per location at ~/.config/sizmo/model/<loc>.json.
|
|
4
|
+
// Atomic write (temp+rename, same-dir to avoid EXDEV), 0600, per-entity age tracked, partial-sync-safe.
|
|
5
|
+
// READ-ONLY. No writes to GoHighLevel.
|
|
6
|
+
import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { env as processEnv } from 'node:process';
|
|
10
|
+
import { mapLimit } from './pool.mjs';
|
|
11
|
+
|
|
12
|
+
const XDG = processEnv.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
13
|
+
export const DEFAULT_MODEL_DIR = join(XDG, 'sizmo', 'model');
|
|
14
|
+
export const SCHEMA_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
// TTLs: pipelines/calendars/users/location 24h; tags/customFields 12h
|
|
17
|
+
const H24 = 24 * 60 * 60 * 1000;
|
|
18
|
+
const H12 = 12 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// Entity specs — each describes how to fetch + parse one CRM entity.
|
|
21
|
+
// buildPath(loc) → the API path segment; version → GHL API Version header;
|
|
22
|
+
// scope → human-readable scope name for blocked messages;
|
|
23
|
+
// ttlMs → staleness threshold; extract(json) → items[] or item{}.
|
|
24
|
+
export const ENTITY_SPECS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'pipelines',
|
|
27
|
+
buildPath: (loc) => `/opportunities/pipelines?locationId=${loc}`,
|
|
28
|
+
version: '2021-07-28',
|
|
29
|
+
scope: 'opportunities.readonly',
|
|
30
|
+
ttlMs: H24,
|
|
31
|
+
extract: (j) => ({ items: j?.pipelines ?? [] }),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'calendars',
|
|
35
|
+
buildPath: (loc) => `/calendars/?locationId=${loc}`,
|
|
36
|
+
version: '2021-04-15',
|
|
37
|
+
scope: 'calendars.readonly',
|
|
38
|
+
ttlMs: H24,
|
|
39
|
+
extract: (j) => ({ items: j?.calendars ?? [] }),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'tags',
|
|
43
|
+
buildPath: (loc) => `/locations/${loc}/tags`,
|
|
44
|
+
version: '2021-07-28',
|
|
45
|
+
scope: 'locations/tags.readonly',
|
|
46
|
+
ttlMs: H12,
|
|
47
|
+
extract: (j) => ({ items: j?.tags ?? [] }),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'customFields',
|
|
51
|
+
buildPath: (loc) => `/locations/${loc}/customFields?model=all`,
|
|
52
|
+
version: '2021-07-28',
|
|
53
|
+
scope: 'locations/customFields.readonly',
|
|
54
|
+
ttlMs: H12,
|
|
55
|
+
extract: (j) => ({ items: j?.customFields ?? [] }),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'users',
|
|
59
|
+
buildPath: (loc) => `/users/?locationId=${loc}`,
|
|
60
|
+
version: '2021-07-28',
|
|
61
|
+
scope: 'users.readonly',
|
|
62
|
+
ttlMs: H24,
|
|
63
|
+
extract: (j) => ({ items: j?.users ?? [] }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'location',
|
|
67
|
+
buildPath: (loc) => `/locations/${loc}`,
|
|
68
|
+
version: '2021-07-28',
|
|
69
|
+
scope: 'locations.readonly',
|
|
70
|
+
ttlMs: H24,
|
|
71
|
+
extract: (j) => ({ item: j?.location ?? {} }),
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* syncModel — fetch (up to) 6 entities and write the blob atomically.
|
|
77
|
+
* @param {object} opts
|
|
78
|
+
* @param {object} opts.http ctx.http (get returns {code,ok,j})
|
|
79
|
+
* @param {string} opts.loc locationId
|
|
80
|
+
* @param {string} [opts.dir] override default model dir (for tests)
|
|
81
|
+
* @param {Function} [opts.now] injectable clock () => ms
|
|
82
|
+
* @param {string[]} [opts.only] subset of entity names (partial sync; for `sync <entity>`)
|
|
83
|
+
* @returns {object} the written model blob, with an `offline` boolean property:
|
|
84
|
+
* true = at least one entity hit a network/transport error (couldn't reach GHL at all).
|
|
85
|
+
* false = all entities either succeeded or were blocked by an HTTP 401/403 scope error.
|
|
86
|
+
* @throws {Error} if the model is missing AND all fetches hit network errors (cold+offline).
|
|
87
|
+
* Caller must show a real error — do NOT display a fresh-looking empty model in this case.
|
|
88
|
+
*/
|
|
89
|
+
export async function syncModel({ http, loc, dir = DEFAULT_MODEL_DIR, now = Date.now, only = null } = {}) {
|
|
90
|
+
const specs = only ? ENTITY_SPECS.filter(s => only.includes(s.name)) : ENTITY_SPECS;
|
|
91
|
+
const syncedAt = now();
|
|
92
|
+
|
|
93
|
+
// Start from any existing model (keep entities not in this sync run)
|
|
94
|
+
const base = loadModel(loc, dir) || {};
|
|
95
|
+
const hadExistingModel = !!(base.entities && Object.keys(base.entities).length > 0);
|
|
96
|
+
const entities = base.entities ? { ...base.entities } : {};
|
|
97
|
+
|
|
98
|
+
let networkErrorCount = 0;
|
|
99
|
+
|
|
100
|
+
await mapLimit(specs, 5, async (spec) => {
|
|
101
|
+
const path = spec.buildPath(loc);
|
|
102
|
+
let r;
|
|
103
|
+
try {
|
|
104
|
+
r = await http.get(path, spec.version !== '2021-07-28' ? { version: spec.version } : undefined);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// Transport/network failure — couldn't reach GHL at all. Distinct from auth errors.
|
|
107
|
+
networkErrorCount++;
|
|
108
|
+
entities[spec.name] = { networkError: true, error: e?.message ?? 'network error', fetchedAt: now() };
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// http.get returned code:0 signals a network-level failure (no response from server)
|
|
112
|
+
if (r.code === 0) {
|
|
113
|
+
networkErrorCount++;
|
|
114
|
+
entities[spec.name] = { networkError: true, error: r.message ?? 'no response', fetchedAt: now() };
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (r.code === 401 || r.code === 403) {
|
|
118
|
+
// Scope/auth blocked — this is NOT a network error. Clearly distinguished.
|
|
119
|
+
entities[spec.name] = { blocked: true, scope: spec.scope, fetchedAt: now() };
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!r.ok) {
|
|
123
|
+
// Other HTTP errors (5xx, 404, etc.) — mark blocked with code, NOT networkError.
|
|
124
|
+
entities[spec.name] = { blocked: true, scope: spec.scope, httpCode: r.code, fetchedAt: now() };
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const extracted = spec.extract(r.j);
|
|
128
|
+
entities[spec.name] = { fetchedAt: now(), ...extracted };
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const offline = networkErrorCount > 0;
|
|
132
|
+
|
|
133
|
+
// Cold + offline: no existing model AND all/some fetches hit network errors.
|
|
134
|
+
// Do NOT write a fresh-looking empty blob. Throw so the caller shows a real error.
|
|
135
|
+
if (!hadExistingModel && offline) {
|
|
136
|
+
const err = new Error(
|
|
137
|
+
"can't reach GoHighLevel — check your connection; run `sizmo sync` when online"
|
|
138
|
+
);
|
|
139
|
+
err.offline = true;
|
|
140
|
+
err.networkErrorCount = networkErrorCount;
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const model = {
|
|
145
|
+
schemaVersion: SCHEMA_VERSION,
|
|
146
|
+
locationId: loc,
|
|
147
|
+
syncedAt,
|
|
148
|
+
entities,
|
|
149
|
+
offline,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
writeModelAtomic(loc, model, dir);
|
|
153
|
+
return model;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* loadModel — read the blob for a location. Returns null if missing, corrupt,
|
|
158
|
+
* or schemaVersion mismatch (caller must re-sync).
|
|
159
|
+
*/
|
|
160
|
+
export function loadModel(loc, dir = DEFAULT_MODEL_DIR) {
|
|
161
|
+
const path = join(dir, `${loc}.json`);
|
|
162
|
+
try {
|
|
163
|
+
const raw = readFileSync(path, 'utf8');
|
|
164
|
+
const parsed = JSON.parse(raw);
|
|
165
|
+
if (!parsed || parsed.schemaVersion !== SCHEMA_VERSION) return null;
|
|
166
|
+
return parsed;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* isStale — true if the entity's fetchedAt is older than ttlMs relative to now.
|
|
174
|
+
* @param {object} entity model.entities[name]
|
|
175
|
+
* @param {number} nowMs current time in ms
|
|
176
|
+
* @param {number} ttlMs TTL for this entity type
|
|
177
|
+
*/
|
|
178
|
+
export function isStale(entity, nowMs, ttlMs) {
|
|
179
|
+
if (!entity || entity.blocked || entity.networkError || typeof entity.fetchedAt !== 'number') return true;
|
|
180
|
+
return (nowMs - entity.fetchedAt) > ttlMs;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ageMs — ms since this entity was last fetched.
|
|
185
|
+
*/
|
|
186
|
+
export function ageMs(entity, nowMs) {
|
|
187
|
+
if (!entity || typeof entity.fetchedAt !== 'number') return null;
|
|
188
|
+
return nowMs - entity.fetchedAt;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── internal ──────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* writeModelAtomic — write model to disk with a same-directory temp+rename pattern.
|
|
195
|
+
* Same-dir temp avoids EXDEV (cross-filesystem rename failure) that occurs when tmpdir()
|
|
196
|
+
* is on a different mount than the model dir. Throws on any write failure so callers can
|
|
197
|
+
* surface the error (M1 + M2 fix).
|
|
198
|
+
*/
|
|
199
|
+
function writeModelAtomic(loc, model, dir) {
|
|
200
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
201
|
+
const dest = join(dir, `${loc}.json`);
|
|
202
|
+
// Same directory as dest — no cross-FS EXDEV risk.
|
|
203
|
+
const tmp = join(dir, `.${loc}.json.tmp.${process.pid}`);
|
|
204
|
+
try {
|
|
205
|
+
writeFileSync(tmp, JSON.stringify(model, null, 2), { mode: 0o600 });
|
|
206
|
+
renameSync(tmp, dest);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// Clean up orphaned temp file on failure; ignore cleanup error.
|
|
209
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
210
|
+
// Re-throw so the caller (sync command) can surface "sync failed — nothing written".
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
}
|
package/lib/registry.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/registry.mjs — name → lazy loader. All
|
|
1
|
+
// lib/registry.mjs — name → lazy loader. All 12 commands run in-process (v0.5 importable-core).
|
|
2
2
|
export const registry = {
|
|
3
3
|
snapshot: () => import('../commands/snapshot.mjs'),
|
|
4
4
|
triage: () => import('../commands/triage.mjs'),
|
|
@@ -10,4 +10,6 @@ export const registry = {
|
|
|
10
10
|
'booked-not-paid': () => import('../commands/booked-not-paid.mjs'),
|
|
11
11
|
brief: () => import('../commands/brief.mjs'),
|
|
12
12
|
focus: () => import('../commands/focus.mjs'),
|
|
13
|
+
crm: () => import('../commands/crm.mjs'),
|
|
14
|
+
sync: () => import('../commands/sync.mjs'),
|
|
13
15
|
};
|
package/lib/resolver.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// lib/resolver.mjs — id→name maps from the CRM model. Never fabricates a name.
|
|
2
|
+
// makeResolver(model, {now}) → resolver with .resolve(kind, id) and .label(kind, id).
|
|
3
|
+
//
|
|
4
|
+
// resolve(kind, id) → { name: string|null, status: 'hit'|'miss'|'stale', pipelineId?, pipelineName? }
|
|
5
|
+
// hit — found in model, entity is fresh
|
|
6
|
+
// stale — found in model, but entity is past its TTL
|
|
7
|
+
// miss — not found (never fabricated; name=null)
|
|
8
|
+
//
|
|
9
|
+
// label(kind, id) → string suitable for human display:
|
|
10
|
+
// hit/stale → name (stale callers should add age note separately)
|
|
11
|
+
// miss → '<unknown:<id> — run sizmo sync>'
|
|
12
|
+
//
|
|
13
|
+
// Kind mapping:
|
|
14
|
+
// 'pipeline' → pipelines entity (id→name)
|
|
15
|
+
// 'stage' → stages nested in pipelines (id→{name,pipelineId,pipelineName,position})
|
|
16
|
+
// 'calendar' → calendars entity
|
|
17
|
+
// 'tag' → tags entity
|
|
18
|
+
// 'customField' → customFields entity
|
|
19
|
+
// 'user' → users entity
|
|
20
|
+
import { isStale, ENTITY_SPECS } from './model.mjs';
|
|
21
|
+
|
|
22
|
+
const TTL_MAP = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s.ttlMs]));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* makeResolver — build maps from a model blob; return resolver.
|
|
26
|
+
* @param {object|null} model the loaded model blob (or null if missing)
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {Function} opts.now injectable clock () => ms
|
|
29
|
+
*/
|
|
30
|
+
export function makeResolver(model, { now = Date.now } = {}) {
|
|
31
|
+
// Materialized maps — built once on construction
|
|
32
|
+
const maps = buildMaps(model);
|
|
33
|
+
|
|
34
|
+
function resolveKind(kind, id) {
|
|
35
|
+
const { entityName, map } = kindMeta(kind, maps);
|
|
36
|
+
if (!map) return { name: null, status: 'miss' };
|
|
37
|
+
|
|
38
|
+
const entry = map.get(id);
|
|
39
|
+
if (!entry) return { name: null, status: 'miss' };
|
|
40
|
+
|
|
41
|
+
// Check staleness of the source entity
|
|
42
|
+
const entity = model?.entities?.[entityName];
|
|
43
|
+
const ttl = TTL_MAP[entityName];
|
|
44
|
+
const stale = entity && ttl ? isStale(entity, now(), ttl) : false;
|
|
45
|
+
|
|
46
|
+
const result = { name: entry.name, status: stale ? 'stale' : 'hit' };
|
|
47
|
+
if (entry.pipelineId !== undefined) result.pipelineId = entry.pipelineId;
|
|
48
|
+
if (entry.pipelineName !== undefined) result.pipelineName = entry.pipelineName;
|
|
49
|
+
if (entry.position !== undefined) result.position = entry.position;
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
/**
|
|
55
|
+
* resolve(kind, id) → { name, status, ...extras }
|
|
56
|
+
* Never throws; miss returns { name: null, status: 'miss' }.
|
|
57
|
+
*/
|
|
58
|
+
resolve(kind, id) {
|
|
59
|
+
try { return resolveKind(kind, id); }
|
|
60
|
+
catch { return { name: null, status: 'miss' }; }
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* label(kind, id) → human string.
|
|
65
|
+
* hit/stale → name; miss → '<unknown:<id> — run sizmo sync>'
|
|
66
|
+
*/
|
|
67
|
+
label(kind, id) {
|
|
68
|
+
const r = resolveKind(kind, id);
|
|
69
|
+
if (r.name !== null) return r.name;
|
|
70
|
+
return `<unknown:${id} — run sizmo sync>`;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── internal ──────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function buildMaps(model) {
|
|
78
|
+
const empty = () => new Map();
|
|
79
|
+
if (!model || !model.entities) {
|
|
80
|
+
return { pipeline: empty(), stage: empty(), calendar: empty(), tag: empty(), customField: empty(), user: empty() };
|
|
81
|
+
}
|
|
82
|
+
const e = model.entities;
|
|
83
|
+
|
|
84
|
+
// pipelines + stages
|
|
85
|
+
const pipelineMap = new Map();
|
|
86
|
+
const stageMap = new Map();
|
|
87
|
+
if (e.pipelines && !e.pipelines.blocked && Array.isArray(e.pipelines.items)) {
|
|
88
|
+
for (const pl of e.pipelines.items) {
|
|
89
|
+
pipelineMap.set(pl.id, { name: pl.name });
|
|
90
|
+
for (const s of (pl.stages || [])) {
|
|
91
|
+
stageMap.set(s.id, { name: s.name, pipelineId: pl.id, pipelineName: pl.name, position: s.position ?? 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// calendars
|
|
97
|
+
const calMap = new Map();
|
|
98
|
+
if (e.calendars && !e.calendars.blocked && Array.isArray(e.calendars.items)) {
|
|
99
|
+
for (const c of e.calendars.items) calMap.set(c.id, { name: c.name });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// tags
|
|
103
|
+
const tagMap = new Map();
|
|
104
|
+
if (e.tags && !e.tags.blocked && Array.isArray(e.tags.items)) {
|
|
105
|
+
for (const t of e.tags.items) tagMap.set(t.id, { name: t.name });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// customFields
|
|
109
|
+
const fieldMap = new Map();
|
|
110
|
+
if (e.customFields && !e.customFields.blocked && Array.isArray(e.customFields.items)) {
|
|
111
|
+
for (const f of e.customFields.items) fieldMap.set(f.id, { name: f.name, key: f.fieldKey });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// users
|
|
115
|
+
const userMap = new Map();
|
|
116
|
+
if (e.users && !e.users.blocked && Array.isArray(e.users.items)) {
|
|
117
|
+
for (const u of e.users.items) {
|
|
118
|
+
const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.name || u.email || u.id;
|
|
119
|
+
userMap.set(u.id, { name });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { pipeline: pipelineMap, stage: stageMap, calendar: calMap, tag: tagMap, customField: fieldMap, user: userMap };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Which entity-name and map corresponds to each public kind string
|
|
127
|
+
function kindMeta(kind, maps) {
|
|
128
|
+
switch (kind) {
|
|
129
|
+
case 'pipeline': return { entityName: 'pipelines', map: maps.pipeline };
|
|
130
|
+
case 'stage': return { entityName: 'pipelines', map: maps.stage };
|
|
131
|
+
case 'calendar': return { entityName: 'calendars', map: maps.calendar };
|
|
132
|
+
case 'tag': return { entityName: 'tags', map: maps.tag };
|
|
133
|
+
case 'customField': return { entityName: 'customFields', map: maps.customField };
|
|
134
|
+
case 'user': return { entityName: 'users', map: maps.user };
|
|
135
|
+
default: return { entityName: null, map: null };
|
|
136
|
+
}
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sizmo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Unofficial read-only GoHighLevel CLI
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Unofficial read-only GoHighLevel CLI — read your CRM (leads, bookings, pipeline, A/R, payments) from the terminal. Not affiliated with HighLevel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"sizmo": "bin/sizmo.mjs"
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
"node": ">=20"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node --test",
|
|
14
|
-
"prepublishOnly": "node --test"
|
|
13
|
+
"test": "node --test --test-concurrency=1",
|
|
14
|
+
"prepublishOnly": "node --test --test-concurrency=1"
|
|
15
15
|
},
|
|
16
16
|
"author": "Sizmo / CJ Salamida",
|
|
17
17
|
"license": "MIT",
|