qodev-apollo-cli 1.1.0__tar.gz → 1.2.1__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 (58) hide show
  1. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/CHANGELOG.md +31 -0
  2. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/PKG-INFO +9 -5
  3. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/README.md +7 -3
  4. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/pyproject.toml +2 -2
  5. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/app.py +2 -0
  6. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/contacts.py +12 -1
  7. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/conversations.py +25 -2
  8. qodev_apollo_cli-1.2.1/src/apollo_cli/commands/custom_fields.py +48 -0
  9. qodev_apollo_cli-1.2.1/src/apollo_cli/commands/deals.py +171 -0
  10. qodev_apollo_cli-1.2.1/src/apollo_cli/commands/people.py +68 -0
  11. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/conversations.py +37 -22
  12. qodev_apollo_cli-1.2.1/src/apollo_cli/formatters/people.py +21 -0
  13. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/SKILL.md +12 -3
  14. qodev_apollo_cli-1.2.1/src/apollo_cli/util.py +53 -0
  15. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_commands.py +207 -0
  16. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_conversations_formatter.py +18 -0
  17. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_util.py +24 -1
  18. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/uv.lock +2 -2
  19. qodev_apollo_cli-1.1.0/src/apollo_cli/commands/deals.py +0 -51
  20. qodev_apollo_cli-1.1.0/src/apollo_cli/commands/people.py +0 -35
  21. qodev_apollo_cli-1.1.0/src/apollo_cli/util.py +0 -22
  22. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/.github/workflows/ci.yml +0 -0
  23. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/.github/workflows/publish.yml +0 -0
  24. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/.gitignore +0 -0
  25. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/LICENSE +0 -0
  26. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/__init__.py +0 -0
  27. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/__main__.py +0 -0
  28. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/__init__.py +0 -0
  29. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/accounts.py +0 -0
  30. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/calls.py +0 -0
  31. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/emails.py +0 -0
  32. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/enrich.py +0 -0
  33. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/install.py +0 -0
  34. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/jobs.py +0 -0
  35. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/news.py +0 -0
  36. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/notes.py +0 -0
  37. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/pipelines.py +0 -0
  38. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/tasks.py +0 -0
  39. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/commands/usage.py +0 -0
  40. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/context.py +0 -0
  41. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/__init__.py +0 -0
  42. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/accounts.py +0 -0
  43. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/contacts.py +0 -0
  44. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/deals.py +0 -0
  45. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/formatters/generic.py +0 -0
  46. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/help_reference.py +0 -0
  47. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/linkedin.py +0 -0
  48. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/output.py +0 -0
  49. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/__init__.py +0 -0
  50. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/__init__.py +0 -0
  51. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/account-workflows.md +0 -0
  52. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/contact-workflows.md +0 -0
  53. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
  54. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/conftest.py +0 -0
  55. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_context.py +0 -0
  56. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_install.py +0 -0
  57. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_linkedin.py +0 -0
  58. {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.1}/tests/test_output.py +0 -0
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.1] - 2026-07-08
10
+
11
+ Follow-ups from code review of the v1.2.0 changes.
12
+
13
+ ### Fixed
14
+
15
+ - **`deals set-role` no longer sends an explicit `null` role type.** Adding a contact without `--role-type` omitted the `opportunity_contact_role_type_id` key entirely instead of posting `null`, which Apollo may reject.
16
+ - **`people search` no longer drops results.** When Apollo returns both `people` and `contacts` (matched CRM records), both are now shown — previously only the first non-empty list was kept.
17
+ - **Conversation detail view is robust to raw dicts.** The participants/deals/summary/transcript sections read fields via a dict-or-model helper, so they render whether `conversations get` returns models or plain dicts (previously the sections silently rendered empty for dicts).
18
+ - **`--stage-name` errors are bounded.** An unknown stage name lists at most 15 available names (`… (+N more)`) instead of dumping the entire list.
19
+
20
+ ### Note
21
+
22
+ - `conversations search --query` maps to Apollo's universal `q_keywords` param. The conversations search endpoint is undocumented and keyword filtering hasn't been confirmed server-side; if a search returns everything unfiltered, that param is the thing to revisit.
23
+
24
+ ## [1.2.0] - 2026-07-08
25
+
26
+ Usage-driven UX improvements — every item here closes a gap where users had been dropping to raw curl or doing extra lookups.
27
+
28
+ ### Added
29
+
30
+ - **people search — company-domain filter.** `people search --organization-domains acme.com,globex.com` (alias `--domains`) finds people at specific companies — the single most common raw-curl workaround (`q_organization_domains_list`). Also adds `--seniorities`, and `people search` now respects the global `--limit`/`--page` (it previously ignored them). Results render as a table instead of a raw dict.
31
+ - **Filter deals/contacts by stage name.** `deals search --stage-name "Negotiation"` and `contacts search --stage-name "Customer"` resolve the name to an ID internally, removing the round-trip through `pipelines stages` / `contacts stages`. An unknown name fails loudly and lists the valid names.
32
+ - **`conversations transcript ID`** — prints just the transcript (no metadata/summary), for when you only want the words.
33
+ - **Deal contact roles.** `deals role-types` lists the available role types; `deals set-role DEAL_ID --contact-id C [--role-type "Decision Maker"] [--primary]` sets/updates a contact's role on a deal (read-modify-write; `--primary` makes them the sole primary contact). Previously only reachable via curl.
34
+ - **`custom-fields list [--modality]`** — lists custom field definitions across contacts/accounts/opportunities.
35
+
36
+ ### Changed
37
+
38
+ - Requires `qodev-apollo-api>=0.3.0` (for `update_opportunity_roles` and `list_custom_fields`).
39
+
9
40
  ## [1.1.0] - 2026-07-08
10
41
 
11
42
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-cli
3
- Version: 1.1.0
3
+ Version: 1.2.1
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
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: cyclopts>=3.0
22
- Requires-Dist: qodev-apollo-api>=0.1.0
22
+ Requires-Dist: qodev-apollo-api>=0.3.1
23
23
  Requires-Dist: rich>=13.0
24
24
  Provides-Extra: dev
25
25
  Requires-Dist: mypy>=1.13.0; extra == 'dev'
@@ -88,7 +88,7 @@ $ qodev-apollo-cli usage
88
88
 
89
89
  | Group | Subcommand | Description |
90
90
  |---|---|---|
91
- | **contacts** | `search` | Search contacts (`--query`, `--stage-id`, `--linkedin-url`) |
91
+ | **contacts** | `search` | Search contacts (`--query`, `--stage-id`, `--stage-name`, `--linkedin-url`) |
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`) |
@@ -96,8 +96,10 @@ $ qodev-apollo-cli usage
96
96
  | | `stages` | List all contact stages |
97
97
  | **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) |
98
98
  | | `get` | Get account details by ID |
99
- | **deals** | `search` | Search opportunities/deals (`--query`, `--stage-id`) |
99
+ | **deals** | `search` | Search opportunities/deals (`--query`, `--stage-id`, `--stage-name`) |
100
100
  | | `get` | Get deal details by ID |
101
+ | | `role-types` | List opportunity contact role types |
102
+ | | `set-role` | Set/update a contact's role on a deal (`--contact-id`, `--role-type`, `--primary`) |
101
103
  | **pipelines** | `list` | List all deal pipelines |
102
104
  | | `get` | Get pipeline details |
103
105
  | | `stages` | List stages in a pipeline |
@@ -105,7 +107,7 @@ $ qodev-apollo-cli usage
105
107
  | | `get` | Get stage details |
106
108
  | **enrich** | `org` | Enrich organization by domain (FREE - no credits) |
107
109
  | | `person` | Enrich person by email (1 credit per lookup) |
108
- | **people** | `search` | Search people database (`--person-titles`, `--q-organization-domains`) |
110
+ | **people** | `search` | Search people database (`--titles`, `--seniorities`, `--locations`, `--organization-domains`) |
109
111
  | **notes** | `search` | Search notes (`--contact-id`, `--account-id`, `--opportunity-id`) |
110
112
  | | `create` | Create a note (`--contact-ids`, `--account-ids`, `--opportunity-ids`, `--content`) |
111
113
  | **tasks** | `search` | Search tasks (`--type`, `--status`) |
@@ -113,6 +115,8 @@ $ qodev-apollo-cli usage
113
115
  | **calls** | `search` | Search call activities |
114
116
  | **conversations** | `search` | Search recorded conversations (`--query`) |
115
117
  | | `get` | Get a conversation with transcript and AI summary |
118
+ | | `transcript` | Print just the transcript of a conversation |
119
+ | **custom-fields** | `list` | List custom field definitions (`--modality`) |
116
120
  | **emails** | `search` | Search email activities |
117
121
  | **news** | `search` | Search news (`--categories`) |
118
122
  | **jobs** | `search` | Search job postings (`--job-titles`, `--company-domains`) |
@@ -57,7 +57,7 @@ $ qodev-apollo-cli usage
57
57
 
58
58
  | Group | Subcommand | Description |
59
59
  |---|---|---|
60
- | **contacts** | `search` | Search contacts (`--query`, `--stage-id`, `--linkedin-url`) |
60
+ | **contacts** | `search` | Search contacts (`--query`, `--stage-id`, `--stage-name`, `--linkedin-url`) |
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`) |
@@ -65,8 +65,10 @@ $ qodev-apollo-cli usage
65
65
  | | `stages` | List all contact stages |
66
66
  | **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) |
67
67
  | | `get` | Get account details by ID |
68
- | **deals** | `search` | Search opportunities/deals (`--query`, `--stage-id`) |
68
+ | **deals** | `search` | Search opportunities/deals (`--query`, `--stage-id`, `--stage-name`) |
69
69
  | | `get` | Get deal details by ID |
70
+ | | `role-types` | List opportunity contact role types |
71
+ | | `set-role` | Set/update a contact's role on a deal (`--contact-id`, `--role-type`, `--primary`) |
70
72
  | **pipelines** | `list` | List all deal pipelines |
71
73
  | | `get` | Get pipeline details |
72
74
  | | `stages` | List stages in a pipeline |
@@ -74,7 +76,7 @@ $ qodev-apollo-cli usage
74
76
  | | `get` | Get stage details |
75
77
  | **enrich** | `org` | Enrich organization by domain (FREE - no credits) |
76
78
  | | `person` | Enrich person by email (1 credit per lookup) |
77
- | **people** | `search` | Search people database (`--person-titles`, `--q-organization-domains`) |
79
+ | **people** | `search` | Search people database (`--titles`, `--seniorities`, `--locations`, `--organization-domains`) |
78
80
  | **notes** | `search` | Search notes (`--contact-id`, `--account-id`, `--opportunity-id`) |
79
81
  | | `create` | Create a note (`--contact-ids`, `--account-ids`, `--opportunity-ids`, `--content`) |
80
82
  | **tasks** | `search` | Search tasks (`--type`, `--status`) |
@@ -82,6 +84,8 @@ $ qodev-apollo-cli usage
82
84
  | **calls** | `search` | Search call activities |
83
85
  | **conversations** | `search` | Search recorded conversations (`--query`) |
84
86
  | | `get` | Get a conversation with transcript and AI summary |
87
+ | | `transcript` | Print just the transcript of a conversation |
88
+ | **custom-fields** | `list` | List custom field definitions (`--modality`) |
85
89
  | **emails** | `search` | Search email activities |
86
90
  | **news** | `search` | Search news (`--categories`) |
87
91
  | **jobs** | `search` | Search job postings (`--job-titles`, `--company-domains`) |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-cli"
3
- version = "1.1.0"
3
+ version = "1.2.1"
4
4
  description = "Agent-friendly CLI for the Apollo API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -20,7 +20,7 @@ classifiers = [
20
20
  dependencies = [
21
21
  "cyclopts>=3.0",
22
22
  "rich>=13.0",
23
- "qodev-apollo-api>=0.1.0",
23
+ "qodev-apollo-api>=0.3.1",
24
24
  ]
25
25
 
26
26
  [project.optional-dependencies]
@@ -27,6 +27,7 @@ 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
29
  from apollo_cli.commands.conversations import conversations_app # noqa: E402
30
+ from apollo_cli.commands.custom_fields import custom_fields_app # noqa: E402
30
31
  from apollo_cli.commands.deals import deals_app # noqa: E402
31
32
  from apollo_cli.commands.emails import emails_app # noqa: E402
32
33
  from apollo_cli.commands.enrich import enrich_app # noqa: E402
@@ -52,6 +53,7 @@ _sub_apps = [
52
53
  tasks_app,
53
54
  calls_app,
54
55
  conversations_app,
56
+ custom_fields_app,
55
57
  emails_app,
56
58
  news_app,
57
59
  jobs_app,
@@ -14,7 +14,7 @@ from apollo_cli.formatters.contacts import (
14
14
  )
15
15
  from apollo_cli.linkedin import apollo_canonical_linkedin_url
16
16
  from apollo_cli.output import error, output, output_list
17
- from apollo_cli.util import parse_comma_list
17
+ from apollo_cli.util import parse_comma_list, resolve_stage_id
18
18
 
19
19
  contacts_app = App(name="contacts", help="Manage contacts.")
20
20
 
@@ -24,6 +24,12 @@ async def search(
24
24
  *,
25
25
  query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword")] = "",
26
26
  stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Filter by stage ID")] = None,
27
+ stage_name: Annotated[
28
+ str | None,
29
+ Parameter(
30
+ name="--stage-name", help="Filter by stage name (resolved to an ID; avoids a `contacts stages` lookup)"
31
+ ),
32
+ ] = None,
27
33
  linkedin_url: Annotated[str | None, Parameter(name="--linkedin-url", help="Filter by LinkedIn URL")] = None,
28
34
  ) -> None:
29
35
  """Search contacts by keyword or filter."""
@@ -38,6 +44,11 @@ async def search(
38
44
  filters["linkedin_url"] = apollo_canonical_linkedin_url(linkedin_url)
39
45
 
40
46
  async with ctx.client() as client:
47
+ if stage_name:
48
+ stages_ = await client.get_contact_stages()
49
+ filters.setdefault("contact_stage_ids", []).append(
50
+ resolve_stage_id(stage_name, stages_, kind="contact stage")
51
+ )
41
52
  result = await client.search_contacts(page=ctx.page, limit=ctx.limit, **filters)
42
53
 
43
54
  output_list(
@@ -7,8 +7,12 @@ from typing import Annotated
7
7
  from cyclopts import App, Parameter
8
8
 
9
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
10
+ from apollo_cli.formatters.conversations import (
11
+ format_conversation_detail,
12
+ format_conversation_list,
13
+ format_transcript,
14
+ )
15
+ from apollo_cli.output import output, output_json, output_list, output_markdown
12
16
 
13
17
  conversations_app = App(name="conversations", help="Recorded conversations (Zoom/Teams/Meet).")
14
18
 
@@ -21,6 +25,10 @@ async def search(
21
25
  """Search recorded conversations."""
22
26
  filters: dict = {}
23
27
  if query:
28
+ # `q_keywords` is Apollo's universal keyword-search param (contacts/accounts/deals/
29
+ # people all use it). The conversations/search endpoint is undocumented and we
30
+ # haven't confirmed it honours the filter server-side — if a search returns
31
+ # everything unfiltered, this is the param to revisit.
24
32
  filters["q_keywords"] = query
25
33
 
26
34
  async with ctx.client() as client:
@@ -46,3 +54,18 @@ async def get(
46
54
  conversation = await client.get_conversation(id)
47
55
 
48
56
  output(conversation, ctx=ctx, format_fn=format_conversation_detail)
57
+
58
+
59
+ @conversations_app.command
60
+ async def transcript(
61
+ id: Annotated[str, Parameter(help="Conversation ID")],
62
+ ) -> None:
63
+ """Print just the transcript of a conversation (no metadata or summary)."""
64
+ async with ctx.client() as client:
65
+ conversation = await client.get_conversation(id)
66
+
67
+ segments = getattr(conversation, "transcript", None) or []
68
+ if ctx.json_mode:
69
+ output_json(segments)
70
+ else:
71
+ output_markdown(format_transcript(conversation))
@@ -0,0 +1,48 @@
1
+ """Custom fields 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.generic import list_table
11
+ from apollo_cli.output import output_list
12
+
13
+ custom_fields_app = App(name="custom-fields", help="Custom field definitions.")
14
+
15
+ CUSTOM_FIELD_COLUMNS = [
16
+ ("ID", "id"),
17
+ ("Modality", "modality"),
18
+ ("Name", "name"),
19
+ ("Type", "type"),
20
+ ("CRM Field", "mapped_crm_field"),
21
+ ]
22
+
23
+
24
+ @custom_fields_app.command(name="list")
25
+ async def list_fields(
26
+ *,
27
+ modality: Annotated[
28
+ str | None,
29
+ Parameter(name="--modality", help="Filter by modality: contact, account, or opportunity"),
30
+ ] = None,
31
+ ) -> None:
32
+ """List custom field definitions across contacts, accounts, and opportunities."""
33
+ async with ctx.client() as client:
34
+ fields = await client.list_custom_fields()
35
+
36
+ if modality:
37
+ target = modality.strip().lower()
38
+ fields = [f for f in fields if (f.modality or "").lower() == target]
39
+
40
+ output_list(
41
+ items=fields,
42
+ total=len(fields),
43
+ page=1,
44
+ limit=len(fields) or 1,
45
+ ctx=ctx,
46
+ format_fn=lambda items, **kw: list_table(items, CUSTOM_FIELD_COLUMNS, title="Custom Fields", **kw),
47
+ resource_name="Custom Fields",
48
+ )
@@ -0,0 +1,171 @@
1
+ """Deals command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any, cast
6
+
7
+ from cyclopts import App, Parameter
8
+ from qodev_apollo_api import RoleAssignment
9
+
10
+ from apollo_cli.context import ctx
11
+ from apollo_cli.formatters.deals import format_deal_detail, format_deal_list
12
+ from apollo_cli.formatters.generic import list_table
13
+ from apollo_cli.output import error, output, output_list
14
+ from apollo_cli.util import resolve_stage_id
15
+
16
+ deals_app = App(name="deals", help="Manage deals/opportunities.")
17
+
18
+
19
+ @deals_app.command
20
+ async def search(
21
+ *,
22
+ query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword")] = "",
23
+ stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Filter by deal stage ID")] = None,
24
+ stage_name: Annotated[
25
+ str | None,
26
+ Parameter(
27
+ name="--stage-name", help="Filter by stage name (resolved to an ID; avoids a `pipelines stages` lookup)"
28
+ ),
29
+ ] = None,
30
+ ) -> None:
31
+ """Search deals by keyword or filter."""
32
+ filters: dict = {}
33
+ if query:
34
+ filters["q_keywords"] = query
35
+ stage_ids: list[str] = []
36
+ if stage_id:
37
+ stage_ids.append(stage_id)
38
+
39
+ async with ctx.client() as client:
40
+ if stage_name:
41
+ all_stages = await client.list_all_stages()
42
+ stage_ids.append(resolve_stage_id(stage_name, all_stages.items, kind="deal stage"))
43
+ if stage_ids:
44
+ filters["opportunity_stage_ids"] = stage_ids
45
+ result = await client.search_deals(page=ctx.page, limit=ctx.limit, **filters)
46
+
47
+ output_list(
48
+ items=result.items,
49
+ total=result.total,
50
+ page=result.page,
51
+ limit=ctx.limit,
52
+ ctx=ctx,
53
+ format_fn=format_deal_list,
54
+ resource_name="Deals",
55
+ )
56
+
57
+
58
+ @deals_app.command
59
+ async def get(
60
+ id: Annotated[str, Parameter(help="Deal ID")],
61
+ ) -> None:
62
+ """Get deal details by ID."""
63
+ async with ctx.client() as client:
64
+ deal = await client.get_deal(id)
65
+
66
+ output(deal, ctx=ctx, format_fn=format_deal_detail)
67
+
68
+
69
+ ROLE_TYPE_COLUMNS = [("ID", "id"), ("Name", "name"), ("Display Order", "display_order")]
70
+
71
+
72
+ @deals_app.command(name="role-types")
73
+ async def role_types() -> None:
74
+ """List the available opportunity contact role types (e.g. Decision Maker, Champion)."""
75
+ async with ctx.client() as client:
76
+ result = await client.list_opportunity_contact_role_types()
77
+
78
+ output_list(
79
+ items=result.items,
80
+ total=result.total,
81
+ page=1,
82
+ limit=len(result.items) or 1,
83
+ ctx=ctx,
84
+ format_fn=lambda items, **kw: list_table(items, ROLE_TYPE_COLUMNS, title="Role Types", **kw),
85
+ resource_name="Role Types",
86
+ )
87
+
88
+
89
+ def _existing_roles(deal: Any) -> list[dict]:
90
+ """Flatten a deal's current opportunity_contact_roles into update_roles entries.
91
+
92
+ ``opportunity_contact_role_type_id`` is only included when the existing role
93
+ actually has one — we never send an explicit ``null`` (see ``_clean_roles``).
94
+ """
95
+ roles: list[dict] = []
96
+ for r in getattr(deal, "opportunity_contact_roles", []) or []:
97
+ entry: dict = {"contact_id": r.contact_id, "is_primary": bool(r.is_primary)}
98
+ if r.role and r.role[0].opportunity_contact_role_type_id:
99
+ entry["opportunity_contact_role_type_id"] = r.role[0].opportunity_contact_role_type_id
100
+ roles.append(entry)
101
+ return roles
102
+
103
+
104
+ def _clean_roles(roles: list[dict]) -> list[dict]:
105
+ """Drop any ``opportunity_contact_role_type_id`` that is ``None`` so we never POST an
106
+ explicit null (Apollo may reject roles without a role type — omit the key instead)."""
107
+ for r in roles:
108
+ if r.get("opportunity_contact_role_type_id") is None:
109
+ r.pop("opportunity_contact_role_type_id", None)
110
+ return roles
111
+
112
+
113
+ @deals_app.command(name="set-role")
114
+ async def set_role(
115
+ id: Annotated[str, Parameter(help="Deal ID")],
116
+ *,
117
+ contact_id: Annotated[str, Parameter(name="--contact-id", help="Contact ID to add/update on the deal")],
118
+ role_type: Annotated[
119
+ str | None,
120
+ Parameter(name="--role-type", help="Role type ID or name (e.g. 'Decision Maker'); resolved to an ID"),
121
+ ] = None,
122
+ primary: Annotated[
123
+ bool,
124
+ Parameter(name="--primary", help="Mark this contact as the primary contact (unsets any other primary)"),
125
+ ] = False,
126
+ ) -> None:
127
+ """Set or update a contact's role on a deal.
128
+
129
+ Reads the deal's current contact roles, applies the change, and writes the full
130
+ set back (Apollo's update_roles replaces all roles). Add ``--primary`` to make
131
+ this contact the single primary contact.
132
+ """
133
+ async with ctx.client() as client:
134
+ deal = await client.get_deal(id)
135
+ roles = _existing_roles(deal)
136
+
137
+ role_type_id: str | None = None
138
+ if role_type:
139
+ role_type_id = role_type
140
+ # Resolve a human name to an ID when it isn't already an ID.
141
+ rt_result = await client.list_opportunity_contact_role_types()
142
+ names = {rt.name.lower(): rt.id for rt in rt_result.items if rt.name}
143
+ ids = {rt.id for rt in rt_result.items}
144
+ if role_type not in ids:
145
+ resolved = names.get(role_type.lower())
146
+ if resolved is None:
147
+ error(
148
+ f"No role type {role_type!r}. Available: "
149
+ + ", ".join(sorted(rt.name for rt in rt_result.items if rt.name)),
150
+ ctx=ctx,
151
+ code="unknown_role_type",
152
+ exit_code=2,
153
+ )
154
+ return
155
+ role_type_id = resolved
156
+
157
+ entry = next((r for r in roles if r["contact_id"] == contact_id), None)
158
+ if entry is None:
159
+ entry = {"contact_id": contact_id, "is_primary": False}
160
+ roles.append(entry)
161
+ if role_type_id is not None:
162
+ entry["opportunity_contact_role_type_id"] = role_type_id
163
+ if primary:
164
+ for r in roles:
165
+ r["is_primary"] = r["contact_id"] == contact_id
166
+
167
+ # Entries are built dynamically (conditional keys, pop), so they're plain dicts;
168
+ # cast to the client's RoleAssignment TypedDict at the boundary.
169
+ updated = await client.update_opportunity_roles(id, cast("list[RoleAssignment]", _clean_roles(roles)))
170
+
171
+ output(updated, ctx=ctx, format_fn=format_deal_detail)
@@ -0,0 +1,68 @@
1
+ """People database search 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.people import format_people_list
11
+ from apollo_cli.output import output_list
12
+ from apollo_cli.util import parse_comma_list
13
+
14
+ people_app = App(name="people", help="People database search.")
15
+
16
+
17
+ @people_app.command
18
+ async def search(
19
+ *,
20
+ keywords: Annotated[str | None, Parameter(name="--keywords", help="Search keywords")] = None,
21
+ titles: Annotated[str | None, Parameter(name="--titles", help="Comma-separated job titles")] = None,
22
+ seniorities: Annotated[
23
+ str | None,
24
+ Parameter(name="--seniorities", help="Comma-separated seniorities (e.g. owner,vp,director,manager)"),
25
+ ] = None,
26
+ locations: Annotated[str | None, Parameter(name="--locations", help="Comma-separated person locations")] = None,
27
+ organization_domains: Annotated[
28
+ str | None,
29
+ Parameter(
30
+ name=["--organization-domains", "--domains"],
31
+ help="Comma-separated company domains — find people at these companies (e.g. acme.com,globex.com)",
32
+ ),
33
+ ] = None,
34
+ ) -> None:
35
+ """Search Apollo's global people database.
36
+
37
+ Respects the global ``--limit`` / ``--page`` options for pagination.
38
+ """
39
+ filters: dict = {"page": ctx.page, "per_page": ctx.limit}
40
+ if keywords:
41
+ filters["q_keywords"] = keywords
42
+ if titles:
43
+ filters["person_titles"] = parse_comma_list(titles)
44
+ if seniorities:
45
+ filters["person_seniorities"] = parse_comma_list(seniorities)
46
+ if locations:
47
+ filters["person_locations"] = parse_comma_list(locations)
48
+ if organization_domains:
49
+ filters["q_organization_domains_list"] = parse_comma_list(organization_domains)
50
+
51
+ async with ctx.client() as client:
52
+ result = await client.search_people(**filters)
53
+
54
+ # search_people returns the raw Apollo dict. Results live under "people"; matched
55
+ # CRM records come back under "contacts". Merge both so we never silently drop half.
56
+ items = [*(result.get("people") or []), *(result.get("contacts") or [])]
57
+ pagination = result.get("pagination", {})
58
+ total = pagination.get("total_entries", len(items))
59
+
60
+ output_list(
61
+ items=items,
62
+ total=total,
63
+ page=ctx.page,
64
+ limit=ctx.limit,
65
+ ctx=ctx,
66
+ format_fn=format_people_list,
67
+ resource_name="People",
68
+ )
@@ -6,6 +6,13 @@ from typing import Any
6
6
 
7
7
  from apollo_cli.formatters.generic import detail_table, list_table
8
8
 
9
+
10
+ def _get(item: Any, key: str) -> Any:
11
+ """Read ``key`` from a Pydantic model (attr) or a dict — so the rich detail view
12
+ renders whether ``get_conversation()`` returns models or raw dicts."""
13
+ return item.get(key) if isinstance(item, dict) else getattr(item, key, None)
14
+
15
+
9
16
  CONVERSATION_LIST_COLUMNS = [
10
17
  ("ID", "id"),
11
18
  ("Topic", "topic"),
@@ -42,55 +49,63 @@ def format_conversation_list(items: list[Any], *, total: int = 0, page: int = 1)
42
49
 
43
50
  def format_conversation_detail(data: Any) -> str:
44
51
  """Format a single conversation (with transcript & summary) as a markdown detail view."""
45
- topic = getattr(data, "topic", None) or "Conversation"
52
+ topic = _get(data, "topic") or "Conversation"
46
53
  md = detail_table(data, CONVERSATION_DETAIL_FIELDS, title=f"Conversation: {topic}")
47
54
 
48
55
  # Participants (richer than the participant_names list in the metadata table)
49
- participants = getattr(data, "participants_info", None) or []
56
+ participants = _get(data, "participants_info") or []
50
57
  if participants:
51
58
  md += "\n\n## Participants\n"
52
59
  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)
60
+ name = _get(p, "name") or "Unknown"
61
+ title = _get(p, "title")
62
+ account = _get(p, "account_name")
63
+ internal = _get(p, "is_internal_participant")
57
64
  suffix = ", ".join(x for x in [title, account] if x)
58
65
  tag = " (internal)" if internal else ""
59
66
  md += f"\n- **{name}**{tag}" + (f" — {suffix}" if suffix else "")
60
67
 
61
68
  # Associated deals
62
- deals = getattr(data, "deals", None) or []
69
+ deals = _get(data, "deals") or []
63
70
  if deals:
64
71
  md += "\n\n## Deals\n"
65
72
  for d in deals:
66
- name = getattr(d, "name", None) or getattr(d, "id", "Unknown")
67
- account = getattr(d, "account_name", None)
73
+ name = _get(d, "name") or _get(d, "id") or "Unknown"
74
+ account = _get(d, "account_name")
68
75
  md += f"\n- {name}" + (f" ({account})" if account else "")
69
76
 
70
77
  # AI-generated call summary (detail endpoint only)
71
- summary = getattr(data, "call_summary", None)
78
+ summary = _get(data, "call_summary")
72
79
  if summary:
73
80
  md += _format_summary(summary)
74
81
 
75
82
  # 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
+ if _get(data, "transcript"):
84
+ md += "\n\n" + format_transcript(data)
83
85
 
84
86
  return md
85
87
 
86
88
 
89
+ def format_transcript(data: Any) -> str:
90
+ """Render just the transcript of a conversation as `**Speaker:** sentence` lines."""
91
+ segments = _get(data, "transcript") or []
92
+ if not segments:
93
+ return "## Transcript\n\n_No transcript available._"
94
+ lines = ["## Transcript", ""]
95
+ for seg in segments:
96
+ speaker = _get(seg, "participant_name") or "Unknown"
97
+ sentence = _get(seg, "spoken_sentence") or ""
98
+ lines.append(f"- **{speaker}:** {sentence}")
99
+ return "\n".join(lines)
100
+
101
+
87
102
  def _format_summary(summary: Any) -> str:
88
103
  """Render the AI call summary (outcome, pain points, objections, next steps)."""
89
104
  md = "\n\n## Call Summary\n"
90
- outcome = getattr(summary, "outcome", None)
105
+ outcome = _get(summary, "outcome")
91
106
  if outcome:
92
107
  md += f"\n**Outcome:** {outcome}\n"
93
- pricing = getattr(summary, "pricing_discussion", None)
108
+ pricing = _get(summary, "pricing_discussion")
94
109
  if pricing:
95
110
  md += f"\n**Pricing discussion:** {pricing}\n"
96
111
 
@@ -99,11 +114,11 @@ def _format_summary(summary: Any) -> str:
99
114
  ("Objections", "objections", "text"),
100
115
  ("Next Steps", "next_steps", "step"),
101
116
  ):
102
- items = getattr(summary, attr, None) or []
117
+ items = _get(summary, attr) or []
103
118
  if items:
104
119
  md += f"\n### {label}\n"
105
120
  for item in items:
106
- text = getattr(item, field, None) or ""
107
- who = getattr(item, "participant_name", None)
121
+ text = _get(item, field) or ""
122
+ who = _get(item, "participant_name")
108
123
  md += f"\n- {text}" + (f" — _{who}_" if who else "")
109
124
  return md
@@ -0,0 +1,21 @@
1
+ """People-search formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from apollo_cli.formatters.generic import list_table
8
+
9
+ # People objects nest their company under `organization`; `list_table` reads dot paths.
10
+ PEOPLE_LIST_COLUMNS = [
11
+ ("Name", "name"),
12
+ ("Title", "title"),
13
+ ("Company", "organization.name"),
14
+ ("Email", "email"),
15
+ ("LinkedIn", "linkedin_url"),
16
+ ]
17
+
18
+
19
+ def format_people_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
20
+ """Format people-database search results as a markdown table."""
21
+ return list_table(items, PEOPLE_LIST_COLUMNS, title="People", total=total, page=page)