@formo/cli 0.2.0 → 1.0.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 +69 -29
- package/dist/commands/alerts.js +1 -1
- package/dist/commands/analytics.d.ts +25 -0
- package/dist/commands/analytics.js +153 -0
- package/dist/commands/profiles.d.ts +7 -0
- package/dist/commands/profiles.js +78 -16
- package/dist/commands/segments.js +1 -1
- package/dist/index.js +5 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -82,18 +82,19 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
|
|
|
82
82
|
| `--address` | Filter by wallet address |
|
|
83
83
|
| `--page` | Page number (1-indexed, default `1`) |
|
|
84
84
|
| `--size` | Page size (default `100`, max `1000`) |
|
|
85
|
-
| `--
|
|
86
|
-
| `--
|
|
85
|
+
| `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
|
|
86
|
+
| `--order-dir` | `asc` or `desc` |
|
|
87
87
|
| `--expand` | Comma-separated fields to expand |
|
|
88
88
|
| `--conditions` | JSON array of `FilterCondition` objects (see below) |
|
|
89
89
|
| `--logic` | Combine conditions with `and` (default) or `or` |
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
92
|
formo profiles search --size 10
|
|
93
|
-
formo profiles search --
|
|
93
|
+
formo profiles search --order-by net_worth_usd --order-dir desc --size 5
|
|
94
94
|
formo profiles search --page 2 --size 20
|
|
95
|
-
formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --size 20
|
|
96
|
-
formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"
|
|
95
|
+
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20
|
|
96
|
+
formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20
|
|
97
|
+
formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
|
|
97
98
|
```
|
|
98
99
|
|
|
99
100
|
### `profiles update <address>`
|
|
@@ -115,18 +116,18 @@ formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}'
|
|
|
115
116
|
|
|
116
117
|
### `profiles labels create <address>`
|
|
117
118
|
|
|
118
|
-
Upsert one or more labels on a wallet profile. Provide either a single label via `--
|
|
119
|
+
Upsert one or more labels on a wallet profile. Provide either a single label via `--tag-id` or a batch via `--labels`.
|
|
119
120
|
|
|
120
121
|
| Option | Description |
|
|
121
122
|
|---|---|
|
|
122
|
-
| `--
|
|
123
|
+
| `--tag-id` | Label identifier (e.g. `vip`, `airdrop_eligible`) |
|
|
123
124
|
| `--value` | Optional label value (e.g. tier name, country code) |
|
|
124
|
-
| `--
|
|
125
|
+
| `--chain-id` | Optional chain identifier the label applies to |
|
|
125
126
|
| `--labels` | JSON array of `UserLabelInput` objects for batch upsert |
|
|
126
127
|
|
|
127
128
|
```bash
|
|
128
|
-
formo profiles labels create 0xd8dA... --
|
|
129
|
-
formo profiles labels create 0xd8dA... --
|
|
129
|
+
formo profiles labels create 0xd8dA... --tag-id vip
|
|
130
|
+
formo profiles labels create 0xd8dA... --tag-id tier --value gold --chain-id 1
|
|
130
131
|
formo profiles labels create 0xd8dA... --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]'
|
|
131
132
|
```
|
|
132
133
|
|
|
@@ -136,12 +137,12 @@ Delete a label from a wallet profile.
|
|
|
136
137
|
|
|
137
138
|
| Option | Description |
|
|
138
139
|
|---|---|
|
|
139
|
-
| `--
|
|
140
|
-
| `--
|
|
140
|
+
| `--tag-id` | Label identifier to delete (required) |
|
|
141
|
+
| `--chain-id` | Optional chain identifier to scope the deletion |
|
|
141
142
|
|
|
142
143
|
```bash
|
|
143
|
-
formo profiles labels delete 0xd8dA... --
|
|
144
|
-
formo profiles labels delete 0xd8dA... --
|
|
144
|
+
formo profiles labels delete 0xd8dA... --tag-id vip
|
|
145
|
+
formo profiles labels delete 0xd8dA... --tag-id tier --chain-id 1
|
|
145
146
|
```
|
|
146
147
|
|
|
147
148
|
> Requires `profiles:write` scope.
|
|
@@ -163,14 +164,14 @@ Get a single alert by ID.
|
|
|
163
164
|
| Option | Description |
|
|
164
165
|
|---|---|
|
|
165
166
|
| `--name` | Alert name |
|
|
166
|
-
| `--
|
|
167
|
-
| `--
|
|
167
|
+
| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) |
|
|
168
|
+
| `--trigger-filters` | JSON array of trigger filter objects |
|
|
168
169
|
| `--recipient` | JSON array of recipient objects |
|
|
169
170
|
| `--secret` | Webhook secret |
|
|
170
171
|
|
|
171
172
|
```bash
|
|
172
|
-
formo alerts create --name "High value tx" --
|
|
173
|
-
--
|
|
173
|
+
formo alerts create --name "High value tx" --trigger-type event \
|
|
174
|
+
--trigger-filters '[{"name":"event","operator":"equals","value":"transaction"}]' \
|
|
174
175
|
--recipient '[{"type":"email","value":["alerts@myapp.com"]}]'
|
|
175
176
|
```
|
|
176
177
|
|
|
@@ -226,19 +227,19 @@ Delete a board.
|
|
|
226
227
|
|
|
227
228
|
Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`.
|
|
228
229
|
|
|
229
|
-
### `charts list --
|
|
230
|
+
### `charts list --board-id <boardId>`
|
|
230
231
|
List all charts in a board.
|
|
231
232
|
|
|
232
|
-
### `charts get <chartId> --
|
|
233
|
+
### `charts get <chartId> --board-id <boardId>`
|
|
233
234
|
Get a single chart by ID.
|
|
234
235
|
|
|
235
|
-
### `charts create --
|
|
236
|
+
### `charts create --board-id <boardId> --body '<json>'`
|
|
236
237
|
Create a chart from a JSON config string.
|
|
237
238
|
|
|
238
|
-
### `charts update <chartId> --
|
|
239
|
+
### `charts update <chartId> --board-id <boardId> --body '<json>'`
|
|
239
240
|
Update a chart.
|
|
240
241
|
|
|
241
|
-
### `charts delete <chartId> --
|
|
242
|
+
### `charts delete <chartId> --board-id <boardId>`
|
|
242
243
|
Delete a chart.
|
|
243
244
|
|
|
244
245
|
---
|
|
@@ -291,7 +292,7 @@ List all user segments.
|
|
|
291
292
|
| Option | Description |
|
|
292
293
|
|---|---|
|
|
293
294
|
| `--title` | Segment title |
|
|
294
|
-
| `--
|
|
295
|
+
| `--filter-sets` | JSON array of filter set strings defining the segment |
|
|
295
296
|
|
|
296
297
|
### `segments delete <segmentId>`
|
|
297
298
|
Delete a user segment.
|
|
@@ -313,6 +314,31 @@ formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net
|
|
|
313
314
|
|
|
314
315
|
---
|
|
315
316
|
|
|
317
|
+
## `formo analytics`
|
|
318
|
+
|
|
319
|
+
Pre-built analytics pipes — the same data that powers the Formo dashboard — without writing SQL. Each pipe is a subcommand: `formo analytics <pipe>`.
|
|
320
|
+
|
|
321
|
+
**Pipes:** `kpis`, `event_timeseries`, `funnel`, `flow`, `frequency`, `lifecycle`, `retention`, `revenue_overview`, `revenue_by_metric`, `revenue_timeseries`, `volume_by_metric`, `top_chains`, `top_events`, `top_locations`, `top_pages`, `top_sources`, `top_wallets`
|
|
322
|
+
|
|
323
|
+
| Option | Description |
|
|
324
|
+
|---|---|
|
|
325
|
+
| `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) |
|
|
326
|
+
| `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) |
|
|
327
|
+
| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`notIn` with a pipe-delimited value (e.g. `"chrome\|firefox"`) |
|
|
328
|
+
| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) |
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
formo analytics kpis
|
|
332
|
+
formo analytics kpis --date-from 2026-04-01 --date-to 2026-04-30 --params '{"group_by":"device"}'
|
|
333
|
+
formo analytics funnel --date-from 2026-04-01 --date-to 2026-04-30 --params '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]}],"window_seconds":86400}'
|
|
334
|
+
formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params '{"limit":10}'
|
|
335
|
+
formo analytics retention --filters '[{"field":"location","op":"equals","value":"US"}]'
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
> Requires `query:read` scope. Run `formo analytics <pipe> --help` for the pipe-specific params accepted via `--params`.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
316
342
|
## `formo import`
|
|
317
343
|
|
|
318
344
|
### `import wallets`
|
|
@@ -322,10 +348,10 @@ Bulk-import wallet addresses into the project via the events API.
|
|
|
322
348
|
| Option | Description |
|
|
323
349
|
|---|---|
|
|
324
350
|
| `--addresses` | JSON array of wallet address strings |
|
|
325
|
-
| `--
|
|
351
|
+
| `--write-key` | Project write SDK key |
|
|
326
352
|
|
|
327
353
|
```bash
|
|
328
|
-
formo import wallets --addresses '["0xabc...","0xdef..."]' --
|
|
354
|
+
formo import wallets --addresses '["0xabc...","0xdef..."]' --write-key write_key_xyz
|
|
329
355
|
```
|
|
330
356
|
|
|
331
357
|
---
|
|
@@ -336,16 +362,30 @@ formo import wallets --addresses '["0xabc...","0xdef..."]' --writeKey write_key_
|
|
|
336
362
|
|
|
337
363
|
```json
|
|
338
364
|
[
|
|
339
|
-
{ "field": "net_worth_usd", "op": "gt", "value": 10000 },
|
|
340
|
-
{ "field": "
|
|
365
|
+
{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 },
|
|
366
|
+
{ "field": "chains.1.balance", "op": "gte", "value": 1000 }
|
|
341
367
|
]
|
|
342
368
|
```
|
|
343
369
|
|
|
370
|
+
> **The `field` must be a typed path.** A bare name like `net_worth_usd` is
|
|
371
|
+
> silently ignored by the API (no error, no filtering — the search returns
|
|
372
|
+
> everything). Always prefix the field with its type.
|
|
373
|
+
|
|
344
374
|
| Field | Type | Description |
|
|
345
375
|
|---|---|---|
|
|
346
|
-
| `field` | `string` |
|
|
376
|
+
| `field` | `string` | Typed path (see prefixes below) |
|
|
347
377
|
| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` |
|
|
348
378
|
| `value` | `any` | Value to compare against |
|
|
379
|
+
| `scope` | `string` | _(token filters only)_ `any` or `protocol` |
|
|
380
|
+
| `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` |
|
|
381
|
+
|
|
382
|
+
| Prefix | Examples |
|
|
383
|
+
|---|---|
|
|
384
|
+
| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` |
|
|
385
|
+
| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) |
|
|
386
|
+
| `apps.` | `apps.uniswap-v3.balance` |
|
|
387
|
+
| `tokens.` | `tokens.0xA0b8…48.balance` |
|
|
388
|
+
| `labels.` | `labels.coinbase.verified_account` |
|
|
349
389
|
|
|
350
390
|
Combine multiple conditions with `--logic and` (default) or `--logic or`.
|
|
351
391
|
|
package/dist/commands/alerts.js
CHANGED
|
@@ -56,7 +56,7 @@ function buildAlertBody(options) {
|
|
|
56
56
|
body.trigger_filters = JSON.parse(options.triggerFilters);
|
|
57
57
|
}
|
|
58
58
|
catch {
|
|
59
|
-
throw new Error('--
|
|
59
|
+
throw new Error('--trigger-filters must be a valid JSON array');
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
if (options.recipient) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Cli } from 'incur';
|
|
2
|
+
export declare const analytics: Cli.Cli<{}, undefined, undefined>;
|
|
3
|
+
export interface AnalyticsOptions {
|
|
4
|
+
dateFrom?: string;
|
|
5
|
+
dateTo?: string;
|
|
6
|
+
filters?: string;
|
|
7
|
+
params?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the query-string params for an analytics pipe request.
|
|
11
|
+
*
|
|
12
|
+
* - `dateFrom`/`dateTo` map to the API's snake_case `date_from`/`date_to`.
|
|
13
|
+
* All pipes, including `funnel` and `flow`, use snake_case.
|
|
14
|
+
* - `filters` is a JSON array of `{ field, op, value }` objects, re-serialized
|
|
15
|
+
* as a JSON string (the pipe expects a JSON-encoded array in the query).
|
|
16
|
+
* - `params` is a JSON object of any pipe-specific params (e.g. funnel
|
|
17
|
+
* `steps`, kpis `group_by`, `limit`). Object/array values are JSON-encoded
|
|
18
|
+
* (pipes like funnel expect `steps` as a JSON-encoded string); primitives
|
|
19
|
+
* pass through unchanged. Reserved keys (the date/filters flags) are
|
|
20
|
+
* rejected, and the validated flags below always take precedence.
|
|
21
|
+
*
|
|
22
|
+
* Exported for unit testing.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildAnalyticsParams(options: AnalyticsOptions): Record<string, string | number | boolean>;
|
|
25
|
+
export declare function runAnalytics(pipe: string, options: AnalyticsOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analytics = void 0;
|
|
4
|
+
exports.buildAnalyticsParams = buildAnalyticsParams;
|
|
5
|
+
exports.runAnalytics = runAnalytics;
|
|
6
|
+
const incur_1 = require("incur");
|
|
7
|
+
const client_1 = require("../lib/client");
|
|
8
|
+
exports.analytics = incur_1.Cli.create('analytics', {
|
|
9
|
+
description: 'Pre-built analytics query commands — KPIs, funnels, retention, revenue, and top-N breakdowns',
|
|
10
|
+
});
|
|
11
|
+
// The pre-built analytics pipes exposed at GET /v0/<pipe>. Each requires the
|
|
12
|
+
// query:read scope. Common params (date_from, date_to, filters) are shared;
|
|
13
|
+
// pipe-specific params (e.g. funnel `steps`, kpis `group_by`, `limit`) are
|
|
14
|
+
// passed through the generic --params JSON object.
|
|
15
|
+
const PIPES = [
|
|
16
|
+
{ name: 'kpis', description: 'Traffic KPIs: visitors, pageviews, bounce rate, session duration' },
|
|
17
|
+
{ name: 'event_timeseries', description: 'Event counts over time' },
|
|
18
|
+
{ name: 'funnel', description: 'Conversion funnel across ordered steps. --params: steps (JSON array of {type,event,name,filters?}), window_seconds, funnel_type, breakdown' },
|
|
19
|
+
{ name: 'flow', description: 'User path/flow analysis. --params: start_step / end_step (JSON {type,event,...}), global_filters, window_seconds, max_steps' },
|
|
20
|
+
{ name: 'frequency', description: 'Engagement frequency distribution' },
|
|
21
|
+
{ name: 'lifecycle', description: 'User lifecycle stages (new, returning, power, resurrected, churned)' },
|
|
22
|
+
{ name: 'retention', description: 'Retention cohort analysis (params: id_type, event_type, event_name, min_users)' },
|
|
23
|
+
{ name: 'revenue_overview', description: 'Revenue overview with optional breakdown (params: group_by, rank_by)' },
|
|
24
|
+
{ name: 'revenue_by_metric', description: 'Revenue ranked by a metric column (params: metric_column, limit, offset)' },
|
|
25
|
+
{ name: 'revenue_timeseries', description: 'Revenue over time (params: address)' },
|
|
26
|
+
{ name: 'volume_by_metric', description: 'Trading volume ranked by a metric column (params: metric_column, limit, offset)' },
|
|
27
|
+
{ name: 'top_chains', description: 'Top chains by activity (params: limit, offset)' },
|
|
28
|
+
{ name: 'top_events', description: 'Top events by count (params: limit, offset, type)' },
|
|
29
|
+
{ name: 'top_locations', description: 'Top locations (params: limit, offset)' },
|
|
30
|
+
{ name: 'top_pages', description: 'Top pages by traffic (params: limit, offset, mode)' },
|
|
31
|
+
{ name: 'top_sources', description: 'Top acquisition sources (params: metric_column, limit, offset)' },
|
|
32
|
+
{ name: 'top_wallets', description: 'Top wallets by activity (params: limit, offset)' },
|
|
33
|
+
];
|
|
34
|
+
// Keys --params is not allowed to set: they have dedicated, validated flags
|
|
35
|
+
// (--date-from/--date-to/--filters). Rejecting them prevents --params from
|
|
36
|
+
// silently overriding validated input or pushing an invalid `filters` value
|
|
37
|
+
// (e.g. a non-JSON string) over the wire. Both casings of the date keys are
|
|
38
|
+
// rejected so a stray camelCase key can't slip through unvalidated.
|
|
39
|
+
const RESERVED_PARAM_KEYS = new Set([
|
|
40
|
+
'date_from',
|
|
41
|
+
'date_to',
|
|
42
|
+
'dateFrom',
|
|
43
|
+
'dateTo',
|
|
44
|
+
'filters',
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Build the query-string params for an analytics pipe request.
|
|
48
|
+
*
|
|
49
|
+
* - `dateFrom`/`dateTo` map to the API's snake_case `date_from`/`date_to`.
|
|
50
|
+
* All pipes, including `funnel` and `flow`, use snake_case.
|
|
51
|
+
* - `filters` is a JSON array of `{ field, op, value }` objects, re-serialized
|
|
52
|
+
* as a JSON string (the pipe expects a JSON-encoded array in the query).
|
|
53
|
+
* - `params` is a JSON object of any pipe-specific params (e.g. funnel
|
|
54
|
+
* `steps`, kpis `group_by`, `limit`). Object/array values are JSON-encoded
|
|
55
|
+
* (pipes like funnel expect `steps` as a JSON-encoded string); primitives
|
|
56
|
+
* pass through unchanged. Reserved keys (the date/filters flags) are
|
|
57
|
+
* rejected, and the validated flags below always take precedence.
|
|
58
|
+
*
|
|
59
|
+
* Exported for unit testing.
|
|
60
|
+
*/
|
|
61
|
+
function buildAnalyticsParams(options) {
|
|
62
|
+
const out = {};
|
|
63
|
+
// --params first, so the validated flags below override it.
|
|
64
|
+
if (options.params) {
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(options.params);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error('--params must be a valid JSON object');
|
|
71
|
+
}
|
|
72
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
73
|
+
throw new Error('--params must be a valid JSON object');
|
|
74
|
+
}
|
|
75
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
76
|
+
if (RESERVED_PARAM_KEYS.has(key)) {
|
|
77
|
+
throw new Error(`--params may not set "${key}" — use the --date-from/--date-to/--filters flags instead`);
|
|
78
|
+
}
|
|
79
|
+
if (value === null || value === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
if (typeof value === 'object') {
|
|
82
|
+
out[key] = JSON.stringify(value);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
out[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (options.dateFrom)
|
|
90
|
+
out.date_from = options.dateFrom;
|
|
91
|
+
if (options.dateTo)
|
|
92
|
+
out.date_to = options.dateTo;
|
|
93
|
+
if (options.filters) {
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(options.filters);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error('--filters must be a valid JSON array of {field,op,value} objects');
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(parsed)) {
|
|
102
|
+
throw new Error('--filters must be a valid JSON array of {field,op,value} objects');
|
|
103
|
+
}
|
|
104
|
+
out.filters = JSON.stringify(parsed);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function runAnalytics(pipe, options) {
|
|
109
|
+
(0, client_1.requireApiKey)();
|
|
110
|
+
const client = (0, client_1.createClient)();
|
|
111
|
+
return client.get(`/v0/${pipe}`, { params: buildAnalyticsParams(options) });
|
|
112
|
+
}
|
|
113
|
+
const sharedOptions = incur_1.z.object({
|
|
114
|
+
dateFrom: incur_1.z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe('Inclusive start date YYYY-MM-DD (default: 7 days before --date-to)'),
|
|
118
|
+
dateTo: incur_1.z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('Inclusive end date YYYY-MM-DD (default: today)'),
|
|
122
|
+
filters: incur_1.z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe('JSON array of filter conditions: [{"field","op","value"}]. ' +
|
|
126
|
+
'Use op "in"/"notIn" with a pipe-delimited value (e.g. "chrome|firefox").'),
|
|
127
|
+
params: incur_1.z
|
|
128
|
+
.string()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe('JSON object of pipe-specific params merged into the query, e.g. ' +
|
|
131
|
+
'{"limit":10,"group_by":"device"} or funnel ' +
|
|
132
|
+
'{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}. ' +
|
|
133
|
+
'May not set date_from/date_to/filters; use the dedicated --date-from/--date-to/--filters flags.'),
|
|
134
|
+
});
|
|
135
|
+
for (const pipe of PIPES) {
|
|
136
|
+
exports.analytics.command(pipe.name, {
|
|
137
|
+
description: pipe.description,
|
|
138
|
+
options: sharedOptions,
|
|
139
|
+
examples: [
|
|
140
|
+
{
|
|
141
|
+
description: `Get ${pipe.name} for the last 7 days (default range)`,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
options: { dateFrom: '2026-04-01', dateTo: '2026-04-30' },
|
|
145
|
+
description: `Get ${pipe.name} for April 2026`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
hint: 'Requires query:read scope on your API key. Pass pipe-specific params via --params.',
|
|
149
|
+
run({ options }) {
|
|
150
|
+
return runAnalytics(pipe.name, options);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -11,6 +11,13 @@ export interface SearchProfilesOptions {
|
|
|
11
11
|
conditions?: string;
|
|
12
12
|
logic?: 'and' | 'or';
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Parse and validate the --conditions JSON. Ensures it is an array of
|
|
16
|
+
* `{ field, op, value }` objects whose `field` is a typed path (e.g.
|
|
17
|
+
* `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
|
|
18
|
+
* dropped by the API, so it is rejected here. Exported for unit testing.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseSearchConditions(raw: string): unknown[];
|
|
14
21
|
export declare function searchProfilesRun(options: SearchProfilesOptions): Promise<import("axios").AxiosResponse<any, any, {}>>;
|
|
15
22
|
export interface UpdateProfileOptions {
|
|
16
23
|
properties: string;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.profilesLabels = exports.profiles = void 0;
|
|
4
4
|
exports.getProfileRun = getProfileRun;
|
|
5
|
+
exports.parseSearchConditions = parseSearchConditions;
|
|
5
6
|
exports.searchProfilesRun = searchProfilesRun;
|
|
6
7
|
exports.buildUpdateProfileBody = buildUpdateProfileBody;
|
|
7
8
|
exports.updateProfileRun = updateProfileRun;
|
|
@@ -41,10 +42,60 @@ exports.profiles.command('get', {
|
|
|
41
42
|
description: 'Get profile with expanded labels and chains',
|
|
42
43
|
},
|
|
43
44
|
],
|
|
45
|
+
hint: 'Requires profiles:read scope on your API key.',
|
|
44
46
|
run({ args, options }) {
|
|
45
47
|
return getProfileRun(args.address, options.expand);
|
|
46
48
|
},
|
|
47
49
|
});
|
|
50
|
+
// Accepted first segments for a FilterCondition `field`, mirroring the API's
|
|
51
|
+
// parseField(). A field whose prefix is not one of these is silently ignored
|
|
52
|
+
// server-side (no error, no filtering — the search returns everything), so we
|
|
53
|
+
// reject it client-side with an actionable message instead.
|
|
54
|
+
const CONDITION_FIELD_PREFIXES = new Set([
|
|
55
|
+
'user',
|
|
56
|
+
'users',
|
|
57
|
+
'chain',
|
|
58
|
+
'chains',
|
|
59
|
+
'app',
|
|
60
|
+
'apps',
|
|
61
|
+
'token',
|
|
62
|
+
'tokens',
|
|
63
|
+
'label',
|
|
64
|
+
'labels',
|
|
65
|
+
]);
|
|
66
|
+
/**
|
|
67
|
+
* Parse and validate the --conditions JSON. Ensures it is an array of
|
|
68
|
+
* `{ field, op, value }` objects whose `field` is a typed path (e.g.
|
|
69
|
+
* `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently
|
|
70
|
+
* dropped by the API, so it is rejected here. Exported for unit testing.
|
|
71
|
+
*/
|
|
72
|
+
function parseSearchConditions(raw) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(raw);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error('--conditions must be a valid JSON array of FilterCondition objects');
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(parsed)) {
|
|
81
|
+
throw new Error('--conditions must be a valid JSON array of FilterCondition objects');
|
|
82
|
+
}
|
|
83
|
+
for (const cond of parsed) {
|
|
84
|
+
if (!cond || typeof cond !== 'object' || Array.isArray(cond)) {
|
|
85
|
+
throw new Error('--conditions: each entry must be an object with field, op, value');
|
|
86
|
+
}
|
|
87
|
+
const field = cond.field;
|
|
88
|
+
if (typeof field !== 'string' || field.length === 0) {
|
|
89
|
+
throw new Error('--conditions: each entry must have a non-empty string "field"');
|
|
90
|
+
}
|
|
91
|
+
if (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) {
|
|
92
|
+
throw new Error(`--conditions: field "${field}" must be a typed path — prefix it with ` +
|
|
93
|
+
'users., chains., apps., tokens., or labels. ' +
|
|
94
|
+
'(a bare name is silently ignored by the API and returns the entire unfiltered dataset)');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
48
99
|
function searchProfilesRun(options) {
|
|
49
100
|
(0, client_1.requireApiKey)();
|
|
50
101
|
const client = (0, client_1.createClient)();
|
|
@@ -63,15 +114,10 @@ function searchProfilesRun(options) {
|
|
|
63
114
|
params.expand = options.expand;
|
|
64
115
|
let body;
|
|
65
116
|
if (options.conditions) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
body = { conditions, logic: options.logic ?? 'and' };
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
throw new Error('--conditions must be valid JSON array of FilterCondition objects');
|
|
74
|
-
}
|
|
117
|
+
body = {
|
|
118
|
+
conditions: parseSearchConditions(options.conditions),
|
|
119
|
+
logic: options.logic ?? 'and',
|
|
120
|
+
};
|
|
75
121
|
}
|
|
76
122
|
return client.request({ method: 'get', url: '/v0/profiles/', params, data: body });
|
|
77
123
|
}
|
|
@@ -102,7 +148,15 @@ exports.profiles.command('search', {
|
|
|
102
148
|
conditions: incur_1.z
|
|
103
149
|
.string()
|
|
104
150
|
.optional()
|
|
105
|
-
.describe('JSON array of FilterCondition objects
|
|
151
|
+
.describe('JSON array of FilterCondition objects: [{"field","op","value"}]. ' +
|
|
152
|
+
'The "field" MUST be a typed path — a bare name like "net_worth_usd" is silently ignored. ' +
|
|
153
|
+
'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' +
|
|
154
|
+
'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' +
|
|
155
|
+
'Socials: users.ens, users.farcaster, users.lens, etc. ' +
|
|
156
|
+
'Chains: chains.balance or chains.{chain_id}.balance. ' +
|
|
157
|
+
'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' +
|
|
158
|
+
'(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' +
|
|
159
|
+
'op: eq, neq, gt, gte, lt, lte, in, nin.'),
|
|
106
160
|
logic: incur_1.z
|
|
107
161
|
.enum(['and', 'or'])
|
|
108
162
|
.optional()
|
|
@@ -120,20 +174,28 @@ exports.profiles.command('search', {
|
|
|
120
174
|
},
|
|
121
175
|
{
|
|
122
176
|
options: {
|
|
123
|
-
conditions: '[{"field":"net_worth_usd","op":"gt","value":10000}]',
|
|
177
|
+
conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]',
|
|
124
178
|
size: 20,
|
|
125
179
|
},
|
|
126
|
-
description: 'Search profiles with net worth >
|
|
180
|
+
description: 'Search profiles with net worth > $10k',
|
|
127
181
|
},
|
|
128
182
|
{
|
|
129
183
|
options: {
|
|
130
|
-
conditions: '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"
|
|
184
|
+
conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]',
|
|
131
185
|
logic: 'or',
|
|
132
186
|
size: 20,
|
|
133
187
|
},
|
|
134
|
-
description: 'Search profiles matching either condition',
|
|
188
|
+
description: 'Search profiles matching either condition (net worth or volume)',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
options: {
|
|
192
|
+
conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]',
|
|
193
|
+
size: 20,
|
|
194
|
+
},
|
|
195
|
+
description: 'Search profiles with > $1k balance on Ethereum (chain 1)',
|
|
135
196
|
},
|
|
136
197
|
],
|
|
198
|
+
hint: 'Requires profiles:read scope on your API key. Filter "field" must be a typed path (e.g. users.net_worth_usd) — bare names are ignored by the API.',
|
|
137
199
|
run({ args: _args, options }) {
|
|
138
200
|
return searchProfilesRun(options);
|
|
139
201
|
},
|
|
@@ -211,7 +273,7 @@ function buildCreateLabelBody(options) {
|
|
|
211
273
|
single.chain_id = options.chainId;
|
|
212
274
|
return single;
|
|
213
275
|
}
|
|
214
|
-
throw new Error('Provide --
|
|
276
|
+
throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)');
|
|
215
277
|
}
|
|
216
278
|
function createProfileLabelRun(address, options) {
|
|
217
279
|
(0, client_1.requireApiKey)();
|
|
@@ -259,7 +321,7 @@ exports.profilesLabels.command('create', {
|
|
|
259
321
|
});
|
|
260
322
|
function buildDeleteLabelBody(options) {
|
|
261
323
|
if (!options.tagId) {
|
|
262
|
-
throw new Error('--
|
|
324
|
+
throw new Error('--tag-id is required');
|
|
263
325
|
}
|
|
264
326
|
const body = { tag_id: options.tagId };
|
|
265
327
|
if (options.chainId)
|
|
@@ -30,7 +30,7 @@ function buildCreateSegmentBody(options) {
|
|
|
30
30
|
parsedFilterSets = JSON.parse(options.filterSets);
|
|
31
31
|
}
|
|
32
32
|
catch {
|
|
33
|
-
throw new Error('--
|
|
33
|
+
throw new Error('--filter-sets must be a valid JSON array');
|
|
34
34
|
}
|
|
35
35
|
return {
|
|
36
36
|
title: options.title,
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const incur_1 = require("incur");
|
|
5
5
|
const alerts_1 = require("./commands/alerts");
|
|
6
|
+
const analytics_1 = require("./commands/analytics");
|
|
6
7
|
const boards_1 = require("./commands/boards");
|
|
7
8
|
const charts_1 = require("./commands/charts");
|
|
8
9
|
const contracts_1 = require("./commands/contracts");
|
|
@@ -64,6 +65,9 @@ const cli = incur_1.Cli.create("formo", {
|
|
|
64
65
|
"get the profile for wallet 0xabc",
|
|
65
66
|
"search profiles with net worth > 10000",
|
|
66
67
|
"run a SQL query on my analytics data",
|
|
68
|
+
"show traffic KPIs for the last 7 days",
|
|
69
|
+
"get the conversion funnel for the last month",
|
|
70
|
+
"list the top wallets by activity",
|
|
67
71
|
"search profiles ordered by last_onchain desc",
|
|
68
72
|
"list all project alerts",
|
|
69
73
|
"create an alert for high-value transactions",
|
|
@@ -234,6 +238,7 @@ cli.command("status", {
|
|
|
234
238
|
// ── command groups ──
|
|
235
239
|
cli.command(profiles_1.profiles);
|
|
236
240
|
cli.command(query_1.query);
|
|
241
|
+
cli.command(analytics_1.analytics);
|
|
237
242
|
cli.command(alerts_1.alerts);
|
|
238
243
|
cli.command(boards_1.boards);
|
|
239
244
|
cli.command(charts_1.charts);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formo/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"packageManager": "pnpm@
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"packageManager": "pnpm@11.1.2",
|
|
5
5
|
"description": "Formo API CLI — query profiles and analytics data",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"test:watch": "mocha --watch"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"axios": "^1.
|
|
32
|
+
"axios": "^1.15.2",
|
|
33
33
|
"incur": "^0.3.4"
|
|
34
34
|
},
|
|
35
35
|
"overrides": {
|