From e4eee2a7b93cc1ef02a988410d8a9529051d22d0 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Fri, 24 Jul 2026 12:14:43 +0700 Subject: [PATCH 1/3] docs: use canonical filter operators (v0/profiles alias retirement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend retired legacy operator aliases (eq/neq/gt/gte/lt/lte/nin/ contains) on POST /v0/profiles — they now return 400. Update the CLI's filter-operator surface to the canonical vocabulary: - profiles.ts: `--conditions` help text now lists equals/notEquals/greater/greaterOrEqual/less/lessOrEqual/in/notIn/ includes/notEmpty/isEmpty (adds the new value-less existence ops). - README.md, SKILLS.md, bodyBuilders.test.ts: canonicalize all `"op":""` examples/fixtures (gt→greater, gte→greaterOrEqual, eq→equals, …) so documented commands don't 400. The CLI forwards `op` verbatim (no client-side validation), so this is a docs/examples change; no request-building logic changed. Build + 122 tests green. --- README.md | 10 +++++----- SKILLS.md | 6 +++--- src/commands/profiles.ts | 3 ++- test/commands/bodyBuilders.test.ts | 10 +++++----- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b5db10c..cac71b7 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,9 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat formo profiles search --size 10 formo profiles search --order-by net_worth_usd --order-dir desc --size 5 formo profiles search --page 2 --size 20 -formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 -formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20 -formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"greater","value":10000}]' --size 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"greater","value":10000},{"field":"users.volume","op":"greater","value":1000}]' --logic or --size 20 +formo profiles search --conditions '[{"field":"chains.1.balance","op":"greater","value":1000}]' --size 20 ``` ### `profiles update
` @@ -453,8 +453,8 @@ formo events ingest --event '{"type":"track","channel":"cli","version":"1","anon ```json [ - { "field": "users.net_worth_usd", "op": "gt", "value": 10000 }, - { "field": "chains.1.balance", "op": "gte", "value": 1000 } + { "field": "users.net_worth_usd", "op": "greater", "value": 10000 }, + { "field": "chains.1.balance", "op": "greaterOrEqual", "value": 1000 } ] ``` diff --git a/SKILLS.md b/SKILLS.md index 0657d32..fb6f951 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -94,10 +94,10 @@ formo profiles search --size 10 formo profiles search --order-by net_worth_usd --order-dir desc --size 5 # Profiles with net worth over $10k -formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"greater","value":10000}]' --size 20 # Profiles with > $1k balance on Ethereum (chain 1) -formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 +formo profiles search --conditions '[{"field":"chains.1.balance","op":"greater","value":1000}]' --size 20 # Second page of 20, sorted by tx count formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --expand labels @@ -105,7 +105,7 @@ formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 -- **FilterCondition schema:** ```json -{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 } +{ "field": "users.net_worth_usd", "op": "greater", "value": 10000 } ``` | Property | Type | Description | diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index d3985f2..a2131bd 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -256,7 +256,8 @@ profiles.command('search', { 'Chains: chains.balance or chains.{chain_id}.balance. ' + 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' + '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' + - 'op: eq, neq, gt, gte, lt, lte, in, nin.', + 'op: equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, in, notIn, includes, notEmpty, isEmpty ' + + '(includes = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields).', ), logic: z .enum(['and', 'or']) diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index 29c39fb..702a783 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -372,7 +372,7 @@ describe('commands / body builders', function () { describe('parseSearchConditions()', function () { it('accepts conditions with typed field prefixes', function () { const conds = parseSearchConditions( - '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.1.balance","op":"gte","value":1000}]', + '[{"field":"users.net_worth_usd","op":"greater","value":10000},{"field":"chains.1.balance","op":"greaterOrEqual","value":1000}]', ); expect(conds).to.have.length(2); expect((conds[0] as { field: string }).field).to.equal('users.net_worth_usd'); @@ -381,20 +381,20 @@ describe('commands / body builders', function () { it('accepts apps., tokens., and labels. prefixes', function () { expect(() => parseSearchConditions( - '[{"field":"apps.uniswap-v3.balance","op":"gt","value":0},{"field":"tokens.0xabc.balance","op":"gt","value":1},{"field":"labels.coinbase.verified_account","op":"eq","value":"true"}]', + '[{"field":"apps.uniswap-v3.balance","op":"greater","value":0},{"field":"tokens.0xabc.balance","op":"greater","value":1},{"field":"labels.coinbase.verified_account","op":"equals","value":"true"}]', ), ).to.not.throw(); }); it('rejects a bare (untyped) field — the silent-failure footgun', function () { expect(() => - parseSearchConditions('[{"field":"net_worth_usd","op":"gt","value":10000}]'), + parseSearchConditions('[{"field":"net_worth_usd","op":"greater","value":10000}]'), ).to.throw(/must be a typed path/); }); it('rejects a known field name without its prefix', function () { expect(() => - parseSearchConditions('[{"field":"tx_count","op":"gt","value":5}]'), + parseSearchConditions('[{"field":"tx_count","op":"greater","value":5}]'), ).to.throw(/must be a typed path/); }); @@ -411,7 +411,7 @@ describe('commands / body builders', function () { }); it('throws when an entry is missing a string field', function () { - expect(() => parseSearchConditions('[{"op":"gt","value":1}]')).to.throw( + expect(() => parseSearchConditions('[{"op":"greater","value":1}]')).to.throw( /must have a non-empty string "field"/, ); }); From 4f13c501d702f0ed2a98fb0456bf47c48d24895b Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Fri, 24 Jul 2026 16:30:10 +0700 Subject: [PATCH 2/3] Emit canonical terse filter operators (long forms now deprecated aliases) The backend's canonical vocabulary is the terse set (eq, neq, in, nin, gt, gte, lt, lte, contains, startsWith, endsWith, notEmpty, isEmpty); long-form spellings are deprecated-but-accepted aliases on /v0/profiles, folded server-side. Docs, help text, and fixtures now use canonical terse ops; the CLI keeps forwarding op verbatim (no client allowlist) so aliases still work end-to-end, pinned by a new test. Fixes latent doc bugs that would 400: analytics --filters said notIn (terse-only endpoints take nin), a retention example used equals, and a SKILLS alert example used greater_than (never a valid token). Co-Authored-By: Claude Fable 5 --- README.md | 18 +++++++++--------- SKILLS.md | 14 +++++++------- src/commands/analytics.ts | 2 +- src/commands/profiles.ts | 5 +++-- test/commands/analytics.test.ts | 4 ++-- test/commands/bodyBuilders.test.ts | 21 ++++++++++++++------- 6 files changed, 36 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index cac71b7..3a8a9cb 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,9 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat formo profiles search --size 10 formo profiles search --order-by net_worth_usd --order-dir desc --size 5 formo profiles search --page 2 --size 20 -formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"greater","value":10000}]' --size 20 -formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"greater","value":10000},{"field":"users.volume","op":"greater","value":1000}]' --logic or --size 20 -formo profiles search --conditions '[{"field":"chains.1.balance","op":"greater","value":1000}]' --size 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]' --logic or --size 20 +formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 ``` ### `profiles update
` @@ -208,7 +208,7 @@ Get a single alert by ID. ```bash formo alerts create --name "High value tx" --trigger-type event \ - --trigger-filters '[{"name":"event","operator":"equals","value":"transaction"}]' \ + --trigger-filters '[{"name":"event","operator":"eq","value":"transaction"}]' \ --recipient '[{"type":"email","value":["alerts@myapp.com"]}]' ``` @@ -401,7 +401,7 @@ Pre-built analytics pipes — the same data that powers the Formo dashboard — |---|---| | `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) | | `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) | -| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`notIn` with a pipe-delimited value (e.g. `"chrome\|firefox"`) | +| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`nin` with a pipe-delimited value (e.g. `"chrome\|firefox"`) | | `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) | ```bash @@ -409,7 +409,7 @@ formo analytics kpis formo analytics kpis --date-from 2026-04-01 --date-to 2026-04-30 --params '{"group_by":"device"}' 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}' formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params '{"limit":10}' -formo analytics retention --filters '[{"field":"location","op":"equals","value":"US"}]' +formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]' ``` > Requires `query:read` scope. Run `formo analytics --help` for the pipe-specific params accepted via `--params`. @@ -453,8 +453,8 @@ formo events ingest --event '{"type":"track","channel":"cli","version":"1","anon ```json [ - { "field": "users.net_worth_usd", "op": "greater", "value": 10000 }, - { "field": "chains.1.balance", "op": "greaterOrEqual", "value": 1000 } + { "field": "users.net_worth_usd", "op": "gt", "value": 10000 }, + { "field": "chains.1.balance", "op": "gte", "value": 1000 } ] ``` @@ -465,7 +465,7 @@ formo events ingest --event '{"type":"track","channel":"cli","version":"1","anon | Field | Type | Description | |---|---|---| | `field` | `string` | Typed path (see prefixes below) | -| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | +| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains` (social fields only), `notEmpty` / `isEmpty` (value-less existence checks). Long-form spellings (`equals`, `greater`, `includes`, …) are deprecated aliases the API still accepts | | `value` | `any` | Value to compare against | | `scope` | `string` | _(token filters only)_ `any` or `protocol` | | `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` | diff --git a/SKILLS.md b/SKILLS.md index fb6f951..8fe8d5a 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -94,10 +94,10 @@ formo profiles search --size 10 formo profiles search --order-by net_worth_usd --order-dir desc --size 5 # Profiles with net worth over $10k -formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"greater","value":10000}]' --size 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --size 20 # Profiles with > $1k balance on Ethereum (chain 1) -formo profiles search --conditions '[{"field":"chains.1.balance","op":"greater","value":1000}]' --size 20 +formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 # Second page of 20, sorted by tx count formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --expand labels @@ -105,13 +105,13 @@ formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 -- **FilterCondition schema:** ```json -{ "field": "users.net_worth_usd", "op": "greater", "value": 10000 } +{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 } ``` | Property | Type | Description | |---|---|---| | `field` | `string` | **Typed path** — a bare name like `net_worth_usd` is silently ignored by the API | -| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | +| `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains` (social fields only), `notEmpty` / `isEmpty` (value-less existence checks). Long-form spellings (`equals`, `greater`, `includes`, …) are deprecated aliases the API still accepts | | `value` | `any` | Value to compare against | | `scope` | `string` | _(token filters only)_ `any` or `protocol` | | `appId` | `string` | _(token filters with `scope: protocol`)_ e.g. `aave-v3` | @@ -194,7 +194,7 @@ formo analytics [options] |---|---| | `--date-from` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--date-to`) | | `--date-to` | Inclusive end date `YYYY-MM-DD` (default: today) | -| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`notIn` with a pipe-delimited value (e.g. `"chrome\|firefox"`) | +| `--filters` | JSON array of `[{field,op,value}]`. Use `in`/`nin` with a pipe-delimited value (e.g. `"chrome\|firefox"`) | | `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) | **Examples:** @@ -212,7 +212,7 @@ formo analytics funnel --date-from 2026-04-01 --date-to 2026-04-30 --params '{"s formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params '{"limit":10}' # Retention filtered to US visitors -formo analytics retention --filters '[{"field":"location","op":"equals","value":"US"}]' +formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"}]' ``` Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `breakdown`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`. @@ -266,7 +266,7 @@ formo alerts create --name "High value tx" --trigger-type event # Create an alert with filters and recipients formo alerts create --name "Whale alert" --trigger-type event \ - --trigger-filters '[{"name":"revenue","operator":"greater_than","value":"100000"}]' \ + --trigger-filters '[{"name":"revenue","operator":"gt","value":"100000"}]' \ --recipient '[{"type":"webhook","value":["https://hooks.example.com/formo"]}]' ``` diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index de5a970..bc5c87f 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -139,7 +139,7 @@ const sharedOptions = z.object({ .optional() .describe( 'JSON array of filter conditions: [{"field","op","value"}]. ' + - 'Use op "in"/"notIn" with a pipe-delimited value (e.g. "chrome|firefox").', + 'Use op "in"/"nin" with a pipe-delimited value (e.g. "chrome|firefox").', ), params: z .string() diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index a2131bd..f59b16c 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -256,8 +256,9 @@ profiles.command('search', { 'Chains: chains.balance or chains.{chain_id}.balance. ' + 'Apps: apps.{app_id}.balance. Tokens: tokens.{address}.balance ' + '(optional "scope":"any"|"protocol" + "appId"). Labels: labels.{tag_id}. ' + - 'op: equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, in, notIn, includes, notEmpty, isEmpty ' + - '(includes = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields).', + 'op: eq, neq, gt, gte, lt, lte, in, nin, contains, notEmpty, isEmpty ' + + '(contains = substring, social fields only; notEmpty/isEmpty = value-less existence checks on string fields). ' + + 'Long-form spellings (equals, notEquals, greater, greaterOrEqual, less, lessOrEqual, notIn, includes) are deprecated aliases the API still accepts.', ), logic: z .enum(['and', 'or']) diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index 5844f0b..f63f2fa 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -21,10 +21,10 @@ describe('commands/analytics', function () { it('re-serializes a valid filters JSON array as a string', function () { const params = buildAnalyticsParams({ - filters: '[{"field":"location","op":"equals","value":"US"}]', + filters: '[{"field":"location","op":"eq","value":"US"}]', }); expect(params.filters).to.equal( - '[{"field":"location","op":"equals","value":"US"}]', + '[{"field":"location","op":"eq","value":"US"}]', ); }); diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index 702a783..2102b95 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -38,10 +38,10 @@ describe('commands / body builders', function () { const body = buildAlertBody({ name: 'x', triggerType: 'event', - triggerFilters: '[{"name":"event","operator":"equals","value":"transaction"}]', + triggerFilters: '[{"name":"event","operator":"eq","value":"transaction"}]', }); expect(body.trigger_filters).to.deep.equal([ - { name: 'event', operator: 'equals', value: 'transaction' }, + { name: 'event', operator: 'eq', value: 'transaction' }, ]); }); @@ -372,7 +372,7 @@ describe('commands / body builders', function () { describe('parseSearchConditions()', function () { it('accepts conditions with typed field prefixes', function () { const conds = parseSearchConditions( - '[{"field":"users.net_worth_usd","op":"greater","value":10000},{"field":"chains.1.balance","op":"greaterOrEqual","value":1000}]', + '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"chains.1.balance","op":"gte","value":1000}]', ); expect(conds).to.have.length(2); expect((conds[0] as { field: string }).field).to.equal('users.net_worth_usd'); @@ -381,20 +381,27 @@ describe('commands / body builders', function () { it('accepts apps., tokens., and labels. prefixes', function () { expect(() => parseSearchConditions( - '[{"field":"apps.uniswap-v3.balance","op":"greater","value":0},{"field":"tokens.0xabc.balance","op":"greater","value":1},{"field":"labels.coinbase.verified_account","op":"equals","value":"true"}]', + '[{"field":"apps.uniswap-v3.balance","op":"gt","value":0},{"field":"tokens.0xabc.balance","op":"gt","value":1},{"field":"labels.coinbase.verified_account","op":"eq","value":"true"}]', ), ).to.not.throw(); }); + it('passes deprecated long-form op aliases through verbatim (server folds them to canonical)', function () { + const conds = parseSearchConditions( + '[{"field":"users.net_worth_usd","op":"greater","value":10000}]', + ); + expect((conds[0] as { op: string }).op).to.equal('greater'); + }); + it('rejects a bare (untyped) field — the silent-failure footgun', function () { expect(() => - parseSearchConditions('[{"field":"net_worth_usd","op":"greater","value":10000}]'), + parseSearchConditions('[{"field":"net_worth_usd","op":"gt","value":10000}]'), ).to.throw(/must be a typed path/); }); it('rejects a known field name without its prefix', function () { expect(() => - parseSearchConditions('[{"field":"tx_count","op":"greater","value":5}]'), + parseSearchConditions('[{"field":"tx_count","op":"gt","value":5}]'), ).to.throw(/must be a typed path/); }); @@ -411,7 +418,7 @@ describe('commands / body builders', function () { }); it('throws when an entry is missing a string field', function () { - expect(() => parseSearchConditions('[{"op":"greater","value":1}]')).to.throw( + expect(() => parseSearchConditions('[{"op":"gt","value":1}]')).to.throw( /must have a non-empty string "field"/, ); }); From dea3e43cec99a3bd4fed7da5917f344e5fa03218 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Fri, 24 Jul 2026 16:39:19 +0700 Subject: [PATCH 3/3] =?UTF-8?q?Dashboard=20nav=20is=20Settings=20=E2=86=92?= =?UTF-8?q?=20API=20(renamed=20from=20API=20Keys)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3a8a9cb..970902c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Or set the `FORMO_API_KEY` environment variable — it takes precedence over the export FORMO_API_KEY=formo_abc123 ``` -Get your API key from `Settings → API Keys` in the [Formo dashboard](https://app.formo.so). +Get your API key from `Settings → API` in the [Formo dashboard](https://app.formo.so). For local development or proxying, override API hosts with: diff --git a/src/index.ts b/src/index.ts index e78c625..0bb7a1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ function loginGuide(): string { color.boldGreen("How to get your API key:"), "", ` ${color.white("1.")} Go to ${color.cyan(DASHBOARD_URL)}`, - ` ${color.white("2.")} Navigate to ${color.bold("Settings")} → ${color.bold("API Keys")}`, + ` ${color.white("2.")} Navigate to ${color.bold("Settings")} → ${color.bold("API")}`, ` ${color.white("3.")} Click ${color.bold('"Create API Key"')} and copy the key`, ` ${color.white("4.")} Run:`, "",