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.
Files changed (55) hide show
  1. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/.gitignore +5 -0
  2. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/CHANGELOG.md +25 -0
  3. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/PKG-INFO +2 -2
  4. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/pyproject.toml +2 -2
  5. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/conversations.py +4 -0
  6. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/deals.py +68 -14
  7. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/people.py +3 -3
  8. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/conversations.py +26 -19
  9. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/util.py +11 -2
  10. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_commands.py +100 -0
  11. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_conversations_formatter.py +18 -0
  12. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_util.py +24 -1
  13. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/uv.lock +2 -2
  14. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/.github/workflows/ci.yml +0 -0
  15. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  16. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/LICENSE +0 -0
  17. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/README.md +0 -0
  18. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/__init__.py +0 -0
  19. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/__main__.py +0 -0
  20. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/app.py +0 -0
  21. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/__init__.py +0 -0
  22. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/accounts.py +0 -0
  23. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/calls.py +0 -0
  24. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/contacts.py +0 -0
  25. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/custom_fields.py +0 -0
  26. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/emails.py +0 -0
  27. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/enrich.py +0 -0
  28. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/install.py +0 -0
  29. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/jobs.py +0 -0
  30. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/news.py +0 -0
  31. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/notes.py +0 -0
  32. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/pipelines.py +0 -0
  33. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/tasks.py +0 -0
  34. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/commands/usage.py +0 -0
  35. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/context.py +0 -0
  36. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/__init__.py +0 -0
  37. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/accounts.py +0 -0
  38. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/contacts.py +0 -0
  39. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/deals.py +0 -0
  40. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/generic.py +0 -0
  41. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/formatters/people.py +0 -0
  42. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/help_reference.py +0 -0
  43. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/linkedin.py +0 -0
  44. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/output.py +0 -0
  45. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/SKILL.md +0 -0
  46. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/__init__.py +0 -0
  47. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/__init__.py +0 -0
  48. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/account-workflows.md +0 -0
  49. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/contact-workflows.md +0 -0
  50. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/src/apollo_cli/skills/references/deal-workflows.md +0 -0
  51. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/conftest.py +0 -0
  52. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_context.py +0 -0
  53. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_install.py +0 -0
  54. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_linkedin.py +0 -0
  55. {qodev_apollo_cli-1.2.0 → qodev_apollo_cli-1.3.0}/tests/test_output.py +0 -0
@@ -7,3 +7,8 @@ dist/
7
7
  .coverage
8
8
  .pytest_cache/
9
9
  .ruff_cache/
10
+ .mypy_cache/
11
+
12
+ # Local secrets — never commit
13
+ .env
14
+ .env.*
@@ -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.2.0
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.3.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.2.0"
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.3.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
- 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
- )
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, "opportunity_contact_role_type_id": None, "is_primary": False}
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
- updated = await client.update_opportunity_roles(id, roles)
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; people live under "people" (and
55
- # sometimes "contacts" for matched CRM records).
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
 
@@ -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 = 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
- if getattr(data, "transcript", None):
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 = getattr(data, "transcript", None) or []
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 = getattr(seg, "participant_name", None) or "Unknown"
90
- sentence = getattr(seg, "spoken_sentence", None) or ""
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 = getattr(summary, "outcome", None)
105
+ outcome = _get(summary, "outcome")
99
106
  if outcome:
100
107
  md += f"\n**Outcome:** {outcome}\n"
101
- pricing = getattr(summary, "pricing_discussion", None)
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 = getattr(summary, attr, None) or []
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 = getattr(item, field, None) or ""
115
- who = getattr(item, "participant_name", None)
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
- 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)'}")
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.3.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.0"
497
+ version = "1.2.1"
498
498
  source = { editable = "." }
499
499
  dependencies = [
500
500
  { name = "cyclopts" },