@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 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
- | `--orderBy` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
86
- | `--orderDir` | `asc` or `desc` |
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 --orderBy net_worth_usd --orderDir desc --size 5
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":"tx_count","op":"gt","value":50}]' --logic or --size 20
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 `--tagId` or a batch via `--labels`.
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
- | `--tagId` | Label identifier (e.g. `vip`, `airdrop_eligible`) |
123
+ | `--tag-id` | Label identifier (e.g. `vip`, `airdrop_eligible`) |
123
124
  | `--value` | Optional label value (e.g. tier name, country code) |
124
- | `--chainId` | Optional chain identifier the label applies to |
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... --tagId vip
129
- formo profiles labels create 0xd8dA... --tagId tier --value gold --chainId 1
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
- | `--tagId` | Label identifier to delete (required) |
140
- | `--chainId` | Optional chain identifier to scope the deletion |
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... --tagId vip
144
- formo profiles labels delete 0xd8dA... --tagId tier --chainId 1
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
- | `--triggerType` | Trigger type (e.g. `event`, `threshold`) |
167
- | `--triggerFilters` | JSON array of trigger filter objects |
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" --triggerType event \
173
- --triggerFilters '[{"name":"event","operator":"equals","value":"transaction"}]' \
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 --boardId <boardId>`
230
+ ### `charts list --board-id <boardId>`
230
231
  List all charts in a board.
231
232
 
232
- ### `charts get <chartId> --boardId <boardId>`
233
+ ### `charts get <chartId> --board-id <boardId>`
233
234
  Get a single chart by ID.
234
235
 
235
- ### `charts create --boardId <boardId> --body '<json>'`
236
+ ### `charts create --board-id <boardId> --body '<json>'`
236
237
  Create a chart from a JSON config string.
237
238
 
238
- ### `charts update <chartId> --boardId <boardId> --body '<json>'`
239
+ ### `charts update <chartId> --board-id <boardId> --body '<json>'`
239
240
  Update a chart.
240
241
 
241
- ### `charts delete <chartId> --boardId <boardId>`
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
- | `--filterSets` | JSON array of filter set strings defining the segment |
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
- | `--writeKey` | Project write SDK key |
351
+ | `--write-key` | Project write SDK key |
326
352
 
327
353
  ```bash
328
- formo import wallets --addresses '["0xabc...","0xdef..."]' --writeKey write_key_xyz
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": "tx_count", "op": "gte", "value": 5 }
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` | Profile field to filter on |
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
 
@@ -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('--triggerFilters must be a valid JSON array');
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
- try {
67
- const conditions = JSON.parse(options.conditions);
68
- if (!Array.isArray(conditions))
69
- throw new Error('not an array');
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 for advanced filtering'),
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 > 10000',
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":"tx_count","op":"gt","value":50}]',
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 --tagId (single label) or --labels (batch JSON array)');
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('--tagId is required');
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('--filterSets must be a valid JSON array');
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.2.0",
4
- "packageManager": "pnpm@10.28.2",
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.7.0",
32
+ "axios": "^1.15.2",
33
33
  "incur": "^0.3.4"
34
34
  },
35
35
  "overrides": {