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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to `pdcli` are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[SemVer](https://semver.org/).

## [0.22.0] - 2026-07-09

Integrations: a local webhook dev loop, mail signal for agents, and filters as
code. The last release before the 1.0 contract freeze.

### Added

- **`pdcli webhook listen`** — a stripe-listen-style dev loop. Tunnel mode
registers a temporary webhook against your public tunnel (`--url`), prints and
optionally forwards (`--forward-to`) each delivery, and deletes the webhook on
exit; the receiver only accepts deliveries carrying the generated basic-auth,
and orphaned webhooks from a crashed run are swept on the next start.
`--synthetic` emits the same delivery envelope off the changes feed with zero
inbound network (for firewalled CI and reactive agents).
- **Mail signal** — `deal context --mail` adds a mail summary (message count,
last message time and direction, participants, latest subject); it is opt-in
because mail needs a scope most tokens lack. New `pdcli mail list --deal <id>`
lists a deal's messages (bodies excluded by design).
- **Filters as code** — `filter create`/`filter update` take conditions as JSON
(inline, `@file`, or stdin), `filter helpers` lists the operators for authoring
them, and `filter export` emits a portable filter definition (single or
`--all`). Condition field references use numeric field_id.

## [0.21.0] - 2026-07-09

Dev loop: check your rate budget, resolve records to ids, and point pdcli at a
Expand Down
123 changes: 122 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Full command reference for the pdcli command-line interface.

<!-- AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. -->

Reference for `pdcli` v0.21.0 (148 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`.
Reference for `pdcli` v0.22.0 (154 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, `--timeout`, and `--limit`.

## Top-level

Expand Down Expand Up @@ -627,6 +627,8 @@ pdcli deal context <id> [flags]
- `--no-notes` — Skip the notes slice
- `--no-products` — Skip the products slice
- `--no-participants` — Skip the participants slice
- `--mail` — Include a mail summary (off by default; needs the mail:read scope and email sync)
- `--mail-limit <value>` — Max mail messages to scan for the summary
- `--activity-limit <value>` — Max activities to include
- `--note-limit <value>` — Max notes to include

Expand Down Expand Up @@ -1269,6 +1271,25 @@ pdcli file upload ./report.pdf --deal 42

## pdcli filter

### `pdcli filter create`

Create a filter

```
pdcli filter create [flags]
```

- `--name <value>` _(required)_ — Filter name
- `--type <deals|leads|org|people|products|activity|projects>` _(required)_ — Filter type
- `--conditions <value>` — Conditions JSON (a value, @file, or piped stdin). Fields are referenced by numeric field_id — run `pdcli filter helpers` for the valid operators. The blob needs the two-level glue structure {"glue":"and","conditions":[{"glue":"and",...},{"glue":"or",...}]}.

Examples:

```bash
pdcli filter create --name "Open deals" --type deals --conditions @conditions.json
cat conditions.json | pdcli filter create --name "Open deals" --type deals
```

### `pdcli filter delete`

Delete a filter
Expand All @@ -1286,6 +1307,25 @@ pdcli filter delete 5
pdcli filter delete 5 --yes
```

### `pdcli filter export`

Export a filter (or all filters) as portable {name, type, conditions} JSON. To recreate it, feed the fields to `filter create` separately (the create command takes --name/--type/--conditions, not one blob).

```
pdcli filter export [id] [flags]
```

- `--all` — Export every filter

Examples:

```bash
pdcli filter export 5 > filter.json
pdcli filter export --all > filters.json
# recreate on another account (conditions reference numeric field_id):
pdcli filter create --name "$(jq -r .name filter.json)" --type "$(jq -r .type filter.json)" --conditions "$(jq -c .conditions filter.json)"
```

### `pdcli filter get`

Get a filter by ID
Expand All @@ -1301,6 +1341,24 @@ pdcli filter get 5
pdcli filter get 5 --output json
```

### `pdcli filter helpers`

List the operators available for authoring filter conditions

```
pdcli filter helpers [flags]
```

- `--type <value>` — Only show operators for this field data type (e.g. varchar, date, int)

Examples:

```bash
pdcli filter helpers
pdcli filter helpers --type varchar
pdcli filter helpers --output json
```

### `pdcli filter list`

List filters
Expand All @@ -1318,6 +1376,24 @@ pdcli filter list
pdcli filter list --type deals --output json
```

### `pdcli filter update`

Update a filter (only provided fields change)

```
pdcli filter update <id> [flags]
```

- `--name <value>` — Filter name
- `--conditions <value>` — Conditions JSON (a value or @file). Fields are referenced by numeric field_id — run `pdcli filter helpers` for the valid operators.

Examples:

```bash
pdcli filter update 5 --name "Renamed filter"
pdcli filter update 5 --conditions @conditions.json
```

## pdcli goal

### `pdcli goal list`
Expand Down Expand Up @@ -1476,6 +1552,25 @@ pdcli lead update adf21080-0e10-11eb-879b-05d71fb426ec --title "Renamed"
pdcli lead update adf21080-0e10-11eb-879b-05d71fb426ec --value 7500 --currency USD
```

## pdcli mail

### `pdcli mail list`

List the synced email linked to a deal. Message bodies are excluded by design (privacy posture) — the 225-char snippet is the preview; each row carries has_body_flag and body_url so you can fetch a body yourself. Requires the mail:read scope and a configured email sync.

```
pdcli mail list [flags]
```

- `--deal <value>` _(required)_ — Deal ID to list mail for

Examples:

```bash
pdcli mail list --deal 42
pdcli mail list --deal 42 --output json
```

## pdcli mcp

### `pdcli mcp serve`
Expand Down Expand Up @@ -2858,3 +2953,29 @@ pdcli webhook list
pdcli webhook list --output json
```

### `pdcli webhook listen`

Run a local webhook dev loop. In tunnel mode it registers a temporary Pipedrive webhook pointing at your public tunnel (--url) and prints/forwards each delivery, deleting the webhook on exit. In --synthetic mode it polls the changes feed instead and emits the same delivery envelope with zero inbound network — useful behind a firewall or for reactive agents.

```
pdcli webhook listen [flags]
```

- `--url <value>` — Public tunnel URL that forwards to the local receiver (tunnel mode)
- `--forward-to <value>` — POST each delivery to this local URL as well
- `--events <value>` — Comma-separated entity.action filters, e.g. deal.change,person.*
- `--port <value>` — Local receiver port (tunnel mode)
- `--synthetic` — Poll the changes feed and emit webhook-shaped events (no inbound network)
- `--since <value>` — Synthetic start point: RFC3339 timestamp or Nd/Nm (else the stored watermark)
- `--interval <value>` — Milliseconds between synthetic poll cycles
- `--once` — Process a single delivery / poll cycle then exit
- `--max-events <value>` — Exit after emitting this many events

Examples:

```bash
pdcli webhook listen --url https://abc123.ngrok.app --forward-to http://localhost:3000
pdcli webhook listen --url https://abc123.ngrok.app --events deal.change,person.*
pdcli webhook listen --synthetic --since 15m
```

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wavyx/pdcli",
"version": "0.21.0",
"version": "0.22.0",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -89,6 +89,9 @@
"note": {
"description": "Notes (list, get, create, update, delete) and comments"
},
"mail": {
"description": "Mail messages synced to a deal (list); bodies excluded by design"
},
"pipeline": {
"description": "Pipelines (list, get, health)"
},
Expand Down
83 changes: 82 additions & 1 deletion src/commands/deal/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,51 @@ import BaseCommand from '../../base-command.js'
import { collectPages } from '../../lib/pagination.js'
import { getFields, makeResolver } from '../../lib/fields.js'
import { assembleContext } from '../../lib/deal-context.js'
import { fetchDealMail, mailDirection } from '../mail/list.js'

/**
* Condense a deal's mail messages into an agent-friendly signal. Returns null
* when there is nothing to summarize (no synced mail linked to the deal).
* Bodies are excluded by design — the 225-char snippet is the only preview.
* @param {object[]} messages already-unwrapped mail message objects
* @returns {{ message_count: number, last_message_at: string|null,
* last_direction: 'sent'|'received', latest_subject: string|null,
* participants: { name: string|null, email: string|null,
* linked_person_id: number|null }[] } | null}
*/
export function summarizeMail(messages) {
if (!Array.isArray(messages) || messages.length === 0) return null

// Latest by message_time — list order is not guaranteed. ISO 8601 strings
// compare correctly lexically.
let latest = messages[0]
for (const m of messages) {
if ((m.message_time ?? '') > (latest.message_time ?? '')) latest = m
}

const participants = []
const seen = new Set()
for (const m of messages) {
for (const p of [...(m.from ?? []), ...(m.to ?? [])]) {
const key = p.email_address ?? p.name ?? p.linked_person_id
if (key == null || seen.has(key)) continue
seen.add(key)
participants.push({
name: p.name ?? null,
email: p.email_address ?? null,
linked_person_id: p.linked_person_id ?? null,
})
}
}

return {
message_count: messages.length,
last_message_at: latest.message_time ?? null,
last_direction: mailDirection(latest),
latest_subject: latest.subject ?? null,
participants,
}
}

export default class DealContextCommand extends BaseCommand {
static description =
Expand Down Expand Up @@ -30,6 +75,14 @@ export default class DealContextCommand extends BaseCommand {
'no-participants': Flags.boolean({
description: 'Skip the participants slice',
}),
mail: Flags.boolean({
description:
'Include a mail summary (off by default; needs the mail:read scope and email sync)',
}),
'mail-limit': Flags.integer({
description: 'Max mail messages to scan for the summary',
default: 50,
}),
'activity-limit': Flags.integer({
description: 'Max activities to include',
default: 50,
Expand All @@ -48,9 +101,26 @@ export default class DealContextCommand extends BaseCommand {
const dealBody = await this.apiClient.get(`/api/v2/deals/${id}`)
const deal = dealBody.data

// Mail needs the mail:read scope + a configured email sync; many api-token
// users have neither, so a 403 / permission error (or any mail-only
// failure) degrades to null rather than sinking the whole context bundle.
const fetchMail = async () => {
if (!flags.mail) return null
try {
const items = await fetchDealMail(
this.apiClient,
id,
flags['mail-limit'],
)
return summarizeMail(items)
} catch {
return null
}
}

// The id-only slices need only the deal id; person/org need the resolved
// join keys. All run concurrently after the deal GET.
const [person, org, activities, notes, products, participants] =
const [person, org, activities, notes, products, participants, mail] =
await Promise.all([
deal.person_id != null
? this.apiClient
Expand Down Expand Up @@ -94,6 +164,7 @@ export default class DealContextCommand extends BaseCommand {
limit: 500,
}),
),
fetchMail(),
])

// Resolve custom-field hash keys → names per entity so the bundle is
Expand Down Expand Up @@ -127,6 +198,9 @@ export default class DealContextCommand extends BaseCommand {
},
{ now, activitiesFetched: !flags['no-activities'] },
)
// assembleContext is mail-agnostic; the mail summary is attached here so
// an unfetched slice is `null`, never `false`.
bundle.mail = mail

if (this.resolveFormat() !== 'table') {
await this.outputResults(bundle, {})
Expand All @@ -148,5 +222,12 @@ export default class DealContextCommand extends BaseCommand {
.filter(([, v]) => v === true)
.map(([k]) => k)
this.log(` Flags: ${risks.length > 0 ? risks.join(', ') : 'none'}`)
if (bundle.mail) {
const m = bundle.mail
this.log(
` Mail: ${m.message_count} msgs · last ${m.last_direction}` +
(m.last_message_at ? ` ${m.last_message_at}` : ''),
)
}
}
}
Loading
Loading