mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
feat(skill): add collaboration & business-app tool layer (Slack, Notion, email, CRM, payments) (#269)
Adds five top-level operational tool skills, one per named tool: - slack: messages, channels, threads, search, files, and webhook signature verification (HMAC-SHA256) via a bounded, stdlib-only slack-cli. - notion: pages, database queries, search, and guarded page updates via notion-cli. - email: transactional email via Twilio SendGrid (send, deliverability bounces/spam reports, Signed Event Webhook verification with a self-contained ECDSA P-256 verifier) via email-cli. - crm: HubSpot CRM records, contact search, and deal pipeline views with guarded stage updates via crm-cli. - stripe: read-only-first balance, payment, and subscription queries with a guarded period-end subscription cancellation via stripe-cli. Each skill ships an executable script (--json output, --limit bounded reads, --dry-run/--yes mutation gate), a human README with the five required sections, a schema-v1 evals/evals.json with six output-quality cases, a dated source index + operations reference, and a deterministic unittest suite run by check-artifacts. All five are indexed in the top-level README and the generated catalogs were regenerated. Eval coverage rises from 78/139 to 83/144. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
f68721873e
commit
3256a87bcb
@@ -185,6 +185,15 @@
|
||||
"strict": false,
|
||||
"description": "Expert skill for role-based multi-agent orchestration with CrewAI. Agents with Role/Goal/Backstory, task design, crew composition (sequential or hierarchical), tool integration, callbacks, and production deployment. Use when orchestrating multi-agent teams or comparing agent frameworks."
|
||||
},
|
||||
{
|
||||
"name": "crm",
|
||||
"source": "./",
|
||||
"skills": [
|
||||
"./crm"
|
||||
],
|
||||
"strict": false,
|
||||
"description": "Operate HubSpot CRM from a terminal or agent: list and search contact records, view deal pipeline stages, and — with explicit confirmation — move deals between stages, backed by a bundled crm-cli script that is read-only by default and gates every stage change behind a --dry-run/--yes confirmation. Use when an agent needs to answer questions about contacts or deals, produce pipeline views, or apply a confirmed stage change. Do not use for building HubSpot apps or workflow automations (that is HubSpot app development), marketing/sequence automation, or other CRMs like Salesforce (that is their own tooling)."
|
||||
},
|
||||
{
|
||||
"name": "crowdsec",
|
||||
"source": "./",
|
||||
@@ -275,6 +284,15 @@
|
||||
"strict": false,
|
||||
"description": "Expert skill for programming—not prompting—language models with Stanford's DSPy framework. Signatures, modules (Predict, ChainOfThought, ReAct), optimizer/teleprompter selection, compilation, caching, evaluation. Use when doing programmatic prompt optimization or building compiled prompt programs."
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"source": "./",
|
||||
"skills": [
|
||||
"./email"
|
||||
],
|
||||
"strict": false,
|
||||
"description": "Send and diagnose transactional email through Twilio SendGrid from a terminal or agent: send messages, check deliverability (bounces and spam reports), and verify Signed Event Webhook signatures (ECDSA P-256) — with a bundled email-cli script that is read-only by default and gates every send behind a --dry-run/--yes confirmation. Use when an agent needs to send a transactional email, triage bounces or spam complaints, or confirm an inbound SendGrid webhook is authentic. Do not use for marketing or bulk email campaigns (that is SendGrid Marketing Campaigns), building email template systems, or other email providers (that is their own tooling)."
|
||||
},
|
||||
{
|
||||
"name": "epub",
|
||||
"source": "./",
|
||||
@@ -599,6 +617,15 @@
|
||||
"strict": false,
|
||||
"description": "Use when asked to fix, build, refactor, review, verify, or release software and the work is non-trivial — including delivering a change request (issue, ticket, or request) from intake through planning, gates, implementation, review, verified PR, and authorized post-merge release. neckbeard routes the change through framing, discovery, design, implementation, review, verification, delivery, and learning — choosing the smallest *safe* intervention, proving it at the real delivery boundary, and leaving an inspectable evidence ledger. For change-request / issue-to-PR work, conditionally loads a 9-phase journey with gates, delivery packet, and lifecycle integration. Composes specialist catalog skills rather than replacing them. Not a persona, not a '10x developer' prompt, not a LOC-minimizer. The journey is not loaded for plain fixes, refactors, or reviews that lack an issue/ticket trajectory."
|
||||
},
|
||||
{
|
||||
"name": "notion",
|
||||
"source": "./",
|
||||
"skills": [
|
||||
"./notion"
|
||||
],
|
||||
"strict": false,
|
||||
"description": "Operate Notion from a terminal or agent: retrieve pages, query databases, search pages and databases, and update page properties — with a bundled notion-cli script that is read-only by default and gates every create or update behind a --dry-run/--yes confirmation. Use when an agent needs to read Notion content, answer questions from a team wiki or database, or make a confirmed edit. Do not use for building Notion integrations or block-level page composition beyond property updates (that is Notion API application development), or for other knowledge bases (that is their own tooling)."
|
||||
},
|
||||
{
|
||||
"name": "nous-branding",
|
||||
"source": "./",
|
||||
@@ -950,6 +977,15 @@
|
||||
"strict": false,
|
||||
"description": "Design, operate, and improve reliable production systems with SLOs, incident command, observability, error budgets, and operational practices."
|
||||
},
|
||||
{
|
||||
"name": "slack",
|
||||
"source": "./",
|
||||
"skills": [
|
||||
"./slack"
|
||||
],
|
||||
"strict": false,
|
||||
"description": "Operate Slack workspaces from a terminal or agent: list channels, read messages, follow threads, search message history, list files, and verify inbound webhook signatures — with a bundled slack-cli script that is read-only by default and gates every send behind a --dry-run/--yes confirmation. Use when an agent needs to read or post Slack data, triage incidents, or answer questions about what was said in a workspace. Do not use for building Slack apps or bots (that is application development) or workspace administration like user provisioning and org settings (that is the Slack admin console)."
|
||||
},
|
||||
{
|
||||
"name": "software-architecture-analysis",
|
||||
"source": "./",
|
||||
@@ -977,6 +1013,15 @@
|
||||
"strict": false,
|
||||
"description": "Structure organizational strategy work: strategic direction, competitive and industry analysis, growth options, capital allocation, acquisitions, and portfolio choices. Use when framing consequential choices about where to compete, how to pursue an opportunity, or how to compare strategic options."
|
||||
},
|
||||
{
|
||||
"name": "stripe",
|
||||
"source": "./",
|
||||
"skills": [
|
||||
"./stripe"
|
||||
],
|
||||
"strict": false,
|
||||
"description": "Read Stripe account state from a terminal or agent: balance, payment intents, and subscriptions — and perform guarded mutations like canceling a subscription — backed by a bundled stripe-cli script that is read-only first and gates every state-changing command behind a --dry-run/--yes confirmation. Use when an agent needs to answer questions about account balance, recent payments, active subscriptions, or apply a confirmed subscription cancellation. Do not use for building Stripe payments into an application (that is Stripe integration development), managing Stripe dashboard settings, or other payment processors (that is their own tooling)."
|
||||
},
|
||||
{
|
||||
"name": "supabase",
|
||||
"source": "./",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"./conditional-customer-success",
|
||||
"./confluence-cli",
|
||||
"./crewai",
|
||||
"./crm",
|
||||
"./crowdsec",
|
||||
"./cyberpunk",
|
||||
"./daily-life-discovery",
|
||||
@@ -54,6 +55,7 @@
|
||||
"./docker-compose",
|
||||
"./documents",
|
||||
"./dspy",
|
||||
"./email",
|
||||
"./epub",
|
||||
"./esp32-development",
|
||||
"./financial-modeling",
|
||||
@@ -89,6 +91,7 @@
|
||||
"./migration-engineering",
|
||||
"./ml-engineering",
|
||||
"./mobile-development",
|
||||
"./notion",
|
||||
"./nous-branding",
|
||||
"./open-knowledge-format",
|
||||
"./openlibrary-cli",
|
||||
@@ -125,9 +128,11 @@
|
||||
"./security-audit-methodology",
|
||||
"./seo-audit",
|
||||
"./site-reliability-engineering",
|
||||
"./slack",
|
||||
"./software-architecture-analysis",
|
||||
"./spec-driven-development",
|
||||
"./strategy-frameworks",
|
||||
"./stripe",
|
||||
"./supabase",
|
||||
"./systematic-debugging",
|
||||
"./technical-documentation",
|
||||
|
||||
@@ -88,6 +88,10 @@ Atlassian Confluence from the terminal. List spaces, browse pages, view content
|
||||
|
||||
An expert-level skill for building **role-based multi-agent teams** with CrewAI. Agents are defined as Roles with Goals and Backstories; crews are composed with sequential or hierarchical workflows.
|
||||
|
||||
### [crm](crm/SKILL.md)
|
||||
|
||||
Operate HubSpot CRM from the terminal: list and search contact records, view deal pipeline stages, and move deals between stages with confirmation. Ships a bounded stdlib CLI (contacts list/get/search, deals list/update-stage, pipelines list) with `--json`, `--limit`, and a `--dry-run`/`--yes` mutation gate for stage changes.
|
||||
|
||||
### [crowdsec](crowdsec/SKILL.md)
|
||||
|
||||
Deploy, configure, and manage CrowdSec — the open-source, collaborative IPS/IDPS/WAF. Covers Security Engine installation (Linux, Docker), cscli hub management, remediation components (firewall, Traefik, Nginx), AppSec WAF, profiles, notifications, blocklists, CTI, metrics, and production best practices. Ships 7 reference files for deep dives into config, AppSec, Docker, Traefik integration, database backends, hub collections, and troubleshooting.
|
||||
@@ -125,6 +129,10 @@ Generate, inspect, validate, and fix PDF, Word (.docx), Excel (.xlsx), and Power
|
||||
|
||||
An expert-level skill for **programmatic prompt optimization** with Stanford's DSPy framework. You write Python programs with typed signatures; DSPy optimizes the prompts automatically. This is the framework for prompt engineering that doesn't feel like engineering.
|
||||
|
||||
### [email](email/SKILL.md)
|
||||
|
||||
Send and diagnose transactional email through Twilio SendGrid: send with `--dry-run`/`--yes` confirmation, check deliverability (bounces and spam reports), and verify Signed Event Webhook signatures with a self-contained ECDSA P-256 verifier. Ships a bounded stdlib CLI with `--json` and `--limit`.
|
||||
|
||||
### [epub](epub/SKILL.md)
|
||||
|
||||
EPUB file format expert — read, write, edit, convert, and repair EPUB2/EPUB3 ebooks.
|
||||
@@ -274,6 +282,10 @@ Build, test, sign, and ship mobile apps across iOS, Android, Flutter, and React
|
||||
|
||||
Evidence-driven software delivery operating model — routes a change through framing, discovery, design, implementation, review, verification, delivery, and learning, choosing the smallest *safe* intervention and proving it at the real delivery boundary. Every non-trivial run leaves an inspectable evidence ledger. Composes the specialist catalog rather than replacing it. Ships a versioned evaluation harness that measures SDLC outcomes, never LOC. Not a "10x developer" persona.
|
||||
|
||||
### [notion](notion/SKILL.md)
|
||||
|
||||
Operate Notion from the terminal: retrieve pages, query databases, search pages and databases, and update page properties with confirmation. Ships a bounded stdlib CLI (pages get/create/update, databases query, search) with `--json`, `--limit`, and a `--dry-run`/`--yes` mutation gate for creates and updates.
|
||||
|
||||
### [nous-branding](nous-branding/SKILL.md)
|
||||
|
||||
Generate images and content consistent with the Nous Research brand identity.
|
||||
@@ -437,6 +449,10 @@ Identify search-discoverability problems through evidence, prioritize the work,
|
||||
|
||||
Build practical reliability practices around the work teams actually perform: measurable service objectives, useful alerts, incident response, and learning-oriented follow-up.
|
||||
|
||||
### [slack](slack/SKILL.md)
|
||||
|
||||
Operate Slack workspaces from the terminal: list channels, read messages, follow threads, search history, list files, and verify webhook signatures. Ships a bounded stdlib CLI with `--json`, `--limit`, and a `--dry-run`/`--yes` mutation gate for sends.
|
||||
|
||||
### [software-architecture-analysis](software-architecture-analysis/SKILL.md)
|
||||
|
||||
Reverse-engineer a software codebase to understand its architecture, data flow, privacy posture, and feature surface — then produce a clean-room design document, PRD, or migration plan under new constraints (local-first, privacy-first, self-hosted). Includes an interface extraction pattern for designing swappable storage provider abstractions.
|
||||
@@ -449,6 +465,10 @@ Spec-Driven Development (SDD) methodology for AI software factories — where st
|
||||
|
||||
Structure organizational strategy decisions about direction, industry structure, growth, capital allocation, acquisitions, and portfolios. Uses frameworks as prompts for evidence and trade-offs, not automatic recommendations.
|
||||
|
||||
### [stripe](stripe/SKILL.md)
|
||||
|
||||
Read Stripe account state from the terminal — balance, payment intents, and subscriptions — with guarded subscription cancellations. Ships a read-only-first stdlib CLI with `--json`, `--limit`, and a `--dry-run`/`--yes` mutation gate for cancellations (period-end default).
|
||||
|
||||
### [supabase](supabase/SKILL.md)
|
||||
|
||||
Develop, self-host, and administer Supabase across the CLI local stack, managed projects, and the official Docker deployment. Covers reproducible migrations, RLS and pgTAP testing, generated client types, Auth, REST, Realtime, Storage, Edge Functions, TLS, secrets, backups, restores, release-set upgrades, and evidence-led troubleshooting.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# CRM — Operate HubSpot from the Terminal
|
||||
|
||||
Look up contacts, search records, and view deal pipeline stages from your terminal or agent — and apply confirmed stage changes — all against HubSpot's CRM API.
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
CRM data is where the answers to "who is this person?" and "what is in the pipeline?" live, and agents have had no bounded way to reach it. This skill gives your agent a real read path into HubSpot (contact records, contact search, deal pipeline views, pipeline stage maps) and a safe write path: moving a deal between stages is a guarded mutation that requires a preview and an explicit confirmation, so the agent can answer sales questions without ever silently changing the pipeline.
|
||||
|
||||
It ships `crm-cli`, a small Python script that speaks the HubSpot CRM v3 API with no third-party dependencies. Reads are capped (`--limit`), output is clean JSON for the agent or readable text for you, and `--help` works with no token and no network. Records are summarized as the fields people actually ask about — name, email, company, amount, stage — instead of raw property maps.
|
||||
|
||||
## What You Get
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | Agent-facing operating contract, mutation gates, and verification boundaries |
|
||||
| `references/` | Dated source index and a HubSpot CRM operations reference (endpoints, object model, pagination, stage updates, errors) |
|
||||
| `scripts/crm-cli` | Bounded, stdlib-only CLI: contacts list/get/search, deals list/update-stage, pipelines list; `--json`, `--limit`, stage changes gated by `--dry-run`/`--yes` |
|
||||
| `tests/` | 13 deterministic tests against a stub HubSpot API, covering the mutation gate and read-only contract |
|
||||
| `evals/evals.json` | Six output-quality evaluation cases for agent runs |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Help works with no token and no network
|
||||
crm/scripts/crm-cli --help
|
||||
|
||||
# List contacts (capped)
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli --json --limit 20 contacts list
|
||||
|
||||
# Find a contact by name
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli --json contacts search --query "ada"
|
||||
|
||||
# View the deal pipeline (optionally filtered)
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli --json --limit 20 deals list
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli --json deals list --pipeline default --stage appointmentscheduled
|
||||
|
||||
# Resolve stage labels to IDs
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli --json pipelines list
|
||||
|
||||
# Move a deal only with a preview first, then explicit confirmation
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli deals update-stage --id 901 --stage closedwon --dry-run
|
||||
HUBSPOT_TOKEN=pat_... crm/scripts/crm-cli deals update-stage --id 901 --stage closedwon --yes
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
Load this skill for `hubspot` / `crm` operations: "who is this contact", searching contacts, what deals are in the pipeline, listing deals by stage, resolving pipeline stages, or moving a deal to a new stage with confirmation. Do not load it for building HubSpot apps or workflow automations, marketing automation, or other CRMs like Salesforce.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+ for `crm-cli` (stdlib only; `--help` needs nothing else).
|
||||
- A HubSpot private app access token (`HUBSPOT_TOKEN`) with object scopes: `crm.objects.contacts.read` and `crm.objects.deals.read` for reads, plus `crm.objects.deals.write` for stage updates.
|
||||
- Network access to `api.hubapi.com` for live reads and updates.
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: crm
|
||||
description: >-
|
||||
Operate HubSpot CRM from a terminal or agent: list and search contact
|
||||
records, view deal pipeline stages, and — with explicit confirmation — move
|
||||
deals between stages, backed by a bundled crm-cli script that is read-only
|
||||
by default and gates every stage change behind a --dry-run/--yes
|
||||
confirmation. Use when an agent needs to answer questions about contacts or
|
||||
deals, produce pipeline views, or apply a confirmed stage change. Do not use
|
||||
for building HubSpot apps or workflow automations (that is HubSpot app
|
||||
development), marketing/sequence automation, or other CRMs like Salesforce
|
||||
(that is their own tooling).
|
||||
license: MIT
|
||||
compatibility: >-
|
||||
The bundled crm-cli script runs on Python 3.9+ with only the standard
|
||||
library. --help and all reads need no network beyond api.hubapi.com; live
|
||||
reads require a HubSpot private app access token with the relevant object
|
||||
scopes (crm.objects.contacts.read, crm.objects.deals.read) and network
|
||||
access to api.hubapi.com.
|
||||
metadata:
|
||||
source: https://developers.hubspot.com/docs/api/crm/understanding-the-crm
|
||||
source_index: references/00-source-index.md
|
||||
research_checked: "2026-08-03"
|
||||
---
|
||||
|
||||
# HubSpot CRM Operations
|
||||
|
||||
Use this skill to read and, with explicit confirmation, update HubSpot CRM data through the HubSpot CRM v3 API: contact records, contact search, deal pipeline views, and deal stage changes. This is a **tool skill** for one CRM vendor (**HubSpot**). Building HubSpot apps or workflows is application development; this skill owns the everyday agent workflow: answering "who is this contact?", "what is in the pipeline?", and applying a confirmed stage change.
|
||||
|
||||
## Operating contract
|
||||
|
||||
1. **Read-only discovery before any mutation.** List and search contacts, view deals and pipelines freely. The bundled `crm-cli` script makes reads without writing anything.
|
||||
2. **Confirm the target, scope, and rollback path before acting.** Moving a deal to a new stage changes a shared pipeline that revenue reporting reads: it requires an explicit human directive naming the deal and the target stage, plus `--dry-run` preview and `--yes` confirmation through `crm-cli`. Stage moves are reversible but leave audit history — confirm before acting.
|
||||
3. **Respect bounded reads.** HubSpot pages with `limit`; never page past what the task needs. `crm-cli --limit` caps every listing and search.
|
||||
4. **Keep evidence bounded.** Quote short names, emails, amounts, and stage labels; never dump full records, tokens, or raw payloads into chat.
|
||||
5. **Know the object model.** Contacts and deals are distinct objects with property maps; stage transitions must use a stage ID from the deal's pipeline (`pipelines list`), not a stage label.
|
||||
|
||||
## The crm-cli script
|
||||
|
||||
`scripts/crm-cli` is an agent-first, stdlib-only CLI over the HubSpot CRM v3 API. It covers the full issue scope: records, search, and pipeline views.
|
||||
|
||||
```bash
|
||||
crm/scripts/crm-cli --help # no token or network needed
|
||||
crm/scripts/crm-cli --json --limit 20 contacts list
|
||||
crm/scripts/crm-cli --json contacts get --id 51
|
||||
crm/scripts/crm-cli --json contacts search --query "ada"
|
||||
crm/scripts/crm-cli --json --limit 20 deals list
|
||||
crm/scripts/crm-cli --json deals list --pipeline default --stage appointmentscheduled
|
||||
crm/scripts/crm-cli --json pipelines list
|
||||
crm/scripts/crm-cli deals update-stage --id 901 --stage closedwon --dry-run # preview
|
||||
crm/scripts/crm-cli deals update-stage --id 901 --stage closedwon --yes # confirmed
|
||||
```
|
||||
|
||||
Exit codes: 0 success, 1 API error or failed check, 2 usage error. Stage changes are guarded: without `--dry-run` or `--yes` the script refuses with exit 1 and never calls the API. Reads are bounded by `--limit` (default 20, max 100).
|
||||
|
||||
## Operating loop
|
||||
|
||||
1. **Scope the question**: is this a lookup (who/what is in the CRM) or a change (move a deal)? Locate the object with `contacts search`/`contacts list` or `deals list`.
|
||||
2. **Read with bounds**: `contacts get` for one record, `deals list` for the pipeline view (optionally filtered by pipeline and stage), `pipelines list` to resolve stage labels to IDs.
|
||||
3. **Triage the answer**: map the question to evidence (contact details, deal amount/stage, pipeline distribution).
|
||||
4. **Act with confirmation**: only a human directive to change, previewed with `--dry-run` and confirmed with `--yes`.
|
||||
5. **Verify**: re-read the deal (`deals list --stage <target>`) and confirm the stage moved.
|
||||
|
||||
## Records, search, pipeline views
|
||||
|
||||
- **Contact records** (`/objects/contacts`): list (GET) or retrieve one (GET by ID); the CLI summarizes first/last name, email, company, and created date. Search (`POST /objects/contacts/search`) finds contacts by query text, bounded by `--limit`.
|
||||
- **Deal pipeline views** (`/objects/deals`): list deals with amount, pipeline, and stage, optionally filtered to one pipeline or stage. `pipelines list` (`/pipelines/deals`) returns the pipelines with their stage IDs and labels — use the stage ID when filtering or updating.
|
||||
- **Stage changes** (`PATCH /objects/deals/{id}`): a guarded mutation that sets the `dealstage` property. Preview the target stage with `--dry-run`, confirm with `--yes`, and verify with a follow-up read. Only stage moves are in scope; other deal property edits are application work.
|
||||
|
||||
## Access model
|
||||
|
||||
- HubSpot private app access tokens (`pat_...`) scope per object and read/write. Reads need `crm.objects.contacts.read` and `crm.objects.deals.read`; stage updates need `crm.objects.deals.write`.
|
||||
- Records carry a `properties` map keyed by property names (e.g. `dealstage`, `dealname`, `amount`). Property values are strings; the CLI summarizes the fields this skill uses.
|
||||
- Tokens are credentials: store in `HUBSPOT_TOKEN`, never in code, chat, or commits. Rotate a leaked token in the private app settings.
|
||||
|
||||
## Reference routing
|
||||
|
||||
| Load when | Reference |
|
||||
|---|---|
|
||||
| Sources, scope tables, refresh procedure | `references/00-source-index.md` |
|
||||
| Endpoints, pagination, object model, stage updates, errors | `references/01-hubspot-crm-operations.md` |
|
||||
|
||||
## Included artifacts
|
||||
|
||||
- `scripts/crm-cli`: bounded, stdlib-only CLI (contacts list/get/search, deals list/update-stage, pipelines list; `--json`; `--limit`; mutations gated by `--dry-run`/`--yes`).
|
||||
- `tests/test_crm_cli.py`: 13 deterministic tests against a stub HubSpot API, including the mutation gate and the read-only contract.
|
||||
- `references/`: dated source index + HubSpot CRM operations reference.
|
||||
- `evals/evals.json`: six output-quality evaluation cases for agent runs.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
| Claim | Minimum evidence |
|
||||
|---|---|
|
||||
| A contact exists | `crm-cli contacts search --query ... --json` or `contacts get` returns the record |
|
||||
| A pipeline view is accurate | `crm-cli deals list --json` returns deals with stage IDs and the filter applied |
|
||||
| A stage label maps to an ID | `crm-cli pipelines list --json` returns the pipeline stage map |
|
||||
| A stage change landed | `crm-cli deals update-stage --yes` exits 0 and a follow-up `deals list --stage` shows the deal |
|
||||
| A mutation is safe to run | `crm-cli deals update-stage --dry-run` prints the exact deal + target stage |
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- Never move a deal without a human directive, `--dry-run` preview, and `--yes` confirmation — pipeline changes feed revenue reporting and audit history.
|
||||
- Never claim a record is missing when the token may lack object scope; check the access model first.
|
||||
- Never page reads past `--limit`; never dump full records, tokens, or raw payloads into chat.
|
||||
- This skill operates the HubSpot CRM API. It does not build HubSpot apps or cover other CRMs.
|
||||
|
||||
## When not to use
|
||||
|
||||
- **Building HubSpot apps, workflow automations, or custom objects** — that is HubSpot app development; see [backend-engineering](../backend-engineering/SKILL.md) for service design.
|
||||
- **Marketing, sequences, and email automation in HubSpot** — that is the HubSpot Marketing surface, not the CRM API this skill covers.
|
||||
- **Other CRMs** (Salesforce, Pipedrive, Zoho) — each has its own API and tooling; this skill covers HubSpot.
|
||||
- **CRM strategy, sales process design, or pipeline methodology** — that is organizational/strategy work, not an API operation.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "crm",
|
||||
"evals": [
|
||||
{
|
||||
"id": "contact-lookup",
|
||||
"prompt": "A user asks: 'Find the contact record for Ada Lovelace in HubSpot and show me her email and company.'",
|
||||
"expected_output": "Run crm-cli contacts search --query 'Ada Lovelace' (bounded) and report the matching contact's email, company, and record ID from the summarized properties. If multiple contacts match, list all candidates with IDs and ask the user to disambiguate. Reads only — nothing is changed.",
|
||||
"assertions": [
|
||||
"Contacts are searched via crm-cli contacts search with a bounded --limit",
|
||||
"The response reports email, company, and record ID for the match",
|
||||
"Multiple candidates are listed for disambiguation instead of picking arbitrarily",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pipeline-view",
|
||||
"prompt": "A user asks: 'Show me every deal in the default pipeline that is still in the Appointment Scheduled stage, with amounts.'",
|
||||
"expected_output": "Run crm-cli pipelines list to resolve the pipeline and stage IDs, then crm-cli deals list --pipeline <id> --stage <stage-id> (bounded) and report each deal's name, amount, and stage. The response confirms the filter used and notes the total count. Reads only — nothing is moved.",
|
||||
"assertions": [
|
||||
"Pipeline and stage IDs are resolved via crm-cli pipelines list before filtering",
|
||||
"Deals are listed with a pipeline and stage filter and a bounded --limit",
|
||||
"Each deal reports name, amount, and stage",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stage-update-confirmed",
|
||||
"prompt": "A user asks: 'Move deal 901 (Acme renewal) to Closed Won. It is currently in Appointment Scheduled.'",
|
||||
"expected_output": "The agent states the current stage and the target, previews the change with crm-cli deals update-stage --id 901 --stage closedwon --dry-run, and asks for explicit confirmation. Only after confirmation does it run the update with --yes, then verifies by re-reading the deal's stage. If the user only asked to draft the change, nothing is applied.",
|
||||
"assertions": [
|
||||
"The exact deal and target stage are previewed with --dry-run before any change",
|
||||
"The current stage and target stage are stated before confirmation",
|
||||
"The update runs only after explicit user confirmation, via --yes",
|
||||
"The result is verified with a follow-up read of the deal stage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "search-before-mutation",
|
||||
"prompt": "A user asks: 'Which deals have \"renewal\" in the name, and should we move the biggest one to Closed Won?'",
|
||||
"expected_output": "The agent performs the search as a read: lists deals with renewal-related names via crm-cli deals list and reports names, amounts, and stages, identifying the largest candidate. It does NOT move any deal on its own: a stage change is a guarded mutation requiring explicit confirmation with a --dry-run preview and --yes, so it asks the user to confirm which deal and to which stage before acting.",
|
||||
"assertions": [
|
||||
"The deal search is performed read-only via crm-cli deals list",
|
||||
"The largest candidate is identified with amount and stage",
|
||||
"No deal is moved without explicit confirmation and a --dry-run preview",
|
||||
"The mutation gate is explained to the user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pipeline-stage-mapping",
|
||||
"prompt": "A user asks: 'What stages exist in our deals pipeline and which one means a deal is won?'",
|
||||
"expected_output": "Run crm-cli pipelines list and report each pipeline with its stages: stage label plus stage ID. The response identifies the won/lost stages by their labels (e.g. Closed Won / Closed Lost) and explains that stage changes use the stage ID, not the label, because the API keys on the ID. Reads only.",
|
||||
"assertions": [
|
||||
"Pipelines and stages are read via crm-cli pipelines list",
|
||||
"Each stage is reported with both label and ID",
|
||||
"Won/lost stages are identified by label",
|
||||
"The response notes that updates use stage IDs, not labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "access-model-triage",
|
||||
"prompt": "A user asks: 'The API returns a 403 when I list deals. Did we lose our deals?'",
|
||||
"expected_output": "The response distinguishes the failure modes: a 403 from the HubSpot API means the private app token lacks the object scope (crm.objects.deals.read), not that the deals are gone. It advises checking the private app's scopes in the HubSpot settings, re-generating or re-granting the token scope, then re-running crm-cli deals list. It does not claim data loss, does not re-run blindly, and performs no mutations during triage.",
|
||||
"assertions": [
|
||||
"A 403 is explained as a scope problem rather than data loss",
|
||||
"The fix is checking and re-granting the private app object scopes",
|
||||
"Triage is read-only and no mutation is attempted",
|
||||
"Data loss is only concluded with concrete evidence"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# HubSpot CRM — Source Index
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
This skill is a distilled operating layer over HubSpot's public developer documentation. Facts and endpoint names in this skill are grounded in the sources below; refresh this index when HubSpot ships API changes.
|
||||
|
||||
| Topic | Source | URL |
|
||||
|---|---|---|
|
||||
| CRM object model | Understanding the CRM | https://developers.hubspot.com/docs/api/crm/understanding-the-crm |
|
||||
| Contacts API | Contacts | https://developers.hubspot.com/docs/api/crm/contacts |
|
||||
| Deals API | Deals | https://developers.hubspot.com/docs/api/crm/deals |
|
||||
| Deal pipelines API | Pipelines | https://developers.hubspot.com/docs/api/crm/pipelines |
|
||||
| Search API | Search | https://developers.hubspot.com/docs/api/crm/search |
|
||||
| Private apps and scopes | Private apps | https://developers.hubspot.com/docs/api/private-apps |
|
||||
|
||||
## Refresh procedure
|
||||
|
||||
- Re-check the object model when a 403 or `PROPERTY_DOES_NOT_EXIST` appears for a documented property; HubSpot object schemas evolve.
|
||||
- Re-check the pipelines API before changing anything in `deals update-stage`; stage IDs are pipeline-scoped.
|
||||
- Update `research_checked` in `SKILL.md` frontmatter and this file's `Last Updated` when you verify the sources again.
|
||||
@@ -0,0 +1,52 @@
|
||||
# HubSpot CRM Operations
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
Operational detail for the HubSpot CRM v3 API surface the skill owns: contact records, contact search, deal pipeline views, and guarded deal stage updates. The bundled `crm-cli` implements this reference; use this document when a call behaves unexpectedly.
|
||||
|
||||
## API conventions
|
||||
|
||||
- Base URL: `https://api.hubapi.com/crm/v3`. Every request carries `Authorization: Bearer <private_app_token>` and JSON bodies.
|
||||
- Access is scope-based: private app tokens grant per-object read/write. A 403 means the token lacks the scope — check `crm.objects.contacts.read`, `crm.objects.deals.read` (reads) and `crm.objects.deals.write` (stage updates) before concluding anything else.
|
||||
- `crm-cli` honors `HUBSPOT_API_BASE` (test/stub override); production default is the v3 base.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
| Operation | Endpoint | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| List contacts | `/objects/contacts?limit=N` | GET | Summarized as name, email, company, createdate |
|
||||
| Get a contact | `/objects/contacts/{id}` | GET | Single record |
|
||||
| Search contacts | `/objects/contacts/search` | POST | JSON body `{"query": ..., "limit": N}` |
|
||||
| List deals | `/objects/deals?limit=N` (+ `pipeline`, `dealstage`) | GET | Pipeline view with amount + stage |
|
||||
| Move a deal | `/objects/deals/{id}` | PATCH | Guarded mutation: sets the `dealstage` property |
|
||||
| List pipelines | `/pipelines/deals` | GET | Pipelines with stage IDs and labels |
|
||||
|
||||
## Object model
|
||||
|
||||
- Every object is an `id` plus a `properties` map keyed by property name. Property values are strings in list/search responses (e.g. `dealstage`, `dealname`, `amount`, `email`, `firstname`, `lastname`, `company`).
|
||||
- Deals belong to a pipeline (`pipeline` property) and a stage (`dealstage` property) whose valid values come from `/pipelines/deals`. **Stage changes use the stage ID, never the label.**
|
||||
- Search (`POST /objects/contacts/search`) accepts a `query` string and a `limit`; it matches across default contact searchable properties.
|
||||
|
||||
## Pagination and bounded reads
|
||||
|
||||
- List endpoints return `results` plus `total` and `paging.next.after` (offset cursor). `limit` caps per-request results (max 100 for most object APIs).
|
||||
- **Bounded-read rule:** request only what the task needs; `crm-cli --limit` caps at the request level. Report the `total` alongside the returned results so the reader knows the cap hid further records.
|
||||
|
||||
## Guarded stage updates
|
||||
|
||||
- Moving a deal sets `{"properties": {"dealstage": "<stage-id>"}}` via PATCH. Preview with `--dry-run` (prints the exact deal + target stage), confirm with `--yes`, then verify by re-reading the deal.
|
||||
- Stage moves are visible to the whole revenue team and land in audit history. They are reversible, but every move is a recorded change — confirm before acting.
|
||||
- Only the `dealstage` property is in this skill's mutation surface. Other deal property edits are application work.
|
||||
|
||||
## Error handling
|
||||
|
||||
- 401 `unauthorized`: token invalid or revoked — rotate the private app token.
|
||||
- 403 `forbidden`: token lacks object scope — grant the scope in the private app settings, don't retry blindly.
|
||||
- 404 `not found`: object does not exist **or** the token cannot see it — verify object ID and scope before concluding deletion.
|
||||
- 429 `RATE_LIMIT`: slow down; HubSpot rate limits per token.
|
||||
- `crm-cli` exit 1 with `HubSpot API HTTP <code>: <message>` (human) or `{"ok": false, "error": "..."}` (JSON). Exit 2 is a usage error.
|
||||
|
||||
## Credential hygiene
|
||||
|
||||
- Private app tokens are full object-scope credentials: store in `HUBSPOT_TOKEN`, never in code, chat, or commits. Scope tokens to the objects the task needs and rotate on leak.
|
||||
- Personal data (emails, names, amounts) lives in CRM records; quote only what the question needs and never dump full records into chat.
|
||||
Executable
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""crm-cli - bounded, agent-first command line for the HubSpot CRM API.
|
||||
|
||||
Reads HubSpot CRM records (contacts, deals) and, with explicit confirmation,
|
||||
updates deal stages over HTTPS using only the Python standard library. Covers
|
||||
records, search, and pipeline views.
|
||||
|
||||
Design rules:
|
||||
|
||||
- Read-only by default. Every state-changing command (moving a deal to a new
|
||||
stage) is a guarded mutation: it requires --dry-run to preview, then --yes
|
||||
to confirm. Mutation requires explicit confirmation.
|
||||
- Bounded reads: every listing and search caps results with --limit and never
|
||||
pages past the requested cap.
|
||||
- --json emits machine-readable JSON; the default is human-readable text.
|
||||
- --help works with no HUBSPOT_TOKEN set and makes no network calls.
|
||||
|
||||
Environment:
|
||||
HUBSPOT_TOKEN HubSpot private app access token
|
||||
|
||||
Exit codes: 0 success, 1 HubSpot API error or failed check, 2 usage error.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
API_BASE = os.environ.get("HUBSPOT_API_BASE", "https://api.hubapi.com/crm/v3")
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
REQUEST_TIMEOUT = 15
|
||||
TEXT_TRUNCATE = 500
|
||||
|
||||
|
||||
class CrmError(Exception):
|
||||
"""Raised when the HubSpot API returns an error or transport fails."""
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
token = os.environ.get("HUBSPOT_TOKEN", "")
|
||||
if not token:
|
||||
raise CrmError("HUBSPOT_TOKEN environment variable is not set")
|
||||
return token
|
||||
|
||||
|
||||
def api_request(method: str, path: str, token: str,
|
||||
body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/{path.lstrip('/')}",
|
||||
data=data,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.loads(error.read().decode("utf-8")).get("message", "")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
raise CrmError(f"HubSpot API HTTP {error.code}: {detail or error.reason}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise CrmError(f"HubSpot API unreachable: {error.reason}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise CrmError(f"HubSpot API returned non-JSON: {error}") from error
|
||||
|
||||
|
||||
def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def summarize_contact(contact: Dict[str, Any]) -> Dict[str, Any]:
|
||||
properties = contact.get("properties", {})
|
||||
return {
|
||||
"id": contact.get("id", ""),
|
||||
"firstname": properties.get("firstname", ""),
|
||||
"lastname": properties.get("lastname", ""),
|
||||
"email": properties.get("email", ""),
|
||||
"company": properties.get("company", ""),
|
||||
"createdate": properties.get("createdate", ""),
|
||||
}
|
||||
|
||||
|
||||
def summarize_deal(deal: Dict[str, Any]) -> Dict[str, Any]:
|
||||
properties = deal.get("properties", {})
|
||||
return {
|
||||
"id": deal.get("id", ""),
|
||||
"dealname": truncate(properties.get("dealname", "")),
|
||||
"amount": properties.get("amount", ""),
|
||||
"pipeline": properties.get("pipeline", ""),
|
||||
"dealstage": properties.get("dealstage", ""),
|
||||
"hs_lastmodifieddate": properties.get("hs_lastmodifieddate", ""),
|
||||
}
|
||||
|
||||
|
||||
def summarize_pipeline(pipeline: Dict[str, Any]) -> Dict[str, Any]:
|
||||
stages = []
|
||||
for stage in pipeline.get("stages", []):
|
||||
stages.append({"id": stage.get("id", ""), "label": stage.get("label", ""),
|
||||
"displayOrder": stage.get("displayOrder")})
|
||||
return {"id": pipeline.get("id", ""), "label": pipeline.get("label", ""),
|
||||
"stages": stages}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _contacts_common(args: argparse.Namespace, token: str, fields: Dict[str, str]) -> Dict[str, Any]:
|
||||
query = "&".join(f"{key}={urllib.parse.quote(value)}" for key, value in fields.items())
|
||||
payload = api_request("GET", f"objects/contacts?{query}", token)
|
||||
contacts = [summarize_contact(c) for c in payload.get("results", [])]
|
||||
return {"ok": True, "contacts": contacts, "total": payload.get("total", len(contacts)),
|
||||
"paging": payload.get("paging", {})}
|
||||
|
||||
|
||||
def cmd_contacts_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
return _contacts_common(args, token, {"limit": str(args.limit)})
|
||||
|
||||
|
||||
def cmd_contacts_get(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
payload = api_request("GET", f"objects/contacts/{args.contact_id}", token)
|
||||
return {"ok": True, "contact": summarize_contact(payload)}
|
||||
|
||||
|
||||
def cmd_contacts_search(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
payload = api_request("POST", "objects/contacts/search", token,
|
||||
{"query": args.query, "limit": args.limit})
|
||||
contacts = [summarize_contact(c) for c in payload.get("results", [])]
|
||||
return {"ok": True, "query": args.query, "contacts": contacts,
|
||||
"total": payload.get("total", len(contacts))}
|
||||
|
||||
|
||||
def cmd_deals_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
fields = {"limit": str(args.limit)}
|
||||
if args.pipeline:
|
||||
fields["pipeline"] = args.pipeline
|
||||
if args.stage:
|
||||
fields["dealstage"] = args.stage
|
||||
query = "&".join(f"{key}={urllib.parse.quote(value)}" for key, value in fields.items())
|
||||
payload = api_request("GET", f"objects/deals?{query}", token)
|
||||
deals = [summarize_deal(d) for d in payload.get("results", [])]
|
||||
return {"ok": True, "deals": deals, "total": payload.get("total", len(deals)),
|
||||
"paging": payload.get("paging", {})}
|
||||
|
||||
|
||||
def cmd_deals_update_stage(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
if not args.dry_run and not args.yes:
|
||||
raise CrmError(
|
||||
"refusing to move a deal without confirmation: pass --dry-run to "
|
||||
"preview or --yes to confirm the mutation"
|
||||
)
|
||||
body = {"properties": {"dealstage": args.stage}}
|
||||
if args.dry_run:
|
||||
return {"ok": True, "dry_run": True, "would_update": {
|
||||
"deal_id": args.deal_id, "dealstage": args.stage}}
|
||||
payload = api_request("PATCH", f"objects/deals/{args.deal_id}", token, body)
|
||||
return {"ok": True, "deal": summarize_deal(payload)}
|
||||
|
||||
|
||||
def cmd_pipelines_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
payload = api_request("GET", "pipelines/deals", token)
|
||||
pipelines = [summarize_pipeline(p) for p in payload.get("results", [])]
|
||||
return {"ok": True, "pipelines": pipelines, "count": len(pipelines)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Output helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def emit(data: Dict[str, Any], json_mode: bool) -> int:
|
||||
if json_mode:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
_emit_human(data)
|
||||
return 0
|
||||
|
||||
|
||||
def _emit_human(data: Dict[str, Any]) -> None:
|
||||
if "contact" in data and "dry_run" not in data:
|
||||
contact = data["contact"]
|
||||
name = f"{contact['firstname']} {contact['lastname']}".strip()
|
||||
print(f"{name or contact['email'] or contact['id']} <{contact['id']}> {contact['email']}")
|
||||
elif "contacts" in data:
|
||||
print(f"contacts ({data['total']}):")
|
||||
for contact in data["contacts"]:
|
||||
name = f"{contact['firstname']} {contact['lastname']}".strip()
|
||||
print(f" {name or contact['email'] or contact['id']} <{contact['id']}>")
|
||||
elif "deals" in data:
|
||||
print(f"deals ({data['total']}):")
|
||||
for deal in data["deals"]:
|
||||
print(f" {deal['dealname']} ${deal['amount']} stage={deal['dealstage']} <{deal['id']}>")
|
||||
elif "pipelines" in data:
|
||||
print(f"deals pipelines ({data['count']}):")
|
||||
for pipeline in data["pipelines"]:
|
||||
labels = ", ".join(f"{s['label']}({s['id']})" for s in pipeline["stages"])
|
||||
print(f" {pipeline['label']} <{pipeline['id']}>: {labels}")
|
||||
elif data.get("dry_run"):
|
||||
print("DRY RUN (no change):")
|
||||
print(f" deal: {data['would_update']['deal_id']}")
|
||||
print(f" stage: {data['would_update']['dealstage']}")
|
||||
elif "deal" in data:
|
||||
deal = data["deal"]
|
||||
print(f"updated deal {deal['dealname']} -> stage {deal['dealstage']}")
|
||||
else:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="crm-cli",
|
||||
description=(
|
||||
"Bounded, agent-first CLI for the HubSpot CRM API: contact records, "
|
||||
"contact search, deal pipeline views, and guarded deal stage "
|
||||
"updates. Read-only by default; stage changes require --dry-run "
|
||||
"then --yes."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
|
||||
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
contacts = sub.add_parser("contacts", help="contact records and search (read-only)")
|
||||
contact_sub = contacts.add_subparsers(dest="action", required=True)
|
||||
contact_list = contact_sub.add_parser("list", help="list contacts")
|
||||
contact_get = contact_sub.add_parser("get", help="get one contact")
|
||||
contact_get.add_argument("--id", dest="contact_id", required=True, help="contact ID")
|
||||
contact_search = contact_sub.add_parser("search", help="search contacts")
|
||||
contact_search.add_argument("--query", required=True, help="search query text")
|
||||
|
||||
deals = sub.add_parser("deals", help="deal pipeline views and updates")
|
||||
deal_sub = deals.add_subparsers(dest="action", required=True)
|
||||
deal_list = deal_sub.add_parser("list", help="list deals in the pipeline view (read-only)")
|
||||
deal_list.add_argument("--pipeline", help="filter to a pipeline ID")
|
||||
deal_list.add_argument("--stage", help="filter to a dealstage ID")
|
||||
deal_update = deal_sub.add_parser("update-stage", help="move a deal to a stage (guarded mutation)")
|
||||
deal_update.add_argument("--id", dest="deal_id", required=True, help="deal ID")
|
||||
deal_update.add_argument("--stage", required=True, help="target dealstage ID")
|
||||
deal_update.add_argument("--dry-run", action="store_true", help="preview the change without applying")
|
||||
deal_update.add_argument("--yes", action="store_true", help="confirm the mutation and apply")
|
||||
|
||||
pipelines = sub.add_parser("pipelines", help="list deal pipelines and stages (read-only)")
|
||||
pipelines.add_argument("action", nargs="?", default="list", choices=["list"])
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
|
||||
try:
|
||||
token = get_token()
|
||||
if args.command == "contacts":
|
||||
if args.action == "list":
|
||||
result = cmd_contacts_list(args, token)
|
||||
elif args.action == "get":
|
||||
result = cmd_contacts_get(args, token)
|
||||
else:
|
||||
result = cmd_contacts_search(args, token)
|
||||
elif args.command == "deals":
|
||||
if args.action == "list":
|
||||
result = cmd_deals_list(args, token)
|
||||
else:
|
||||
result = cmd_deals_update_stage(args, token)
|
||||
elif args.command == "pipelines":
|
||||
result = cmd_pipelines_list(args, token)
|
||||
else: # pragma: no cover - argparse prevents this
|
||||
parser.error(f"unknown command: {args.command}")
|
||||
return emit(result, args.json)
|
||||
except CrmError as error:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"crm-cli: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for crm/scripts/crm-cli.
|
||||
|
||||
Runs the script as a subprocess so the tests exercise the real CLI surface
|
||||
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
|
||||
stdlib HTTP server stubs the HubSpot CRM v3 API (contacts, deals, pipelines,
|
||||
search), so no external network or HubSpot account is needed. Also asserts the
|
||||
read-only contract: reads never call write methods, and the mutation gate
|
||||
refuses to move a deal without --dry-run or --yes.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "crm-cli"
|
||||
|
||||
CONTACT = {"id": "51", "properties": {"firstname": "Ada", "lastname": "Lovelace",
|
||||
"email": "ada@example.com", "company": "Analytical",
|
||||
"createdate": "2026-01-01T00:00:00Z"}}
|
||||
DEAL = {"id": "901", "properties": {"dealname": "Acme renewal", "amount": "12000",
|
||||
"pipeline": "default", "dealstage": "appointmentscheduled",
|
||||
"hs_lastmodifieddate": "2026-01-02T00:00:00Z"}}
|
||||
PIPELINE = {"id": "default", "label": "Default pipeline",
|
||||
"stages": [{"id": "appointmentscheduled", "label": "Appointment Scheduled",
|
||||
"displayOrder": 0}]}
|
||||
|
||||
|
||||
class StubHubSpotServer:
|
||||
"""Minimal stub of the HubSpot CRM v3 API surface used by crm-cli."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests = [] # (method, path, body)
|
||||
handler = self._make_handler()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def _make_handler(self):
|
||||
stub = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||||
|
||||
def _json(self, payload, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
stub.requests.append(("GET", self.path, None))
|
||||
if "/objects/contacts?" in self.path:
|
||||
self._json({"results": [CONTACT], "total": 1, "paging": {}})
|
||||
elif "/objects/contacts/" in self.path:
|
||||
self._json(CONTACT)
|
||||
elif "/objects/deals?" in self.path:
|
||||
self._json({"results": [DEAL], "total": 1, "paging": {}})
|
||||
elif "/pipelines/deals" in self.path:
|
||||
self._json({"results": [PIPELINE]})
|
||||
else:
|
||||
self._json({"message": "not_found"}, 404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
body = self._read_body()
|
||||
stub.requests.append(("POST", self.path, body))
|
||||
if "/search" in self.path:
|
||||
limit = body.get("limit", 20)
|
||||
self._json({"results": [CONTACT][:limit], "total": 1})
|
||||
else:
|
||||
self._json({"message": "not_found"}, 404)
|
||||
|
||||
def do_PATCH(self): # noqa: N802
|
||||
body = self._read_body()
|
||||
stub.requests.append(("PATCH", self.path, body))
|
||||
if "/objects/deals/" in self.path:
|
||||
updated = json.loads(json.dumps(DEAL))
|
||||
updated["properties"]["dealstage"] = body.get("properties", {}).get("dealstage")
|
||||
self._json(updated)
|
||||
else:
|
||||
self._json({"message": "not_found"}, 404)
|
||||
|
||||
def log_message(self, *args): # silence stderr
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
def run_script(env, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def base_env(stub):
|
||||
env = dict(os.environ)
|
||||
env["HUBSPOT_TOKEN"] = "pat-test"
|
||||
env["HUBSPOT_API_BASE"] = f"http://127.0.0.1:{stub.port}/"
|
||||
return env
|
||||
|
||||
|
||||
def load_json(proc):
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
class CrmCliTests(unittest.TestCase):
|
||||
def test_help_lists_json_and_bounded_reads(self):
|
||||
proc = run_script(dict(os.environ), "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
self.assertIn("--json", proc.stdout)
|
||||
self.assertIn("--limit", proc.stdout)
|
||||
|
||||
def test_help_works_without_token(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("HUBSPOT_TOKEN", None)
|
||||
proc = run_script(env, "contacts", "list", "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
|
||||
def test_contacts_list(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "--limit", "5", "contacts", "list")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["contacts"][0]["email"], "ada@example.com")
|
||||
|
||||
def test_contacts_get(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "contacts", "get", "--id", "51")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertEqual(load_json(proc)["contact"]["id"], "51")
|
||||
|
||||
def test_contacts_search(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "contacts", "search", "--query", "ada")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["total"], 1)
|
||||
self.assertEqual(data["contacts"][0]["firstname"], "Ada")
|
||||
|
||||
def test_search_sends_limit(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
run_script(base_env(stub), "--json", "--limit", "3", "contacts", "search",
|
||||
"--query", "ada")
|
||||
posts = [body for method, path, body in stub.requests
|
||||
if method == "POST" and "/search" in path]
|
||||
self.assertEqual(len(posts), 1)
|
||||
self.assertEqual(posts[0].get("limit"), 3)
|
||||
|
||||
def test_deals_list_pipeline_view(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "deals", "list",
|
||||
"--pipeline", "default", "--stage", "appointmentscheduled")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["deals"][0]["dealname"], "Acme renewal")
|
||||
gets = [path for method, path, _ in stub.requests if method == "GET"]
|
||||
self.assertTrue(any("objects/deals?" in path for path in gets))
|
||||
|
||||
def test_pipelines_list(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "pipelines", "list")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["pipelines"][0]["id"], "default")
|
||||
self.assertEqual(data["pipelines"][0]["stages"][0]["id"], "appointmentscheduled")
|
||||
|
||||
def test_update_stage_requires_confirmation(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "deals", "update-stage", "--id", "901",
|
||||
"--stage", "closedwon")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("refusing to move", proc.stderr)
|
||||
self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
|
||||
|
||||
def test_update_stage_dry_run_does_not_patch(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "deals", "update-stage", "--id", "901",
|
||||
"--stage", "closedwon", "--dry-run")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["dry_run"])
|
||||
self.assertEqual(stub.requests, [], "dry-run must not reach the API")
|
||||
|
||||
def test_update_stage_with_yes_patches(self):
|
||||
with StubHubSpotServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "deals", "update-stage", "--id", "901",
|
||||
"--stage", "closedwon", "--yes")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["deal"]["dealstage"], "closedwon")
|
||||
self.assertTrue(any(method == "PATCH" for method, _path, _body in stub.requests))
|
||||
|
||||
def test_missing_token_errors_cleanly(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("HUBSPOT_TOKEN", None)
|
||||
proc = run_script(env, "--json", "contacts", "list")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("HUBSPOT_TOKEN", proc.stdout)
|
||||
|
||||
def test_read_only_contract_no_write_opens(self):
|
||||
source = SCRIPT.read_text()
|
||||
writes = [line for line in source.splitlines()
|
||||
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
|
||||
self.assertEqual(writes, [], "script must never open files in write mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
# Email — Send and Diagnose Transactional Email (SendGrid)
|
||||
|
||||
Send transactional email through Twilio SendGrid from your terminal or agent: send with confirmation, check deliverability (bounces and spam reports), and verify that inbound webhook events are authentically signed by SendGrid.
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
Transactional email is how services talk to users — password resets, receipts, alerts — and agents have had no bounded way to send or diagnose it. This skill gives your agent a real send path that is safe by design: every send is a guarded mutation requiring a preview (`--dry-run`) and an explicit confirmation (`--yes`), so no mail goes out by accident. It also gives a read path for the two signals that matter for delivery health: bounces and spam complaints.
|
||||
|
||||
It ships `email-cli`, a small Python script that speaks the SendGrid v3 API with no third-party dependencies. The webhook verifier is fully self-contained: it validates SendGrid's Signed Event Webhook (ECDSA P-256 signatures) using only the Python standard library, with a replay-window timestamp check, so your event handler can prove a delivery or bounce event really came from SendGrid.
|
||||
|
||||
## What You Get
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | Agent-facing operating contract, mutation gates, and verification boundaries |
|
||||
| `references/` | Dated source index and a SendGrid operations reference (API surface, deliverability semantics, webhook verification) |
|
||||
| `scripts/email-cli` | Bounded, stdlib-only CLI: send, deliverability bounces/spam-reports, webhook verify (self-contained ECDSA P-256); `--json`, `--limit`, sends gated by `--dry-run`/`--yes` |
|
||||
| `tests/` | 15 deterministic tests against a stub SendGrid API plus OpenSSL-generated ECDSA vectors cross-checking the verifier |
|
||||
| `evals/evals.json` | Six output-quality evaluation cases for agent runs |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Help works with no API key and no network
|
||||
email/scripts/email-cli --help
|
||||
|
||||
# Who bounced recently? (capped at 20)
|
||||
SENDGRID_API_KEY=SG.xxx email/scripts/email-cli --json --limit 20 deliverability bounces
|
||||
|
||||
# Spam complaints
|
||||
SENDGRID_API_KEY=SG.xxx email/scripts/email-cli --json --limit 20 deliverability spam-reports
|
||||
|
||||
# Send only with a preview first, then explicit confirmation
|
||||
SENDGRID_API_KEY=SG.xxx email/scripts/email-cli send --to user@example.com \
|
||||
--from no-reply@example.com --subject "Password reset" --body "Click the link" --dry-run
|
||||
SENDGRID_API_KEY=SG.xxx email/scripts/email-cli send --to user@example.com \
|
||||
--from no-reply@example.com --subject "Password reset" --body "Click the link" --yes
|
||||
|
||||
# Verify an inbound Signed Event Webhook before trusting it
|
||||
email/scripts/email-cli webhook verify --body-file body.json \
|
||||
--signature "MEUC..." --timestamp 1712345678 --public-key-file public-key.pem
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
Load this skill for `email` operations: sending a transactional email (with confirmation), checking why mail bounced, looking up spam complaints and suppressions, or verifying a SendGrid `X-Twilio-Email-Event-Webhook-Signature`. Do not load it for marketing campaigns, other email providers (Postmark, SES, Mailgun), or building an email feature inside an application.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+ for `email-cli` (stdlib only; `--help`, deliverability reads, and webhook verification need nothing else).
|
||||
- A SendGrid API key (`SENDGRID_API_KEY`) with `mail.send` access to send and suppression read access for deliverability checks.
|
||||
- A verified sender identity for the from-address you use.
|
||||
- For webhook verification: the webhook public key (shown in Event Webhook settings when Signed Webhook is enabled) saved as a PEM file.
|
||||
- Network access to `api.sendgrid.com` for live sends and deliverability reads.
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: email
|
||||
description: >-
|
||||
Send and diagnose transactional email through Twilio SendGrid from a
|
||||
terminal or agent: send messages, check deliverability (bounces and spam
|
||||
reports), and verify Signed Event Webhook signatures (ECDSA P-256) — with a
|
||||
bundled email-cli script that is read-only by default and gates every send
|
||||
behind a --dry-run/--yes confirmation. Use when an agent needs to send a
|
||||
transactional email, triage bounces or spam complaints, or confirm an
|
||||
inbound SendGrid webhook is authentic. Do not use for marketing or bulk
|
||||
email campaigns (that is SendGrid Marketing Campaigns), building email
|
||||
template systems, or other email providers (that is their own tooling).
|
||||
license: MIT
|
||||
compatibility: >-
|
||||
The bundled email-cli script runs on Python 3.9+ with only the standard
|
||||
library, including the self-contained ECDSA P-256 webhook verifier. --help,
|
||||
deliverability checks, and signature verification need no network; sending
|
||||
requires a SendGrid API key with mail.send access and network access to
|
||||
api.sendgrid.com.
|
||||
metadata:
|
||||
source: https://www.twilio.com/docs/sendgrid
|
||||
source_index: references/00-source-index.md
|
||||
research_checked: "2026-08-03"
|
||||
---
|
||||
|
||||
# Transactional Email Operations (SendGrid)
|
||||
|
||||
Use this skill to send transactional email through **Twilio SendGrid** and to diagnose delivery: bounces, spam complaints, and the Signed Event Webhook. This is a **tool skill** for one vendor (SendGrid). Marketing campaigns, template builders, and other providers are out of scope; this skill owns the operational loop for application-triggered email: send it, check it landed, and verify the events claiming so are authentic.
|
||||
|
||||
## Operating contract
|
||||
|
||||
1. **Read-only discovery before any mutation.** Check deliverability signals (bounces, spam reports) freely. The bundled `email-cli` script makes reads without writing anything.
|
||||
2. **Confirm the target, scope, and rollback path before acting.** Sending email puts words in recipients' inboxes in your organization's name: it requires an explicit human directive naming the recipients, sender, and content, plus `--dry-run` preview and `--yes` confirmation through `email-cli`. There is no reliable "un-send" for delivered mail.
|
||||
3. **Respect bounded reads.** Suppression listings cap results with `--limit`; never page past what the task needs.
|
||||
4. **Verify webhooks before trusting them.** SendGrid's Signed Event Webhook signs every request; verify the ECDSA signature and timestamp before acting on event data. Unverified webhook endpoints accept forged delivery/bounce events.
|
||||
5. **Keep evidence bounded.** Quote short message previews and email addresses; never paste API keys, full message bodies, or suppression lists into chat.
|
||||
6. **Never send to unverified addresses or real users without a directive.** Deliverability triage reads are safe; the send path is always gated.
|
||||
|
||||
## The email-cli script
|
||||
|
||||
`scripts/email-cli` is an agent-first, stdlib-only CLI over the SendGrid v3 API, including a self-contained ECDSA P-256 signature verifier with no third-party crypto dependency.
|
||||
|
||||
```bash
|
||||
email/scripts/email-cli --help # no key or network needed
|
||||
email/scripts/email-cli --json --limit 20 deliverability bounces
|
||||
email/scripts/email-cli --json --limit 20 deliverability spam-reports
|
||||
email/scripts/email-cli send --to user@example.com --from no-reply@example.com \
|
||||
--subject "Password reset" --body "..." --dry-run # preview only
|
||||
email/scripts/email-cli send --to user@example.com --from no-reply@example.com \
|
||||
--subject "Password reset" --body "..." --yes # confirmed send
|
||||
email/scripts/email-cli webhook verify --body-file body.json \
|
||||
--signature "MEUC..." --timestamp 1712345678 --public-key-file public-key.pem
|
||||
```
|
||||
|
||||
Exit codes: 0 success, 1 API error or failed verification, 2 usage error. Sends are guarded: without `--dry-run` or `--yes` the script refuses with exit 1 and never calls the API. Reads are bounded by `--limit` (default 20, max 100).
|
||||
|
||||
## Operating loop
|
||||
|
||||
1. **Scope the delivery question**: is this a send (mutation) or a deliverability investigation (read)? Who is the sender, who receives, what is the content?
|
||||
2. **Read with bounds**: `deliverability bounces` and `deliverability spam-reports` to see who failed to receive and why.
|
||||
3. **Triage the signal**: map the evidence to the cause (hard bounce → bad or typo'd address; spam complaint → content or frequency problem; suppression list → prior bounce). Check SendGrid's event webhook payloads for delivery/bounce events — after verifying the signature.
|
||||
4. **Act with confirmation**: only a human directive to send, previewed with `--dry-run` and confirmed with `--yes`.
|
||||
5. **Verify**: confirm the send response (`x-message-id`), then later confirm delivery via webhook/API evidence rather than assuming.
|
||||
|
||||
## Sending transactional email
|
||||
|
||||
- **Compose the message**: verified sender (`from`), one or more `to` recipients, `subject`, plain-text `body`, and optionally an `html` body. SendGrid requires the from-address to be a verified sender identity on your account.
|
||||
- **Preview before sending**: `--dry-run` prints the exact from/to/subject/body preview and never calls the API. `--yes` confirms and posts to `POST /v3/mail/send`, which returns 202 Accepted with the `x-message-id` header.
|
||||
- **Do not send secrets or tokens by email.** Email is a leak channel; a password-reset link is fine, a raw credential is not.
|
||||
- SendGrid returns 202 (accepted) — acceptance is not delivery. Confirm delivery from the event webhook or the activity feed before claiming success.
|
||||
|
||||
## Deliverability checks
|
||||
|
||||
- **Bounces** (`GET /suppression/bounces`): recipients whose mail bounced, with reason and status. Hard bounces (5.x.x permanent) indicate invalid addresses; repeated bounces hurt sender reputation.
|
||||
- **Spam reports** (`GET /suppression/spam_reports`): recipients who marked mail as spam. Frequent complaints indicate a content or targeting problem.
|
||||
- Deliverability triage is read-only: diagnose from the suppression lists and webhook events, then change the *next* send (a mutation) only with confirmation.
|
||||
|
||||
## Webhook signature verification
|
||||
|
||||
SendGrid's Signed Event Webhook signs each request with an ECDSA key pair. To verify:
|
||||
|
||||
1. Take the **exact raw request body** bytes — any re-encoding breaks the signature.
|
||||
2. Check `X-Twilio-Email-Event-Webhook-Timestamp` is recent (replay protection; `email-cli` default window 300s, disable with `--max-age 0`).
|
||||
3. Compute SHA-256 over `timestamp + raw body` (bytes concatenated, no separator) and verify the ECDSA P-256 signature (`X-Twilio-Email-Event-Webhook-Signature`, base64-decoded ASN.1 DER) against the webhook public key.
|
||||
4. Reject with 401 if the timestamp is stale or the signature does not verify.
|
||||
|
||||
`email-cli webhook verify --body-file body.json --signature <header> --timestamp <header> --public-key-file public-key.pem` runs exactly this check with a stdlib-only P-256 implementation. The public key is shown in the Event Webhook settings when Signed Webhook is enabled; store it as a file, never in code.
|
||||
|
||||
## Reference routing
|
||||
|
||||
| Load when | Reference |
|
||||
|---|---|
|
||||
| Sources, refresh procedure | `references/00-source-index.md` |
|
||||
| API surface, webhook verification details, deliverability semantics | `references/01-sendgrid-operations.md` |
|
||||
|
||||
## Included artifacts
|
||||
|
||||
- `scripts/email-cli`: bounded, stdlib-only CLI (send, deliverability bounces/spam-reports, webhook verify with self-contained ECDSA P-256; `--json`; `--limit`; send gated by `--dry-run`/`--yes`).
|
||||
- `tests/test_email_cli.py`: 15 deterministic tests against a stub SendGrid API plus OpenSSL-generated ECDSA webhook vectors (independent cross-check of the verifier).
|
||||
- `references/`: dated source index + SendGrid operations reference.
|
||||
- `evals/evals.json`: six output-quality evaluation cases for agent runs.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
| Claim | Minimum evidence |
|
||||
|---|---|
|
||||
| A message was accepted | `email-cli send --yes` exits 0 and returns the `x-message-id` |
|
||||
| A recipient bounced | `email-cli deliverability bounces --json` lists the address with reason and status |
|
||||
| A webhook is authentic | `email-cli webhook verify` exits 0 with `verified: true` for the exact body/signature/timestamp |
|
||||
| Delivery actually happened | Delivery webhook event (verified) or activity feed shows the message as delivered |
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- Never send email without a human directive, `--dry-run` preview, and `--yes` confirmation — sent mail is durable, external, and in recipients' inboxes.
|
||||
- Never trust an inbound webhook without ECDSA signature and timestamp verification.
|
||||
- Never page reads past `--limit`; never dump full message bodies, API keys, or suppression lists into chat.
|
||||
- This skill covers SendGrid transactional email only. Marketing campaigns, template systems, and other providers are out of scope.
|
||||
|
||||
## When not to use
|
||||
|
||||
- **Marketing or bulk email campaigns** (SendGrid Marketing Campaigns, segmentation, blast sends) — that is a different product surface with its own tooling.
|
||||
- **Other email providers** (Postmark, SES, Mailgun, Resend) — each has its own API; this skill covers SendGrid.
|
||||
- **Designing email deliverability strategy or domain reputation policy at org scale** — that is operational policy work; this skill operates the SendGrid surface.
|
||||
- **Building an email feature into an application** (template rendering, transactional flows) — that is application development; see [backend-engineering](../backend-engineering/SKILL.md).
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "email",
|
||||
"evals": [
|
||||
{
|
||||
"id": "guarded-transactional-send",
|
||||
"prompt": "A user asks: 'Send a password reset email to carol@example.com from no-reply@example.com with subject \"Password reset\" and body \"Use this link to reset your password.\"'",
|
||||
"expected_output": "The agent previews the exact email (from, to, subject, body) with email-cli send --dry-run and asks the user to confirm, then runs send with --yes after confirmation and reports the returned x-message-id. It explains that SendGrid returns 202 Accepted and that acceptance is not the same as delivery. If the user only asked to draft the email, nothing is sent.",
|
||||
"assertions": [
|
||||
"The exact email is previewed with --dry-run before any send",
|
||||
"The send happens only after explicit user confirmation, via --yes",
|
||||
"The response reports the x-message-id and explains 202 Accepted means queued, not delivered",
|
||||
"No API call is made without confirmation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bounce-triage",
|
||||
"prompt": "A user asks: 'Several users say they never received their signup confirmation emails. What should I check first?'",
|
||||
"expected_output": "The agent runs email-cli deliverability bounces (bounded) and reports bounced addresses with reasons and status codes, distinguishing hard bounces (permanent, likely bad addresses) from soft/transient ones. It checks spam reports as a secondary signal, and explains that 202 Accepted means the provider accepted the mail — delivery evidence comes from bounce events, spam reports, or the activity feed. It does not re-send to bounced addresses and does not change anything without confirmation.",
|
||||
"assertions": [
|
||||
"Bounces are checked first via email-cli deliverability bounces, bounded by --limit",
|
||||
"Hard versus transient bounces are distinguished using the status/reason",
|
||||
"Spam reports are checked as a secondary deliverability signal",
|
||||
"No re-send or mutation happens without confirmation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "webhook-authenticity",
|
||||
"prompt": "A user's webhook endpoint just received a SendGrid delivery event with X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp headers, and the raw body is saved to body.json. They ask: 'Is this event really from SendGrid? Can I mark the message delivered?'",
|
||||
"expected_output": "The agent runs email-cli webhook verify with the exact raw body file, the signature header value, the timestamp header value, and the webhook public key PEM. It explains the scheme: SHA-256 over timestamp + raw body (no separator), ECDSA P-256 verification against the public key, with a replay-window timestamp check. A verified result means the event can be processed; a stale timestamp or failed signature means the request must be rejected (401) and treated as forged. The response never re-encodes the body before verification.",
|
||||
"assertions": [
|
||||
"The exact raw body, signature, timestamp, and public key are passed to email-cli webhook verify",
|
||||
"The ECDSA scheme (SHA-256 of timestamp + raw body, P-256, base64 DER signature) is explained",
|
||||
"Verified events are distinguished from rejections on stale timestamp or failed signature",
|
||||
"The body is not re-encoded before verification"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "spam-complaint-response",
|
||||
"prompt": "A user asks: 'We got a spam complaint from jane@example.com. What should we do?'",
|
||||
"expected_output": "The agent runs email-cli deliverability spam-reports (bounded), confirms jane@example.com is listed, and explains the operating response: stop sending to that address (it is already suppressed by SendGrid), do not re-send, review the content and sending cadence that triggered the complaint, and consider whether the address was opted-in. Reads are bounded and no message is sent without confirmation.",
|
||||
"assertions": [
|
||||
"Spam reports are read via email-cli deliverability spam-reports with a bounded --limit",
|
||||
"The response instructs stopping mail to the complaining address rather than re-sending",
|
||||
"Content and cadence are reviewed as the likely cause",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deliverability-read-before-mutation",
|
||||
"prompt": "A user asks: 'Our signup emails have a 20% bounce rate. Diagnose it and tell me what to change before I approve a re-send.'",
|
||||
"expected_output": "The agent treats the diagnosis as read-only: runs deliverability bounces and spam-reports, groups the bounce reasons (invalid domain, mailbox full, greylisting), and hypothesizes the cause (bad address capture, validation missing at signup, sender reputation). It proposes concrete changes (add email validation, stop mailing to hard-bounced addresses, review content) but does not send anything: a re-send is a mutation requiring explicit approval with --dry-run preview and --yes confirmation.",
|
||||
"assertions": [
|
||||
"Diagnosis is performed read-only via deliverability reads",
|
||||
"Bounce reasons are grouped to separate address problems from reputation problems",
|
||||
"Concrete changes are proposed but not executed",
|
||||
"Any re-send is framed as a confirmed mutation with preview and approval"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "missing-key-and-error-hygiene",
|
||||
"prompt": "A user tries to run email-cli deliverability bounces but SENDGRID_API_KEY is not set. What happens and what is the right response?",
|
||||
"expected_output": "The agent explains that email-cli exits 1 with a clear error naming the missing SENDGRID_API_KEY (and the JSON variant emits {\"ok\": false, \"error\": \"...\"}), that --help works without the key for discovery, and that the correct fix is to export the API key with mail.send and suppression read access. The response does not hardcode or log any key material and does not attempt the call without credentials.",
|
||||
"assertions": [
|
||||
"The missing SENDGRID_API_KEY produces exit 1 with a descriptive error naming the variable",
|
||||
"--help is identified as working without credentials",
|
||||
"The fix is exporting an appropriately scoped API key",
|
||||
"No key material is hardcoded or logged"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Email (SendGrid) — Source Index
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
This skill is a distilled operating layer over Twilio SendGrid's public developer documentation. Facts and endpoint names in this skill are grounded in the sources below; refresh this index when SendGrid ships API changes.
|
||||
|
||||
| Topic | Source | URL |
|
||||
|---|---|---|
|
||||
| SendGrid v3 API reference | Twilio SendGrid API v3 reference | https://www.twilio.com/docs/sendgrid/api-reference |
|
||||
| Send email | Send Mail API | https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send |
|
||||
| Suppressions (bounces, spam reports) | Suppressions API | https://www.twilio.com/docs/sendgrid/api-reference/suppressions |
|
||||
| Signed Event Webhook (signature verification) | Event Webhook security features | https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features |
|
||||
| Event Webhook payload reference | Event webhook reference | https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event |
|
||||
| Official Python verification helper | sendgrid-python eventwebhook | https://github.com/sendgrid/sendgrid-python/tree/main/sendgrid/helpers/eventwebhook |
|
||||
|
||||
## Refresh procedure
|
||||
|
||||
- Re-check the signed-webhook security page before changing anything in `webhook verify`; the ECDSA signing scheme is a security boundary and the official Python helper is the reference implementation this skill mirrors.
|
||||
- Re-check the Mail Send API when SendGrid announces breaking changes to the `/v3/mail/send` payload.
|
||||
- Update `research_checked` in `SKILL.md` frontmatter and this file's `Last Updated` when you verify the sources again.
|
||||
@@ -0,0 +1,52 @@
|
||||
# SendGrid Operations
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
Operational detail for the Twilio SendGrid surface the skill owns: the Mail Send API, suppression endpoints for deliverability, and Signed Event Webhook verification. The bundled `email-cli` implements this reference; use this document when a call behaves unexpectedly.
|
||||
|
||||
## API conventions
|
||||
|
||||
- Base URL: `https://api.sendgrid.com/v3`. Every request carries `Authorization: Bearer <API_KEY>` and JSON bodies.
|
||||
- The API key scope matters: `mail.send` is needed to send; suppression reads need access to the Suppression endpoints (`suppression.read`/suppression access on the key). An unauthorized call returns 401 with an error body.
|
||||
- `email-cli` honors `SENDGRID_API_BASE` (test/stub override); production default is the v3 base.
|
||||
|
||||
## Sending (POST /v3/mail/send)
|
||||
|
||||
- Payload: `{"from": {"email": ...}, "personalizations": [{"to": [{"email": ...}], "subject": ...}], "content": [{"type": "text/plain", "value": ...}]}` plus an optional `text/html` content part.
|
||||
- The from-address must be a **verified sender identity** on the account; unverified senders are rejected.
|
||||
- Success is **HTTP 202 Accepted with an empty body** and the message id in the `X-Message-Id` response header — the email is queued, not delivered. Acceptance ≠ delivery; confirm with bounce events, spam reports, or the activity feed.
|
||||
- Failure is an HTTP 4xx/5xx with an `errors` array naming the field (e.g. invalid `from`, missing content).
|
||||
|
||||
## Deliverability surface (read-only)
|
||||
|
||||
| Signal | Endpoint | What it means |
|
||||
|---|---|---|
|
||||
| Bounces | `GET /suppression/bounces?limit=N` | Recipients whose mail bounced, with `reason`, `status`, `created` |
|
||||
| Spam reports | `GET /suppression/spam_reports?limit=N` | Recipients who marked mail as spam, with `ip` and `reason` |
|
||||
|
||||
- Suppression endpoints return plain arrays (not paginated objects); `limit` caps them (max 500). **Bounded-read rule:** request only what the task needs; `email-cli --limit` caps at the request level.
|
||||
- Hard bounces (permanent, 5.x.x) indicate invalid addresses — do not re-send to them. Spam complaints indicate content or targeting problems — review before the next send.
|
||||
|
||||
## Signed Event Webhook verification
|
||||
|
||||
SendGrid signs each webhook POST when "Signed Webhook" is enabled. Algorithm (mirrors the official `sendgrid-python` `EventWebhook.verify_signature` helper):
|
||||
|
||||
1. **Raw body bytes**: use the exact request body; re-encoding (pretty-print, charset change) breaks verification.
|
||||
2. **Timestamp**: `X-Twilio-Email-Event-Webhook-Timestamp` (Unix seconds). Reject if `|now - ts| > 300s` (default replay window; `--max-age 0` disables for historical verification).
|
||||
3. **Data**: SHA-256 digest over `timestamp + raw body` — bytes concatenated with **no separator** (`timestamped_payload = (timestamp + payload).encode('utf-8')`).
|
||||
4. **Signature**: `X-Twilio-Email-Event-Webhook-Signature`, base64-decoded ASN.1 DER `(r, s)`, verified as an ECDSA signature over the P-256 (secp256r1) curve with the webhook public key.
|
||||
5. Constant-time semantics: compare by performing the mathematical verification; never string-compare signatures.
|
||||
|
||||
The public key is displayed in the Event Webhook settings dialog when Signed Webhook is enabled and can also be fetched via the Event Webhook API. Store it as a PEM file, never in code. `email-cli webhook verify` implements steps 1–5 with a self-contained stdlib P-256 verifier (no `cryptography` dependency).
|
||||
|
||||
## Error handling
|
||||
|
||||
- 401 `unauthorized`: API key invalid or missing scope — rotate/regrant, never retry blindly.
|
||||
- 400 `errors[]`: payload problem; the message names the offending field (e.g. from-address not verified).
|
||||
- 429 `rate_limited`: slow down and retry with backoff.
|
||||
- `email-cli` exit 1 with `Email API HTTP <code>: <detail>` (human) or `{"ok": false, "error": "..."}` (JSON). Exit 2 is a usage error.
|
||||
|
||||
## Credential hygiene
|
||||
|
||||
- API keys are full-account credentials: store in `SENDGRID_API_KEY`, never in code, chat, or commits. Scope keys narrowly (`mail.send` only, or a separate key for suppression reads) and rotate on leak.
|
||||
- Webhook public keys are public material; webhook *secrets* do not exist in this scheme (signature verification is the security boundary).
|
||||
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env python3
|
||||
"""email-cli - bounded, agent-first command line for transactional email.
|
||||
|
||||
Sends transactional email through Twilio SendGrid and checks deliverability
|
||||
(bounces, spam reports) using only the Python standard library. Includes
|
||||
verification of SendGrid's Signed Event Webhook (ECDSA P-256) with a
|
||||
self-contained implementation — no third-party crypto dependency.
|
||||
|
||||
Design rules:
|
||||
|
||||
- Read-only by default. Sending email is a guarded mutation: it requires
|
||||
--dry-run to preview, then --yes to confirm. Mutation requires explicit
|
||||
confirmation.
|
||||
- Bounded reads: deliverability listings cap results with --limit and never
|
||||
page past the requested cap.
|
||||
- --json emits machine-readable JSON; the default is human-readable text.
|
||||
- --help works with no SENDGRID_API_KEY set and makes no network calls.
|
||||
|
||||
Environment:
|
||||
SENDGRID_API_KEY SendGrid API key with mail.send + suppression access
|
||||
|
||||
Webhook signature verification (the Signed Event Webhook) follows Twilio's
|
||||
reference implementation: data = timestamp + raw body (bytes concatenated,
|
||||
no separator), SHA-256 digest, verified as an ECDSA signature over the P-256
|
||||
curve with the webhook public key; the header signature is base64-decoded
|
||||
ASN.1 DER (r, s).
|
||||
|
||||
Exit codes: 0 success, 1 API error or failed verification, 2 usage error.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
API_BASE = os.environ.get("SENDGRID_API_BASE", "https://api.sendgrid.com/v3")
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
REQUEST_TIMEOUT = 15
|
||||
DEFAULT_MAX_AGE_SECONDS = 300
|
||||
TEXT_TRUNCATE = 500
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
"""Raised when the SendGrid API returns an error or verification fails."""
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
key = os.environ.get("SENDGRID_API_KEY", "")
|
||||
if not key:
|
||||
raise EmailError("SENDGRID_API_KEY environment variable is not set")
|
||||
return key
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ECDSA P-256 verification (stdlib-only)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# secp256r1 / prime256v1 domain parameters
|
||||
_P256 = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
|
||||
_P256_A = _P256 - 3
|
||||
_P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
|
||||
_P256_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
|
||||
_P256_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
|
||||
_P256_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
||||
_P256_G = (_P256_GX, _P256_GY)
|
||||
_POINT_AT_INFINITY = None # represented by None
|
||||
|
||||
|
||||
def _mod_inverse(value: int, modulus: int) -> int:
|
||||
return pow(value % modulus, -1, modulus)
|
||||
|
||||
|
||||
def _point_add(p1: Optional[Tuple[int, int]], p2: Optional[Tuple[int, int]]) -> Optional[Tuple[int, int]]:
|
||||
if p1 is None:
|
||||
return p2
|
||||
if p2 is None:
|
||||
return p1
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
if x1 == x2 and (y1 + y2) % _P256 == 0:
|
||||
return None
|
||||
if p1 == p2:
|
||||
lam = (3 * x1 * x1 + _P256_A) * _mod_inverse(2 * y1, _P256) % _P256
|
||||
else:
|
||||
lam = (y2 - y1) * _mod_inverse(x2 - x1, _P256) % _P256
|
||||
x3 = (lam * lam - x1 - x2) % _P256
|
||||
y3 = (lam * (x1 - x3) - y1) % _P256
|
||||
return (x3, y3)
|
||||
|
||||
|
||||
def _point_mul(scalar: int, point: Optional[Tuple[int, int]]) -> Optional[Tuple[int, int]]:
|
||||
result = None
|
||||
addend = point
|
||||
while scalar:
|
||||
if scalar & 1:
|
||||
result = _point_add(result, addend)
|
||||
addend = _point_add(addend, addend)
|
||||
scalar >>= 1
|
||||
return result
|
||||
|
||||
|
||||
def _parse_public_key(pem: str) -> Tuple[int, int]:
|
||||
"""Extract the uncompressed P-256 point (0x04 + x + y) from a PEM key."""
|
||||
lines = [line.strip() for line in pem.splitlines() if line.strip() and "-----" not in line]
|
||||
try:
|
||||
der = base64.b64decode("".join(lines))
|
||||
except (ValueError, TypeError) as error:
|
||||
raise EmailError("public key is not valid base64 PEM") from error
|
||||
marker = bytes([0x04])
|
||||
idx = der.find(marker)
|
||||
if idx < 0 or idx + 65 > len(der):
|
||||
raise EmailError("public key is not a valid EC P-256 uncompressed point")
|
||||
point = der[idx + 1 : idx + 65]
|
||||
x = int.from_bytes(point[:32], "big")
|
||||
y = int.from_bytes(point[32:], "big")
|
||||
# Point-on-curve check: y^2 == x^3 + ax + b (mod p)
|
||||
if (y * y - (x * x * x + _P256_A * x + _P256_B)) % _P256 != 0:
|
||||
raise EmailError("public key point is not on the P-256 curve")
|
||||
return (x, y)
|
||||
|
||||
|
||||
def _parse_der_signature(raw: bytes) -> Tuple[int, int]:
|
||||
"""Parse a minimal DER SEQUENCE of two INTEGERs into (r, s)."""
|
||||
if len(raw) < 8 or raw[0] != 0x30:
|
||||
raise EmailError("signature is not a DER SEQUENCE")
|
||||
offset = 2 # skip 0x30 + length byte (short-form lengths only)
|
||||
integers = []
|
||||
for _ in range(2):
|
||||
if offset >= len(raw) or raw[offset] != 0x02:
|
||||
raise EmailError("signature is not two DER INTEGERs")
|
||||
length = raw[offset + 1]
|
||||
start = offset + 2
|
||||
if start + length > len(raw):
|
||||
raise EmailError("signature INTEGER overruns the blob")
|
||||
integers.append(int.from_bytes(raw[start : start + length], "big"))
|
||||
offset = start + length
|
||||
if len(integers) != 2:
|
||||
raise EmailError("signature must contain r and s")
|
||||
return (integers[0], integers[1])
|
||||
|
||||
|
||||
def verify_webhook_signature(public_key_pem: str, body: bytes, signature: str, timestamp: str,
|
||||
max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) -> Dict[str, Any]:
|
||||
"""Verify a SendGrid Signed Event Webhook request.
|
||||
|
||||
Data is `timestamp + raw body` (bytes, no separator), SHA-256 digest,
|
||||
ECDSA P-256 verification against the webhook public key; the
|
||||
X-Twilio-Email-Event-Webhook-Signature header value is base64-decoded
|
||||
ASN.1 DER (r, s).
|
||||
"""
|
||||
if max_age_seconds > 0:
|
||||
try:
|
||||
if abs(int(time.time()) - int(timestamp)) > max_age_seconds:
|
||||
raise EmailError(
|
||||
f"webhook timestamp is outside the {max_age_seconds}s replay window"
|
||||
)
|
||||
except ValueError as error:
|
||||
raise EmailError(f"webhook timestamp is not a valid Unix timestamp: {timestamp!r}") from error
|
||||
try:
|
||||
decoded = base64.b64decode(signature, validate=True)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise EmailError("signature header is not valid base64") from error
|
||||
point = _parse_public_key(public_key_pem)
|
||||
r, s = _parse_der_signature(decoded)
|
||||
if not (1 <= r < _P256_N and 1 <= s < _P256_N):
|
||||
raise EmailError("webhook signature (r, s) values are out of range")
|
||||
digest = hashlib.sha256(timestamp.encode("utf-8") + body).digest()
|
||||
e = int.from_bytes(digest, "big")
|
||||
w = _mod_inverse(s, _P256_N)
|
||||
u1 = (e * w) % _P256_N
|
||||
u2 = (r * w) % _P256_N
|
||||
result = _point_add(_point_mul(u1, _P256_G), _point_mul(u2, point))
|
||||
if result is None or result[0] % _P256_N != r:
|
||||
raise EmailError("webhook signature does not match the public key")
|
||||
return {"ok": True, "verified": True, "timestamp": timestamp}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SendGrid API helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def api_request(method: str, path: str, api_key: str,
|
||||
body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/{path.lstrip('/')}",
|
||||
data=data,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.loads(error.read().decode("utf-8")).get("errors", "")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
raise EmailError(f"SendGrid API HTTP {error.code}: {detail or error.reason}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise EmailError(f"SendGrid API unreachable: {error.reason}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise EmailError(f"SendGrid API returned non-JSON: {error}") from error
|
||||
|
||||
|
||||
def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def summarize_bounce(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"email": item.get("email", ""), "created": item.get("created", ""),
|
||||
"reason": truncate(item.get("reason", "")), "status": item.get("status", "")}
|
||||
|
||||
|
||||
def summarize_spam_report(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"email": item.get("email", ""), "created": item.get("created", ""),
|
||||
"ip": item.get("ip", ""), "reason": truncate(item.get("reason", ""))}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_send(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
if not args.dry_run and not args.yes:
|
||||
raise EmailError(
|
||||
"refusing to send email without confirmation: pass --dry-run to "
|
||||
"preview or --yes to confirm the mutation"
|
||||
)
|
||||
payload = {
|
||||
"from": {"email": args.from_addr},
|
||||
"personalizations": [{"to": [{"email": to} for to in args.to], "subject": args.subject}],
|
||||
"content": [{"type": "text/plain", "value": args.body}],
|
||||
}
|
||||
if args.html:
|
||||
payload["content"].append({"type": "text/html", "value": args.html})
|
||||
if args.dry_run:
|
||||
return {"ok": True, "dry_run": True, "would_send": {
|
||||
"from": args.from_addr, "to": args.to, "subject": args.subject,
|
||||
"body_preview": truncate(args.body),
|
||||
}}
|
||||
# SendGrid returns 202 with an empty body; the message id is an HTTP header.
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/mail/send",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
message_id = ""
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
message_id = response.headers.get("X-Message-Id", "")
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.loads(error.read().decode("utf-8")).get("errors", "")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
raise EmailError(f"SendGrid API HTTP {error.code}: {detail or error.reason}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise EmailError(f"SendGrid API unreachable: {error.reason}") from error
|
||||
return {"ok": True, "message_id": message_id}
|
||||
|
||||
|
||||
def cmd_deliverability(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
if args.action == "bounces":
|
||||
payload = api_request("GET", f"suppression/bounces?limit={args.limit}", api_key)
|
||||
items = [summarize_bounce(i) for i in payload]
|
||||
return {"ok": True, "kind": "bounces", "items": items, "count": len(items)}
|
||||
payload = api_request("GET", f"suppression/spam_reports?limit={args.limit}", api_key)
|
||||
items = [summarize_spam_report(i) for i in payload]
|
||||
return {"ok": True, "kind": "spam_reports", "items": items, "count": len(items)}
|
||||
|
||||
|
||||
def cmd_webhook_verify(args: argparse.Namespace, unused_api_key: str) -> Dict[str, Any]:
|
||||
try:
|
||||
body = open(args.body_file, "rb").read()
|
||||
except OSError as error:
|
||||
raise EmailError(f"cannot read body file {args.body_file}: {error}") from error
|
||||
try:
|
||||
public_key = open(args.public_key_file, "r", encoding="utf-8").read()
|
||||
except OSError as error:
|
||||
raise EmailError(f"cannot read public key file {args.public_key_file}: {error}") from error
|
||||
return verify_webhook_signature(public_key, body, args.signature, args.timestamp, args.max_age)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Output helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def emit(data: Dict[str, Any], json_mode: bool) -> int:
|
||||
if json_mode:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
_emit_human(data)
|
||||
return 0
|
||||
|
||||
|
||||
def _emit_human(data: Dict[str, Any]) -> None:
|
||||
if data.get("dry_run"):
|
||||
print("DRY RUN (no email sent):")
|
||||
print(f" from: {data['would_send']['from']}")
|
||||
print(f" to: {', '.join(data['would_send']['to'])}")
|
||||
print(f" subject: {data['would_send']['subject']}")
|
||||
elif "message_id" in data:
|
||||
print(f"sent: message_id={data['message_id']}")
|
||||
elif "items" in data:
|
||||
print(f"{data['kind']} ({data['count']}):")
|
||||
for item in data["items"]:
|
||||
if "status" in item:
|
||||
print(f" {item['email']} {item['status']} {item['reason']}")
|
||||
else:
|
||||
print(f" {item['email']} {item['reason']}")
|
||||
elif data.get("verified"):
|
||||
print("webhook signature verified (ECDSA P-256 match)")
|
||||
else:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="email-cli",
|
||||
description=(
|
||||
"Bounded, agent-first CLI for transactional email via Twilio "
|
||||
"SendGrid: send, deliverability checks (bounces, spam reports), "
|
||||
"and Signed Event Webhook signature verification. Sending is a "
|
||||
"guarded mutation requiring --dry-run then --yes."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
|
||||
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
send = sub.add_parser("send", help="send a transactional email (guarded mutation)")
|
||||
send.add_argument("--to", action="append", required=True, metavar="EMAIL",
|
||||
help="recipient email (repeatable)")
|
||||
send.add_argument("--from", dest="from_addr", required=True, metavar="EMAIL",
|
||||
help="verified sender email")
|
||||
send.add_argument("--subject", required=True, help="email subject")
|
||||
send.add_argument("--body", required=True, help="plain-text body")
|
||||
send.add_argument("--html", help="optional HTML body")
|
||||
send.add_argument("--dry-run", action="store_true", help="preview the email without sending")
|
||||
send.add_argument("--yes", action="store_true", help="confirm the mutation and send")
|
||||
|
||||
deliverability = sub.add_parser("deliverability", help="check deliverability signals (read-only)")
|
||||
del_sub = deliverability.add_subparsers(dest="action", required=True)
|
||||
del_bounces = del_sub.add_parser("bounces", help="list bounced recipients")
|
||||
del_spam = del_sub.add_parser("spam-reports", help="list spam-reporting recipients")
|
||||
|
||||
webhook = sub.add_parser("webhook", help="verify a SendGrid webhook request (read-only)")
|
||||
wv_sub = webhook.add_subparsers(dest="action", required=True)
|
||||
wv_verify = wv_sub.add_parser("verify", help="verify X-Twilio-Email-Event-Webhook-Signature")
|
||||
wv_verify.add_argument("--body-file", required=True, help="path to the raw webhook request body")
|
||||
wv_verify.add_argument("--signature", required=True,
|
||||
help="X-Twilio-Email-Event-Webhook-Signature header value (base64)")
|
||||
wv_verify.add_argument("--timestamp", required=True,
|
||||
help="X-Twilio-Email-Event-Webhook-Timestamp header value (Unix seconds)")
|
||||
wv_verify.add_argument("--public-key-file", required=True,
|
||||
help="path to the webhook public verification key (PEM)")
|
||||
wv_verify.add_argument("--max-age", type=int, default=DEFAULT_MAX_AGE_SECONDS,
|
||||
help=f"replay window in seconds (0 disables; default {DEFAULT_MAX_AGE_SECONDS})")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
|
||||
try:
|
||||
if args.command == "webhook":
|
||||
result = cmd_webhook_verify(args, "")
|
||||
else:
|
||||
api_key = get_api_key()
|
||||
if args.command == "send":
|
||||
result = cmd_send(args, api_key)
|
||||
elif args.command == "deliverability":
|
||||
result = cmd_deliverability(args, api_key)
|
||||
else: # pragma: no cover - argparse prevents this
|
||||
parser.error(f"unknown command: {args.command}")
|
||||
return emit(result, args.json)
|
||||
except EmailError as error:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"email-cli: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for email/scripts/email-cli.
|
||||
|
||||
Runs the script as a subprocess so the tests exercise the real CLI surface
|
||||
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
|
||||
stdlib HTTP server stubs the SendGrid v3 API (mail/send, suppression
|
||||
bounces/spam_reports). Webhook signature verification uses fixed ECDSA P-256
|
||||
test vectors generated offline with OpenSSL (independent of this
|
||||
implementation), so the valid-signature path is cross-checked against a
|
||||
reference signing tool. No external network is needed.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "email-cli"
|
||||
|
||||
# Fixed ECDSA P-256 vectors: signature over SHA256("1712345678" + body)
|
||||
# generated with OpenSSL (openssl dgst -sha256 -sign) as an independent
|
||||
# cross-check of the stdlib verifier embedded in email-cli.
|
||||
WEBHOOK_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwOVzJT7nlfguzr/GSiBh0EAp7fQX
|
||||
YqMhYoJ0lChYC6LnXnC1y5BQiF5SxM7tjj69z9NeMnM9fQvZ4h65V0AYaQ==
|
||||
-----END PUBLIC KEY-----
|
||||
"""
|
||||
WEBHOOK_BODY = b'{"RecordType":"Delivery","MessageID":"m1"}'
|
||||
WEBHOOK_TIMESTAMP = "1712345678"
|
||||
WEBHOOK_SIGNATURE = "MEUCID/YPP4118Tr+6IPyV+OlrV0IktxgRWcQk+a2BOKkeP/AiEA7XPfz1DejTxPuCXkFCnXEPhSPy4L2nDwuvC20TZBiZ8="
|
||||
|
||||
BOUNCE = {"email": "bad@example.com", "created": "2026-08-01T00:00:00Z",
|
||||
"reason": "550 invalid mailbox", "status": "5.1.1"}
|
||||
SPAM = {"email": "spam@example.com", "created": "2026-08-01T00:00:00Z",
|
||||
"ip": "1.2.3.4", "reason": "user complaint"}
|
||||
|
||||
|
||||
class StubSendGridServer:
|
||||
"""Minimal stub of the SendGrid v3 API surface used by email-cli."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests = [] # (method, path, body)
|
||||
handler = self._make_handler()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def _make_handler(self):
|
||||
stub = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||||
|
||||
def _json(self, payload, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
stub.requests.append(("GET", self.path, None))
|
||||
if "/suppression/bounces" in self.path:
|
||||
self._json([BOUNCE])
|
||||
elif "/suppression/spam_reports" in self.path:
|
||||
self._json([SPAM])
|
||||
else:
|
||||
self._json({"errors": [{"message": "not_found"}]}, 404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
body = self._read_body()
|
||||
stub.requests.append(("POST", self.path, body))
|
||||
if self.path == "/v3/mail/send":
|
||||
self.send_response(202)
|
||||
self.send_header("X-Message-Id", "msg-test-0001")
|
||||
self.end_headers()
|
||||
else:
|
||||
self._json({"errors": [{"message": "not_found"}]}, 404)
|
||||
|
||||
def log_message(self, *args): # silence stderr
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
def run_script(env, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def base_env(stub):
|
||||
env = dict(os.environ)
|
||||
env["SENDGRID_API_KEY"] = "SG.test-key"
|
||||
env["SENDGRID_API_BASE"] = f"http://127.0.0.1:{stub.port}/v3"
|
||||
return env
|
||||
|
||||
|
||||
def load_json(proc):
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def write_webhook_files(tmpdir):
|
||||
body_file = tmpdir / "webhook-body.json"
|
||||
key_file = tmpdir / "public-key.pem"
|
||||
body_file.write_bytes(WEBHOOK_BODY)
|
||||
key_file.write_text(WEBHOOK_PUBLIC_KEY)
|
||||
return body_file, key_file
|
||||
|
||||
|
||||
class EmailCliTests(unittest.TestCase):
|
||||
def test_help_lists_json_and_bounded_reads(self):
|
||||
proc = run_script(dict(os.environ), "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
self.assertIn("--json", proc.stdout)
|
||||
self.assertIn("--limit", proc.stdout)
|
||||
|
||||
def test_help_works_without_api_key(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("SENDGRID_API_KEY", None)
|
||||
proc = run_script(env, "deliverability", "bounces", "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
|
||||
def test_send_requires_confirmation(self):
|
||||
with StubSendGridServer() as stub:
|
||||
proc = run_script(base_env(stub), "send", "--to", "a@example.com",
|
||||
"--from", "b@example.com", "--subject", "Hi", "--body", "Hello")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("refusing to send", proc.stderr)
|
||||
self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
|
||||
|
||||
def test_send_dry_run_does_not_post(self):
|
||||
with StubSendGridServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "send", "--to", "a@example.com",
|
||||
"--from", "b@example.com", "--subject", "Hi", "--body", "Hello",
|
||||
"--dry-run")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["dry_run"])
|
||||
self.assertEqual(stub.requests, [], "dry-run must not reach the API")
|
||||
|
||||
def test_send_with_yes_posts(self):
|
||||
with StubSendGridServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "send", "--to", "a@example.com",
|
||||
"--from", "b@example.com", "--subject", "Hi", "--body", "Hello",
|
||||
"--yes")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["message_id"], "msg-test-0001")
|
||||
posts = [body for method, path, body in stub.requests
|
||||
if method == "POST" and path == "/v3/mail/send"]
|
||||
self.assertEqual(len(posts), 1)
|
||||
self.assertEqual(posts[0]["personalizations"][0]["to"][0]["email"], "a@example.com")
|
||||
|
||||
def test_deliverability_bounces(self):
|
||||
with StubSendGridServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "--limit", "5", "deliverability", "bounces")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["kind"], "bounces")
|
||||
self.assertEqual(data["items"][0]["email"], "bad@example.com")
|
||||
|
||||
def test_deliverability_spam_reports(self):
|
||||
with StubSendGridServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "--limit", "5", "deliverability", "spam-reports")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["kind"], "spam_reports")
|
||||
self.assertEqual(data["items"][0]["email"], "spam@example.com")
|
||||
|
||||
def test_deliverability_limit_is_bounded_in_request(self):
|
||||
with StubSendGridServer() as stub:
|
||||
run_script(base_env(stub), "--json", "--limit", "7", "deliverability", "bounces")
|
||||
gets = [path for method, path, _ in stub.requests if method == "GET"]
|
||||
self.assertTrue(any("/suppression/bounces?limit=7" in path for path in gets))
|
||||
|
||||
def test_webhook_verify_valid_signature(self):
|
||||
tmp = ROOT / "tests"
|
||||
body_file, key_file = write_webhook_files(tmp)
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
||||
"--timestamp", WEBHOOK_TIMESTAMP,
|
||||
"--public-key-file", str(key_file), "--max-age", "0")
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
key_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertTrue(load_json(proc)["verified"])
|
||||
|
||||
def test_webhook_verify_rejects_tampered_body(self):
|
||||
tmp = ROOT / "tests"
|
||||
body_file = tmp / "webhook-body.json"
|
||||
key_file = tmp / "public-key.pem"
|
||||
body_file.write_bytes(b'{"RecordType":"Delivery","MessageID":"FORGED"}')
|
||||
key_file.write_text(WEBHOOK_PUBLIC_KEY)
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
||||
"--timestamp", WEBHOOK_TIMESTAMP,
|
||||
"--public-key-file", str(key_file), "--max-age", "0")
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
key_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("does not match", proc.stdout)
|
||||
|
||||
def test_webhook_verify_rejects_wrong_key(self):
|
||||
tmp = ROOT / "tests"
|
||||
body_file, key_file = write_webhook_files(tmp)
|
||||
other_key = """-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEefPWWSDlPHw0mRIqmQHcvmNx6ArM
|
||||
SCPBuBGQ9ObEdv7Wyfnu/T99Aa1eD3Nfd3YG+WKUvNUEw5qXX2EXJIP/Kw==
|
||||
-----END PUBLIC KEY-----
|
||||
"""
|
||||
key_file.write_text(other_key)
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
||||
"--timestamp", WEBHOOK_TIMESTAMP,
|
||||
"--public-key-file", str(key_file), "--max-age", "0")
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
key_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("does not match", proc.stdout)
|
||||
|
||||
def test_webhook_verify_rejects_stale_timestamp(self):
|
||||
tmp = ROOT / "tests"
|
||||
body_file, key_file = write_webhook_files(tmp)
|
||||
stale = str(int(__import__("time").time()) - 3600)
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file), "--signature", WEBHOOK_SIGNATURE,
|
||||
"--timestamp", stale, "--public-key-file", str(key_file))
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
key_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("replay window", proc.stdout)
|
||||
|
||||
def test_webhook_verify_rejects_malformed_signature(self):
|
||||
tmp = ROOT / "tests"
|
||||
body_file, key_file = write_webhook_files(tmp)
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file), "--signature", "not-base64!!",
|
||||
"--timestamp", WEBHOOK_TIMESTAMP,
|
||||
"--public-key-file", str(key_file), "--max-age", "0")
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
key_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("base64", proc.stdout)
|
||||
|
||||
def test_missing_api_key_errors_cleanly(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("SENDGRID_API_KEY", None)
|
||||
proc = run_script(env, "--json", "deliverability", "bounces")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("SENDGRID_API_KEY", proc.stdout)
|
||||
|
||||
def test_read_only_contract_no_write_opens(self):
|
||||
source = SCRIPT.read_text()
|
||||
writes = [line for line in source.splitlines()
|
||||
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
|
||||
self.assertEqual(writes, [], "script must never open files in write mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,6 +22,7 @@
|
||||
- [conditional-customer-success](conditional-customer-success/SKILL.md): Guide recurring human-relationship practices — success plans, health evidence, renewal and expansion signals, QBRs, handoffs, escalation, and closed-loop Voice of Customer. Do not use this skill for products without accounts, renewals, QBRs, or a customer-success team, including some internal tools, pure transactional products without recurring relationships, and public services without account-based engagement. Load only when the product context includes a recurring human relationship; decline or route away otherwise.
|
||||
- [confluence-cli](confluence-cli/SKILL.md): Interact with Atlassian Confluence from the terminal: list spaces, browse pages, view page content, search with CQL, and create pages. Use when the user mentions Confluence, a space key (e.g. DEV), or asks about documentation, wiki pages, space content, or knowledge base articles.
|
||||
- [crewai](crewai/SKILL.md): Expert skill for role-based multi-agent orchestration with CrewAI. Agents with Role/Goal/Backstory, task design, crew composition (sequential or hierarchical), tool integration, callbacks, and production deployment. Use when orchestrating multi-agent teams or comparing agent frameworks.
|
||||
- [crm](crm/SKILL.md): Operate HubSpot CRM from a terminal or agent: list and search contact records, view deal pipeline stages, and — with explicit confirmation — move deals between stages, backed by a bundled crm-cli script that is read-only by default and gates every stage change behind a --dry-run/--yes confirmation. Use when an agent needs to answer questions about contacts or deals, produce pipeline views, or apply a confirmed stage change. Do not use for building HubSpot apps or workflow automations (that is HubSpot app development), marketing/sequence automation, or other CRMs like Salesforce (that is their own tooling).
|
||||
- [crowdsec](crowdsec/SKILL.md): Deploy, configure, and manage CrowdSec — the open-source, collaborative IPS/IDPS/WAF. Covers Security Engine setup (Linux, Docker), cscli hub management, remediation components, AppSec WAF, profiles, notifications, blocklists, CTI, and metrics. Use when setting up or troubleshooting CrowdSec.
|
||||
- [cyberpunk](cyberpunk/SKILL.md): Create or analyze settings, scenes, world operations, and image direction in the literary cyberpunk mode of William Gibson's Sprawl fiction: dense, accreted urban systems; uneven high technology; corporate power; mediated culture; and human-scale survival inside global networks. Use for Gibson-informed creative work, setting design, or visual briefs, not for generic neon cyberpunk, faithful continuation of named canon, or imitation of Gibson's prose.
|
||||
- [daily-life-discovery](daily-life-discovery/SKILL.md): Guide a consent-based conversation that helps a person discover how an AI agent could improve their day-to-day life: routines, friction, attention, decisions, relationships, learning, and small experiments. Use when someone asks for a daily check-in, wants the agent to learn how they work, says "grill me," wants a conversational journal, or asks what an AI could help with. Do not use for therapy, diagnosis, crisis support, covert monitoring, or product requirements interviews.
|
||||
@@ -32,6 +33,7 @@
|
||||
- [docker-compose](docker-compose/SKILL.md): Use Docker Compose to define, run, debug, and harden multi-container applications. Load for compose.yaml design, networking, volumes, secrets, profiles, overrides, watch mode, lifecycle operations, or troubleshooting.
|
||||
- [documents](documents/SKILL.md): Generate, inspect, validate, and fix PDF, Word (.docx), Excel (.xlsx), and PowerPoint (.pptx) documents: turn structured content into render-ready artifacts, verify structural and output quality before delivery, and repair broken files. Use when a task involves creating, editing, converting, or validating office documents and PDFs. Do not use for ebook packaging (use epub), for images, video, or other media production, for API or code documentation, or for data pipelines (use data-engineering).
|
||||
- [dspy](dspy/SKILL.md): Expert skill for programming—not prompting—language models with Stanford's DSPy framework. Signatures, modules (Predict, ChainOfThought, ReAct), optimizer/teleprompter selection, compilation, caching, evaluation. Use when doing programmatic prompt optimization or building compiled prompt programs.
|
||||
- [email](email/SKILL.md): Send and diagnose transactional email through Twilio SendGrid from a terminal or agent: send messages, check deliverability (bounces and spam reports), and verify Signed Event Webhook signatures (ECDSA P-256) — with a bundled email-cli script that is read-only by default and gates every send behind a --dry-run/--yes confirmation. Use when an agent needs to send a transactional email, triage bounces or spam complaints, or confirm an inbound SendGrid webhook is authentic. Do not use for marketing or bulk email campaigns (that is SendGrid Marketing Campaigns), building email template systems, or other email providers (that is their own tooling).
|
||||
- [epub](epub/SKILL.md): EPUB file format expert — read, write, and edit EPUB2/EPUB3 ebooks. Extract text, metadata, structure, and knowledge from EPUB files for enrichment or memory. Create valid EPUBs from scratch. Validate against the EPUB specification. Use when the user mentions epub, ebook, EPUB file, ebook format, read epub, write epub, create ebook, extract from epub, epub to text, or ebook structure.
|
||||
- [esp32-development](esp32-development/SKILL.md): Build, configure, flash, test, debug, and recover firmware for ESP32-family boards, including ESP-IDF C/C++, Arduino/PlatformIO, MicroPython, CircuitPython, ESPHome, Zephyr, Rust, and NuttX. Use when identifying an ESP32 board, choosing a framework, wiring GPIO or peripheral buses, integrating sensors or actuators, diagnosing serial/boot/power/network failures, or planning OTA and production security. Do not use as a substitute for the exact board schematic, SoC datasheet, or attached component datasheet.
|
||||
- [financial-modeling](financial-modeling/SKILL.md): Build and review assumptions-led financial models, unit economics, pricing, fundraising scenarios, and SaaS operating metrics. Use when calculating CAC, LTV, payback, runway, ARR, churn, NDR, Rule of 40, or sales efficiency; when modeling revenue, costs, cash flow, pricing, cap tables, or financing.
|
||||
@@ -68,6 +70,7 @@
|
||||
- [ml-engineering](ml-engineering/SKILL.md): Plan and execute production ML engineering work — model training and fine-tuning (LoRA/QLoRA), evaluation and eval-set design, quantization decisions, inference deployment, and regression triage, grounded in practical engineering patterns for production ML systems. Do not use for statistical modeling and experimental design (that's the data scientist) or for operating a specific inference engine (that's a tool skill such as llama-cpp or vllm).
|
||||
- [mobile-development](mobile-development/SKILL.md): Build, test, sign, and ship mobile apps across iOS, Android, Flutter, and React Native — project scaffolding, builds and code signing, device and emulator testing, store submission (App Store and Play Store), app lifecycle and backgrounding, offline and sync, and mobile-specific testing. Use when the task involves creating, building, testing, or shipping a mobile app for iOS or Android, or reasoning about mobile behavior such as background execution, push notifications, offline storage, and data sync. Do not use for web frontend work (that is frontend-engineering), backend services and APIs (that is backend-engineering), or desktop and web platform targets outside the iOS and Android scope.
|
||||
- [neckbeard](bundles/neckbeard/SKILL.md): Use when asked to fix, build, refactor, review, verify, or release software and the work is non-trivial — including delivering a change request (issue, ticket, or request) from intake through planning, gates, implementation, review, verified PR, and authorized post-merge release. neckbeard routes the change through framing, discovery, design, implementation, review, verification, delivery, and learning — choosing the smallest *safe* intervention, proving it at the real delivery boundary, and leaving an inspectable evidence ledger. For change-request / issue-to-PR work, conditionally loads a 9-phase journey with gates, delivery packet, and lifecycle integration. Composes specialist catalog skills rather than replacing them. Not a persona, not a '10x developer' prompt, not a LOC-minimizer. The journey is not loaded for plain fixes, refactors, or reviews that lack an issue/ticket trajectory.
|
||||
- [notion](notion/SKILL.md): Operate Notion from a terminal or agent: retrieve pages, query databases, search pages and databases, and update page properties — with a bundled notion-cli script that is read-only by default and gates every create or update behind a --dry-run/--yes confirmation. Use when an agent needs to read Notion content, answer questions from a team wiki or database, or make a confirmed edit. Do not use for building Notion integrations or block-level page composition beyond property updates (that is Notion API application development), or for other knowledge bases (that is their own tooling).
|
||||
- [nous-branding](nous-branding/SKILL.md): Generate images and content consistent with the Nous Research brand identity. Use when creating visuals in the Nous / Theia / Hermes ecosystem: a "cyber-classical" style blending neo-classical statuary, cyberpunk/industrial grunge, and retro anime illustration. Covers official brand color palette, typography (Inter/IBM Plex Sans, JetBrains Mono, heavy distressed display faces), the Nous Girl mascot, texture system, and image prompt construction. Ships reference images for palette, mascot, and brand collage that can be used as img2img inputs.
|
||||
- [open-knowledge-format](open-knowledge-format/SKILL.md): Google's Open Knowledge Format (OKF) v0.1 — an open, vendor-neutral spec for representing knowledge as markdown files with YAML frontmatter, designed for AI agent consumption. Use when the user mentions OKF, Open Knowledge Format, Google's knowledge format, LLM wiki bundles, agent knowledge packs, creating OKF bundles, validating OKF documents, or converting knowledge into the OKF standard.
|
||||
- [openlibrary-cli](openlibrary-cli/SKILL.md): Search books, authors, and works on Open Library from the terminal. Look up books by ISBN, search titles and authors, and fetch detailed work/author records via the public Open Library API. No API key required.
|
||||
@@ -107,9 +110,11 @@
|
||||
- [security-audit-methodology](security-audit-methodology/SKILL.md): Plan authorized security reviews with threat modeling, architecture and dependency audits, and vulnerability classification. Use for scoped defensive security assessment. Do not use for offensive operations, unauthorized testing, or security control implementation.
|
||||
- [seo-audit](seo-audit/SKILL.md): Audit websites and pages for technical SEO, on-page SEO, schema markup, content discoverability, and answer-engine readiness. Use when prioritizing search visibility improvements.
|
||||
- [site-reliability-engineering](site-reliability-engineering/SKILL.md): Design, operate, and improve reliable production systems with SLOs, incident command, observability, error budgets, and operational practices.
|
||||
- [slack](slack/SKILL.md): Operate Slack workspaces from a terminal or agent: list channels, read messages, follow threads, search message history, list files, and verify inbound webhook signatures — with a bundled slack-cli script that is read-only by default and gates every send behind a --dry-run/--yes confirmation. Use when an agent needs to read or post Slack data, triage incidents, or answer questions about what was said in a workspace. Do not use for building Slack apps or bots (that is application development) or workspace administration like user provisioning and org settings (that is the Slack admin console).
|
||||
- [software-architecture-analysis](software-architecture-analysis/SKILL.md): Reverse-engineer a software codebase to understand architecture, data flow, privacy posture, and feature surface — then produce a clean-room design document, PRD, or migration plan that re-imagines the system under new constraints (local-first, privacy-first, self-hosted). Use when you need to understand how a system works from its source code or produce a specification without copying implementation details.
|
||||
- [spec-driven-development](spec-driven-development/SKILL.md): Spec-Driven Development (SDD) methodology for AI software factories — where structured specifications are the input, AI agents generate the code, and quality gates enforce correctness at each pipeline phase. Use when designing a spec → review → decompose → implement → verify pipeline that any AI coding tool (Claude Code, Cursor, Hermes Agent, Devin, OpenHands) can follow.
|
||||
- [strategy-frameworks](strategy-frameworks/SKILL.md): Structure organizational strategy work: strategic direction, competitive and industry analysis, growth options, capital allocation, acquisitions, and portfolio choices. Use when framing consequential choices about where to compete, how to pursue an opportunity, or how to compare strategic options.
|
||||
- [stripe](stripe/SKILL.md): Read Stripe account state from a terminal or agent: balance, payment intents, and subscriptions — and perform guarded mutations like canceling a subscription — backed by a bundled stripe-cli script that is read-only first and gates every state-changing command behind a --dry-run/--yes confirmation. Use when an agent needs to answer questions about account balance, recent payments, active subscriptions, or apply a confirmed subscription cancellation. Do not use for building Stripe payments into an application (that is Stripe integration development), managing Stripe dashboard settings, or other payment processors (that is their own tooling).
|
||||
- [supabase](supabase/SKILL.md): Use this skill when developing applications with Supabase, running the Supabase CLI, designing migrations and RLS policies, testing database behavior, generating client types, deploying the official self-hosted Docker stack, or administering its Postgres, Auth, Storage, Realtime, Functions, API gateway, backups, upgrades, and security. Use it for managed and self-hosted projects. Do not use it for generic PostgreSQL work with no Supabase services or conventions.
|
||||
- [systematic-debugging](systematic-debugging/SKILL.md): 4-phase root cause debugging protocol: understand bugs before fixing. Use for ANY technical issue — test failures, production bugs, unexpected behavior, performance problems, build failures, or integration issues. ESPECIALLY when under time pressure, when "one quick fix" seems obvious, or when previous fix attempts have failed.
|
||||
- [tailscale](bundles/tailscale/SKILL.md): Self-hosted Tailscale/Headscale ecosystem: deploy and manage a Headscale control server, configure tailscale clients, manage ACL policies, node lifecycle, subnet routing, DERP relays, and backup/migration. Use when the user mentions Tailscale, Headscale, tailnet, mesh VPN, WireGuard mesh, or self-hosted VPN infrastructure.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Notion — Read and Edit Notion from the Terminal
|
||||
|
||||
Operate Notion without leaving your terminal or your agent's tool loop: retrieve pages, query databases, search across the workspace, and make confirmed property updates.
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
Teams run their operational memory in Notion — runbooks, on-call docs, product trackers, decision logs — and agents have had no bounded way to read it. This skill gives your agent a real read path (page retrieval, database queries, search) and a safe write path: creating a page or updating a property is a guarded mutation that requires a preview and an explicit confirmation, so the agent can answer questions from Notion without ever silently editing shared content.
|
||||
|
||||
It ships `notion-cli`, a small Python script that speaks the Notion API with no third-party dependencies. Reads are capped (`--limit`), output is clean JSON for the agent or readable text for you, and `--help` works with no token and no network. The script sends the standard `Notion-Version` header and summarizes pages as title + ID + URL instead of dumping raw block trees.
|
||||
|
||||
## What You Get
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | Agent-facing operating contract, mutation gates, and verification boundaries |
|
||||
| `references/` | Dated source index and an API operations reference (endpoints, pagination, property types, filters, errors) |
|
||||
| `scripts/notion-cli` | Bounded, stdlib-only CLI: pages get/create/update, databases query, search; `--json`, `--limit`, mutations gated by `--dry-run`/`--yes` |
|
||||
| `tests/` | 13 deterministic tests against a stub Notion API, covering the mutation gate and read-only contract |
|
||||
| `evals/evals.json` | Six output-quality evaluation cases for agent runs |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Help works with no token and no network
|
||||
notion/scripts/notion-cli --help
|
||||
|
||||
# Find a page by text (bounded)
|
||||
NOTION_TOKEN=secret_... notion/scripts/notion-cli --json search query --query "on-call runbook"
|
||||
|
||||
# Retrieve one page
|
||||
NOTION_TOKEN=secret_... notion/scripts/notion-cli --json pages get --page-id <page-id>
|
||||
|
||||
# Query a database, capped at 10 rows
|
||||
NOTION_TOKEN=secret_... notion/scripts/notion-cli --json --limit 10 databases query --database-id <db-id>
|
||||
|
||||
# Update a page property: preview first, then confirm
|
||||
printf '{"Status": {"select": {"name": "Done"}}}' > props.json
|
||||
NOTION_TOKEN=secret_... notion/scripts/notion-cli pages update --page-id <page-id> --properties props.json --dry-run
|
||||
NOTION_TOKEN=secret_... notion/scripts/notion-cli pages update --page-id <page-id> --properties props.json --yes
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
Load this skill for `notion` operations: "what does the runbook say", reading pages, querying a Notion database (rows, filters), searching pages and databases, creating a page row, or updating a page property with confirmation. Do not load it for building Notion integrations or apps, workspace administration, or other knowledge bases like Confluence.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+ for `notion-cli` (stdlib only; `--help` needs nothing else).
|
||||
- A Notion integration token (`NOTION_TOKEN`, `secret_...`) with the workspace pages/databases you need **shared with the integration**. Search and database access require the corresponding capabilities in the integration settings.
|
||||
- Network access to `api.notion.com` for live reads and writes. Optionally set `NOTION_VERSION` to pin the API version (default `2022-06-28`).
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: notion
|
||||
description: >-
|
||||
Operate Notion from a terminal or agent: retrieve pages, query databases,
|
||||
search pages and databases, and update page properties — with a bundled
|
||||
notion-cli script that is read-only by default and gates every create or
|
||||
update behind a --dry-run/--yes confirmation. Use when an agent needs to
|
||||
read Notion content, answer questions from a team wiki or database, or make
|
||||
a confirmed edit. Do not use for building Notion integrations or
|
||||
block-level page composition beyond property updates (that is Notion API
|
||||
application development), or for other knowledge bases (that is their own
|
||||
tooling).
|
||||
license: MIT
|
||||
compatibility: >-
|
||||
The bundled notion-cli script runs on Python 3.9+ with only the standard
|
||||
library. --help and page/database/search reads need no network; live reads
|
||||
require a Notion integration token (secret_...) with the right workspace
|
||||
capabilities and network access to api.notion.com.
|
||||
metadata:
|
||||
source: https://developers.notion.com/reference
|
||||
source_index: references/00-source-index.md
|
||||
research_checked: "2026-08-03"
|
||||
---
|
||||
|
||||
# Notion Operations
|
||||
|
||||
Use this skill to read and, with explicit confirmation, write Notion content through the Notion API: pages, database queries, search, and page property updates. This is a **tool skill** for the Notion platform. Building Notion integrations, writing complex block compositions, or building an app on the Notion API is application development; this skill owns the everyday agent workflow: finding the right page, answering from a database, and making a confirmed edit.
|
||||
|
||||
## Operating contract
|
||||
|
||||
1. **Read-only discovery before any mutation.** Retrieve pages, query databases, and search freely. The bundled `notion-cli` script makes reads without writing anything.
|
||||
2. **Confirm the target, scope, and rollback path before acting.** Creating a page or updating properties changes a shared workspace that teammates read. Both require an explicit human directive plus `--dry-run` preview and `--yes` confirmation through `notion-cli`. Property updates overwrite existing values — state the current value and the replacement before confirming.
|
||||
3. **Respect bounded reads.** Notion paginates with `page_size` and `has_more`; never page past what the task needs. `notion-cli --limit` caps every search and query.
|
||||
4. **Keep evidence bounded.** Quote short page titles, property values, and IDs; never paste full pages, tokens, or raw API payloads into chat.
|
||||
5. **Know the API version.** The `Notion-Version` header pins the API contract; reads that work today can change with a version bump. `notion-cli` sends `2022-06-28` by default and honors `NOTION_VERSION`.
|
||||
|
||||
## The notion-cli script
|
||||
|
||||
`scripts/notion-cli` is an agent-first, stdlib-only CLI over the Notion API. It covers the full issue scope: pages, databases (query), search, and updates.
|
||||
|
||||
```bash
|
||||
notion/scripts/notion-cli --help # no token or network needed
|
||||
notion/scripts/notion-cli --json pages get --page-id <page>
|
||||
notion/scripts/notion-cli --json --limit 10 databases query --database-id <db>
|
||||
notion/scripts/notion-cli --json search query --query "on-call runbook"
|
||||
notion/scripts/notion-cli pages update --page-id <page> --properties props.json --dry-run
|
||||
notion/scripts/notion-cli pages update --page-id <page> --properties props.json --yes
|
||||
notion/scripts/notion-cli pages create --parent-database <db> --title "New row" --yes
|
||||
```
|
||||
|
||||
Exit codes: 0 success, 1 API error or failed check, 2 usage error. Creates and updates are guarded: without `--dry-run` or `--yes` the script refuses with exit 1 and never calls the API. Reads are bounded by `--limit` (default 20, max 100).
|
||||
|
||||
## Operating loop
|
||||
|
||||
1. **Locate the content**: `search query` to find pages and databases by text, or a known ID directly.
|
||||
2. **Read with bounds**: `pages get` for a single page, `databases query` for rows in a database (optionally with a JSON `--filter`), always capped by `--limit`.
|
||||
3. **Triage the answer**: map the question to evidence (page title + properties, database rows, search results with `has_more` state).
|
||||
4. **Act with confirmation**: only a human directive to change, previewed with `--dry-run` and confirmed with `--yes`.
|
||||
5. **Verify**: re-read the page (`pages get`) and confirm the property values landed.
|
||||
|
||||
## Pages, databases, search
|
||||
|
||||
- **Pages** (`GET /pages/{id}`): a page is an ID, a title (extracted from the `title` or `Name` property), a URL, and timestamps. Property values live under `properties`; the CLI summarizes them rather than dumping the full block tree.
|
||||
- **Databases** (`POST /databases/{id}/query`): query rows as pages with a `page_size` cap and an optional structured `--filter` JSON file (e.g. `{"property": "Status", "select": {"equals": "Done"}}`). `has_more` tells you whether the cap hid further rows.
|
||||
- **Search** (`POST /search`): finds pages and databases by text across the integration's accessible workspace; results are bounded by `--limit`.
|
||||
- **Updates** (`PATCH /pages/{id}`): property updates overwrite values (select, status, checkbox, rich text, etc.). Preview the exact properties payload with `--dry-run` and confirm with `--yes`; verify with a follow-up `pages get`.
|
||||
|
||||
## Integration access model
|
||||
|
||||
- Notion integrations authenticate with a bot-style token (`secret_...`) and can only see the pages and databases explicitly **shared with the integration**. A page that exists in the workspace but is not shared returns 404/`object_not_found` — that is an access-model result, not a missing page.
|
||||
- The `Notion-Version` header selects the API contract. The CLI defaults to `2022-06-28`; set `NOTION_VERSION` when a workspace or application pins a different version.
|
||||
- Tokens are workspace-scoped credentials. Store them in the environment (`NOTION_TOKEN`), never in code, chat, or commits. Revoke a leaked integration token in the Notion integration settings.
|
||||
|
||||
## Reference routing
|
||||
|
||||
| Load when | Reference |
|
||||
|---|---|
|
||||
| Sources, version notes, refresh procedure | `references/00-source-index.md` |
|
||||
| API endpoints, pagination, property types, filters, and error handling | `references/01-api-operations.md` |
|
||||
|
||||
## Included artifacts
|
||||
|
||||
- `scripts/notion-cli`: bounded, stdlib-only CLI (pages get/create/update, databases query, search; `--json`; `--limit`; mutations gated by `--dry-run`/`--yes`).
|
||||
- `tests/test_notion_cli.py`: 13 deterministic tests against a stub Notion API, including the mutation gate and the read-only contract.
|
||||
- `references/`: dated source index + API operations reference.
|
||||
- `evals/evals.json`: six output-quality evaluation cases for agent runs.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
| Claim | Minimum evidence |
|
||||
|---|---|
|
||||
| A page exists and its title | `notion-cli pages get --page-id ... --json` returns the title and ID |
|
||||
| A database query answered the question | `notion-cli databases query --json` returns bounded rows with `has_more` state |
|
||||
| Search found the content | `notion-cli search query --json` returns the matching page/database with ID and title |
|
||||
| An update landed | `notion-cli pages update --yes` exits 0 and a follow-up `pages get` shows the new property values |
|
||||
| A mutation is safe to run | `notion-cli ... --dry-run` prints the exact payload that would be sent |
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- Never create or update a page without a human directive, `--dry-run` preview, and `--yes` confirmation — Notion edits are visible to everyone with access to the page.
|
||||
- Never claim a page is missing when it may simply not be shared with the integration; verify the access model first.
|
||||
- Never page reads past `--limit`; never dump full pages, tokens, or raw payloads into chat.
|
||||
- This skill operates the Notion API. It does not build Notion integrations (application development) or cover other knowledge-base products.
|
||||
|
||||
## When not to use
|
||||
|
||||
- **Building Notion integrations or apps** (OAuth flows, custom blocks, public API products, block-tree composition beyond property updates) — that is application development; see [backend-engineering](../backend-engineering/SKILL.md) for service design.
|
||||
- **Other knowledge bases and document tools** (Confluence, Google Docs, wikis) — each has its own tooling; this skill covers Notion only.
|
||||
- **Workspace administration** (user management, workspace settings, integration approval) — that is the Notion admin console.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "notion",
|
||||
"evals": [
|
||||
{
|
||||
"id": "page-retrieval",
|
||||
"prompt": "A user asks: 'Show me what the on-call runbook page says. I have the page ID page-9f8e7d6c5b4a3210.'",
|
||||
"expected_output": "Run notion-cli pages get --page-id page-9f8e7d6c5b4a3210 and report the page title, URL, last-edited time, and a summary of the meaningful property values. The response quotes short values with the page ID for verification and does not dump the raw block tree or full property payload. If the page is not accessible, it checks whether the page is shared with the integration before concluding it is missing.",
|
||||
"assertions": [
|
||||
"The page is retrieved via notion-cli pages get with the exact page ID",
|
||||
"The response summarizes the title, URL, and property values without dumping raw payloads",
|
||||
"Quotes are short and include the page ID for verification",
|
||||
"An inaccessible page is checked against the integration sharing model before being called missing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "database-query",
|
||||
"prompt": "A user asks: 'How many open bugs are tracked in our issues database (db-1234), and what are the five oldest? Show me their titles and statuses.'",
|
||||
"expected_output": "Run notion-cli databases query --database-id db-1234 with a bounded --limit (at least 20) and optionally a JSON filter on the Status property equals 'Open' to narrow the answer. Report the row count visible under the cap, then the five oldest rows by created time with title and status. The response notes whether has_more indicates further rows beyond the cap and offers to page with a higher limit. Reads only — nothing is updated.",
|
||||
"assertions": [
|
||||
"The database is queried via notion-cli databases query with the database ID",
|
||||
"The query uses a bounded --limit and reports has_more state",
|
||||
"A Status filter is used when narrowing to open bugs",
|
||||
"The response is read-only and offers to page if rows exceed the cap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workspace-search",
|
||||
"prompt": "A user asks: 'Search Notion for anything about the postmortem for last week's checkout outage. I do not remember where we wrote it.'",
|
||||
"expected_output": "Run notion-cli search query with terms derived from the request (e.g. 'postmortem checkout outage') and a bounded --limit. Report each result as page or database with title, ID, and URL, noting how many results were returned versus the cap. If the term set is too broad, suggest a more specific query or narrowing keywords. The response stays read-only.",
|
||||
"assertions": [
|
||||
"Search is run via notion-cli search query with terms derived from the user's topic",
|
||||
"Each result identifies object type (page/database), title, ID, and URL",
|
||||
"The bounded cap and has_more state are reported",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "guarded-property-update",
|
||||
"prompt": "A user asks: 'Mark the runbook page page-9f8e7d6c5b4a3210 as Reviewed and set the reviewer to me. The current status is Draft.'",
|
||||
"expected_output": "The agent builds the properties payload ({'Status': {'select': {'name': 'Reviewed'}}, 'Reviewer': {'rich_text': [{'text': {'content': '<user>'}}]}}), states the current value (Draft) and the replacement, then previews via notion-cli pages update --dry-run and asks for explicit confirmation. Only after the user confirms does it run pages update --yes, then verifies with pages get that the properties changed. If the user only asked to draft the change, nothing is written.",
|
||||
"assertions": [
|
||||
"The exact properties payload is previewed with --dry-run before any update",
|
||||
"The current value and replacement are stated before confirmation",
|
||||
"The update runs only after explicit user confirmation, via --yes",
|
||||
"The result is verified with a follow-up pages get"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "page-creation-confirmed",
|
||||
"prompt": "A user asks: 'Add a row to the incident log database db-5678 titled \"Checkout latency spike\" so we can track it.'",
|
||||
"expected_output": "The agent resolves the parent (database db-5678), builds the page payload with the title property, previews it with notion-cli pages create --parent-database db-5678 --title 'Checkout latency spike' --dry-run, and asks for confirmation. After explicit confirmation it runs the create with --yes and reports the new page ID and URL. It does not create anything if the user only asked for a draft.",
|
||||
"assertions": [
|
||||
"The create payload is previewed with --dry-run before any request",
|
||||
"The parent database and title are explicit in the preview",
|
||||
"Creation happens only after explicit user confirmation, via --yes",
|
||||
"The response reports the new page ID and URL as delivery evidence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "access-model-triage",
|
||||
"prompt": "A user asks: 'The integration can't find page-1111. Did we delete it?' The search API returns no result for the page title they expect.",
|
||||
"expected_output": "The response distinguishes the failure modes: a 404/object_not_found from the Notion API usually means the page exists in the workspace but is not shared with the integration, not that it was deleted. It advises checking the page's sharing settings (add the integration to the page or its parent), then re-running notion-cli pages get or search. It does not claim the page is deleted without evidence, and it never creates or updates anything during triage.",
|
||||
"assertions": [
|
||||
"object_not_found is explained as an access-model result rather than proof of deletion",
|
||||
"The fix is to share the page with the integration and re-check",
|
||||
"Triage is read-only and no mutation is attempted",
|
||||
"Deletion is only concluded with concrete evidence"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Notion — Source Index
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
This skill is a distilled operating layer over Notion's public developer documentation. Facts and endpoint names in this skill are grounded in the sources below; refresh this index when Notion ships API changes.
|
||||
|
||||
| Topic | Source | URL |
|
||||
|---|---|---|
|
||||
| API reference (endpoints, versioning) | Notion API reference | https://developers.notion.com/reference |
|
||||
| Versioning and the `Notion-Version` header | API versioning | https://developers.notion.com/reference/versioning |
|
||||
| Authentication and integration tokens | Authorization | https://developers.notion.com/reference/authorization |
|
||||
| Pages (retrieve, create, update) | Page endpoints | https://developers.notion.com/reference/patch-page |
|
||||
| Database queries and filters | Query a database | https://developers.notion.com/reference/post-database-query |
|
||||
| Search | Search endpoint | https://developers.notion.com/reference/post-search |
|
||||
| Property types and values | Property value objects | https://developers.notion.com/reference/property-value-object |
|
||||
|
||||
## Refresh procedure
|
||||
|
||||
- Re-check the API reference when a call returns `validation_error` for a documented body shape or when `Notion-Version` deprecations are announced.
|
||||
- The `Notion-Version` header is a security-adjacent contract pin: before changing the default in `notion-cli`, verify the new version's property object shapes in the versioning page.
|
||||
- Update `research_checked` in `SKILL.md` frontmatter and this file's `Last Updated` when you verify the sources again.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Notion API Operations
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
Operational detail for the Notion API surface the skill owns: endpoints, the version header, pagination, property values, filters, and error handling. The bundled `notion-cli` implements this reference; use this document when a call behaves unexpectedly.
|
||||
|
||||
## Request conventions
|
||||
|
||||
- Base URL: `https://api.notion.com/v1`. Every request carries `Authorization: Bearer <integration_token>` and the `Notion-Version` header (default `2022-06-28`; `notion-cli` honors `NOTION_VERSION`).
|
||||
- Bodies are JSON. Reads with bodies (search, database query) are `POST`; single-object reads are `GET`; updates are `PATCH`; creates are `POST`.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
| Operation | Endpoint | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| Retrieve a page | `/pages/{id}` | GET | Summarized as id, title, url, timestamps |
|
||||
| Create a page | `/pages` | POST | Guarded mutation; `parent` is `page_id` or `database_id` |
|
||||
| Update page properties | `/pages/{id}` | PATCH | Guarded mutation; overwrites the given property values |
|
||||
| Query a database | `/databases/{id}/query` | POST | `page_size` cap + optional `filter` object |
|
||||
| Search | `/search` | POST | Finds pages and databases by text; `page_size` cap |
|
||||
|
||||
## Pagination and bounded reads
|
||||
|
||||
- Search and database queries take `page_size` (max 100) and return `has_more` plus a `next_cursor` when more rows exist.
|
||||
- **Bounded-read rule:** request only what the task needs; `notion-cli --limit` caps `page_size` at the request level. If a task needs more, raise the limit or page with the cursor, and stop when the question is answered.
|
||||
- Always report `has_more` when summarizing a query so the reader knows the cap hid further rows.
|
||||
|
||||
## Property values
|
||||
|
||||
- A page's `properties` is a map of property names to value objects. Title extraction: `notion-cli` looks for a property typed `title` (commonly named `title` or `Name`).
|
||||
- Common value objects for updates: `{"select": {"name": "..."}}`, `{"status": {"name": "..."}}`, `{"checkbox": true|false}`, `{"rich_text": [{"text": {"content": "..."}}]}`, `{"number": 42}`, `{"date": {"start": "2026-08-03"}}`.
|
||||
- An update `PATCH` sends only the properties you include; properties you omit are left unchanged. Omitted properties are safe; *wrong* values for included properties are the risk, so preview the exact payload with `--dry-run`.
|
||||
|
||||
## Filters
|
||||
|
||||
Database query filters are structured JSON, e.g.:
|
||||
|
||||
```json
|
||||
{"property": "Status", "select": {"equals": "Open"}}
|
||||
{"or": [
|
||||
{"property": "Priority", "select": {"equals": "High"}},
|
||||
{"property": "Priority", "select": {"equals": "Critical"}}
|
||||
]}
|
||||
```
|
||||
|
||||
A `--filter` file must be a single JSON object; `notion-cli` validates it parses before sending.
|
||||
|
||||
## Error handling
|
||||
|
||||
- HTTP 400 `validation_error`: the body shape or filter is wrong — the message names the offending field. Fix the payload, never retry blindly.
|
||||
- HTTP 404 `object_not_found`: almost always the page/database is **not shared with the integration**, not deleted. Check sharing settings before concluding data loss.
|
||||
- HTTP 401 `unauthorized`: token invalid or revoked — rotate the integration token.
|
||||
- HTTP 429 `rate_limited`: slow down; Notion rate limits per integration.
|
||||
- `notion-cli` exit 1 with a `Notion API HTTP <code>: <message>` line; the `--json` variant emits `{"ok": false, "error": "..."}`.
|
||||
|
||||
## Integration access model
|
||||
|
||||
- An integration sees exactly the pages and databases **shared with it**. Sharing a parent page shares descendants unless a child overrides.
|
||||
- Search only covers content the integration can access — a workspace-wide search from the app may find more than the API search will.
|
||||
- Tokens are workspace-scoped credentials; store in `NOTION_TOKEN`, never in code, chat, or commits. Revoke a leaked token in the integration settings.
|
||||
Executable
+321
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""notion-cli - bounded, agent-first command line for the Notion API.
|
||||
|
||||
Reads Notion pages and databases and, with explicit confirmation, creates or
|
||||
updates pages over HTTPS using only the Python standard library. Covers pages,
|
||||
databases (query), search, and page updates.
|
||||
|
||||
Design rules:
|
||||
|
||||
- Read-only by default. Every state-changing command (creating or updating a
|
||||
page) is a guarded mutation: it requires --dry-run to preview, then --yes to
|
||||
confirm. Mutation requires explicit confirmation.
|
||||
- Bounded reads: search and database queries cap results with --limit and
|
||||
never page past the requested cap.
|
||||
- --json emits machine-readable JSON; the default is human-readable text.
|
||||
- --help works with no NOTION_TOKEN set and makes no network calls.
|
||||
|
||||
Environment:
|
||||
NOTION_TOKEN Notion integration token (secret_...)
|
||||
NOTION_VERSION API version header (default: 2022-06-28)
|
||||
|
||||
Exit codes: 0 success, 1 Notion API error or failed check, 2 usage error.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
API_BASE = os.environ.get("NOTION_API_BASE", "https://api.notion.com/v1")
|
||||
DEFAULT_VERSION = "2022-06-28"
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
REQUEST_TIMEOUT = 15
|
||||
TEXT_TRUNCATE = 500
|
||||
|
||||
|
||||
class NotionError(Exception):
|
||||
"""Raised when the Notion API returns an error or transport fails."""
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
token = os.environ.get("NOTION_TOKEN", "")
|
||||
if not token:
|
||||
raise NotionError("NOTION_TOKEN environment variable is not set")
|
||||
return token
|
||||
|
||||
|
||||
def headers(token: str) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Notion-Version": os.environ.get("NOTION_VERSION", DEFAULT_VERSION),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def api_request(method: str, path: str, token: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Send a JSON request to a Notion API path and return the payload."""
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/{path.lstrip('/')}",
|
||||
data=data,
|
||||
headers=headers(token),
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.loads(error.read().decode("utf-8")).get("message", "")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
raise NotionError(f"Notion API HTTP {error.code}: {detail or error.reason}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise NotionError(f"Notion API unreachable: {error.reason}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise NotionError(f"Notion API returned non-JSON: {error}") from error
|
||||
|
||||
|
||||
def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def page_title(page: Dict[str, Any]) -> str:
|
||||
"""Best-effort title extraction from a Notion page object."""
|
||||
properties = page.get("properties", {})
|
||||
for key in ("title", "Name"):
|
||||
prop = properties.get(key)
|
||||
if not prop:
|
||||
continue
|
||||
if prop.get("type") == "title":
|
||||
parts = prop.get("title", [])
|
||||
if parts:
|
||||
return truncate(parts[0].get("plain_text", ""))
|
||||
return page.get("id", "")
|
||||
|
||||
|
||||
def summarize_page(page: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": page.get("id", ""),
|
||||
"title": page_title(page),
|
||||
"url": page.get("url", ""),
|
||||
"object": page.get("object", "page"),
|
||||
"last_edited_time": page.get("last_edited_time", ""),
|
||||
"created_time": page.get("created_time", ""),
|
||||
}
|
||||
|
||||
|
||||
def summarize_database(database: Dict[str, Any]) -> Dict[str, Any]:
|
||||
title_parts = database.get("title", [])
|
||||
title = truncate("".join(p.get("plain_text", "") for p in title_parts)) if title_parts else ""
|
||||
return {
|
||||
"id": database.get("id", ""),
|
||||
"title": title,
|
||||
"url": database.get("url", ""),
|
||||
"last_edited_time": database.get("last_edited_time", ""),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_pages_get(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
page = api_request("GET", f"pages/{args.page_id}", token)
|
||||
return {"ok": True, "page": summarize_page(page)}
|
||||
|
||||
|
||||
def cmd_pages_create(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
if not args.dry_run and not args.yes:
|
||||
raise NotionError(
|
||||
"refusing to create a page without confirmation: pass --dry-run to "
|
||||
"preview or --yes to confirm the mutation"
|
||||
)
|
||||
parent = {"type": "page_id", "page_id": args.parent_page} if args.parent_page \
|
||||
else {"type": "database_id", "database_id": args.parent_database}
|
||||
body: Dict[str, Any] = {"parent": parent, "properties": {}}
|
||||
if args.title:
|
||||
body["properties"]["title"] = {"title": [{"text": {"content": args.title}}]}
|
||||
if args.dry_run:
|
||||
return {"ok": True, "dry_run": True, "would_create": body}
|
||||
page = api_request("POST", "pages", token, body)
|
||||
return {"ok": True, "page": summarize_page(page)}
|
||||
|
||||
|
||||
def cmd_pages_update(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
if not args.dry_run and not args.yes:
|
||||
raise NotionError(
|
||||
"refusing to update a page without confirmation: pass --dry-run to "
|
||||
"preview or --yes to confirm the mutation"
|
||||
)
|
||||
try:
|
||||
with open(args.properties, "r", encoding="utf-8") as handle:
|
||||
properties = json.load(handle)
|
||||
except OSError as error:
|
||||
raise NotionError(f"cannot read properties file {args.properties}: {error}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise NotionError(f"properties file is not valid JSON: {error}") from error
|
||||
if not isinstance(properties, dict):
|
||||
raise NotionError("properties file must contain a JSON object of property values")
|
||||
body = {"properties": properties}
|
||||
if args.dry_run:
|
||||
return {"ok": True, "dry_run": True, "would_update": {"page_id": args.page_id, "properties": properties}}
|
||||
page = api_request("PATCH", f"pages/{args.page_id}", token, body)
|
||||
return {"ok": True, "page": summarize_page(page)}
|
||||
|
||||
|
||||
def cmd_databases_query(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
body: Dict[str, Any] = {"page_size": args.limit}
|
||||
if args.filter:
|
||||
try:
|
||||
with open(args.filter, "r", encoding="utf-8") as handle:
|
||||
body["filter"] = json.load(handle)
|
||||
except OSError as error:
|
||||
raise NotionError(f"cannot read filter file {args.filter}: {error}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise NotionError(f"filter file is not valid JSON: {error}") from error
|
||||
payload = api_request("POST", f"databases/{args.database_id}/query", token, body)
|
||||
pages = [summarize_page(p) for p in payload.get("results", [])]
|
||||
return {"ok": True, "database_id": args.database_id, "pages": pages,
|
||||
"has_more": payload.get("has_more", False)}
|
||||
|
||||
|
||||
def cmd_search(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
payload = api_request("POST", "search", token, {"query": args.query, "page_size": args.limit})
|
||||
results = []
|
||||
for item in payload.get("results", []):
|
||||
if item.get("object") == "database":
|
||||
results.append({"object": "database", **summarize_database(item)})
|
||||
else:
|
||||
results.append({"object": "page", **summarize_page(item)})
|
||||
return {"ok": True, "query": args.query, "results": results,
|
||||
"has_more": payload.get("has_more", False)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Output helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def emit(data: Dict[str, Any], json_mode: bool) -> int:
|
||||
if json_mode:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
_emit_human(data)
|
||||
return 0
|
||||
|
||||
|
||||
def _emit_human(data: Dict[str, Any]) -> None:
|
||||
if "page" in data and "dry_run" not in data:
|
||||
page = data["page"]
|
||||
print(f"{page['title']} <{page['id']}> {page['url']}")
|
||||
elif "pages" in data:
|
||||
print(f"database <{data['database_id']}> (has_more={data['has_more']}):")
|
||||
for page in data["pages"]:
|
||||
print(f" {page['title']} <{page['id']}>")
|
||||
elif "results" in data:
|
||||
print(f"search '{data['query']}' (has_more={data['has_more']}):")
|
||||
for result in data["results"]:
|
||||
print(f" [{result['object']}] {result['title']} <{result['id']}>")
|
||||
elif data.get("dry_run"):
|
||||
if "would_create" in data:
|
||||
print("DRY RUN (nothing created):")
|
||||
print(json.dumps(data["would_create"], indent=2))
|
||||
else:
|
||||
print("DRY RUN (nothing updated):")
|
||||
print(json.dumps(data["would_update"], indent=2))
|
||||
else:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="notion-cli",
|
||||
description=(
|
||||
"Bounded, agent-first CLI for the Notion API: pages, database "
|
||||
"queries, search, and guarded page updates. Read-only by default; "
|
||||
"create/update require --dry-run then --yes."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
|
||||
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
pages = sub.add_parser("pages", help="get, create, or update pages")
|
||||
page_sub = pages.add_subparsers(dest="action", required=True)
|
||||
|
||||
page_get = page_sub.add_parser("get", help="retrieve a page (read-only)")
|
||||
page_get.add_argument("--page-id", required=True, help="page ID (32-hex or UUID form)")
|
||||
|
||||
page_create = page_sub.add_parser("create", help="create a page (guarded mutation)")
|
||||
page_create.add_argument("--parent-page", help="parent page ID for the new page")
|
||||
page_create.add_argument("--parent-database", help="parent database ID for the new page")
|
||||
page_create.add_argument("--title", help="page title text")
|
||||
page_create.add_argument("--dry-run", action="store_true", help="preview the payload without creating")
|
||||
page_create.add_argument("--yes", action="store_true", help="confirm the mutation and create")
|
||||
|
||||
page_update = page_sub.add_parser("update", help="update page properties (guarded mutation)")
|
||||
page_update.add_argument("--page-id", required=True, help="page ID to update")
|
||||
page_update.add_argument("--properties", required=True,
|
||||
help="path to a JSON file of property values")
|
||||
page_update.add_argument("--dry-run", action="store_true", help="preview the payload without updating")
|
||||
page_update.add_argument("--yes", action="store_true", help="confirm the mutation and update")
|
||||
|
||||
databases = sub.add_parser("databases", help="query a database (read-only)")
|
||||
db_sub = databases.add_subparsers(dest="action", required=True)
|
||||
db_query = db_sub.add_parser("query", help="query database pages")
|
||||
db_query.add_argument("--database-id", required=True, help="database ID to query")
|
||||
db_query.add_argument("--filter", help="path to a JSON filter object")
|
||||
|
||||
search = sub.add_parser("search", help="search pages and databases (read-only)")
|
||||
search.add_argument("action", nargs="?", default="query", choices=["query"])
|
||||
search.add_argument("--query", required=True, help="search query text")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
|
||||
try:
|
||||
token = get_token()
|
||||
if args.command == "pages":
|
||||
if args.action == "get":
|
||||
result = cmd_pages_get(args, token)
|
||||
elif args.action == "create":
|
||||
result = cmd_pages_create(args, token)
|
||||
else:
|
||||
result = cmd_pages_update(args, token)
|
||||
elif args.command == "databases":
|
||||
result = cmd_databases_query(args, token)
|
||||
elif args.command == "search":
|
||||
result = cmd_search(args, token)
|
||||
else: # pragma: no cover - argparse prevents this
|
||||
parser.error(f"unknown command: {args.command}")
|
||||
return emit(result, args.json)
|
||||
except NotionError as error:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"notion-cli: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for notion/scripts/notion-cli.
|
||||
|
||||
Runs the script as a subprocess so the tests exercise the real CLI surface
|
||||
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
|
||||
stdlib HTTP server stubs the Notion API (pages, databases/query, search), so
|
||||
no external network or Notion workspace is needed. Also asserts the read-only
|
||||
contract: reads never call write methods, and the mutation gate refuses to
|
||||
create/update without --dry-run or --yes.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "notion-cli"
|
||||
|
||||
SAMPLE_PAGE = {
|
||||
"object": "page",
|
||||
"id": "page-1234",
|
||||
"url": "https://www.notion.so/page-1234",
|
||||
"created_time": "2026-01-01T00:00:00.000Z",
|
||||
"last_edited_time": "2026-01-02T00:00:00.000Z",
|
||||
"properties": {"title": {"type": "title", "title": [{"plain_text": "Meeting notes"}]}},
|
||||
}
|
||||
|
||||
|
||||
class StubNotionServer:
|
||||
"""Minimal stub of the Notion API surface used by notion-cli."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests = [] # (method, path, body) recorded by the stub
|
||||
handler = self._make_handler()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def _make_handler(self):
|
||||
stub = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _respond(self, payload, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def _read_json(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
stub.requests.append(("GET", self.path, None))
|
||||
if self.path.startswith("/pages/"):
|
||||
self._respond(SAMPLE_PAGE)
|
||||
else:
|
||||
self._respond({"message": "not_found"}, 404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
body = self._read_json()
|
||||
stub.requests.append(("POST", self.path, body))
|
||||
if self.path == "/search":
|
||||
results = [SAMPLE_PAGE]
|
||||
page_size = body.get("page_size", 20)
|
||||
self._respond({"results": results[:page_size], "has_more": False})
|
||||
elif "/query" in self.path:
|
||||
page_size = body.get("page_size", 20)
|
||||
self._respond({"results": [SAMPLE_PAGE][:page_size], "has_more": False})
|
||||
elif self.path == "/pages":
|
||||
created = dict(SAMPLE_PAGE, id="page-new")
|
||||
self._respond(created, 200)
|
||||
else:
|
||||
self._respond({"message": "not_found"}, 404)
|
||||
|
||||
def do_PATCH(self): # noqa: N802
|
||||
body = self._read_json()
|
||||
stub.requests.append(("PATCH", self.path, body))
|
||||
self._respond(SAMPLE_PAGE, 200)
|
||||
|
||||
def log_message(self, *args): # silence stderr
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
def run_script(env, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def base_env(stub):
|
||||
env = dict(os.environ)
|
||||
env["NOTION_TOKEN"] = "secret_test"
|
||||
env["NOTION_API_BASE"] = f"http://127.0.0.1:{stub.port}/"
|
||||
return env
|
||||
|
||||
|
||||
def load_json(proc):
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
class NotionCliTests(unittest.TestCase):
|
||||
def test_help_lists_json_and_bounded_reads(self):
|
||||
proc = run_script(dict(os.environ), "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
self.assertIn("--json", proc.stdout)
|
||||
self.assertIn("--limit", proc.stdout)
|
||||
|
||||
def test_help_works_without_token(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("NOTION_TOKEN", None)
|
||||
proc = run_script(env, "search", "query", "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
|
||||
def test_pages_get(self):
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "pages", "get", "--page-id", "page-1234")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["page"]["title"], "Meeting notes")
|
||||
self.assertEqual(data["page"]["id"], "page-1234")
|
||||
|
||||
def test_database_query_bounded(self):
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "--limit", "5", "databases", "query",
|
||||
"--database-id", "db-1")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["database_id"], "db-1")
|
||||
self.assertEqual(data["pages"][0]["id"], "page-1234")
|
||||
|
||||
def test_database_query_sends_page_size(self):
|
||||
with StubNotionServer() as stub:
|
||||
run_script(base_env(stub), "--json", "--limit", "3", "databases", "query",
|
||||
"--database-id", "db-1")
|
||||
posts = [body for method, path, body in stub.requests
|
||||
if method == "POST" and path == "/databases/db-1/query"]
|
||||
self.assertEqual(len(posts), 1)
|
||||
self.assertEqual(posts[0].get("page_size"), 3)
|
||||
|
||||
def test_search(self):
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "search", "query", "--query", "meeting")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["results"][0]["object"], "page")
|
||||
|
||||
def test_pages_create_requires_confirmation(self):
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "pages", "create", "--parent-page", "page-1",
|
||||
"--title", "New page")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("refusing to create", proc.stderr)
|
||||
self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
|
||||
|
||||
def test_pages_create_dry_run_does_not_post(self):
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "pages", "create", "--parent-page", "page-1",
|
||||
"--title", "New page", "--dry-run")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["dry_run"])
|
||||
self.assertEqual(stub.requests, [], "dry-run must not reach the API")
|
||||
|
||||
def test_pages_create_with_yes_posts(self):
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "pages", "create", "--parent-page", "page-1",
|
||||
"--title", "New page", "--yes")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertEqual(load_json(proc)["page"]["id"], "page-new")
|
||||
self.assertTrue(any(method == "POST" and path == "/pages" for method, path, _ in stub.requests))
|
||||
|
||||
def test_pages_update_requires_confirmation(self):
|
||||
props = ROOT / "tests" / "props.json"
|
||||
props.write_text(json.dumps({"Status": {"select": {"name": "Done"}}}))
|
||||
try:
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "pages", "update", "--page-id", "page-1234",
|
||||
"--properties", str(props))
|
||||
finally:
|
||||
props.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("refusing to update", proc.stderr)
|
||||
self.assertEqual(stub.requests, [])
|
||||
|
||||
def test_pages_update_with_yes_patches(self):
|
||||
props = ROOT / "tests" / "props.json"
|
||||
props.write_text(json.dumps({"Status": {"select": {"name": "Done"}}}))
|
||||
try:
|
||||
with StubNotionServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "pages", "update", "--page-id", "page-1234",
|
||||
"--properties", str(props), "--yes")
|
||||
finally:
|
||||
props.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertTrue(any(method == "PATCH" for method, _path, _body in stub.requests))
|
||||
|
||||
def test_missing_token_errors_cleanly(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("NOTION_TOKEN", None)
|
||||
proc = run_script(env, "--json", "search", "query", "--query", "x")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("NOTION_TOKEN", proc.stdout)
|
||||
|
||||
def test_read_only_contract_no_write_opens(self):
|
||||
source = SCRIPT.read_text()
|
||||
writes = [line for line in source.splitlines()
|
||||
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
|
||||
self.assertEqual(writes, [], "script must never open files in write mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
# Slack — Read and Post to Slack from the Terminal
|
||||
|
||||
Operate a Slack workspace without leaving your terminal or your agent's tool loop: list channels, read messages, follow threads, search history, find files, and verify that inbound webhook events are authentic.
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
Most agents have no way to answer the basic question "what did the team say about X in Slack?" — so they guess, or you paste screenshots. This skill gives your agent a real, bounded read path into a workspace (channels, messages, threads, search, files) plus a safe write path: sending a message is a guarded mutation that requires a preview and an explicit confirmation, so the agent can triage and answer without ever posting by accident.
|
||||
|
||||
It ships `slack-cli`, a small Python script that speaks the Slack Web API with no third-party dependencies. Reads are capped (`--limit`), output is clean JSON for the agent or readable text for you, and `--help` works with no token and no network. Webhook verification is built in: any event endpoint can prove a request really came from Slack using the standard HMAC-SHA256 signature check.
|
||||
|
||||
## What You Get
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | Agent-facing operating contract, mutation gates, and verification boundaries |
|
||||
| `references/` | Dated source index and a web API operations reference (methods, scopes, pagination, webhook verification) |
|
||||
| `scripts/slack-cli` | Bounded, stdlib-only CLI: channels, messages, threads, search, files, webhook verify; `--json`, `--limit`, sends gated by `--dry-run`/`--yes` |
|
||||
| `tests/` | 16 deterministic tests against a stub Slack API, covering the mutation gate and read-only contract |
|
||||
| `evals/evals.json` | Six output-quality evaluation cases for agent runs |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Help works with no token and no network
|
||||
slack/scripts/slack-cli --help
|
||||
|
||||
# Find the channel ID from a name (reads are capped at --limit)
|
||||
SLACK_TOKEN=xoxb-... slack/scripts/slack-cli --json --limit 10 channels list
|
||||
|
||||
# Read the latest messages in a channel
|
||||
SLACK_TOKEN=xoxb-... slack/scripts/slack-cli --json messages list --channel C12345
|
||||
|
||||
# Follow a thread (parent ts from the message list)
|
||||
SLACK_TOKEN=xoxb-... slack/scripts/slack-cli --json threads list --channel C12345 --ts 1712345678.000001
|
||||
|
||||
# Search history, bounded
|
||||
SLACK_TOKEN=xoxb-... slack/scripts/slack-cli --json search messages --query "incident"
|
||||
|
||||
# Send only with a preview first, then explicit confirmation
|
||||
SLACK_TOKEN=xoxb-... slack/scripts/slack-cli messages send --channel C12345 --text "on it" --dry-run
|
||||
SLACK_TOKEN=xoxb-... slack/scripts/slack-cli messages send --channel C12345 --text "on it" --yes
|
||||
|
||||
# Verify an inbound webhook before trusting it
|
||||
slack/scripts/slack-cli webhook verify --body-file body.json --signature "v0=..." --timestamp 1712345678
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
Load this skill for `slack` operations: "what was said in #channel", reading or listing messages and channels, following or summarizing threads, searching Slack history, finding shared files, posting a message or thread reply (with confirmation), and verifying `X-Slack-Signature` webhook events. Do not load it for building Slack apps or bots, workspace administration (user provisioning, org settings), or other chat platforms like Discord or Teams.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+ for `slack-cli` (stdlib only; `--help` and webhook verification need nothing else).
|
||||
- A Slack bot or user token (`SLACK_TOKEN`) with the scopes the read needs: `channels:read`, `channels:history`, `groups:read`, `groups:history`, `search:read`, `files:read`, and `chat:write` for sending. For `webhook verify`, the app signing secret (`SLACK_WEBHOOK_SECRET`).
|
||||
- Network access to `api.slack.com` for live reads and sends.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: slack
|
||||
description: >-
|
||||
Operate Slack workspaces from a terminal or agent: list channels, read
|
||||
messages, follow threads, search message history, list files, and verify
|
||||
inbound webhook signatures — with a bundled slack-cli script that is
|
||||
read-only by default and gates every send behind a --dry-run/--yes
|
||||
confirmation. Use when an agent needs to read or post Slack data, triage
|
||||
incidents, or answer questions about what was said in a workspace. Do not
|
||||
use for building Slack apps or bots (that is application development) or
|
||||
workspace administration like user provisioning and org settings (that is
|
||||
the Slack admin console).
|
||||
license: MIT
|
||||
compatibility: >-
|
||||
The bundled slack-cli script runs on Python 3.9+ with only the standard
|
||||
library. --help, channel/message/thread/search/file reads, and webhook
|
||||
signature verification need no network; live reads require a Slack bot/user
|
||||
token with the right scopes and network access to api.slack.com.
|
||||
metadata:
|
||||
source: https://api.slack.com/web
|
||||
source_index: references/00-source-index.md
|
||||
research_checked: "2026-08-03"
|
||||
---
|
||||
|
||||
# Slack Operations
|
||||
|
||||
Use this skill to read and, with explicit confirmation, write Slack data through the Slack Web API: channels, messages, threads, search, files, and webhook signature verification. This is a **tool skill** for the Slack platform. Building Slack apps and bots is application development; workspace administration (user provisioning, org-level settings, SSO) lives in the Slack admin console. This skill owns the everyday agent workflow: knowing what was said, finding it later, and posting a reply when a human confirms.
|
||||
|
||||
## Operating contract
|
||||
|
||||
1. **Read-only discovery before any mutation.** List channels, read history, follow threads, search, and list files freely. The bundled `slack-cli` script makes reads without writing anything.
|
||||
2. **Confirm the target, scope, and rollback path before acting.** Sending a message or replying in a thread changes shared workspace state visible to everyone: it requires an explicit human directive naming the channel, plus `--dry-run` preview and `--yes` confirmation through `slack-cli`. There is no "unsend" for team members who already read it.
|
||||
3. **Respect bounded reads.** Slack paginates; never page past what the task needs. `slack-cli --limit N` caps every listing, and responses summarize records rather than dumping raw payloads.
|
||||
4. **Verify webhooks before trusting them.** Any handler that accepts Slack events must verify `X-Slack-Signature` and `X-Slack-Request-Timestamp` against the app signing secret, or anyone who can reach the endpoint can forge events. `slack-cli webhook verify` does this check.
|
||||
5. **Keep evidence bounded.** Quote short message excerpts and IDs; never paste full threads, tokens, or file contents into chat.
|
||||
|
||||
## The slack-cli script
|
||||
|
||||
`scripts/slack-cli` is an agent-first, stdlib-only CLI over the Slack Web API. It covers the full issue scope: messages, channels, threads, search, files, and webhook verification.
|
||||
|
||||
```bash
|
||||
slack/scripts/slack-cli --help # no token or network needed
|
||||
slack/scripts/slack-cli --json --limit 10 channels list
|
||||
slack/scripts/slack-cli --json messages list --channel C12345
|
||||
slack/scripts/slack-cli --json threads list --channel C12345 --ts 1712345678.000001
|
||||
slack/scripts/slack-cli --json search messages --query "incident"
|
||||
slack/scripts/slack-cli --json files list --limit 5
|
||||
slack/scripts/slack-cli messages send --channel C12345 --text "on it" --dry-run # preview
|
||||
slack/scripts/slack-cli messages send --channel C12345 --text "on it" --yes # confirmed
|
||||
slack/scripts/slack-cli webhook verify --body-file body.json --signature "v0=..." --timestamp 1712345678
|
||||
```
|
||||
|
||||
Exit codes: 0 success, 1 API error or failed verification, 2 usage error. Sends are guarded: without `--dry-run` or `--yes` the script refuses with exit 1 and never calls the API. Reads are bounded by `--limit` (default 20, max 100).
|
||||
|
||||
## Operating loop
|
||||
|
||||
1. **Scope the workspace surface**: which channel(s) are relevant, what the question is (what was said, who said it, when), and whether any action is a mutation.
|
||||
2. **Read with bounds**: `channels list` to find IDs, `messages list`/`threads list` for history, `search messages` for cross-channel discovery. All read-only.
|
||||
3. **Triage the answer**: map the question to evidence (thread replies for context, search for the exact phrase, files list for shared artifacts).
|
||||
4. **Act with confirmation**: only a human directive to send, previewed with `--dry-run` and confirmed with `--yes`.
|
||||
5. **Verify**: confirm the posted message `ts`/channel in the response, or for webhooks confirm the signature check result before trusting the event.
|
||||
|
||||
## Messages, channels, threads
|
||||
|
||||
- **Channels** (`conversations.list`): public and private channels, archived state, member counts. Channel IDs (`C...`) are the stable key for every other call — resolve names to IDs before use.
|
||||
- **Messages** (`conversations.history`): newest-first history in a channel, one page at a time. Message records carry `ts` (the ID), `user`, and `text`. Use `--cursor` from the response metadata to page deliberately.
|
||||
- **Threads** (`conversations.replies`): replies keyed by the parent `ts`; the parent message is the first result. Thread replies keep `thread_ts` set to the parent.
|
||||
- **Sending** (`chat.postMessage`): the only mutation in this skill's surface. Always preview with `--dry-run`, confirm with `--yes`, and pass `--thread-ts` to reply in a thread instead of starting a new message. Verify the returned `ts` and channel.
|
||||
|
||||
## Search and files
|
||||
|
||||
- **Search** (`search.messages`): full-text search across visible history with Slack's search syntax (`from:`, `in:`, quoted phrases, `before:`/`after:`). Results include the matching channel; `total` tells you how many matches exist while `matches` stays bounded by `--limit`.
|
||||
- **Files** (`files.list`): files shared in the workspace, filterable by channel or user, with permalinks and sizes. Downloading file *content* is out of scope for the CLI (bounded reads); use it to find the file, then fetch the permalink with an authenticated request when a human asks for the content.
|
||||
|
||||
## Webhook verification
|
||||
|
||||
Slack signs every HTTP request to your event/command/interactivity endpoints. To verify:
|
||||
|
||||
1. Take the raw request body (exactly as received — do not re-encode).
|
||||
2. Check `X-Slack-Request-Timestamp` is within ~5 minutes of now (replay protection).
|
||||
3. Compute `v0=HMAC_SHA256(signing_secret, "v0:" + timestamp + ":" + body)` and compare with `X-Slack-Signature` using a constant-time comparison.
|
||||
4. Reject with 401 if the timestamp is stale or the signature mismatches.
|
||||
|
||||
`slack-cli webhook verify --body-file body.json --signature "v0=..." --timestamp <unix>` runs exactly this check against `SLACK_WEBHOOK_SECRET` (or `--secret`) and reports a constant-time-verified result. Always verify before trusting event payloads — unverified webhook endpoints accept forged events.
|
||||
|
||||
## Reference routing
|
||||
|
||||
| Load when | Reference |
|
||||
|---|---|
|
||||
| Sources, scope tables, refresh procedure | `references/00-source-index.md` |
|
||||
| API method surface, pagination, scopes, and webhook verification details | `references/01-web-api-operations.md` |
|
||||
|
||||
## Included artifacts
|
||||
|
||||
- `scripts/slack-cli`: bounded, stdlib-only CLI (messages, channels, threads, search, files, webhook verify; `--json`; `--limit`; send gated by `--dry-run`/`--yes`).
|
||||
- `tests/test_slack_cli.py`: 16 deterministic tests against a stub Slack API, including the mutation gate and the read-only contract.
|
||||
- `references/`: dated source index + web API operations reference.
|
||||
- `evals/evals.json`: six output-quality evaluation cases for agent runs.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
| Claim | Minimum evidence |
|
||||
|---|---|
|
||||
| A channel exists and its name | `slack-cli channels list --json` returns it with its `C...` ID |
|
||||
| A message was sent | `slack-cli messages send --yes` returns the new `ts` and channel, and `messages list` shows it |
|
||||
| A search found matches | `slack-cli search messages --query "..." --json` returns `total_matches` with bounded matches |
|
||||
| A webhook is authentic | `slack-cli webhook verify` exits 0 with `verified: true` for the exact body/signature/timestamp |
|
||||
| A file exists | `slack-cli files list --json` returns its `F...` ID and permalink |
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- Never send a message or thread reply without a human directive, `--dry-run` preview, and `--yes` confirmation — Slack posts are public, durable, and unreadable-back.
|
||||
- Never trust an inbound webhook without signature and timestamp verification.
|
||||
- Never page reads past `--limit`; never dump full threads, tokens, or file contents into chat.
|
||||
- This skill operates the Slack Web API. It does not build Slack apps (application development), manage users/org settings (admin console), or cover alternative channels platforms (that is their own tooling).
|
||||
|
||||
## When not to use
|
||||
|
||||
- **Building Slack apps or bots** (Block Kit, Bolt, slash-command apps, OAuth flow design) — that is application development; see [backend-engineering](../backend-engineering/SKILL.md) for service design.
|
||||
- **Workspace administration** (user provisioning, deprovisioning, org-level settings, SSO/SAML, data exports at the org level) — that is the Slack admin console, not the Web API.
|
||||
- **Other team-chat platforms** (Discord, Mattermost, Teams) — each has its own tooling; this skill covers Slack only.
|
||||
- **Company-wide policy on messaging or channel governance** — that is organizational policy, not an API operation.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "slack",
|
||||
"evals": [
|
||||
{
|
||||
"id": "channel-discovery",
|
||||
"prompt": "A user asks: 'Find the channel where the on-call team discusses incidents and post its name and ID. I believe it is something like #incidents or #oncall.'",
|
||||
"expected_output": "Run slack-cli channels list with a bounded --limit and match the channel name case-insensitively against the user's guesses, returning the exact channel name, its C-prefixed ID, whether it is private, and its member count. If more than one candidate matches, list all candidates with IDs and ask the user to disambiguate rather than guessing. Reads only — no messages are posted.",
|
||||
"assertions": [
|
||||
"The response resolves a channel name to its C-prefixed channel ID using slack-cli channels list",
|
||||
"Reads are bounded with --limit and the response never pages past the cap",
|
||||
"Multiple candidates are listed for the user to disambiguate instead of picking arbitrarily",
|
||||
"The operation is read-only and no message is sent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "message-history-read",
|
||||
"prompt": "A user asks: 'What did people say in #deployments in the last few hours? Summarize the discussion. Do not post anything.'",
|
||||
"expected_output": "Run slack-cli messages list --channel with the resolved channel ID and a bounded --limit, then summarize the returned messages in chronological order: who said what, and the timestamps. Quote short excerpts with message ts values so the user can jump to the source. The summary explicitly notes the cap (e.g. 'last 20 messages') and offers to page further with a cursor if needed. Nothing is posted.",
|
||||
"assertions": [
|
||||
"The channel name is resolved to an ID before reading history",
|
||||
"Messages are read with a bounded --limit and summarized in chronological order",
|
||||
"Short excerpts are quoted with their ts identifiers",
|
||||
"The response states the read cap and explicitly does not post anything"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "thread-follow-up",
|
||||
"prompt": "A user asks: 'Someone replied in the thread under message 1712345678.000001 in #support. What did they say and what is the open question?'",
|
||||
"expected_output": "Run slack-cli threads list --channel with the resolved support channel ID and --ts 1712345678.000001, bounded by --limit, and summarize the replies in order: each reply's author, text, and ts. The parent message is identified as the first result. The response flags the open question if one is visible, quotes short excerpts, and does not reply into the thread without an explicit request.",
|
||||
"assertions": [
|
||||
"The thread is read via slack-cli threads list with the parent ts",
|
||||
"Replies are summarized in order with authors and ts values",
|
||||
"The parent message is identified as the first result of the replies call",
|
||||
"The agent does not post into the thread without explicit confirmation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "search-history",
|
||||
"prompt": "A user asks: 'Search Slack history for when we last discussed the database migration and who was involved. I do not know which channel.'",
|
||||
"expected_output": "Run slack-cli search messages --query for terms like 'database migration' with a bounded --limit, and report each match: channel, author, timestamp, and a short excerpt. The response uses the total_matches count to indicate how many hits exist beyond the cap, offers to narrow the search with Slack syntax (in:, from:, before:/after:) if the hit list is large, and stays read-only.",
|
||||
"assertions": [
|
||||
"Slack search is run with a bounded --limit and the query is derived from the user's topic",
|
||||
"Each match reports channel, author, timestamp, and a short excerpt",
|
||||
"The total match count is used to suggest narrowing the query",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "guarded-send",
|
||||
"prompt": "A user asks: 'Post to #incidents that we are investigating the checkout failure and will update in 30 minutes.'",
|
||||
"expected_output": "The agent resolves #incidents to its channel ID, then presents a preview of the exact message (channel, text, and that it will be posted as a new message, not a thread reply) via slack-cli messages send --dry-run and asks the user to confirm. Only after explicit confirmation does it run messages send --yes and report the returned ts and channel. If the user only asked to draft or preview, nothing is posted.",
|
||||
"assertions": [
|
||||
"The exact message is previewed with --dry-run before any send",
|
||||
"The send happens only after explicit user confirmation, via --yes",
|
||||
"The response reports the posted message ts and channel as delivery evidence",
|
||||
"No API call is made without confirmation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "webhook-verification",
|
||||
"prompt": "A user runs an endpoint that receives Slack events and asks: 'An event just arrived with X-Slack-Request-Timestamp 1712345678 and X-Slack-Signature v0=... The raw body is in body.json. Is it really from Slack?'",
|
||||
"expected_output": "Run slack-cli webhook verify with the exact raw body file, the signature header value, and the timestamp, using the app signing secret (SLACK_WEBHOOK_SECRET or --secret). Explain that verification computes HMAC-SHA256 over 'v0:' + timestamp + ':' + the exact raw body and compares constant-time with the header. A pass reports verified: true and the event may be processed; a fail or stale-timestamp error means the request must be rejected with 401 and treated as forged.",
|
||||
"assertions": [
|
||||
"The exact raw body, signature, and timestamp are passed to slack-cli webhook verify",
|
||||
"The HMAC-SHA256 verification construction (v0:timestamp:body) is explained",
|
||||
"A verified result is distinguished from a rejection with 401 on failure or stale timestamp",
|
||||
"The response does not re-encode or reformat the body before verification"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Slack — Source Index
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
This skill is a distilled operating layer over Slack's public developer documentation. Facts and method names in this skill are grounded in the sources below; refresh this index when Slack ships API changes.
|
||||
|
||||
| Topic | Source | URL |
|
||||
|---|---|---|
|
||||
| Web API overview and conventions | Slack Web API documentation | https://api.slack.com/web |
|
||||
| Method catalog (conversations, chat, search, files) | Slack Method Reference | https://api.slack.com/methods |
|
||||
| Scopes and tokens | Slack Token & Scopes docs | https://api.slack.com/authentication/token-types |
|
||||
| Webhook signing (signature verification) | Verifying requests from Slack | https://api.slack.com/authentication/verifying-requests-from-slack |
|
||||
| Pagination | Paging through collections | https://docs.slack.dev/web/using-the-web-api/#pagination |
|
||||
|
||||
## Refresh procedure
|
||||
|
||||
- Re-check the method reference when a call returns `method_not_supported` or a scope error for a documented method.
|
||||
- Re-check the signing-verification page before changing anything in `webhook verify`; the `v0` signing scheme is a security boundary.
|
||||
- Update `research_checked` in `SKILL.md` frontmatter and this file's `Last Updated` when you verify the sources again.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Slack Web API Operations
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
Operational detail for the Slack Web API surface the skill owns: methods, scopes, pagination, error handling, and webhook signature verification. The bundled `slack-cli` implements this reference; use this document when a call behaves unexpectedly or you need the exact scope/method contract.
|
||||
|
||||
## Method surface
|
||||
|
||||
All calls POST form-encoded fields to `https://slack.com/api/<method>` with the token as `Authorization: Bearer` and read the JSON response; `ok: false` plus `error` is the error shape.
|
||||
|
||||
| Operation | Method | Required scopes | Notes |
|
||||
|---|---|---|---|
|
||||
| List channels | `conversations.list` | `channels:read`, `groups:read` | `types` selects public/private/IM/MPIM; `exclude_archived` filters |
|
||||
| Read history | `conversations.history` | `channels:history`, `groups:history` | Newest first; returns `messages` + `response_metadata.next_cursor` |
|
||||
| Thread replies | `conversations.replies` | `channels:history`, `groups:history` | Parent message is the first result; `ts` is the parent timestamp |
|
||||
| Post message | `chat.postMessage` | `chat:write` | Guarded mutation; `thread_ts` replies in a thread |
|
||||
| Search messages | `search.messages` | `search:read` | Supports `in:`, `from:`, `before:`/`after:`, quoted phrases |
|
||||
| List files | `files.list` | `files:read` | Optionally filter by channel or user |
|
||||
|
||||
## Pagination and bounded reads
|
||||
|
||||
- Every listing method returns at most `limit` results per page (max 100 for most) plus `response_metadata.next_cursor`.
|
||||
- **Bounded-read rule:** request only what the task needs; `slack-cli --limit` caps at the request level. If a task needs more, page explicitly with `--cursor`, and stop when the question is answered.
|
||||
- Search returns `total` (total matches) alongside the bounded `matches` array — report the total, return only the cap.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `ok: false` with an `error` string: `invalid_auth` (token bad/expired), `missing_scope` (token lacks the scope — the exact scope is in the response `needed`/`provided` fields), `channel_not_found`, `not_in_channel`, `ratelimited` (429 — back off and retry with `Retry-After`).
|
||||
- `is_ratelimited: true` in a 200 response: the method was throttled; slow down.
|
||||
- Never retry a failed send blindly: `chat.postMessage` can be retried with the same `text` (it is not idempotent in the strict sense), so confirm state via `conversations.history` before re-sending.
|
||||
|
||||
## Webhook signature verification
|
||||
|
||||
Slack signs every outbound HTTP request to your endpoints (events, slash commands, interactivity). Algorithm per [Verifying requests from Slack](https://api.slack.com/authentication/verifying-requests-from-slack):
|
||||
|
||||
1. Take the **exact raw request body** bytes — any re-encoding (JSON pretty-print, charset change) breaks the signature.
|
||||
2. Reject if `|now - X-Slack-Request-Timestamp| > 300` seconds (replay window).
|
||||
3. Compute `v0=HMAC_SHA256(signing_secret, "v0:" + timestamp + ":" + body)`.
|
||||
4. Compare with `X-Slack-Signature` using a constant-time comparison (`hmac.compare_digest`).
|
||||
|
||||
The app signing secret lives in the Slack app settings (Basic Information → App Credentials → Signing Secret) and is distinct from the bot token. Never log the secret, the signature comparison, or full webhook bodies; `slack-cli webhook verify` reports a boolean result.
|
||||
|
||||
## Token and scope hygiene
|
||||
|
||||
- Bot tokens (`xoxb-`) act as the app; user tokens (`xoxp-`) act as a user. Scope needs differ: reading public channels needs `channels:history`; reading private channels needs `groups:history` on a bot that has been added to the channel.
|
||||
- Store tokens in the environment (`SLACK_TOKEN`), never in code, chat, or commit messages. Revoke and rotate a token that leaks — it is a credential, not a config value.
|
||||
Executable
+400
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""slack-cli - bounded, agent-first command line for the Slack Web API.
|
||||
|
||||
Reads Slack data and, with explicit confirmation, sends messages over HTTPS
|
||||
using only the Python standard library. Covers messages, channels, threads,
|
||||
search, files, and webhook signature verification.
|
||||
|
||||
Design rules:
|
||||
|
||||
- Read-only by default. Every state-changing command (sending a message or
|
||||
replying in a thread) is a guarded mutation: it requires --dry-run to
|
||||
preview, then --yes to confirm. Mutation requires explicit confirmation.
|
||||
- Bounded reads: every listing command caps results with --limit and never
|
||||
pages past the requested cap.
|
||||
- --json emits machine-readable JSON; the default is human-readable text.
|
||||
- --help works with no SLACK_TOKEN set and makes no network calls.
|
||||
|
||||
Environment:
|
||||
SLACK_TOKEN Slack bot/user token (xoxb-... / xoxp-...)
|
||||
SLACK_WEBHOOK_SECRET Slack app signing secret (for `webhook verify`)
|
||||
|
||||
Exit codes: 0 success, 1 Slack API error or failed check, 2 usage error.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
API_BASE = os.environ.get("SLACK_API_BASE", "https://slack.com/api")
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
REQUEST_TIMEOUT = 15
|
||||
WEBHOOK_MAX_AGE_SECONDS = 300
|
||||
TEXT_TRUNCATE = 500
|
||||
|
||||
|
||||
class SlackError(Exception):
|
||||
"""Raised when the Slack API returns ok:false or transport fails."""
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
token = os.environ.get("SLACK_TOKEN", "")
|
||||
if not token:
|
||||
raise SlackError("SLACK_TOKEN environment variable is not set")
|
||||
return token
|
||||
|
||||
|
||||
def api_call(method: str, fields: Dict[str, str], token: str) -> Dict[str, Any]:
|
||||
"""POST form fields to a Slack API method and return the JSON payload."""
|
||||
data = urllib.parse.urlencode(fields).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/{method}",
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
raise SlackError(
|
||||
f"Slack API HTTP {error.code}: {error.read().decode('utf-8', 'replace')}"
|
||||
) from error
|
||||
except urllib.error.URLError as error:
|
||||
raise SlackError(f"Slack API unreachable: {error.reason}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise SlackError(f"Slack API returned non-JSON: {error}") from error
|
||||
if not payload.get("ok"):
|
||||
raise SlackError(f"Slack API error: {payload.get('error', 'unknown')}")
|
||||
return payload
|
||||
|
||||
|
||||
def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def summarize_message(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"ts": item.get("ts", ""),
|
||||
"user": item.get("user", ""),
|
||||
"type": item.get("type", "message"),
|
||||
"channel": item.get("channel", ""),
|
||||
"text": truncate(item.get("text", "")),
|
||||
"thread_ts": item.get("thread_ts", ""),
|
||||
"reply_count": item.get("reply_count"),
|
||||
}
|
||||
|
||||
|
||||
def summarize_channel(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": item.get("id", ""),
|
||||
"name": item.get("name", ""),
|
||||
"is_channel": item.get("is_channel", False),
|
||||
"is_private": item.get("is_private", False),
|
||||
"is_archived": item.get("is_archived", False),
|
||||
"num_members": item.get("num_members"),
|
||||
}
|
||||
|
||||
|
||||
def summarize_file(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": item.get("id", ""),
|
||||
"name": item.get("name", ""),
|
||||
"title": truncate(item.get("title", "")),
|
||||
"filetype": item.get("filetype", ""),
|
||||
"size": item.get("size"),
|
||||
"created": item.get("created"),
|
||||
"permalink": item.get("permalink", ""),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_channels_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
payload = api_call(
|
||||
"conversations.list",
|
||||
{
|
||||
"types": args.types,
|
||||
"limit": str(args.limit),
|
||||
"exclude_archived": "true" if args.exclude_archived else "false",
|
||||
},
|
||||
token,
|
||||
)
|
||||
channels = [summarize_channel(c) for c in payload.get("channels", [])]
|
||||
return {"ok": True, "channels": channels, "response_metadata": payload.get("response_metadata", {})}
|
||||
|
||||
|
||||
def cmd_messages_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
fields = {"channel": args.channel, "limit": str(args.limit)}
|
||||
if args.cursor:
|
||||
fields["cursor"] = args.cursor
|
||||
payload = api_call("conversations.history", fields, token)
|
||||
messages = [summarize_message(m) for m in payload.get("messages", [])]
|
||||
return {"ok": True, "channel": args.channel, "messages": messages,
|
||||
"response_metadata": payload.get("response_metadata", {})}
|
||||
|
||||
|
||||
def cmd_messages_send(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
if not args.dry_run and not args.yes:
|
||||
raise SlackError(
|
||||
"refusing to send without confirmation: pass --dry-run to preview "
|
||||
"or --yes to confirm the mutation"
|
||||
)
|
||||
if args.dry_run:
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"would_post": {
|
||||
"channel": args.channel,
|
||||
"thread_ts": args.thread_ts or None,
|
||||
"text": truncate(args.text),
|
||||
},
|
||||
}
|
||||
fields = {"channel": args.channel, "text": args.text}
|
||||
if args.thread_ts:
|
||||
fields["thread_ts"] = args.thread_ts
|
||||
payload = api_call("chat.postMessage", fields, token)
|
||||
return {
|
||||
"ok": True,
|
||||
"ts": payload.get("ts", ""),
|
||||
"channel": payload.get("channel", ""),
|
||||
"message": summarize_message(payload.get("message", {})),
|
||||
}
|
||||
|
||||
|
||||
def cmd_threads_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
fields = {"channel": args.channel, "ts": args.ts, "limit": str(args.limit)}
|
||||
if args.cursor:
|
||||
fields["cursor"] = args.cursor
|
||||
payload = api_call("conversations.replies", fields, token)
|
||||
messages = [summarize_message(m) for m in payload.get("messages", [])]
|
||||
return {"ok": True, "channel": args.channel, "thread_ts": args.ts, "messages": messages,
|
||||
"response_metadata": payload.get("response_metadata", {})}
|
||||
|
||||
|
||||
def cmd_search(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
payload = api_call(
|
||||
"search.messages",
|
||||
{"query": args.query, "count": str(args.limit), "sort": "timestamp", "sort_dir": "desc"},
|
||||
token,
|
||||
)
|
||||
matches = payload.get("messages", {}).get("matches", [])
|
||||
results = [summarize_message(m) for m in matches[: args.limit]]
|
||||
return {
|
||||
"ok": True,
|
||||
"query": args.query,
|
||||
"total_matches": payload.get("messages", {}).get("total", 0),
|
||||
"matches": results,
|
||||
}
|
||||
|
||||
|
||||
def cmd_files_list(args: argparse.Namespace, token: str) -> Dict[str, Any]:
|
||||
fields = {"limit": str(args.limit), "show_files_hidden_by_limit": "true"}
|
||||
if args.channel:
|
||||
fields["channel"] = args.channel
|
||||
if args.user:
|
||||
fields["user"] = args.user
|
||||
payload = api_call("files.list", fields, token)
|
||||
files = [summarize_file(f) for f in payload.get("files", [])]
|
||||
return {"ok": True, "files": files, "response_metadata": payload.get("response_metadata", {})}
|
||||
|
||||
|
||||
def verify_webhook_signature(body: bytes, signature: str, timestamp: str, secret: str) -> Dict[str, Any]:
|
||||
"""Verify a Slack request against the app signing secret (HMAC-SHA256).
|
||||
|
||||
Slack signs the request as "v0=hex(HMAC_SHA256(secret, 'v0:' + timestamp
|
||||
+ ':' + body))" and also sends X-Slack-Request-Timestamp. The timestamp
|
||||
is checked for replay freshness before the signature is compared.
|
||||
"""
|
||||
if not signature.startswith("v0="):
|
||||
raise SlackError(f"unsupported signature format: {signature[:16]!r}")
|
||||
try:
|
||||
if abs(int(time.time()) - int(timestamp)) > WEBHOOK_MAX_AGE_SECONDS:
|
||||
raise SlackError(
|
||||
f"webhook timestamp is outside the {WEBHOOK_MAX_AGE_SECONDS}s replay window"
|
||||
)
|
||||
except ValueError as error:
|
||||
raise SlackError(f"webhook timestamp is not a valid Unix timestamp: {timestamp!r}") from error
|
||||
base = f"v0:{timestamp}:".encode("utf-8") + body
|
||||
expected = "v0=" + hmac.new(secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
|
||||
valid = hmac.compare_digest(expected, signature)
|
||||
if not valid:
|
||||
raise SlackError("webhook signature does not match the signing secret")
|
||||
return {"ok": True, "verified": True, "timestamp": timestamp}
|
||||
|
||||
|
||||
def cmd_webhook_verify(args: argparse.Namespace, unused_token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
body = open(args.body_file, "rb").read()
|
||||
except OSError as error:
|
||||
raise SlackError(f"cannot read body file {args.body_file}: {error}") from error
|
||||
secret = args.secret or os.environ.get("SLACK_WEBHOOK_SECRET", "")
|
||||
if not secret:
|
||||
raise SlackError(
|
||||
"no webhook secret available: pass --secret or set SLACK_WEBHOOK_SECRET"
|
||||
)
|
||||
return verify_webhook_signature(body, args.signature, args.timestamp, secret)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Output helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def emit(data: Dict[str, Any], json_mode: bool) -> int:
|
||||
if json_mode:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
_emit_human(data)
|
||||
return 0
|
||||
|
||||
|
||||
def _emit_human(data: Dict[str, Any]) -> None:
|
||||
if "channels" in data:
|
||||
for channel in data["channels"]:
|
||||
flag = "#" if channel.get("is_channel") else "🔒" if channel.get("is_private") else "?"
|
||||
print(f"{flag} {channel['name']} <{channel['id']}> members={channel.get('num_members') or '?'}")
|
||||
elif "messages" in data and "thread_ts" not in data:
|
||||
for message in data["messages"]:
|
||||
print(f"[{message['ts']}] <{message['user']}> {message['text']}")
|
||||
elif "messages" in data:
|
||||
for message in data["messages"]:
|
||||
print(f"[{message['ts']}] <{message['user']}> (reply) {message['text']}")
|
||||
elif "matches" in data:
|
||||
print(f"total matches: {data['total_matches']}")
|
||||
for match in data["matches"]:
|
||||
print(f"[{match['ts']}] <{match['user']}> {match['text']}")
|
||||
elif "files" in data:
|
||||
for item in data["files"]:
|
||||
print(f"{item['name']} <{item['id']}> {item['filetype']} {item['size']} bytes")
|
||||
elif data.get("dry_run"):
|
||||
print("DRY RUN (no message sent):")
|
||||
print(f" channel: {data['would_post']['channel']}")
|
||||
print(f" text: {data['would_post']['text']}")
|
||||
elif "ts" in data and "message" in data:
|
||||
print(f"posted to <{data['channel']}> as message {data['ts']}")
|
||||
elif data.get("verified"):
|
||||
print("webhook signature verified (HMAC-SHA256 match)")
|
||||
else:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="slack-cli",
|
||||
description=(
|
||||
"Bounded, agent-first CLI for the Slack Web API: messages, channels, "
|
||||
"threads, search, files, and webhook signature verification. "
|
||||
"Read-only by default; sending requires --dry-run then --yes."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
|
||||
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
channels = sub.add_parser("channels", help="list channels (read-only)")
|
||||
channels.add_argument("action", nargs="?", default="list", choices=["list"])
|
||||
channels.add_argument("--types", default="public_channel,private_channel",
|
||||
help="comma-separated channel types (default: public,private)")
|
||||
channels.add_argument("--exclude-archived", action="store_true",
|
||||
help="exclude archived channels")
|
||||
channels.add_argument("--cursor", help="pagination cursor from a previous response")
|
||||
|
||||
messages = sub.add_parser("messages", help="list or send messages")
|
||||
msg_sub = messages.add_subparsers(dest="action", required=True)
|
||||
|
||||
msg_list = msg_sub.add_parser("list", help="list messages in a channel (read-only)")
|
||||
msg_list.add_argument("--channel", required=True, help="channel ID (C...)")
|
||||
msg_list.add_argument("--cursor", help="pagination cursor from a previous response")
|
||||
|
||||
msg_send = msg_sub.add_parser("send", help="send a message (guarded mutation)")
|
||||
msg_send.add_argument("--channel", required=True, help="channel ID (C...)")
|
||||
msg_send.add_argument("--text", required=True, help="message text")
|
||||
msg_send.add_argument("--thread-ts", help="reply in a thread at this ts")
|
||||
msg_send.add_argument("--dry-run", action="store_true", help="preview the message without sending")
|
||||
msg_send.add_argument("--yes", action="store_true", help="confirm the mutation and send")
|
||||
|
||||
threads = sub.add_parser("threads", help="list replies in a thread (read-only)")
|
||||
threads.add_argument("action", nargs="?", default="list", choices=["list"])
|
||||
threads.add_argument("--channel", required=True, help="channel ID (C...)")
|
||||
threads.add_argument("--ts", required=True, help="parent message timestamp (ts)")
|
||||
threads.add_argument("--cursor", help="pagination cursor from a previous response")
|
||||
|
||||
search = sub.add_parser("search", help="search messages (read-only)")
|
||||
search.add_argument("action", nargs="?", default="messages", choices=["messages"])
|
||||
search.add_argument("--query", required=True, help="search query (see Slack search syntax)")
|
||||
|
||||
files = sub.add_parser("files", help="list files (read-only)")
|
||||
files.add_argument("action", nargs="?", default="list", choices=["list"])
|
||||
files.add_argument("--channel", help="restrict to files in a channel")
|
||||
files.add_argument("--user", help="restrict to files shared by a user")
|
||||
|
||||
webhook = sub.add_parser("webhook", help="verify a Slack webhook request (read-only)")
|
||||
wv_sub = webhook.add_subparsers(dest="action", required=True)
|
||||
wv_verify = wv_sub.add_parser("verify", help="verify X-Slack-Signature (HMAC-SHA256)")
|
||||
wv_verify.add_argument("--body-file", required=True,
|
||||
help="path to the raw webhook request body")
|
||||
wv_verify.add_argument("--signature", required=True,
|
||||
help="X-Slack-Signature header value (v0=...)")
|
||||
wv_verify.add_argument("--timestamp", required=True,
|
||||
help="X-Slack-Request-Timestamp header value (Unix seconds)")
|
||||
wv_verify.add_argument("--secret", help="signing secret (default: $SLACK_WEBHOOK_SECRET)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
|
||||
try:
|
||||
if args.command == "webhook":
|
||||
result = cmd_webhook_verify(args, "")
|
||||
else:
|
||||
token = get_token()
|
||||
if args.command == "channels":
|
||||
result = cmd_channels_list(args, token)
|
||||
elif args.command == "messages":
|
||||
result = cmd_messages_list(args, token) if args.action == "list" else cmd_messages_send(args, token)
|
||||
elif args.command == "threads":
|
||||
result = cmd_threads_list(args, token)
|
||||
elif args.command == "search":
|
||||
result = cmd_search(args, token)
|
||||
elif args.command == "files":
|
||||
result = cmd_files_list(args, token)
|
||||
else: # pragma: no cover - argparse prevents this
|
||||
parser.error(f"unknown command: {args.command}")
|
||||
return emit(result, args.json)
|
||||
except SlackError as error:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"slack-cli: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for slack/scripts/slack-cli.
|
||||
|
||||
Runs the script as a subprocess so the tests exercise the real CLI surface
|
||||
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
|
||||
stdlib HTTP server stubs the Slack Web API methods, so no external network or
|
||||
Slack workspace is needed. Also asserts the read-only contract: the script
|
||||
never opens files in write mode, and the mutation gate refuses to send without
|
||||
--dry-run or --yes.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "slack-cli"
|
||||
|
||||
VALID_CHANNEL = {"id": "C123", "name": "general", "is_channel": True, "is_private": False, "num_members": 42}
|
||||
VALID_MESSAGE = {"ts": "1712345678.000001", "user": "U1", "type": "message",
|
||||
"channel": "C123", "text": "hello from tests", "thread_ts": ""}
|
||||
|
||||
|
||||
class StubSlackServer:
|
||||
"""Minimal read-only stub of the Slack Web API surface."""
|
||||
|
||||
def __init__(self):
|
||||
self.posted = [] # (method, fields) recorded by the stub
|
||||
handler = self._make_handler()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def _make_handler(self):
|
||||
stub = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
fields = {k: v for k, v in urllib.parse.parse_qsl(raw.decode("utf-8"))}
|
||||
method = self.path.strip("/")
|
||||
stub.posted.append((method, fields))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
if method == "conversations.list":
|
||||
payload = {"ok": True, "channels": [VALID_CHANNEL],
|
||||
"response_metadata": {"next_cursor": ""}}
|
||||
elif method == "conversations.history":
|
||||
payload = {"ok": True, "messages": [VALID_MESSAGE],
|
||||
"response_metadata": {"next_cursor": ""}}
|
||||
elif method == "conversations.replies":
|
||||
payload = {"ok": True, "messages": [dict(VALID_MESSAGE, thread_ts="1712345678.000001")],
|
||||
"response_metadata": {"next_cursor": ""}}
|
||||
elif method == "chat.postMessage":
|
||||
payload = {"ok": True, "ts": "1712345679.000002", "channel": fields.get("channel", ""),
|
||||
"message": {"ts": "1712345679.000002", "user": "U1", "type": "message",
|
||||
"text": fields.get("text", "")}}
|
||||
elif method == "search.messages":
|
||||
payload = {"ok": True,
|
||||
"messages": {"total": 1, "matches": [dict(VALID_MESSAGE, channel="C123")]}}
|
||||
elif method == "files.list":
|
||||
payload = {"ok": True, "files": [{"id": "F1", "name": "notes.md", "title": "notes",
|
||||
"filetype": "text", "size": 512}],
|
||||
"response_metadata": {"next_cursor": ""}}
|
||||
else:
|
||||
payload = {"ok": False, "error": "method_not_supported"}
|
||||
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def log_message(self, *args): # silence stderr
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
def run_script(env, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def base_env(stub):
|
||||
env = dict(os.environ)
|
||||
env["SLACK_TOKEN"] = "xoxb-test-token"
|
||||
env["SLACK_API_BASE"] = f"http://127.0.0.1:{stub.port}/"
|
||||
return env
|
||||
|
||||
|
||||
def load_json(proc):
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
class SlackCliTests(unittest.TestCase):
|
||||
def test_help_lists_json_and_bounded_reads(self):
|
||||
proc = run_script(dict(os.environ), "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
self.assertIn("--json", proc.stdout)
|
||||
self.assertIn("--limit", proc.stdout)
|
||||
|
||||
def test_help_works_without_token(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("SLACK_TOKEN", None)
|
||||
proc = run_script(env, "channels", "list", "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
|
||||
def test_channels_list_json(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "--limit", "5", "channels", "list")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertEqual(data["channels"][0]["id"], "C123")
|
||||
|
||||
def test_limit_is_bounded_in_request(self):
|
||||
with StubSlackServer() as stub:
|
||||
run_script(base_env(stub), "--json", "--limit", "3", "channels", "list")
|
||||
methods = [m for m, _ in stub.posted]
|
||||
fields = dict(stub.posted[methods.index("conversations.list")][1])
|
||||
self.assertEqual(fields.get("limit"), "3")
|
||||
|
||||
def test_messages_send_requires_confirmation(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "messages", "send", "--channel", "C123",
|
||||
"--text", "hello")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("refusing to send", proc.stderr)
|
||||
self.assertEqual(stub.posted, [], "no API call may be made without confirmation")
|
||||
|
||||
def test_messages_send_dry_run_does_not_post(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "messages", "send", "--channel", "C123",
|
||||
"--text", "hello", "--dry-run")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["dry_run"])
|
||||
self.assertEqual(stub.posted, [], "dry-run must not reach the API")
|
||||
|
||||
def test_messages_send_with_yes_posts(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "messages", "send", "--channel", "C123",
|
||||
"--text", "hello", "--yes")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["ts"], "1712345679.000002")
|
||||
methods = [m for m, _ in stub.posted]
|
||||
self.assertIn("chat.postMessage", methods)
|
||||
|
||||
def test_messages_list(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "messages", "list", "--channel", "C123")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertEqual(load_json(proc)["messages"][0]["text"], "hello from tests")
|
||||
|
||||
def test_threads_list(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "threads", "list",
|
||||
"--channel", "C123", "--ts", "1712345678.000001")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertEqual(load_json(proc)["thread_ts"], "1712345678.000001")
|
||||
|
||||
def test_search(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "search", "messages", "--query", "hello")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["total_matches"], 1)
|
||||
self.assertEqual(data["matches"][0]["channel"], "C123")
|
||||
|
||||
def test_files_list(self):
|
||||
with StubSlackServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "files", "list")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertEqual(load_json(proc)["files"][0]["id"], "F1")
|
||||
|
||||
def test_missing_token_errors_cleanly(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("SLACK_TOKEN", None)
|
||||
env["SLACK_API_BASE"] = "http://127.0.0.1:1/"
|
||||
proc = run_script(env, "--json", "channels", "list")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("SLACK_TOKEN", proc.stdout)
|
||||
|
||||
def test_webhook_verify_valid_signature(self):
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
secret = "signing-secret"
|
||||
body = b'{"event": {"type": "message"}}'
|
||||
timestamp = str(int(time.time()))
|
||||
base = f"v0:{timestamp}:".encode() + body
|
||||
signature = "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
|
||||
body_file = ROOT / "tests" / "webhook-body.json"
|
||||
body_file.write_bytes(body)
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file), "--signature", signature,
|
||||
"--timestamp", timestamp, "--secret", secret)
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertTrue(load_json(proc)["verified"])
|
||||
|
||||
def test_webhook_verify_rejects_bad_signature(self):
|
||||
import time
|
||||
body_file = ROOT / "tests" / "webhook-body.json"
|
||||
body_file.write_bytes(b'{"event": {}}')
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file),
|
||||
"--signature", "v0=" + "0" * 64,
|
||||
"--timestamp", str(int(time.time())), "--secret", "signing-secret")
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("does not match", proc.stdout)
|
||||
|
||||
def test_webhook_verify_rejects_stale_timestamp(self):
|
||||
stale = str(int(__import__("time").time()) - 600)
|
||||
body_file = ROOT / "tests" / "webhook-body.json"
|
||||
body_file.write_bytes(b"{}")
|
||||
try:
|
||||
proc = run_script(dict(os.environ), "--json", "webhook", "verify",
|
||||
"--body-file", str(body_file),
|
||||
"--signature", "v0=" + "0" * 64,
|
||||
"--timestamp", stale, "--secret", "s")
|
||||
finally:
|
||||
body_file.unlink(missing_ok=True)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("replay window", proc.stdout)
|
||||
|
||||
def test_read_only_contract_no_write_opens(self):
|
||||
source = SCRIPT.read_text()
|
||||
writes = [line for line in source.splitlines()
|
||||
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
|
||||
self.assertEqual(writes, [], "script must never open files in write mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Stripe — Read Your Account State (and Guarded Cancellations)
|
||||
|
||||
Check Stripe balance, recent payments, and subscriptions from your terminal or agent — and, with explicit confirmation, cancel a subscription — all through a read-only-first CLI.
|
||||
|
||||
## Why Install This Skill
|
||||
|
||||
Financial questions are the ones agents get wrong when they guess: "what is our Stripe balance?", "did this payment go through?", "which subscriptions are still active?". This skill gives your agent a bounded, read-only path to the real answers, and a deliberately narrow write path: canceling a subscription is a guarded mutation that requires a preview and an explicit confirmation, and it defaults to scheduling cancellation at the end of the billing period rather than cutting service off instantly.
|
||||
|
||||
It ships `stripe-cli`, a small Python script that speaks the Stripe API with no third-party dependencies. The read surface is primary — balance, payment intents, subscriptions — and every listing is capped (`--limit`). Output is clean JSON for the agent or readable text for you, and `--help` works with no key and no network. The script verifies Stripe actually confirmed a cancellation before reporting success, so a failed request is never mistaken for a done deal.
|
||||
|
||||
## What You Get
|
||||
|
||||
| Directory | Purpose |
|
||||
|---|---|
|
||||
| `SKILL.md` | Agent-facing operating contract, mutation gates, and verification boundaries |
|
||||
| `references/` | Dated source index and a Stripe read-operations reference (endpoints, pagination, cancellation semantics, errors) |
|
||||
| `scripts/stripe-cli` | Bounded, stdlib-only CLI: balance, payments list, subscriptions list/get, guarded cancel; `--json`, `--limit`, mutation gated by `--dry-run`/`--yes` |
|
||||
| `tests/` | 12 deterministic tests against a stub Stripe API, covering the read-only-first contract and mutation gate |
|
||||
| `evals/evals.json` | Six output-quality evaluation cases for agent runs |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Help works with no key and no network; shows the read-only surface
|
||||
stripe/scripts/stripe-cli --help
|
||||
|
||||
# Account balance (available + pending)
|
||||
STRIPE_API_KEY=sk_test_... stripe/scripts/stripe-cli --json balance show
|
||||
|
||||
# Recent payments (capped)
|
||||
STRIPE_API_KEY=sk_test_... stripe/scripts/stripe-cli --json --limit 20 payments list
|
||||
|
||||
# Active subscriptions
|
||||
STRIPE_API_KEY=sk_test_... stripe/scripts/stripe-cli --json --limit 20 subscriptions list
|
||||
STRIPE_API_KEY=sk_test_... stripe/scripts/stripe-cli --json subscriptions get --id sub_123
|
||||
|
||||
# Cancel only with a preview first, then explicit confirmation
|
||||
STRIPE_API_KEY=sk_test_... stripe/scripts/stripe-cli subscriptions cancel --id sub_123 --dry-run
|
||||
STRIPE_API_KEY=sk_test_... stripe/scripts/stripe-cli subscriptions cancel --id sub_123 --yes
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
Load this skill for `stripe` / payments operations: account balance, whether a payment succeeded, listing payment intents, active subscriptions and their items, or canceling a subscription with confirmation. Do not load it for building Stripe payments into an application, Stripe dashboard administration, refunds or immediate cancellations, or other payment processors.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+ for `stripe-cli` (stdlib only; `--help` and the read surface need nothing else).
|
||||
- A Stripe API key (`STRIPE_API_KEY`): a restricted key scoped to `balance:read`, `payment_intents:read`, `subscriptions:read` for reads, plus `subscriptions:write` only if you need cancellations. Prefer test keys (`sk_test_`) for anything non-production.
|
||||
- Network access to `api.stripe.com` for live reads and cancellations.
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: stripe
|
||||
description: >-
|
||||
Read Stripe account state from a terminal or agent: balance, payment
|
||||
intents, and subscriptions — and perform guarded mutations like canceling a
|
||||
subscription — backed by a bundled stripe-cli script that is read-only first
|
||||
and gates every state-changing command behind a --dry-run/--yes
|
||||
confirmation. Use when an agent needs to answer questions about account
|
||||
balance, recent payments, active subscriptions, or apply a confirmed
|
||||
subscription cancellation. Do not use for building Stripe payments into an
|
||||
application (that is Stripe integration development), managing Stripe
|
||||
dashboard settings, or other payment processors (that is their own tooling).
|
||||
license: MIT
|
||||
compatibility: >-
|
||||
The bundled stripe-cli script runs on Python 3.9+ with only the standard
|
||||
library. --help and the read-only surface (balance, payments,
|
||||
subscriptions) need no network beyond api.stripe.com; live reads require a
|
||||
Stripe secret or restricted API key with read access and network access to
|
||||
api.stripe.com.
|
||||
metadata:
|
||||
source: https://docs.stripe.com/api
|
||||
source_index: references/00-source-index.md
|
||||
research_checked: "2026-08-03"
|
||||
---
|
||||
|
||||
# Stripe Operations
|
||||
|
||||
Use this skill to read Stripe account state and, with explicit confirmation, perform guarded mutations: account balance, payment intents, subscriptions, and subscription cancellation. This is a **tool skill** for the Stripe platform. Building Stripe payments into an application is integration development; this skill owns the everyday agent workflow: answering "what is our balance?", "which payments succeeded?", "what subscriptions are active?", and applying a confirmed cancellation.
|
||||
|
||||
## Operating contract
|
||||
|
||||
1. **Read-only first.** Balance, payment, and subscription queries run freely and never change anything. The bundled `stripe-cli` script's primary surface is these reads.
|
||||
2. **Guard every mutation.** State-changing operations — canceling a subscription — require an explicit human directive plus `--dry-run` preview and `--yes` confirmation through `stripe-cli`. Cancellations are financial actions with billing consequences: confirm the subscription, the timing, and the impact before acting.
|
||||
3. **Respect bounded reads.** Stripe paginates with `limit` and `has_more`; never page past what the task needs. `stripe-cli --limit` caps every listing.
|
||||
4. **Keep evidence bounded.** Quote short IDs, amounts, and statuses; never dump full API keys, customer data, or raw payloads into chat.
|
||||
5. **Treat money data as sensitive.** Balances, payments, and subscription details are financial records; quote only what the question needs and never expose full card or customer data.
|
||||
|
||||
## The stripe-cli script
|
||||
|
||||
`scripts/stripe-cli` is an agent-first, stdlib-only CLI over the Stripe API. It is **read-only-first**: balance, payments, and subscriptions are the primary surface; the only mutation is guarded.
|
||||
|
||||
```bash
|
||||
stripe/scripts/stripe-cli --help # no key or network needed
|
||||
stripe/scripts/stripe-cli --json balance show
|
||||
stripe/scripts/stripe-cli --json --limit 20 payments list
|
||||
stripe/scripts/stripe-cli --json --limit 20 subscriptions list
|
||||
stripe/scripts/stripe-cli --json subscriptions get --id sub_123
|
||||
stripe/scripts/stripe-cli subscriptions cancel --id sub_123 --dry-run # preview
|
||||
stripe/scripts/stripe-cli subscriptions cancel --id sub_123 --yes # confirmed
|
||||
```
|
||||
|
||||
Exit codes: 0 success, 1 API error or failed check, 2 usage error. Cancellations are guarded: without `--dry-run` or `--yes` the script refuses with exit 1 and never calls the API. Reads are bounded by `--limit` (default 20, max 100).
|
||||
|
||||
## Operating loop
|
||||
|
||||
1. **Scope the question**: is this a read (balance, payments, subscriptions) or a mutation (cancel)? Locate the object with the read surface first.
|
||||
2. **Read with bounds**: `balance show` for available and pending funds; `payments list` for recent payment intents; `subscriptions list`/`subscriptions get` for active subscriptions and their items.
|
||||
3. **Triage the answer**: map the question to evidence (amounts, statuses, customers, period end dates).
|
||||
4. **Act with confirmation**: only a human directive to change, previewed with `--dry-run` and confirmed with `--yes`.
|
||||
5. **Verify**: re-read the subscription and confirm `cancel_at_period_end` is set (cancellation at period end) — and state that the customer keeps service until that date.
|
||||
|
||||
## Read surface: balance, payments, subscriptions
|
||||
|
||||
- **Balance** (`GET /balance`): available and pending balances per currency. A read-only snapshot of account funds.
|
||||
- **Payments** (`GET /payment_intents`): recent payment intents with amount, currency, status (`succeeded`, `requires_action`, etc.), and customer. Bounded by `--limit`; `has_more` tells you whether the cap hid further records.
|
||||
- **Subscriptions** (`GET /subscriptions`, `GET /subscriptions/{id}`): active subscriptions with status, customer, period end, and items (price, amount, interval). A read before any cancellation.
|
||||
|
||||
## Guarded mutation: subscription cancellation
|
||||
|
||||
- `subscriptions cancel --id sub_... --dry-run` previews the cancellation; `--yes` confirms and posts `cancel_at_period_end=true` — the safe default that **schedules cancellation at the period end** (customer keeps service until then) rather than canceling immediately.
|
||||
- The script verifies Stripe confirmed the change (response `cancel_at_period_end: true`) before reporting success; a mismatch raises an error and no state change is assumed.
|
||||
- Cancellation scheduled at period end is reversible by setting `cancel_at_period_end=false` before the period ends. Immediate cancellation and refunds are deliberately NOT in this skill's mutation surface — they need a human at the Stripe dashboard or a dedicated integration.
|
||||
|
||||
## Access model and credentials
|
||||
|
||||
- Stripe authenticates with secret keys (`sk_...`) or restricted keys (`rk_...`). Use a **restricted key scoped to read-only** (`balance:read`, `payment_intents:read`, `subscriptions:read`) for the read surface; add `subscriptions:write` only where cancellations are genuinely needed.
|
||||
- Keys are live or test (`sk_live_`/`sk_test_`). Never point live keys at test data or vice versa; verify the key type before running anything that could touch real charges.
|
||||
- Store keys in `STRIPE_API_KEY`, never in code, chat, or commits. Rotate a leaked key immediately in the Stripe dashboard.
|
||||
|
||||
## Reference routing
|
||||
|
||||
| Load when | Reference |
|
||||
|---|---|
|
||||
| Sources, refresh procedure | `references/00-source-index.md` |
|
||||
| Endpoints, pagination, cancellation semantics, errors | `references/01-stripe-read-operations.md` |
|
||||
|
||||
## Included artifacts
|
||||
|
||||
- `scripts/stripe-cli`: bounded, stdlib-only CLI (balance, payments list, subscriptions list/get, guarded cancel; `--json`; `--limit`; mutation gated by `--dry-run`/`--yes`).
|
||||
- `tests/test_stripe_cli.py`: 12 deterministic tests against a stub Stripe API, including the read-only-first contract and the mutation gate.
|
||||
- `references/`: dated source index + Stripe read-operations reference.
|
||||
- `evals/evals.json`: six output-quality evaluation cases for agent runs.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
| Claim | Minimum evidence |
|
||||
|---|---|
|
||||
| Balance is current | `stripe-cli balance show --json` returns available and pending per currency |
|
||||
| A payment succeeded | `stripe-cli payments list --json` shows the intent with status `succeeded` |
|
||||
| A subscription is active | `stripe-cli subscriptions get --json` returns status `active` and period end |
|
||||
| A cancellation was accepted | `stripe-cli subscriptions cancel --yes` exits 0 and the response has `cancel_at_period_end: true` |
|
||||
| A mutation is safe to run | `stripe-cli subscriptions cancel --dry-run` prints the exact subscription ID |
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
- Never cancel a subscription without a human directive, `--dry-run` preview, and `--yes` confirmation — cancellations have billing consequences.
|
||||
- The mutation surface is limited to scheduling cancellation at period end. Refunds, immediate cancellations, and charge operations are out of scope.
|
||||
- Never page reads past `--limit`; never dump full API keys, customer data, or raw payloads into chat.
|
||||
- This skill operates the Stripe API. It does not build payments into applications or cover other payment processors.
|
||||
|
||||
## When not to use
|
||||
|
||||
- **Building Stripe payments into an application** (Checkout, Payment Intents in code, webhooks for your app, billing logic) — that is integration development; see [backend-engineering](../backend-engineering/SKILL.md).
|
||||
- **Stripe dashboard administration** (account settings, bank accounts, disputes, tax registration) — that is the Stripe Dashboard.
|
||||
- **Refunds, immediate cancellations, or charge operations** — deliberately out of this skill's guarded-mutation surface; those are human decisions in the dashboard or a dedicated integration.
|
||||
- **Other payment processors** (Braintree, Adyen, PayPal) — each has its own API and tooling; this skill covers Stripe.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"skill_name": "stripe",
|
||||
"evals": [
|
||||
{
|
||||
"id": "balance-read",
|
||||
"prompt": "A user asks: 'What is our Stripe account balance right now?'",
|
||||
"expected_output": "Run stripe-cli balance show and report the available and pending balances per currency. The response states that this is a read-only snapshot and that pending funds are not yet settled. It does not mutate anything and does not page — the balance endpoint has no pagination.",
|
||||
"assertions": [
|
||||
"The balance is read via stripe-cli balance show",
|
||||
"Available and pending balances are reported per currency",
|
||||
"Pending funds are described as not yet settled",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment-status-check",
|
||||
"prompt": "A user asks: 'Did payment pi_123 for $42 go through? Our customer says they were charged twice.'",
|
||||
"expected_output": "Run stripe-cli payments list (bounded) and locate payment pi_123, reporting its amount, currency, and status. The response explains what the status means (e.g. succeeded means captured; requires_action means the customer must complete authentication) and that duplicate charges must be checked by comparing distinct payment intents before any refund decision. Refunds are out of the skill's mutation surface, so the response stops at evidence and asks a human before any charge-level action.",
|
||||
"assertions": [
|
||||
"Payments are read via stripe-cli payments list with a bounded --limit",
|
||||
"The specific payment is located and its amount, currency, and status reported",
|
||||
"Status semantics are explained (succeeded vs requires_action)",
|
||||
"No refund or charge-level mutation is attempted"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "subscription-listing",
|
||||
"prompt": "A user asks: 'Which subscriptions are currently active, and what do they cost per month?'",
|
||||
"expected_output": "Run stripe-cli subscriptions list (bounded) and report each active subscription with its ID, status, customer, and per-item prices with intervals. The response notes has_more if additional subscriptions exist beyond the cap. Reads only — no subscription is modified.",
|
||||
"assertions": [
|
||||
"Subscriptions are read via stripe-cli subscriptions list with a bounded --limit",
|
||||
"Each subscription reports ID, status, customer, and item prices with intervals",
|
||||
"has_more is reported when the cap hides further subscriptions",
|
||||
"The operation is read-only"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "guarded-subscription-cancel",
|
||||
"prompt": "A user asks: 'Cancel subscription sub_123. It renews in 3 days and the customer no longer wants it.'",
|
||||
"expected_output": "The agent states the subscription's current status and that the cancellation will be scheduled at the end of the current billing period (customer keeps service until then), previews with stripe-cli subscriptions cancel --id sub_123 --dry-run, and asks for explicit confirmation. Only after confirmation does it run the cancel with --yes, then verifies the response shows cancel_at_period_end true and re-reads the subscription. If the user only asked to draft the change, nothing is canceled.",
|
||||
"assertions": [
|
||||
"The cancellation is previewed with --dry-run before any change",
|
||||
"The period-end scheduling semantics are stated before confirmation",
|
||||
"The cancel runs only after explicit user confirmation, via --yes",
|
||||
"The response verifies cancel_at_period_end is true and re-reads the subscription"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "read-before-mutation",
|
||||
"prompt": "A user asks: 'Which subscriptions should we cancel to reduce spend? Show me the most expensive first.'",
|
||||
"expected_output": "The agent performs the analysis as a read: stripe-cli subscriptions list (bounded) and payments where relevant, sorting active subscriptions by monthly item amount and presenting the list with IDs and costs. It does NOT cancel anything on its own: each cancellation is a guarded mutation requiring explicit confirmation with a --dry-run preview and --yes, so it presents the candidates and asks the user to confirm which subscriptions to cancel.",
|
||||
"assertions": [
|
||||
"The subscription analysis is performed read-only via stripe-cli subscriptions list",
|
||||
"Subscriptions are ordered by cost with IDs and amounts reported",
|
||||
"No subscription is canceled without explicit confirmation and a --dry-run preview",
|
||||
"The mutation gate is explained to the user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "key-and-environment-hygiene",
|
||||
"prompt": "A user asks: 'I want to check our production balance. Which key should I use, and what happens if STRIPE_API_KEY is not set?'",
|
||||
"expected_output": "The response explains key types: sk_test_ keys hit test data, sk_live_ keys hit real production data — never mix them, and prefer a restricted read-only key (balance:read, payment_intents:read, subscriptions:read) for balance queries. It states that stripe-cli exits 1 with a clear error naming STRIPE_API_KEY when unset, while --help works without it. No key material is hardcoded or logged.",
|
||||
"assertions": [
|
||||
"Test versus live key behavior is explained and mixing is warned against",
|
||||
"A restricted read-only key is recommended for balance queries",
|
||||
"The missing-key error and --help behavior are described accurately",
|
||||
"No key material is hardcoded or logged"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Stripe — Source Index
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
This skill is a distilled operating layer over Stripe's public API documentation. Facts and endpoint names in this skill are grounded in the sources below; refresh this index when Stripe ships API changes.
|
||||
|
||||
| Topic | Source | URL |
|
||||
|---|---|---|
|
||||
| API reference | Stripe API reference | https://docs.stripe.com/api |
|
||||
| Balance | Balance API | https://docs.stripe.com/api/balance |
|
||||
| Payment Intents | Payment Intents API | https://docs.stripe.com/api/payment_intents |
|
||||
| Subscriptions | Subscriptions API | https://docs.stripe.com/api/subscriptions |
|
||||
| Authentication and keys | Authentication | https://docs.stripe.com/api/authentication |
|
||||
| Restricted API keys | Restricted keys | https://docs.stripe.com/keys#limit-access |
|
||||
|
||||
## Refresh procedure
|
||||
|
||||
- Re-check the Subscriptions API before changing anything in `subscriptions cancel`; cancellation semantics (`cancel_at_period_end`, immediate `cancel`) have changed across API versions and the safe period-end default is deliberate.
|
||||
- Re-check the Payment Intents API when payment statuses behave unexpectedly; status names evolve with new confirmation flows.
|
||||
- Update `research_checked` in `SKILL.md` frontmatter and this file's `Last Updated` when you verify the sources again.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Stripe Read Operations
|
||||
|
||||
> **Last Updated:** 2026-08-03
|
||||
|
||||
Operational detail for the Stripe API surface the skill owns: the read-only-first surface (balance, payment intents, subscriptions) and the one guarded mutation (scheduling a subscription cancellation at period end). The bundled `stripe-cli` implements this reference; use this document when a call behaves unexpectedly.
|
||||
|
||||
## API conventions
|
||||
|
||||
- Base URL: `https://api.stripe.com/v1`. Every request carries `Authorization: Bearer <api_key>`.
|
||||
- GET endpoints take query-string parameters (`limit`); POST endpoints take form-encoded bodies. `stripe-cli` encodes GET parameters in the URL and POST parameters in the body.
|
||||
- Keys: `sk_test_` (test data) vs `sk_live_` (real production data) vs `rk_` (restricted). Restricted read-only keys (`balance:read`, `payment_intents:read`, `subscriptions:read`) are the right default for the read surface.
|
||||
|
||||
## Endpoint surface
|
||||
|
||||
| Operation | Endpoint | Method | Notes |
|
||||
|---|---|---|---|
|
||||
| Balance | `/balance` | GET | Available + pending per currency; no pagination |
|
||||
| List payments | `/payment_intents?limit=N` | GET | Recent intents with amount, currency, status, customer |
|
||||
| List subscriptions | `/subscriptions?limit=N` | GET | Active subscriptions with status, customer, items |
|
||||
| Get a subscription | `/subscriptions/{id}` | GET | Single subscription with period end |
|
||||
| Schedule cancellation | `/subscriptions/{id}` | POST | Guarded mutation: `cancel_at_period_end=true` |
|
||||
|
||||
## Pagination and bounded reads
|
||||
|
||||
- List endpoints accept `limit` (max 100) and return `has_more` plus a `starting_after` cursor when more records exist.
|
||||
- **Bounded-read rule:** request only what the task needs; `stripe-cli --limit` caps at the request level. Report `has_more` when summarizing so the reader knows the cap hid further records.
|
||||
|
||||
## Read surface semantics
|
||||
|
||||
- **Balance**: `available` (settled funds you can pay out) vs `pending` (in transit, e.g. captured but not yet settled). Always distinguish the two when reporting.
|
||||
- **Payment intents**: statuses include `requires_payment_method`, `requires_confirmation`, `requires_action`, `processing`, `succeeded`, `canceled`. `succeeded` means captured; `requires_action` means the customer must complete authentication. A "charged twice" report must be checked against distinct intent IDs before any refund discussion — and refunds are outside this skill's mutation surface.
|
||||
- **Subscriptions**: `status` (`active`, `past_due`, `canceled`, `unpaid`, `trialing`) plus `current_period_end` (Unix) and `cancel_at_period_end` (bool). Items carry price, amount, and interval.
|
||||
|
||||
## Guarded mutation: cancellation at period end
|
||||
|
||||
- `POST /subscriptions/{id}` with `cancel_at_period_end=true` schedules cancellation at the end of the current billing period — the customer keeps service until then and the change is reversible (set it back to `false` before the period ends).
|
||||
- `stripe-cli` verifies the response has `cancel_at_period_end: true` before reporting success; a mismatch raises an error and no state change is assumed.
|
||||
- Immediate cancellation (`cancel=true`) and refunds are deliberately out of scope: they are irreversible financial actions that belong to a human decision with dedicated tooling.
|
||||
|
||||
## Error handling
|
||||
|
||||
- 401 `invalid_request_error`/authentication failure: key invalid or revoked — rotate the key.
|
||||
- 403: restricted key lacks the scope — grant the needed scope, don't retry blindly.
|
||||
- 404: object does not exist in the key's mode (test vs live) — verify the key type and object ID before concluding.
|
||||
- 429 `rate_limit`: slow down; Stripe rate limits per key.
|
||||
- `stripe-cli` exit 1 with `Stripe API HTTP <code>: <message>` (human) or `{"ok": false, "error": "..."}` (JSON). Exit 2 is a usage error.
|
||||
|
||||
## Credential and data hygiene
|
||||
|
||||
- Keys are full or scoped account credentials: store in `STRIPE_API_KEY`, never in code, chat, or commits. Use restricted read-only keys for the read surface; add `subscriptions:write` only where cancellations are genuinely needed. Rotate a leaked key immediately.
|
||||
- Balance, payment, and subscription data is financial and often personal: quote only what the question needs and never dump full customer data or raw payloads into chat.
|
||||
Executable
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stripe-cli - bounded, agent-first command line for the Stripe API.
|
||||
|
||||
Reads Stripe balance, payments, and subscriptions and, with explicit
|
||||
confirmation, performs guarded mutations (canceling a subscription) over
|
||||
HTTPS using only the Python standard library. The read surface is primary;
|
||||
every state-changing command requires --dry-run to preview, then --yes to
|
||||
confirm. Mutation requires explicit confirmation.
|
||||
|
||||
Design rules:
|
||||
|
||||
- Read-only first: balance, payment, and subscription queries run freely.
|
||||
- Guarded mutations: canceling a subscription requires --dry-run then --yes.
|
||||
- Bounded reads: every listing caps results with --limit and never pages past
|
||||
the requested cap.
|
||||
- --json emits machine-readable JSON; the default is human-readable text.
|
||||
- --help works with no STRIPE_API_KEY set and makes no network calls.
|
||||
|
||||
Environment:
|
||||
STRIPE_API_KEY Stripe secret or restricted API key (sk_... / rk_...)
|
||||
|
||||
Exit codes: 0 success, 1 Stripe API error or failed check, 2 usage error.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
API_BASE = os.environ.get("STRIPE_API_BASE", "https://api.stripe.com/v1")
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
REQUEST_TIMEOUT = 15
|
||||
TEXT_TRUNCATE = 500
|
||||
|
||||
|
||||
class StripeError(Exception):
|
||||
"""Raised when the Stripe API returns an error or transport fails."""
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
key = os.environ.get("STRIPE_API_KEY", "")
|
||||
if not key:
|
||||
raise StripeError("STRIPE_API_KEY environment variable is not set")
|
||||
return key
|
||||
|
||||
|
||||
def api_request(method: str, path: str, api_key: str,
|
||||
fields: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
base = f"{API_BASE}/{path.lstrip('/')}"
|
||||
if fields and method == "GET":
|
||||
query = "&".join(f"{key}={urllib.parse.quote(str(value))}" for key, value in fields.items())
|
||||
url = f"{base}?{query}"
|
||||
data = None
|
||||
elif fields:
|
||||
url = base
|
||||
data = urllib.parse.urlencode(fields).encode("utf-8")
|
||||
else:
|
||||
url = base
|
||||
data = None
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = ""
|
||||
try:
|
||||
detail = json.loads(error.read().decode("utf-8")).get("error", {}).get("message", "")
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass
|
||||
raise StripeError(f"Stripe API HTTP {error.code}: {detail or error.reason}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise StripeError(f"Stripe API unreachable: {error.reason}") from error
|
||||
except json.JSONDecodeError as error:
|
||||
raise StripeError(f"Stripe API returned non-JSON: {error}") from error
|
||||
|
||||
|
||||
def usd_cents_to_display(cents: Optional[int]) -> str:
|
||||
if cents is None:
|
||||
return ""
|
||||
return f"{cents / 100:.2f}"
|
||||
|
||||
|
||||
def summarize_balance(balance: Dict[str, Any]) -> Dict[str, Any]:
|
||||
available = [{"amount": usd_cents_to_display(b.get("amount")), "currency": b.get("currency", "").upper()}
|
||||
for b in balance.get("available", [])]
|
||||
pending = [{"amount": usd_cents_to_display(b.get("amount")), "currency": b.get("currency", "").upper()}
|
||||
for b in balance.get("pending", [])]
|
||||
return {"available": available, "pending": pending}
|
||||
|
||||
|
||||
def summarize_payment(payment: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": payment.get("id", ""),
|
||||
"amount": usd_cents_to_display(payment.get("amount")),
|
||||
"currency": (payment.get("currency") or "").upper(),
|
||||
"status": payment.get("status", ""),
|
||||
"customer": payment.get("customer", ""),
|
||||
"created": payment.get("created"),
|
||||
}
|
||||
|
||||
|
||||
def summarize_subscription(subscription: Dict[str, Any]) -> Dict[str, Any]:
|
||||
items = []
|
||||
for item in subscription.get("items", {}).get("data", []):
|
||||
price = item.get("price", {})
|
||||
items.append({
|
||||
"id": item.get("id", ""),
|
||||
"price_id": price.get("id", ""),
|
||||
"amount": usd_cents_to_display(price.get("unit_amount")),
|
||||
"currency": (price.get("currency") or "").upper(),
|
||||
"interval": (price.get("recurring") or {}).get("interval", ""),
|
||||
})
|
||||
return {
|
||||
"id": subscription.get("id", ""),
|
||||
"status": subscription.get("status", ""),
|
||||
"customer": subscription.get("customer", ""),
|
||||
"current_period_end": subscription.get("current_period_end"),
|
||||
"cancel_at_period_end": subscription.get("cancel_at_period_end", False),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_balance(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
payload = api_request("GET", "balance", api_key)
|
||||
return {"ok": True, "balance": summarize_balance(payload)}
|
||||
|
||||
|
||||
def cmd_payments_list(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
payload = api_request("GET", "payment_intents", api_key, {"limit": str(args.limit)})
|
||||
payments = [summarize_payment(p) for p in payload.get("data", [])]
|
||||
return {"ok": True, "payments": payments, "has_more": payload.get("has_more", False)}
|
||||
|
||||
|
||||
def cmd_subscriptions_list(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
payload = api_request("GET", "subscriptions", api_key, {"limit": str(args.limit)})
|
||||
subscriptions = [summarize_subscription(s) for s in payload.get("data", [])]
|
||||
return {"ok": True, "subscriptions": subscriptions, "has_more": payload.get("has_more", False)}
|
||||
|
||||
|
||||
def cmd_subscriptions_get(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
payload = api_request("GET", f"subscriptions/{args.subscription_id}", api_key)
|
||||
return {"ok": True, "subscription": summarize_subscription(payload)}
|
||||
|
||||
|
||||
def cmd_subscriptions_cancel(args: argparse.Namespace, api_key: str) -> Dict[str, Any]:
|
||||
if not args.dry_run and not args.yes:
|
||||
raise StripeError(
|
||||
"refusing to cancel a subscription without confirmation: pass "
|
||||
"--dry-run to preview or --yes to confirm the mutation"
|
||||
)
|
||||
if args.dry_run:
|
||||
return {"ok": True, "dry_run": True, "would_cancel": {"subscription_id": args.subscription_id}}
|
||||
# Safer default: schedule cancellation at the period end (reversible by
|
||||
# setting cancel_at_period_end back to false) rather than cancelling
|
||||
# immediately.
|
||||
payload = api_request("POST", f"subscriptions/{args.subscription_id}", api_key,
|
||||
{"cancel_at_period_end": "true"})
|
||||
if payload.get("cancel_at_period_end") is not True:
|
||||
raise StripeError(
|
||||
"Stripe did not confirm the cancellation (cancel_at_period_end is "
|
||||
f"{payload.get('cancel_at_period_end')!r}); no state change assumed"
|
||||
)
|
||||
return {"ok": True, "subscription": summarize_subscription(payload)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Output helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def emit(data: Dict[str, Any], json_mode: bool) -> int:
|
||||
if json_mode:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
_emit_human(data)
|
||||
return 0
|
||||
|
||||
|
||||
def _emit_human(data: Dict[str, Any]) -> None:
|
||||
if "balance" in data:
|
||||
for entry in data["balance"]["available"]:
|
||||
print(f"available: {entry['currency']} {entry['amount']}")
|
||||
for entry in data["balance"]["pending"]:
|
||||
print(f"pending: {entry['currency']} {entry['amount']}")
|
||||
elif "payments" in data:
|
||||
print(f"payment intents (has_more={data['has_more']}):")
|
||||
for payment in data["payments"]:
|
||||
print(f" {payment['id']} {payment['currency']} {payment['amount']} "
|
||||
f"status={payment['status']} customer={payment['customer']}")
|
||||
elif "subscriptions" in data:
|
||||
print(f"subscriptions (has_more={data['has_more']}):")
|
||||
for subscription in data["subscriptions"]:
|
||||
print(f" {subscription['id']} status={subscription['status']} "
|
||||
f"customer={subscription['customer']}")
|
||||
elif "subscription" in data and "dry_run" not in data:
|
||||
subscription = data["subscription"]
|
||||
print(f"subscription {subscription['id']} status={subscription['status']} "
|
||||
f"customer={subscription['customer']}")
|
||||
for item in subscription["items"]:
|
||||
print(f" item {item['id']}: {item['currency']} {item['amount']}/{item['interval']}")
|
||||
elif data.get("dry_run"):
|
||||
print("DRY RUN (nothing canceled):")
|
||||
print(f" subscription: {data['would_cancel']['subscription_id']}")
|
||||
else:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="stripe-cli",
|
||||
description=(
|
||||
"Bounded, agent-first CLI for the Stripe API. Read-only-first "
|
||||
"surface: balance, payment intents, and subscriptions. Guarded "
|
||||
"mutation: canceling a subscription requires --dry-run then --yes."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output")
|
||||
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
|
||||
help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
balance = sub.add_parser("balance", help="read account balance (read-only)")
|
||||
balance.add_argument("action", nargs="?", default="show", choices=["show"])
|
||||
|
||||
payments = sub.add_parser("payments", help="read payment intents (read-only)")
|
||||
pay_sub = payments.add_subparsers(dest="action", required=True)
|
||||
pay_list = pay_sub.add_parser("list", help="list recent payment intents")
|
||||
|
||||
subscriptions = sub.add_parser("subscriptions", help="read or cancel subscriptions")
|
||||
sub_sub = subscriptions.add_subparsers(dest="action", required=True)
|
||||
sub_list = sub_sub.add_parser("list", help="list subscriptions (read-only)")
|
||||
sub_get = sub_sub.add_parser("get", help="get one subscription (read-only)")
|
||||
sub_get.add_argument("--id", dest="subscription_id", required=True, help="subscription ID (sub_...)")
|
||||
sub_cancel = sub_sub.add_parser("cancel", help="cancel a subscription (guarded mutation)")
|
||||
sub_cancel.add_argument("--id", dest="subscription_id", required=True,
|
||||
help="subscription ID (sub_...)")
|
||||
sub_cancel.add_argument("--dry-run", action="store_true",
|
||||
help="preview the cancellation without applying")
|
||||
sub_cancel.add_argument("--yes", action="store_true", help="confirm the mutation and cancel")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.limit < 1 or args.limit > MAX_LIMIT:
|
||||
parser.error(f"--limit must be between 1 and {MAX_LIMIT}")
|
||||
try:
|
||||
api_key = get_api_key()
|
||||
if args.command == "balance":
|
||||
result = cmd_balance(args, api_key)
|
||||
elif args.command == "payments":
|
||||
result = cmd_payments_list(args, api_key)
|
||||
elif args.command == "subscriptions":
|
||||
if args.action == "list":
|
||||
result = cmd_subscriptions_list(args, api_key)
|
||||
elif args.action == "get":
|
||||
result = cmd_subscriptions_get(args, api_key)
|
||||
else:
|
||||
result = cmd_subscriptions_cancel(args, api_key)
|
||||
else: # pragma: no cover - argparse prevents this
|
||||
parser.error(f"unknown command: {args.command}")
|
||||
return emit(result, args.json)
|
||||
except StripeError as error:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"stripe-cli: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic tests for stripe/scripts/stripe-cli.
|
||||
|
||||
Runs the script as a subprocess so the tests exercise the real CLI surface
|
||||
(--help, --json, --limit, mutation gate, exit codes, JSON payloads). A local
|
||||
stdlib HTTP server stubs the Stripe API (balance, payment_intents,
|
||||
subscriptions), so no external network or Stripe account is needed. Also
|
||||
asserts the read-only-first contract: reads never call write methods, and the
|
||||
mutation gate refuses to cancel a subscription without --dry-run or --yes.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "stripe-cli"
|
||||
|
||||
BALANCE = {"available": [{"amount": 250000, "currency": "usd"}],
|
||||
"pending": [{"amount": 5000, "currency": "usd"}]}
|
||||
PAYMENT = {"id": "pi_123", "amount": 4200, "currency": "usd", "status": "succeeded",
|
||||
"customer": "cus_1", "created": 1712345678}
|
||||
SUBSCRIPTION = {"id": "sub_1", "status": "active", "customer": "cus_1",
|
||||
"current_period_end": 1712500000, "cancel_at_period_end": False,
|
||||
"items": {"data": [{"id": "si_1",
|
||||
"price": {"id": "price_1", "unit_amount": 9900,
|
||||
"currency": "usd",
|
||||
"recurring": {"interval": "month"}}}]}}
|
||||
|
||||
|
||||
class StubStripeServer:
|
||||
"""Minimal stub of the Stripe API surface used by stripe-cli."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests = [] # (method, path, form)
|
||||
handler = self._make_handler()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def _make_handler(self):
|
||||
stub = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _read_form(self):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
import urllib.parse
|
||||
return {k: v for k, v in urllib.parse.parse_qsl(raw.decode("utf-8"))}
|
||||
|
||||
def _json(self, payload, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(payload).encode("utf-8"))
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
stub.requests.append(("GET", self.path, None))
|
||||
if self.path == "/balance" or self.path.startswith("/balance?"):
|
||||
self._json(BALANCE)
|
||||
elif "/payment_intents" in self.path:
|
||||
self._json({"data": [PAYMENT], "has_more": False})
|
||||
elif "/subscriptions" in self.path:
|
||||
if self.path.rstrip("/") == "/subscriptions" or "?" in self.path:
|
||||
self._json({"data": [SUBSCRIPTION], "has_more": False})
|
||||
else:
|
||||
self._json(SUBSCRIPTION)
|
||||
else:
|
||||
self._json({"error": {"message": "not_found"}}, 404)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
form = self._read_form()
|
||||
stub.requests.append(("POST", self.path, form))
|
||||
if "/subscriptions/" in self.path:
|
||||
canceled = json.loads(json.dumps(SUBSCRIPTION))
|
||||
canceled["cancel_at_period_end"] = form.get("cancel_at_period_end") == "true"
|
||||
self._json(canceled)
|
||||
else:
|
||||
self._json({"error": {"message": "not_found"}}, 404)
|
||||
|
||||
def log_message(self, *args): # silence stderr
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
|
||||
def run_script(env, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def base_env(stub):
|
||||
env = dict(os.environ)
|
||||
env["STRIPE_API_KEY"] = "sk_test_dummy"
|
||||
env["STRIPE_API_BASE"] = f"http://127.0.0.1:{stub.port}/"
|
||||
return env
|
||||
|
||||
|
||||
def load_json(proc):
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
class StripeCliTests(unittest.TestCase):
|
||||
def test_help_shows_readonly_surface_and_json(self):
|
||||
proc = run_script(dict(os.environ), "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
self.assertIn("--json", proc.stdout)
|
||||
self.assertIn("--limit", proc.stdout)
|
||||
for term in ("balance", "payment", "subscription"):
|
||||
self.assertIn(term, proc.stdout)
|
||||
|
||||
def test_help_works_without_api_key(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("STRIPE_API_KEY", None)
|
||||
proc = run_script(env, "balance", "show", "--help")
|
||||
self.assertEqual(proc.returncode, 0)
|
||||
|
||||
def test_balance_read(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "balance", "show")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["balance"]["available"][0]["amount"], "2500.00")
|
||||
|
||||
def test_payments_list(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "--limit", "5", "payments", "list")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["payments"][0]["id"], "pi_123")
|
||||
self.assertEqual(data["payments"][0]["amount"], "42.00")
|
||||
|
||||
def test_payments_limit_is_bounded_in_request(self):
|
||||
with StubStripeServer() as stub:
|
||||
run_script(base_env(stub), "--json", "--limit", "3", "payments", "list")
|
||||
gets = [path for method, path, _ in stub.requests if method == "GET"]
|
||||
self.assertTrue(any("payment_intents" in path and "limit=3" in path for path in gets))
|
||||
|
||||
def test_subscriptions_list(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "subscriptions", "list")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["subscriptions"][0]["status"], "active")
|
||||
|
||||
def test_subscriptions_get(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "subscriptions", "get", "--id", "sub_1")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertEqual(data["subscription"]["id"], "sub_1")
|
||||
self.assertEqual(data["subscription"]["items"][0]["interval"], "month")
|
||||
|
||||
def test_cancel_requires_confirmation(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "subscriptions", "cancel", "--id", "sub_1")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("refusing to cancel", proc.stderr)
|
||||
self.assertEqual(stub.requests, [], "no API call may be made without confirmation")
|
||||
|
||||
def test_cancel_dry_run_does_not_post(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "subscriptions", "cancel",
|
||||
"--id", "sub_1", "--dry-run")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["dry_run"])
|
||||
self.assertEqual(stub.requests, [], "dry-run must not reach the API")
|
||||
|
||||
def test_cancel_with_yes_posts_cancel_at_period_end(self):
|
||||
with StubStripeServer() as stub:
|
||||
proc = run_script(base_env(stub), "--json", "subscriptions", "cancel",
|
||||
"--id", "sub_1", "--yes")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
data = load_json(proc)
|
||||
self.assertTrue(data["subscription"]["cancel_at_period_end"])
|
||||
posts = [form for method, path, form in stub.requests
|
||||
if method == "POST" and "/subscriptions/sub_1" in path]
|
||||
self.assertEqual(len(posts), 1)
|
||||
self.assertEqual(posts[0].get("cancel_at_period_end"), "true")
|
||||
|
||||
def test_missing_api_key_errors_cleanly(self):
|
||||
env = dict(os.environ)
|
||||
env.pop("STRIPE_API_KEY", None)
|
||||
proc = run_script(env, "--json", "balance", "show")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("STRIPE_API_KEY", proc.stdout)
|
||||
|
||||
def test_read_only_contract_no_write_opens(self):
|
||||
source = SCRIPT.read_text()
|
||||
writes = [line for line in source.splitlines()
|
||||
if line.strip().startswith("open(") and ("'w'" in line or '"w"' in line)]
|
||||
self.assertEqual(writes, [], "script must never open files in write mode")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user