qodev-apollo-cli 0.1.0__tar.gz → 1.1.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.1.0/CHANGELOG.md +71 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/PKG-INFO +13 -11
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/README.md +12 -10
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/pyproject.toml +2 -2
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/app.py +4 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/contacts.py +72 -23
- qodev_apollo_cli-1.1.0/src/apollo_cli/commands/conversations.py +48 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/notes.py +15 -3
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/people.py +3 -2
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/tasks.py +2 -1
- qodev_apollo_cli-1.1.0/src/apollo_cli/formatters/conversations.py +109 -0
- qodev_apollo_cli-1.1.0/src/apollo_cli/linkedin.py +34 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/SKILL.md +19 -10
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/contact-workflows.md +13 -10
- qodev_apollo_cli-1.1.0/src/apollo_cli/util.py +22 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/conftest.py +15 -0
- qodev_apollo_cli-1.1.0/tests/test_commands.py +467 -0
- qodev_apollo_cli-1.1.0/tests/test_conversations_formatter.py +70 -0
- qodev_apollo_cli-1.1.0/tests/test_linkedin.py +44 -0
- qodev_apollo_cli-1.1.0/tests/test_util.py +33 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/uv.lock +4 -4
- 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.1.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/.gitignore +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/LICENSE +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/__main__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/accounts.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/calls.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/deals.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/emails.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/enrich.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/install.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/jobs.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/news.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/pipelines.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/usage.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/context.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/accounts.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/contacts.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/deals.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/generic.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/help_reference.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/output.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/__init__.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/test_context.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/test_install.py +0 -0
- {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/test_output.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.1.0] - 2026-07-08
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **conversations**: new command group exposing the recorded-conversation endpoints (Zoom/Teams/Meet). `conversations search [--query]` lists conversations; `conversations get ID` returns the full detail including transcript and AI call summary (outcome, pain points, objections, next steps). The underlying `qodev-apollo-api` client already supported `search_conversations`/`get_conversation` — only the CLI surface was missing. Note this is distinct from `calls`, which covers dialer/phone-call activity, not recorded meetings.
|
|
14
|
+
|
|
15
|
+
## [1.0.0] - 2026-07-01
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **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).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **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`.
|
|
24
|
+
- **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.
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- **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`.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **`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).
|
|
35
|
+
|
|
36
|
+
## [0.1.0] - 2026-02-26
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- Initial CLI implementation with cyclopts framework
|
|
41
|
+
- Dual output mode: Markdown (default, agent/human-friendly) and JSON (`--json`)
|
|
42
|
+
- Global options: `--json`, `--api-key`, `--limit`, `--page`
|
|
43
|
+
- **contacts**: search, get, create, update, find-by-linkedin, stages
|
|
44
|
+
- **accounts**: search, get
|
|
45
|
+
- **deals**: search, get
|
|
46
|
+
- **pipelines**: list, get, stages
|
|
47
|
+
- **stages**: list (all stages across pipelines)
|
|
48
|
+
- **enrich**: org (free), person (1 credit)
|
|
49
|
+
- **people**: search (global database)
|
|
50
|
+
- **notes**: search, create
|
|
51
|
+
- **tasks**: search, create, complete
|
|
52
|
+
- **calls**: search, list (per contact)
|
|
53
|
+
- **emails**: search
|
|
54
|
+
- **news**: list (per account)
|
|
55
|
+
- **jobs**: list (per account)
|
|
56
|
+
- **usage**: API usage stats and rate limits
|
|
57
|
+
- **install**: Install AI agent skill files with `--skills` flag
|
|
58
|
+
- Centralized error handling with semantic exit codes (80-83)
|
|
59
|
+
- Rich markdown rendering for terminal output with `help_format="rich"`
|
|
60
|
+
- Pagination support with page/limit controls
|
|
61
|
+
- Comprehensive README.md with command reference and usage examples
|
|
62
|
+
- MIT License
|
|
63
|
+
- Full PyPI metadata in pyproject.toml
|
|
64
|
+
- CI/CD workflows (lint, typecheck, test, publish)
|
|
65
|
+
- Complete test suite with pytest and pytest-asyncio
|
|
66
|
+
- AI agent skill files (SKILL.md + workflow references)
|
|
67
|
+
- Dynamic help epilogue with all commands
|
|
68
|
+
- Dev dependencies (ruff, mypy, pytest)
|
|
69
|
+
- Tool configurations (ruff, mypy, pytest)
|
|
70
|
+
</content>
|
|
71
|
+
</invoke>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qodev-apollo-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.1.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,11 +106,13 @@ $ 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 |
|
|
114
|
+
| **conversations** | `search` | Search recorded conversations (`--query`) |
|
|
115
|
+
| | `get` | Get a conversation with transcript and AI summary |
|
|
114
116
|
| **emails** | `search` | Search email activities |
|
|
115
117
|
| **news** | `search` | Search news (`--categories`) |
|
|
116
118
|
| **jobs** | `search` | Search job postings (`--job-titles`, `--company-domains`) |
|
|
@@ -198,15 +200,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
|
|
|
198
200
|
### LinkedIn integration
|
|
199
201
|
|
|
200
202
|
```bash
|
|
201
|
-
#
|
|
202
|
-
qodev-apollo-cli contacts
|
|
203
|
+
# Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
|
|
204
|
+
qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"
|
|
203
205
|
|
|
204
|
-
#
|
|
205
|
-
qodev-apollo-cli contacts
|
|
206
|
+
# Get or create a contact by LinkedIn URL (upsert); --name is required to create
|
|
207
|
+
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
|
|
206
208
|
|
|
207
|
-
#
|
|
208
|
-
qodev-apollo-cli contacts
|
|
209
|
-
--
|
|
209
|
+
# Set title / stage when creating
|
|
210
|
+
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
|
|
211
|
+
--name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
|
|
210
212
|
```
|
|
211
213
|
|
|
212
214
|
### 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,11 +75,13 @@ $ 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 |
|
|
83
|
+
| **conversations** | `search` | Search recorded conversations (`--query`) |
|
|
84
|
+
| | `get` | Get a conversation with transcript and AI summary |
|
|
83
85
|
| **emails** | `search` | Search email activities |
|
|
84
86
|
| **news** | `search` | Search news (`--categories`) |
|
|
85
87
|
| **jobs** | `search` | Search job postings (`--job-titles`, `--company-domains`) |
|
|
@@ -167,15 +169,15 @@ qodev-apollo-cli deals search --stage-id <stage-id>
|
|
|
167
169
|
### LinkedIn integration
|
|
168
170
|
|
|
169
171
|
```bash
|
|
170
|
-
#
|
|
171
|
-
qodev-apollo-cli contacts
|
|
172
|
+
# Read-only lookup by LinkedIn URL (returns 0..n contacts, never writes)
|
|
173
|
+
qodev-apollo-cli contacts search --linkedin-url "https://linkedin.com/in/janesmith"
|
|
172
174
|
|
|
173
|
-
#
|
|
174
|
-
qodev-apollo-cli contacts
|
|
175
|
+
# Get or create a contact by LinkedIn URL (upsert); --name is required to create
|
|
176
|
+
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" --name "Jane Smith"
|
|
175
177
|
|
|
176
|
-
#
|
|
177
|
-
qodev-apollo-cli contacts
|
|
178
|
-
--
|
|
178
|
+
# Set title / stage when creating
|
|
179
|
+
qodev-apollo-cli contacts upsert-by-linkedin "https://linkedin.com/in/janesmith" \
|
|
180
|
+
--name "Jane Smith" --title "VP Engineering" --stage-id <stage-id>
|
|
179
181
|
```
|
|
180
182
|
|
|
181
183
|
### Company enrichment (FREE)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qodev-apollo-cli"
|
|
3
|
-
version = "
|
|
3
|
+
version = "1.1.0"
|
|
4
4
|
description = "Agent-friendly CLI for the Apollo API"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -48,7 +48,7 @@ build-backend = "hatchling.build"
|
|
|
48
48
|
packages = ["src/apollo_cli"]
|
|
49
49
|
|
|
50
50
|
[tool.uv.sources]
|
|
51
|
-
qodev-apollo-api = { path = "
|
|
51
|
+
qodev-apollo-api = { path = "../apollo-api" }
|
|
52
52
|
|
|
53
53
|
[tool.ruff]
|
|
54
54
|
target-version = "py311"
|
|
@@ -26,6 +26,7 @@ app.meta.group_parameters = Group("Global Options", sort_key=0)
|
|
|
26
26
|
from apollo_cli.commands.accounts import accounts_app # noqa: E402
|
|
27
27
|
from apollo_cli.commands.calls import calls_app # noqa: E402
|
|
28
28
|
from apollo_cli.commands.contacts import contacts_app # noqa: E402
|
|
29
|
+
from apollo_cli.commands.conversations import conversations_app # noqa: E402
|
|
29
30
|
from apollo_cli.commands.deals import deals_app # noqa: E402
|
|
30
31
|
from apollo_cli.commands.emails import emails_app # noqa: E402
|
|
31
32
|
from apollo_cli.commands.enrich import enrich_app # noqa: E402
|
|
@@ -50,6 +51,7 @@ _sub_apps = [
|
|
|
50
51
|
notes_app,
|
|
51
52
|
tasks_app,
|
|
52
53
|
calls_app,
|
|
54
|
+
conversations_app,
|
|
53
55
|
emails_app,
|
|
54
56
|
news_app,
|
|
55
57
|
jobs_app,
|
|
@@ -102,6 +104,8 @@ def launcher(
|
|
|
102
104
|
_handle_error(msg, code="rate_limit", exit_code=EXIT_RATE_LIMIT)
|
|
103
105
|
except APIError as exc:
|
|
104
106
|
_handle_error(str(exc), code="api_error", exit_code=EXIT_API)
|
|
107
|
+
except ValueError as exc:
|
|
108
|
+
_handle_error(str(exc), code="validation", exit_code=EXIT_VALIDATION)
|
|
105
109
|
except SystemExit:
|
|
106
110
|
raise
|
|
107
111
|
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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Conversations command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import App, Parameter
|
|
8
|
+
|
|
9
|
+
from apollo_cli.context import ctx
|
|
10
|
+
from apollo_cli.formatters.conversations import format_conversation_detail, format_conversation_list
|
|
11
|
+
from apollo_cli.output import output, output_list
|
|
12
|
+
|
|
13
|
+
conversations_app = App(name="conversations", help="Recorded conversations (Zoom/Teams/Meet).")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@conversations_app.command
|
|
17
|
+
async def search(
|
|
18
|
+
*,
|
|
19
|
+
query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword (topic/title)")] = "",
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Search recorded conversations."""
|
|
22
|
+
filters: dict = {}
|
|
23
|
+
if query:
|
|
24
|
+
filters["q_keywords"] = query
|
|
25
|
+
|
|
26
|
+
async with ctx.client() as client:
|
|
27
|
+
result = await client.search_conversations(page=ctx.page, limit=ctx.limit, **filters)
|
|
28
|
+
|
|
29
|
+
output_list(
|
|
30
|
+
items=result.items,
|
|
31
|
+
total=result.total,
|
|
32
|
+
page=result.page,
|
|
33
|
+
limit=ctx.limit,
|
|
34
|
+
ctx=ctx,
|
|
35
|
+
format_fn=format_conversation_list,
|
|
36
|
+
resource_name="Conversations",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@conversations_app.command
|
|
41
|
+
async def get(
|
|
42
|
+
id: Annotated[str, Parameter(help="Conversation ID")],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Get conversation details by ID (includes transcript and AI summary)."""
|
|
45
|
+
async with ctx.client() as client:
|
|
46
|
+
conversation = await client.get_conversation(id)
|
|
47
|
+
|
|
48
|
+
output(conversation, ctx=ctx, format_fn=format_conversation_detail)
|
|
@@ -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,109 @@
|
|
|
1
|
+
"""Conversation-specific formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from apollo_cli.formatters.generic import detail_table, list_table
|
|
8
|
+
|
|
9
|
+
CONVERSATION_LIST_COLUMNS = [
|
|
10
|
+
("ID", "id"),
|
|
11
|
+
("Topic", "topic"),
|
|
12
|
+
("Type", "conversation_type"),
|
|
13
|
+
("Start", "start_time"),
|
|
14
|
+
("Duration", "duration"),
|
|
15
|
+
("Host", "host"),
|
|
16
|
+
("State", "state"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
CONVERSATION_DETAIL_FIELDS = [
|
|
20
|
+
("ID", "id"),
|
|
21
|
+
("Topic", "topic"),
|
|
22
|
+
("Type", "conversation_type"),
|
|
23
|
+
("Start", "start_time"),
|
|
24
|
+
("Duration (s)", "duration"),
|
|
25
|
+
("Host", "host"),
|
|
26
|
+
("Host ID", "host_id"),
|
|
27
|
+
("State", "state"),
|
|
28
|
+
("Internal", "is_internal"),
|
|
29
|
+
("Private", "is_private"),
|
|
30
|
+
("Comments", "comment_count"),
|
|
31
|
+
("Participants", "participant_names"),
|
|
32
|
+
("Accounts", "account_names"),
|
|
33
|
+
("Recording", "video_recording.url"),
|
|
34
|
+
("Pushed to CRM", "pushed_to_crm"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_conversation_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
|
|
39
|
+
"""Format a list of conversations as a markdown table."""
|
|
40
|
+
return list_table(items, CONVERSATION_LIST_COLUMNS, title="Conversations", total=total, page=page)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_conversation_detail(data: Any) -> str:
|
|
44
|
+
"""Format a single conversation (with transcript & summary) as a markdown detail view."""
|
|
45
|
+
topic = getattr(data, "topic", None) or "Conversation"
|
|
46
|
+
md = detail_table(data, CONVERSATION_DETAIL_FIELDS, title=f"Conversation: {topic}")
|
|
47
|
+
|
|
48
|
+
# Participants (richer than the participant_names list in the metadata table)
|
|
49
|
+
participants = getattr(data, "participants_info", None) or []
|
|
50
|
+
if participants:
|
|
51
|
+
md += "\n\n## Participants\n"
|
|
52
|
+
for p in participants:
|
|
53
|
+
name = getattr(p, "name", None) or "Unknown"
|
|
54
|
+
title = getattr(p, "title", None)
|
|
55
|
+
account = getattr(p, "account_name", None)
|
|
56
|
+
internal = getattr(p, "is_internal_participant", None)
|
|
57
|
+
suffix = ", ".join(x for x in [title, account] if x)
|
|
58
|
+
tag = " (internal)" if internal else ""
|
|
59
|
+
md += f"\n- **{name}**{tag}" + (f" — {suffix}" if suffix else "")
|
|
60
|
+
|
|
61
|
+
# Associated deals
|
|
62
|
+
deals = getattr(data, "deals", None) or []
|
|
63
|
+
if deals:
|
|
64
|
+
md += "\n\n## Deals\n"
|
|
65
|
+
for d in deals:
|
|
66
|
+
name = getattr(d, "name", None) or getattr(d, "id", "Unknown")
|
|
67
|
+
account = getattr(d, "account_name", None)
|
|
68
|
+
md += f"\n- {name}" + (f" ({account})" if account else "")
|
|
69
|
+
|
|
70
|
+
# AI-generated call summary (detail endpoint only)
|
|
71
|
+
summary = getattr(data, "call_summary", None)
|
|
72
|
+
if summary:
|
|
73
|
+
md += _format_summary(summary)
|
|
74
|
+
|
|
75
|
+
# Transcript (detail endpoint only)
|
|
76
|
+
transcript = getattr(data, "transcript", None) or []
|
|
77
|
+
if transcript:
|
|
78
|
+
md += "\n\n## Transcript\n"
|
|
79
|
+
for seg in transcript:
|
|
80
|
+
speaker = getattr(seg, "participant_name", None) or "Unknown"
|
|
81
|
+
sentence = getattr(seg, "spoken_sentence", None) or ""
|
|
82
|
+
md += f"\n- **{speaker}:** {sentence}"
|
|
83
|
+
|
|
84
|
+
return md
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _format_summary(summary: Any) -> str:
|
|
88
|
+
"""Render the AI call summary (outcome, pain points, objections, next steps)."""
|
|
89
|
+
md = "\n\n## Call Summary\n"
|
|
90
|
+
outcome = getattr(summary, "outcome", None)
|
|
91
|
+
if outcome:
|
|
92
|
+
md += f"\n**Outcome:** {outcome}\n"
|
|
93
|
+
pricing = getattr(summary, "pricing_discussion", None)
|
|
94
|
+
if pricing:
|
|
95
|
+
md += f"\n**Pricing discussion:** {pricing}\n"
|
|
96
|
+
|
|
97
|
+
for label, attr, field in (
|
|
98
|
+
("Pain Points", "pain_points", "text"),
|
|
99
|
+
("Objections", "objections", "text"),
|
|
100
|
+
("Next Steps", "next_steps", "step"),
|
|
101
|
+
):
|
|
102
|
+
items = getattr(summary, attr, None) or []
|
|
103
|
+
if items:
|
|
104
|
+
md += f"\n### {label}\n"
|
|
105
|
+
for item in items:
|
|
106
|
+
text = getattr(item, field, None) or ""
|
|
107
|
+
who = getattr(item, "participant_name", None)
|
|
108
|
+
md += f"\n- {text}" + (f" — _{who}_" if who else "")
|
|
109
|
+
return md
|
|
@@ -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}"
|