qodev-apollo-cli 0.1.0__py3-none-any.whl

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 (37) hide show
  1. apollo_cli/__init__.py +3 -0
  2. apollo_cli/__main__.py +5 -0
  3. apollo_cli/app.py +120 -0
  4. apollo_cli/commands/__init__.py +0 -0
  5. apollo_cli/commands/accounts.py +51 -0
  6. apollo_cli/commands/calls.py +54 -0
  7. apollo_cli/commands/contacts.py +147 -0
  8. apollo_cli/commands/deals.py +51 -0
  9. apollo_cli/commands/emails.py +37 -0
  10. apollo_cli/commands/enrich.py +34 -0
  11. apollo_cli/commands/install.py +59 -0
  12. apollo_cli/commands/jobs.py +36 -0
  13. apollo_cli/commands/news.py +35 -0
  14. apollo_cli/commands/notes.py +68 -0
  15. apollo_cli/commands/people.py +34 -0
  16. apollo_cli/commands/pipelines.py +106 -0
  17. apollo_cli/commands/tasks.py +79 -0
  18. apollo_cli/commands/usage.py +56 -0
  19. apollo_cli/context.py +35 -0
  20. apollo_cli/formatters/__init__.py +0 -0
  21. apollo_cli/formatters/accounts.py +62 -0
  22. apollo_cli/formatters/contacts.py +80 -0
  23. apollo_cli/formatters/deals.py +46 -0
  24. apollo_cli/formatters/generic.py +101 -0
  25. apollo_cli/help_reference.py +123 -0
  26. apollo_cli/output.py +150 -0
  27. apollo_cli/skills/SKILL.md +200 -0
  28. apollo_cli/skills/__init__.py +1 -0
  29. apollo_cli/skills/references/__init__.py +1 -0
  30. apollo_cli/skills/references/account-workflows.md +202 -0
  31. apollo_cli/skills/references/contact-workflows.md +110 -0
  32. apollo_cli/skills/references/deal-workflows.md +142 -0
  33. qodev_apollo_cli-0.1.0.dist-info/METADATA +224 -0
  34. qodev_apollo_cli-0.1.0.dist-info/RECORD +37 -0
  35. qodev_apollo_cli-0.1.0.dist-info/WHEEL +4 -0
  36. qodev_apollo_cli-0.1.0.dist-info/entry_points.txt +2 -0
  37. qodev_apollo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,68 @@
1
+ """Notes command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import App, Parameter
8
+
9
+ from apollo_cli.context import ctx
10
+ from apollo_cli.formatters.generic import list_table
11
+ from apollo_cli.output import output, output_list
12
+
13
+ notes_app = App(name="notes", help="Notes management.")
14
+
15
+ NOTE_LIST_COLUMNS = [
16
+ ("ID", "id"),
17
+ ("Content", "content"),
18
+ ("Contact ID", "contact_id"),
19
+ ("Account ID", "account_id"),
20
+ ("Created", "created_at"),
21
+ ]
22
+
23
+
24
+ @notes_app.command
25
+ async def search(
26
+ *,
27
+ contact_id: Annotated[str | None, Parameter(name="--contact-id", help="Filter by contact ID")] = None,
28
+ account_id: Annotated[str | None, Parameter(name="--account-id", help="Filter by account ID")] = None,
29
+ ) -> None:
30
+ """Search notes by contact or account."""
31
+ filters: dict = {}
32
+ if contact_id:
33
+ filters["contact_ids"] = [contact_id]
34
+ if account_id:
35
+ filters["account_ids"] = [account_id]
36
+
37
+ async with ctx.client() as client:
38
+ result = await client.search_notes(page=ctx.page, limit=ctx.limit, **filters)
39
+
40
+ output_list(
41
+ items=result.items,
42
+ total=result.total,
43
+ page=result.page,
44
+ limit=ctx.limit,
45
+ ctx=ctx,
46
+ format_fn=lambda items, **kw: list_table(items, NOTE_LIST_COLUMNS, title="Notes", **kw),
47
+ resource_name="Notes",
48
+ )
49
+
50
+
51
+ @notes_app.command
52
+ async def create(
53
+ *,
54
+ content: Annotated[str, Parameter(name="--content", help="Note content")],
55
+ contact_ids: Annotated[str | None, Parameter(name="--contact-ids", help="Comma-separated contact IDs")] = None,
56
+ account_ids: Annotated[str | None, Parameter(name="--account-ids", help="Comma-separated account IDs")] = None,
57
+ ) -> None:
58
+ """Create a new note."""
59
+ kwargs: dict = {}
60
+ if contact_ids:
61
+ kwargs["contact_ids"] = [cid.strip() for cid in contact_ids.split(",")]
62
+ if account_ids:
63
+ kwargs["account_ids"] = [aid.strip() for aid in account_ids.split(",")]
64
+
65
+ async with ctx.client() as client:
66
+ result = await client.create_note(content, **kwargs)
67
+
68
+ output(result, ctx=ctx)
@@ -0,0 +1,34 @@
1
+ """People database search command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import App, Parameter
8
+
9
+ from apollo_cli.context import ctx
10
+ from apollo_cli.output import output
11
+
12
+ people_app = App(name="people", help="People database search.")
13
+
14
+
15
+ @people_app.command
16
+ async def search(
17
+ *,
18
+ keywords: Annotated[str | None, Parameter(name="--keywords", help="Search keywords")] = None,
19
+ titles: Annotated[str | None, Parameter(name="--titles", help="Comma-separated job titles")] = None,
20
+ locations: Annotated[str | None, Parameter(name="--locations", help="Comma-separated locations")] = None,
21
+ ) -> None:
22
+ """Search Apollo's global people database."""
23
+ filters: dict = {}
24
+ if keywords:
25
+ filters["q_keywords"] = keywords
26
+ if titles:
27
+ filters["person_titles"] = [t.strip() for t in titles.split(",")]
28
+ if locations:
29
+ filters["person_locations"] = [loc.strip() for loc in locations.split(",")]
30
+
31
+ async with ctx.client() as client:
32
+ result = await client.search_people(**filters)
33
+
34
+ output(result, ctx=ctx)
@@ -0,0 +1,106 @@
1
+ """Pipelines and stages command groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import App, Parameter
8
+
9
+ from apollo_cli.context import ctx
10
+ from apollo_cli.formatters.generic import detail_table, list_table
11
+ from apollo_cli.output import output, output_list
12
+
13
+ pipelines_app = App(name="pipelines", help="Pipeline management.")
14
+ stages_app = App(name="stages", help="Stage management.")
15
+
16
+ PIPELINE_LIST_COLUMNS = [
17
+ ("ID", "id"),
18
+ ("Title", "title"),
19
+ ("Default", "default_pipeline"),
20
+ ("Order", "display_order"),
21
+ ]
22
+
23
+ PIPELINE_DETAIL_FIELDS = [
24
+ ("ID", "id"),
25
+ ("Title", "title"),
26
+ ("Default", "default_pipeline"),
27
+ ("Display Order", "display_order"),
28
+ ("Source", "source"),
29
+ ]
30
+
31
+ STAGE_LIST_COLUMNS = [
32
+ ("ID", "id"),
33
+ ("Name", "name"),
34
+ ("Pipeline ID", "opportunity_pipeline_id"),
35
+ ("Order", "display_order"),
36
+ ("Probability", "probability"),
37
+ ("Type", "type"),
38
+ ]
39
+
40
+
41
+ @pipelines_app.command(name="list")
42
+ async def list_pipelines() -> None:
43
+ """List all pipelines."""
44
+ async with ctx.client() as client:
45
+ result = await client.list_pipelines(page=ctx.page, limit=ctx.limit)
46
+
47
+ output_list(
48
+ items=result.items,
49
+ total=result.total,
50
+ page=result.page,
51
+ limit=ctx.limit,
52
+ ctx=ctx,
53
+ format_fn=lambda items, **kw: list_table(items, PIPELINE_LIST_COLUMNS, title="Pipelines", **kw),
54
+ resource_name="Pipelines",
55
+ )
56
+
57
+
58
+ @pipelines_app.command
59
+ async def get(
60
+ id: Annotated[str, Parameter(help="Pipeline ID")],
61
+ ) -> None:
62
+ """Get pipeline details by ID."""
63
+ async with ctx.client() as client:
64
+ pipeline = await client.get_pipeline(id)
65
+
66
+ output(
67
+ pipeline,
68
+ ctx=ctx,
69
+ format_fn=lambda d: detail_table(d, PIPELINE_DETAIL_FIELDS, title=f"Pipeline: {d.title or d.id}"),
70
+ )
71
+
72
+
73
+ @pipelines_app.command(name="stages")
74
+ async def pipeline_stages(
75
+ pipeline_id: Annotated[str, Parameter(help="Pipeline ID")],
76
+ ) -> None:
77
+ """List stages for a specific pipeline."""
78
+ async with ctx.client() as client:
79
+ result = await client.list_pipeline_stages(pipeline_id, page=ctx.page, limit=ctx.limit)
80
+
81
+ output_list(
82
+ items=result.items,
83
+ total=result.total,
84
+ page=result.page,
85
+ limit=ctx.limit,
86
+ ctx=ctx,
87
+ format_fn=lambda items, **kw: list_table(items, STAGE_LIST_COLUMNS, title="Pipeline Stages", **kw),
88
+ resource_name="Stages",
89
+ )
90
+
91
+
92
+ @stages_app.command(name="list")
93
+ async def list_all_stages() -> None:
94
+ """List all stages across all pipelines."""
95
+ async with ctx.client() as client:
96
+ result = await client.list_all_stages()
97
+
98
+ output_list(
99
+ items=result.items,
100
+ total=result.total,
101
+ page=result.page,
102
+ limit=ctx.limit,
103
+ ctx=ctx,
104
+ format_fn=lambda items, **kw: list_table(items, STAGE_LIST_COLUMNS, title="All Stages", **kw),
105
+ resource_name="Stages",
106
+ )
@@ -0,0 +1,79 @@
1
+ """Tasks command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from cyclopts import App, Parameter
8
+
9
+ from apollo_cli.context import ctx
10
+ from apollo_cli.formatters.generic import list_table
11
+ from apollo_cli.output import output, output_list
12
+
13
+ tasks_app = App(name="tasks", help="Task management.")
14
+
15
+ TASK_LIST_COLUMNS = [
16
+ ("ID", "id"),
17
+ ("Subject", "subject"),
18
+ ("Type", "type"),
19
+ ("Priority", "priority"),
20
+ ("Status", "status"),
21
+ ("Due", "due_at"),
22
+ ]
23
+
24
+
25
+ @tasks_app.command
26
+ async def search(
27
+ *,
28
+ type: Annotated[str | None, Parameter(name="--type", help="Filter by task type (call, action_item, etc.)")] = None,
29
+ contact_id: Annotated[str | None, Parameter(name="--contact-id", help="Filter by contact ID")] = None,
30
+ ) -> None:
31
+ """Search tasks."""
32
+ filters: dict = {}
33
+ if type:
34
+ filters["task_type_cds"] = [type]
35
+ if contact_id:
36
+ filters["contact_ids"] = [contact_id]
37
+
38
+ async with ctx.client() as client:
39
+ result = await client.search_tasks(page=ctx.page, limit=ctx.limit, **filters)
40
+
41
+ output_list(
42
+ items=result.items,
43
+ total=result.total,
44
+ page=result.page,
45
+ limit=ctx.limit,
46
+ ctx=ctx,
47
+ format_fn=lambda items, **kw: list_table(items, TASK_LIST_COLUMNS, title="Tasks", **kw),
48
+ resource_name="Tasks",
49
+ )
50
+
51
+
52
+ @tasks_app.command
53
+ async def create(
54
+ *,
55
+ contact_ids: Annotated[str, Parameter(name="--contact-ids", help="Comma-separated contact IDs")],
56
+ note: Annotated[str, Parameter(name="--note", help="Task description")],
57
+ type: Annotated[str, Parameter(name="--type", help="Task type")] = "action_item",
58
+ priority: Annotated[str, Parameter(name="--priority", help="Priority (high, medium, low)")] = "medium",
59
+ ) -> None:
60
+ """Create a new task."""
61
+ ids = [cid.strip() for cid in contact_ids.split(",")]
62
+
63
+ async with ctx.client() as client:
64
+ result = await client.create_task(contact_ids=ids, note=note, type=type, priority=priority)
65
+
66
+ output(result, ctx=ctx)
67
+
68
+
69
+ @tasks_app.command
70
+ async def complete(
71
+ id: Annotated[str, Parameter(help="Task ID")],
72
+ *,
73
+ note: Annotated[str | None, Parameter(name="--note", help="Completion note")] = None,
74
+ ) -> None:
75
+ """Mark a task as completed."""
76
+ async with ctx.client() as client:
77
+ result = await client.complete_task(id, note=note)
78
+
79
+ output(result, ctx=ctx)
@@ -0,0 +1,56 @@
1
+ """Usage stats command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cyclopts import App
6
+
7
+ from apollo_cli.context import ctx
8
+ from apollo_cli.output import output_json, output_markdown
9
+
10
+ usage_app = App(name="usage", help="API usage statistics.")
11
+
12
+
13
+ @usage_app.default
14
+ async def usage() -> None:
15
+ """Show API usage statistics and rate limits."""
16
+ async with ctx.client() as client:
17
+ data = await client.get_api_usage()
18
+ rate = client.rate_limit_status
19
+
20
+ if ctx.json_mode:
21
+ output_json({"usage": data, "rate_limits": rate})
22
+ else:
23
+ md = _format_usage(data, rate)
24
+ output_markdown(md)
25
+
26
+
27
+ def _format_usage(data: dict, rate: dict) -> str:
28
+ lines = ["# API Usage"]
29
+
30
+ if rate:
31
+ lines.append("")
32
+ lines.append("## Rate Limits")
33
+ lines.append("")
34
+ lines.append("| Window | Limit | Remaining |")
35
+ lines.append("|--------|-------|-----------|")
36
+ if "hourly_limit" in rate:
37
+ lines.append(f"| Hourly | {rate.get('hourly_limit', '?')} | {rate.get('hourly_left', '?')} |")
38
+ if "minute_limit" in rate:
39
+ lines.append(f"| Minute | {rate.get('minute_limit', '?')} | {rate.get('minute_left', '?')} |")
40
+ if "daily_limit" in rate:
41
+ lines.append(f"| Daily | {rate.get('daily_limit', '?')} | {rate.get('daily_left', '?')} |")
42
+
43
+ if data:
44
+ lines.append("")
45
+ lines.append("## Endpoint Usage")
46
+ lines.append("")
47
+ lines.append("| Endpoint | Day Consumed | Day Left | Hour Consumed | Hour Left |")
48
+ lines.append("|----------|-------------|----------|--------------|-----------|")
49
+ for endpoint, stats in data.items():
50
+ day_consumed = stats.get("day", {}).get("consumed", "?")
51
+ day_left = stats.get("day", {}).get("left_over", "?")
52
+ hour_consumed = stats.get("hour", {}).get("consumed", "?")
53
+ hour_left = stats.get("hour", {}).get("left_over", "?")
54
+ lines.append(f"| {endpoint} | {day_consumed} | {day_left} | {hour_consumed} | {hour_left} |")
55
+
56
+ return "\n".join(lines)
apollo_cli/context.py ADDED
@@ -0,0 +1,35 @@
1
+ """Global context shared across all commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from qodev_apollo_api import ApolloClient
8
+
9
+
10
+ @dataclass
11
+ class Context:
12
+ """Shared state passed from the meta launcher to every command."""
13
+
14
+ json_mode: bool = False
15
+ api_key: str | None = None
16
+ limit: int = 25
17
+ page: int = 1
18
+
19
+ def client(self) -> ApolloClient:
20
+ """Create a new ApolloClient with the configured API key."""
21
+ kwargs: dict = {}
22
+ if self.api_key:
23
+ kwargs["api_key"] = self.api_key
24
+ return ApolloClient(**kwargs)
25
+
26
+ def configure(self, *, json_mode: bool, api_key: str | None, limit: int, page: int) -> None:
27
+ """Update context fields in place (preserves references held by command modules)."""
28
+ self.json_mode = json_mode
29
+ self.api_key = api_key
30
+ self.limit = limit
31
+ self.page = page
32
+
33
+
34
+ # Module-level singleton — updated in place by the meta launcher.
35
+ ctx = Context()
File without changes
@@ -0,0 +1,62 @@
1
+ """Account-specific formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from apollo_cli.formatters.generic import detail_table, list_table
8
+
9
+ ACCOUNT_DETAIL_FIELDS = [
10
+ ("ID", "id"),
11
+ ("Name", "name"),
12
+ ("Domain", "domain"),
13
+ ("Phone", "phone"),
14
+ ("Industry", "industry"),
15
+ ("Employees", "estimated_num_employees"),
16
+ ("Revenue", "annual_revenue_printed"),
17
+ ("LinkedIn", "linkedin_url"),
18
+ ("Website", "website_url"),
19
+ ("City", "city"),
20
+ ("State", "state"),
21
+ ("Country", "country"),
22
+ ("Stage ID", "account_stage_id"),
23
+ ("Owner ID", "owner_id"),
24
+ ("Contacts", "num_contacts"),
25
+ ("Founded", "founded_year"),
26
+ ("Description", "short_description"),
27
+ ("Created", "created_at"),
28
+ ("Last Activity", "last_activity_date"),
29
+ ]
30
+
31
+ ACCOUNT_LIST_COLUMNS = [
32
+ ("Name", "name"),
33
+ ("Domain", "domain"),
34
+ ("Industry", "industry"),
35
+ ("Employees", "estimated_num_employees"),
36
+ ("City", "city"),
37
+ ]
38
+
39
+
40
+ def format_account_detail(data: Any) -> str:
41
+ """Format a single account as a markdown detail view."""
42
+ name = data.name if hasattr(data, "name") else data.get("name", "Unknown")
43
+ md = detail_table(data, ACCOUNT_DETAIL_FIELDS, title=f"Account: {name}")
44
+
45
+ # Technology stack (detail endpoint)
46
+ tech = getattr(data, "technology_names", None) or (data.get("technology_names") if isinstance(data, dict) else None)
47
+ if tech:
48
+ md += "\n\n## Technology Stack\n"
49
+ md += "\n" + ", ".join(tech)
50
+
51
+ # Keywords
52
+ keywords = getattr(data, "keywords", None) or (data.get("keywords") if isinstance(data, dict) else None)
53
+ if keywords:
54
+ md += "\n\n## Keywords\n"
55
+ md += "\n" + ", ".join(keywords)
56
+
57
+ return md
58
+
59
+
60
+ def format_account_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
61
+ """Format a list of accounts as a markdown table."""
62
+ return list_table(items, ACCOUNT_LIST_COLUMNS, title="Accounts", total=total, page=page)
@@ -0,0 +1,80 @@
1
+ """Contact-specific formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from apollo_cli.formatters.generic import detail_table, list_table
8
+
9
+ CONTACT_DETAIL_FIELDS = [
10
+ ("ID", "id"),
11
+ ("Name", "name"),
12
+ ("Email", "email"),
13
+ ("Title", "title"),
14
+ ("Company", "organization_name"),
15
+ ("LinkedIn", "linkedin_url"),
16
+ ("Phone", "sanitized_phone"),
17
+ ("Stage ID", "contact_stage_id"),
18
+ ("City", "city"),
19
+ ("State", "state"),
20
+ ("Country", "country"),
21
+ ("Source", "source"),
22
+ ("Owner ID", "owner_id"),
23
+ ("Created", "created_at"),
24
+ ("Updated", "updated_at"),
25
+ ("Last Activity", "last_activity_date"),
26
+ ]
27
+
28
+ CONTACT_LIST_COLUMNS = [
29
+ ("Name", "name"),
30
+ ("Title", "title"),
31
+ ("Company", "organization_name"),
32
+ ("Email", "email"),
33
+ ("LinkedIn", "linkedin_url"),
34
+ ]
35
+
36
+
37
+ def format_contact_detail(data: Any) -> str:
38
+ """Format a single contact as a markdown detail view."""
39
+ name = data.name if hasattr(data, "name") else data.get("name", "Unknown")
40
+ md = detail_table(data, CONTACT_DETAIL_FIELDS, title=f"Contact: {name}")
41
+
42
+ # Phone numbers section
43
+ phones = data.phone_numbers if hasattr(data, "phone_numbers") else data.get("phone_numbers", [])
44
+ if phones:
45
+ md += "\n\n## Phone Numbers\n"
46
+ for p in phones:
47
+ if hasattr(p, "sanitized_number"):
48
+ num = p.sanitized_number or p.number
49
+ ptype = p.type or "unknown"
50
+ else:
51
+ num = p.get("sanitized_number") or p.get("number", "")
52
+ ptype = p.get("type", "unknown")
53
+ md += f"\n- {num} ({ptype})"
54
+
55
+ # Employment history (detail endpoint only)
56
+ history = getattr(data, "employment_history", None)
57
+ if history:
58
+ md += "\n\n## Employment History\n"
59
+ for entry in history:
60
+ org = entry.organization_name or "Unknown"
61
+ title = entry.title or "Unknown"
62
+ current = " (current)" if entry.current else ""
63
+ md += f"\n- **{title}** at {org}{current}"
64
+
65
+ return md
66
+
67
+
68
+ def format_contact_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
69
+ """Format a list of contacts as a markdown table."""
70
+ return list_table(items, CONTACT_LIST_COLUMNS, title="Contacts", total=total, page=page)
71
+
72
+
73
+ def format_stages_list(stages: list[Any], *, total: int = 0, page: int = 1) -> str:
74
+ """Format contact stages."""
75
+ columns = [
76
+ ("ID", "id"),
77
+ ("Name", "name"),
78
+ ("Display Order", "display_order"),
79
+ ]
80
+ return list_table(stages, columns, title="Contact Stages", total=total, page=page)
@@ -0,0 +1,46 @@
1
+ """Deal-specific formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from apollo_cli.formatters.generic import detail_table, list_table
8
+
9
+ DEAL_DETAIL_FIELDS = [
10
+ ("ID", "id"),
11
+ ("Name", "name"),
12
+ ("Amount", "amount"),
13
+ ("Stage", "stage_name"),
14
+ ("Pipeline ID", "opportunity_pipeline_id"),
15
+ ("Probability", "probability"),
16
+ ("Close Date", "closed_date"),
17
+ ("Won", "is_won"),
18
+ ("Closed", "is_closed"),
19
+ ("Account ID", "account_id"),
20
+ ("Owner ID", "owner_id"),
21
+ ("Source", "source"),
22
+ ("Next Step", "next_step"),
23
+ ("Next Step Date", "next_step_date"),
24
+ ("Forecast Category", "forecast_category"),
25
+ ("Created", "created_at"),
26
+ ("Last Activity", "last_activity_date"),
27
+ ]
28
+
29
+ DEAL_LIST_COLUMNS = [
30
+ ("Name", "name"),
31
+ ("Amount", "amount"),
32
+ ("Stage", "stage_name"),
33
+ ("Close Date", "closed_date"),
34
+ ("Won", "is_won"),
35
+ ]
36
+
37
+
38
+ def format_deal_detail(data: Any) -> str:
39
+ """Format a single deal as a markdown detail view."""
40
+ name = data.name if hasattr(data, "name") else data.get("name", "Unknown")
41
+ return detail_table(data, DEAL_DETAIL_FIELDS, title=f"Deal: {name}")
42
+
43
+
44
+ def format_deal_list(items: list[Any], *, total: int = 0, page: int = 1) -> str:
45
+ """Format a list of deals as a markdown table."""
46
+ return list_table(items, DEAL_LIST_COLUMNS, title="Deals", total=total, page=page)
@@ -0,0 +1,101 @@
1
+ """Generic formatters for Pydantic models and dicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from apollo_cli.output import md_table
10
+
11
+
12
+ def detail_table(data: Any, fields: list[tuple[str, str]], *, title: str | None = None) -> str:
13
+ """Render a single record as a Field/Value markdown table.
14
+
15
+ Args:
16
+ data: Pydantic model or dict.
17
+ fields: List of (label, key) — key supports dot notation for nested access.
18
+ title: Optional heading above the table.
19
+ """
20
+ d = data.model_dump() if isinstance(data, BaseModel) else data
21
+
22
+ lines: list[str] = []
23
+ if title:
24
+ lines.append(f"# {title}")
25
+ lines.append("")
26
+
27
+ lines.append("| Field | Value |")
28
+ lines.append("|-------|-------|")
29
+ for label, key in fields:
30
+ value = _get(d, key)
31
+ if value is None or value == "" or value == []:
32
+ continue
33
+ lines.append(f"| {label} | {_fmt(value)} |")
34
+ return "\n".join(lines)
35
+
36
+
37
+ def list_table(
38
+ items: list[Any],
39
+ columns: list[tuple[str, str]],
40
+ *,
41
+ title: str = "Results",
42
+ total: int = 0,
43
+ page: int = 1,
44
+ ) -> str:
45
+ """Render a list of records as a markdown table.
46
+
47
+ Args:
48
+ items: List of Pydantic models or dicts.
49
+ columns: List of (header_label, key) tuples.
50
+ title: Heading text.
51
+ total: Total results count (for pagination header).
52
+ page: Current page number.
53
+ """
54
+ if not items:
55
+ return f"# {title}\n\n_No results found._"
56
+
57
+ header = f"# {title}"
58
+ if total:
59
+ header += f" (page {page}, {total} total)"
60
+
61
+ rows: list[dict[str, str]] = []
62
+ for item in items:
63
+ d = item.model_dump() if isinstance(item, BaseModel) else item
64
+ row = {}
65
+ for _, key in columns:
66
+ row[key] = _fmt(_get(d, key))
67
+ rows.append(row)
68
+
69
+ return f"{header}\n\n{md_table(rows, columns)}"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Helpers
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def _get(d: dict, key: str) -> Any:
78
+ """Get a value from a dict, supporting dot notation."""
79
+ parts = key.split(".")
80
+ current: Any = d
81
+ for part in parts:
82
+ if isinstance(current, dict):
83
+ current = current.get(part)
84
+ else:
85
+ return None
86
+ if current is None:
87
+ return None
88
+ return current
89
+
90
+
91
+ def _fmt(value: Any) -> str:
92
+ """Format a value for display in a markdown table cell."""
93
+ if value is None:
94
+ return ""
95
+ if isinstance(value, bool):
96
+ return "Yes" if value else "No"
97
+ if isinstance(value, list):
98
+ if not value:
99
+ return ""
100
+ return ", ".join(str(v) for v in value)
101
+ return str(value)