qodev-apollo-cli 0.1.0__tar.gz → 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. qodev_apollo_cli-1.0.0/CHANGELOG.md +63 -0
  2. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/PKG-INFO +11 -11
  3. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/README.md +10 -10
  4. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/pyproject.toml +1 -1
  5. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/app.py +2 -0
  6. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/contacts.py +72 -23
  7. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/notes.py +15 -3
  8. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/people.py +3 -2
  9. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/tasks.py +2 -1
  10. qodev_apollo_cli-1.0.0/src/apollo_cli/linkedin.py +34 -0
  11. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/SKILL.md +10 -10
  12. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/contact-workflows.md +13 -10
  13. qodev_apollo_cli-1.0.0/src/apollo_cli/util.py +22 -0
  14. qodev_apollo_cli-1.0.0/tests/test_commands.py +408 -0
  15. qodev_apollo_cli-1.0.0/tests/test_linkedin.py +44 -0
  16. qodev_apollo_cli-1.0.0/tests/test_util.py +33 -0
  17. qodev_apollo_cli-0.1.0/CHANGELOG.md +0 -42
  18. qodev_apollo_cli-0.1.0/tests/test_commands.py +0 -223
  19. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/.github/workflows/ci.yml +0 -0
  20. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/.github/workflows/publish.yml +0 -0
  21. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/.gitignore +0 -0
  22. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/LICENSE +0 -0
  23. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/__init__.py +0 -0
  24. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/__main__.py +0 -0
  25. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/__init__.py +0 -0
  26. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/accounts.py +0 -0
  27. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/calls.py +0 -0
  28. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/deals.py +0 -0
  29. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/emails.py +0 -0
  30. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/enrich.py +0 -0
  31. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/install.py +0 -0
  32. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/jobs.py +0 -0
  33. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/news.py +0 -0
  34. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/pipelines.py +0 -0
  35. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/usage.py +0 -0
  36. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/context.py +0 -0
  37. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/__init__.py +0 -0
  38. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/accounts.py +0 -0
  39. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/contacts.py +0 -0
  40. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/deals.py +0 -0
  41. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/generic.py +0 -0
  42. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/help_reference.py +0 -0
  43. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/output.py +0 -0
  44. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/__init__.py +0 -0
  45. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/__init__.py +0 -0
  46. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
  47. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
  48. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/conftest.py +0 -0
  49. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/test_context.py +0 -0
  50. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/test_install.py +0 -0
  51. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/test_output.py +0 -0
  52. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/uv.lock +0 -0
@@ -0,0 +1,63 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [1.0.0] - 2026-07-01
8
+
9
+ ### Added
10
+
11
+ - **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).
12
+
13
+ ### Changed
14
+
15
+ - **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`.
16
+ - **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.
17
+
18
+ ### Removed
19
+
20
+ - **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`.
21
+
22
+ ### Fixed
23
+
24
+ - **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.
25
+ - **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.
26
+ - **`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).
27
+
28
+ ## [0.1.0] - 2026-02-26
29
+
30
+ ### Added
31
+
32
+ - Initial CLI implementation with cyclopts framework
33
+ - Dual output mode: Markdown (default, agent/human-friendly) and JSON (`--json`)
34
+ - Global options: `--json`, `--api-key`, `--limit`, `--page`
35
+ - **contacts**: search, get, create, update, find-by-linkedin, stages
36
+ - **accounts**: search, get
37
+ - **deals**: search, get
38
+ - **pipelines**: list, get, stages
39
+ - **stages**: list (all stages across pipelines)
40
+ - **enrich**: org (free), person (1 credit)
41
+ - **people**: search (global database)
42
+ - **notes**: search, create
43
+ - **tasks**: search, create, complete
44
+ - **calls**: search, list (per contact)
45
+ - **emails**: search
46
+ - **news**: list (per account)
47
+ - **jobs**: list (per account)
48
+ - **usage**: API usage stats and rate limits
49
+ - **install**: Install AI agent skill files with `--skills` flag
50
+ - Centralized error handling with semantic exit codes (80-83)
51
+ - Rich markdown rendering for terminal output with `help_format="rich"`
52
+ - Pagination support with page/limit controls
53
+ - Comprehensive README.md with command reference and usage examples
54
+ - MIT License
55
+ - Full PyPI metadata in pyproject.toml
56
+ - CI/CD workflows (lint, typecheck, test, publish)
57
+ - Complete test suite with pytest and pytest-asyncio
58
+ - AI agent skill files (SKILL.md + workflow references)
59
+ - Dynamic help epilogue with all commands
60
+ - Dev dependencies (ruff, mypy, pytest)
61
+ - Tool configurations (ruff, mypy, pytest)
62
+ </content>
63
+ </invoke>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-cli
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
  Summary: Agent-friendly CLI for the Apollo API
5
5
  Project-URL: Homepage, https://github.com/qodevai/apollo-cli
6
6
  Project-URL: Repository, https://github.com/qodevai/apollo-cli
@@ -92,7 +92,7 @@ $ qodev-apollo-cli usage
92
92
  | | `get` | Get contact details by ID |
93
93
  | | `create` | Create a new contact (`--first-name`, `--last-name`, `--email`, etc.) |
94
94
  | | `update` | Update contact (`--title`, `--label-ids`) |
95
- | | `find-by-linkedin` | Find contact by LinkedIn URL (`--create`, `--stage-id`) |
95
+ | | `upsert-by-linkedin` | Get or create a contact by LinkedIn URL (`--name`, `--title`, `--stage-id`) |
96
96
  | | `stages` | List all contact stages |
97
97
  | **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) |
98
98
  | | `get` | Get account details by ID |
@@ -106,8 +106,8 @@ $ qodev-apollo-cli usage
106
106
  | **enrich** | `org` | Enrich organization by domain (FREE - no credits) |
107
107
  | | `person` | Enrich person by email (1 credit per lookup) |
108
108
  | **people** | `search` | Search people database (`--person-titles`, `--q-organization-domains`) |
109
- | **notes** | `search` | Search notes (`--contact-id`) |
110
- | | `create` | Create a note (`--contact-ids`, `--note`) |
109
+ | **notes** | `search` | Search notes (`--contact-id`, `--account-id`, `--opportunity-id`) |
110
+ | | `create` | Create a note (`--contact-ids`, `--account-ids`, `--opportunity-ids`, `--content`) |
111
111
  | **tasks** | `search` | Search tasks (`--type`, `--status`) |
112
112
  | | `create` | Create a task (`--contact-ids`, `--note`, `--due-at`) |
113
113
  | **calls** | `search` | Search call activities |
@@ -198,15 +198,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
198
198
  ### LinkedIn integration
199
199
 
200
200
  ```bash
201
- # Find contact by LinkedIn URL
202
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
201
+ # Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
202
+ qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"
203
203
 
204
- # Auto-create if not found
205
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
204
+ # Get or create a contact by LinkedIn URL (upsert); --name is required to create
205
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
206
206
 
207
- # Assign to stage on creation
208
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
209
- --create --stage-id <stage-id>
207
+ # Set title / stage when creating
208
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
209
+ --name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
210
210
  ```
211
211
 
212
212
  ### Company enrichment (FREE)
@@ -61,7 +61,7 @@ $ qodev-apollo-cli usage
61
61
  | | `get` | Get contact details by ID |
62
62
  | | `create` | Create a new contact (`--first-name`, `--last-name`, `--email`, etc.) |
63
63
  | | `update` | Update contact (`--title`, `--label-ids`) |
64
- | | `find-by-linkedin` | Find contact by LinkedIn URL (`--create`, `--stage-id`) |
64
+ | | `upsert-by-linkedin` | Get or create a contact by LinkedIn URL (`--name`, `--title`, `--stage-id`) |
65
65
  | | `stages` | List all contact stages |
66
66
  | **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) |
67
67
  | | `get` | Get account details by ID |
@@ -75,8 +75,8 @@ $ qodev-apollo-cli usage
75
75
  | **enrich** | `org` | Enrich organization by domain (FREE - no credits) |
76
76
  | | `person` | Enrich person by email (1 credit per lookup) |
77
77
  | **people** | `search` | Search people database (`--person-titles`, `--q-organization-domains`) |
78
- | **notes** | `search` | Search notes (`--contact-id`) |
79
- | | `create` | Create a note (`--contact-ids`, `--note`) |
78
+ | **notes** | `search` | Search notes (`--contact-id`, `--account-id`, `--opportunity-id`) |
79
+ | | `create` | Create a note (`--contact-ids`, `--account-ids`, `--opportunity-ids`, `--content`) |
80
80
  | **tasks** | `search` | Search tasks (`--type`, `--status`) |
81
81
  | | `create` | Create a task (`--contact-ids`, `--note`, `--due-at`) |
82
82
  | **calls** | `search` | Search call activities |
@@ -167,15 +167,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
167
167
  ### LinkedIn integration
168
168
 
169
169
  ```bash
170
- # Find contact by LinkedIn URL
171
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
170
+ # Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
171
+ qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"
172
172
 
173
- # Auto-create if not found
174
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
173
+ # Get or create a contact by LinkedIn URL (upsert); --name is required to create
174
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
175
175
 
176
- # Assign to stage on creation
177
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
178
- --create --stage-id <stage-id>
176
+ # Set title / stage when creating
177
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
178
+ --name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
179
179
  ```
180
180
 
181
181
  ### Company enrichment (FREE)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-cli"
3
- version = "0.1.0"
3
+ version = "1.0.0"
4
4
  description = "Agent-friendly CLI for the Apollo API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -102,6 +102,8 @@ def launcher(
102
102
  _handle_error(msg, code="rate_limit", exit_code=EXIT_RATE_LIMIT)
103
103
  except APIError as exc:
104
104
  _handle_error(str(exc), code="api_error", exit_code=EXIT_API)
105
+ except ValueError as exc:
106
+ _handle_error(str(exc), code="validation", exit_code=EXIT_VALIDATION)
105
107
  except SystemExit:
106
108
  raise
107
109
  except KeyboardInterrupt:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Annotated
5
+ from typing import Annotated, Any
6
6
 
7
7
  from cyclopts import App, Parameter
8
8
 
@@ -12,7 +12,9 @@ from apollo_cli.formatters.contacts import (
12
12
  format_contact_list,
13
13
  format_stages_list,
14
14
  )
15
- from apollo_cli.output import output, output_list
15
+ from apollo_cli.linkedin import apollo_canonical_linkedin_url
16
+ from apollo_cli.output import error, output, output_list
17
+ from apollo_cli.util import parse_comma_list
16
18
 
17
19
  contacts_app = App(name="contacts", help="Manage contacts.")
18
20
 
@@ -31,7 +33,9 @@ async def search(
31
33
  if stage_id:
32
34
  filters["contact_stage_ids"] = [stage_id]
33
35
  if linkedin_url:
34
- filters["linkedin_url"] = linkedin_url
36
+ # Apollo exact-matches its stored http://www.linkedin.com/in/<slug> form, so
37
+ # canonicalize or the filter silently returns nothing.
38
+ filters["linkedin_url"] = apollo_canonical_linkedin_url(linkedin_url)
35
39
 
36
40
  async with ctx.client() as client:
37
41
  result = await client.search_contacts(page=ctx.page, limit=ctx.limit, **filters)
@@ -97,7 +101,7 @@ async def update(
97
101
  if title:
98
102
  fields["title"] = title
99
103
  if label_ids:
100
- fields["label_ids"] = [lid.strip() for lid in label_ids.split(",")]
104
+ fields["label_ids"] = parse_comma_list(label_ids)
101
105
 
102
106
  async with ctx.client() as client:
103
107
  result = await client.update_contact(id, **fields)
@@ -105,29 +109,74 @@ async def update(
105
109
  output(result, ctx=ctx, format_fn=format_contact_detail)
106
110
 
107
111
 
108
- @contacts_app.command(name="find-by-linkedin")
109
- async def find_by_linkedin(
112
+ def _format_upsert_result(data: dict[str, Any]) -> str:
113
+ status = "Created new contact" if data["created"] else "Found existing contact"
114
+ return f"**{status}**\n\n{format_contact_detail(data['contact'])}"
115
+
116
+
117
+ @contacts_app.command(name="upsert-by-linkedin")
118
+ async def upsert_by_linkedin(
110
119
  url: Annotated[str, Parameter(help="LinkedIn profile URL")],
111
120
  *,
112
- name: Annotated[str | None, Parameter(name="--name", help="Person's full name (for fallback search)")] = None,
113
- create_flag: Annotated[bool, Parameter(name="--create", help="Auto-create if not found", negative="")] = False,
114
- stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Stage ID for auto-created contact")] = None,
121
+ name: Annotated[
122
+ str | None,
123
+ Parameter(name="--name", help="Full name 'First Last' — required to create the contact if it doesn't exist"),
124
+ ] = None,
125
+ title: Annotated[str | None, Parameter(name="--title", help="Job title (used only when creating)")] = None,
126
+ company: Annotated[str | None, Parameter(name="--company", help="Company name (used only when creating)")] = None,
127
+ stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Stage ID (used only when creating)")] = None,
115
128
  ) -> None:
116
- """Find a contact by LinkedIn URL with fallback strategies."""
117
- async with ctx.client() as client:
118
- contact_id = await client.find_contact_by_linkedin_url(
119
- linkedin_url=url,
120
- person_name=name,
121
- create_if_missing=create_flag,
122
- contact_stage_id=stage_id,
123
- )
124
-
125
- if contact_id:
126
- output({"contact_id": contact_id}, ctx=ctx)
127
- else:
128
- from apollo_cli.output import error
129
+ """Get or create a contact by LinkedIn URL (upsert).
129
130
 
130
- error("Contact not found.", ctx=ctx, code="not_found", exit_code=1)
131
+ Resolves the URL to an existing contact and returns it, or — if none exists —
132
+ creates one (requires ``--name``) and returns it. The result carries a ``created``
133
+ flag. For a read-only lookup that never writes, use ``contacts search --linkedin-url``.
134
+ """
135
+ canonical = apollo_canonical_linkedin_url(url)
136
+ async with ctx.client() as client:
137
+ # 1. Exact-match lookup on Apollo's canonical URL.
138
+ result = await client.search_contacts(linkedin_url=canonical, limit=1)
139
+ existing = result.items[0] if result.items else None
140
+
141
+ # 2. Name fallback — catch a contact stored under a drifted/numeric URL so we
142
+ # don't create a duplicate; accept only an exact canonical-URL identity match.
143
+ # First match wins: upsert just needs to know one exists (unlike the old client,
144
+ # we intentionally don't treat >1 match as ambiguous).
145
+ if existing is None and name:
146
+ by_name = await client.search_contacts(q_keywords=name, limit=10)
147
+ existing = next(
148
+ (
149
+ c
150
+ for c in by_name.items
151
+ if c.linkedin_url and apollo_canonical_linkedin_url(c.linkedin_url) == canonical
152
+ ),
153
+ None,
154
+ )
155
+
156
+ if existing is not None:
157
+ output({"created": False, "contact": existing}, ctx=ctx, format_fn=_format_upsert_result)
158
+ return
159
+
160
+ # 3. Create — Apollo needs both a first and a last name.
161
+ first, _, last = name.strip().partition(" ") if name else ("", "", "")
162
+ if not (first and last):
163
+ error(
164
+ 'No contact for that LinkedIn URL. Pass --name "First Last" to create one.',
165
+ ctx=ctx,
166
+ code="name_required",
167
+ exit_code=2,
168
+ )
169
+ return
170
+ fields: dict = {"linkedin_url": canonical}
171
+ if title:
172
+ fields["title"] = title
173
+ if company:
174
+ fields["company_name"] = company
175
+ if stage_id:
176
+ fields["contact_stage_id"] = stage_id
177
+ created = await client.create_contact(first, last, **fields)
178
+
179
+ output({"created": True, "contact": created}, ctx=ctx, format_fn=_format_upsert_result)
131
180
 
132
181
 
133
182
  @contacts_app.command
@@ -9,6 +9,7 @@ from cyclopts import App, Parameter
9
9
  from apollo_cli.context import ctx
10
10
  from apollo_cli.formatters.generic import list_table
11
11
  from apollo_cli.output import output, output_list
12
+ from apollo_cli.util import parse_comma_list
12
13
 
13
14
  notes_app = App(name="notes", help="Notes management.")
14
15
 
@@ -17,6 +18,7 @@ NOTE_LIST_COLUMNS = [
17
18
  ("Content", "content"),
18
19
  ("Contact ID", "contact_id"),
19
20
  ("Account ID", "account_id"),
21
+ ("Opportunity ID", "opportunity_id"),
20
22
  ("Created", "created_at"),
21
23
  ]
22
24
 
@@ -26,13 +28,18 @@ async def search(
26
28
  *,
27
29
  contact_id: Annotated[str | None, Parameter(name="--contact-id", help="Filter by contact ID")] = None,
28
30
  account_id: Annotated[str | None, Parameter(name="--account-id", help="Filter by account ID")] = None,
31
+ opportunity_id: Annotated[
32
+ str | None, Parameter(name="--opportunity-id", help="Filter by opportunity/deal ID")
33
+ ] = None,
29
34
  ) -> None:
30
- """Search notes by contact or account."""
35
+ """Search notes by contact, account, or opportunity."""
31
36
  filters: dict = {}
32
37
  if contact_id:
33
38
  filters["contact_ids"] = [contact_id]
34
39
  if account_id:
35
40
  filters["account_ids"] = [account_id]
41
+ if opportunity_id:
42
+ filters["opportunity_ids"] = [opportunity_id]
36
43
 
37
44
  async with ctx.client() as client:
38
45
  result = await client.search_notes(page=ctx.page, limit=ctx.limit, **filters)
@@ -54,13 +61,18 @@ async def create(
54
61
  content: Annotated[str, Parameter(name="--content", help="Note content")],
55
62
  contact_ids: Annotated[str | None, Parameter(name="--contact-ids", help="Comma-separated contact IDs")] = None,
56
63
  account_ids: Annotated[str | None, Parameter(name="--account-ids", help="Comma-separated account IDs")] = None,
64
+ opportunity_ids: Annotated[
65
+ str | None, Parameter(name="--opportunity-ids", help="Comma-separated opportunity/deal IDs")
66
+ ] = None,
57
67
  ) -> None:
58
68
  """Create a new note."""
59
69
  kwargs: dict = {}
60
70
  if contact_ids:
61
- kwargs["contact_ids"] = [cid.strip() for cid in contact_ids.split(",")]
71
+ kwargs["contact_ids"] = parse_comma_list(contact_ids)
62
72
  if account_ids:
63
- kwargs["account_ids"] = [aid.strip() for aid in account_ids.split(",")]
73
+ kwargs["account_ids"] = parse_comma_list(account_ids)
74
+ if opportunity_ids:
75
+ kwargs["opportunity_ids"] = parse_comma_list(opportunity_ids)
64
76
 
65
77
  async with ctx.client() as client:
66
78
  result = await client.create_note(content, **kwargs)
@@ -8,6 +8,7 @@ from cyclopts import App, Parameter
8
8
 
9
9
  from apollo_cli.context import ctx
10
10
  from apollo_cli.output import output
11
+ from apollo_cli.util import parse_comma_list
11
12
 
12
13
  people_app = App(name="people", help="People database search.")
13
14
 
@@ -24,9 +25,9 @@ async def search(
24
25
  if keywords:
25
26
  filters["q_keywords"] = keywords
26
27
  if titles:
27
- filters["person_titles"] = [t.strip() for t in titles.split(",")]
28
+ filters["person_titles"] = parse_comma_list(titles)
28
29
  if locations:
29
- filters["person_locations"] = [loc.strip() for loc in locations.split(",")]
30
+ filters["person_locations"] = parse_comma_list(locations)
30
31
 
31
32
  async with ctx.client() as client:
32
33
  result = await client.search_people(**filters)
@@ -9,6 +9,7 @@ from cyclopts import App, Parameter
9
9
  from apollo_cli.context import ctx
10
10
  from apollo_cli.formatters.generic import list_table
11
11
  from apollo_cli.output import output, output_list
12
+ from apollo_cli.util import parse_comma_list
12
13
 
13
14
  tasks_app = App(name="tasks", help="Task management.")
14
15
 
@@ -58,7 +59,7 @@ async def create(
58
59
  priority: Annotated[str, Parameter(name="--priority", help="Priority (high, medium, low)")] = "medium",
59
60
  ) -> None:
60
61
  """Create a new task."""
61
- ids = [cid.strip() for cid in contact_ids.split(",")]
62
+ ids = parse_comma_list(contact_ids)
62
63
 
63
64
  async with ctx.client() as client:
64
65
  result = await client.create_task(contact_ids=ids, note=note, type=type, priority=priority)
@@ -0,0 +1,34 @@
1
+ """LinkedIn URL canonicalization for Apollo lookups.
2
+
3
+ Apollo stores and *exact-matches* LinkedIn profile URLs in the canonical form
4
+ ``http://www.linkedin.com/in/<slug>`` — http scheme, ``www`` host, no trailing slash,
5
+ lowercased slug (Apollo lowercases the whole URL on its side). Apollo's ``linkedin_url``
6
+ search filter is a literal string match, so any other shape a user pastes (``https://``,
7
+ missing ``www``, a trailing slash, a mixed-case slug or ``%HEX``, tracking query params)
8
+ silently returns zero results. Canonicalizing
9
+ before searching is what makes ``contacts search --linkedin-url`` and ``upsert-by-linkedin``
10
+ match reliably.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ # Modern LinkedIn profile URLs are /in/<slug>. Legacy /pub/ URLs carry a multi-segment
18
+ # id we must not truncate, so we leave anything that isn't /in/ untouched.
19
+ _PROFILE_RE = re.compile(r"linkedin\.com/in/([^/?#]+)", re.IGNORECASE)
20
+
21
+
22
+ def apollo_canonical_linkedin_url(url: str) -> str:
23
+ """Return *url* in the exact form Apollo stores and matches on.
24
+
25
+ Returns *url* unchanged if it is not a recognizable ``/in/`` LinkedIn profile URL,
26
+ so passing a company page, a legacy ``/pub/`` URL, or an empty string is a no-op.
27
+ """
28
+ if not url:
29
+ return url
30
+ match = _PROFILE_RE.search(url.strip())
31
+ if not match:
32
+ return url
33
+ slug = match.group(1).rstrip("/").lower()
34
+ return f"http://www.linkedin.com/in/{slug}"
@@ -33,7 +33,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti
33
33
  | `contacts get ID` | Get contact details |
34
34
  | `contacts create --first-name F --last-name L [--email E] [--title T] [--company C] [--linkedin-url URL]` | Create contact |
35
35
  | `contacts update ID [--title T] [--label-ids IDS]` | Update contact |
36
- | `contacts find-by-linkedin URL [--create] [--name N] [--stage-id ID]` | Find contact by LinkedIn URL |
36
+ | `contacts upsert-by-linkedin URL [--name N] [--title T] [--stage-id ID]` | Get or create a contact by LinkedIn URL |
37
37
  | `contacts stages` | List all contact stages |
38
38
 
39
39
  ### accounts
@@ -82,8 +82,8 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti
82
82
 
83
83
  | Command | Description |
84
84
  |---------|-------------|
85
- | `notes search [--contact-id ID]` | Search notes |
86
- | `notes create --contact-ids IDS --note TEXT` | Create a note |
85
+ | `notes search [--contact-id ID] [--account-id ID] [--opportunity-id ID]` | Search notes |
86
+ | `notes create --content TEXT [--contact-ids IDS] [--account-ids IDS] [--opportunity-ids IDS]` | Create a note (attach to any combination of contacts/accounts/opportunities) |
87
87
 
88
88
  ### tasks
89
89
 
@@ -182,15 +182,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
182
182
  ### LinkedIn integration
183
183
 
184
184
  ```bash
185
- # Find contact by LinkedIn URL
186
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
185
+ # Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
186
+ qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"
187
187
 
188
- # Auto-create if not found
189
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
188
+ # Get or create a contact by LinkedIn URL (upsert); --name is required to create
189
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
190
190
 
191
- # Assign to stage on creation
192
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
193
- --create --stage-id <stage-id>
191
+ # Set title / stage when creating
192
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
193
+ --name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
194
194
  ```
195
195
 
196
196
  ## References
@@ -20,22 +20,25 @@ qodev-apollo-cli contacts search --query "engineer" --page 2 --limit 50
20
20
 
21
21
  ## LinkedIn Integration
22
22
 
23
- Find or create contacts from LinkedIn profiles:
23
+ Two commands cover LinkedIn URLs a read-only lookup and an upsert:
24
24
 
25
25
  ```bash
26
- # Find existing contact by LinkedIn URL
27
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
26
+ # Read-only lookup returns 0..n matching contacts, never writes
27
+ qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"
28
28
 
29
- # Auto-create if not found
30
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
29
+ # Upsert — resolve to an existing contact, or create one (--name required to create).
30
+ # The result carries a "created" flag.
31
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
31
32
 
32
- # Specify name for fallback search
33
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
34
-
35
- # Assign to stage on creation
36
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create --stage-id <stage-id>
33
+ # Set title / company / stage when creating
34
+ qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
35
+ --name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
37
36
  ```
38
37
 
38
+ URLs are canonicalized to Apollo's exact-match form automatically, so any common shape
39
+ (`https://`, trailing slash, `www`/no-`www`) resolves the same contact. Before creating,
40
+ the upsert also name-searches to avoid duplicating a contact stored under a different URL.
41
+
39
42
  ## Contact Creation
40
43
 
41
44
  Create new contacts manually:
@@ -0,0 +1,22 @@
1
+ """Shared utility helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def parse_comma_list(raw: str) -> list[str]:
7
+ """Parse a comma-separated CLI argument into a list of stripped, non-empty tokens.
8
+
9
+ - `"a,b,c"` → `["a", "b", "c"]`
10
+ - `"a, ,b"`, `"a,,b"`, `",a,b,"` → `["a", "b"]` (drops empty/whitespace-only tokens,
11
+ forgiving of typos)
12
+ - `""` or `" "` → `[]` (no meaningful input, treat as "flag not provided")
13
+ - `",,,"` → raises `ValueError` (user typed *something* but it collapsed to
14
+ nothing — that's broken input, not "empty", so fail loud rather than silently
15
+ omit the flag)
16
+
17
+ Not a real CSV parser — no quoting or escaping, just comma-split-and-strip.
18
+ """
19
+ tokens = [t.strip() for t in raw.split(",") if t.strip()]
20
+ if not tokens and raw.strip():
21
+ raise ValueError(f"expected comma-separated values, got only separators: {raw!r}")
22
+ return tokens