Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,31 +57,35 @@ $ 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`) |
| | `upsert-by-linkedin` | Get or create a contact by LinkedIn URL (`--name`, `--title`, `--stage-id`) |
| | `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 |
| **stages** | `list` | List all contact stages |
| | `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`) |
| | `create` | Create a task (`--contact-ids`, `--note`, `--due-at`) |
| **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`) |
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions src/apollo_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -52,6 +53,7 @@
tasks_app,
calls_app,
conversations_app,
custom_fields_app,
emails_app,
news_app,
jobs_app,
Expand Down
13 changes: 12 additions & 1 deletion src/apollo_cli/commands/contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand All @@ -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."""
Expand All @@ -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(
Expand Down
23 changes: 21 additions & 2 deletions src/apollo_cli/commands/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).")

Expand Down Expand Up @@ -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))
48 changes: 48 additions & 0 deletions src/apollo_cli/commands/custom_fields.py
Original file line number Diff line number Diff line change
@@ -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",
)
116 changes: 113 additions & 3 deletions src/apollo_cli/commands/deals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand All @@ -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(
Expand All @@ -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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] deals set-role adds a new entry with opportunity_contact_role_type_id: None when --role-type isn’t provided.

Fix: Confirm qodev-apollo-api / Apollo accepts roles without a role type. If not, either require --role-type for new roles, or omit the key entirely until it’s set (so we don’t send an explicit null).

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)
Loading
Loading