qodev-apollo-cli 1.1.0__tar.gz → 1.2.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 → qodev_apollo_cli-1.2.0}/CHANGELOG.md +16 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/PKG-INFO +9 -5
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/README.md +7 -3
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/pyproject.toml +2 -2
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/app.py +2 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/contacts.py +12 -1
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/conversations.py +21 -2
- qodev_apollo_cli-1.2.0/src/apollo_cli/commands/custom_fields.py +48 -0
- qodev_apollo_cli-1.2.0/src/apollo_cli/commands/deals.py +161 -0
- qodev_apollo_cli-1.2.0/src/apollo_cli/commands/people.py +68 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/conversations.py +15 -7
- qodev_apollo_cli-1.2.0/src/apollo_cli/formatters/people.py +21 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/SKILL.md +12 -3
- qodev_apollo_cli-1.2.0/src/apollo_cli/util.py +44 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_commands.py +161 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/uv.lock +2 -2
- qodev_apollo_cli-1.1.0/src/apollo_cli/commands/deals.py +0 -51
- qodev_apollo_cli-1.1.0/src/apollo_cli/commands/people.py +0 -35
- qodev_apollo_cli-1.1.0/src/apollo_cli/util.py +0 -22
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/.gitignore +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/LICENSE +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/__init__.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/__main__.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/__init__.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/accounts.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/calls.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/emails.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/enrich.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/install.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/jobs.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/news.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/notes.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/pipelines.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/tasks.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/commands/usage.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/context.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/__init__.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/accounts.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/contacts.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/deals.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/generic.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/help_reference.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/linkedin.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/output.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/__init__.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/references/__init__.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/references/contact-workflows.md +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/conftest.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_context.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_conversations_formatter.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_install.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_linkedin.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_output.py +0 -0
- {qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/tests/test_util.py +0 -0
|
@@ -6,6 +6,22 @@ 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.0] - 2026-07-08
|
|
10
|
+
|
|
11
|
+
Usage-driven UX improvements — every item here closes a gap where users had been dropping to raw curl or doing extra lookups.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **`conversations transcript ID`** — prints just the transcript (no metadata/summary), for when you only want the words.
|
|
18
|
+
- **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.
|
|
19
|
+
- **`custom-fields list [--modality]`** — lists custom field definitions across contacts/accounts/opportunities.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Requires `qodev-apollo-api>=0.3.0` (for `update_opportunity_roles` and `list_custom_fields`).
|
|
24
|
+
|
|
9
25
|
## [1.1.0] - 2026-07-08
|
|
10
26
|
|
|
11
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qodev-apollo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.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
|
|
@@ -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.
|
|
22
|
+
Requires-Dist: qodev-apollo-api>=0.3.0
|
|
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 (`--
|
|
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 (`--
|
|
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.
|
|
3
|
+
version = "1.2.0"
|
|
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.
|
|
23
|
+
"qodev-apollo-api>=0.3.0",
|
|
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
|
|
11
|
-
|
|
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
|
|
|
@@ -46,3 +50,18 @@ async def get(
|
|
|
46
50
|
conversation = await client.get_conversation(id)
|
|
47
51
|
|
|
48
52
|
output(conversation, ctx=ctx, format_fn=format_conversation_detail)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@conversations_app.command
|
|
56
|
+
async def transcript(
|
|
57
|
+
id: Annotated[str, Parameter(help="Conversation ID")],
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Print just the transcript of a conversation (no metadata or summary)."""
|
|
60
|
+
async with ctx.client() as client:
|
|
61
|
+
conversation = await client.get_conversation(id)
|
|
62
|
+
|
|
63
|
+
segments = getattr(conversation, "transcript", None) or []
|
|
64
|
+
if ctx.json_mode:
|
|
65
|
+
output_json(segments)
|
|
66
|
+
else:
|
|
67
|
+
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,161 @@
|
|
|
1
|
+
"""Deals command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from cyclopts import App, Parameter
|
|
8
|
+
|
|
9
|
+
from apollo_cli.context import ctx
|
|
10
|
+
from apollo_cli.formatters.deals import format_deal_detail, format_deal_list
|
|
11
|
+
from apollo_cli.formatters.generic import list_table
|
|
12
|
+
from apollo_cli.output import error, output, output_list
|
|
13
|
+
from apollo_cli.util import resolve_stage_id
|
|
14
|
+
|
|
15
|
+
deals_app = App(name="deals", help="Manage deals/opportunities.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@deals_app.command
|
|
19
|
+
async def search(
|
|
20
|
+
*,
|
|
21
|
+
query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword")] = "",
|
|
22
|
+
stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Filter by deal stage ID")] = None,
|
|
23
|
+
stage_name: Annotated[
|
|
24
|
+
str | None,
|
|
25
|
+
Parameter(
|
|
26
|
+
name="--stage-name", help="Filter by stage name (resolved to an ID; avoids a `pipelines stages` lookup)"
|
|
27
|
+
),
|
|
28
|
+
] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Search deals by keyword or filter."""
|
|
31
|
+
filters: dict = {}
|
|
32
|
+
if query:
|
|
33
|
+
filters["q_keywords"] = query
|
|
34
|
+
stage_ids: list[str] = []
|
|
35
|
+
if stage_id:
|
|
36
|
+
stage_ids.append(stage_id)
|
|
37
|
+
|
|
38
|
+
async with ctx.client() as client:
|
|
39
|
+
if stage_name:
|
|
40
|
+
all_stages = await client.list_all_stages()
|
|
41
|
+
stage_ids.append(resolve_stage_id(stage_name, all_stages.items, kind="deal stage"))
|
|
42
|
+
if stage_ids:
|
|
43
|
+
filters["opportunity_stage_ids"] = stage_ids
|
|
44
|
+
result = await client.search_deals(page=ctx.page, limit=ctx.limit, **filters)
|
|
45
|
+
|
|
46
|
+
output_list(
|
|
47
|
+
items=result.items,
|
|
48
|
+
total=result.total,
|
|
49
|
+
page=result.page,
|
|
50
|
+
limit=ctx.limit,
|
|
51
|
+
ctx=ctx,
|
|
52
|
+
format_fn=format_deal_list,
|
|
53
|
+
resource_name="Deals",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@deals_app.command
|
|
58
|
+
async def get(
|
|
59
|
+
id: Annotated[str, Parameter(help="Deal ID")],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Get deal details by ID."""
|
|
62
|
+
async with ctx.client() as client:
|
|
63
|
+
deal = await client.get_deal(id)
|
|
64
|
+
|
|
65
|
+
output(deal, ctx=ctx, format_fn=format_deal_detail)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ROLE_TYPE_COLUMNS = [("ID", "id"), ("Name", "name"), ("Display Order", "display_order")]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@deals_app.command(name="role-types")
|
|
72
|
+
async def role_types() -> None:
|
|
73
|
+
"""List the available opportunity contact role types (e.g. Decision Maker, Champion)."""
|
|
74
|
+
async with ctx.client() as client:
|
|
75
|
+
result = await client.list_opportunity_contact_role_types()
|
|
76
|
+
|
|
77
|
+
output_list(
|
|
78
|
+
items=result.items,
|
|
79
|
+
total=result.total,
|
|
80
|
+
page=1,
|
|
81
|
+
limit=len(result.items) or 1,
|
|
82
|
+
ctx=ctx,
|
|
83
|
+
format_fn=lambda items, **kw: list_table(items, ROLE_TYPE_COLUMNS, title="Role Types", **kw),
|
|
84
|
+
resource_name="Role Types",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _existing_roles(deal: Any) -> list[dict]:
|
|
89
|
+
"""Flatten a deal's current opportunity_contact_roles into update_roles entries."""
|
|
90
|
+
roles: list[dict] = []
|
|
91
|
+
for r in getattr(deal, "opportunity_contact_roles", []) or []:
|
|
92
|
+
role_type_id = None
|
|
93
|
+
if r.role:
|
|
94
|
+
role_type_id = r.role[0].opportunity_contact_role_type_id
|
|
95
|
+
roles.append(
|
|
96
|
+
{
|
|
97
|
+
"contact_id": r.contact_id,
|
|
98
|
+
"opportunity_contact_role_type_id": role_type_id,
|
|
99
|
+
"is_primary": bool(r.is_primary),
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
return roles
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@deals_app.command(name="set-role")
|
|
106
|
+
async def set_role(
|
|
107
|
+
id: Annotated[str, Parameter(help="Deal ID")],
|
|
108
|
+
*,
|
|
109
|
+
contact_id: Annotated[str, Parameter(name="--contact-id", help="Contact ID to add/update on the deal")],
|
|
110
|
+
role_type: Annotated[
|
|
111
|
+
str | None,
|
|
112
|
+
Parameter(name="--role-type", help="Role type ID or name (e.g. 'Decision Maker'); resolved to an ID"),
|
|
113
|
+
] = None,
|
|
114
|
+
primary: Annotated[
|
|
115
|
+
bool,
|
|
116
|
+
Parameter(name="--primary", help="Mark this contact as the primary contact (unsets any other primary)"),
|
|
117
|
+
] = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Set or update a contact's role on a deal.
|
|
120
|
+
|
|
121
|
+
Reads the deal's current contact roles, applies the change, and writes the full
|
|
122
|
+
set back (Apollo's update_roles replaces all roles). Add ``--primary`` to make
|
|
123
|
+
this contact the single primary contact.
|
|
124
|
+
"""
|
|
125
|
+
async with ctx.client() as client:
|
|
126
|
+
deal = await client.get_deal(id)
|
|
127
|
+
roles = _existing_roles(deal)
|
|
128
|
+
|
|
129
|
+
role_type_id: str | None = None
|
|
130
|
+
if role_type:
|
|
131
|
+
role_type_id = role_type
|
|
132
|
+
# Resolve a human name to an ID when it isn't already an ID.
|
|
133
|
+
rt_result = await client.list_opportunity_contact_role_types()
|
|
134
|
+
names = {rt.name.lower(): rt.id for rt in rt_result.items if rt.name}
|
|
135
|
+
ids = {rt.id for rt in rt_result.items}
|
|
136
|
+
if role_type not in ids:
|
|
137
|
+
resolved = names.get(role_type.lower())
|
|
138
|
+
if resolved is None:
|
|
139
|
+
error(
|
|
140
|
+
f"No role type {role_type!r}. Available: "
|
|
141
|
+
+ ", ".join(sorted(rt.name for rt in rt_result.items if rt.name)),
|
|
142
|
+
ctx=ctx,
|
|
143
|
+
code="unknown_role_type",
|
|
144
|
+
exit_code=2,
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
role_type_id = resolved
|
|
148
|
+
|
|
149
|
+
entry = next((r for r in roles if r["contact_id"] == contact_id), None)
|
|
150
|
+
if entry is None:
|
|
151
|
+
entry = {"contact_id": contact_id, "opportunity_contact_role_type_id": None, "is_primary": False}
|
|
152
|
+
roles.append(entry)
|
|
153
|
+
if role_type_id is not None:
|
|
154
|
+
entry["opportunity_contact_role_type_id"] = role_type_id
|
|
155
|
+
if primary:
|
|
156
|
+
for r in roles:
|
|
157
|
+
r["is_primary"] = r["contact_id"] == contact_id
|
|
158
|
+
|
|
159
|
+
updated = await client.update_opportunity_roles(id, roles)
|
|
160
|
+
|
|
161
|
+
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; people live under "people" (and
|
|
55
|
+
# sometimes "contacts" for matched CRM records).
|
|
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
|
+
)
|
{qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/formatters/conversations.py
RENAMED
|
@@ -73,17 +73,25 @@ def format_conversation_detail(data: Any) -> str:
|
|
|
73
73
|
md += _format_summary(summary)
|
|
74
74
|
|
|
75
75
|
# Transcript (detail endpoint only)
|
|
76
|
-
|
|
77
|
-
|
|
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}"
|
|
76
|
+
if getattr(data, "transcript", None):
|
|
77
|
+
md += "\n\n" + format_transcript(data)
|
|
83
78
|
|
|
84
79
|
return md
|
|
85
80
|
|
|
86
81
|
|
|
82
|
+
def format_transcript(data: Any) -> str:
|
|
83
|
+
"""Render just the transcript of a conversation as `**Speaker:** sentence` lines."""
|
|
84
|
+
segments = getattr(data, "transcript", None) or []
|
|
85
|
+
if not segments:
|
|
86
|
+
return "## Transcript\n\n_No transcript available._"
|
|
87
|
+
lines = ["## Transcript", ""]
|
|
88
|
+
for seg in segments:
|
|
89
|
+
speaker = getattr(seg, "participant_name", None) or "Unknown"
|
|
90
|
+
sentence = getattr(seg, "spoken_sentence", None) or ""
|
|
91
|
+
lines.append(f"- **{speaker}:** {sentence}")
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
87
95
|
def _format_summary(summary: Any) -> str:
|
|
88
96
|
"""Render the AI call summary (outcome, pain points, objections, next steps)."""
|
|
89
97
|
md = "\n\n## Call Summary\n"
|
|
@@ -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)
|
|
@@ -29,7 +29,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti
|
|
|
29
29
|
|
|
30
30
|
| Command | Description |
|
|
31
31
|
|---------|-------------|
|
|
32
|
-
| `contacts search [--query Q] [--stage-id ID] [--linkedin-url URL]` | Search contacts |
|
|
32
|
+
| `contacts search [--query Q] [--stage-id ID] [--stage-name NAME] [--linkedin-url URL]` | Search contacts |
|
|
33
33
|
| `contacts get ID` | Get contact details |
|
|
34
34
|
| `contacts create --first-name F --last-name L [--email E] [--title T] [--company C] [--linkedin-url URL]` | Create contact |
|
|
35
35
|
| `contacts update ID [--title T] [--label-ids IDS]` | Update contact |
|
|
@@ -47,8 +47,10 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti
|
|
|
47
47
|
|
|
48
48
|
| Command | Description |
|
|
49
49
|
|---------|-------------|
|
|
50
|
-
| `deals search [--query Q] [--stage-id ID]` | Search opportunities/deals |
|
|
50
|
+
| `deals search [--query Q] [--stage-id ID] [--stage-name NAME]` | Search opportunities/deals |
|
|
51
51
|
| `deals get ID` | Get deal details |
|
|
52
|
+
| `deals role-types` | List opportunity contact role types |
|
|
53
|
+
| `deals set-role ID --contact-id C [--role-type NAME_OR_ID] [--primary]` | Set/update a contact's role on a deal |
|
|
52
54
|
|
|
53
55
|
### pipelines
|
|
54
56
|
|
|
@@ -76,7 +78,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti
|
|
|
76
78
|
|
|
77
79
|
| Command | Description |
|
|
78
80
|
|---------|-------------|
|
|
79
|
-
| `people search [--
|
|
81
|
+
| `people search [--titles T] [--seniorities S] [--locations L] [--organization-domains D]` | Search people database (respects `--limit`/`--page`) |
|
|
80
82
|
|
|
81
83
|
### notes
|
|
82
84
|
|
|
@@ -106,6 +108,13 @@ Recorded meetings (Zoom/Teams/Meet) with transcript and AI summary — distinct
|
|
|
106
108
|
|---------|-------------|
|
|
107
109
|
| `conversations search [--query TEXT]` | Search recorded conversations |
|
|
108
110
|
| `conversations get ID` | Get a conversation with transcript and AI summary |
|
|
111
|
+
| `conversations transcript ID` | Print just the transcript |
|
|
112
|
+
|
|
113
|
+
### custom-fields
|
|
114
|
+
|
|
115
|
+
| Command | Description |
|
|
116
|
+
|---------|-------------|
|
|
117
|
+
| `custom-fields list [--modality contact\|account\|opportunity]` | List custom field definitions |
|
|
109
118
|
|
|
110
119
|
### emails
|
|
111
120
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared utility helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _field(item: Any, key: str) -> Any:
|
|
9
|
+
"""Read ``key`` from either a Pydantic model (attr) or a dict."""
|
|
10
|
+
return item.get(key) if isinstance(item, dict) else getattr(item, key, None)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_stage_id(name: str, stages: list[Any], *, kind: str = "stage") -> str:
|
|
14
|
+
"""Resolve a stage *name* (case-insensitive) to its ID from a list of stages.
|
|
15
|
+
|
|
16
|
+
``stages`` items may be Pydantic models or dicts exposing ``name`` and ``id``.
|
|
17
|
+
Raises ``ValueError`` (surfaced by the CLI as a validation error) listing the
|
|
18
|
+
available names when there is no match.
|
|
19
|
+
"""
|
|
20
|
+
target = name.strip().lower()
|
|
21
|
+
match = next((s for s in stages if (_field(s, "name") or "").lower() == target), None)
|
|
22
|
+
if match is None:
|
|
23
|
+
available = ", ".join(sorted(n for s in stages if (n := _field(s, "name"))))
|
|
24
|
+
raise ValueError(f"No {kind} named {name!r}. Available: {available or '(none)'}")
|
|
25
|
+
return _field(match, "id")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_comma_list(raw: str) -> list[str]:
|
|
29
|
+
"""Parse a comma-separated CLI argument into a list of stripped, non-empty tokens.
|
|
30
|
+
|
|
31
|
+
- `"a,b,c"` → `["a", "b", "c"]`
|
|
32
|
+
- `"a, ,b"`, `"a,,b"`, `",a,b,"` → `["a", "b"]` (drops empty/whitespace-only tokens,
|
|
33
|
+
forgiving of typos)
|
|
34
|
+
- `""` or `" "` → `[]` (no meaningful input, treat as "flag not provided")
|
|
35
|
+
- `",,,"` → raises `ValueError` (user typed *something* but it collapsed to
|
|
36
|
+
nothing — that's broken input, not "empty", so fail loud rather than silently
|
|
37
|
+
omit the flag)
|
|
38
|
+
|
|
39
|
+
Not a real CSV parser — no quoting or escaping, just comma-split-and-strip.
|
|
40
|
+
"""
|
|
41
|
+
tokens = [t.strip() for t in raw.split(",") if t.strip()]
|
|
42
|
+
if not tokens and raw.strip():
|
|
43
|
+
raise ValueError(f"expected comma-separated values, got only separators: {raw!r}")
|
|
44
|
+
return tokens
|
|
@@ -350,6 +350,167 @@ class TestConversationsCommands:
|
|
|
350
350
|
assert data["topic"] == "Acme <> QoDev discovery call"
|
|
351
351
|
|
|
352
352
|
|
|
353
|
+
class TestPeopleCommands:
|
|
354
|
+
@pytest.mark.asyncio
|
|
355
|
+
async def test_people_search_domain_and_pagination(self, capsys) -> None:
|
|
356
|
+
"""people search forwards --organization-domains and the global --limit/--page."""
|
|
357
|
+
mock_client = MagicMock()
|
|
358
|
+
mock_client.search_people = AsyncMock(
|
|
359
|
+
return_value={"people": [{"id": "p1", "name": "Jane"}], "pagination": {"total_entries": 1}}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=50, page=2)
|
|
363
|
+
|
|
364
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
365
|
+
from apollo_cli.commands.people import search
|
|
366
|
+
|
|
367
|
+
await search(organization_domains="acme.com, globex.com", seniorities="vp,director")
|
|
368
|
+
|
|
369
|
+
kwargs = mock_client.search_people.call_args.kwargs
|
|
370
|
+
assert kwargs["q_organization_domains_list"] == ["acme.com", "globex.com"]
|
|
371
|
+
assert kwargs["person_seniorities"] == ["vp", "director"]
|
|
372
|
+
assert kwargs["per_page"] == 50
|
|
373
|
+
assert kwargs["page"] == 2
|
|
374
|
+
data = json.loads(capsys.readouterr().out)
|
|
375
|
+
assert data["items"][0]["name"] == "Jane"
|
|
376
|
+
assert data["total"] == 1
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class TestStageNameFilter:
|
|
380
|
+
@pytest.mark.asyncio
|
|
381
|
+
async def test_deals_search_stage_name_resolves(self, sample_deal: dict, capsys) -> None:
|
|
382
|
+
"""deals search --stage-name resolves the name to an opportunity_stage_ids filter."""
|
|
383
|
+
mock_client = MagicMock()
|
|
384
|
+
mock_client.list_all_stages = AsyncMock(
|
|
385
|
+
return_value=MockSearchResult(
|
|
386
|
+
items=[{"id": "st-neg", "name": "Negotiation"}, {"id": "st-won", "name": "Won"}], total=2, page=1
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
mock_client.search_deals = AsyncMock(return_value=MockSearchResult(items=[sample_deal], total=1, page=1))
|
|
390
|
+
|
|
391
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
392
|
+
|
|
393
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
394
|
+
from apollo_cli.commands.deals import search
|
|
395
|
+
|
|
396
|
+
await search(stage_name="negotiation") # case-insensitive
|
|
397
|
+
|
|
398
|
+
assert mock_client.search_deals.call_args.kwargs["opportunity_stage_ids"] == ["st-neg"]
|
|
399
|
+
|
|
400
|
+
@pytest.mark.asyncio
|
|
401
|
+
async def test_deals_search_unknown_stage_name_errors(self, capsys) -> None:
|
|
402
|
+
"""An unknown --stage-name is a validation error listing the available names."""
|
|
403
|
+
mock_client = MagicMock()
|
|
404
|
+
mock_client.list_all_stages = AsyncMock(
|
|
405
|
+
return_value=MockSearchResult(items=[{"id": "st-won", "name": "Won"}], total=1, page=1)
|
|
406
|
+
)
|
|
407
|
+
mock_client.search_deals = AsyncMock()
|
|
408
|
+
|
|
409
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
410
|
+
|
|
411
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
412
|
+
from apollo_cli.commands.deals import search
|
|
413
|
+
|
|
414
|
+
with pytest.raises(ValueError, match="No deal stage named 'Nope'"):
|
|
415
|
+
await search(stage_name="Nope")
|
|
416
|
+
|
|
417
|
+
mock_client.search_deals.assert_not_called()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class TestDealRoleCommands:
|
|
421
|
+
@pytest.mark.asyncio
|
|
422
|
+
async def test_set_role_read_modify_write_primary(self, sample_deal: dict, capsys) -> None:
|
|
423
|
+
"""set-role reads existing roles, adds the contact, and makes it the sole primary."""
|
|
424
|
+
from qodev_apollo_api.models import Deal
|
|
425
|
+
|
|
426
|
+
deal = Deal.model_validate(
|
|
427
|
+
{
|
|
428
|
+
"id": "d1",
|
|
429
|
+
"name": "Enterprise Deal",
|
|
430
|
+
"opportunity_contact_roles": [
|
|
431
|
+
{
|
|
432
|
+
"id": "r1",
|
|
433
|
+
"contact_id": "c-old",
|
|
434
|
+
"is_primary": True,
|
|
435
|
+
"role": [{"opportunity_contact_role_type_id": "rt-x"}],
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
mock_client = MagicMock()
|
|
441
|
+
mock_client.get_deal = AsyncMock(return_value=deal)
|
|
442
|
+
mock_client.update_opportunity_roles = AsyncMock(return_value=deal)
|
|
443
|
+
|
|
444
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
445
|
+
|
|
446
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
447
|
+
from apollo_cli.commands.deals import set_role
|
|
448
|
+
|
|
449
|
+
await set_role("d1", contact_id="c-new", primary=True)
|
|
450
|
+
|
|
451
|
+
opp_id, roles = mock_client.update_opportunity_roles.call_args.args
|
|
452
|
+
assert opp_id == "d1"
|
|
453
|
+
by_contact = {r["contact_id"]: r for r in roles}
|
|
454
|
+
assert by_contact["c-new"]["is_primary"] is True
|
|
455
|
+
assert by_contact["c-old"]["is_primary"] is False # demoted
|
|
456
|
+
assert by_contact["c-old"]["opportunity_contact_role_type_id"] == "rt-x" # preserved
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class TestCustomFieldsCommand:
|
|
460
|
+
@pytest.mark.asyncio
|
|
461
|
+
async def test_custom_fields_list_filters_by_modality(self, capsys) -> None:
|
|
462
|
+
"""custom-fields list --modality filters the returned definitions client-side."""
|
|
463
|
+
from qodev_apollo_api.models import CustomField
|
|
464
|
+
|
|
465
|
+
fields = [
|
|
466
|
+
CustomField.model_validate({"id": "f1", "modality": "contact", "name": "First Message", "type": "date"}),
|
|
467
|
+
CustomField.model_validate({"id": "f2", "modality": "opportunity", "name": "Region", "type": "text"}),
|
|
468
|
+
]
|
|
469
|
+
mock_client = MagicMock()
|
|
470
|
+
mock_client.list_custom_fields = AsyncMock(return_value=fields)
|
|
471
|
+
|
|
472
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
473
|
+
|
|
474
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
475
|
+
from apollo_cli.commands.custom_fields import list_fields
|
|
476
|
+
|
|
477
|
+
await list_fields(modality="opportunity")
|
|
478
|
+
|
|
479
|
+
data = json.loads(capsys.readouterr().out)
|
|
480
|
+
assert len(data["items"]) == 1
|
|
481
|
+
assert data["items"][0]["name"] == "Region"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class TestConversationTranscript:
|
|
485
|
+
@pytest.mark.asyncio
|
|
486
|
+
async def test_conversations_transcript_json(self, capsys) -> None:
|
|
487
|
+
"""conversations transcript emits just the transcript segments in JSON mode."""
|
|
488
|
+
from qodev_apollo_api.models import ConversationDetail
|
|
489
|
+
|
|
490
|
+
detail = ConversationDetail.model_validate(
|
|
491
|
+
{
|
|
492
|
+
"id": "c1",
|
|
493
|
+
"topic": "Sync",
|
|
494
|
+
"transcript": [
|
|
495
|
+
{"id": "t1", "participant_name": "Jane", "spoken_sentence": "Hi."},
|
|
496
|
+
{"id": "t2", "participant_name": "John", "spoken_sentence": "Hello."},
|
|
497
|
+
],
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
mock_client = MagicMock()
|
|
501
|
+
mock_client.get_conversation = AsyncMock(return_value=detail)
|
|
502
|
+
|
|
503
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
504
|
+
|
|
505
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
506
|
+
from apollo_cli.commands.conversations import transcript
|
|
507
|
+
|
|
508
|
+
await transcript(id="c1")
|
|
509
|
+
|
|
510
|
+
data = json.loads(capsys.readouterr().out)
|
|
511
|
+
assert [seg["spoken_sentence"] for seg in data] == ["Hi.", "Hello."]
|
|
512
|
+
|
|
513
|
+
|
|
353
514
|
class TestUsageCommand:
|
|
354
515
|
@pytest.mark.asyncio
|
|
355
516
|
async def test_usage_json(self, sample_usage: dict, capsys) -> None:
|
|
@@ -472,7 +472,7 @@ wheels = [
|
|
|
472
472
|
|
|
473
473
|
[[package]]
|
|
474
474
|
name = "qodev-apollo-api"
|
|
475
|
-
version = "0.
|
|
475
|
+
version = "0.3.0"
|
|
476
476
|
source = { directory = "../apollo-api" }
|
|
477
477
|
dependencies = [
|
|
478
478
|
{ name = "httpx" },
|
|
@@ -494,7 +494,7 @@ provides-extras = ["dev"]
|
|
|
494
494
|
|
|
495
495
|
[[package]]
|
|
496
496
|
name = "qodev-apollo-cli"
|
|
497
|
-
version = "1.
|
|
497
|
+
version = "1.2.0"
|
|
498
498
|
source = { editable = "." }
|
|
499
499
|
dependencies = [
|
|
500
500
|
{ name = "cyclopts" },
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
"""Deals 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.deals import format_deal_detail, format_deal_list
|
|
11
|
-
from apollo_cli.output import output, output_list
|
|
12
|
-
|
|
13
|
-
deals_app = App(name="deals", help="Manage deals/opportunities.")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@deals_app.command
|
|
17
|
-
async def search(
|
|
18
|
-
*,
|
|
19
|
-
query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword")] = "",
|
|
20
|
-
stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Filter by deal stage ID")] = None,
|
|
21
|
-
) -> None:
|
|
22
|
-
"""Search deals by keyword or filter."""
|
|
23
|
-
filters: dict = {}
|
|
24
|
-
if query:
|
|
25
|
-
filters["q_keywords"] = query
|
|
26
|
-
if stage_id:
|
|
27
|
-
filters["opportunity_stage_ids"] = [stage_id]
|
|
28
|
-
|
|
29
|
-
async with ctx.client() as client:
|
|
30
|
-
result = await client.search_deals(page=ctx.page, limit=ctx.limit, **filters)
|
|
31
|
-
|
|
32
|
-
output_list(
|
|
33
|
-
items=result.items,
|
|
34
|
-
total=result.total,
|
|
35
|
-
page=result.page,
|
|
36
|
-
limit=ctx.limit,
|
|
37
|
-
ctx=ctx,
|
|
38
|
-
format_fn=format_deal_list,
|
|
39
|
-
resource_name="Deals",
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@deals_app.command
|
|
44
|
-
async def get(
|
|
45
|
-
id: Annotated[str, Parameter(help="Deal ID")],
|
|
46
|
-
) -> None:
|
|
47
|
-
"""Get deal details by ID."""
|
|
48
|
-
async with ctx.client() as client:
|
|
49
|
-
deal = await client.get_deal(id)
|
|
50
|
-
|
|
51
|
-
output(deal, ctx=ctx, format_fn=format_deal_detail)
|
|
@@ -1,35 +0,0 @@
|
|
|
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.output import output
|
|
11
|
-
from apollo_cli.util import parse_comma_list
|
|
12
|
-
|
|
13
|
-
people_app = App(name="people", help="People database search.")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@people_app.command
|
|
17
|
-
async def search(
|
|
18
|
-
*,
|
|
19
|
-
keywords: Annotated[str | None, Parameter(name="--keywords", help="Search keywords")] = None,
|
|
20
|
-
titles: Annotated[str | None, Parameter(name="--titles", help="Comma-separated job titles")] = None,
|
|
21
|
-
locations: Annotated[str | None, Parameter(name="--locations", help="Comma-separated locations")] = None,
|
|
22
|
-
) -> None:
|
|
23
|
-
"""Search Apollo's global people database."""
|
|
24
|
-
filters: dict = {}
|
|
25
|
-
if keywords:
|
|
26
|
-
filters["q_keywords"] = keywords
|
|
27
|
-
if titles:
|
|
28
|
-
filters["person_titles"] = parse_comma_list(titles)
|
|
29
|
-
if locations:
|
|
30
|
-
filters["person_locations"] = parse_comma_list(locations)
|
|
31
|
-
|
|
32
|
-
async with ctx.client() as client:
|
|
33
|
-
result = await client.search_people(**filters)
|
|
34
|
-
|
|
35
|
-
output(result, ctx=ctx)
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Shared utility helpers for CLI commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def parse_comma_list(raw: str) -> list[str]:
|
|
7
|
-
"""Parse a comma-separated CLI argument into a list of stripped, non-empty tokens.
|
|
8
|
-
|
|
9
|
-
- `"a,b,c"` → `["a", "b", "c"]`
|
|
10
|
-
- `"a, ,b"`, `"a,,b"`, `",a,b,"` → `["a", "b"]` (drops empty/whitespace-only tokens,
|
|
11
|
-
forgiving of typos)
|
|
12
|
-
- `""` or `" "` → `[]` (no meaningful input, treat as "flag not provided")
|
|
13
|
-
- `",,,"` → raises `ValueError` (user typed *something* but it collapsed to
|
|
14
|
-
nothing — that's broken input, not "empty", so fail loud rather than silently
|
|
15
|
-
omit the flag)
|
|
16
|
-
|
|
17
|
-
Not a real CSV parser — no quoting or escaping, just comma-split-and-strip.
|
|
18
|
-
"""
|
|
19
|
-
tokens = [t.strip() for t in raw.split(",") if t.strip()]
|
|
20
|
-
if not tokens and raw.strip():
|
|
21
|
-
raise ValueError(f"expected comma-separated values, got only separators: {raw!r}")
|
|
22
|
-
return tokens
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/references/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_cli-1.1.0 → qodev_apollo_cli-1.2.0}/src/apollo_cli/skills/references/deal-workflows.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|