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.
Files changed (55) hide show
  1. qodev_apollo_cli-1.1.0/CHANGELOG.md +71 -0
  2. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/PKG-INFO +13 -11
  3. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/README.md +12 -10
  4. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/pyproject.toml +2 -2
  5. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/app.py +4 -0
  6. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/contacts.py +72 -23
  7. qodev_apollo_cli-1.1.0/src/apollo_cli/commands/conversations.py +48 -0
  8. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/notes.py +15 -3
  9. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/people.py +3 -2
  10. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/tasks.py +2 -1
  11. qodev_apollo_cli-1.1.0/src/apollo_cli/formatters/conversations.py +109 -0
  12. qodev_apollo_cli-1.1.0/src/apollo_cli/linkedin.py +34 -0
  13. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/SKILL.md +19 -10
  14. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/contact-workflows.md +13 -10
  15. qodev_apollo_cli-1.1.0/src/apollo_cli/util.py +22 -0
  16. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/conftest.py +15 -0
  17. qodev_apollo_cli-1.1.0/tests/test_commands.py +467 -0
  18. qodev_apollo_cli-1.1.0/tests/test_conversations_formatter.py +70 -0
  19. qodev_apollo_cli-1.1.0/tests/test_linkedin.py +44 -0
  20. qodev_apollo_cli-1.1.0/tests/test_util.py +33 -0
  21. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/uv.lock +4 -4
  22. qodev_apollo_cli-0.1.0/CHANGELOG.md +0 -42
  23. qodev_apollo_cli-0.1.0/tests/test_commands.py +0 -223
  24. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/.github/workflows/ci.yml +0 -0
  25. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/.github/workflows/publish.yml +0 -0
  26. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/.gitignore +0 -0
  27. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/LICENSE +0 -0
  28. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/__init__.py +0 -0
  29. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/__main__.py +0 -0
  30. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/__init__.py +0 -0
  31. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/accounts.py +0 -0
  32. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/calls.py +0 -0
  33. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/deals.py +0 -0
  34. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/emails.py +0 -0
  35. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/enrich.py +0 -0
  36. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/install.py +0 -0
  37. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/jobs.py +0 -0
  38. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/news.py +0 -0
  39. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/pipelines.py +0 -0
  40. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/commands/usage.py +0 -0
  41. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/context.py +0 -0
  42. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/__init__.py +0 -0
  43. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/accounts.py +0 -0
  44. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/contacts.py +0 -0
  45. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/deals.py +0 -0
  46. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/formatters/generic.py +0 -0
  47. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/help_reference.py +0 -0
  48. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/output.py +0 -0
  49. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/__init__.py +0 -0
  50. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/__init__.py +0 -0
  51. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
  52. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
  53. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/test_context.py +0 -0
  54. {qodev_apollo_cli-0.1.0 → qodev_apollo_cli-1.1.0}/tests/test_install.py +0 -0
  55. {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: 0.1.0
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
- | | `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,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`, `--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 |
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
- # Find contact by LinkedIn URL
202
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
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
- # Auto-create if not found
205
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
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
- # Assign to stage on creation
208
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
209
- --create --stage-id <stage-id>
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
- | | `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,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`, `--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 |
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
- # Find contact by LinkedIn URL
171
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith"
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
- # Auto-create if not found
174
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" --create
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
- # Assign to stage on creation
177
- qodev-apollo-cli contacts find-by-linkedin "https://linkedin.com/in/janesmith" \
178
- --create --stage-id <stage-id>
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 = "0.1.0"
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 = "../../libs/apollo" }
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.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
@@ -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 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,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}"