Skip to content

v0.21.0 — dev loop: quota, lookup, localhost mock#26

Merged
wavyx merged 3 commits into
mainfrom
feat-dev-loop
Jul 9, 2026
Merged

v0.21.0 — dev loop: quota, lookup, localhost mock#26
wavyx merged 3 commits into
mainfrom
feat-dev-loop

Conversation

@wavyx

@wavyx wavyx commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Third pre-v1 release (dev-loop primitives).

Added

  • pdcli quota / ratelimit — daily token-budget readout with --min/--threshold CI gates (exit 75). Prints the reading even when the budget is exhausted.
  • pdcli lookup <entity> --field --value — exact-match id lookup via the field-search endpoint (name→hash resolved), --first for --jq .id, no-match exits 3 for create-vs-update branching.
  • Localhost mockPDCLI_BASE_URL honored only for localhost (else exit 78); the keychain/OAuth token is never sent to the override host; loud stderr banner; Prism recipe documented.

Quality gate

  • TDD. 1721 tests, 100% coverage, lint clean. 148 commands.
  • Adversarial review (7 findings, all fixed). Security-critical fix: the mock now forces the env token + token-auth at the client layer, so a skipAuth command can't leak the real keychain token to a localhost mock. Also: lookup tolerates both response shapes, --threshold fails closed, the probe shows a low budget instead of erroring, exit 3 documented.
  • Live-verified: host-lock rejects evil.com, localhost.evil.com, 127.0.0.1.evil.com (exit 78); valid localhost banners to stderr and targets the override.
  • Pending live re-confirm on sandbox (not build-blocking): the daily-budget header names and the exact itemSearch/field element shape.

https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9

Greptile Summary

This PR adds three dev-loop primitives: pdcli quota (daily token-budget readout with CI gates), pdcli lookup (exact-match id resolution via the field-search endpoint), and a guarded PDCLI_BASE_URL localhost mock override. The mock implementation is notably careful: host-lock is enforced at the URL level, the real keychain/OAuth token is replaced with PDCLI_API_TOKEN at client-construction time, and the onRefresh callback is nulled so a 401 from the mock can never trigger a real token mint.

  • quota probes /api/v1/users/me, snapshots lastRateLimit off the response (including 429s), and uses --min/--threshold for CI gating; --threshold fails closed (exit 69) when the daily-limit header is absent, which is correct but not yet documented in the exit-codes table.
  • lookup resolves human field names to API key hashes, caps results at 100 (the endpoint's limit), tolerates both wrapped and flat response shapes, and exits 3 on no-match for create-vs-update branching.
  • Mock endpoint is locked to localhost/127.0.0.1/[::1] via URL.hostname comparison; token substitution is enforced at the client layer so skipAuth commands cannot inadvertently leak stored credentials.

Confidence Score: 4/5

The three new commands are safe to merge. Security properties of the mock override are well-implemented and tested; the quota and lookup commands are straightforward additions with good test coverage.

The implementation is sound across all three features. The two findings are both in quota.js: an unguarded rl.dailyRemaining dereference (not reachable today but fragile under future refactors), and an undocumented exit 69 from quota --threshold when the daily-limit header is missing. Neither affects correct behavior on the current code paths. The mock token-substitution logic, host-lock, and lookup field-resolution are all implemented correctly and thoroughly tested.

src/commands/quota.js warrants a second look for the missing null guard and the undocumented exit-69 case; website/src/content/docs/automation/exit-codes.mdx should be updated to document that exit code.

Important Files Changed

Filename Overview
src/commands/quota.js New quota command. Correctly swallows RateLimitError from the probe and reads lastRateLimit afterward; --threshold fail-closed logic is sound. Minor: rl is accessed without a null guard, and exit 69 for the missing-limit-header case is undocumented.
src/commands/lookup.js New lookup command. Field-name-to-key resolution, 100-item cap, flat/wrapped result shape tolerance, and exit-3 no-match contract all look correct. No issues found.
src/lib/client.js resolveMockOrigin host-lock and token-substitution logic is thorough (covers localhost, 127.0.0.1, [::1]; forces PDCLI_API_TOKEN; nulls onRefresh). lastRateLimit capture placed correctly before any throw inside transport.
test/lib/client.test.js Adds lastRateLimit capture tests and a comprehensive mock-endpoint suite covering the token-substitution, onRefresh suppression, host-lock rejection, IPv6, and malformed-URL cases.
website/src/content/docs/automation/exit-codes.mdx Adds exit 3 for lookup no-match. Missing: exit 69 from quota --threshold when the daily-limit header is absent.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CLI
    participant createClient
    participant transport
    participant API as Pipedrive / Mock

    Note over CLI,API: pdcli quota
    CLI->>createClient: usingMock? resolveMockOrigin / normal origin
    CLI->>transport: GET /api/v1/users/me
    transport->>API: request (with auth headers)
    API-->>transport: 200 or 429 + rate-limit headers
    transport->>transport: "api.lastRateLimit = {dailyRemaining, dailyLimit, reset}"
    alt 429 RateLimitError
        transport-->>CLI: throw RateLimitError (caught, swallowed)
    end
    CLI->>CLI: read lastRateLimit, compute pct
    CLI->>CLI: --min / --threshold gate (exit 75 or 69)

    Note over CLI,API: pdcli lookup
    CLI->>transport: "GET /api/v2/{entity}Fields"
    transport->>API: request
    API-->>transport: field list
    CLI->>CLI: resolver.nameToKey(flags.field)
    CLI->>transport: "GET /api/v2/itemSearch/field?term=...&field=..."
    transport->>API: request
    API-->>transport: "data [{item: {id,...}}]"
    alt "rows.length === 0"
        CLI-->>CLI: exit 3 (no match)
    else
        CLI-->>CLI: outputResults (--first rows[0])
    end

    Note over CLI,API: PDCLI_BASE_URL mock flow
    CLI->>createClient: "PDCLI_BASE_URL=http://127.0.0.1:4010"
    createClient->>createClient: resolveMockOrigin, validate localhost
    createClient->>createClient: "token=PDCLI_API_TOKEN, authMode=token, onRefresh=undefined"
    CLI->>transport: any request
    transport->>API: http://127.0.0.1:4010 (dummy token, no keychain)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CLI
    participant createClient
    participant transport
    participant API as Pipedrive / Mock

    Note over CLI,API: pdcli quota
    CLI->>createClient: usingMock? resolveMockOrigin / normal origin
    CLI->>transport: GET /api/v1/users/me
    transport->>API: request (with auth headers)
    API-->>transport: 200 or 429 + rate-limit headers
    transport->>transport: "api.lastRateLimit = {dailyRemaining, dailyLimit, reset}"
    alt 429 RateLimitError
        transport-->>CLI: throw RateLimitError (caught, swallowed)
    end
    CLI->>CLI: read lastRateLimit, compute pct
    CLI->>CLI: --min / --threshold gate (exit 75 or 69)

    Note over CLI,API: pdcli lookup
    CLI->>transport: "GET /api/v2/{entity}Fields"
    transport->>API: request
    API-->>transport: field list
    CLI->>CLI: resolver.nameToKey(flags.field)
    CLI->>transport: "GET /api/v2/itemSearch/field?term=...&field=..."
    transport->>API: request
    API-->>transport: "data [{item: {id,...}}]"
    alt "rows.length === 0"
        CLI-->>CLI: exit 3 (no match)
    else
        CLI-->>CLI: outputResults (--first rows[0])
    end

    Note over CLI,API: PDCLI_BASE_URL mock flow
    CLI->>createClient: "PDCLI_BASE_URL=http://127.0.0.1:4010"
    createClient->>createClient: resolveMockOrigin, validate localhost
    createClient->>createClient: "token=PDCLI_API_TOKEN, authMode=token, onRefresh=undefined"
    CLI->>transport: any request
    transport->>API: http://127.0.0.1:4010 (dummy token, no keychain)
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "chore: release v0.21.0" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

wavyx added 3 commits July 9, 2026 13:37
- pdcli quota (alias ratelimit): reads the daily token-budget headers
  (x-daily-ratelimit-token-remaining/-limit) via one cheap GET; --min /
  --threshold gate CI with exit 75; absent headers print n/a, exit 0.
  The budget is company-wide, so treat the reading as a hint.
- pdcli lookup <entity> --field <name> --value <v>: exact-match id lookup
  via GET /api/v2/itemSearch/field (name to hash resolved from the fields
  cache). --first yields one row for shell composition (--jq .id); no
  match exits 3 so scripts branch create-vs-update. Case-sensitive and
  eventually consistent.
- Localhost mock: PDCLI_BASE_URL is honored only when it resolves to
  localhost/127.0.0.1/::1 (anything else exits 78, reaffirming the
  host-lock); a loud stderr banner prints; the real keychain token is
  never sent to the override host (PDCLI_API_TOKEN required). Prism
  recipe documented.
- MCP: lookup and quota classified read; lookup added to the curated set.

Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
Security:
- The localhost mock now forces the credential to PDCLI_API_TOKEN and
  token auth (and disables OAuth refresh) at the client layer, so a
  skipAuth command (auth status/login) can no longer send the real
  keychain/OAuth token to the override host. Withholding was previously
  presence-only.

quota:
- --threshold fails closed: if the daily-limit header is absent it errors
  (exit 69) instead of silently exiting 0.
- The probe catches a RateLimitError and still prints the reading from
  the captured headers, so an exhausted budget shows numbers (and gates)
  instead of erroring out with exit 75 and no output.

lookup:
- Tolerates both the wrapped ({item}) and flat ({id}) result shapes, so a
  flat response no longer yields undefined rows and a false "found".
- Exit 3 (no match) is now documented in the exit-code contract.
- Held out of the MCP curated default: its exit 3 reads as a tool error
  to agents (still reachable via --all-tools).

docs: the mock recipe points at a downloadable spec, not a private path.

Claude-Session: https://claude.ai/code/session_018dRMUUj9vpKp3tqKQFsvE9
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@wavyx wavyx merged commit d8c9de0 into main Jul 9, 2026
12 checks passed
Comment thread src/commands/quota.js
Comment on lines +75 to +76
const rl = this.apiClient.lastRateLimit
const remaining = rl.dailyRemaining

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

Comment thread src/commands/quota.js
Comment on lines +107 to +113
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 },
)
}

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants