Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,26 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
## [1.0.0] - 2026-07-01

### Added

- **notes**: `--opportunity-ids` on `create` and `--opportunity-id` filter on `search`. The underlying `qodev-apollo-api` client already supported opportunity attachment; only the CLI surface was missing. Enables attaching notes directly to deals/opportunities so they appear in the deal view (previously notes could only be attached to accounts/contacts, which don't surface on the opportunity UI).

### Changed

- **BREAKING — `contacts find-by-linkedin` is now `contacts upsert-by-linkedin`** with honest get-or-create semantics. It returns the full contact plus a `created` flag (not a bare `contact_id`), a missing contact is a normal result rather than an exit-1 `not_found` error, and `--name` is required to create. Before creating it name-searches to avoid duplicating a contact stored under a different URL. Read-only lookups now use `contacts search --linkedin-url`.
- **internal**: Extracted the inline comma-splitting logic (used in `contacts update --label-ids`, `people search --titles/--locations`, `tasks create --contact-ids`, and `notes create --contact-ids/--account-ids/--opportunity-ids`) into a shared `apollo_cli.util.parse_comma_list` helper. Behavior is now consistent across every comma-list flag.

### Removed

- **BREAKING — `contacts find-by-linkedin`** (and its `--create` flag). The read path is `contacts search --linkedin-url`; the write path is `contacts upsert-by-linkedin`.

### Fixed

- **comma-list flags — forgiving on typos, loud on garbage.** All comma-separated CLI arguments now drop embedded empty segments, whitespace-only tokens, and leading/trailing commas — so `--contact-ids "a,,b"` sends `["a", "b"]` instead of `["a", "", "b"]` (which Apollo rejects with a 400). Empty (`""`) or whitespace-only input maps to "flag not provided", but input like `",,,"` — where the user typed *something* that collapses to nothing — now surfaces as a validation error (exit code 83, `"validation"`) via the CLI's central error handler, instead of a raw Python traceback or a silent flag-omit. Affects every command that takes a comma-list flag.
- **notes docs**: `README.md` and `skills/SKILL.md` referenced a non-existent `--note` flag on `notes create`; the actual flag has always been `--content`. Also surfaced the already-implemented `--account-id`/`--account-ids` flags in both docs (previously only `--contact-id`/`--contact-ids` were documented). AI agents following `SKILL.md` would have hit `--note` errors.
- **`contacts search --linkedin-url` now matches reliably.** Apollo stores and
exact-matches LinkedIn URLs as `http://www.linkedin.com/in/<slug>` (http, `www`, no
trailing slash, lowercase `%hex`); the filter was passed through verbatim, so a normal
`https://.../in/slug/` URL silently returned zero results. Inputs are now canonicalized
to Apollo's stored form before searching (new `apollo_cli.linkedin` module).
- **`contacts find-by-linkedin` no longer under-matches.** It resolves the URL via an
exact canonical search first, instead of the API client's `find_contact_by_linkedin_url`,
whose `https://` normalization never matches Apollo's `http://`-stored URLs (its URL tier
always missed and fell through to name search). Name-search / auto-create fallbacks are
still used when the canonical search finds nothing.
- **`contacts search --linkedin-url` now matches reliably.** Apollo stores and exact-matches LinkedIn URLs as `http://www.linkedin.com/in/<slug>` (http, `www`, no trailing slash, lowercase); the filter was passed through verbatim, so a normal `https://.../in/slug/` URL silently returned zero results. Inputs are now canonicalized to Apollo's stored form before searching (new `apollo_cli.linkedin` module).

## [0.1.0] - 2026-02-26

Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ $ qodev-apollo-cli usage
| | `get` | Get contact details by ID |
| | `create` | Create a new contact (`--first-name`, `--last-name`, `--email`, etc.) |
| | `update` | Update contact (`--title`, `--label-ids`) |
| | `find-by-linkedin` | Find contact by LinkedIn URL (`--create`, `--stage-id`) |
| | `upsert-by-linkedin` | Get or create a contact by LinkedIn URL (`--name`, `--title`, `--stage-id`) |
| | `stages` | List all contact stages |
| **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) |
| | `get` | Get account details by ID |
Expand Down Expand Up @@ -167,15 +167,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
### LinkedIn integration

```bash
# Find contact by LinkedIn URL
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
# Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"

# Auto-create if not found
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
# Get or create a contact by LinkedIn URL (upsert); --name is required to create
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"

# Assign to stage on creation
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
--create --stage-id <stage-id>
# Set title / stage when creating
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
--name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
```

### Company enrichment (FREE)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "qodev-apollo-cli"
version = "0.1.0"
version = "1.0.0"
description = "Agent-friendly CLI for the Apollo API"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
89 changes: 63 additions & 26 deletions src/apollo_cli/commands/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import Annotated
from typing import Annotated, Any

from cyclopts import App, Parameter

Expand All @@ -13,7 +13,7 @@
format_stages_list,
)
from apollo_cli.linkedin import apollo_canonical_linkedin_url
from apollo_cli.output import output, output_list
from apollo_cli.output import error, output, output_list
from apollo_cli.util import parse_comma_list

contacts_app = App(name="contacts", help="Manage contacts.")
Expand Down Expand Up @@ -109,37 +109,74 @@ async def update(
output(result, ctx=ctx, format_fn=format_contact_detail)


@contacts_app.command(name="find-by-linkedin")
async def find_by_linkedin(
def _format_upsert_result(data: dict[str, Any]) -> str:
status = "Created new contact" if data["created"] else "Found existing contact"
return f"**{status}**\n\n{format_contact_detail(data['contact'])}"


@contacts_app.command(name="upsert-by-linkedin")
async def upsert_by_linkedin(
url: Annotated[str, Parameter(help="LinkedIn profile URL")],
*,
name: Annotated[str | None, Parameter(name="--name", help="Person's full name (for fallback search)")] = None,
create_flag: Annotated[bool, Parameter(name="--create", help="Auto-create if not found", negative="")] = False,
stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Stage ID for auto-created contact")] = None,
name: Annotated[
str | None,
Parameter(name="--name", help="Full name 'First Last' — required to create the contact if it doesn't exist"),
] = None,
title: Annotated[str | None, Parameter(name="--title", help="Job title (used only when creating)")] = None,
company: Annotated[str | None, Parameter(name="--company", help="Company name (used only when creating)")] = None,
stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Stage ID (used only when creating)")] = None,
) -> None:
"""Find a contact by LinkedIn URL with fallback strategies."""
"""Get or create a contact by LinkedIn URL (upsert).

Resolves the URL to an existing contact and returns it, or — if none exists —
creates one (requires ``--name``) and returns it. The result carries a ``created``
flag. For a read-only lookup that never writes, use ``contacts search --linkedin-url``.
"""
canonical = apollo_canonical_linkedin_url(url)
async with ctx.client() as client:
# Reliable exact-match on Apollo's canonical URL first. The client's
# find_contact_by_linkedin_url normalizes to https://, which never matches
# Apollo's http://-stored URLs, so its URL tier always misses; do the search
# here and only delegate for the name-search / auto-create fallbacks.
result = await client.search_contacts(linkedin_url=canonical, limit=5)
contact_id = result.items[0].id if result.items else None
if not contact_id and (name or create_flag):
contact_id = await client.find_contact_by_linkedin_url(
linkedin_url=url,
person_name=name,
create_if_missing=create_flag,
contact_stage_id=stage_id,
# 1. Exact-match lookup on Apollo's canonical URL.
result = await client.search_contacts(linkedin_url=canonical, limit=1)
existing = result.items[0] if result.items else None

# 2. Name fallback — catch a contact stored under a drifted/numeric URL so we
# don't create a duplicate; accept only an exact canonical-URL identity match.
# First match wins: upsert just needs to know one exists (unlike the old client,
# we intentionally don't treat >1 match as ambiguous).
if existing is None and name:
by_name = await client.search_contacts(q_keywords=name, limit=10)
existing = next(
(
c
for c in by_name.items
if c.linkedin_url and apollo_canonical_linkedin_url(c.linkedin_url) == canonical
),
None,
)

if contact_id:
output({"contact_id": contact_id}, ctx=ctx)
else:
from apollo_cli.output import error

error("Contact not found.", ctx=ctx, code="not_found", exit_code=1)
if existing is not None:
output({"created": False, "contact": existing}, ctx=ctx, format_fn=_format_upsert_result)
return

# 3. Create — Apollo needs both a first and a last name.
first, _, last = name.strip().partition(" ") if name else ("", "", "")
if not (first and last):
error(
'No contact for that LinkedIn URL. Pass --name "First Last" to create one.',
ctx=ctx,
code="name_required",
exit_code=2,
)
return
fields: dict = {"linkedin_url": canonical}
if title:
fields["title"] = title
if company:
fields["company_name"] = company
if stage_id:
fields["contact_stage_id"] = stage_id
created = await client.create_contact(first, last, **fields)

output({"created": True, "contact": created}, ctx=ctx, format_fn=_format_upsert_result)


@contacts_app.command
Expand Down
12 changes: 6 additions & 6 deletions src/apollo_cli/linkedin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

Apollo stores and *exact-matches* LinkedIn profile URLs in the canonical form
``http://www.linkedin.com/in/<slug>`` — http scheme, ``www`` host, no trailing slash,
lowercase percent-encoding. Apollo's ``linkedin_url`` search filter is a literal string
match, so any other shape a user pastes (``https://``, missing ``www``, a trailing slash,
uppercase ``%HEX``, tracking query params) silently returns zero results. Canonicalizing
before searching is what makes ``contacts search --linkedin-url`` and ``find-by-linkedin``
lowercased slug (Apollo lowercases the whole URL on its side). Apollo's ``linkedin_url``
search filter is a literal string match, so any other shape a user pastes (``https://``,
missing ``www``, a trailing slash, a mixed-case slug or ``%HEX``, tracking query params)
silently returns zero results. Canonicalizing
before searching is what makes ``contacts search --linkedin-url`` and ``upsert-by-linkedin``
match reliably.
"""

Expand All @@ -16,7 +17,6 @@
# Modern LinkedIn profile URLs are /in/<slug>. Legacy /pub/ URLs carry a multi-segment
# id we must not truncate, so we leave anything that isn't /in/ untouched.
_PROFILE_RE = re.compile(r"linkedin\.com/in/([^/?#]+)", re.IGNORECASE)
_PCT_RE = re.compile(r"%[0-9A-Fa-f]{2}")


def apollo_canonical_linkedin_url(url: str) -> str:
Expand All @@ -30,5 +30,5 @@ def apollo_canonical_linkedin_url(url: str) -> str:
match = _PROFILE_RE.search(url.strip())
if not match:
return url
slug = _PCT_RE.sub(lambda m: m.group(0).lower(), match.group(1).rstrip("/"))
slug = match.group(1).rstrip("/").lower()
return f"http://www.linkedin.com/in/{slug}"
16 changes: 8 additions & 8 deletions src/apollo_cli/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti
| `contacts get ID` | Get contact details |
| `contacts create --first-name F --last-name L [--email E] [--title T] [--company C] [--linkedin-url URL]` | Create contact |
| `contacts update ID [--title T] [--label-ids IDS]` | Update contact |
| `contacts find-by-linkedin URL [--create] [--name N] [--stage-id ID]` | Find contact by LinkedIn URL |
| `contacts upsert-by-linkedin URL [--name N] [--title T] [--stage-id ID]` | Get or create a contact by LinkedIn URL |
| `contacts stages` | List all contact stages |

### accounts
Expand Down Expand Up @@ -182,15 +182,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
### LinkedIn integration

```bash
# Find contact by LinkedIn URL
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
# Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"

# Auto-create if not found
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
# Get or create a contact by LinkedIn URL (upsert); --name is required to create
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"

# Assign to stage on creation
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
--create --stage-id <stage-id>
# Set title / stage when creating
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
--name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
```

## References
Expand Down
23 changes: 13 additions & 10 deletions src/apollo_cli/skills/references/contact-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,25 @@ qodev-apollo-cli contacts search --query "engineer" --page 2 --limit 50

## LinkedIn Integration

Find or create contacts from LinkedIn profiles:
Two commands cover LinkedIn URLs — a read-only lookup and an upsert:

```bash
# Find existing contact by LinkedIn URL
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
# Read-only lookup — returns 0..n matching contacts, never writes
qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"

# Auto-create if not found
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
# Upsert — resolve to an existing contact, or create one (--name required to create).
# The result carries a "created" flag.
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"

# Specify name for fallback search
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"

# Assign to stage on creation
qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create --stage-id <stage-id>
# Set title / company / stage when creating
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
--name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
```

URLs are canonicalized to Apollo's exact-match form automatically, so any common shape
(`https://`, trailing slash, `www`/no-`www`) resolves the same contact. Before creating,
the upsert also name-searches to avoid duplicating a contact stored under a different URL.

## Contact Creation

Create new contacts manually:
Expand Down
Loading
Loading