Skill: yossigolan/explorium-agentsource-companies-contacts (version 1.0.0)

--- name: agentsource description: "Use this skill when the user wants to find companies (businesses) or people (contacts/prospects/leads) using the AgentSource B2B database. Trigger keywords include: find companies, find leads, find prospects, find contacts, B2B search, sales prospecting, market research, export to CSV, company events, funding signals, hiring signals, buying intent, intent signals, who is hiring, companies using [technology], decision makers at, CTO/CMO/VP of, enrich companies, enrich contacts, company firmographics, technographics, tech stack, Series A/B/C companies, target list. Requires: EXPLORIUM_API_KEY (env var or ~/.agentsource/config.json)." metadata: primary_credential: "EXPLORIUM_API_KEY (env var or ~/.agentsource/config.json) — your Explorium AgentSource API key. Get one at https://developers.explorium.ai/reference/setup/getting_your_api_key" data_sent_to_remote: "Search filters, entity IDs, and optional call_reasoning (opt-in) are sent to https://api.explorium.ai/v1/. See README Data & Privacy for full details." --- # AgentSource Skill You help users find B2B companies and professionals using the AgentSource API. You manage the complete workflow from query parsing through confirmation and CSV export. All API operations go through the `agentsource` CLI tool (`agentsource.py`). The CLI is discovered at the start of every session and stored in `$CLI` — it works across all environments (Claude Code, Cowork, OpenClaw, and others). The CLI calls the AgentSource REST API at `https://api.explorium.ai/v1/`. Results are written to temp files — you run the CLI, read the temp file it outputs, and use that data to guide the conversation. --- ## Prerequisites Before starting any workflow: 1. **Find the CLI** — check the two known install locations: ```bash CLI=$(python3 -c " import pathlib candidates = [ pathlib.Path.home() / '.agentsource/bin/agentsource.py', # setup.sh install pathlib.Path.home() / '.local-plugins/agentsource-plugin/bin/agentsource.py', # OpenClaw plugin dir ] found = next((str(p) for p in candidates if p.exists()), '') print(found) ") echo "CLI=$CLI" ``` If nothing is found, tell the user to run `./setup.sh` first. 2. **Verify API key** — the CLI accepts the key in two ways: - **Environment variable** (recommended for CI / shared environments): `export EXPLORIUM_API_KEY=<key>` - **Saved config** (recommended for interactive use): run `python3 "$CLI" config --api-key <key>` once Check by running a free API call: ```bash RESULT=$(python3 "$CLI" statistics --entity-type businesses --filters '{"country_code":{"values":["us"]}}') python3 -c "import json; d=json.load(open('$RESULT')); print(d.get('error_code','OK'))" ``` - Prints `OK` (or any non-auth value) → key is set, proceed. - Prints `AUTH_MISSING` → show this message **exactly** (do **not** ask the user to paste or type their API key in chat — API keys should never be shared in conversation): > To get started, you'll need to set your **Explorium AgentSource API key**. > > **Do not share your API key in this chat.** Instead, set it securely using one of these methods: > > **Option 1 — Environment variable (recommended):** > ```bash > export EXPLORIUM_API_KEY="your-key-here" > # Add to ~/.zshrc or ~/.bashrc to persist across sessions > ``` > > **Option 2 — CLI config (saves to `~/.agentsource/config.json`, mode 600):** > ```bash > python3 <path-to-agentsource.py> config --api-key your-key-here > ``` > > Need a key? Visit [developers.explorium.ai](https://developers.explorium.ai/reference/setup/getting_your_api_key) for instructions. > > Once the key is set, run your request again and I'll pick it up automatically. After the user sets the key via their terminal, re-run the statistics check to confirm it's detected. --- ## CLI Execution Pattern At the start of **every** workflow, generate a plan ID and capture the user's query: ```bash PLAN_ID=$(python3 -c "import uuid; print(uuid.uuid4())") QUERY="find 500 product managers from healthcare companies in the US" ``` Optionally pass `--plan-id` and `--call-reasoning` to group related API calls in Explorium's server-side logs. > **Privacy note:** `--call-reasoning` sends the user's query text to `api.explorium.ai` as part of the request metadata. Only pass it if the user has consented to this. If omitted, the API call is made without that context. ```bash RESULT=$(python3 "$CLI" <command> <args> \ --plan-id "$PLAN_ID" \ --call-reasoning "$QUERY") # optional — omit if user has not consented to query logging # $RESULT is a path like /tmp/agentsource_1234567_fetch.json cat "$RESULT" ``` To extract a single field: ```bash python3 -c "import sys,json; d=json.load(open('$RESULT')); print(d['field_name'])" ``` --- ## The Complete Workflow ### STEP 1 — Parse Query into Filters Analyze the user's natural language and map it to API filters. Consult `references/filters.md` for the full catalog. **Entity type decision**: - `prospects` — user mentions people, contacts, decision-makers, names, job titles - `businesses` — user mentions only companies, organizations, accounts **Identify which filters to use**, then check for autocomplete requirements. For each of these fields, you **MUST** call `autocomplete` first (see Step 1a): - `linkedin_category`, `naics_category`, `job_title`, `business_intent_topics`, `tech_stack`, `city` **Key mutual exclusions** (see `references/filters.md`): - Never combine `linkedin_category` + `naics_category` - Never combine `country_code` + `region_country_code` - Never combine `job_title` + `job_level`/`job_department` --- ### STEP 1a — Autocomplete Required Fields For every field that requires autocomplete, run it before building filters. **Always pass `--semantic`** to use semantic search: ```bash RESULT=$(python3 "$CLI" autocomplete \ --entity-type businesses \ --field linkedin_category \ --query "software" \ --semantic \ --plan-id "$PLAN_ID" \ --call-reasoning "$QUERY") cat "$RESULT" ``` Read the `results` array. Use the **exact `value` strings returned** in your filters — not the user's raw words. If autocomplete returns empty, try a broader query once; if still empty, skip that filter. --- ### STEP 2 — Market Sizing (Free — No Credits) Get a count before spending any credits: ```bash RESULT=$(python3 "$CLI" statistics \ --entity-type businesses \ --filters '{"linkedin_category":{"values":["software development"]},"company_size":{"values":["51-200","201-500"]}}') cat "$RESULT" ``` Present `total_results` to the user. If >50,000, suggest narrowing filters. --- ### STEP 3 — Sample Fetch (5–10 Results) ```bash FETCH_RESULT=$(python3 "$CLI" fetch \ --entity-type businesses \ --filters '{"linkedin_category":{"values":["software development"]},"country_code":{"values":["us"]}}' \ --limit 10) cat "$FETCH_RESULT" ``` Record: - `total_results` — total matching entities in the database - `total_fetched` — number fetched into this result file - `sample` — preview rows (first 10) --- ### STEP 4 — Present Sample and WAIT for Confirmation **This step is mandatory — never skip it.** Show the user: 1. Total results found (e.g., "Found 177,588 matching businesses") 2. Credit cost estimate (~1 credit per entity fetched) 3. Sample rows as a markdown table 4. Ask explicitly: > "Would you like to: > - **Fetch all [N] results and export to CSV** > - **Add enrichments** (firmographics, tech stack, funding, contacts, etc.) > - **Add event data** (funding rounds, hiring signals, etc.) > - **Refine the search** (adjust filters)" **NEVER proceed to a full fetch or CSV export without the user's explicit confirmation.** --- ### STEP 5 — Full Fetch (after confirmation) Re-run `fetch` with the desired total count. The CLI paginates automatically in batches of 500: ```bash FETCH_RESULT=$(python3 "$CLI" fetch \ --entity-type businesses \ --filters '{"linkedin_category":{"values":["software development"]},"country_code":{"values":["us"]}}' \ --limit 1000) cat "$FETCH_RESULT" ``` The result file has `data` (array of all entities), `total_fetched`, `pages_fetched`. --- ### STEP 6 (Optional) — Enrich Only if user requested enrichment. Consult `references/enrichments.md`. The `enrich` command reads a fetch result file, runs bulk enrichment in batches of 50, and merges enrichment data back into each entity: ```bash ENRICH_RESULT=$(python3 "$CLI" enrich \ --input-file "$FETCH_RESULT" \ --enrichments "firmographics,technographics") cat "$ENRICH_RESULT" ``` For prospects (to get emails and phones): ```bash ENRICH_RESULT=$(python3 "$CLI" enrich \ --input-file "$FETCH_RESULT" \ --enrichments "contacts_information,profiles") cat "$ENRICH_RESULT" ``` After enrichment, the result file has the same structure but with enrichment data merged into each entity. Show the enriched `sample` (first 5 entries) to the user. --- ### STEP 7 (Optional) — Event Data Only for businesses. Consult `references/events.md` for event types. The `events` command reads a fetch result file and retrieves events for all `business_id` values in it: ```bash EVENTS_RESULT=$(python3 "$CLI" events \ --input-file "$FETCH_RESULT" \ --event-types "new_funding_round,hiring_in_engineering_department" \ --since "2025-11-01") cat "$EVENTS_RESULT" ``` The result file has `data` (array of event objects, each with `business_id`, `event_name`, `event_time`, and event-specific fields). --- ### STEP 8 — Export to CSV Convert the fetch (or enrich) result file to a local CSV: ```bash CSV_RESULT=$(python3 "$CLI" to-csv \ --input-file "$FETCH_RESULT" \ --output ~/Downloads/us_saas_companies.csv) cat "$CSV_RESULT" ``` Read `csv_path` and `row_count` from the result and present them to the user: > "Your CSV is ready: `~/Downloads/us_saas_companies.csv` — 1,000 rows, 18 columns." For events, convert the events result file separately: ```bash python3 "$CLI" to-csv \ --input-file "$EVENTS_RESULT" \ --output ~/Downloads/funding_events.csv ``` --- ## Error Handling If a result file contains `"success": false`, read `error_code`: | `error_code` | Action | |---|---| | `AUTH_MISSING` / `AUTH_FAILED` (401) | Ask user to set `EXPLORIUM_API_KEY` or run `config --api-key` | | `FORBIDDEN` (403) | Show error message; may be a credit or permission issue | | `BAD_REQUEST` (400) / `VALIDATION_ERROR` (422) | Fix the filter — check `references/filters.md`; run `autocomplete` if needed | | `RATE_LIMIT` (429) | Wait 10 seconds and retry once | | `SERVER_ERROR` (5xx) | Wait 5 seconds and retry once; report if it persists | | `NETWORK_ERROR` | Ask user to check connectivity and retry | --- ## Special Workflows ### Start from an Existing CSV When a user has an existing list (companies or contacts) and wants to enrich or extend it: **Step 1 — Convert the CSV to a JSON temp file** (full data stays out of context): ```bash CSV_JSON=$(python3 "$CLI" from-csv \ --input ~/Downloads/my_accounts.csv) ``` **Step 2 — Read ONLY the metadata into context** (columns + 5 sample rows — never `cat` the full file): ```bash python3 -c " import json d = json.load(open('$CSV_JSON')) print('rows:', d['total_rows']) print('columns:', d['columns']) print('sample:') for r in d['sample']: print(r) " ``` Inspect the column names and sample values. Use your judgment to map them to the correct API fields: - **Businesses**: identify which column is the company name → `name`; which is the website/domain → `domain` - **Prospects**: identify the person's name → `full_name` (or `first_name`+`last_name`); employer → `company_name`; contact → `email` or `linkedin` - **CRITICAL**: the prospect LinkedIn field is `"linkedin"` — **never** `"linkedin_url"` (that name is only valid for businesses) **Step 3 — Match with your deduced column map** (batches automatically, 50 rows per call): ```bash # For a company list — pass your deduced mapping explicitly: MATCH_RESULT=$(python3 "$CLI" match-business \ --input-file "$CSV_JSON" \ --column-map '{"Company Name": "name", "Website URL": "domain"}' \ --plan-id "$PLAN_ID" --call-reasoning "$QUERY") python3 -c "import json; d=json.load(open('$MATCH_RESULT')); print('matched:', d['total_matched'], '/', d['total_input'])" # For a contact list (note: LinkedIn field is "linkedin", NOT "linkedin_url"): MATCH_RESULT=$(python3 "$CLI" match-prospect \ --input-file "$CSV_JSON" \ --column-map '{"Full Name": "full_name", "Employer": "company_name", "Work Email": "email", "LinkedIn": "linkedin"}' \ --plan-id "$PLAN_ID" --call-reasoning "$QUERY") ``` If `--column-map` is omitted, the CLI falls back to auto-alias matching on lowercased column names (e.g. `company_name`, `domain`, `website` are recognised automatically). Always prefer the explicit map for better match rates. **Step 4 — Continue the normal workflow** The match result has the same `data` array format as a `fetch` result, so it plugs directly into `enrich` or `events`: ```bash ENRICH_RESULT=$(python3 "$CLI" enrich \ --input-file "$MATCH_RESULT" \ --enrichments "firmographics,technographics" \ --plan-id "$PLAN_ID" --call-reasoning "$QUERY") ``` --- ### Match a User-Provided List (no CSV) When a user types a list of companies or people directly in their message (e.g. "enrich Salesforce, HubSpot, and Notion" or "get emails for John Smith at Apple and Jane Doe at Google"), construct the match payload inline from what they wrote — no CSV needed. **Company list → `match-business`:** ```bash MATCH_RESULT=$(python3 "$CLI" match-business \ --businesses '[ {"name": "Salesforce", "domain": "salesforce.com"}, {"name": "HubSpot", "domain": "hubspot.com"}, {"name": "Notion", "domain": "notion.so"} ]' \ --plan-id "$PLAN_ID" --call-reasoning "$QUERY") python3 -c "import json; d=json.load(open('$MATCH_RESULT')); print('matched:', d['total_matched'], '/', d['total_input'])" ``` Include as many identifiers as the user gave: `name`, `domain`, or both. More fields = better match rate. **Contact list → `match-prospect`:** ```bash MATCH_RESULT=$(python3 "$CLI" match-prospect \ --prospects '[ {"full_name": "John Smith", "company_name": "Apple"}, {"full_name": "Jane Doe", "company_name": "Google", "email": "[REDACTED_SECRET]"} ]' \ --plan-id "$PLAN_ID" --call-reasoning "$QUERY") ``` After matching, pipe the result directly into `enrich` or `to-csv` as normal. --- ### Find Prospects at Specific Companies 1. Match companies to get their `business_id` values: ```bash RESULT=$(python3 "$CLI" match-business \ --businesses '[{"name":"Salesforce","domain":"salesforce.com"}]') cat "$RESULT" ``` 2. Extract the `business_id` and use it as a filter in the prospect fetch: ```bash BID=$(python3 -c "import json; print(json.load(open('$RESULT'))['data'][0]['business_id'])") FETCH_RESULT=$(python3 "$CLI" fetch \ --entity-type prospects \ --filters "{\"business_id\":{\"values\":[\"$BID\"]},\"job_level\":{\"values\":[\"c-suite\"]}}") ``` ### Companies → Prospects (Chaining) 1. Fetch target companies 2. Extract their `business_id` values from the result file 3. Pass them in the `business_id` filter when fetching prospects ### Buying Intent When user wants to find companies showing interest in a product/topic: 1. `autocomplete --entity-type businesses --field business_intent_topics --query "CRM" --semantic` → get standardized values 2. Use them in the `business_intent_topics` filter in `fetch` --- ## Pagination Notes The `fetch` command paginates automatically. With `--limit 1000`: - Issues page 1 (500 records) then page 2 (500 records) - Writes all 1000 into a single result file - `pages_fetched` in the result tells you how many pages were used - `total_results` is the full database count matching your filters The `enrich` command handles its own batching (50 IDs per API call) internally. The `events` command batches 40 business IDs per API call internally.

path: setup.sh
content: #!/usr/bin/env bash
set -euo pipefail

# AgentSource Plugin — Setup Script
# Installs the CLI to ~/.agentsource/bin/ and optionally saves your API key.
#
# Files installed:
#   ~/.agentsource/bin/agentsource.py   — the CLI tool (copied from ./bin/agentsource.py)
#   ~/.agentsource/config.json          — API key storage (mode 600, only if you choose to save it)
#
# Nothing is sent to any network during setup. The API key is only used when
# you run CLI commands that call https://api.explorium.ai/v1/.

PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$HOME/.agentsource"
BIN_DIR="$INSTALL_DIR/bin"
CONFIG_FILE="$INSTALL_DIR/config.json"

echo "=== AgentSource Plugin Setup ==="
echo ""
echo "This script will:"
echo "  1. Copy bin/agentsource.py  →  $BIN_DIR/agentsource.py"
echo "  2. Optionally save your API key to $CONFIG_FILE (mode 600)"
echo ""

# ---------------------------------------------------------------------------
# 1. Verify Python 3.8+
# ---------------------------------------------------------------------------
if ! command -v python3 &>/dev/null; then
    echo "[ERROR] Python 3 not found. Please install Python 3.8+ and retry."
    exit 1
fi

PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')

if [ "$PYTHON_MAJOR" -lt 3 ] || { [ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 8 ]; }; then
    echo "[ERROR] Python 3.8+ required. Found: $PYTHON_VERSION"
    exit 1
fi

echo "[OK] Python $PYTHON_VERSION"

# ---------------------------------------------------------------------------
# 2. Create directory structure
# ---------------------------------------------------------------------------
mkdir -p "$BIN_DIR"
echo "[OK] Created $BIN_DIR"

# ---------------------------------------------------------------------------
# 3. Install CLI
# ---------------------------------------------------------------------------
cp "$PLUGIN_DIR/bin/agentsource.py" "$BIN_DIR/agentsource.py"
chmod +x "$BIN_DIR/agentsource.py"
echo "[OK] CLI installed to $BIN_DIR/agentsource.py"

# ---------------------------------------------------------------------------
# 4. API key configuration
#    IMPORTANT: Never enter your API key into the AI chat window.
#    Set it here in your terminal (secure) or via environment variable.
# ---------------------------------------------------------------------------
echo ""
echo "--- API Key Setup ---"
echo "IMPORTANT: Do NOT share your API key in the AI chat."
echo "Set it here in your terminal or via the EXPLORIUM_API_KEY environment variable."
echo ""

if [ -n "${EXPLORIUM_API_KEY:-}" ]; then
    echo "[OK] EXPLORIUM_API_KEY is already set in your environment."
    read -r -p "Save it to $CONFIG_FILE for persistence? [y/N] " save_key
    if [[ "${save_key:-}" =~ ^[Yy]$ ]]; then
        printf '{\n  "api_key": "%s"\n}\n' \
            "$EXPLORIUM_API_KEY" > "$CONFIG_FILE"
        chmod 600 "$CONFIG_FILE"
        echo "[OK] API key saved to $CONFIG_FILE (mode 600, owner read-only)"
    fi
else
    echo "No EXPLORIUM_API_KEY environment variable detected."
    echo ""
    read -r -p "Enter your Explorium API key (or press Enter to skip): " api_key
    if [ -n "${api_key:-}" ]; then
        printf '{\n  "api_key": "%s"\n}\n' \
            "$api_key" > "$CONFIG_FILE"
        chmod 600 "$CONFIG_FILE"
        echo "[OK] API key saved to $CONFIG_FILE (mode 600, owner read-only)"
        echo ""
        echo "To also set it as an environment variable, add this to ~/.zshrc or ~/.bashrc:"
        echo "  export EXPLORIUM_API_KEY='$api_key'"
    else
        echo "[WARN] No API key configured."
        echo "  Set it later with:  python3 $BIN_DIR/agentsource.py config --api-key <key>"
        echo "  Or set env var:     export EXPLORIUM_API_KEY=<key>"
        echo "  Get a key at:       https://developers.explorium.ai/reference/setup/getting_your_api_key"
    fi
fi

# ---------------------------------------------------------------------------
# 5. Smoke-test the CLI
# ---------------------------------------------------------------------------
echo ""
echo "Running CLI smoke test..."
if python3 "$BIN_DIR/agentsource.py" --help &>/dev/null; then
    echo "[OK] CLI smoke test passed"
else
    echo "[ERROR] CLI smoke test failed. Check your Python installation."
    exit 1
fi

# ---------------------------------------------------------------------------
# 6. Done
# ---------------------------------------------------------------------------
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Files installed:"
echo "  $BIN_DIR/agentsource.py   — CLI tool"
if [ -f "$CONFIG_FILE" ]; then
    echo "  $CONFIG_FILE       — API key (mode 600)"
fi
echo ""
echo "Optional: Add the CLI to your PATH:"
echo "  echo 'export PATH=\"\$HOME/.agentsource/bin:\$PATH\"' >> ~/.zshrc"
echo ""
echo "Usage — describe your target to your AI agent, for example:"
echo "  'Find CTOs at Series B SaaS companies in California'"
echo "  'Show me fintech companies using Stripe with 50-200 employees'"
echo "  'Get emails for VP Sales at companies that recently raised Series A'"
echo ""
echo "Direct CLI usage:"
echo "  python3 $BIN_DIR/agentsource.py --help"
echo "  python3 $BIN_DIR/agentsource.py autocomplete --entity-type businesses --field linkedin_category --query 'saas' --semantic"


path: plugin.json
content: {
  "name": "Companies & Contacts enrichment - Explorium AgentSource",
  "version": "1.0.0",
  "description": "Find B2B companies & contacts using the Explorium API. Enrich with contact info, intent data, technographics, firmographics, live events, and more. Export as CSV.",
  "author": {
    "name": "Yossi Golan"
  },
  "required_credentials": [
    {
      "name": "EXPLORIUM_API_KEY",
      "type": "env_var",
      "description": "Your Explorium AgentSource API key. Get one at https://developers.explorium.ai/reference/setup/getting_your_api_key",
      "required": true,
      "storage": "~/.agentsource/config.json (mode 600) or EXPLORIUM_API_KEY environment variable"
    }
  ],
  "data_privacy": {
    "remote_endpoints": [
      "https://api.explorium.ai/v1/"
    ],
    "data_sent": "API key (header), search filters, entity IDs. Free-text call_reasoning is opt-in only.",
    "local_storage": [
      "~/.agentsource/config.json (API key, mode 600, owner read-only)",
      "/tmp/agentsource_*.json (result data, auto-cleaned by OS)"
    ]
  }
}


path: README.md
content: # AgentSource Plugin for AI Agents

B2B prospecting and company intelligence using the AgentSource API. Describe who you're looking for in plain English — your AI agent guides you through the workflow, shows you a preview, and exports to CSV.

Works with **Claude Code**, **Claude Cowork**, **OpenClaw**, and any other AI agent environment that supports skills and plugins.

## How It Works

1. You describe a target in natural language
2. Your AI agent (guided by the `agentsource` skill) parses your intent into API filters
3. It shows you market statistics and a data sample before touching any credits
4. You confirm — then it fetches your results and exports a downloadable CSV

All API responses are written to temp files so that large payloads never appear in the conversation context window, enabling scale queries of thousands of records.

## Requirements

- Python 3.8+ (standard library only — no `pip` required)
- An Explorium AgentSource API key
- Any AI agent environment that supports skills/plugins (Claude Code, Cowork, OpenClaw, etc.)

---

## Quick Start

### 1. Install
```bash
./setup.sh
```

This installs the CLI to `~/.agentsource/bin/agentsource.py` and optionally saves your API key to `~/.agentsource/config.json` (permissions 600, owner read-only).

### 2. Set your API key

**Do not share your API key in the AI chat.** Set it securely using one of these methods:

**Option A — Environment variable** (recommended for persistent shell setups):
```bash
export EXPLORIUM_API_KEY=your_api_key_here
# Add to ~/.zshrc or ~/.bashrc to persist across sessions
```

**Option B — CLI config command** (saves to `~/.agentsource/config.json`, mode 600):
```bash
python3 ~/.agentsource/bin/agentsource.py config --api-key your_api_key_here
```

Don't have a key yet? Visit [developers.explorium.ai](https://developers.explorium.ai/reference/setup/getting_your_api_key) for instructions.

### 3. Open your AI agent and describe your target
```
Find CTOs at Series B SaaS companies in California
```
```
Show me fintech companies using Stripe with 50–200 employees
```
```
Get emails for VP Sales at companies that recently raised Series A
```
```
Find companies hiring engineers that use Kubernetes
```

---

## File Structure

```
agentsource-plugin/
├── bin/
│   └── agentsource.py               # The CLI (pure Python stdlib)
├── references/
│   ├── filters.md                   # All available filters with valid values
│   ├── enrichments.md               # Enrichment types and credit costs
│   └── events.md                    # Business/prospect trigger event types
├── SKILL.md                         # Workflow logic — how the AI agent uses the API
├── setup.sh                         # Installation script
└── README.md
```

---

## CLI Reference

All commands write results to `/tmp/agentsource_<timestamp>_<command>.json` and print only the file path to stdout.

### API Key Config
```bash
python3 ~/.agentsource/bin/agentsource.py config --api-key <key>
```

### Autocomplete (required for certain filter fields)
```bash
# Always pass --semantic for best results
python3 ~/.agentsource/bin/agentsource.py autocomplete \
  --entity-type businesses \
  --field linkedin_category --query "software" --semantic
```

### Market Sizing (free — no credits)
```bash
python3 ~/.agentsource/bin/agentsource.py statistics \
  --entity-type businesses \
  --filters '{"company_size":{"values":["51-200"]}}'
```

### Fetch (auto-paginates in batches of 500)
```bash
# Sample — 10 results
python3 ~/.agentsource/bin/agentsource.py fetch \
  --entity-type businesses \
  --filters '{"country_code":{"values":["us"]},"company_size":{"values":["51-200"]}}' \
  --limit 10

# Large query — 3,000 records (6 pages, automatic)
python3 ~/.agentsource/bin/agentsource.py fetch \
  --entity-type businesses \
  --filters '{"country_code":{"values":["us"]}}' \
  --limit 3000
```

### Enrich
```bash
# Enrich companies with firmographics and tech stack
python3 ~/.agentsource/bin/agentsource.py enrich \
  --input-file /tmp/agentsource_xxx_fetch.json \
  --enrichments "firmographics,technographics"

# Enrich prospects with emails and phones
python3 ~/.agentsource/bin/agentsource.py enrich \
  --input-file /tmp/agentsource_xxx_fetch.json \
  --enrichments "contacts_information,profiles"
```

### Events
```bash
python3 ~/.agentsource/bin/agentsource.py events \
  --input-file /tmp/agentsource_xxx_fetch.json \
  --event-types "new_funding_round,hiring_in_engineering_department" \
  --since "2025-01-01"
```

### Export to CSV
```bash
python3 ~/.agentsource/bin/agentsource.py to-csv \
  --input-file /tmp/agentsource_xxx_fetch.json \
  --output ~/Downloads/results.csv
```

### Entity Matching
```bash
# Match specific companies by name/domain
python3 ~/.agentsource/bin/agentsource.py match-business \
  --businesses '[{"name":"Salesforce","domain":"salesforce.com"}]'

# Match from an existing CSV
python3 ~/.agentsource/bin/agentsource.py match-business \
  --input-file /tmp/agentsource_xxx_from_csv.json \
  --column-map '{"Company":"name","Website":"domain"}'

# Match specific people
python3 ~/.agentsource/bin/agentsource.py match-prospect \
  --prospects '[{"full_name":"Jane Doe","company_name":"Acme Corp","email":"[REDACTED_SECRET]"}]'

# Match prospects from a CSV
python3 ~/.agentsource/bin/agentsource.py match-prospect \
  --input-file /tmp/agentsource_xxx_from_csv.json \
  --column-map '{"Name":"full_name","Company":"company_name","LinkedIn":"linkedin","Email":"email"}'
```

### CSV Import
```bash
# Convert an existing CSV for use with match-business or match-prospect
python3 ~/.agentsource/bin/agentsource.py from-csv \
  --input ~/Downloads/my_accounts.csv
```

---

## Data & Privacy

### What is stored locally

| Location | Contents | Permissions |
|---|---|---|
| `~/.agentsource/config.json` | API key (if saved via `config` command) | 600 (owner read-only) |
| `/tmp/agentsource_*.json` | API result data | Cleaned up automatically by OS |

### What is sent to Explorium's API

All API calls go to `https://api.explorium.ai/v1/`. The following data is transmitted:

| Data | When sent |
|---|---|
| Your API key (as `api_key` header) | Every request |
| Search filters (e.g., country, company size) | `fetch`, `statistics`, `autocomplete` |
| Entity IDs for enrichment/events | `enrich`, `events` |
| Company/contact records for matching | `match-business`, `match-prospect` |
| `plan_id` (random UUID) | When `--plan-id` is passed (optional) |
| `call_reasoning` (your natural-language query) | **Only** when `--call-reasoning` is passed (opt-in) |

**Note on `call_reasoning`**: When the AI agent passes `--call-reasoning`, the text of your search query (e.g., "find CTOs at Series B SaaS companies") is sent to Explorium's server as request metadata for logging and support purposes. This is opt-in — if omitted, no query text is transmitted.

### What is NOT sent

- Conversation content or chat history
- File contents beyond what's needed for matching/enrichment
- Any data beyond what is documented above

---

## Troubleshooting

**"EXPLORIUM_API_KEY is not set"**
→ Run `python3 ~/.agentsource/bin/agentsource.py config --api-key <key>`, or `export EXPLORIUM_API_KEY=your_key`

**"CLI not found"**
→ Run `./setup.sh` from the plugin directory

**401 / Auth errors**
→ Verify your API key at [developers.explorium.ai](https://developers.explorium.ai/reference/setup/getting_your_api_key)

**400 / Bad request errors**
→ A filter value is invalid. Run `autocomplete --semantic` for the relevant field, or check `references/filters.md`

**Large queries timing out**
→ Reduce `--limit` or split the query by adding a narrower filter (country, company size, etc.)


path: references/filters.md
content: # Filter Reference

All filters are passed inside the `--filters` JSON argument of `fetch` and `statistics` commands.

## Filter JSON Structure

Every filter uses this shape:
```json
{
  "filter_name": {
    "values": ["value1", "value2"],
    "negate": false
  }
}
```
Set `"negate": true` to **exclude** entities matching those values.

---

## Filters Requiring Autocomplete (MANDATORY)

These fields MUST be resolved via the `autocomplete` command before use.
Never pass raw user text directly — always use the standardized values returned.

| Filter Field | `--field` Arg | Example Query |
|---|---|---|
| `linkedin_category` | `linkedin_category` | `"software development"`, `"financial services"` |
| `naics_category` | `naics_category` | `"software"`, `"healthcare"` |
| `job_title` | `job_title` | `"Chief Technology Officer"`, `"Account Executive"` |
| `business_intent_topics` | `business_intent_topics` | `"CRM software"`, `"cloud migration"` |
| `company_tech_stack_tech` | `company_tech_stack_tech` | `"Salesforce"`, `"React"`, `"Kubernetes"` |
| `city_region` | `city_region` | `"San Francisco"`, `"New York"` (US cities only) |

**Mutual exclusion**: Never use both `linkedin_category` AND `naics_category` in the same query.

---

## Enum Filters (Use Values Directly — No Autocomplete)

### `company_size` (number of employees)
```
"1-10"  "11-50"  "51-200"  "201-500"  "501-1000"
"1001-5000"  "5001-10000"  "10001+"
```

### `company_revenue` (annual revenue)
```
"0-500K"  "500K-1M"  "1M-5M"  "5M-10M"  "10M-25M"  "25M-75M"
"75M-200M"  "200M-500M"  "500M-1B"  "1B-10B"  "10B-100B"  "100B-1T"
"1T-10T"  "10T+"
```

### `company_age` (years since founding)
```
"0-3"  "3-6"  "6-10"  "10-20"  "20+"
```

### `number_of_locations`
```
"0-1"  "2-5"  "6-20"  "21-50"  "51-100"  "101-1000"  "1001+"
```

### `company_tech_stack_category`
```
"Technology"  "Marketing"  "E-commerce"  "Devops And Development"
"Programming Languages And Frameworks"  "Testing And Qa"
"Platform And Storage"  "Health Tech"  "Business Intelligence And Analytics"
"Operations Management"  "Customer Management"  "Hr"
"Industrial Engineering And Manufacturing"  "Product And Design"
"Sales"  "It Security"  "It Management"  "Finance And Accounting"
"Computer Networks"  "Collaboration"  "Communications"
"Productivity And Operations"  "Healthcare And Life Science"  "Other"
```

### `job_level` (prospects only)
```
"c-suite"  "manager"  "owner"  "senior non-managerial"  "partner"
"freelancer"  "junior"  "director"  "board member"  "founder"
"president"  "senior manager"  "advisor"  "non-managerial"  "vice president"
```

### `job_department` (prospects only)
```
"administration"  "healthcare"  "partnerships"  "c-suite"  "design"
"human resources"  "engineering"  "education"  "strategy"  "product"
"sales"  "r&d"  "retail"  "customer success"  "security"  "public service"
"creative"  "it"  "support"  "marketing"  "trade"  "legal"  "operations"
"real estate"  "procurement"  "data"  "manufacturing"  "logistics"  "finance"
```

---

## Location Filters

### Country codes (ISO Alpha-2)
Use with `company_country_code` or `prospect_country_code`:
```
"US"  "GB"  "DE"  "FR"  "IL"  "CA"  "AU"  "SG"  "IN"  "NL"  ...
```

### Region/State codes (ISO 3166-2)
Use with `company_region_country_code` or `prospect_region_country_code`:
```
"US-CA"  "US-NY"  "US-TX"  "US-FL"  "US-WA"  "IL-TA"  "GB-ENG"  ...
```

**Mutual exclusion**: Never combine `company_country_code` AND `company_region_country_code` (same applies to prospect variants). Use one or the other.

**US cities**: Use `city_region` filter (requires autocomplete) instead of `region_country_code` for city-level targeting.

---

## Boolean / Presence Filters

| Filter | Valid Values | Notes |
|---|---|---|
| `has_website` | `true`, `false`, `null` | null = no filter |
| `is_public_company` | `true`, `false`, `null` | true = NYSE/NASDAQ listed |
| `has_email` | `true`, `null` | prospects only; use `true` when contact emails needed |
| `has_phone_number` | `true`, `null` | prospects only |

---

## Range Filters (Prospects Only)

### `total_experience_months`
```json
{ "total_experience_months": { "gte": 24, "lte": 120 } }
```

### `current_role_months`
```json
{ "current_role_months": { "gte": 6, "lte": 36 } }
```

---

## Event Filter (use within `fetch`)

Filter businesses that have a specific event within a lookback window:
```json
{
  "events": {
    "values": ["new_funding_round", "new_partnership"],
    "last_occurrence": 60,
    "negate": false
  }
}
```
`last_occurrence`: number of days (30–90 only, **cannot exceed 90**).

---

## Title vs. Department/Level

- For **specific titles**: use `job_title` (requires autocomplete)
- For **broad roles**: use `job_level` + `job_department` (no autocomplete)
- **Never** combine `job_title` WITH `job_level` or `job_department` in the same query

---

## Example Filter Objects

### US SaaS companies, 51–500 employees
```json
{
  "linkedin_category": { "values": ["Software Development"] },
  "company_country_code": { "values": ["US"] },
  "company_size": { "values": ["51-200", "201-500"] }
}
```

### CTOs at companies using Salesforce (prospects)
```json
{
  "job_title": { "values": ["Chief Technology Officer"] },
  "company_tech_stack_tech": { "values": ["Salesforce"] }
}
```

### Companies with recent funding in fintech
```json
{
  "linkedin_category": { "values": ["Financial Services"] },
  "events": { "values": ["new_funding_round"], "last_occurrence": 60 }
}
```

### Exclude companies under 10 employees
```json
{
  "company_size": { "values": ["1-10"], "negate": true }
}
```


path: references/events.md
content: # Events Reference

Events let you filter for companies/prospects based on recent real-world triggers, or fetch detailed event data for a result set.

## Business Events (`business-events` command)

### Growth & Expansion
| Event Type | Description |
|---|---|
| `new_funding_round` | New investment round (Seed, Series A/B/C/D/E, PE, etc.) |
| `ipo_announcement` | IPO filed or publicly announced |
| `new_investment` | Company made an investment in another company |
| `new_product` | New product or service launched or announced |
| `new_office` | New office location opened |
| `new_partnership` | New strategic partnership or integration announced |
| `company_award` | Award, recognition, or ranking received |

### Hiring & Workforce Growth
| Event Type | Description |
|---|---|
| `increase_in_engineering_department` | Engineering headcount growing |
| `increase_in_sales_department` | Sales headcount growing |
| `increase_in_marketing_department` | Marketing headcount growing |
| `increase_in_operations_department` | Operations headcount growing |
| `increase_in_customer_service_department` | Customer service headcount growing |
| `increase_in_all_departments` | Company-wide headcount growth |
| `hiring_in_engineering_department` | Active engineering job postings |
| `hiring_in_sales_department` | Active sales job postings |
| `hiring_in_marketing_department` | Active marketing job postings |
| `hiring_in_finance_department` | Active finance job postings |
| `hiring_in_legal_department` | Active legal job postings |
| `hiring_in_operations_department` | Active operations job postings |
| `hiring_in_human_resources_department` | Active HR job postings |
| `hiring_in_creative_department` | Active creative job postings |
| `hiring_in_education_department` | Active education job postings |
| `hiring_in_health_department` | Active healthcare job postings |
| `hiring_in_professional_service_department` | Active professional services job postings |
| `hiring_in_support_department` | Active support job postings |
| `hiring_in_trade_department` | Active trade job postings |
| `hiring_in_unknown_department` | Active hiring, department unclear |
| `employee_joined_company` | Notable senior employee joined the company |

### Contraction & Challenges
| Event Type | Description |
|---|---|
| `decrease_in_engineering_department` | Engineering headcount shrinking |
| `decrease_in_sales_department` | Sales headcount shrinking |
| `decrease_in_marketing_department` | Marketing headcount shrinking |
| `decrease_in_operations_department` | Operations headcount shrinking |
| `decrease_in_customer_service_department` | Customer service headcount shrinking |
| `decrease_in_all_departments` | Company-wide headcount contraction / layoffs |
| `closing_office` | Office location closed |
| `cost_cutting` | Cost reduction initiatives announced |
| `merger_and_acquisitions` | M&A activity (being acquired or acquiring) |
| `lawsuits_and_legal_issues` | Legal proceedings, regulatory actions |
| `outages_and_security_breaches` | Technical incidents, security breaches |

---

## Prospect Events (`prospect-events` command)

| Event Type | Description |
|---|---|
| `prospect_changed_role` | Person promoted or changed roles within the same company |
| `prospect_changed_company` | Person moved to a new company |
| `prospect_job_start_anniversary` | Tenure milestone (1-year, 2-year, etc.) |

---

## Using Events as a Fetch Filter

To filter the search result to only include companies that experienced a specific event, add the `events` filter inside the `--filters` JSON of the `fetch` command:

```json
{
  "events": {
    "values": ["new_funding_round", "hiring_in_engineering_department"],
    "last_occurrence": 60,
    "negate": false
  }
}
```

`last_occurrence`: number of days to look back — **30 to 90 only** (hard API limit). Cannot exceed 90.

---

## Fetching Detailed Event Data

After fetching entities, call `business-events` or `prospect-events` to retrieve the actual event details for each company/prospect in the result table.

```bash
RESULT=$(python ~/.agentsource/bin/agentsource.py business-events \
  --session-id "$SESSION_ID" \
  --table-name "$TABLE_NAME" \
  --event-types "new_funding_round,new_partnership" \
  --since "2025-11-01")
cat "$RESULT"
```

**Sample vs. Export**:
- Sample preview (via `--sample-size 3-5`): Shows up to 3 events per entity
- Full export: ALL events per entity, which can be tens to hundreds per company

**Note**: Event data is returned as a new `events_table_name` — use this table name when calling `export`.

---

## Common Event-Driven Workflows

| Sales Trigger | Events to Use |
|---|---|
| Companies that just raised money | `new_funding_round` |
| Companies actively hiring (expansion signal) | `increase_in_all_departments`, `hiring_in_engineering_department` |
| Companies going through change (potential buyer) | `merger_and_acquisitions`, `new_product`, `new_partnership` |
| Distressed companies (cost-cutting tools) | `cost_cutting`, `decrease_in_all_departments`, `closing_office` |
| New executive hire (champion at new company) | `employee_joined_company` |
| Person who just changed jobs (new budget) | `prospect_changed_company`, `prospect_changed_role` |


path: references/enrichments.md
content: # Enrichment Reference

Enrichments add detailed data fields to fetch results and consume additional credits.
**Max 3 enrichment types per single enrich call.** Chain multiple calls for more.

## Business Enrichments (`enrich-business` command)

| Enrichment Type | Data Provided | Notes |
|---|---|---|
| `enrich-business-firmographics` | Name, description, website, HQ location, industry, employee count, revenue range | Basic company profile — usually the first enrichment |
| `enrich-business-technographics` | Complete tech stack (products + categories) | Identifies what software the company uses |
| `enrich-business-company-ratings` | Employee satisfaction, culture scores, Glassdoor-style ratings | |
| `enrich-business-financial-metrics` | Revenue, margins, EPS, EBITDA, market cap | **Public companies only**. Requires `--date YYYY-MM-DD` (the reporting period) |
| `enrich-business-funding-and-acquisitions` | Funding rounds, investors, total raised, IPO details, acquisitions | Includes round type (Seed/Series A-E/PE/etc.) |
| `enrich-business-challenges` | Business risks, strategic challenges from SEC 10-K filings | **Public companies only** |
| `enrich-business-competitive-landscape` | Competitors, market positioning from SEC filings | **Public companies only** |
| `enrich-business-strategic-insights` | Strategic focus, value propositions, key initiatives from SEC filings | **Public companies only** |
| `enrich-business-workforce-trends` | Dept headcount breakdown, hiring velocity, YoY growth by department | |
| `enrich-business-linkedin-posts` | Recent company LinkedIn posts, engagement metrics (likes, comments, shares) | |
| `enrich-business-website-changes` | Website content changes detected over time, summary of updates | |
| [REDACTED_SECRET] | Whether specific keywords appear on company website | Requires `--keywords kw1,kw2` |
| `enrich-business-webstack` | Web-specific tech: CDN, analytics platforms, CMS, chat widgets | More granular than technographics for web layer |
| `enrich-business-company-hierarchies` | Parent company, subsidiaries, org tree structure | Output is nested JSON |

## Prospect Enrichments (`enrich-prospects` command)

| Enrichment Type | Data Provided | Notes |
|---|---|---|
| `enrich-prospects-contacts` | Professional email, personal email, direct phone, mobile phone | Use `--contact-types email,phone` to limit scope; `email` only is cheaper |
| `enrich-prospects-profiles` | Full name, demographics, current & past work history, education, LinkedIn URL | |

## Common Enrichment Combinations

| Use Case | Enrichments |
|---|---|
| Basic company profile | `enrich-business-firmographics` |
| Tech stack + company info | `enrich-business-firmographics,enrich-business-technographics` |
| Investor/VC targeting | `enrich-business-firmographics,enrich-business-funding-and-acquisitions` |
| Website intelligence | `enrich-business-website-changes,enrich-business-webstack` |
| Workforce growth signals | `enrich-business-workforce-trends,enrich-business-firmographics` |
| Full contact list | `enrich-prospects-contacts,enrich-prospects-profiles` |
| Email-only list (cheaper) | `enrich-prospects-contacts` + `--contact-types email` |
| All company intel (chain 2 calls) | Call 1: `enrich-business-firmographics,enrich-business-technographics,enrich-business-funding-and-acquisitions` → Call 2: `enrich-business-workforce-trends,enrich-business-linkedin-posts` |

## Critical Rules

1. **Always use `enriched_table_name`** from the enrich response in all subsequent operations (export, further enrich, events). The enriched table is a new table — do not use the original fetch table name.
2. **Chain enrichments sequentially**: Each enrich call returns a new `enriched_table_name`; pass it as `--table-name` to the next enrich call.
3. **Public company enrichments** (`financial-metrics`, `challenges`, `competitive-landscape`, `strategic-insights`) return empty data for private companies — use `is_public_company: true` filter when these are the goal.
4. **Re-present sample** to the user after enrichment before asking about export.


path: bin/agentsource.py
content: #!/usr/bin/env python3
"""
AgentSource CLI — wraps the Explorium REST API at api.explorium.ai.

All results are written to temporary files in /tmp/.
The CLI prints ONLY the temp-file path to stdout so that large payloads
never enter the conversation context window.

Usage:
    python agentsource.py <command> [options]

Authentication:
    Set EXPLORIUM_API_KEY environment variable, or run:
        python agentsource.py config --api-key <key>
"""

import argparse
import csv
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

BASE_URL = "https://api.explorium.ai/v1"
INSTALL_DIR = pathlib.Path.home() / ".agentsource"
# API key is stored only at this single location (mode 600, owner read-only)
CONFIG_FILE = INSTALL_DIR / "config.json"
TEMP_DIR = pathlib.Path("/tmp")

# Max IDs per bulk enrichment request (API limit)
BULK_ENRICH_BATCH = 50
# Max IDs per events request (API limit)
EVENTS_BATCH = 40
# Max page size (API limit)
MAX_PAGE_SIZE = 500
# Max entities per match request (API limit)
MATCH_BATCH = 50

# Column alias maps: CSV column name (lowercased, underscored) → API match field
BUSINESS_MATCH_ALIASES = {
    "name": "name", "company_name": "name", "company": "name",
    "organization": "name", "account": "name", "account_name": "name",
    "domain": "domain", "website": "domain", "url": "domain",
    "web": "domain", "homepage": "domain",
}

PROSPECT_MATCH_ALIASES = {
    "full_name": "full_name", "name": "full_name",
    "first_name": "first_name", "last_name": "last_name",
    "company_name": "company_name", "company": "company_name",
    "organization": "company_name", "employer": "company_name",
    "email": "email", "email_address": "email",
    "linkedin": "linkedin", "linkedin_url": "linkedin",
    "linkedin_profile": "linkedin",
}

# Bulk enrichment endpoint path fragments (relative to BASE_URL)
BUSINESS_ENRICHMENT_PATHS = {
    "firmographics": "businesses/firmographics",
    "technographics": "businesses/technographics",
    "funding_and_acquisition": "businesses/funding_and_acquisition",
    "company_ratings": "businesses/company_ratings",
    "financial_metrics": "businesses/financial_metrics",
    "challenges": "businesses/challenges",
    "competitive_landscape": "businesses/competitive_landscape",
    "strategic_insights": "businesses/strategic_insights",
    "workforce_trends": "businesses/workforce_trends",
    "social_media": "businesses/company_social_media",
    "website_content_changes": "businesses/website_content_changes",
    "webstack": "businesses/webstack",
    "keyword_search_on_websites": "businesses/keyword_search_on_websites",
    "company_hierarchy": "businesses/company_hierarchy",
}

PROSPECT_ENRICHMENT_PATHS = {
    "contacts_information": "prospects/contacts_information",
    "profiles": "prospects/profiles",
}

# ---------------------------------------------------------------------------
# Helpers — API key
# ---------------------------------------------------------------------------

def get_api_key() -> str:
    key = os.environ.get("EXPLORIUM_API_KEY", "").strip()
    if key:
        return key
    if CONFIG_FILE.exists():
        try:
            key = json.loads(CONFIG_FILE.read_text()).get("api_key", "").strip()
            if key:
                return key
        except (json.JSONDecodeError, OSError):
            pass
    write_error("get_api_key", (
        "EXPLORIUM_API_KEY is not set. "
        "Export it as an environment variable or run: "
        "python agentsource.py config --api-key <your_key>"
    ), "AUTH_MISSING")
    sys.exit(1)


# ---------------------------------------------------------------------------
# Helpers — temp file output
# ---------------------------------------------------------------------------

def make_temp_path(command: str) -> pathlib.Path:
    ts = int(time.time())
    return TEMP_DIR / f"agentsource_{ts}_{command}.json"


def write_result(command: str, data: dict) -> pathlib.Path:
    path = make_temp_path(command)
    path.write_text(json.dumps(data, indent=2, default=str))
    print(str(path))
    return path


def write_error(command: str, error_msg: str, error_code: str = None,
                http_status: int = None) -> pathlib.Path:
    data = {
        "success": False,
        "error": error_msg,
        "error_code": error_code,
        "http_status": http_status,
        "command": command,
        "timestamp": int(time.time()),
    }
    path = make_temp_path(f"{command}_error")
    path.write_text(json.dumps(data, indent=2))
    print(str(path))
    return path


# ---------------------------------------------------------------------------
# Helpers — HTTP REST calls
# ---------------------------------------------------------------------------

def _request(api_key: str, method: str, path: str,
             params: dict = None, body: dict = None,
             retry: int = 1) -> dict:
    """
    Make an authenticated call to the AgentSource REST API.
    Returns the parsed JSON response dict.
    Raises ValueError with structured info on failure.
    """
    url = f"{BASE_URL}/{path}"
    if params:
        url = url + "?" + urllib.parse.urlencode(params)

    data = json.dumps(body).encode() if body is not None else None
    headers = {
        "api_key": api_key,
        "Content-Type": "application/json",
        "accept": "application/json",
        "User-Agent": "agentsource-cli/1.0",
    }
    req = urllib.request.Request(url, data=data, headers=headers, method=method)

    for attempt in range(retry + 1):
        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                return json.loads(resp.read())
        except urllib.error.HTTPError as exc:
            status = exc.code
            body_text = exc.read().decode("utf-8", errors="replace")
            if status == 429 and attempt < retry:
                retry_after = int(exc.headers.get("Retry-After", "10"))
                time.sleep(retry_after)
                continue
            if status in (500, 502, 503) and attempt < retry:
                time.sleep(5)
                continue
            code_map = {401: "AUTH_FAILED", 403: "FORBIDDEN", 400: "BAD_REQUEST",
                        404: "NOT_FOUND", 422: "VALIDATION_ERROR", 429: "RATE_LIMIT"}
            raise ValueError(json.dumps({
                "error_code": code_map.get(status, "SERVER_ERROR"),
                "http_status": status,
                "error": body_text[:500],
            }))
        except (urllib.error.URLError, OSError) as exc:
            if attempt < retry:
                time.sleep(3)
                continue
            raise ValueError(json.dumps({
                "error_code": "NETWORK_ERROR", "http_status": None, "error": str(exc)
            }))


def safe_request(command: str, api_key: str, method: str, path: str,
                 params: dict = None, body: dict = None) -> dict:
    """Wrapper that writes an error temp file and exits on failure."""
    try:
        return _request(api_key, method, path, params=params, body=body)
    except ValueError as exc:
        try:
            info = json.loads(str(exc))
        except json.JSONDecodeError:
            info = {"error_code": "UNKNOWN", "http_status": None, "error": str(exc)}
        write_error(command, info.get("error", str(exc)),
                    info.get("error_code"), info.get("http_status"))
        sys.exit(1)


# ---------------------------------------------------------------------------
# Helpers — request context (usage tracking)
# ---------------------------------------------------------------------------

def _add_tracking_args(parser) -> None:
    """Add optional request-context tracking args to any API-calling subcommand."""
    parser.add_argument(
        "--call-reasoning", default=None, metavar="TEXT",
        help="The user query that triggered this call (e.g. 'find 500 healthcare PMs')",
    )
    parser.add_argument(
        "--plan-id", default=None, metavar="UUID",
        help="Unique ID grouping all CLI calls made to answer a single user query",
    )


def _request_context(args):
    """Build the optional request_context payload for server-side call grouping.

    Privacy note: when --call-reasoning is supplied, the user's query text is
    sent to api.explorium.ai as part of the request metadata. This is opt-in —
    omit --call-reasoning if the user has not consented to query logging.
    """
    reasoning = getattr(args, "call_reasoning", None)
    plan_id = getattr(args, "plan_id", None)
    if not reasoning and not plan_id:
        return None
    return {
        "type": "agentsource",
        "call_reasoning": reasoning,
        "plan_id": plan_id,
    }


# ---------------------------------------------------------------------------
# Helpers — data utilities
# ---------------------------------------------------------------------------

def _flatten(obj: dict, prefix: str = "") -> dict:
    """Recursively flatten a nested dict, joining keys with '.'."""
    result = {}
    for k, v in obj.items():
        key = f"{prefix}.{k}" if prefix else k
        if isinstance(v, dict):
            result.update(_flatten(v, key))
        elif isinstance(v, list):
            result[key] = json.dumps(v, ensure_ascii=False)
        else:
            result[key] = v
    return result


def _extract_match_fields(row: dict, aliases: dict, column_map: dict = None) -> dict:
    """Map a CSV row's columns to the API's expected match field names.

    If column_map is provided ({"CSV Column": "api_field", ...}), it is used
    directly (exact column names, case-sensitive).  Otherwise the alias table
    is used as a fallback (lowercased/underscored column names).
    """
    out = {}
    if column_map:
        for csv_col, api_field in column_map.items():
            val = row.get(csv_col, "")
            if val and str(val).strip():
                out[api_field] = str(val).strip()
    else:
        for col, val in row.items():
            key = col.lower().strip().replace(" ", "_").replace("-", "_")
            if key in aliases and val and str(val).strip():
                out[aliases[key]] = str(val).strip()

    # Normalize linkedin URL fields: ensure they start with https://www.
    for li_field in ("linkedin_url", "linkedin"):
        if li_field in out:
            url = out[li_field]
            if url.startswith("linkedin.com"):
                url = "https://www." + url
            elif url.startswith("www.linkedin.com"):
                url = "https://" + url
            out[li_field] = url

    # Construct full_name from first_name + last_name if not already present
    if "full_name" not in out and ("first_name" in out or "last_name" in out):
        parts = [out.pop("first_name", ""), out.pop("last_name", "")]
        out["full_name"] = " ".join(p for p in parts if p)
    return out


def _load_input_file(path_str: str, command: str) -> dict:
    """Load a JSON temp file produced by a previous CLI command."""
    p = pathlib.Path(path_str)
    if not p.exists():
        write_error(command, f"Input file not found: {path_str}", "FILE_NOT_FOUND")
        sys.exit(1)
    try:
        data = json.loads(p.read_text())
    except json.JSONDecodeError as exc:
        write_error(command, f"Cannot parse input file: {exc}", "PARSE_ERROR")
        sys.exit(1)
    if not data.get("success"):
        write_error(command,
                    f"Input file contains a failed result: {data.get('error', 'unknown')}",
                    "BAD_INPUT")
        sys.exit(1)
    return data


# ---------------------------------------------------------------------------
# Command implementations
# ---------------------------------------------------------------------------

def cmd_config(args):
    INSTALL_DIR.mkdir(parents=True, exist_ok=True)
    cfg = {}
    if CONFIG_FILE.exists():
        try:
            cfg = json.loads(CONFIG_FILE.read_text())
        except (json.JSONDecodeError, OSError):
            pass
    if args.api_key:
        cfg["api_key"] = args.api_key.strip()
    # API key is stored only in a single location with restrictive permissions
    INSTALL_DIR.mkdir(parents=True, exist_ok=True)
    CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
    CONFIG_FILE.chmod(0o600)
    write_result("config", {"success": True, "saved_to": str(CONFIG_FILE)})


def cmd_from_csv(args):
    """Convert an existing CSV file to a JSON temp file without loading it into context."""
    p = pathlib.Path(args.input).expanduser()
    if not p.exists():
        write_error("from_csv", f"CSV file not found: {args.input}", "FILE_NOT_FOUND")
        sys.exit(1)

    rows = []
    columns = []
    try:
        with p.open(newline="", encoding="utf-8-sig") as f:
            reader = csv.DictReader(f)
            columns = list(reader.fieldnames or [])
            for row in reader:
                rows.append(dict(row))
    except (OSError, csv.Error) as exc:
        write_error("from_csv", f"Cannot read CSV: {exc}", "PARSE_ERROR")
        sys.exit(1)

    write_result("from_csv", {
        "success": True,
        "source_csv": str(p),
        "total_rows": len(rows),
        "columns": columns,
        "data": rows,
        "sample": rows[:5],
    })


def cmd_autocomplete(args):
    [REDACTED_SECRET])
    entity = args.entity_type  # "businesses" or "prospects"
    params = {"field": args.field, "query": args.query}
    if args.semantic:
        params["semantic_search"] = "true"

    raw = safe_request("autocomplete", api_key, "GET",
                       f"{entity}/autocomplete", params=params)

    # Response is a list of {query, label, value}
    results = raw if isinstance(raw, list) else raw.get("data", raw)
    write_result("autocomplete", {
        "success": True,
        "entity_type": entity,
        "field": args.field,
        "query": args.query,
        "results": results,
    })


def cmd_statistics(args):
    [REDACTED_SECRET])
    try:
        filters = json.loads(args.filters) if args.filters else {}
    except json.JSONDecodeError as exc:
        write_error("statistics", f"Invalid --filters JSON: {exc}", "BAD_REQUEST")
        sys.exit(1)

    entity = args.entity_type
    body: dict = {"filters": filters}
    ctx = _request_context(args)
    if ctx:
        body["request_context"] = ctx
    raw = safe_request("statistics", api_key, "POST", f"{entity}/stats", body=body)

    write_result("statistics", {
        "success": True,
        "entity_type": entity,
        "total_results": raw.get("total_results"),
        "stats": raw.get("stats", raw),
    })


def cmd_fetch(args):
    [REDACTED_SECRET])
    try:
        filters = json.loads(args.filters) if args.filters else {}
    except json.JSONDecodeError as exc:
        write_error("fetch", f"Invalid --filters JSON: {exc}", "BAD_REQUEST")
        sys.exit(1)

    entity = args.entity_type
    total_limit = args.limit
    page_size = min(total_limit, MAX_PAGE_SIZE)
    all_data = []
    page = 1
    total_results = None
    total_pages = None

    while len(all_data) < total_limit:
        want = min(page_size, total_limit - len(all_data))
        body = {
            "mode": "full",
            "size": total_limit,
            "page_size": want,
            "page": page,
            "filters": filters,
        }
        ctx = _request_context(args)
        if ctx:
            body["request_context"] = ctx
        if getattr(args, "exclude", None):
            try:
                body["exclude"] = json.loads(args.exclude)
            except (json.JSONDecodeError, TypeError):
                pass

        try:
            raw = _request(api_key, "POST", entity, body=body)
        except ValueError as exc:
            try:
                info = json.loads(str(exc))
            except json.JSONDecodeError:
                info = {"error_code": "UNKNOWN", "http_status": None, "error": str(exc)}
            write_error("fetch", info.get("error", str(exc)),
                        info.get("error_code"), info.get("http_status"))
            sys.exit(1)

        page_data = raw.get("data", [])
        all_data.extend(page_data)

        if total_results is None:
            total_results = raw.get("total_results")
        total_pages = raw.get("total_pages", 1)

        if page >= total_pages or len(page_data) < want:
            break
        page += 1

    write_result("fetch", {
        "success": True,
        "entity_type": entity,
        "total_results": total_results,
        "total_fetched": len(all_data),
        "pages_fetched": page,
        "data": all_data,
        "sample": all_data[:10],
    })


def cmd_enrich(args):
    [REDACTED_SECRET])
    input_data = _load_input_file(args.input_file, "enrich")
    entity_type = input_data.get("entity_type")
    entities = input_data.get("data", [])

    enrichments = [e.strip() for e in args.enrichments.split(",") if e.strip()]

    if entity_type == "businesses":
        id_field = "business_id"
        enrich_map = BUSINESS_ENRICHMENT_PATHS
        batch_key = "business_ids"
    elif entity_type == "prospects":
        id_field = "prospect_id"
        enrich_map = PROSPECT_ENRICHMENT_PATHS
        batch_key = "prospect_ids"
    else:
        write_error("enrich",
                    f"Unknown entity_type '{entity_type}' in input file. Expected 'businesses' or 'prospects'.",
                    "BAD_INPUT")
        sys.exit(1)

    # Validate enrichment names
    for e in enrichments:
        if e not in enrich_map:
            valid = ", ".join(sorted(enrich_map))
            write_error("enrich",
                        f"Unknown enrichment '{e}' for {entity_type}. Valid: {valid}",
                        "BAD_REQUEST")
            sys.exit(1)

    # Build id → entity dict
    id_to_entity = {}
    for ent in entities:
        eid = ent.get(id_field)
        if eid:
            id_to_entity[eid] = dict(ent)

    all_ids = list(id_to_entity.keys())

    for enrichment in enrichments:
        path = f"{enrich_map[enrichment]}/bulk_enrich"
        for i in range(0, len(all_ids), BULK_ENRICH_BATCH):
            batch = all_ids[i:i + BULK_ENRICH_BATCH]
            body = {batch_key: batch}
            ctx = _request_context(args)
            if ctx:
                body["request_context"] = ctx
            if getattr(args, "parameters", None):
                try:
                    body["parameters"] = json.loads(args.parameters)
                except (json.JSONDecodeError, TypeError):
                    pass

            try:
                raw = _request(api_key, "POST", path, body=body)
            except ValueError as exc:
                try:
                    info = json.loads(str(exc))
                except json.JSONDecodeError:
                    info = {"error_code": "UNKNOWN", "http_status": None, "error": str(exc)}
                write_error("enrich", info.get("error", str(exc)),
                            info.get("error_code"), info.get("http_status"))
                sys.exit(1)

            for row in raw.get("data", []):
                eid = row.get(id_field)
                if eid and eid in id_to_entity:
                    id_to_entity[eid][enrichment] = row.get("data", row)

    enriched = list(id_to_entity.values())
    write_result("enrich", {
        "success": True,
        "entity_type": entity_type,
        "enrichments_applied": enrichments,
        "total_fetched": len(enriched),
        "data": enriched,
        "sample": enriched[:5],
    })


def cmd_events(args):
    [REDACTED_SECRET])
    input_data = _load_input_file(args.input_file, "events")
    entities = input_data.get("data", [])
    event_types = [e.strip() for e in args.event_types.split(",") if e.strip()]

    # Extract business_ids from the result set
    business_ids = [e.get("business_id") for e in entities if e.get("business_id")]
    if not business_ids:
        write_error("events", "No business_ids found in input file.", "BAD_INPUT")
        sys.exit(1)

    all_events = []
    for i in range(0, len(business_ids), EVENTS_BATCH):
        batch = business_ids[i:i + EVENTS_BATCH]
        body = {"event_types": event_types, "business_ids": batch}
        if args.since:
            body["timestamp_from"] = args.since
        if getattr(args, "until", None):
            body["timestamp_to"] = args.until
        ctx = _request_context(args)
        if ctx:
            body["request_context"] = ctx

        try:
            raw = _request(api_key, "POST", "businesses/events", body=body)
        except ValueError as exc:
            try:
                info = json.loads(str(exc))
            except json.JSONDecodeError:
                info = {"error_code": "UNKNOWN", "http_status": None, "error": str(exc)}
            write_error("events", info.get("error", str(exc)),
                        info.get("error_code"), info.get("http_status"))
            sys.exit(1)

        all_events.extend(raw.get("output_events", raw.get("data", [])))

    write_result("events", {
        "success": True,
        "entity_type": "businesses",
        "event_types": event_types,
        "total_events": len(all_events),
        "data": all_events,
        "sample": all_events[:10],
    })


def cmd_to_csv(args):
    input_data = _load_input_file(args.input_file, "to_csv")
    entities = input_data.get("data", [])

    if not entities:
        write_error("to_csv", "No data found in input file.", "EMPTY_DATA")
        sys.exit(1)

    # Flatten all records
    flat = [_flatten(e) for e in entities]

    # Collect all keys in order of first appearance
    all_keys: list = []
    seen: set = set()
    for row in flat:
        for k in row:
            if k not in seen:
                all_keys.append(k)
                seen.add(k)

    output_path = pathlib.Path(args.output)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    with output_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=all_keys, extrasaction="ignore",
                                 quoting=csv.QUOTE_MINIMAL)
        writer.writeheader()
        writer.writerows(flat)

    write_result("to_csv", {
        "success": True,
        "csv_path": str(output_path),
        "row_count": len(flat),
        "columns": all_keys,
    })


def cmd_match_business(args):
    [REDACTED_SECRET])

    if getattr(args, "input_file", None):
        # --- CSV-originated flow: read JSON file, extract + batch ---
        input_data = _load_input_file(args.input_file, "match_business")
        rows = input_data.get("data", [])

        column_map = None
        if getattr(args, "column_map", None):
            try:
                column_map = json.loads(args.column_map)
            except json.JSONDecodeError as exc:
                write_error("match_business", f"Invalid --column-map JSON: {exc}", "BAD_REQUEST")
                sys.exit(1)

        candidates = [_extract_match_fields(r, BUSINESS_MATCH_ALIASES, column_map) for r in rows]
        candidates = [c for c in candidates if c]  # drop rows with no mappable fields
        if not candidates:
            write_error("match_business",
                        "No mappable columns found. Pass --column-map to specify the mapping "
                        "explicitly, e.g. '{\"Company\": \"name\", \"Website\": \"domain\"}'.",
                        "BAD_INPUT")
            sys.exit(1)

        all_matched = []
        ctx = _request_context(args)
        for i in range(0, len(candidates), MATCH_BATCH):
            batch = candidates[i:i + MATCH_BATCH]
            body: dict = {"businesses_to_match": batch}
            if ctx:
                body["request_context"] = ctx
            try:
                raw = _request(api_key, "POST", "businesses/match", body=body)
            except ValueError as exc:
                try:
                    info = json.loads(str(exc))
                except json.JSONDecodeError:
                    info = {"error_code": "UNKNOWN", "http_status": None, "error": str(exc)}
                write_error("match_business", info.get("error", str(exc)),
                            info.get("error_code"), info.get("http_status"))
                sys.exit(1)
            all_matched.extend(raw.get("matched_businesses", []))

        write_result("match_business", {
            "success": True,
            "entity_type": "businesses",
            "total_input": len(candidates),
            "total_matched": len(all_matched),
            "data": all_matched,
            "sample": all_matched[:10],
        })

    else:
        # --- Inline JSON flow ---
        if not getattr(args, "businesses", None):
            write_error("match_business",
                        "Provide either --businesses JSON or --input-file PATH.", "BAD_REQUEST")
            sys.exit(1)
        try:
            businesses = json.loads(args.businesses)
        except json.JSONDecodeError as exc:
            write_error("match_business", f"Invalid --businesses JSON: {exc}", "BAD_REQUEST")
            sys.exit(1)

        body: dict = {"businesses_to_match": businesses}
        ctx = _request_context(args)
        if ctx:
            body["request_context"] = ctx
        raw = safe_request("match_business", api_key, "POST", "businesses/match", body=body)

        write_result("match_business", {
            "success": True,
            "entity_type": "businesses",
            "total_input": len(businesses),
            "total_matched": raw.get("total_matches", len(raw.get("matched_businesses", []))),
            "data": raw.get("matched_businesses", []),
            "sample": raw.get("matched_businesses", [])[:10],
        })


def cmd_match_prospect(args):
    [REDACTED_SECRET])

    if getattr(args, "input_file", None):
        # --- CSV-originated flow: read JSON file, extract + batch ---
        input_data = _load_input_file(args.input_file, "match_prospect")
        rows = input_data.get("data", [])

        column_map = None
        if getattr(args, "column_map", None):
            try:
                column_map = json.loads(args.column_map)
            except json.JSONDecodeError as exc:
                write_error("match_prospect", f"Invalid --column-map JSON: {exc}", "BAD_REQUEST")
                sys.exit(1)

        candidates = [_extract_match_fields(r, PROSPECT_MATCH_ALIASES, column_map) for r in rows]
        candidates = [c for c in candidates if c]
        if not candidates:
            write_error("match_prospect",
                        "No mappable columns found. Pass --column-map to specify the mapping "
                        "explicitly, e.g. '{\"Full Name\": \"full_name\", \"Company\": \"company_name\"}'.",
                        "BAD_INPUT")
            sys.exit(1)

        all_matched = []
        ctx = _request_context(args)
        for i in range(0, len(candidates), MATCH_BATCH):
            batch = candidates[i:i + MATCH_BATCH]
            body: dict = {"prospects_to_match": batch}
            if ctx:
                body["request_context"] = ctx
            try:
                raw = _request(api_key, "POST", "prospects/match", body=body)
            except ValueError as exc:
                try:
                    info = json.loads(str(exc))
                except json.JSONDecodeError:
                    info = {"error_code": "UNKNOWN", "http_status": None, "error": str(exc)}
                write_error("match_prospect", info.get("error", str(exc)),
                            info.get("error_code"), info.get("http_status"))
                sys.exit(1)
            all_matched.extend(raw.get("matched_prospects", []))

        write_result("match_prospect", {
            "success": True,
            "entity_type": "prospects",
            "total_input": len(candidates),
            "total_matched": len(all_matched),
            "data": all_matched,
            "sample": all_matched[:10],
        })

    else:
        # --- Inline JSON flow ---
        if not getattr(args, "prospects", None):
            write_error("match_prospect",
                        "Provide either --prospects JSON or --input-file PATH.", "BAD_REQUEST")
            sys.exit(1)
        try:
            prospects = json.loads(args.prospects)
        except json.JSONDecodeError as exc:
            write_error("match_prospect", f"Invalid --prospects JSON: {exc}", "BAD_REQUEST")
            sys.exit(1)

        body: dict = {"prospects_to_match": prospects}
        ctx = _request_context(args)
        if ctx:
            body["request_context"] = ctx
        raw = safe_request("match_prospect", api_key, "POST", "prospects/match", body=body)

        write_result("match_prospect", {
            "success": True,
            "entity_type": "prospects",
            "total_input": len(prospects),
            "total_matched": raw.get("total_matches", len(raw.get("matched_prospects", []))),
            "data": raw.get("matched_prospects", []),
            "sample": raw.get("matched_prospects", [])[:10],
        })


# ---------------------------------------------------------------------------
# CLI argument parser
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="agentsource",
        description=(
            "AgentSource CLI — B2B prospecting via the Explorium REST API.\n"
            "All results are written to /tmp temp files; only the path is printed."
        ),
    )
    sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")

    # --- config ---
    p = sub.add_parser("config", help="Save API key to ~/.agentsource/config.json")
    p.add_argument("--api-key", metavar="KEY")
    p.set_defaults(func=cmd_config)

    # --- from-csv ---
    p = sub.add_parser(
        "from-csv",
        help="Convert an existing CSV file to a JSON temp file for use with match-business/match-prospect",
    )
    p.add_argument("--input", required=True, metavar="CSV_PATH",
                   help="Path to the source CSV file")
    _add_tracking_args(p)
    p.set_defaults(func=cmd_from_csv)

    # --- autocomplete ---
    p = sub.add_parser(
        "autocomplete",
        help="Get standardized filter values (required before using certain filter fields)",
    )
    p.add_argument("--entity-type", required=True, choices=["businesses", "prospects"],
                   help="Entity type to autocomplete for")
    p.add_argument("--field", required=True,
                   help="Filter field name (e.g. linkedin_category, job_title, country_code)")
    p.add_argument("--query", required=True, help="Search string")
    p.add_argument("--semantic", action="store_true",
                   help="Enable semantic (broader) search")
    _add_tracking_args(p)
    p.set_defaults(func=cmd_autocomplete)

    # --- statistics ---
    p = sub.add_parser("statistics",
                       help="Get market sizing stats for a filter set (free — no credits consumed)")
    p.add_argument("--entity-type", required=True, choices=["businesses", "prospects"])
    p.add_argument("--filters", default="{}", metavar="JSON",
                   help='Filters as JSON, e.g. \'{"company_size":{"values":["51-200"]}}\'')
    _add_tracking_args(p)
    p.set_defaults(func=cmd_statistics)

    # --- fetch ---
    p = sub.add_parser(
        "fetch",
        help="Fetch entities. Paginates automatically when --limit > 500.",
    )
    p.add_argument("--entity-type", required=True, choices=["businesses", "prospects"])
    p.add_argument("--filters", default="{}", metavar="JSON",
                   help='Filters as JSON, e.g. \'{"country_code":{"values":["us"]}}\'')
    p.add_argument("--limit", type=int, default=10,
                   help="Total results to fetch (default: 10; paginates automatically in batches of 500)")
    p.add_argument("--exclude", default=None, metavar="JSON",
                   help="JSON array of entity IDs to exclude from results")
    _add_tracking_args(p)
    p.set_defaults(func=cmd_fetch)

    # --- enrich ---
    p = sub.add_parser(
        "enrich",
        help="Bulk-enrich entities from a fetch result file",
    )
    p.add_argument("--input-file", required=True, metavar="PATH",
                   help="Path to a temp file produced by the `fetch` command")
    p.add_argument(
        "--enrichments", required=True,
        help=(
            "Comma-separated enrichment types.\n"
            "Business: firmographics, technographics, funding_and_acquisition, "
            "company_ratings, financial_metrics, challenges, competitive_landscape, "
            "strategic_insights, workforce_trends, social_media, "
            "website_content_changes, webstack, keyword_search_on_websites, company_hierarchy\n"
            "Prospect: contacts_information, profiles"
        ),
    )
    p.add_argument("--parameters", default=None, metavar="JSON",
                   help="Optional parameters JSON passed to each enrichment call")
    _add_tracking_args(p)
    p.set_defaults(func=cmd_enrich)

    # --- events ---
    p = sub.add_parser(
        "events",
        help="Fetch business trigger events for all business_ids in a fetch result file",
    )
    p.add_argument("--input-file", required=True, metavar="PATH",
                   help="Path to a temp file produced by `fetch --entity-type businesses`")
    p.add_argument("--event-types", required=True,
                   help="Comma-separated event types (see references/events.md)")
    p.add_argument("--since", default=None, metavar="YYYY-MM-DD",
                   help="Fetch events from this date onwards")
    p.add_argument("--until", default=None, metavar="YYYY-MM-DD",
                   help="Fetch events up to this date")
    _add_tracking_args(p)
    p.set_defaults(func=cmd_events)

    # --- to-csv ---
    p = sub.add_parser(
        "to-csv",
        help="Convert a fetch or enrich result file to a CSV file",
    )
    p.add_argument("--input-file", required=True, metavar="PATH",
                   help="Path to a temp file produced by `fetch` or `enrich`")
    p.add_argument("--output", required=True, metavar="CSV_PATH",
                   help="Path to write the CSV output (e.g. ~/results/companies.csv)")
    _add_tracking_args(p)
    p.set_defaults(func=cmd_to_csv)

    # --- match-business ---
    p = sub.add_parser(
        "match-business",
        help="Match businesses by name/domain to get Explorium business_ids. "
             "Use --input-file with a from-csv result to start from an existing CSV.",
    )
    g = p.add_mutually_exclusive_group(required=True)
    g.add_argument("--businesses", metavar="JSON",
                   help='Inline JSON array, e.g. \'[{"name":"Acme","domain":"acme.com"}]\'')
    g.add_argument("--input-file", metavar="PATH",
                   help="Path to a from-csv result file (auto-maps columns to match fields)")
    p.add_argument("--column-map", default=None, metavar="JSON",
                   help='Explicit CSV→API column mapping, e.g. \'{"Company Name":"name","Website":"domain"}\'. '
                        'Use after inspecting from-csv columns+sample. Overrides auto-alias matching.')
    _add_tracking_args(p)
    p.set_defaults(func=cmd_match_business)

    # --- match-prospect ---
    p = sub.add_parser(
        "match-prospect",
        help="Match prospects by name/company to get Explorium prospect_ids. "
             "Use --input-file with a from-csv result to start from an existing CSV.",
    )
    g = p.add_mutually_exclusive_group(required=True)
    g.add_argument("--prospects", metavar="JSON",
                   help='Inline JSON array, e.g. \'[{"full_name":"Jane Doe","company_name":"Acme"}]\'')
    g.add_argument("--input-file", metavar="PATH",
                   help="Path to a from-csv result file (auto-maps columns to match fields)")
    p.add_argument("--column-map", default=None, metavar="JSON",
                   help='Explicit CSV→API column mapping, e.g. \'{"Full Name":"full_name","Employer":"company_name","Email":"email"}\'. '
                        'Use after inspecting from-csv columns+sample. Overrides auto-alias matching.')
    _add_tracking_args(p)
    p.set_defaults(func=cmd_match_prospect)

    return parser


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main():
    parser = build_parser()
    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)