diff --git a/CHANGELOG.md b/CHANGELOG.md index 286df02..8b8d0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.2.0] - 2026-07-08 + +Usage-driven UX improvements — every item here closes a gap where users had been dropping to raw curl or doing extra lookups. + +### Added + +- **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. +- **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. +- **`conversations transcript ID`** — prints just the transcript (no metadata/summary), for when you only want the words. +- **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. +- **`custom-fields list [--modality]`** — lists custom field definitions across contacts/accounts/opportunities. + +### Changed + +- Requires `qodev-apollo-api>=0.3.0` (for `update_opportunity_roles` and `list_custom_fields`). + ## [1.1.0] - 2026-07-08 ### Added diff --git a/README.md b/README.md index bc8bd5f..da18b0b 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ $ qodev-apollo-cli usage | Group | Subcommand | Description | |---|---|---| -| **contacts** | `search` | Search contacts (`--query`, `--stage-id`, `--linkedin-url`) | +| **contacts** | `search` | Search contacts (`--query`, `--stage-id`, `--stage-name`, `--linkedin-url`) | | | `get` | Get contact details by ID | | | `create` | Create a new contact (`--first-name`, `--last-name`, `--email`, etc.) | | | `update` | Update contact (`--title`, `--label-ids`) | @@ -65,8 +65,10 @@ $ qodev-apollo-cli usage | | `stages` | List all contact stages | | **accounts** | `search` | Search companies/accounts (`--query`, `--domain`) | | | `get` | Get account details by ID | -| **deals** | `search` | Search opportunities/deals (`--query`, `--stage-id`) | +| **deals** | `search` | Search opportunities/deals (`--query`, `--stage-id`, `--stage-name`) | | | `get` | Get deal details by ID | +| | `role-types` | List opportunity contact role types | +| | `set-role` | Set/update a contact's role on a deal (`--contact-id`, `--role-type`, `--primary`) | | **pipelines** | `list` | List all deal pipelines | | | `get` | Get pipeline details | | | `stages` | List stages in a pipeline | @@ -74,7 +76,7 @@ $ qodev-apollo-cli usage | | `get` | Get stage details | | **enrich** | `org` | Enrich organization by domain (FREE - no credits) | | | `person` | Enrich person by email (1 credit per lookup) | -| **people** | `search` | Search people database (`--person-titles`, `--q-organization-domains`) | +| **people** | `search` | Search people database (`--titles`, `--seniorities`, `--locations`, `--organization-domains`) | | **notes** | `search` | Search notes (`--contact-id`, `--account-id`, `--opportunity-id`) | | | `create` | Create a note (`--contact-ids`, `--account-ids`, `--opportunity-ids`, `--content`) | | **tasks** | `search` | Search tasks (`--type`, `--status`) | @@ -82,6 +84,8 @@ $ qodev-apollo-cli usage | **calls** | `search` | Search call activities | | **conversations** | `search` | Search recorded conversations (`--query`) | | | `get` | Get a conversation with transcript and AI summary | +| | `transcript` | Print just the transcript of a conversation | +| **custom-fields** | `list` | List custom field definitions (`--modality`) | | **emails** | `search` | Search email activities | | **news** | `search` | Search news (`--categories`) | | **jobs** | `search` | Search job postings (`--job-titles`, `--company-domains`) | diff --git a/pyproject.toml b/pyproject.toml index d712cbd..260d395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "qodev-apollo-cli" -version = "1.1.0" +version = "1.2.0" description = "Agent-friendly CLI for the Apollo API" readme = "README.md" requires-python = ">=3.11" @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ "cyclopts>=3.0", "rich>=13.0", - "qodev-apollo-api>=0.1.0", + "qodev-apollo-api>=0.3.0", ] [project.optional-dependencies] diff --git a/src/apollo_cli/app.py b/src/apollo_cli/app.py index 6feaae1..05f9e36 100644 --- a/src/apollo_cli/app.py +++ b/src/apollo_cli/app.py @@ -27,6 +27,7 @@ from apollo_cli.commands.calls import calls_app # noqa: E402 from apollo_cli.commands.contacts import contacts_app # noqa: E402 from apollo_cli.commands.conversations import conversations_app # noqa: E402 +from apollo_cli.commands.custom_fields import custom_fields_app # noqa: E402 from apollo_cli.commands.deals import deals_app # noqa: E402 from apollo_cli.commands.emails import emails_app # noqa: E402 from apollo_cli.commands.enrich import enrich_app # noqa: E402 @@ -52,6 +53,7 @@ tasks_app, calls_app, conversations_app, + custom_fields_app, emails_app, news_app, jobs_app, diff --git a/src/apollo_cli/commands/contacts.py b/src/apollo_cli/commands/contacts.py index c5f215b..fb1d8ad 100644 --- a/src/apollo_cli/commands/contacts.py +++ b/src/apollo_cli/commands/contacts.py @@ -14,7 +14,7 @@ ) from apollo_cli.linkedin import apollo_canonical_linkedin_url from apollo_cli.output import error, output, output_list -from apollo_cli.util import parse_comma_list +from apollo_cli.util import parse_comma_list, resolve_stage_id contacts_app = App(name="contacts", help="Manage contacts.") @@ -24,6 +24,12 @@ async def search( *, query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword")] = "", stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Filter by stage ID")] = None, + stage_name: Annotated[ + str | None, + Parameter( + name="--stage-name", help="Filter by stage name (resolved to an ID; avoids a `contacts stages` lookup)" + ), + ] = None, linkedin_url: Annotated[str | None, Parameter(name="--linkedin-url", help="Filter by LinkedIn URL")] = None, ) -> None: """Search contacts by keyword or filter.""" @@ -38,6 +44,11 @@ async def search( filters["linkedin_url"] = apollo_canonical_linkedin_url(linkedin_url) async with ctx.client() as client: + if stage_name: + stages_ = await client.get_contact_stages() + filters.setdefault("contact_stage_ids", []).append( + resolve_stage_id(stage_name, stages_, kind="contact stage") + ) result = await client.search_contacts(page=ctx.page, limit=ctx.limit, **filters) output_list( diff --git a/src/apollo_cli/commands/conversations.py b/src/apollo_cli/commands/conversations.py index d1b14e9..8991405 100644 --- a/src/apollo_cli/commands/conversations.py +++ b/src/apollo_cli/commands/conversations.py @@ -7,8 +7,12 @@ from cyclopts import App, Parameter from apollo_cli.context import ctx -from apollo_cli.formatters.conversations import format_conversation_detail, format_conversation_list -from apollo_cli.output import output, output_list +from apollo_cli.formatters.conversations import ( + format_conversation_detail, + format_conversation_list, + format_transcript, +) +from apollo_cli.output import output, output_json, output_list, output_markdown conversations_app = App(name="conversations", help="Recorded conversations (Zoom/Teams/Meet).") @@ -46,3 +50,18 @@ async def get( conversation = await client.get_conversation(id) output(conversation, ctx=ctx, format_fn=format_conversation_detail) + + +@conversations_app.command +async def transcript( + id: Annotated[str, Parameter(help="Conversation ID")], +) -> None: + """Print just the transcript of a conversation (no metadata or summary).""" + async with ctx.client() as client: + conversation = await client.get_conversation(id) + + segments = getattr(conversation, "transcript", None) or [] + if ctx.json_mode: + output_json(segments) + else: + output_markdown(format_transcript(conversation)) diff --git a/src/apollo_cli/commands/custom_fields.py b/src/apollo_cli/commands/custom_fields.py new file mode 100644 index 0000000..d9049ed --- /dev/null +++ b/src/apollo_cli/commands/custom_fields.py @@ -0,0 +1,48 @@ +"""Custom fields command group.""" + +from __future__ import annotations + +from typing import Annotated + +from cyclopts import App, Parameter + +from apollo_cli.context import ctx +from apollo_cli.formatters.generic import list_table +from apollo_cli.output import output_list + +custom_fields_app = App(name="custom-fields", help="Custom field definitions.") + +CUSTOM_FIELD_COLUMNS = [ + ("ID", "id"), + ("Modality", "modality"), + ("Name", "name"), + ("Type", "type"), + ("CRM Field", "mapped_crm_field"), +] + + +@custom_fields_app.command(name="list") +async def list_fields( + *, + modality: Annotated[ + str | None, + Parameter(name="--modality", help="Filter by modality: contact, account, or opportunity"), + ] = None, +) -> None: + """List custom field definitions across contacts, accounts, and opportunities.""" + async with ctx.client() as client: + fields = await client.list_custom_fields() + + if modality: + target = modality.strip().lower() + fields = [f for f in fields if (f.modality or "").lower() == target] + + output_list( + items=fields, + total=len(fields), + page=1, + limit=len(fields) or 1, + ctx=ctx, + format_fn=lambda items, **kw: list_table(items, CUSTOM_FIELD_COLUMNS, title="Custom Fields", **kw), + resource_name="Custom Fields", + ) diff --git a/src/apollo_cli/commands/deals.py b/src/apollo_cli/commands/deals.py index bf2fb02..8357907 100644 --- a/src/apollo_cli/commands/deals.py +++ b/src/apollo_cli/commands/deals.py @@ -2,13 +2,15 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any from cyclopts import App, Parameter from apollo_cli.context import ctx from apollo_cli.formatters.deals import format_deal_detail, format_deal_list -from apollo_cli.output import output, output_list +from apollo_cli.formatters.generic import list_table +from apollo_cli.output import error, output, output_list +from apollo_cli.util import resolve_stage_id deals_app = App(name="deals", help="Manage deals/opportunities.") @@ -18,15 +20,27 @@ async def search( *, query: Annotated[str, Parameter(name=["--query", "-q"], help="Search keyword")] = "", stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Filter by deal stage ID")] = None, + stage_name: Annotated[ + str | None, + Parameter( + name="--stage-name", help="Filter by stage name (resolved to an ID; avoids a `pipelines stages` lookup)" + ), + ] = None, ) -> None: """Search deals by keyword or filter.""" filters: dict = {} if query: filters["q_keywords"] = query + stage_ids: list[str] = [] if stage_id: - filters["opportunity_stage_ids"] = [stage_id] + stage_ids.append(stage_id) async with ctx.client() as client: + if stage_name: + all_stages = await client.list_all_stages() + stage_ids.append(resolve_stage_id(stage_name, all_stages.items, kind="deal stage")) + if stage_ids: + filters["opportunity_stage_ids"] = stage_ids result = await client.search_deals(page=ctx.page, limit=ctx.limit, **filters) output_list( @@ -49,3 +63,99 @@ async def get( deal = await client.get_deal(id) output(deal, ctx=ctx, format_fn=format_deal_detail) + + +ROLE_TYPE_COLUMNS = [("ID", "id"), ("Name", "name"), ("Display Order", "display_order")] + + +@deals_app.command(name="role-types") +async def role_types() -> None: + """List the available opportunity contact role types (e.g. Decision Maker, Champion).""" + async with ctx.client() as client: + result = await client.list_opportunity_contact_role_types() + + output_list( + items=result.items, + total=result.total, + page=1, + limit=len(result.items) or 1, + ctx=ctx, + format_fn=lambda items, **kw: list_table(items, ROLE_TYPE_COLUMNS, title="Role Types", **kw), + resource_name="Role Types", + ) + + +def _existing_roles(deal: Any) -> list[dict]: + """Flatten a deal's current opportunity_contact_roles into update_roles entries.""" + roles: list[dict] = [] + for r in getattr(deal, "opportunity_contact_roles", []) or []: + role_type_id = None + if r.role: + role_type_id = r.role[0].opportunity_contact_role_type_id + roles.append( + { + "contact_id": r.contact_id, + "opportunity_contact_role_type_id": role_type_id, + "is_primary": bool(r.is_primary), + } + ) + return roles + + +@deals_app.command(name="set-role") +async def set_role( + id: Annotated[str, Parameter(help="Deal ID")], + *, + contact_id: Annotated[str, Parameter(name="--contact-id", help="Contact ID to add/update on the deal")], + role_type: Annotated[ + str | None, + Parameter(name="--role-type", help="Role type ID or name (e.g. 'Decision Maker'); resolved to an ID"), + ] = None, + primary: Annotated[ + bool, + Parameter(name="--primary", help="Mark this contact as the primary contact (unsets any other primary)"), + ] = False, +) -> None: + """Set or update a contact's role on a deal. + + Reads the deal's current contact roles, applies the change, and writes the full + set back (Apollo's update_roles replaces all roles). Add ``--primary`` to make + this contact the single primary contact. + """ + async with ctx.client() as client: + deal = await client.get_deal(id) + roles = _existing_roles(deal) + + role_type_id: str | None = None + if role_type: + role_type_id = role_type + # Resolve a human name to an ID when it isn't already an ID. + rt_result = await client.list_opportunity_contact_role_types() + names = {rt.name.lower(): rt.id for rt in rt_result.items if rt.name} + ids = {rt.id for rt in rt_result.items} + if role_type not in ids: + resolved = names.get(role_type.lower()) + if resolved is None: + error( + f"No role type {role_type!r}. Available: " + + ", ".join(sorted(rt.name for rt in rt_result.items if rt.name)), + ctx=ctx, + code="unknown_role_type", + exit_code=2, + ) + return + role_type_id = resolved + + entry = next((r for r in roles if r["contact_id"] == contact_id), None) + if entry is None: + entry = {"contact_id": contact_id, "opportunity_contact_role_type_id": None, "is_primary": False} + roles.append(entry) + if role_type_id is not None: + entry["opportunity_contact_role_type_id"] = role_type_id + if primary: + for r in roles: + r["is_primary"] = r["contact_id"] == contact_id + + updated = await client.update_opportunity_roles(id, roles) + + output(updated, ctx=ctx, format_fn=format_deal_detail) diff --git a/src/apollo_cli/commands/people.py b/src/apollo_cli/commands/people.py index 85d1e96..0b8d558 100644 --- a/src/apollo_cli/commands/people.py +++ b/src/apollo_cli/commands/people.py @@ -7,7 +7,8 @@ from cyclopts import App, Parameter from apollo_cli.context import ctx -from apollo_cli.output import output +from apollo_cli.formatters.people import format_people_list +from apollo_cli.output import output_list from apollo_cli.util import parse_comma_list people_app = App(name="people", help="People database search.") @@ -18,18 +19,50 @@ async def search( *, keywords: Annotated[str | None, Parameter(name="--keywords", help="Search keywords")] = None, titles: Annotated[str | None, Parameter(name="--titles", help="Comma-separated job titles")] = None, - locations: Annotated[str | None, Parameter(name="--locations", help="Comma-separated locations")] = None, + seniorities: Annotated[ + str | None, + Parameter(name="--seniorities", help="Comma-separated seniorities (e.g. owner,vp,director,manager)"), + ] = None, + locations: Annotated[str | None, Parameter(name="--locations", help="Comma-separated person locations")] = None, + organization_domains: Annotated[ + str | None, + Parameter( + name=["--organization-domains", "--domains"], + help="Comma-separated company domains — find people at these companies (e.g. acme.com,globex.com)", + ), + ] = None, ) -> None: - """Search Apollo's global people database.""" - filters: dict = {} + """Search Apollo's global people database. + + Respects the global ``--limit`` / ``--page`` options for pagination. + """ + filters: dict = {"page": ctx.page, "per_page": ctx.limit} if keywords: filters["q_keywords"] = keywords if titles: filters["person_titles"] = parse_comma_list(titles) + if seniorities: + filters["person_seniorities"] = parse_comma_list(seniorities) if locations: filters["person_locations"] = parse_comma_list(locations) + if organization_domains: + filters["q_organization_domains_list"] = parse_comma_list(organization_domains) async with ctx.client() as client: result = await client.search_people(**filters) - output(result, ctx=ctx) + # search_people returns the raw Apollo dict; people live under "people" (and + # sometimes "contacts" for matched CRM records). + items = result.get("people") or result.get("contacts") or [] + pagination = result.get("pagination", {}) + total = pagination.get("total_entries", len(items)) + + output_list( + items=items, + total=total, + page=ctx.page, + limit=ctx.limit, + ctx=ctx, + format_fn=format_people_list, + resource_name="People", + ) diff --git a/src/apollo_cli/formatters/conversations.py b/src/apollo_cli/formatters/conversations.py index f0d00e7..bf88320 100644 --- a/src/apollo_cli/formatters/conversations.py +++ b/src/apollo_cli/formatters/conversations.py @@ -73,17 +73,25 @@ def format_conversation_detail(data: Any) -> str: md += _format_summary(summary) # Transcript (detail endpoint only) - transcript = getattr(data, "transcript", None) or [] - if transcript: - md += "\n\n## Transcript\n" - for seg in transcript: - speaker = getattr(seg, "participant_name", None) or "Unknown" - sentence = getattr(seg, "spoken_sentence", None) or "" - md += f"\n- **{speaker}:** {sentence}" + if getattr(data, "transcript", None): + md += "\n\n" + format_transcript(data) return md +def format_transcript(data: Any) -> str: + """Render just the transcript of a conversation as `**Speaker:** sentence` lines.""" + segments = getattr(data, "transcript", None) or [] + if not segments: + return "## Transcript\n\n_No transcript available._" + lines = ["## Transcript", ""] + for seg in segments: + speaker = getattr(seg, "participant_name", None) or "Unknown" + sentence = getattr(seg, "spoken_sentence", None) or "" + lines.append(f"- **{speaker}:** {sentence}") + return "\n".join(lines) + + def _format_summary(summary: Any) -> str: """Render the AI call summary (outcome, pain points, objections, next steps).""" md = "\n\n## Call Summary\n" diff --git a/src/apollo_cli/formatters/people.py b/src/apollo_cli/formatters/people.py new file mode 100644 index 0000000..ff8e8d5 --- /dev/null +++ b/src/apollo_cli/formatters/people.py @@ -0,0 +1,21 @@ +"""People-search formatters.""" + +from __future__ import annotations + +from typing import Any + +from apollo_cli.formatters.generic import list_table + +# People objects nest their company under `organization`; `list_table` reads dot paths. +PEOPLE_LIST_COLUMNS = [ + ("Name", "name"), + ("Title", "title"), + ("Company", "organization.name"), + ("Email", "email"), + ("LinkedIn", "linkedin_url"), +] + + +def format_people_list(items: list[Any], *, total: int = 0, page: int = 1) -> str: + """Format people-database search results as a markdown table.""" + return list_table(items, PEOPLE_LIST_COLUMNS, title="People", total=total, page=page) diff --git a/src/apollo_cli/skills/SKILL.md b/src/apollo_cli/skills/SKILL.md index 1d66290..c4df50f 100644 --- a/src/apollo_cli/skills/SKILL.md +++ b/src/apollo_cli/skills/SKILL.md @@ -29,7 +29,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti | Command | Description | |---------|-------------| -| `contacts search [--query Q] [--stage-id ID] [--linkedin-url URL]` | Search contacts | +| `contacts search [--query Q] [--stage-id ID] [--stage-name NAME] [--linkedin-url URL]` | Search contacts | | `contacts get ID` | Get contact details | | `contacts create --first-name F --last-name L [--email E] [--title T] [--company C] [--linkedin-url URL]` | Create contact | | `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 | Command | Description | |---------|-------------| -| `deals search [--query Q] [--stage-id ID]` | Search opportunities/deals | +| `deals search [--query Q] [--stage-id ID] [--stage-name NAME]` | Search opportunities/deals | | `deals get ID` | Get deal details | +| `deals role-types` | List opportunity contact role types | +| `deals set-role ID --contact-id C [--role-type NAME_OR_ID] [--primary]` | Set/update a contact's role on a deal | ### pipelines @@ -76,7 +78,7 @@ Get your API key from [Apollo.io Settings → API](https://app.apollo.io/#/setti | Command | Description | |---------|-------------| -| `people search [--person-titles TITLES] [--q-organization-domains DOMAINS]` | Search people database | +| `people search [--titles T] [--seniorities S] [--locations L] [--organization-domains D]` | Search people database (respects `--limit`/`--page`) | ### notes @@ -106,6 +108,13 @@ Recorded meetings (Zoom/Teams/Meet) with transcript and AI summary — distinct |---------|-------------| | `conversations search [--query TEXT]` | Search recorded conversations | | `conversations get ID` | Get a conversation with transcript and AI summary | +| `conversations transcript ID` | Print just the transcript | + +### custom-fields + +| Command | Description | +|---------|-------------| +| `custom-fields list [--modality contact\|account\|opportunity]` | List custom field definitions | ### emails diff --git a/src/apollo_cli/util.py b/src/apollo_cli/util.py index 14314dd..c3ced1a 100644 --- a/src/apollo_cli/util.py +++ b/src/apollo_cli/util.py @@ -2,6 +2,28 @@ from __future__ import annotations +from typing import Any + + +def _field(item: Any, key: str) -> Any: + """Read ``key`` from either a Pydantic model (attr) or a dict.""" + return item.get(key) if isinstance(item, dict) else getattr(item, key, None) + + +def resolve_stage_id(name: str, stages: list[Any], *, kind: str = "stage") -> str: + """Resolve a stage *name* (case-insensitive) to its ID from a list of stages. + + ``stages`` items may be Pydantic models or dicts exposing ``name`` and ``id``. + Raises ``ValueError`` (surfaced by the CLI as a validation error) listing the + available names when there is no match. + """ + target = name.strip().lower() + match = next((s for s in stages if (_field(s, "name") or "").lower() == target), None) + if match is None: + available = ", ".join(sorted(n for s in stages if (n := _field(s, "name")))) + raise ValueError(f"No {kind} named {name!r}. Available: {available or '(none)'}") + return _field(match, "id") + def parse_comma_list(raw: str) -> list[str]: """Parse a comma-separated CLI argument into a list of stripped, non-empty tokens. diff --git a/tests/test_commands.py b/tests/test_commands.py index b7f2b59..3c5e3d5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -350,6 +350,167 @@ async def test_conversations_get(self, sample_conversation: dict, capsys) -> Non assert data["topic"] == "Acme <> QoDev discovery call" +class TestPeopleCommands: + @pytest.mark.asyncio + async def test_people_search_domain_and_pagination(self, capsys) -> None: + """people search forwards --organization-domains and the global --limit/--page.""" + mock_client = MagicMock() + mock_client.search_people = AsyncMock( + return_value={"people": [{"id": "p1", "name": "Jane"}], "pagination": {"total_entries": 1}} + ) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=50, page=2) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.people import search + + await search(organization_domains="acme.com, globex.com", seniorities="vp,director") + + kwargs = mock_client.search_people.call_args.kwargs + assert kwargs["q_organization_domains_list"] == ["acme.com", "globex.com"] + assert kwargs["person_seniorities"] == ["vp", "director"] + assert kwargs["per_page"] == 50 + assert kwargs["page"] == 2 + data = json.loads(capsys.readouterr().out) + assert data["items"][0]["name"] == "Jane" + assert data["total"] == 1 + + +class TestStageNameFilter: + @pytest.mark.asyncio + async def test_deals_search_stage_name_resolves(self, sample_deal: dict, capsys) -> None: + """deals search --stage-name resolves the name to an opportunity_stage_ids filter.""" + mock_client = MagicMock() + mock_client.list_all_stages = AsyncMock( + return_value=MockSearchResult( + items=[{"id": "st-neg", "name": "Negotiation"}, {"id": "st-won", "name": "Won"}], total=2, page=1 + ) + ) + mock_client.search_deals = AsyncMock(return_value=MockSearchResult(items=[sample_deal], total=1, page=1)) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.deals import search + + await search(stage_name="negotiation") # case-insensitive + + assert mock_client.search_deals.call_args.kwargs["opportunity_stage_ids"] == ["st-neg"] + + @pytest.mark.asyncio + async def test_deals_search_unknown_stage_name_errors(self, capsys) -> None: + """An unknown --stage-name is a validation error listing the available names.""" + mock_client = MagicMock() + mock_client.list_all_stages = AsyncMock( + return_value=MockSearchResult(items=[{"id": "st-won", "name": "Won"}], total=1, page=1) + ) + mock_client.search_deals = AsyncMock() + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.deals import search + + with pytest.raises(ValueError, match="No deal stage named 'Nope'"): + await search(stage_name="Nope") + + mock_client.search_deals.assert_not_called() + + +class TestDealRoleCommands: + @pytest.mark.asyncio + async def test_set_role_read_modify_write_primary(self, sample_deal: dict, capsys) -> None: + """set-role reads existing roles, adds the contact, and makes it the sole primary.""" + from qodev_apollo_api.models import Deal + + deal = Deal.model_validate( + { + "id": "d1", + "name": "Enterprise Deal", + "opportunity_contact_roles": [ + { + "id": "r1", + "contact_id": "c-old", + "is_primary": True, + "role": [{"opportunity_contact_role_type_id": "rt-x"}], + }, + ], + } + ) + mock_client = MagicMock() + mock_client.get_deal = AsyncMock(return_value=deal) + mock_client.update_opportunity_roles = AsyncMock(return_value=deal) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.deals import set_role + + await set_role("d1", contact_id="c-new", primary=True) + + opp_id, roles = mock_client.update_opportunity_roles.call_args.args + assert opp_id == "d1" + by_contact = {r["contact_id"]: r for r in roles} + assert by_contact["c-new"]["is_primary"] is True + assert by_contact["c-old"]["is_primary"] is False # demoted + assert by_contact["c-old"]["opportunity_contact_role_type_id"] == "rt-x" # preserved + + +class TestCustomFieldsCommand: + @pytest.mark.asyncio + async def test_custom_fields_list_filters_by_modality(self, capsys) -> None: + """custom-fields list --modality filters the returned definitions client-side.""" + from qodev_apollo_api.models import CustomField + + fields = [ + CustomField.model_validate({"id": "f1", "modality": "contact", "name": "First Message", "type": "date"}), + CustomField.model_validate({"id": "f2", "modality": "opportunity", "name": "Region", "type": "text"}), + ] + mock_client = MagicMock() + mock_client.list_custom_fields = AsyncMock(return_value=fields) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.custom_fields import list_fields + + await list_fields(modality="opportunity") + + data = json.loads(capsys.readouterr().out) + assert len(data["items"]) == 1 + assert data["items"][0]["name"] == "Region" + + +class TestConversationTranscript: + @pytest.mark.asyncio + async def test_conversations_transcript_json(self, capsys) -> None: + """conversations transcript emits just the transcript segments in JSON mode.""" + from qodev_apollo_api.models import ConversationDetail + + detail = ConversationDetail.model_validate( + { + "id": "c1", + "topic": "Sync", + "transcript": [ + {"id": "t1", "participant_name": "Jane", "spoken_sentence": "Hi."}, + {"id": "t2", "participant_name": "John", "spoken_sentence": "Hello."}, + ], + } + ) + mock_client = MagicMock() + mock_client.get_conversation = AsyncMock(return_value=detail) + + _ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1) + + with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)): + from apollo_cli.commands.conversations import transcript + + await transcript(id="c1") + + data = json.loads(capsys.readouterr().out) + assert [seg["spoken_sentence"] for seg in data] == ["Hi.", "Hello."] + + class TestUsageCommand: @pytest.mark.asyncio async def test_usage_json(self, sample_usage: dict, capsys) -> None: diff --git a/uv.lock b/uv.lock index becb682..1665eee 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "qodev-apollo-api" -version = "0.2.2" +version = "0.3.0" source = { directory = "../apollo-api" } dependencies = [ { name = "httpx" }, @@ -494,7 +494,7 @@ provides-extras = ["dev"] [[package]] name = "qodev-apollo-cli" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "cyclopts" },