diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8bd3a..88256dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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 @@ -12,22 +12,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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/` (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/` (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 diff --git a/README.md b/README.md index 1b00abc..a751d72 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -167,15 +167,15 @@ qodev-apollo-cli deals search --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 +# 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 ``` ### Company enrichment (FREE) diff --git a/pyproject.toml b/pyproject.toml index ed8e1f8..24a2dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/apollo_cli/commands/contacts.py b/src/apollo_cli/commands/contacts.py index cc5fbef..c5f215b 100644 --- a/src/apollo_cli/commands/contacts.py +++ b/src/apollo_cli/commands/contacts.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any from cyclopts import App, Parameter @@ -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.") @@ -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 diff --git a/src/apollo_cli/linkedin.py b/src/apollo_cli/linkedin.py index c872902..d497d63 100644 --- a/src/apollo_cli/linkedin.py +++ b/src/apollo_cli/linkedin.py @@ -2,10 +2,11 @@ Apollo stores and *exact-matches* LinkedIn profile URLs in the canonical form ``http://www.linkedin.com/in/`` — 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. """ @@ -16,7 +17,6 @@ # Modern LinkedIn profile URLs are /in/. 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: @@ -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}" diff --git a/src/apollo_cli/skills/SKILL.md b/src/apollo_cli/skills/SKILL.md index de3a22c..672a783 100644 --- a/src/apollo_cli/skills/SKILL.md +++ b/src/apollo_cli/skills/SKILL.md @@ -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 @@ -182,15 +182,15 @@ qodev-apollo-cli deals search --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 +# 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 ``` ## References diff --git a/src/apollo_cli/skills/references/contact-workflows.md b/src/apollo_cli/skills/references/contact-workflows.md index fc7eaf5..140bab9 100644 --- a/src/apollo_cli/skills/references/contact-workflows.md +++ b/src/apollo_cli/skills/references/contact-workflows.md @@ -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 +# 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 ``` +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: diff --git a/tests/test_commands.py b/tests/test_commands.py index f79a370..bd737b0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from qodev_apollo_api.models import Contact import apollo_cli.context as _ctx @@ -86,24 +87,114 @@ async def test_contacts_search_canonicalizes_linkedin_url(self, sample_contact: assert call_kwargs["linkedin_url"] == "http://www.linkedin.com/in/janesmith" @pytest.mark.asyncio - async def test_find_by_linkedin_uses_canonical_search(self, capsys) -> None: - """find-by-linkedin resolves via an exact canonical search (not the client's https path).""" + async def test_upsert_returns_existing_contact(self, capsys) -> None: + """upsert-by-linkedin returns the existing contact (created=False) via canonical search, no write.""" mock_client = MagicMock() - match = MagicMock(id="abc-123", linkedin_url="http://www.linkedin.com/in/janesmith") - mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[match], total=1, page=1)) - mock_client.find_contact_by_linkedin_url = AsyncMock(return_value=None) + contact = Contact.model_validate( + {"id": "c1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + ) + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[contact], total=1, page=1)) + mock_client.create_contact = AsyncMock() _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): - from apollo_cli.commands.contacts import find_by_linkedin + from apollo_cli.commands.contacts import upsert_by_linkedin - await find_by_linkedin("https://www.linkedin.com/in/janesmith/") + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/") assert mock_client.search_contacts.call_args.kwargs["linkedin_url"] == "http://www.linkedin.com/in/janesmith" - mock_client.find_contact_by_linkedin_url.assert_not_called() # found via search; no fallback + mock_client.create_contact.assert_not_called() data = json.loads(capsys.readouterr().out) - assert data["contact_id"] == "abc-123" + assert data["created"] is False + assert data["contact"]["id"] == "c1" + + @pytest.mark.asyncio + async def test_upsert_creates_when_missing(self, capsys) -> None: + """upsert-by-linkedin creates (created=True) when none exists and --name is given.""" + mock_client = MagicMock() + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) + mock_client.create_contact = AsyncMock( + return_value=Contact.model_validate( + {"id": "new1", "name": "Jane Smith", "linkedin_url": "http://www.linkedin.com/in/janesmith"} + ) + ) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.contacts import upsert_by_linkedin + + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/", name="Jane Smith", title="VP") + + args, kwargs = mock_client.create_contact.call_args + assert args == ("Jane", "Smith") + assert kwargs["linkedin_url"] == "http://www.linkedin.com/in/janesmith" + assert kwargs["title"] == "VP" + data = json.loads(capsys.readouterr().out) + assert data["created"] is True + assert data["contact"]["id"] == "new1" + + @pytest.mark.asyncio + async def test_upsert_requires_name_to_create(self, capsys) -> None: + """upsert-by-linkedin errors (exit 2, code name_required) when missing and no --name given.""" + mock_client = MagicMock() + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) + mock_client.create_contact = AsyncMock() + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.contacts import upsert_by_linkedin + + with pytest.raises(SystemExit) as exc: + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/") + + assert exc.value.code == 2 + mock_client.create_contact.assert_not_called() + assert json.loads(capsys.readouterr().out)["code"] == "name_required" + + @pytest.mark.asyncio + async def test_upsert_rejects_single_word_name(self, capsys) -> None: + """A single-word --name can't create (Apollo needs first + last) — errors, no write.""" + mock_client = MagicMock() + mock_client.search_contacts = AsyncMock(return_value=MockSearchResult(items=[], total=0, page=1)) + mock_client.create_contact = AsyncMock() + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.contacts import upsert_by_linkedin + + with pytest.raises(SystemExit) as exc: + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/", name="Cher") + + assert exc.value.code == 2 + mock_client.create_contact.assert_not_called() + assert json.loads(capsys.readouterr().out)["code"] == "name_required" + + @pytest.mark.asyncio + async def test_upsert_name_fallback_prevents_duplicate(self, capsys) -> None: + """A URL miss but a name-search hit with the same canonical URL returns existing, not a new create.""" + mock_client = MagicMock() + existing = Contact.model_validate({"id": "c9", "linkedin_url": "https://www.linkedin.com/in/janesmith/"}) + mock_client.search_contacts = AsyncMock( + side_effect=[ + MockSearchResult(items=[], total=0, page=1), # URL lookup misses + MockSearchResult(items=[existing], total=1, page=1), # name search hits + ] + ) + mock_client.create_contact = AsyncMock() + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.contacts import upsert_by_linkedin + + await upsert_by_linkedin("https://www.linkedin.com/in/janesmith/", name="Jane Smith") + + mock_client.create_contact.assert_not_called() # deduped — no duplicate created + assert json.loads(capsys.readouterr().out)["created"] is False @pytest.mark.asyncio async def test_contacts_get(self, sample_contact: dict, capsys) -> None: diff --git a/tests/test_linkedin.py b/tests/test_linkedin.py index 4a359fc..dd0161e 100644 --- a/tests/test_linkedin.py +++ b/tests/test_linkedin.py @@ -18,6 +18,7 @@ "www.linkedin.com/in/daniel-wessel-575597b8", # no scheme "https://www.linkedin.com/in/daniel-wessel-575597b8?utm_source=share", # query params " https://www.linkedin.com/in/daniel-wessel-575597b8/ ", # surrounding whitespace + "https://www.linkedin.com/in/Daniel-Wessel-575597b8", # mixed-case slug (Apollo stores lowercase) ], ) def test_canonicalizes_common_variants_to_apollo_form(raw: str) -> None: