qodev-apollo-cli 1.2.0__tar.gz → 1.3.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.2.0 → qodev_apollo_cli-1.3.0}/.gitignore +5 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/CHANGELOG.md +25 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/PKG-INFO +2 -2
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/pyproject.toml +2 -2
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/conversations.py +4 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/deals.py +68 -14
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/people.py +3 -3
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/conversations.py +26 -19
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/util.py +11 -2
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_commands.py +100 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_conversations_formatter.py +18 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_util.py +24 -1
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/uv.lock +2 -2
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/LICENSE +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/README.md +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/__init__.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/__main__.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/app.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/__init__.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/accounts.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/calls.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/contacts.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/custom_fields.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/emails.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/enrich.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/install.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/jobs.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/news.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/notes.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/pipelines.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/tasks.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/usage.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/context.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/__init__.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/accounts.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/contacts.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/deals.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/generic.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/people.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/help_reference.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/linkedin.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/output.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/SKILL.md +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/__init__.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/__init__.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/contact-workflows.md +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/conftest.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_context.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_install.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_linkedin.py +0 -0
- {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_output.py +0 -0
|
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.3.0] - 2026-07-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `deals create` command — create a deal/opportunity from the CLI: `deals create --name "…" [--owner-id] [--account-id] [--amount] [--stage-id | --stage-name] [--closed-date]`. `--name` is the only required flag; `--stage-name` resolves to an ID via the stage list (same helper as `deals search`), and passing both `--stage-id` and `--stage-name` is a validation error. Requires a master Apollo API key (non-master keys get a 403).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Require `qodev-apollo-api>=0.4.0` (adds `create_deal`, and ships the fixed `update_opportunity_roles` payload so `deals set-role` now works, plus `search_accounts` filter validation).
|
|
18
|
+
|
|
19
|
+
## [1.2.1] - 2026-07-08
|
|
20
|
+
|
|
21
|
+
Follow-ups from code review of the v1.2.0 changes.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **`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.
|
|
26
|
+
- **`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.
|
|
27
|
+
- **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).
|
|
28
|
+
- **`--stage-name` errors are bounded.** An unknown stage name lists at most 15 available names (`… (+N more)`) instead of dumping the entire list.
|
|
29
|
+
|
|
30
|
+
### Note
|
|
31
|
+
|
|
32
|
+
- `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.
|
|
33
|
+
|
|
9
34
|
## [1.2.0] - 2026-07-08
|
|
10
35
|
|
|
11
36
|
Usage-driven UX improvements — every item here closes a gap where users had been dropping to raw curl or doing extra lookups.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qodev-apollo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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.4.0
|
|
23
23
|
Requires-Dist: rich>=13.0
|
|
24
24
|
Provides-Extra: dev
|
|
25
25
|
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qodev-apollo-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.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.4.0",
|
|
24
24
|
]
|
|
25
25
|
|
|
26
26
|
[project.optional-dependencies]
|
|
@@ -25,6 +25,10 @@ async def search(
|
|
|
25
25
|
"""Search recorded conversations."""
|
|
26
26
|
filters: dict = {}
|
|
27
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.
|
|
28
32
|
filters["q_keywords"] = query
|
|
29
33
|
|
|
30
34
|
async with ctx.client() as client:
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Annotated, Any
|
|
5
|
+
from typing import Annotated, Any, cast
|
|
6
6
|
|
|
7
7
|
from cyclopts import App, Parameter
|
|
8
|
+
from qodev_apollo_api import RoleAssignment
|
|
8
9
|
|
|
9
10
|
from apollo_cli.context import ctx
|
|
10
11
|
from apollo_cli.formatters.deals import format_deal_detail, format_deal_list
|
|
@@ -65,6 +66,50 @@ async def get(
|
|
|
65
66
|
output(deal, ctx=ctx, format_fn=format_deal_detail)
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
@deals_app.command
|
|
70
|
+
async def create(
|
|
71
|
+
*,
|
|
72
|
+
name: Annotated[str, Parameter(name="--name", help="Deal name (required)")],
|
|
73
|
+
owner_id: Annotated[str | None, Parameter(name="--owner-id", help="Deal owner (team member) ID")] = None,
|
|
74
|
+
account_id: Annotated[str | None, Parameter(name="--account-id", help="Target account/company ID")] = None,
|
|
75
|
+
amount: Annotated[float | None, Parameter(name="--amount", help="Deal value (no currency symbol)")] = None,
|
|
76
|
+
stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Deal stage ID")] = None,
|
|
77
|
+
stage_name: Annotated[
|
|
78
|
+
str | None,
|
|
79
|
+
Parameter(name="--stage-name", help="Deal stage name (resolved to an ID; avoids a `pipelines stages` lookup)"),
|
|
80
|
+
] = None,
|
|
81
|
+
closed_date: Annotated[str | None, Parameter(name="--closed-date", help="Expected close date (YYYY-MM-DD)")] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Create a new deal/opportunity.
|
|
84
|
+
|
|
85
|
+
Requires a master Apollo API key (non-master keys get a 403). ``--name`` is the
|
|
86
|
+
only required field. Use ``--stage-name`` to set the stage by name instead of ID.
|
|
87
|
+
"""
|
|
88
|
+
if stage_id and stage_name:
|
|
89
|
+
error("Pass either --stage-id or --stage-name, not both.", ctx=ctx, code="conflicting_args", exit_code=2)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
fields: dict[str, Any] = {}
|
|
93
|
+
if owner_id:
|
|
94
|
+
fields["owner_id"] = owner_id
|
|
95
|
+
if account_id:
|
|
96
|
+
fields["account_id"] = account_id
|
|
97
|
+
if amount is not None:
|
|
98
|
+
fields["amount"] = amount
|
|
99
|
+
if closed_date:
|
|
100
|
+
fields["closed_date"] = closed_date
|
|
101
|
+
|
|
102
|
+
async with ctx.client() as client:
|
|
103
|
+
if stage_name:
|
|
104
|
+
all_stages = await client.list_all_stages()
|
|
105
|
+
stage_id = resolve_stage_id(stage_name, all_stages.items, kind="deal stage")
|
|
106
|
+
if stage_id:
|
|
107
|
+
fields["opportunity_stage_id"] = stage_id
|
|
108
|
+
deal = await client.create_deal(name, **fields)
|
|
109
|
+
|
|
110
|
+
output(deal, ctx=ctx, format_fn=format_deal_detail)
|
|
111
|
+
|
|
112
|
+
|
|
68
113
|
ROLE_TYPE_COLUMNS = [("ID", "id"), ("Name", "name"), ("Display Order", "display_order")]
|
|
69
114
|
|
|
70
115
|
|
|
@@ -86,19 +131,26 @@ async def role_types() -> None:
|
|
|
86
131
|
|
|
87
132
|
|
|
88
133
|
def _existing_roles(deal: Any) -> list[dict]:
|
|
89
|
-
"""Flatten a deal's current opportunity_contact_roles into update_roles entries.
|
|
134
|
+
"""Flatten a deal's current opportunity_contact_roles into update_roles entries.
|
|
135
|
+
|
|
136
|
+
``opportunity_contact_role_type_id`` is only included when the existing role
|
|
137
|
+
actually has one — we never send an explicit ``null`` (see ``_clean_roles``).
|
|
138
|
+
"""
|
|
90
139
|
roles: list[dict] = []
|
|
91
140
|
for r in getattr(deal, "opportunity_contact_roles", []) or []:
|
|
92
|
-
|
|
93
|
-
if r.role:
|
|
94
|
-
|
|
95
|
-
roles.append(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
141
|
+
entry: dict = {"contact_id": r.contact_id, "is_primary": bool(r.is_primary)}
|
|
142
|
+
if r.role and r.role[0].opportunity_contact_role_type_id:
|
|
143
|
+
entry["opportunity_contact_role_type_id"] = r.role[0].opportunity_contact_role_type_id
|
|
144
|
+
roles.append(entry)
|
|
145
|
+
return roles
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _clean_roles(roles: list[dict]) -> list[dict]:
|
|
149
|
+
"""Drop any ``opportunity_contact_role_type_id`` that is ``None`` so we never POST an
|
|
150
|
+
explicit null (Apollo may reject roles without a role type — omit the key instead)."""
|
|
151
|
+
for r in roles:
|
|
152
|
+
if r.get("opportunity_contact_role_type_id") is None:
|
|
153
|
+
r.pop("opportunity_contact_role_type_id", None)
|
|
102
154
|
return roles
|
|
103
155
|
|
|
104
156
|
|
|
@@ -148,7 +200,7 @@ async def set_role(
|
|
|
148
200
|
|
|
149
201
|
entry = next((r for r in roles if r["contact_id"] == contact_id), None)
|
|
150
202
|
if entry is None:
|
|
151
|
-
entry = {"contact_id": contact_id, "
|
|
203
|
+
entry = {"contact_id": contact_id, "is_primary": False}
|
|
152
204
|
roles.append(entry)
|
|
153
205
|
if role_type_id is not None:
|
|
154
206
|
entry["opportunity_contact_role_type_id"] = role_type_id
|
|
@@ -156,6 +208,8 @@ async def set_role(
|
|
|
156
208
|
for r in roles:
|
|
157
209
|
r["is_primary"] = r["contact_id"] == contact_id
|
|
158
210
|
|
|
159
|
-
|
|
211
|
+
# Entries are built dynamically (conditional keys, pop), so they're plain dicts;
|
|
212
|
+
# cast to the client's RoleAssignment TypedDict at the boundary.
|
|
213
|
+
updated = await client.update_opportunity_roles(id, cast("list[RoleAssignment]", _clean_roles(roles)))
|
|
160
214
|
|
|
161
215
|
output(updated, ctx=ctx, format_fn=format_deal_detail)
|
|
@@ -51,9 +51,9 @@ async def search(
|
|
|
51
51
|
async with ctx.client() as client:
|
|
52
52
|
result = await client.search_people(**filters)
|
|
53
53
|
|
|
54
|
-
# search_people returns the raw Apollo dict
|
|
55
|
-
#
|
|
56
|
-
items = result.get("people") or result.get("contacts") or []
|
|
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
57
|
pagination = result.get("pagination", {})
|
|
58
58
|
total = pagination.get("total_entries", len(items))
|
|
59
59
|
|
{qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/conversations.py
RENAMED
|
@@ -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,38 +49,38 @@ 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 =
|
|
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 =
|
|
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 =
|
|
54
|
-
title =
|
|
55
|
-
account =
|
|
56
|
-
internal =
|
|
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 =
|
|
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 =
|
|
67
|
-
account =
|
|
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 =
|
|
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
|
-
if
|
|
83
|
+
if _get(data, "transcript"):
|
|
77
84
|
md += "\n\n" + format_transcript(data)
|
|
78
85
|
|
|
79
86
|
return md
|
|
@@ -81,13 +88,13 @@ def format_conversation_detail(data: Any) -> str:
|
|
|
81
88
|
|
|
82
89
|
def format_transcript(data: Any) -> str:
|
|
83
90
|
"""Render just the transcript of a conversation as `**Speaker:** sentence` lines."""
|
|
84
|
-
segments =
|
|
91
|
+
segments = _get(data, "transcript") or []
|
|
85
92
|
if not segments:
|
|
86
93
|
return "## Transcript\n\n_No transcript available._"
|
|
87
94
|
lines = ["## Transcript", ""]
|
|
88
95
|
for seg in segments:
|
|
89
|
-
speaker =
|
|
90
|
-
sentence =
|
|
96
|
+
speaker = _get(seg, "participant_name") or "Unknown"
|
|
97
|
+
sentence = _get(seg, "spoken_sentence") or ""
|
|
91
98
|
lines.append(f"- **{speaker}:** {sentence}")
|
|
92
99
|
return "\n".join(lines)
|
|
93
100
|
|
|
@@ -95,10 +102,10 @@ def format_transcript(data: Any) -> str:
|
|
|
95
102
|
def _format_summary(summary: Any) -> str:
|
|
96
103
|
"""Render the AI call summary (outcome, pain points, objections, next steps)."""
|
|
97
104
|
md = "\n\n## Call Summary\n"
|
|
98
|
-
outcome =
|
|
105
|
+
outcome = _get(summary, "outcome")
|
|
99
106
|
if outcome:
|
|
100
107
|
md += f"\n**Outcome:** {outcome}\n"
|
|
101
|
-
pricing =
|
|
108
|
+
pricing = _get(summary, "pricing_discussion")
|
|
102
109
|
if pricing:
|
|
103
110
|
md += f"\n**Pricing discussion:** {pricing}\n"
|
|
104
111
|
|
|
@@ -107,11 +114,11 @@ def _format_summary(summary: Any) -> str:
|
|
|
107
114
|
("Objections", "objections", "text"),
|
|
108
115
|
("Next Steps", "next_steps", "step"),
|
|
109
116
|
):
|
|
110
|
-
items =
|
|
117
|
+
items = _get(summary, attr) or []
|
|
111
118
|
if items:
|
|
112
119
|
md += f"\n### {label}\n"
|
|
113
120
|
for item in items:
|
|
114
|
-
text =
|
|
115
|
-
who =
|
|
121
|
+
text = _get(item, field) or ""
|
|
122
|
+
who = _get(item, "participant_name")
|
|
116
123
|
md += f"\n- {text}" + (f" — _{who}_" if who else "")
|
|
117
124
|
return md
|
|
@@ -20,11 +20,20 @@ def resolve_stage_id(name: str, stages: list[Any], *, kind: str = "stage") -> st
|
|
|
20
20
|
target = name.strip().lower()
|
|
21
21
|
match = next((s for s in stages if (_field(s, "name") or "").lower() == target), None)
|
|
22
22
|
if match is None:
|
|
23
|
-
|
|
24
|
-
raise ValueError(f"No {kind} named {name!r}. Available: {
|
|
23
|
+
names = sorted(n for s in stages if (n := _field(s, "name")))
|
|
24
|
+
raise ValueError(f"No {kind} named {name!r}. Available: {_preview(names)}")
|
|
25
25
|
return _field(match, "id")
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _preview(names: list[str], limit: int = 15) -> str:
|
|
29
|
+
"""Render a name list for an error message, capped so it can't get huge."""
|
|
30
|
+
if not names:
|
|
31
|
+
return "(none)"
|
|
32
|
+
if len(names) <= limit:
|
|
33
|
+
return ", ".join(names)
|
|
34
|
+
return f"{', '.join(names[:limit])}, … (+{len(names) - limit} more)"
|
|
35
|
+
|
|
36
|
+
|
|
28
37
|
def parse_comma_list(raw: str) -> list[str]:
|
|
29
38
|
"""Parse a comma-separated CLI argument into a list of stripped, non-empty tokens.
|
|
30
39
|
|
|
@@ -375,6 +375,28 @@ class TestPeopleCommands:
|
|
|
375
375
|
assert data["items"][0]["name"] == "Jane"
|
|
376
376
|
assert data["total"] == 1
|
|
377
377
|
|
|
378
|
+
@pytest.mark.asyncio
|
|
379
|
+
async def test_people_search_merges_people_and_contacts(self, capsys) -> None:
|
|
380
|
+
"""When Apollo returns both `people` and `contacts`, neither half is dropped."""
|
|
381
|
+
mock_client = MagicMock()
|
|
382
|
+
mock_client.search_people = AsyncMock(
|
|
383
|
+
return_value={
|
|
384
|
+
"people": [{"id": "p1", "name": "Jane"}],
|
|
385
|
+
"contacts": [{"id": "c1", "name": "John"}],
|
|
386
|
+
"pagination": {"total_entries": 2},
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
391
|
+
|
|
392
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
393
|
+
from apollo_cli.commands.people import search
|
|
394
|
+
|
|
395
|
+
await search(keywords="jane")
|
|
396
|
+
|
|
397
|
+
data = json.loads(capsys.readouterr().out)
|
|
398
|
+
assert {p["name"] for p in data["items"]} == {"Jane", "John"}
|
|
399
|
+
|
|
378
400
|
|
|
379
401
|
class TestStageNameFilter:
|
|
380
402
|
@pytest.mark.asyncio
|
|
@@ -454,6 +476,84 @@ class TestDealRoleCommands:
|
|
|
454
476
|
assert by_contact["c-new"]["is_primary"] is True
|
|
455
477
|
assert by_contact["c-old"]["is_primary"] is False # demoted
|
|
456
478
|
assert by_contact["c-old"]["opportunity_contact_role_type_id"] == "rt-x" # preserved
|
|
479
|
+
# New contact without --role-type: the key is omitted, never sent as an explicit null.
|
|
480
|
+
assert "opportunity_contact_role_type_id" not in by_contact["c-new"]
|
|
481
|
+
|
|
482
|
+
@pytest.mark.asyncio
|
|
483
|
+
async def test_set_role_never_sends_null_role_type(self, capsys) -> None:
|
|
484
|
+
"""An existing role stored without a role type is sent without the key (no null)."""
|
|
485
|
+
from qodev_apollo_api.models import Deal
|
|
486
|
+
|
|
487
|
+
deal = Deal.model_validate(
|
|
488
|
+
{"id": "d1", "opportunity_contact_roles": [{"id": "r1", "contact_id": "c-old", "is_primary": True}]}
|
|
489
|
+
)
|
|
490
|
+
mock_client = MagicMock()
|
|
491
|
+
mock_client.get_deal = AsyncMock(return_value=deal)
|
|
492
|
+
mock_client.update_opportunity_roles = AsyncMock(return_value=deal)
|
|
493
|
+
|
|
494
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
495
|
+
|
|
496
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
497
|
+
from apollo_cli.commands.deals import set_role
|
|
498
|
+
|
|
499
|
+
await set_role("d1", contact_id="c-new")
|
|
500
|
+
|
|
501
|
+
_, roles = mock_client.update_opportunity_roles.call_args.args
|
|
502
|
+
assert all("opportunity_contact_role_type_id" not in r for r in roles)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class TestDealCreate:
|
|
506
|
+
@pytest.mark.asyncio
|
|
507
|
+
async def test_create_passes_name_and_optional_fields(self, sample_deal: dict, capsys) -> None:
|
|
508
|
+
"""deals create forwards --name plus only the optional fields that were provided."""
|
|
509
|
+
mock_client = MagicMock()
|
|
510
|
+
mock_client.create_deal = AsyncMock(return_value=sample_deal)
|
|
511
|
+
|
|
512
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
513
|
+
|
|
514
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
515
|
+
from apollo_cli.commands.deals import create
|
|
516
|
+
|
|
517
|
+
await create(name="Big One", owner_id="o1", amount=5000, stage_id="st1")
|
|
518
|
+
|
|
519
|
+
name_arg = mock_client.create_deal.call_args.args[0]
|
|
520
|
+
fields = mock_client.create_deal.call_args.kwargs
|
|
521
|
+
assert name_arg == "Big One"
|
|
522
|
+
assert fields == {"owner_id": "o1", "amount": 5000, "opportunity_stage_id": "st1"}
|
|
523
|
+
|
|
524
|
+
@pytest.mark.asyncio
|
|
525
|
+
async def test_create_resolves_stage_name(self, sample_deal: dict, capsys) -> None:
|
|
526
|
+
"""deals create --stage-name resolves to opportunity_stage_id."""
|
|
527
|
+
mock_client = MagicMock()
|
|
528
|
+
mock_client.list_all_stages = AsyncMock(
|
|
529
|
+
return_value=MockSearchResult(items=[{"id": "st-neg", "name": "Negotiation"}], total=1, page=1)
|
|
530
|
+
)
|
|
531
|
+
mock_client.create_deal = AsyncMock(return_value=sample_deal)
|
|
532
|
+
|
|
533
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
534
|
+
|
|
535
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
536
|
+
from apollo_cli.commands.deals import create
|
|
537
|
+
|
|
538
|
+
await create(name="Deal", stage_name="negotiation")
|
|
539
|
+
|
|
540
|
+
assert mock_client.create_deal.call_args.kwargs["opportunity_stage_id"] == "st-neg"
|
|
541
|
+
|
|
542
|
+
@pytest.mark.asyncio
|
|
543
|
+
async def test_create_rejects_both_stage_id_and_name(self, capsys) -> None:
|
|
544
|
+
"""Passing both --stage-id and --stage-name is a validation error; no API call is made."""
|
|
545
|
+
mock_client = MagicMock()
|
|
546
|
+
mock_client.create_deal = AsyncMock()
|
|
547
|
+
|
|
548
|
+
_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)
|
|
549
|
+
|
|
550
|
+
with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
|
|
551
|
+
from apollo_cli.commands.deals import create
|
|
552
|
+
|
|
553
|
+
with pytest.raises(SystemExit):
|
|
554
|
+
await create(name="Deal", stage_id="st1", stage_name="Negotiation")
|
|
555
|
+
|
|
556
|
+
mock_client.create_deal.assert_not_called()
|
|
457
557
|
|
|
458
558
|
|
|
459
559
|
class TestCustomFieldsCommand:
|
|
@@ -68,3 +68,21 @@ def test_detail_formatter_minimal_conversation() -> None:
|
|
|
68
68
|
assert "Conversation: Quick sync" in md
|
|
69
69
|
assert "## Transcript" not in md
|
|
70
70
|
assert "## Call Summary" not in md
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_detail_formatter_handles_raw_dict() -> None:
|
|
74
|
+
"""The rich detail view renders from a raw dict too, not only Pydantic models."""
|
|
75
|
+
data = {
|
|
76
|
+
"id": "c1",
|
|
77
|
+
"topic": "Dict sync",
|
|
78
|
+
"participants_info": [{"name": "Jane", "title": "VP", "is_internal_participant": True}],
|
|
79
|
+
"deals": [{"id": "d1", "name": "Enterprise Deal", "account_name": "Acme"}],
|
|
80
|
+
"call_summary": {"outcome": "Good", "next_steps": [{"step": "Follow up"}]},
|
|
81
|
+
"transcript": [{"participant_name": "Jane", "spoken_sentence": "Hello."}],
|
|
82
|
+
}
|
|
83
|
+
md = format_conversation_detail(data)
|
|
84
|
+
assert "Conversation: Dict sync" in md
|
|
85
|
+
assert "## Participants" in md and "Jane" in md and "(internal)" in md
|
|
86
|
+
assert "## Deals" in md and "Enterprise Deal" in md
|
|
87
|
+
assert "## Call Summary" in md and "Good" in md and "Follow up" in md
|
|
88
|
+
assert "## Transcript" in md and "**Jane:** Hello." in md
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
-
from apollo_cli.util import parse_comma_list
|
|
7
|
+
from apollo_cli.util import parse_comma_list, resolve_stage_id
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class TestParseCommaList:
|
|
@@ -31,3 +31,26 @@ class TestParseCommaList:
|
|
|
31
31
|
input (tested above) is fine because it maps cleanly to "flag not provided"."""
|
|
32
32
|
with pytest.raises(ValueError, match="only separators"):
|
|
33
33
|
parse_comma_list(raw)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestResolveStageId:
|
|
37
|
+
def test_matches_case_insensitively(self) -> None:
|
|
38
|
+
stages = [{"id": "s1", "name": "Negotiation"}, {"id": "s2", "name": "Won"}]
|
|
39
|
+
assert resolve_stage_id("negotiation", stages) == "s1"
|
|
40
|
+
|
|
41
|
+
def test_works_with_model_like_objects(self) -> None:
|
|
42
|
+
class S:
|
|
43
|
+
def __init__(self, id, name):
|
|
44
|
+
self.id, self.name = id, name
|
|
45
|
+
|
|
46
|
+
assert resolve_stage_id("Won", [S("s1", "Neg"), S("s2", "Won")]) == "s2"
|
|
47
|
+
|
|
48
|
+
def test_unknown_name_lists_available(self) -> None:
|
|
49
|
+
stages = [{"id": "s1", "name": "Won"}, {"id": "s2", "name": "Lost"}]
|
|
50
|
+
with pytest.raises(ValueError, match=r"No stage named 'Nope'\. Available: Lost, Won"):
|
|
51
|
+
resolve_stage_id("Nope", stages)
|
|
52
|
+
|
|
53
|
+
def test_available_list_is_capped(self) -> None:
|
|
54
|
+
stages = [{"id": str(i), "name": f"Stage{i:02d}"} for i in range(30)]
|
|
55
|
+
with pytest.raises(ValueError, match=r"\(\+15 more\)"):
|
|
56
|
+
resolve_stage_id("missing", stages)
|
|
@@ -472,7 +472,7 @@ wheels = [
|
|
|
472
472
|
|
|
473
473
|
[[package]]
|
|
474
474
|
name = "qodev-apollo-api"
|
|
475
|
-
version = "0.
|
|
475
|
+
version = "0.4.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.2.
|
|
497
|
+
version = "1.2.1"
|
|
498
498
|
source = { editable = "." }
|
|
499
499
|
dependencies = [
|
|
500
500
|
{ name = "cyclopts" },
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.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
|