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.
- qodev_apollo_cli-1.0.0/CHANGELOG.md +63 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/PKG-INFO +11 -11
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/README.md +10 -10
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/pyproject.toml +1 -1
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/app.py +2 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/contacts.py +72 -23
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/notes.py +15 -3
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/people.py +3 -2
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/tasks.py +2 -1
- qodev_apollo_cli-1.0.0/src/apollo_cli/linkedin.py +34 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/SKILL.md +10 -10
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/contact-workflows.md +13 -10
- qodev_apollo_cli-1.0.0/src/apollo_cli/util.py +22 -0
- qodev_apollo_cli-1.0.0/tests/test_commands.py +408 -0
- qodev_apollo_cli-1.0.0/tests/test_linkedin.py +44 -0
- qodev_apollo_cli-1.0.0/tests/test_util.py +33 -0
- qodev_apollo_cli-0.1.0/CHANGELOG.md +0 -42
- qodev_apollo_cli-0.1.0/tests/test_commands.py +0 -223
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/.gitignore +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/LICENSE +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/__main__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/accounts.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/calls.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/deals.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/emails.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/enrich.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/install.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/jobs.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/news.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/pipelines.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/commands/usage.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/context.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/accounts.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/contacts.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/deals.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/formatters/generic.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/help_reference.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/output.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/conftest.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/test_context.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/test_install.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.0.0}/tests/test_output.py +0 -0
- {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:
|
|
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
|
-
| | `
|
|
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`, `--
|
|
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
|
-
#
|
|
202
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
205
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
208
|
-
qodev-apollo-cli contacts
|
|
209
|
-
--
|
|
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
|
-
| | `
|
|
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`, `--
|
|
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
|
-
#
|
|
171
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
174
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
177
|
-
qodev-apollo-cli contacts
|
|
178
|
-
--
|
|
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)
|
|
@@ -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.
|
|
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
|
-
|
|
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"] =
|
|
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
|
-
|
|
109
|
-
|
|
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[
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
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"] =
|
|
71
|
+
kwargs["contact_ids"] = parse_comma_list(contact_ids)
|
|
62
72
|
if account_ids:
|
|
63
|
-
kwargs["account_ids"] =
|
|
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"] =
|
|
28
|
+
filters["person_titles"] = parse_comma_list(titles)
|
|
28
29
|
if locations:
|
|
29
|
-
filters["person_locations"] =
|
|
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 =
|
|
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
|
|
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 --
|
|
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
|
-
#
|
|
186
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
189
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
192
|
-
qodev-apollo-cli contacts
|
|
193
|
-
--
|
|
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
|
-
|
|
23
|
+
Two commands cover LinkedIn URLs — a read-only lookup and an upsert:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
#
|
|
27
|
-
qodev-apollo-cli contacts
|
|
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
|
-
#
|
|
30
|
-
|
|
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
|
-
#
|
|
33
|
-
qodev-apollo-cli contacts
|
|
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
|