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

## [0.21.0] - 2026-07-09

Dev loop: check your rate budget, resolve records to ids, and point pdcli at a
local mock.

### Added

- **`pdcli quota` (alias `ratelimit`)** — reads Pipedrive's daily token-budget
headers via one cheap request and prints remaining/limit/percent. `--min <n>`
and `--threshold <pct>` gate CI (exit 75 when too low) so a batch can check
headroom first: `pdcli quota --min 5000 && pdcli sync warehouse`. The budget is
company-wide, so treat the number as a hint. An exhausted budget still prints
the reading instead of erroring out.
- **`pdcli lookup <entity> --field <name> --value <v>`** — exact-match id lookup
(deal, person, org, product, lead) via the field-search endpoint, resolving a
human field name to its custom-field key. `--first` returns one row for shell
composition (`--first --jq .id`); **no match exits 3** so a script can branch
create-vs-update. Matching is case-sensitive and the search index is eventually
consistent.
- **Localhost mock endpoint** — set `PDCLI_BASE_URL` to a `localhost`/`127.0.0.1`
address (e.g. a Prism mock of the OpenAPI spec) to run pdcli against it. Any
non-localhost value is refused (exit 78), the real keychain/OAuth token is
never sent to the override host (a `PDCLI_API_TOKEN` is required), and a loud
banner prints to stderr.

## [0.20.0] - 2026-07-09

Distribution: install pdcli however your pipeline prefers, and drive the
Expand Down
44 changes: 43 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.20.0 (146 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.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`.

## Top-level

Expand Down Expand Up @@ -148,6 +148,48 @@ pdcli funnel
pdcli funnel --pipeline 1 --period 180d
```

### `pdcli lookup`

Find item IDs by an exact field value — the create-vs-update primitive.
Matching is CASE-SENSITIVE, and the search index is eventually consistent (a just-created record is not immediately findable), so a lookup-then-create loop can double-create in fast pipelines. Exit 3 means no match (branch to create); exit 0 with rows means it exists.

```
pdcli lookup <entity> [flags]
```

- `--field <value>` _(required)_ — Field name (built-in or custom) to search on
- `--value <value>` _(required)_ — Value to match against the field
- `--match <exact|beginning|middle>` — Match mode (case-sensitive)
- `--first` — Return only the first match (a single object)

Examples:

```bash
pdcli lookup deal --field "PO Number" --value PO-1234
pdcli lookup person --field Email --value a@b.com --first --jq .id
pdcli lookup org --field Name --value Acme --match beginning
```

### `pdcli quota`

Show the remaining Pipedrive daily API token budget. Makes one cheap probe request purely to read the rate-limit headers. The budget is COMPANY-WIDE — shared across every integration and user on the account — so treat the reading as a hint, not a guarantee. --min / --threshold gate CI: they exit 75 (rate-limited) when the budget is too low.

```
pdcli quota [flags]
```

- `--min <value>` — Exit 75 if the remaining daily token budget is below this many tokens
- `--threshold <value>` — Exit 75 if the remaining daily token budget is below this percentage

Examples:

```bash
pdcli quota
pdcli quota --output json
pdcli quota --min 5000
pdcli quota --threshold 10
```

### `pdcli search`

Search across deals, persons, organizations, products, leads, files, and projects
Expand Down
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wavyx/pdcli",
"version": "0.20.0",
"version": "0.21.0",
"publishConfig": {
"access": "public"
},
Expand Down
112 changes: 112 additions & 0 deletions src/commands/lookup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Args, Flags } from '@oclif/core'
import BaseCommand from '../base-command.js'
import { CliError } from '../lib/errors.js'
import { getFields, makeResolver } from '../lib/fields.js'

// User-facing entity -> itemSearch/field entity_type. Only entities that have
// both a fields endpoint (for name->key resolution) and a searchable type are
// supported; `project` is intentionally omitted (no fields endpoint).
const ENTITY_TYPES = {
deal: 'deal',
person: 'person',
org: 'organization',
product: 'product',
lead: 'lead',
}

const columns = {
id: { header: 'ID' },
name: { header: 'Name / Title', get: (row) => row.title ?? row.name ?? '' },
}

export default class LookupCommand extends BaseCommand {
static description =
'Find item IDs by an exact field value — the create-vs-update primitive.\n' +
'Matching is CASE-SENSITIVE, and the search index is eventually consistent ' +
'(a just-created record is not immediately findable), so a lookup-then-create ' +
'loop can double-create in fast pipelines. Exit 3 means no match (branch to ' +
'create); exit 0 with rows means it exists.'

static examples = [
'<%= config.bin %> lookup deal --field "PO Number" --value PO-1234',
'<%= config.bin %> lookup person --field Email --value a@b.com --first --jq .id',
'<%= config.bin %> lookup org --field Name --value Acme --match beginning',
]

static args = {
entity: Args.string({
required: true,
description: 'Item type to search',
options: Object.keys(ENTITY_TYPES),
}),
}

static flags = {
...BaseCommand.baseFlags,
field: Flags.string({
required: true,
description: 'Field name (built-in or custom) to search on',
}),
value: Flags.string({
required: true,
description: 'Value to match against the field',
}),
match: Flags.string({
description: 'Match mode (case-sensitive)',
options: ['exact', 'beginning', 'middle'],
default: 'exact',
}),
first: Flags.boolean({
description: 'Return only the first match (a single object)',
default: false,
}),
}

async run() {
const { args, flags } = await this.parse(LookupCommand)
const entityType = ENTITY_TYPES[args.entity]

const resolver = makeResolver(await getFields(this.apiClient, args.entity))
const field = resolver.nameToKey(flags.field)
if (!field) {
throw new CliError(`Unknown field "${flags.field}" for ${args.entity}`, {
exitCode: 64,
})
}

// exact match allows a 1-char term; the other modes need at least 2.
const minLength = flags.match === 'exact' ? 1 : 2
if (flags.value.length < minLength) {
throw new CliError(
`--value must be at least ${minLength} character(s) for --match ${flags.match}`,
{ exitCode: 64 },
)
}

// Search costs 20 rate-limit tokens; the field-search endpoint caps limit
// at 100 (single request, no auto-paging).
const body = await this.apiClient.get('/api/v2/itemSearch/field', {
query: {
term: flags.value,
entity_type: entityType,
field,
match: flags.match,
limit: flags.limit != null ? Math.min(flags.limit, 100) : undefined,
},
})

// Each result wraps the matched record under `item`; tolerate a flat shape
// (`{ id, ... }` directly) so the no-match check and output stay correct
// whichever the live endpoint returns.
const rows = (body.data ?? []).map((entry) => entry.item ?? entry)

if (rows.length === 0) {
throw new CliError(
`No ${args.entity} found where ${flags.field} = ${flags.value}`,
{ exitCode: 3 },
)
}

await this.outputResults(flags.first ? rows[0] : rows, columns)
}
}
123 changes: 123 additions & 0 deletions src/commands/quota.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Flags } from '@oclif/core'
import BaseCommand from '../base-command.js'
import { CliError, RateLimitError } from '../lib/errors.js'

const columns = {
remaining: {
header: 'Remaining',
get: (r) => (r.daily.remaining == null ? 'n/a' : r.daily.remaining),
},
limit: {
header: 'Limit',
get: (r) => (r.daily.limit == null ? 'n/a' : r.daily.limit),
},
pct: {
header: '% Left',
get: (r) => (r.daily.pct == null ? 'n/a' : `${r.daily.pct}%`),
},
reset: {
header: 'Burst reset (s)',
get: (r) => (r.reset == null ? 'n/a' : r.reset),
},
}

export default class QuotaCommand extends BaseCommand {
static aliases = ['ratelimit']

static description =
'Show the remaining Pipedrive daily API token budget. Makes one cheap ' +
'probe request purely to read the rate-limit headers. The budget is ' +
'COMPANY-WIDE — shared across every integration and user on the account — ' +
'so treat the reading as a hint, not a guarantee. --min / --threshold gate ' +
'CI: they exit 75 (rate-limited) when the budget is too low.'

static examples = [
'<%= config.bin %> quota',
'<%= config.bin %> quota --output json',
'<%= config.bin %> quota --min 5000',
'<%= config.bin %> quota --threshold 10',
]

static flags = {
...BaseCommand.baseFlags,
min: Flags.integer({
description:
'Exit 75 if the remaining daily token budget is below this many tokens',
min: 0,
}),
threshold: Flags.integer({
description:
'Exit 75 if the remaining daily token budget is below this percentage',
min: 0,
max: 100,
}),
}

async run() {
const { flags } = await this.parse(QuotaCommand)

// A GET-single-entity probe (~2 tokens) purely to populate the rate-limit
// headers. Users is v1-only (no v2 equivalent) and always exists. The
// probe goes through the shared transport, which THROWS a RateLimitError
// (exit 75) on a 429 or an exhausted daily budget — but that is precisely
// the reading a user runs `quota` to see. transport snapshots lastRateLimit
// off the 429 response BEFORE throwing, so swallow the rate-limit throw and
// fall through to print the reading; re-throw anything else (auth, 5xx,
// network) unchanged.
try {
await this.apiClient.get('/api/v1/users/me')
} catch (err) {
if (!(err instanceof RateLimitError)) throw err
}

// transport() populates lastRateLimit off every response (including the 429
// above), so it is set whether the probe resolved or was rate-limited.
const rl = this.apiClient.lastRateLimit
const remaining = rl.dailyRemaining
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unguarded dereference of lastRateLimit

this.apiClient.lastRateLimit is typed as { ... } | undefined until the first HTTP response is received. The probe request is made just before this code and all non-RateLimitError exceptions are re-thrown, so in every currently reachable path rl is an object. However, if a future code path (e.g. a quota invocation without the probe, or a refactor of the try/catch) ever allows control to reach here without a prior response, rl.dailyRemaining will throw a TypeError with no useful message rather than an actionable CLI error. A defensive early return keeps the invariant explicit.

Fix in Claude Code

const limit = rl.dailyLimit
const hasData = remaining != null
const pct =
hasData && limit ? Math.round((remaining / limit) * 100) : undefined

const data = {
daily: {
remaining: hasData ? remaining : null,
limit: limit ?? null,
pct: pct ?? null,
},
reset: rl.reset ?? null,
}

await this.outputResults(data, columns)

// --min gates on the absolute remaining; skip silently when there is no
// remaining reading to compare (nothing to gate on — never fabricate one).
if (flags.min != null && hasData && remaining < flags.min) {
throw new CliError(
`Daily token budget ${remaining} is below --min ${flags.min}`,
{ exitCode: 75 },
)
}

// --threshold gates on the percentage. If the caller asked for it but no
// pct could be computed (the daily-limit header was absent, or there was no
// reading at all), do NOT silently pass — a false green would let a CI job
// gating on `pdcli quota --threshold N` proceed blind. Fail closed.
if (flags.threshold != null) {
if (pct == null) {
throw new CliError(
'cannot evaluate --threshold: no daily-limit header in the API ' +
'response (x-daily-ratelimit-token-limit absent)',
{ exitCode: 69 },
)
}
Comment on lines +107 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Exit 69 from --threshold when limit header is absent is undocumented

When --threshold N is set but the x-daily-ratelimit-token-limit header is absent, this branch exits 69 ("fail closed"). Exit 69 is documented in exit-codes.mdx as "service unavailable / Pipedrive is down — safe to retry later," and --min/--threshold are only documented as "exit 75 when budget is too low." A CI script that gates on pdcli quota --threshold 10 would not know that a missing header produces exit 69 rather than 75, making it hard to write a correct handler. Worth adding a line to the exit-codes table or the quota flag description.

Fix in Claude Code

if (pct < flags.threshold) {
throw new CliError(
`Daily token budget ${pct}% remaining is below --threshold ` +
`${flags.threshold}%`,
{ exitCode: 75 },
)
}
}
}
}
Loading
Loading