From 43cf1080bebdc0af3cb645e063fca405570668fb Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Mon, 18 May 2026 15:28:18 +0700 Subject: [PATCH 1/5] Update CLI to latest API --- README.md | 50 ++++++++- SKILLS.md | 66 +++++++++++- src/commands/analytics.ts | 182 ++++++++++++++++++++++++++++++++ src/commands/profiles.ts | 30 +++++- src/index.ts | 5 + test/commands/analytics.test.ts | 142 +++++++++++++++++++++++++ 6 files changed, 460 insertions(+), 15 deletions(-) create mode 100644 src/commands/analytics.ts create mode 100644 test/commands/analytics.test.ts diff --git a/README.md b/README.md index 2a5f9ca..10d8617 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat formo profiles search --size 10 formo profiles search --orderBy net_worth_usd --orderDir desc --size 5 formo profiles search --page 2 --size 20 -formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --size 20 -formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]' --logic or --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
` @@ -313,6 +314,31 @@ formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net --- +## `formo analytics` + +Pre-built analytics pipes — the same data that powers the Formo dashboard — without writing SQL. Each pipe is a subcommand: `formo analytics `. + +**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` + +| Option | Description | +|---|---| +| `--dateFrom` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--dateTo`) | +| `--dateTo` | 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"`) | +| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) | + +```bash +formo analytics kpis +formo analytics kpis --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"group_by":"device"}' +formo analytics funnel --dateFrom 2026-04-01 --dateTo 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 --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"limit":10}' +formo analytics retention --filters '[{"field":"location","op":"equals","value":"US"}]' +``` + +> Requires `query:read` scope. Run `formo analytics --help` for the pipe-specific params accepted via `--params`. + +--- + ## `formo import` ### `import wallets` @@ -336,16 +362,30 @@ formo import wallets --addresses '["0xabc...","0xdef..."]' --writeKey write_key_ ```json [ - { "field": "net_worth_usd", "op": "gt", "value": 10000 }, - { "field": "tx_count", "op": "gte", "value": 5 } + { "field": "users.net_worth_usd", "op": "gt", "value": 10000 }, + { "field": "chains.1.balance", "op": "gte", "value": 1000 } ] ``` +> **The `field` must be a typed path.** A bare name like `net_worth_usd` is +> silently ignored by the API (no error, no filtering — the search returns +> everything). Always prefix the field with its type. + | Field | Type | Description | |---|---|---| -| `field` | `string` | Profile field to filter on | +| `field` | `string` | Typed path (see prefixes below) | | `op` | `string` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | | `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` | + +| Prefix | Examples | +|---|---| +| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` | +| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) | +| `apps.` | `apps.uniswap-v3.balance` | +| `tokens.` | `tokens.0xA0b8…48.balance` | +| `labels.` | `labels.coinbase.verified_account` | Combine multiple conditions with `--logic and` (default) or `--logic or`. diff --git a/SKILLS.md b/SKILLS.md index 707cf67..8a827ef 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -83,24 +83,39 @@ formo profiles search --limit 10 formo profiles search --orderBy net_worth_usd --orderDir desc --limit 5 # Profiles with net worth over $10k -formo profiles search --conditions '[{"field":"net_worth_usd","op":"gt","value":10000}]' --limit 20 +formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --limit 20 -# High-activity wallets +# Profiles with > $1k balance on Ethereum (chain 1) +formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --limit 20 + +# High-activity wallets, sorted by tx count formo profiles search --orderBy tx_count --orderDir desc --limit 10 --expand labels ``` **FilterCondition schema:** ```json -{ "field": "net_worth_usd", "op": "gt", "value": 10000 } +{ "field": "users.net_worth_usd", "op": "gt", "value": 10000 } ``` | Property | Type | Description | |---|---|---| -| `field` | `string` | Profile field to filter on | +| `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` | | `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` | + +**Field path prefixes:** + +| Prefix | Examples | +|---|---| +| `users.` | `users.net_worth_usd`, `users.volume`, `users.revenue`, `users.points`, `users.device`, `users.location`, `users.lifecycle`, `users.ens`, `users.farcaster` | +| `chains.` | `chains.balance` (any chain), `chains.1.balance` (Ethereum) | +| `apps.` | `apps.uniswap-v3.balance` | +| `tokens.` | `tokens.0xA0b8…48.balance` | +| `labels.` | `labels.coinbase.verified_account` | -Multiple conditions are combined with `AND` logic. +Combine multiple conditions with `--logic and` (default) or `--logic or`. --- @@ -130,6 +145,47 @@ formo query "SELECT address, last_seen FROM wallet_profiles ORDER BY last_seen D --- +## Pre-built Analytics + +Pre-computed analytics pipes — the same data that powers the Formo dashboard — without writing SQL. + +```bash +formo analytics [options] +``` + +> Requires `query:read` scope on your API key. + +**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` + +| Option | Description | +|---|---| +| `--dateFrom` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--dateTo`) | +| `--dateTo` | 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"`) | +| `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) | + +**Examples:** +```bash +# Traffic KPIs for the last 7 days (default range) +formo analytics kpis + +# KPIs for April 2026, broken down by device +formo analytics kpis --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"group_by":"device"}' + +# Conversion funnel across ordered steps (each step: {type,event,name,filters?}) +formo analytics funnel --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]}],"window_seconds":86400}' + +# Top 10 wallets by activity last month +formo analytics top_wallets --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"limit":10}' + +# Retention filtered to US visitors +formo analytics retention --filters '[{"field":"location","op":"equals","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`. + +--- + ## Project Alerts Manage project alerts that trigger notifications when conditions are met (e.g. high-value transaction events, metric thresholds). Alerts are delivered via configured recipients such as webhooks. diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts new file mode 100644 index 0000000..774b252 --- /dev/null +++ b/src/commands/analytics.ts @@ -0,0 +1,182 @@ +import { Cli, z } from 'incur' +import { createClient, requireApiKey } from '../lib/client' + +export const analytics = Cli.create('analytics', { + description: + 'Pre-built analytics query commands — KPIs, funnels, retention, revenue, and top-N breakdowns', +}) + +// The pre-built analytics pipes exposed at GET /v0/. Each requires the +// query:read scope. Common params (date_from, date_to, filters) are shared; +// pipe-specific params (e.g. funnel `steps`, kpis `group_by`, `limit`) are +// passed through the generic --params JSON object. +const PIPES: Array<{ name: string; description: string }> = [ + { name: 'kpis', description: 'Traffic KPIs: visitors, pageviews, bounce rate, session duration' }, + { name: 'event_timeseries', description: 'Event counts over time' }, + { name: 'funnel', description: 'Conversion funnel across ordered steps. --params: steps (JSON array of {type,event,name,filters?}), window_seconds, funnel_type, breakdown' }, + { name: 'flow', description: 'User path/flow analysis. --params: start_step / end_step (JSON {type,event,...}), global_filters, window_seconds, max_steps' }, + { name: 'frequency', description: 'Engagement frequency distribution' }, + { name: 'lifecycle', description: 'User lifecycle stages (new, returning, power, resurrected, churned)' }, + { name: 'retention', description: 'Retention cohort analysis (params: id_type, event_type, event_name, min_users)' }, + { name: 'revenue_overview', description: 'Revenue overview with optional breakdown (params: group_by, rank_by)' }, + { name: 'revenue_by_metric', description: 'Revenue ranked by a metric column (params: metric_column, limit, offset)' }, + { name: 'revenue_timeseries', description: 'Revenue over time (params: address)' }, + { name: 'volume_by_metric', description: 'Trading volume ranked by a metric column (params: metric_column, limit, offset)' }, + { name: 'top_chains', description: 'Top chains by activity (params: limit, offset)' }, + { name: 'top_events', description: 'Top events by count (params: limit, offset, type)' }, + { name: 'top_locations', description: 'Top locations (params: limit, offset)' }, + { name: 'top_pages', description: 'Top pages by traffic (params: limit, offset, mode)' }, + { name: 'top_sources', description: 'Top acquisition sources (params: metric_column, limit, offset)' }, + { name: 'top_wallets', description: 'Top wallets by activity (params: limit, offset)' }, +] + +export interface AnalyticsOptions { + dateFrom?: string + dateTo?: string + filters?: string + params?: string +} + +// `funnel` and `flow` take camelCase `dateFrom`/`dateTo` query params; every +// other pipe takes snake_case `date_from`/`date_to`. Sending the wrong casing +// makes the API's strict validator reject the request as +// "Invalid query parameters". +const CAMEL_DATE_PIPES = new Set(['funnel', 'flow']) + +// Keys --params is not allowed to set: they have dedicated, validated flags +// (--dateFrom/--dateTo/--filters). Rejecting them prevents --params from +// silently overriding validated input or pushing an invalid `filters` value +// (e.g. a non-JSON string) over the wire. +const RESERVED_PARAM_KEYS = new Set([ + 'date_from', + 'date_to', + 'dateFrom', + 'dateTo', + 'filters', +]) + +/** + * Build the query-string params for an analytics pipe request. + * + * - `dateFrom`/`dateTo` map to `date_from`/`date_to`, except for `funnel` and + * `flow` which use camelCase `dateFrom`/`dateTo` (see CAMEL_DATE_PIPES). + * - `filters` is a JSON array of `{ field, op, value }` objects, re-serialized + * as a JSON string (the pipe expects a JSON-encoded array in the query). + * - `params` is a JSON object of any pipe-specific params (e.g. funnel + * `steps`, kpis `group_by`, `limit`). Object/array values are JSON-encoded + * (pipes like funnel expect `steps` as a JSON-encoded string); primitives + * pass through unchanged. Reserved keys (the date/filters flags) are + * rejected, and the validated flags below always take precedence. + * + * Exported for unit testing. + */ +export function buildAnalyticsParams( + pipe: string, + options: AnalyticsOptions, +): Record { + const out: Record = {} + const camelDates = CAMEL_DATE_PIPES.has(pipe) + const fromKey = camelDates ? 'dateFrom' : 'date_from' + const toKey = camelDates ? 'dateTo' : 'date_to' + + // --params first, so the validated flags below override it. + if (options.params) { + let parsed: unknown + try { + parsed = JSON.parse(options.params) + } catch { + throw new Error('--params must be a valid JSON object') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('--params must be a valid JSON object') + } + for (const [key, value] of Object.entries(parsed as Record)) { + if (RESERVED_PARAM_KEYS.has(key)) { + throw new Error( + `--params may not set "${key}" — use the --dateFrom/--dateTo/--filters flags instead`, + ) + } + if (value === null || value === undefined) continue + if (typeof value === 'object') { + out[key] = JSON.stringify(value) + } else { + out[key] = value as string | number | boolean + } + } + } + + if (options.dateFrom) out[fromKey] = options.dateFrom + if (options.dateTo) out[toKey] = options.dateTo + + if (options.filters) { + let parsed: unknown + try { + parsed = JSON.parse(options.filters) + } catch { + throw new Error( + '--filters must be a valid JSON array of {field,op,value} objects', + ) + } + if (!Array.isArray(parsed)) { + throw new Error( + '--filters must be a valid JSON array of {field,op,value} objects', + ) + } + out.filters = JSON.stringify(parsed) + } + + return out +} + +export function runAnalytics(pipe: string, options: AnalyticsOptions) { + requireApiKey() + const client = createClient() + return client.get(`/v0/${pipe}`, { params: buildAnalyticsParams(pipe, options) }) +} + +const sharedOptions = z.object({ + dateFrom: z + .string() + .optional() + .describe('Inclusive start date YYYY-MM-DD (default: 7 days before --dateTo)'), + dateTo: z + .string() + .optional() + .describe('Inclusive end date YYYY-MM-DD (default: today)'), + filters: z + .string() + .optional() + .describe( + 'JSON array of filter conditions: [{"field","op","value"}]. ' + + 'Use op "in"/"notIn" with a pipe-delimited value (e.g. "chrome|firefox").', + ), + params: z + .string() + .optional() + .describe( + 'JSON object of pipe-specific params merged into the query, e.g. ' + + '{"limit":10,"group_by":"device"} or funnel ' + + '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}. ' + + 'May not set dateFrom/dateTo/filters (use the dedicated flags).', + ), +}) + +for (const pipe of PIPES) { + analytics.command(pipe.name, { + description: pipe.description, + options: sharedOptions, + examples: [ + { + description: `Get ${pipe.name} for the last 7 days (default range)`, + }, + { + options: { dateFrom: '2026-04-01', dateTo: '2026-04-30' }, + description: `Get ${pipe.name} for April 2026`, + }, + ], + hint: 'Requires query:read scope on your API key. Pass pipe-specific params via --params.', + run({ options }) { + return runAnalytics(pipe.name, options) + }, + }) +} diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index e2d4e4b..79c3f7a 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -32,6 +32,7 @@ profiles.command('get', { description: 'Get profile with expanded labels and chains', }, ], + hint: 'Requires profiles:read scope on your API key.', run({ args, options }) { return getProfileRun(args.address, options.expand) }, @@ -101,7 +102,17 @@ profiles.command('search', { conditions: z .string() .optional() - .describe('JSON array of FilterCondition objects for advanced filtering'), + .describe( + 'JSON array of FilterCondition objects: [{"field","op","value"}]. ' + + 'The "field" MUST be a typed path — a bare name like "net_worth_usd" is silently ignored. ' + + 'Profile: users.net_worth_usd, users.volume, users.revenue, users.points. ' + + 'Engagement: users.device, users.browser, users.os, users.location, users.lifecycle. ' + + 'Socials: users.ens, users.farcaster, users.lens, etc. ' + + '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.', + ), logic: z .enum(['and', 'or']) .optional() @@ -119,20 +130,29 @@ profiles.command('search', { }, { options: { - conditions: '[{"field":"net_worth_usd","op":"gt","value":10000}]', + conditions: '[{"field":"users.net_worth_usd","op":"gt","value":10000}]', size: 20, }, - description: 'Search profiles with net worth > 10000', + description: 'Search profiles with net worth > $10k', }, { options: { - conditions: '[{"field":"net_worth_usd","op":"gt","value":10000},{"field":"tx_count","op":"gt","value":50}]', + conditions: + '[{"field":"users.net_worth_usd","op":"gt","value":10000},{"field":"users.volume","op":"gt","value":1000}]', logic: 'or', size: 20, }, - description: 'Search profiles matching either condition', + description: 'Search profiles matching either condition (net worth or volume)', + }, + { + options: { + conditions: '[{"field":"chains.1.balance","op":"gt","value":1000}]', + size: 20, + }, + description: 'Search profiles with > $1k balance on Ethereum (chain 1)', }, ], + 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.', run({ args: _args, options }) { return searchProfilesRun(options) }, diff --git a/src/index.ts b/src/index.ts index f98ca4f..8ed94f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Cli, z } from "incur"; import { alerts } from "./commands/alerts"; +import { analytics } from "./commands/analytics"; import { boards } from "./commands/boards"; import { charts } from "./commands/charts"; import { contracts } from "./commands/contracts"; @@ -76,6 +77,9 @@ const cli = Cli.create("formo", { "get the profile for wallet 0xabc", "search profiles with net worth > 10000", "run a SQL query on my analytics data", + "show traffic KPIs for the last 7 days", + "get the conversion funnel for the last month", + "list the top wallets by activity", "search profiles ordered by last_onchain desc", "list all project alerts", "create an alert for high-value transactions", @@ -284,6 +288,7 @@ cli.command("status", { cli.command(profiles); cli.command(query); +cli.command(analytics); cli.command(alerts); cli.command(boards); cli.command(charts); diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts new file mode 100644 index 0000000..e14ab7d --- /dev/null +++ b/test/commands/analytics.test.ts @@ -0,0 +1,142 @@ +import { expect } from 'chai'; +import { buildAnalyticsParams, runAnalytics } from '../../src/commands/analytics'; +import { requiresLiveApi } from '../helpers/liveApi'; + +describe('commands/analytics', function () { + describe('buildAnalyticsParams()', function () { + it('returns an empty object when no options are given', function () { + expect(buildAnalyticsParams('kpis', {})).to.deep.equal({}); + }); + + it('maps dateFrom/dateTo to snake_case date_from/date_to for normal pipes', function () { + const params = buildAnalyticsParams('kpis', { + dateFrom: '2026-04-01', + dateTo: '2026-04-30', + }); + expect(params).to.deep.equal({ + date_from: '2026-04-01', + date_to: '2026-04-30', + }); + }); + + it('uses camelCase dateFrom/dateTo for funnel and flow', function () { + for (const pipe of ['funnel', 'flow']) { + const params = buildAnalyticsParams(pipe, { + dateFrom: '2026-04-01', + dateTo: '2026-04-30', + }); + expect(params).to.deep.equal({ + dateFrom: '2026-04-01', + dateTo: '2026-04-30', + }); + } + }); + + it('re-serializes a valid filters JSON array as a string', function () { + const params = buildAnalyticsParams('kpis', { + filters: '[{"field":"location","op":"equals","value":"US"}]', + }); + expect(params.filters).to.equal( + '[{"field":"location","op":"equals","value":"US"}]', + ); + }); + + it('throws when filters is not valid JSON', function () { + expect(() => + buildAnalyticsParams('kpis', { filters: 'not json' }), + ).to.throw(/--filters must be a valid JSON array/); + }); + + it('throws when filters is valid JSON but not an array', function () { + expect(() => + buildAnalyticsParams('kpis', { filters: '{"field":"x"}' }), + ).to.throw(/--filters must be a valid JSON array/); + }); + + it('merges primitive params through unchanged', function () { + const params = buildAnalyticsParams('kpis', { + params: '{"limit":10,"group_by":"device"}', + }); + expect(params).to.deep.equal({ limit: 10, group_by: 'device' }); + }); + + it('JSON-encodes object/array param values (e.g. funnel steps)', function () { + const params = buildAnalyticsParams('funnel', { + params: + '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}', + }); + expect(params.steps).to.equal( + '[{"type":"event","event":"page","name":"page::0","filters":[]}]', + ); + }); + + it('skips null/undefined param values', function () { + const params = buildAnalyticsParams('kpis', { params: '{"limit":null}' }); + expect(params).to.not.have.property('limit'); + }); + + it('throws when params is not a JSON object', function () { + expect(() => + buildAnalyticsParams('kpis', { params: '[1,2,3]' }), + ).to.throw(/--params must be a valid JSON object/); + expect(() => buildAnalyticsParams('kpis', { params: 'nope' })).to.throw( + /--params must be a valid JSON object/, + ); + }); + + it('rejects reserved keys in --params (no validation bypass)', function () { + for (const key of ['date_from', 'date_to', 'dateFrom', 'dateTo', 'filters']) { + expect(() => + buildAnalyticsParams('kpis', { + params: JSON.stringify({ [key]: 'x' }), + }), + ).to.throw(new RegExp(`--params may not set "${key}"`)); + } + }); + + it('lets the validated flags take precedence over --params', function () { + // params is applied first; the dedicated flags override afterwards. + const params = buildAnalyticsParams('kpis', { + dateFrom: '2026-04-01', + params: '{"group_by":"device"}', + }); + expect(params).to.deep.equal({ + group_by: 'device', + date_from: '2026-04-01', + }); + }); + }); + + describe('runAnalytics()', function () { + it('returns data from the kpis pipe', async function () { + await requiresLiveApi(this); + const result = (await runAnalytics('kpis', { + dateFrom: '2026-01-01', + dateTo: '2026-01-31', + })) as unknown; + expect(result).to.exist; + }); + + it('returns data from the funnel pipe (camelCase dates + JSON steps)', async function () { + await requiresLiveApi(this); + const result = (await runAnalytics('funnel', { + dateFrom: '2026-03-01', + dateTo: '2026-04-30', + params: + '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]},{"type":"track","event":"connect","name":"connect::1","filters":[]}]}', + })) as Record; + expect(result).to.have.property('data'); + }); + + it('returns data from the flow pipe (camelCase dates + JSON start_step)', async function () { + await requiresLiveApi(this); + const result = (await runAnalytics('flow', { + dateFrom: '2026-03-01', + dateTo: '2026-04-30', + params: + '{"start_step":{"type":"event","event":"page","resolved_event":"__ALL_PAGE_VIEWS__","filters":[]}}', + })) as Record; + expect(result).to.have.property('data'); + }); + }); +}); From b2959680ab90570cebaab101673d7453cfc1bbdb Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Mon, 18 May 2026 15:52:05 +0700 Subject: [PATCH 2/5] Unify analytics funnel/flow date params to snake_case The API is being updated so /v0/funnel and /v0/flow accept snake_case date_from/date_to like every other pipe. Drop the CAMEL_DATE_PIPES special-case so the CLI always sends snake_case, and remove the now-unused `pipe` arg from buildAnalyticsParams. Live funnel/flow integration tests are skipped until the API-side fix deploys (production still only accepts camelCase for these two pipes, so a snake_case call returns HTTP 400); the deterministic snake_case unit test keeps the CLI behavior locked meanwhile. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/analytics.ts | 23 ++++-------- test/commands/analytics.test.ts | 63 ++++++++++++++------------------- 2 files changed, 34 insertions(+), 52 deletions(-) diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index 774b252..d07c45c 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -37,16 +37,11 @@ export interface AnalyticsOptions { params?: string } -// `funnel` and `flow` take camelCase `dateFrom`/`dateTo` query params; every -// other pipe takes snake_case `date_from`/`date_to`. Sending the wrong casing -// makes the API's strict validator reject the request as -// "Invalid query parameters". -const CAMEL_DATE_PIPES = new Set(['funnel', 'flow']) - // Keys --params is not allowed to set: they have dedicated, validated flags // (--dateFrom/--dateTo/--filters). Rejecting them prevents --params from // silently overriding validated input or pushing an invalid `filters` value -// (e.g. a non-JSON string) over the wire. +// (e.g. a non-JSON string) over the wire. Both casings of the date keys are +// rejected so a stray camelCase key can't slip through unvalidated. const RESERVED_PARAM_KEYS = new Set([ 'date_from', 'date_to', @@ -58,8 +53,8 @@ const RESERVED_PARAM_KEYS = new Set([ /** * Build the query-string params for an analytics pipe request. * - * - `dateFrom`/`dateTo` map to `date_from`/`date_to`, except for `funnel` and - * `flow` which use camelCase `dateFrom`/`dateTo` (see CAMEL_DATE_PIPES). + * - `dateFrom`/`dateTo` map to the API's snake_case `date_from`/`date_to`. + * All pipes, including `funnel` and `flow`, use snake_case. * - `filters` is a JSON array of `{ field, op, value }` objects, re-serialized * as a JSON string (the pipe expects a JSON-encoded array in the query). * - `params` is a JSON object of any pipe-specific params (e.g. funnel @@ -71,13 +66,9 @@ const RESERVED_PARAM_KEYS = new Set([ * Exported for unit testing. */ export function buildAnalyticsParams( - pipe: string, options: AnalyticsOptions, ): Record { const out: Record = {} - const camelDates = CAMEL_DATE_PIPES.has(pipe) - const fromKey = camelDates ? 'dateFrom' : 'date_from' - const toKey = camelDates ? 'dateTo' : 'date_to' // --params first, so the validated flags below override it. if (options.params) { @@ -105,8 +96,8 @@ export function buildAnalyticsParams( } } - if (options.dateFrom) out[fromKey] = options.dateFrom - if (options.dateTo) out[toKey] = options.dateTo + if (options.dateFrom) out.date_from = options.dateFrom + if (options.dateTo) out.date_to = options.dateTo if (options.filters) { let parsed: unknown @@ -131,7 +122,7 @@ export function buildAnalyticsParams( export function runAnalytics(pipe: string, options: AnalyticsOptions) { requireApiKey() const client = createClient() - return client.get(`/v0/${pipe}`, { params: buildAnalyticsParams(pipe, options) }) + return client.get(`/v0/${pipe}`, { params: buildAnalyticsParams(options) }) } const sharedOptions = z.object({ diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index e14ab7d..5844f0b 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -5,11 +5,11 @@ import { requiresLiveApi } from '../helpers/liveApi'; describe('commands/analytics', function () { describe('buildAnalyticsParams()', function () { it('returns an empty object when no options are given', function () { - expect(buildAnalyticsParams('kpis', {})).to.deep.equal({}); + expect(buildAnalyticsParams({})).to.deep.equal({}); }); - it('maps dateFrom/dateTo to snake_case date_from/date_to for normal pipes', function () { - const params = buildAnalyticsParams('kpis', { + it('maps dateFrom/dateTo to snake_case date_from/date_to', function () { + const params = buildAnalyticsParams({ dateFrom: '2026-04-01', dateTo: '2026-04-30', }); @@ -19,21 +19,8 @@ describe('commands/analytics', function () { }); }); - it('uses camelCase dateFrom/dateTo for funnel and flow', function () { - for (const pipe of ['funnel', 'flow']) { - const params = buildAnalyticsParams(pipe, { - dateFrom: '2026-04-01', - dateTo: '2026-04-30', - }); - expect(params).to.deep.equal({ - dateFrom: '2026-04-01', - dateTo: '2026-04-30', - }); - } - }); - it('re-serializes a valid filters JSON array as a string', function () { - const params = buildAnalyticsParams('kpis', { + const params = buildAnalyticsParams({ filters: '[{"field":"location","op":"equals","value":"US"}]', }); expect(params.filters).to.equal( @@ -42,26 +29,26 @@ describe('commands/analytics', function () { }); it('throws when filters is not valid JSON', function () { - expect(() => - buildAnalyticsParams('kpis', { filters: 'not json' }), - ).to.throw(/--filters must be a valid JSON array/); + expect(() => buildAnalyticsParams({ filters: 'not json' })).to.throw( + /--filters must be a valid JSON array/, + ); }); it('throws when filters is valid JSON but not an array', function () { - expect(() => - buildAnalyticsParams('kpis', { filters: '{"field":"x"}' }), - ).to.throw(/--filters must be a valid JSON array/); + expect(() => buildAnalyticsParams({ filters: '{"field":"x"}' })).to.throw( + /--filters must be a valid JSON array/, + ); }); it('merges primitive params through unchanged', function () { - const params = buildAnalyticsParams('kpis', { + const params = buildAnalyticsParams({ params: '{"limit":10,"group_by":"device"}', }); expect(params).to.deep.equal({ limit: 10, group_by: 'device' }); }); it('JSON-encodes object/array param values (e.g. funnel steps)', function () { - const params = buildAnalyticsParams('funnel', { + const params = buildAnalyticsParams({ params: '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}', }); @@ -71,15 +58,15 @@ describe('commands/analytics', function () { }); it('skips null/undefined param values', function () { - const params = buildAnalyticsParams('kpis', { params: '{"limit":null}' }); + const params = buildAnalyticsParams({ params: '{"limit":null}' }); expect(params).to.not.have.property('limit'); }); it('throws when params is not a JSON object', function () { - expect(() => - buildAnalyticsParams('kpis', { params: '[1,2,3]' }), - ).to.throw(/--params must be a valid JSON object/); - expect(() => buildAnalyticsParams('kpis', { params: 'nope' })).to.throw( + expect(() => buildAnalyticsParams({ params: '[1,2,3]' })).to.throw( + /--params must be a valid JSON object/, + ); + expect(() => buildAnalyticsParams({ params: 'nope' })).to.throw( /--params must be a valid JSON object/, ); }); @@ -87,16 +74,14 @@ describe('commands/analytics', function () { it('rejects reserved keys in --params (no validation bypass)', function () { for (const key of ['date_from', 'date_to', 'dateFrom', 'dateTo', 'filters']) { expect(() => - buildAnalyticsParams('kpis', { - params: JSON.stringify({ [key]: 'x' }), - }), + buildAnalyticsParams({ params: JSON.stringify({ [key]: 'x' }) }), ).to.throw(new RegExp(`--params may not set "${key}"`)); } }); it('lets the validated flags take precedence over --params', function () { // params is applied first; the dedicated flags override afterwards. - const params = buildAnalyticsParams('kpis', { + const params = buildAnalyticsParams({ dateFrom: '2026-04-01', params: '{"group_by":"device"}', }); @@ -117,7 +102,13 @@ describe('commands/analytics', function () { expect(result).to.exist; }); - it('returns data from the funnel pipe (camelCase dates + JSON steps)', async function () { + // SKIPPED until the API-side fix unifying /v0/funnel and /v0/flow to + // snake_case date_from/date_to is deployed. The CLI now sends snake_case + // (see buildAnalyticsParams); production still only accepts camelCase + // dateFrom/dateTo for these two pipes, so a live call returns HTTP 400. + // Re-enable (.skip -> it) once the API change ships. The deterministic + // snake_case unit test above keeps the CLI behavior locked meanwhile. + it.skip('returns data from the funnel pipe (snake_case dates + JSON steps)', async function () { await requiresLiveApi(this); const result = (await runAnalytics('funnel', { dateFrom: '2026-03-01', @@ -128,7 +119,7 @@ describe('commands/analytics', function () { expect(result).to.have.property('data'); }); - it('returns data from the flow pipe (camelCase dates + JSON start_step)', async function () { + it.skip('returns data from the flow pipe (snake_case dates + JSON start_step)', async function () { await requiresLiveApi(this); const result = (await runAnalytics('flow', { dateFrom: '2026-03-01', From 30e964c22b60b1453ad301bab2a8bd841068dc79 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Mon, 18 May 2026 16:35:00 +0700 Subject: [PATCH 3/5] Use kebab-case flags in docs, help text, and error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incur renders flag definitions in kebab-case (--date-from, --order-by, --tag-id, …) and that matches the Node/TS CLI ecosystem standard (oclif/commander/yargs), but the docs and hand-written CLI strings used camelCase, so a user copying from `formo --help`'s flag list got a form the docs didn't show. Normalize every user-facing flag reference to kebab-case across README.md, SKILLS.md, command describe()/hint/error strings, and update the test assertions that matched the old camelCase error text. Both spellings still work at runtime (incur accepts camelCase too); this is consistency only. Known limitation: incur renders in-code `examples:` option keys verbatim (camelCase) because they are type-bound to the zod schema, so the example lines in `--help` remain camelCase. Not fixable without an incur change. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 58 ++++++++++++++--------------- SKILLS.md | 60 +++++++++++++++--------------- src/commands/alerts.ts | 2 +- src/commands/analytics.ts | 8 ++-- src/commands/profiles.ts | 4 +- src/commands/segments.ts | 2 +- test/commands/alerts.test.ts | 4 +- test/commands/bodyBuilders.test.ts | 4 +- test/commands/profiles.test.ts | 8 ++-- test/commands/segments.test.ts | 4 +- 10 files changed, 77 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 10d8617..1d4d904 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,15 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat | `--address` | Filter by wallet address | | `--page` | Page number (1-indexed, default `1`) | | `--size` | Page size (default `100`, max `1000`) | -| `--orderBy` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` | -| `--orderDir` | `asc` or `desc` | +| `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` | +| `--order-dir` | `asc` or `desc` | | `--expand` | Comma-separated fields to expand | | `--conditions` | JSON array of `FilterCondition` objects (see below) | | `--logic` | Combine conditions with `and` (default) or `or` | ```bash formo profiles search --size 10 -formo profiles search --orderBy net_worth_usd --orderDir desc --size 5 +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 @@ -116,18 +116,18 @@ formo profiles update vitalik.eth --properties '{"email":"alice@example.com"}' ### `profiles labels create
` -Upsert one or more labels on a wallet profile. Provide either a single label via `--tagId` or a batch via `--labels`. +Upsert one or more labels on a wallet profile. Provide either a single label via `--tag-id` or a batch via `--labels`. | Option | Description | |---|---| -| `--tagId` | Label identifier (e.g. `vip`, `airdrop_eligible`) | +| `--tag-id` | Label identifier (e.g. `vip`, `airdrop_eligible`) | | `--value` | Optional label value (e.g. tier name, country code) | -| `--chainId` | Optional chain identifier the label applies to | +| `--chain-id` | Optional chain identifier the label applies to | | `--labels` | JSON array of `UserLabelInput` objects for batch upsert | ```bash -formo profiles labels create 0xd8dA... --tagId vip -formo profiles labels create 0xd8dA... --tagId tier --value gold --chainId 1 +formo profiles labels create 0xd8dA... --tag-id vip +formo profiles labels create 0xd8dA... --tag-id tier --value gold --chain-id 1 formo profiles labels create 0xd8dA... --labels '[{"tag_id":"vip"},{"tag_id":"airdrop_eligible","chain_id":"1"}]' ``` @@ -137,12 +137,12 @@ Delete a label from a wallet profile. | Option | Description | |---|---| -| `--tagId` | Label identifier to delete (required) | -| `--chainId` | Optional chain identifier to scope the deletion | +| `--tag-id` | Label identifier to delete (required) | +| `--chain-id` | Optional chain identifier to scope the deletion | ```bash -formo profiles labels delete 0xd8dA... --tagId vip -formo profiles labels delete 0xd8dA... --tagId tier --chainId 1 +formo profiles labels delete 0xd8dA... --tag-id vip +formo profiles labels delete 0xd8dA... --tag-id tier --chain-id 1 ``` > Requires `profiles:write` scope. @@ -164,14 +164,14 @@ Get a single alert by ID. | Option | Description | |---|---| | `--name` | Alert name | -| `--triggerType` | Trigger type (e.g. `event`, `threshold`) | -| `--triggerFilters` | JSON array of trigger filter objects | +| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) | +| `--trigger-filters` | JSON array of trigger filter objects | | `--recipient` | JSON array of recipient objects | | `--secret` | Webhook secret | ```bash -formo alerts create --name "High value tx" --triggerType event \ - --triggerFilters '[{"name":"event","operator":"equals","value":"transaction"}]' \ +formo alerts create --name "High value tx" --trigger-type event \ + --trigger-filters '[{"name":"event","operator":"equals","value":"transaction"}]' \ --recipient '[{"type":"email","value":["alerts@myapp.com"]}]' ``` @@ -227,19 +227,19 @@ Delete a board. Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`. -### `charts list --boardId ` +### `charts list --board-id ` List all charts in a board. -### `charts get --boardId ` +### `charts get --board-id ` Get a single chart by ID. -### `charts create --boardId --body ''` +### `charts create --board-id --body ''` Create a chart from a JSON config string. -### `charts update --boardId --body ''` +### `charts update --board-id --body ''` Update a chart. -### `charts delete --boardId ` +### `charts delete --board-id ` Delete a chart. --- @@ -292,7 +292,7 @@ List all user segments. | Option | Description | |---|---| | `--title` | Segment title | -| `--filterSets` | JSON array of filter set strings defining the segment | +| `--filter-sets` | JSON array of filter set strings defining the segment | ### `segments delete ` Delete a user segment. @@ -322,16 +322,16 @@ Pre-built analytics pipes — the same data that powers the Formo dashboard — | Option | Description | |---|---| -| `--dateFrom` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--dateTo`) | -| `--dateTo` | Inclusive end date `YYYY-MM-DD` (default: today) | +| `--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"`) | | `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) | ```bash formo analytics kpis -formo analytics kpis --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"group_by":"device"}' -formo analytics funnel --dateFrom 2026-04-01 --dateTo 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 --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"limit":10}' +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"}]' ``` @@ -348,10 +348,10 @@ Bulk-import wallet addresses into the project via the events API. | Option | Description | |---|---| | `--addresses` | JSON array of wallet address strings | -| `--writeKey` | Project write SDK key | +| `--write-key` | Project write SDK key | ```bash -formo import wallets --addresses '["0xabc...","0xdef..."]' --writeKey write_key_xyz +formo import wallets --addresses '["0xabc...","0xdef..."]' --write-key write_key_xyz ``` --- diff --git a/SKILLS.md b/SKILLS.md index 8a827ef..bb86c4c 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -67,12 +67,12 @@ formo profiles search [options] | `--address` | `string` | Filter to a specific wallet address | | `--limit` | `number` | Max results (default: API default) | | `--offset` | `number` | Pagination offset | -| `--orderBy` | see below | Field to sort by | -| `--orderDir` | `asc`, `desc` | Sort direction | +| `--order-by` | see below | Field to sort by | +| `--order-dir` | `asc`, `desc` | Sort direction | | `--expand` | `string` | Comma-separated fields to expand | | `--conditions` | JSON array | Advanced filter conditions (see below) | -**`--orderBy` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` +**`--order-by` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` **Examples:** ```bash @@ -80,7 +80,7 @@ formo profiles search [options] formo profiles search --limit 10 # Top 5 by net worth (descending) -formo profiles search --orderBy net_worth_usd --orderDir desc --limit 5 +formo profiles search --order-by net_worth_usd --order-dir desc --limit 5 # Profiles with net worth over $10k formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","value":10000}]' --limit 20 @@ -89,7 +89,7 @@ formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","v formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --limit 20 # High-activity wallets, sorted by tx count -formo profiles search --orderBy tx_count --orderDir desc --limit 10 --expand labels +formo profiles search --order-by tx_count --order-dir desc --limit 10 --expand labels ``` **FilterCondition schema:** @@ -159,8 +159,8 @@ formo analytics [options] | Option | Description | |---|---| -| `--dateFrom` | Inclusive start date `YYYY-MM-DD` (default: 7 days before `--dateTo`) | -| `--dateTo` | Inclusive end date `YYYY-MM-DD` (default: today) | +| `--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"`) | | `--params` | JSON object of pipe-specific params merged into the query (e.g. `{"limit":10,"group_by":"device"}`) | @@ -170,13 +170,13 @@ formo analytics [options] formo analytics kpis # KPIs for April 2026, broken down by device -formo analytics kpis --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"group_by":"device"}' +formo analytics kpis --date-from 2026-04-01 --date-to 2026-04-30 --params '{"group_by":"device"}' # Conversion funnel across ordered steps (each step: {type,event,name,filters?}) -formo analytics funnel --dateFrom 2026-04-01 --dateTo 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 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}' # Top 10 wallets by activity last month -formo analytics top_wallets --dateFrom 2026-04-01 --dateTo 2026-04-30 --params '{"limit":10}' +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"}]' @@ -213,14 +213,14 @@ formo alerts get ### Create an alert ```bash -formo alerts create --name --triggerType [options] +formo alerts create --name --trigger-type [options] ``` | Option | Description | |---|---| | `--name` | Alert name | -| `--triggerType` | Trigger type (e.g. `event`, `threshold`) | -| `--triggerFilters` | JSON array of trigger filter objects (optional) | +| `--trigger-type` | Trigger type (e.g. `event`, `threshold`) | +| `--trigger-filters` | JSON array of trigger filter objects (optional) | | `--recipient` | JSON array of recipient objects (optional) | | `--secret` | Webhook secret string (optional) | @@ -229,18 +229,18 @@ formo alerts create --name --triggerType [options] **Examples:** ```bash # Create a basic event alert -formo alerts create --name "High value tx" --triggerType event +formo alerts create --name "High value tx" --trigger-type event # Create an alert with filters and recipients -formo alerts create --name "Whale alert" --triggerType threshold \ - --triggerFilters '[{"field":"amount","op":"gt","value":100000}]' \ +formo alerts create --name "Whale alert" --trigger-type threshold \ + --trigger-filters '[{"field":"amount","op":"gt","value":100000}]' \ --recipient '["https://hooks.example.com/formo"]' ``` ### Update an alert ```bash -formo alerts update --name --triggerType [options] +formo alerts update --name --trigger-type [options] ``` > Requires `alerts:write` scope. @@ -340,7 +340,7 @@ Manage charts within dashboard boards. Charts are visualizations of analytics da ### List charts in a board ```bash -formo charts list --boardId +formo charts list --board-id ``` > Requires `boards:read` scope. @@ -348,7 +348,7 @@ formo charts list --boardId ### Get a single chart ```bash -formo charts get --boardId +formo charts get --board-id ``` > Requires `boards:read` scope. @@ -356,26 +356,26 @@ formo charts get --boardId ### Create a chart ```bash -formo charts create --boardId --body '' +formo charts create --board-id --body '' ``` | Option | Description | |---|---| -| `--boardId` | Board ID to add the chart to | +| `--board-id` | Board ID to add the chart to | | `--body` | Full chart configuration as a JSON string | > Requires `boards:write` scope. **Examples:** ```bash -formo charts create --boardId board_abc123 \ +formo charts create --board-id board_abc123 \ --body '{"name":"Daily active users","chartType":"line"}' ``` ### Update a chart ```bash -formo charts update --boardId --body '' +formo charts update --board-id --body '' ``` > Requires `boards:write` scope. @@ -383,7 +383,7 @@ formo charts update --boardId --body '' ### Delete a chart ```bash -formo charts delete --boardId +formo charts delete --board-id ``` > Requires `boards:write` scope. @@ -466,19 +466,19 @@ formo segments list ### Create a segment ```bash -formo segments create --title --filterSets '<json>' +formo segments create --title <title> --filter-sets '<json>' ``` | Option | Description | |---|---| | `--title` | Segment title | -| `--filterSets` | JSON array of filter set strings defining the segment | +| `--filter-sets` | JSON array of filter set strings defining the segment | > Requires `segments:write` scope. **Examples:** ```bash -formo segments create --title "Whales" --filterSets '["net_worth_usd > 100000"]' +formo segments create --title "Whales" --filter-sets '["net_worth_usd > 100000"]' ``` ### Delete a segment @@ -496,13 +496,13 @@ formo segments delete <segmentId> Bulk import wallet addresses into a project to track them. This creates identify events for each address. ```bash -formo import wallets --addresses '<json>' --writeKey <writeKey> +formo import wallets --addresses '<json>' --write-key <writeKey> ``` | Option | Description | |---|---| | `--addresses` | JSON array of wallet address strings to import | -| `--writeKey` | Project write SDK key | +| `--write-key` | Project write SDK key | > Requires `profiles:write` scope. **Only available on Scale and Enterprise plans.** @@ -510,7 +510,7 @@ formo import wallets --addresses '<json>' --writeKey <writeKey> ```bash formo import wallets \ --addresses '["0xabc123…","0xdef456…"]' \ - --writeKey write_key_xxx + --write-key write_key_xxx ``` --- diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index 6742ec0..bde37cc 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -64,7 +64,7 @@ export function buildAlertBody(options: CreateAlertOptions | UpdateAlertOptions) try { body.trigger_filters = JSON.parse(options.triggerFilters) } catch { - throw new Error('--triggerFilters must be a valid JSON array') + throw new Error('--trigger-filters must be a valid JSON array') } } diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index d07c45c..6be9534 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -38,7 +38,7 @@ export interface AnalyticsOptions { } // Keys --params is not allowed to set: they have dedicated, validated flags -// (--dateFrom/--dateTo/--filters). Rejecting them prevents --params from +// (--date-from/--date-to/--filters). Rejecting them prevents --params from // silently overriding validated input or pushing an invalid `filters` value // (e.g. a non-JSON string) over the wire. Both casings of the date keys are // rejected so a stray camelCase key can't slip through unvalidated. @@ -84,7 +84,7 @@ export function buildAnalyticsParams( for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { if (RESERVED_PARAM_KEYS.has(key)) { throw new Error( - `--params may not set "${key}" — use the --dateFrom/--dateTo/--filters flags instead`, + `--params may not set "${key}" — use the --date-from/--date-to/--filters flags instead`, ) } if (value === null || value === undefined) continue @@ -129,7 +129,7 @@ const sharedOptions = z.object({ dateFrom: z .string() .optional() - .describe('Inclusive start date YYYY-MM-DD (default: 7 days before --dateTo)'), + .describe('Inclusive start date YYYY-MM-DD (default: 7 days before --date-to)'), dateTo: z .string() .optional() @@ -148,7 +148,7 @@ const sharedOptions = z.object({ 'JSON object of pipe-specific params merged into the query, e.g. ' + '{"limit":10,"group_by":"device"} or funnel ' + '{"steps":[{"type":"event","event":"page","name":"page::0","filters":[]}]}. ' + - 'May not set dateFrom/dateTo/filters (use the dedicated flags).', + 'May not set date_from/date_to/filters; use the dedicated --date-from/--date-to/--filters flags.', ), }) diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 79c3f7a..c75c48a 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -255,7 +255,7 @@ export function buildCreateLabelBody(options: CreateProfileLabelOptions): unknow if (options.chainId) single.chain_id = options.chainId return single } - throw new Error('Provide --tagId (single label) or --labels (batch JSON array)') + throw new Error('Provide --tag-id (single label) or --labels (batch JSON array)') } export function createProfileLabelRun( @@ -319,7 +319,7 @@ export interface DeleteProfileLabelOptions { export function buildDeleteLabelBody(options: DeleteProfileLabelOptions) { if (!options.tagId) { - throw new Error('--tagId is required') + throw new Error('--tag-id is required') } const body: Record<string, string> = { tag_id: options.tagId } if (options.chainId) body.chain_id = options.chainId diff --git a/src/commands/segments.ts b/src/commands/segments.ts index 3c4672f..27fa51a 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -34,7 +34,7 @@ export function buildCreateSegmentBody(options: CreateSegmentOptions) { try { parsedFilterSets = JSON.parse(options.filterSets) } catch { - throw new Error('--filterSets must be a valid JSON array') + throw new Error('--filter-sets must be a valid JSON array') } return { diff --git a/test/commands/alerts.test.ts b/test/commands/alerts.test.ts index aff5c85..69193eb 100644 --- a/test/commands/alerts.test.ts +++ b/test/commands/alerts.test.ts @@ -28,8 +28,8 @@ describe('commands/alerts', function () { }); describe('createAlertRun() — local validation', function () { - it('throws on invalid triggerFilters JSON', function () { - expect(() => createAlertRun({ name: 'x', triggerType: 'event', triggerFilters: 'not-json' })).to.throw(/triggerFilters/); + it('throws on invalid --trigger-filters JSON', function () { + expect(() => createAlertRun({ name: 'x', triggerType: 'event', triggerFilters: 'not-json' })).to.throw(/trigger-filters/); }); it('throws on invalid recipient JSON', function () { diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index 6ed187d..f61d1ab 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -145,7 +145,7 @@ describe('commands / body builders', function () { // ── Profiles labels create ── describe('buildCreateLabelBody()', function () { - it('produces a single-label object body when --tagId is given', function () { + it('produces a single-label object body when --tag-id is given', function () { const body = buildCreateLabelBody({ tagId: 'vip' }); expect(body).to.deep.equal({ tag_id: 'vip' }); }); @@ -173,7 +173,7 @@ describe('commands / body builders', function () { ]); }); - it('--labels takes precedence over --tagId when both are provided', function () { + it('--labels takes precedence over --tag-id when both are provided', function () { const body = buildCreateLabelBody({ tagId: 'should-be-ignored', labels: '[{"tag_id":"vip"}]', diff --git a/test/commands/profiles.test.ts b/test/commands/profiles.test.ts index fcf5710..04e27a5 100644 --- a/test/commands/profiles.test.ts +++ b/test/commands/profiles.test.ts @@ -78,8 +78,8 @@ describe('commands/profiles', function () { }); describe('createProfileLabelRun() — local validation', function () { - it('throws when neither --tagId nor --labels is provided', function () { - expect(() => createProfileLabelRun(KNOWN_ADDRESS, {})).to.throw(/tagId|labels/); + it('throws when neither --tag-id nor --labels is provided', function () { + expect(() => createProfileLabelRun(KNOWN_ADDRESS, {})).to.throw(/tag-id|labels/); }); it('throws on invalid labels JSON', function () { @@ -96,10 +96,10 @@ describe('commands/profiles', function () { }); describe('deleteProfileLabelRun() — local validation', function () { - it('throws when --tagId is missing', function () { + it('throws when --tag-id is missing', function () { expect(() => deleteProfileLabelRun(KNOWN_ADDRESS, { tagId: '' }), - ).to.throw(/tagId/); + ).to.throw(/tag-id/); }); }); }); diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index 2f4ae62..6013a2e 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -16,8 +16,8 @@ describe('commands/segments', function () { }); describe('createSegmentRun() — local validation', function () { - it('throws on invalid filterSets JSON', function () { - expect(() => createSegmentRun({ title: 'x', filterSets: 'not-json' })).to.throw(/filterSets/); + it('throws on invalid --filter-sets JSON', function () { + expect(() => createSegmentRun({ title: 'x', filterSets: 'not-json' })).to.throw(/filter-sets/); }); }); }); From 46b6111d41a9c899a707264c1ccc67308cf02ff2 Mon Sep 17 00:00:00 2001 From: Yos Riady <yos@formo.so> Date: Mon, 18 May 2026 16:53:08 +0700 Subject: [PATCH 4/5] Patch incur for kebab examples; enforce typed --conditions; fix SKILLS pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Patch incur (pnpm patchedDependencies, patches/incur.patch) so formatExamples() kebab-cases option keys like the flag-definition renderer already does — `--help` example lines now match the flag list (--order-by, --date-from, …) and so do generated skills/MCP. - profiles search: validate --conditions field paths client-side. A bare/untyped field (e.g. "net_worth_usd") is silently ignored by the API and returns the entire unfiltered dataset; now it fails fast with an actionable error. Added parseSearchConditions() + tests. - SKILLS.md: profiles search documented --limit/--offset but the CLI exposes --page/--size; corrected table and examples (matches README). Addresses Codex review P1 (unenforced typed-path requirement) and P2 (SKILLS.md pagination drift). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- SKILLS.md | 16 ++++---- patches/incur.patch | 13 +++++++ pnpm-lock.yaml | 7 +++- pnpm-workspace.yaml | 3 ++ src/commands/profiles.ts | 61 +++++++++++++++++++++++++++--- test/commands/bodyBuilders.test.ts | 51 +++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 patches/incur.patch diff --git a/SKILLS.md b/SKILLS.md index bb86c4c..f1c3c56 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -65,8 +65,8 @@ formo profiles search [options] | Option | Values | Description | |---|---|---| | `--address` | `string` | Filter to a specific wallet address | -| `--limit` | `number` | Max results (default: API default) | -| `--offset` | `number` | Pagination offset | +| `--page` | `number` | Page number (1-indexed, default `1`) | +| `--size` | `number` | Page size (default `100`, max `1000`) | | `--order-by` | see below | Field to sort by | | `--order-dir` | `asc`, `desc` | Sort direction | | `--expand` | `string` | Comma-separated fields to expand | @@ -77,19 +77,19 @@ formo profiles search [options] **Examples:** ```bash # First 10 profiles -formo profiles search --limit 10 +formo profiles search --size 10 # Top 5 by net worth (descending) -formo profiles search --order-by net_worth_usd --order-dir desc --limit 5 +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}]' --limit 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":"gt","value":1000}]' --limit 20 +formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20 -# High-activity wallets, sorted by tx count -formo profiles search --order-by tx_count --order-dir desc --limit 10 --expand labels +# Second page of 20, sorted by tx count +formo profiles search --order-by tx_count --order-dir desc --page 2 --size 20 --expand labels ``` **FilterCondition schema:** diff --git a/patches/incur.patch b/patches/incur.patch new file mode 100644 index 0000000..add3548 --- /dev/null +++ b/patches/incur.patch @@ -0,0 +1,13 @@ +diff --git a/dist/Cli.js b/dist/Cli.js +index 1467ccf7a6eb6c2bf1e7a5172631032ea3c342e7..1286e927e8b3449fec56601fef633eef68391b4e 100644 +--- a/dist/Cli.js ++++ b/dist/Cli.js +@@ -2113,7 +2113,7 @@ export function formatExamples(examples) { + parts.push(String(value)); + if (ex.options) + for (const [key, value] of Object.entries(ex.options)) +- parts.push(`--${key} ${value}`); ++ parts.push(`--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${value}`); + const result = { command: parts.join(' ') }; + if (ex.description) + result.description = ex.description; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e294013..169e492 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,9 @@ settings: overrides: serialize-javascript: '>=7.0.5' +patchedDependencies: + incur: 61a525bed57fffbfcf01a35647a140e86da48ff828c37fd5088e80fe1cd3fef2 + importers: .: @@ -16,7 +19,7 @@ importers: version: 1.16.1 incur: specifier: ^0.3.4 - version: 0.3.25 + version: 0.3.25(patch_hash=61a525bed57fffbfcf01a35647a140e86da48ff828c37fd5088e80fe1cd3fef2) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -1720,7 +1723,7 @@ snapshots: imurmurhash@0.1.4: {} - incur@0.3.25: + incur@0.3.25(patch_hash=61a525bed57fffbfcf01a35647a140e86da48ff828c37fd5088e80fe1cd3fef2): dependencies: '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8894240..c7d9a6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,6 @@ overrides: allowBuilds: esbuild: true + +patchedDependencies: + incur: patches/incur.patch diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index c75c48a..f69b648 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -49,6 +49,58 @@ export interface SearchProfilesOptions { logic?: 'and' | 'or' } +// Accepted first segments for a FilterCondition `field`, mirroring the API's +// parseField(). A field whose prefix is not one of these is silently ignored +// server-side (no error, no filtering — the search returns everything), so we +// reject it client-side with an actionable message instead. +const CONDITION_FIELD_PREFIXES = new Set([ + 'user', + 'users', + 'chain', + 'chains', + 'app', + 'apps', + 'token', + 'tokens', + 'label', + 'labels', +]) + +/** + * Parse and validate the --conditions JSON. Ensures it is an array of + * `{ field, op, value }` objects whose `field` is a typed path (e.g. + * `users.net_worth_usd`) — a bare name like `net_worth_usd` is silently + * dropped by the API, so it is rejected here. Exported for unit testing. + */ +export function parseSearchConditions(raw: string): unknown[] { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('--conditions must be a valid JSON array of FilterCondition objects') + } + if (!Array.isArray(parsed)) { + throw new Error('--conditions must be a valid JSON array of FilterCondition objects') + } + for (const cond of parsed) { + if (!cond || typeof cond !== 'object' || Array.isArray(cond)) { + throw new Error('--conditions: each entry must be an object with field, op, value') + } + const field = (cond as { field?: unknown }).field + if (typeof field !== 'string' || field.length === 0) { + throw new Error('--conditions: each entry must have a non-empty string "field"') + } + if (!field.includes('.') || !CONDITION_FIELD_PREFIXES.has(field.split('.')[0])) { + throw new Error( + `--conditions: field "${field}" must be a typed path — prefix it with ` + + 'users., chains., apps., tokens., or labels. ' + + '(a bare name is silently ignored by the API and returns the entire unfiltered dataset)', + ) + } + } + return parsed +} + export function searchProfilesRun(options: SearchProfilesOptions) { requireApiKey() const client = createClient() @@ -63,12 +115,9 @@ export function searchProfilesRun(options: SearchProfilesOptions) { let body: object | undefined if (options.conditions) { - try { - const conditions = JSON.parse(options.conditions) - if (!Array.isArray(conditions)) throw new Error('not an array') - body = { conditions, logic: options.logic ?? 'and' } - } catch { - throw new Error('--conditions must be valid JSON array of FilterCondition objects') + body = { + conditions: parseSearchConditions(options.conditions), + logic: options.logic ?? 'and', } } diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index f61d1ab..2e71fa6 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -9,6 +9,7 @@ import { buildCreateLabelBody, buildDeleteLabelBody, buildUpdateProfileBody, + parseSearchConditions, } from '../../src/commands/profiles'; import { buildCreateSegmentBody } from '../../src/commands/segments'; @@ -195,4 +196,54 @@ describe('commands / body builders', function () { expect(body).to.deep.equal({ tag_id: 'tier', chain_id: '1' }); }); }); + + // ── Profiles search conditions ── + + 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}]', + ); + expect(conds).to.have.length(2); + expect((conds[0] as { field: string }).field).to.equal('users.net_worth_usd'); + }); + + 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"}]', + ), + ).to.not.throw(); + }); + + it('rejects a bare (untyped) field — the silent-failure footgun', function () { + expect(() => + 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":"gt","value":5}]'), + ).to.throw(/must be a typed path/); + }); + + it('throws on invalid JSON', function () { + expect(() => parseSearchConditions('not-json')).to.throw( + /valid JSON array of FilterCondition/, + ); + }); + + it('throws when not an array', function () { + expect(() => parseSearchConditions('{"field":"users.net_worth_usd"}')).to.throw( + /valid JSON array of FilterCondition/, + ); + }); + + it('throws when an entry is missing a string field', function () { + expect(() => parseSearchConditions('[{"op":"gt","value":1}]')).to.throw( + /must have a non-empty string "field"/, + ); + }); + }); }); From a802093b9c84487253b62cb06709e7383865665f Mon Sep 17 00:00:00 2001 From: Yos Riady <yos@formo.so> Date: Mon, 18 May 2026 17:06:25 +0700 Subject: [PATCH 5/5] Extend incur patch: kebab-case flags in --llms manifest, usage, CTAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier patch only fixed formatExamples(). Three other incur renderers still emitted raw camelCase keys: - Skill.js renderCommandBody(): the `--llms`/`--llms-full` Options tables (`| --triggerType |`) — agent-facing manifest. - Help.js formatCommand(): explicit usage-pattern option lines. - Cli.js formatCta(): suggested-command / error-CTA strings. All now apply the same kebab transform, so every user- and agent-facing flag surface (help, examples, manifest, skills, MCP, CTAs) is consistent. patches/incur.patch now carries 4 hunks. Verified: `--llms-full` option tables and `--help` examples both kebab; build/lint pass; 87 passing / 0 failing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- patches/incur.patch | 37 ++++++++++++++++++++++++++++++++++++- pnpm-lock.yaml | 6 +++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/patches/incur.patch b/patches/incur.patch index add3548..ea3a385 100644 --- a/patches/incur.patch +++ b/patches/incur.patch @@ -1,7 +1,16 @@ diff --git a/dist/Cli.js b/dist/Cli.js -index 1467ccf7a6eb6c2bf1e7a5172631032ea3c342e7..1286e927e8b3449fec56601fef633eef68391b4e 100644 +index 1467ccf7a6eb6c2bf1e7a5172631032ea3c342e7..fc5913148ee2ae0423d90d30b276390129a222c6 100644 --- a/dist/Cli.js +++ b/dist/Cli.js +@@ -1951,7 +1951,7 @@ function formatCta(name, cta) { + cmd += value === true ? ` <${key}>` : ` ${value}`; + if (cta.options) + for (const [key, value] of Object.entries(cta.options)) +- cmd += value === true ? ` --${key} <${key}>` : ` --${key} ${value}`; ++ cmd += value === true ? ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} <${key}>` : ` --${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${value}`; + return { command: cmd, ...(cta.description ? { description: cta.description } : undefined) }; + } + /** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */ @@ -2113,7 +2113,7 @@ export function formatExamples(examples) { parts.push(String(value)); if (ex.options) @@ -11,3 +20,29 @@ index 1467ccf7a6eb6c2bf1e7a5172631032ea3c342e7..1286e927e8b3449fec56601fef633eef const result = { command: parts.join(' ') }; if (ex.description) result.description = ex.description; +diff --git a/dist/Help.js b/dist/Help.js +index a0545f3eda9b12d8e33a46c02941926e72dab995..ea3caac698c27daa05d219addb202c66428337cd 100644 +--- a/dist/Help.js ++++ b/dist/Help.js +@@ -52,7 +52,7 @@ export function formatCommand(name, options = {}) { + parts.push(`<${key}>`); + if (u.options) + for (const key of Object.keys(u.options)) +- parts.push(`--${key} <${key}>`); ++ parts.push(`--${toKebab(key)} <${key}>`); + if (u.suffix) + parts.push(u.suffix); + return parts.join(' '); +diff --git a/dist/Skill.js b/dist/Skill.js +index d5b5bfb74d2b6f20c4a089e2315fe7f52181a3bd..1295e5a46e5b1f323e855d5c062feb4b95aca765 100644 +--- a/dist/Skill.js ++++ b/dist/Skill.js +@@ -143,7 +143,7 @@ function renderCommandBody(cli, cmd, level = 1) { + const def = prop?.default !== undefined ? String(prop.default) : ''; + const rawDesc = field.description ?? ''; + const desc = prop?.deprecated ? `**Deprecated.** ${rawDesc}` : rawDesc; +- return `| \`--${key}\` | \`${type}\` | ${def ? `\`${def}\`` : ''} | ${desc} |`; ++ return `| \`--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}\` | \`${type}\` | ${def ? `\`${def}\`` : ''} | ${desc} |`; + }); + sections.push(`${sub} Options\n\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n${rows.join('\n')}`); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 169e492..82b83b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ overrides: serialize-javascript: '>=7.0.5' patchedDependencies: - incur: 61a525bed57fffbfcf01a35647a140e86da48ff828c37fd5088e80fe1cd3fef2 + incur: 59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc importers: @@ -19,7 +19,7 @@ importers: version: 1.16.1 incur: specifier: ^0.3.4 - version: 0.3.25(patch_hash=61a525bed57fffbfcf01a35647a140e86da48ff828c37fd5088e80fe1cd3fef2) + version: 0.3.25(patch_hash=59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -1723,7 +1723,7 @@ snapshots: imurmurhash@0.1.4: {} - incur@0.3.25(patch_hash=61a525bed57fffbfcf01a35647a140e86da48ff828c37fd5088e80fe1cd3fef2): + incur@0.3.25(patch_hash=59ec45aa48f7686d1061ad0b42aaf3a47e84080dfce7d2baf3c763a1b2d214fc): dependencies: '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/server': 2.0.0-alpha.2(@cfworker/json-schema@4.1.1)