Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ jobs:
- name: Run lint
run: pnpm lint

- name: Typecheck (src + tests)
run: pnpm typecheck

test:
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [build, lint]
Expand Down
69 changes: 41 additions & 28 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,40 @@ The Formo CLI is built with [incur](https://github.com/tryincur/incur), a type-s
## Project structure

```
apps/cli/
cli/
├── src/
│ ├── index.ts # CLI entry point — registers all commands and calls cli.serve()
│ ├── commands/
│ │ ├── profiles.ts # profiles get / profiles search commands + exported run helpers
│ │ ├── profiles.test.ts # unit tests for profiles commands
│ │ ├── query.ts # query run command + exported run helper
│ │ └── query.test.ts # unit tests for query commands
│ ├── commands/ # one file per command group + exported run helpers
│ │ ├── alerts.ts
│ │ ├── analytics.ts
│ │ ├── boards.ts
│ │ ├── charts.ts
│ │ ├── contracts.ts
│ │ ├── events.ts
│ │ ├── import.ts
│ │ ├── profiles.ts
│ │ ├── query.ts
│ │ └── segments.ts
│ └── lib/
│ ├── client.ts # axios HTTP client factory + requireApiKey guard
│ ├── client.test.ts # unit tests for client
│ ├── config.ts # ~/.config/formo/config.json read/write + FORMO_API_KEY env
│ └── config.test.ts # unit tests for config
│ ├── json.ts # JSON option parsing helpers
│ ├── sql.ts # SQL helpers (strip trailing FORMAT clause)
│ └── ui.ts # terminal styling utilities
├── test/
│ ├── commands/ # per-command tests (mostly live-API integration tests)
│ ├── lib/ # unit tests for lib modules
│ ├── helpers/
│ │ └── liveApi.ts # probes the live API once; skips integration tests on auth failure
│ ├── preload.cjs # loads .env, maps TEST_TOKEN → FORMO_API_KEY
│ └── setup.ts
├── .mocharc.json
├── CONTRIBUTING.md
├── README.md
├── SKILLS.md
├── package.json
├── tsconfig.json
└── vitest.config.ts
└── tsconfig.test.json
```

## How commands work
Expand Down Expand Up @@ -59,7 +74,7 @@ This pattern means tests call `getProfileRun(...)` directly without needing to i
2. Export a named helper function containing the logic.
3. Register the command on a `Cli.create(...)` subcommand group or directly on `cli`.
4. Register the group in `src/index.ts` if it's new.
5. Write tests in a `.test.ts` sibling file.
5. Write tests in `test/commands/<name>.test.ts`.

Minimal example — adding `formo wallets list`:

Expand Down Expand Up @@ -115,41 +130,39 @@ color.red('text') // Red text
## Local development

```bash
# Install dependencies from repo root
# Install dependencies
pnpm install

# Run in dev mode (ts-node, no build needed)
FORMO_API_KEY=formo_xxx pnpm --filter @formo/cli dev profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
FORMO_API_KEY=formo_xxx pnpm dev profiles get 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045

# Or set the key once and run any command
export FORMO_API_KEY=formo_xxx
pnpm --filter @formo/cli dev query run "SELECT count(*) FROM events"
pnpm dev query run "SELECT count(*) FROM events"

# Build TypeScript
pnpm --filter @formo/cli build
pnpm build

# Run the built binary directly
node apps/cli/dist/index.js profiles search --limit 5
# Run the built entrypoint directly
node dist/index.js profiles search --size 5
```

## Running tests

```bash
# Run all tests once
pnpm --filter @formo/cli test
pnpm test

# Watch mode
pnpm --filter @formo/cli test:watch
pnpm test:watch
```

Tests use [Vitest](https://vitest.dev/) with `vi.mock('../lib/client')` to isolate HTTP calls. The pattern is:
Tests live in `test/` and run with [Mocha](https://mochajs.org/) via [tsx](https://tsx.is/) (see `.mocharc.json`), with [chai](https://www.chaijs.com/) `expect` assertions. There are two kinds of tests:

1. Mock `createClient` to return a fake axios instance with `vi.fn()` methods.
2. Mock `requireApiKey` to be a no-op (or throw for error cases).
3. Import and call the exported helper directly.
4. Assert on the mock calls.
- **Live-API integration tests** — most of `test/commands/` hits the real Formo API. `test/preload.cjs` loads `.env` and requires `TEST_TOKEN` (mapped to `FORMO_API_KEY`) before any test module loads; the run aborts if it is missing. `test/helpers/liveApi.ts` probes the API once and, if the key is rejected or the API is unreachable, integration tests are skipped with a clear message via `requiresLiveApi(this)`.
- **Pure unit tests** — `test/commands/bodyBuilders.test.ts` and the `test/lib/` suites test exported helpers (body builders, JSON/SQL parsing, config) directly without any network calls.

Note: `requireApiKey()` and `JSON.parse()` errors throw **synchronously**, so use `expect(() => fn()).toThrow(...)` (not `rejects.toThrow`) for those cases.
Note: `requireApiKey()` and `JSON.parse()` errors throw **synchronously**, so use `expect(() => fn()).to.throw(...)` for those cases.

## AI agent mode

Expand All @@ -170,15 +183,15 @@ When an AI agent runs `formo` with the incur sync protocol, it gets a structured

## Authentication in tests

Tests mock `requireApiKey` so they never need a real API key. For manual end-to-end testing:
The test suite needs a real API key: add `TEST_TOKEN=formo_your_key` to a `.env` file in the repo root before running `pnpm test`. For manual end-to-end testing:

```bash
# Option 1: env var (temporary)
FORMO_API_KEY=formo_xxx pnpm --filter @formo/cli dev profiles get vitalik.eth
FORMO_API_KEY=formo_xxx pnpm dev profiles get vitalik.eth

# Option 2: save to config file (persistent)
pnpm --filter @formo/cli dev login formo_xxx
pnpm --filter @formo/cli dev profiles get vitalik.eth
pnpm dev login formo_xxx
pnpm dev profiles get vitalik.eth
```

The config is stored at `~/.config/formo/config.json` with mode `0600`.
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Search wallet profiles with filters, sorting, and pagination. Returns a `Paginat
| Option | Description |
|---|---|
| `--address` | Filter by wallet address |
| `--search` | Free-text search across address and identity fields |
| `--page` | Page number (1-indexed, default `1`) |
| `--size` | Page size (default `100`, max `1000`) |
| `--order-by` | `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points` |
Expand All @@ -121,6 +122,20 @@ formo profiles search --conditions '[{"field":"users.net_worth_usd","op":"gt","v
formo profiles search --conditions '[{"field":"chains.1.balance","op":"gt","value":1000}]' --size 20
```

### Lifecycle tuning (advanced)

Both `profiles get` and `profiles search` accept optional flags to override the lifecycle stage thresholds used when computing `lifecycle`:

| Option | Description |
|---|---|
| `--new-window-days` | Override lifecycle new-user window in days |
| `--churn-window-days` | Override lifecycle churn window in days |
| `--power-user-min-active-days` | Override lifecycle power-user minimum active days |
| `--power-user-window-days` | Override lifecycle power-user window in days |
| `--resurrected-gap-days` | Override lifecycle resurrected gap in days |
| `--at-risk-min-days-inactive` | Override lifecycle at-risk minimum inactive days |
| `--at-risk-prior-active-days-threshold` | Override lifecycle at-risk prior active days threshold |

### `profiles update <address>`

Merge-update identity properties on a wallet profile.
Expand Down Expand Up @@ -205,6 +220,7 @@ Get a single alert by ID.
| `--trigger-filters` | JSON array of trigger filter objects |
| `--recipient` | JSON array of recipient objects |
| `--secret` | Webhook secret |
| `--slack-property-keys` | JSON array of event/user property keys to include in Slack alerts |

```bash
formo alerts create --name "High value tx" --trigger-type event \
Expand All @@ -213,7 +229,7 @@ formo alerts create --name "High value tx" --trigger-type event \
```

### `alerts update <alertId>`
Same options as `create`. Replaces the alert configuration.
Same options as `create`. Replaces the alert configuration in full — omitted options are reset to their defaults (e.g. leaving out `--trigger-filters` clears the existing trigger filters).

### `alerts delete <alertId>`
Delete an alert.
Expand Down Expand Up @@ -271,7 +287,7 @@ Delete a board.

## `formo charts`

Chart commands. Charts live inside a board. Requires `charts:read` / `charts:write`.
Chart commands. Charts live inside a board. Requires `boards:read` / `boards:write`.

### `charts list --board-id <boardId>`
List all charts in a board.
Expand All @@ -294,8 +310,12 @@ formo charts create --board-id brd_123 --title "Daily Active Users" \
formo charts create --board-id brd_123 --body '{"title":"Recent Events","chart_type":"table","query":"SELECT * FROM events LIMIT 10"}'
```

### `charts update <chartId> --board-id <boardId> --body '<json>'`
Update a chart.
### `charts update <chartId> --board-id <boardId> [options]`
Update a chart. Accepts the same options as `create`: a raw `--body '<json>'` and/or typed flags (`--title`, `--chart-type`, `--query`, `--description`, `--x-axis`, `--y-axis`, `--group-by`, `--steps`, `--settings`). Typed flags override matching `--body` keys.

```bash
formo charts update chart_abc123 --board-id brd_123 --title "Renamed chart"
```

### `charts query <chartId> --board-id <boardId> --date-from <YYYY-MM-DD> --date-to <YYYY-MM-DD>`
Execute a saved chart that uses `{{date_from}}` / `{{date_to}}` variables.
Expand Down Expand Up @@ -420,7 +440,7 @@ formo analytics retention --filters '[{"field":"location","op":"eq","value":"US"

### `import wallets`

Bulk-import wallet addresses into the project via the events API.
Bulk-import wallet addresses into the project via the main Formo API, authenticated with your workspace API key.

| Option | Description |
|---|---|
Expand All @@ -432,17 +452,26 @@ formo import wallets --addresses '["0xabc...","0xdef..."]'
formo import wallets --rows '[{"address":"0xabc...","properties":{"display_name":"Alice"}}]'
```

> Requires `profiles:write` scope. Only available on Scale and Enterprise plans.

---

## `formo events`

### `events ingest`

Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key.
Send raw analytics events to `events.formo.so`. This command uses a project SDK write key, not the workspace API key — pass it via `--write-key` or the `FORMO_WRITE_KEY` environment variable.

| Option | Description |
|---|---|
| `--event` | Single event as a JSON object; wrapped in an array before sending |
| `--events` | JSON array of event objects to send as a batch |
| `--write-key` | Project SDK write key (defaults to `FORMO_WRITE_KEY`) |

```bash
export FORMO_WRITE_KEY=formo_write_key_xxx
formo events ingest --event '{"type":"track","channel":"cli","version":"1","anonymous_id":"anon_123","event":"CLI Test","context":{},"properties":{},"original_timestamp":"2026-04-27T23:05:38.000Z","sent_at":"2026-04-27T23:05:42.000Z","message_id":"cli-test-1"}'
formo events ingest --events '[{"type":"track","event":"First"},{"type":"track","event":"Second"}]'
```

---
Expand Down
19 changes: 14 additions & 5 deletions SKILLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ formo status

Shows whether an API key is configured, where it was loaded from (env var or config file), and masked key value.

### Log out

```bash
formo logout
```

Removes the saved API key and clears authentication state.

---

## Wallet Profiles
Expand Down Expand Up @@ -82,6 +90,7 @@ formo profiles search [options]
| `--order-dir` | `asc`, `desc` | Sort direction |
| `--expand` | `string` | Comma-separated fields to expand |
| `--conditions` | JSON array | Advanced filter conditions (see below) |
| `--logic` | `and`, `or` | Logic operator for combining conditions (default `and`) |

**`--order-by` values:** `last_onchain`, `first_onchain`, `net_worth_usd`, `updated_at`, `tx_count`, `first_seen`, `last_seen`, `num_sessions`, `revenue`, `volume`, `points`

Expand Down Expand Up @@ -159,21 +168,21 @@ formo profiles labels delete <address> --tag-id vip
Execute a SQL query against your Formo analytics data (events, sessions, wallet profiles).

```bash
formo query "<sql>"
formo query run "<sql>"
```

> Requires `query:read` scope on your API key.

**Examples:**
```bash
# Count total events
formo query "SELECT count(*) FROM events"
formo query run "SELECT count(*) FROM events"

# Top 10 wallets by net worth
formo query "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10"
formo query run "SELECT address, net_worth_usd FROM wallet_profiles ORDER BY net_worth_usd DESC LIMIT 10"

# Recent sessions
formo query "SELECT address, last_seen FROM wallet_profiles ORDER BY last_seen DESC LIMIT 5"
formo query run "SELECT address, last_seen FROM wallet_profiles ORDER BY last_seen DESC LIMIT 5"
```

---
Expand Down Expand Up @@ -215,7 +224,7 @@ formo analytics top_wallets --date-from 2026-04-01 --date-to 2026-04-30 --params
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`.
Each pipe accepts pipe-specific params via `--params` (see each command's `--help`): e.g. `funnel` → `steps`, `window_seconds`, `funnel_type`, `group_by`, `limit`, `attribution`; `kpis` → `group_by`, `limit`; `top_*` → `limit`, `offset`.

---

Expand Down
18 changes: 7 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "@formo/cli",
"version": "1.1.0",
"packageManager": "pnpm@11.1.2",
"engines": {
"node": ">=22.12"
},
"description": "Formo API CLI — query profiles and analytics data",
"license": "MIT",
"repository": {
Expand All @@ -22,19 +25,18 @@
],
"scripts": {
"build": "tsc",
"dev": "ts-node src/index.ts",
"prepublishOnly": "pnpm build",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"typecheck": "tsc -p tsconfig.test.json --noEmit",
"test": "mocha",
"test:watch": "mocha --watch"
},
"dependencies": {
"axios": "^1.18.0",
"incur": "^0.3.4"
},
"overrides": {
"serialize-javascript": ">=7.0.5"
"incur": "0.3.25"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand All @@ -46,14 +48,8 @@
"eslint": "^10.2.0",
"globals": "^17.4.0",
"mocha": "^11.7.5",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.58.1"
},
"pnpm": {
"overrides": {
"serialize-javascript": ">=7.0.5"
}
}
}
Loading