ontora-cli 0.1.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.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: ontora-cli
3
+ Version: 0.1.0
4
+ Summary: Ontora CLI — manage interview campaigns, transcripts, reports, and webhooks from the terminal.
5
+ Author: Ontora
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Documentation, https://docs.ontora.com/cli/install
8
+ Project-URL: API, https://docs.ontora.com/api-reference/introduction
9
+ Project-URL: Homepage, https://docs.ontora.com
10
+ Keywords: ontora,interviews,research,synthesis,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: typer>=0.12.0
21
+ Requires-Dist: httpx>=0.27.0
22
+ Requires-Dist: rich>=13.7.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: pydantic>=2.6.0
25
+
26
+ # Ontora CLI
27
+
28
+ Manage Ontora interview campaigns, transcripts, reports, and webhooks from the terminal.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install ontora-cli
34
+ # or, from this repo:
35
+ cd cli && pip install -e .
36
+ ```
37
+
38
+ ## Setup
39
+
40
+ ```bash
41
+ ontora auth login # Paste your API key (or set ONTORA_API_KEY)
42
+ ontora auth status # Verify connection
43
+ ```
44
+
45
+ Get an API key from the Ontora web app (Settings → API Keys) or via
46
+ `POST /v1/api-keys`. You'll also need to know your workspace ID.
47
+
48
+ ```bash
49
+ export ONTORA_WORKSPACE_ID=<uuid>
50
+ # or set per-command with --workspace
51
+ ```
52
+
53
+ ## Campaigns
54
+
55
+ ```bash
56
+ ontora campaigns list
57
+ ontora campaigns create --config campaign.yaml
58
+ ontora campaigns show <id>
59
+ ontora campaigns start <id>
60
+ ontora campaigns pause <id>
61
+ ```
62
+
63
+ ### Config-as-code (YAML)
64
+
65
+ ```yaml
66
+ name: "Q2 Procurement Discovery"
67
+ goal: "Map end-to-end procurement workflow to identify automation opportunities"
68
+ channel: in_app_chat
69
+ duration_minutes: 30
70
+ interview_context_for_employee: >
71
+ We're mapping how procurement works across the organization.
72
+ success_criteria: >
73
+ Complete process map with step-level pain points from every department.
74
+
75
+ topics:
76
+ - name: "Daily Workflow"
77
+ goal: "Understand the step-by-step procurement process"
78
+ priority: required
79
+ success_threshold: "Can describe process end-to-end with tools used"
80
+ sub_topics: ["intake", "approval", "vendor selection", "PO creation"]
81
+ questions:
82
+ - text: "Walk me through a typical purchase request"
83
+ required: true
84
+ - text: "What tools do you use at each step?"
85
+
86
+ contacts:
87
+ - name: "Jane Smith"
88
+ email: "jane@company.com"
89
+ department: "Procurement"
90
+ job_title: "Senior Buyer"
91
+ seniority_level: 4
92
+ # Or: { file: contacts.csv }
93
+ ```
94
+
95
+ ## Transcripts and reports
96
+
97
+ ```bash
98
+ ontora transcripts list <campaign-id>
99
+ ontora transcripts get <campaign-id> <conversation-id>
100
+ ontora transcripts export <campaign-id> [--output ./out] # ZIP of .md files
101
+ ontora reports show <campaign-id>
102
+ ontora reports export <campaign-id> [--output report.md]
103
+ ```
104
+
105
+ ## Graph RAG query
106
+
107
+ ```bash
108
+ ontora query <campaign-id> "What are the top pain points across procurement?"
109
+ ```
110
+
111
+ ## Webhooks
112
+
113
+ ```bash
114
+ ontora webhooks list
115
+ ontora webhooks add https://your-app.example/hook --events interview.completed,campaign.completed
116
+ ontora webhooks test <id>
117
+ ontora webhooks remove <id>
118
+ ```
@@ -0,0 +1,93 @@
1
+ # Ontora CLI
2
+
3
+ Manage Ontora interview campaigns, transcripts, reports, and webhooks from the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install ontora-cli
9
+ # or, from this repo:
10
+ cd cli && pip install -e .
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ ontora auth login # Paste your API key (or set ONTORA_API_KEY)
17
+ ontora auth status # Verify connection
18
+ ```
19
+
20
+ Get an API key from the Ontora web app (Settings → API Keys) or via
21
+ `POST /v1/api-keys`. You'll also need to know your workspace ID.
22
+
23
+ ```bash
24
+ export ONTORA_WORKSPACE_ID=<uuid>
25
+ # or set per-command with --workspace
26
+ ```
27
+
28
+ ## Campaigns
29
+
30
+ ```bash
31
+ ontora campaigns list
32
+ ontora campaigns create --config campaign.yaml
33
+ ontora campaigns show <id>
34
+ ontora campaigns start <id>
35
+ ontora campaigns pause <id>
36
+ ```
37
+
38
+ ### Config-as-code (YAML)
39
+
40
+ ```yaml
41
+ name: "Q2 Procurement Discovery"
42
+ goal: "Map end-to-end procurement workflow to identify automation opportunities"
43
+ channel: in_app_chat
44
+ duration_minutes: 30
45
+ interview_context_for_employee: >
46
+ We're mapping how procurement works across the organization.
47
+ success_criteria: >
48
+ Complete process map with step-level pain points from every department.
49
+
50
+ topics:
51
+ - name: "Daily Workflow"
52
+ goal: "Understand the step-by-step procurement process"
53
+ priority: required
54
+ success_threshold: "Can describe process end-to-end with tools used"
55
+ sub_topics: ["intake", "approval", "vendor selection", "PO creation"]
56
+ questions:
57
+ - text: "Walk me through a typical purchase request"
58
+ required: true
59
+ - text: "What tools do you use at each step?"
60
+
61
+ contacts:
62
+ - name: "Jane Smith"
63
+ email: "jane@company.com"
64
+ department: "Procurement"
65
+ job_title: "Senior Buyer"
66
+ seniority_level: 4
67
+ # Or: { file: contacts.csv }
68
+ ```
69
+
70
+ ## Transcripts and reports
71
+
72
+ ```bash
73
+ ontora transcripts list <campaign-id>
74
+ ontora transcripts get <campaign-id> <conversation-id>
75
+ ontora transcripts export <campaign-id> [--output ./out] # ZIP of .md files
76
+ ontora reports show <campaign-id>
77
+ ontora reports export <campaign-id> [--output report.md]
78
+ ```
79
+
80
+ ## Graph RAG query
81
+
82
+ ```bash
83
+ ontora query <campaign-id> "What are the top pain points across procurement?"
84
+ ```
85
+
86
+ ## Webhooks
87
+
88
+ ```bash
89
+ ontora webhooks list
90
+ ontora webhooks add https://your-app.example/hook --events interview.completed,campaign.completed
91
+ ontora webhooks test <id>
92
+ ontora webhooks remove <id>
93
+ ```
@@ -0,0 +1,3 @@
1
+ """Ontora CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,72 @@
1
+ """Thin httpx wrapper around the Ontora REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ontora_cli.config import CliConfig
11
+
12
+
13
+ class ApiError(Exception):
14
+ def __init__(self, status: int, detail: Any):
15
+ self.status = status
16
+ self.detail = detail
17
+ super().__init__(f"HTTP {status}: {detail}")
18
+
19
+
20
+ class OntoraClient:
21
+ def __init__(self, cfg: CliConfig, api_key: str):
22
+ self.cfg = cfg
23
+ self._client = httpx.Client(
24
+ base_url=cfg.base_url,
25
+ headers={"X-API-Key": api_key, "User-Agent": "ontora-cli/0.1.0"},
26
+ timeout=60.0,
27
+ follow_redirects=True,
28
+ )
29
+
30
+ def close(self) -> None:
31
+ self._client.close()
32
+
33
+ def __enter__(self) -> "OntoraClient":
34
+ return self
35
+
36
+ def __exit__(self, *_: Any) -> None:
37
+ self.close()
38
+
39
+ def _raise(self, r: httpx.Response) -> None:
40
+ if r.is_success:
41
+ return
42
+ try:
43
+ detail = r.json()
44
+ except json.JSONDecodeError:
45
+ detail = r.text
46
+ raise ApiError(r.status_code, detail)
47
+
48
+ # ------------------------------------------------------------------ HTTP
49
+ def get(self, path: str, params: dict | None = None) -> Any:
50
+ r = self._client.get(path, params=params)
51
+ self._raise(r)
52
+ return r.json() if r.content else None
53
+
54
+ def post(self, path: str, json_body: dict | None = None, params: dict | None = None) -> Any:
55
+ r = self._client.post(path, json=json_body, params=params)
56
+ self._raise(r)
57
+ return r.json() if r.content else None
58
+
59
+ def patch(self, path: str, json_body: dict) -> Any:
60
+ r = self._client.patch(path, json=json_body)
61
+ self._raise(r)
62
+ return r.json() if r.content else None
63
+
64
+ def delete(self, path: str) -> None:
65
+ r = self._client.delete(path)
66
+ self._raise(r)
67
+
68
+ def get_raw(self, path: str, params: dict | None = None) -> httpx.Response:
69
+ """Return the raw response (for downloading binary/markdown)."""
70
+ r = self._client.get(path, params=params)
71
+ self._raise(r)
72
+ return r
File without changes
@@ -0,0 +1,40 @@
1
+ """Auth commands: login, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+
8
+ from ontora_cli.client import ApiError, OntoraClient
9
+ from ontora_cli.config import load_config, save_config
10
+
11
+ app = typer.Typer(help="API key and connection management.")
12
+
13
+
14
+ @app.command()
15
+ def login(
16
+ api_key: str = typer.Option(..., "--api-key", "-k", prompt=True, hide_input=True, help="Your Ontora API key (ont_...)"),
17
+ base_url: str | None = typer.Option(None, "--base-url", help="Override API base URL."),
18
+ workspace: str | None = typer.Option(None, "--workspace", help="Default workspace ID."),
19
+ ) -> None:
20
+ """Save an API key + optional defaults to ~/.ontora/config.yaml."""
21
+ save_config(api_key=api_key, base_url=base_url, workspace_id=workspace)
22
+ rprint("[green]Saved credentials to ~/.ontora/config.yaml[/green]")
23
+
24
+
25
+ @app.command()
26
+ def status() -> None:
27
+ """Verify the API key works by calling the health endpoint."""
28
+ cfg = load_config()
29
+ if not cfg.api_key:
30
+ rprint("[red]No API key configured.[/red] Run `ontora auth login`.")
31
+ raise typer.Exit(1)
32
+ try:
33
+ with OntoraClient(cfg, cfg.api_key) as c:
34
+ c.get("/v1/health")
35
+ rprint(f"[green]Connected.[/green] base_url={cfg.base_url}")
36
+ if cfg.workspace_id:
37
+ rprint(f"Default workspace: {cfg.workspace_id}")
38
+ except ApiError as e:
39
+ rprint(f"[red]API error ({e.status}):[/red] {e.detail}")
40
+ raise typer.Exit(1)
@@ -0,0 +1,161 @@
1
+ """Campaign commands: list, create (from YAML), show, start, pause."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+ from rich import print as rprint
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+
14
+ from ontora_cli.client import ApiError, OntoraClient
15
+ from ontora_cli.config import load_config, require_api_key, require_workspace
16
+ from ontora_cli.formatters import print_campaigns
17
+ from ontora_cli.schemas import CampaignYaml
18
+
19
+ app = typer.Typer(help="Manage interview campaigns.")
20
+ console = Console()
21
+
22
+
23
+ @app.command("list")
24
+ def list_cmd(
25
+ workspace: str | None = typer.Option(None, "--workspace", "-w"),
26
+ status: str | None = typer.Option(None, "--status", help="Filter by draft/active/paused/completed"),
27
+ ) -> None:
28
+ cfg = load_config()
29
+ key = require_api_key(cfg)
30
+ ws = require_workspace(cfg, workspace)
31
+ params = {"workspace_id": ws}
32
+ if status:
33
+ params["status"] = status
34
+ with OntoraClient(cfg, key) as c:
35
+ data = c.get("/v1/interviews", params=params)
36
+ print_campaigns(data or [])
37
+
38
+
39
+ @app.command("show")
40
+ def show_cmd(interview_id: str) -> None:
41
+ cfg = load_config()
42
+ key = require_api_key(cfg)
43
+ with OntoraClient(cfg, key) as c:
44
+ data = c.get(f"/v1/interviews/{interview_id}/detail")
45
+ progress = None
46
+ try:
47
+ progress = c.get(f"/v1/interviews/{interview_id}/progress")
48
+ except ApiError:
49
+ pass
50
+ rprint(Panel.fit(f"[bold]{data['name']}[/bold]\n{data.get('goal') or ''}", title="Campaign"))
51
+ rprint(f"Status: [cyan]{data.get('status')}[/cyan] Channel: {data.get('channel')}")
52
+ if progress:
53
+ rprint(
54
+ f"Progress: {progress.get('completed', 0)}/{progress.get('total', 0)} "
55
+ f"({progress.get('completion_percent', 0):.1f}%)"
56
+ )
57
+ rprint(f"Topics: {len(data.get('question_sets') or [])} Contacts: {len(data.get('contacts') or [])}")
58
+
59
+
60
+ @app.command("create")
61
+ def create_cmd(
62
+ config_file: Path = typer.Option(..., "--config", "-c", exists=True, readable=True, help="Campaign YAML file"),
63
+ workspace: str | None = typer.Option(None, "--workspace", "-w"),
64
+ start: bool = typer.Option(False, "--start", help="Start the campaign immediately after creation"),
65
+ ) -> None:
66
+ cfg = load_config()
67
+ key = require_api_key(cfg)
68
+ ws = require_workspace(cfg, workspace)
69
+
70
+ with config_file.open() as f:
71
+ raw = yaml.safe_load(f)
72
+ spec = CampaignYaml.model_validate(raw)
73
+
74
+ with OntoraClient(cfg, key) as c:
75
+ # 1. Create interview shell
76
+ create_body = {
77
+ "workspace_id": ws,
78
+ "name": spec.name,
79
+ "goal": spec.goal,
80
+ "description": spec.description,
81
+ "channel": spec.channel,
82
+ "interview_duration_minutes": spec.duration_minutes,
83
+ "completion_threshold": spec.completion_threshold,
84
+ "interview_context_for_employee": spec.interview_context_for_employee,
85
+ "success_criteria": spec.success_criteria,
86
+ "agent_behavior": spec.agent_behavior,
87
+ "welcome_screen": spec.welcome_screen,
88
+ "closing_message": spec.closing_message,
89
+ "timezone": spec.timezone,
90
+ "interview_order": "seniority_high_first",
91
+ }
92
+ create_body = {k: v for k, v in create_body.items() if v is not None}
93
+ interview = c.post("/v1/interviews", json_body=create_body)
94
+ interview_id = interview["id"]
95
+ rprint(f"[green]Created campaign[/green] {interview_id}")
96
+
97
+ # 2. Question sets
98
+ for idx, topic in enumerate(spec.topics):
99
+ qset_body = {
100
+ "name": topic.name,
101
+ "description": topic.goal,
102
+ "sort_order": idx,
103
+ "topic_metadata": {
104
+ "priority": topic.priority,
105
+ "success_threshold": topic.success_threshold,
106
+ "sub_topics": topic.sub_topics,
107
+ },
108
+ "questions": [
109
+ {"question_text": q.text, "is_required": q.required, "sort_order": qi}
110
+ for qi, q in enumerate(topic.questions)
111
+ ],
112
+ }
113
+ c.post(f"/v1/interviews/{interview_id}/question-sets", json_body=qset_body)
114
+ if spec.topics:
115
+ rprint(f"[green]Added {len(spec.topics)} topic(s)[/green]")
116
+
117
+ # 3. Contacts — inline + CSV references
118
+ inline_contacts = []
119
+ csv_contacts = []
120
+ for contact in spec.contacts:
121
+ if contact.file:
122
+ csv_path = (config_file.parent / contact.file).resolve()
123
+ with open(csv_path) as f:
124
+ for row in csv.DictReader(f):
125
+ csv_contacts.append({k.strip(): v for k, v in row.items() if v})
126
+ else:
127
+ d = contact.model_dump(exclude_none=True, exclude={"file"})
128
+ if d:
129
+ inline_contacts.append(d)
130
+
131
+ all_contacts = inline_contacts + csv_contacts
132
+ if all_contacts:
133
+ c.post(
134
+ f"/v1/interviews/{interview_id}/contacts/bulk",
135
+ json_body={"contacts": all_contacts},
136
+ )
137
+ rprint(f"[green]Added {len(all_contacts)} contact(s)[/green]")
138
+
139
+ if start:
140
+ resp = c.post(f"/v1/interviews/{interview_id}/start")
141
+ rprint(f"[bold green]Started![/bold green] {resp.get('message', '')}")
142
+
143
+ rprint(f"\nView: {cfg.base_url.replace('api.', 'app.')}/interviews/{interview_id}")
144
+
145
+
146
+ @app.command("start")
147
+ def start_cmd(interview_id: str) -> None:
148
+ cfg = load_config()
149
+ key = require_api_key(cfg)
150
+ with OntoraClient(cfg, key) as c:
151
+ resp = c.post(f"/v1/interviews/{interview_id}/start")
152
+ rprint(f"[green]{resp.get('message', 'Started.')}[/green]")
153
+
154
+
155
+ @app.command("pause")
156
+ def pause_cmd(interview_id: str) -> None:
157
+ cfg = load_config()
158
+ key = require_api_key(cfg)
159
+ with OntoraClient(cfg, key) as c:
160
+ c.post(f"/v1/interviews/{interview_id}/pause")
161
+ rprint("[yellow]Paused.[/yellow]")
@@ -0,0 +1,64 @@
1
+ """Contact commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich import print as rprint
10
+
11
+ from ontora_cli.client import OntoraClient
12
+ from ontora_cli.config import load_config, require_api_key
13
+ from ontora_cli.formatters import print_contacts
14
+
15
+ app = typer.Typer(help="Contacts.")
16
+
17
+
18
+ @app.command("list")
19
+ def list_cmd(interview_id: str, status: str | None = typer.Option(None, "--status")) -> None:
20
+ cfg = load_config()
21
+ key = require_api_key(cfg)
22
+ params = {"status": status} if status else None
23
+ with OntoraClient(cfg, key) as c:
24
+ data = c.get(f"/v1/interviews/{interview_id}/contacts", params=params)
25
+ print_contacts(data or [])
26
+
27
+
28
+ @app.command("add")
29
+ def add_cmd(
30
+ interview_id: str,
31
+ name: str = typer.Option(..., "--name"),
32
+ email: str | None = typer.Option(None, "--email"),
33
+ phone: str | None = typer.Option(None, "--phone"),
34
+ job_title: str | None = typer.Option(None, "--job-title"),
35
+ department: str | None = typer.Option(None, "--department"),
36
+ seniority_level: int | None = typer.Option(None, "--seniority"),
37
+ ) -> None:
38
+ cfg = load_config()
39
+ key = require_api_key(cfg)
40
+ body = {
41
+ "name": name,
42
+ "email": email,
43
+ "phone": phone,
44
+ "job_title": job_title,
45
+ "department": department,
46
+ "seniority_level": seniority_level,
47
+ }
48
+ body = {k: v for k, v in body.items() if v is not None}
49
+ with OntoraClient(cfg, key) as c:
50
+ resp = c.post(f"/v1/interviews/{interview_id}/contacts", json_body=body)
51
+ rprint(f"[green]Added {resp.get('name')} ({resp.get('id')})[/green]")
52
+
53
+
54
+ @app.command("import")
55
+ def import_cmd(interview_id: str, csv_path: Path = typer.Argument(..., exists=True, readable=True)) -> None:
56
+ cfg = load_config()
57
+ key = require_api_key(cfg)
58
+ rows = []
59
+ with csv_path.open() as f:
60
+ for r in csv.DictReader(f):
61
+ rows.append({k.strip(): v for k, v in r.items() if v})
62
+ with OntoraClient(cfg, key) as c:
63
+ resp = c.post(f"/v1/interviews/{interview_id}/contacts/bulk", json_body={"contacts": rows})
64
+ rprint(f"[green]Imported {resp.get('created', len(rows))} contacts[/green]")
@@ -0,0 +1,41 @@
1
+ """Graph RAG query command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+ from rich.markdown import Markdown
8
+ from rich.panel import Panel
9
+
10
+ from ontora_cli.client import OntoraClient
11
+ from ontora_cli.config import load_config, require_api_key
12
+
13
+ app = typer.Typer()
14
+
15
+
16
+ @app.callback(invoke_without_command=True)
17
+ def query(
18
+ interview_id: str = typer.Argument(...),
19
+ question: str = typer.Argument(...),
20
+ ) -> None:
21
+ """Ask a natural-language question about a campaign's data."""
22
+ cfg = load_config()
23
+ key = require_api_key(cfg)
24
+ with OntoraClient(cfg, key) as c:
25
+ resp = c.post(
26
+ f"/v1/interviews/{interview_id}/graph/query",
27
+ json_body={"question": question},
28
+ )
29
+ answer = resp.get("answer") or ""
30
+ rprint(Panel(Markdown(answer), title="Answer", border_style="cyan"))
31
+
32
+ patterns = resp.get("patterns") or []
33
+ if patterns:
34
+ rprint("\n[bold]Patterns[/bold]")
35
+ for p in patterns:
36
+ rprint(f"- {p.get('text', p)}")
37
+ divergences = resp.get("divergences") or []
38
+ if divergences:
39
+ rprint("\n[bold]Divergences[/bold]")
40
+ for d in divergences:
41
+ rprint(f"- {d.get('text', d)}")
@@ -0,0 +1,39 @@
1
+ """Report / synthesis commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich import print as rprint
9
+ from rich.markdown import Markdown
10
+
11
+ from ontora_cli.client import OntoraClient
12
+ from ontora_cli.config import load_config, require_api_key
13
+
14
+ app = typer.Typer(help="Synthesis reports.")
15
+
16
+
17
+ @app.command("show")
18
+ def show_cmd(interview_id: str) -> None:
19
+ cfg = load_config()
20
+ key = require_api_key(cfg)
21
+ with OntoraClient(cfg, key) as c:
22
+ resp = c.get_raw(f"/v1/interviews/{interview_id}/export/summary")
23
+ rprint(Markdown(resp.text))
24
+
25
+
26
+ @app.command("export")
27
+ def export_cmd(
28
+ interview_id: str,
29
+ kind: str = typer.Option("report", "--kind", "-k", help="report | summary"),
30
+ output: Path | None = typer.Option(None, "--output", "-o"),
31
+ ) -> None:
32
+ cfg = load_config()
33
+ key = require_api_key(cfg)
34
+ endpoint = f"/v1/interviews/{interview_id}/export/{kind}"
35
+ with OntoraClient(cfg, key) as c:
36
+ resp = c.get_raw(endpoint)
37
+ out = output or Path(f"{interview_id[:8]}_{kind}.md")
38
+ out.write_text(resp.text)
39
+ rprint(f"[green]Wrote {out}[/green]")
@@ -0,0 +1,65 @@
1
+ """Transcript and report commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich import print as rprint
9
+ from rich.markdown import Markdown
10
+
11
+ from ontora_cli.client import OntoraClient
12
+ from ontora_cli.config import load_config, require_api_key
13
+ from ontora_cli.formatters import print_transcripts
14
+
15
+ app = typer.Typer(help="Transcripts.")
16
+
17
+
18
+ @app.command("list")
19
+ def list_cmd(interview_id: str) -> None:
20
+ cfg = load_config()
21
+ key = require_api_key(cfg)
22
+ with OntoraClient(cfg, key) as c:
23
+ data = c.get(f"/v1/interviews/{interview_id}/conversations")
24
+ # The endpoint may return {"conversations": [...]} or a list; handle both.
25
+ rows = data.get("conversations") if isinstance(data, dict) else data
26
+ print_transcripts(rows or [])
27
+
28
+
29
+ @app.command("get")
30
+ def get_cmd(
31
+ interview_id: str,
32
+ conversation_id: str,
33
+ format: str = typer.Option("md", "--format", "-f", help="md | json"),
34
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write to file instead of stdout"),
35
+ ) -> None:
36
+ cfg = load_config()
37
+ key = require_api_key(cfg)
38
+ with OntoraClient(cfg, key) as c:
39
+ if format == "json":
40
+ data = c.get(f"/v1/interviews/{interview_id}/conversations/{conversation_id}")
41
+ text = __import__("json").dumps(data, indent=2, default=str)
42
+ else:
43
+ resp = c.get_raw(f"/v1/interviews/{interview_id}/export/transcript/{conversation_id}")
44
+ text = resp.text
45
+ if output:
46
+ output.write_text(text)
47
+ rprint(f"[green]Wrote {output}[/green]")
48
+ else:
49
+ if format == "md":
50
+ rprint(Markdown(text))
51
+ else:
52
+ typer.echo(text)
53
+
54
+
55
+ @app.command("export")
56
+ def export_cmd(
57
+ interview_id: str,
58
+ output: Path = typer.Option(Path("./transcripts.zip"), "--output", "-o"),
59
+ ) -> None:
60
+ cfg = load_config()
61
+ key = require_api_key(cfg)
62
+ with OntoraClient(cfg, key) as c:
63
+ resp = c.get_raw(f"/v1/interviews/{interview_id}/export/transcripts")
64
+ output.write_bytes(resp.content)
65
+ rprint(f"[green]Saved ZIP to {output}[/green]")
@@ -0,0 +1,90 @@
1
+ """Webhook commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich import print as rprint
7
+
8
+ from ontora_cli.client import OntoraClient
9
+ from ontora_cli.config import load_config, require_api_key, require_workspace
10
+ from ontora_cli.formatters import print_webhooks
11
+
12
+ app = typer.Typer(help="Outbound webhook endpoints.")
13
+
14
+
15
+ @app.command("list")
16
+ def list_cmd(workspace: str | None = typer.Option(None, "--workspace", "-w")) -> None:
17
+ cfg = load_config()
18
+ key = require_api_key(cfg)
19
+ ws = require_workspace(cfg, workspace)
20
+ with OntoraClient(cfg, key) as c:
21
+ data = c.get("/v1/webhook-endpoints", params={"workspace_id": ws})
22
+ print_webhooks(data or [])
23
+
24
+
25
+ @app.command("add")
26
+ def add_cmd(
27
+ url: str = typer.Argument(...),
28
+ events: str = typer.Option("", "--events", help="Comma-separated event types. Empty = all."),
29
+ description: str | None = typer.Option(None, "--description"),
30
+ workspace: str | None = typer.Option(None, "--workspace", "-w"),
31
+ ) -> None:
32
+ cfg = load_config()
33
+ key = require_api_key(cfg)
34
+ ws = require_workspace(cfg, workspace)
35
+ event_list = [e.strip() for e in events.split(",") if e.strip()]
36
+ with OntoraClient(cfg, key) as c:
37
+ resp = c.post(
38
+ "/v1/webhook-endpoints",
39
+ json_body={
40
+ "workspace_id": ws,
41
+ "url": url,
42
+ "events": event_list,
43
+ "description": description,
44
+ },
45
+ )
46
+ rprint(f"[green]Created endpoint[/green] {resp['id']}")
47
+ rprint(f"[bold yellow]Signing secret (save now, not shown again):[/bold yellow] {resp['secret']}")
48
+
49
+
50
+ @app.command("test")
51
+ def test_cmd(endpoint_id: str) -> None:
52
+ cfg = load_config()
53
+ key = require_api_key(cfg)
54
+ with OntoraClient(cfg, key) as c:
55
+ resp = c.post(f"/v1/webhook-endpoints/{endpoint_id}/test")
56
+ rprint(f"[green]Enqueued {resp.get('deliveries_enqueued', 0)} test delivery(ies)[/green]")
57
+
58
+
59
+ @app.command("remove")
60
+ def remove_cmd(endpoint_id: str) -> None:
61
+ cfg = load_config()
62
+ key = require_api_key(cfg)
63
+ with OntoraClient(cfg, key) as c:
64
+ c.delete(f"/v1/webhook-endpoints/{endpoint_id}")
65
+ rprint("[yellow]Removed.[/yellow]")
66
+
67
+
68
+ @app.command("deliveries")
69
+ def deliveries_cmd(endpoint_id: str, limit: int = typer.Option(25, "--limit", "-n")) -> None:
70
+ cfg = load_config()
71
+ key = require_api_key(cfg)
72
+ with OntoraClient(cfg, key) as c:
73
+ data = c.get(f"/v1/webhook-endpoints/{endpoint_id}/deliveries", params={"limit": limit})
74
+ from rich.table import Table
75
+ from rich.console import Console
76
+ table = Table(title="Deliveries")
77
+ table.add_column("Event")
78
+ table.add_column("Status")
79
+ table.add_column("Attempts")
80
+ table.add_column("Response")
81
+ table.add_column("Created")
82
+ for d in data or []:
83
+ table.add_row(
84
+ d.get("event_type", ""),
85
+ str(d.get("status", "")),
86
+ str(d.get("attempts", 0)),
87
+ str(d.get("response_status") or "—"),
88
+ str(d.get("created_at", ""))[:19],
89
+ )
90
+ Console().print(table)
@@ -0,0 +1,72 @@
1
+ """CLI config: API key + base URL + workspace, loaded from env vars or `~/.ontora/config.yaml`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+ DEFAULT_BASE_URL = "https://api.ontora.com"
12
+ CONFIG_PATH = Path.home() / ".ontora" / "config.yaml"
13
+
14
+
15
+ @dataclass
16
+ class CliConfig:
17
+ api_key: str | None
18
+ base_url: str
19
+ workspace_id: str | None
20
+
21
+
22
+ def _load_file() -> dict:
23
+ if not CONFIG_PATH.exists():
24
+ return {}
25
+ try:
26
+ with CONFIG_PATH.open("r") as f:
27
+ data = yaml.safe_load(f) or {}
28
+ return data if isinstance(data, dict) else {}
29
+ except (OSError, yaml.YAMLError):
30
+ return {}
31
+
32
+
33
+ def load_config() -> CliConfig:
34
+ file_cfg = _load_file()
35
+ return CliConfig(
36
+ api_key=os.environ.get("ONTORA_API_KEY") or file_cfg.get("api_key"),
37
+ base_url=os.environ.get("ONTORA_BASE_URL") or file_cfg.get("base_url") or DEFAULT_BASE_URL,
38
+ workspace_id=os.environ.get("ONTORA_WORKSPACE_ID") or file_cfg.get("workspace_id"),
39
+ )
40
+
41
+
42
+ def save_config(*, api_key: str | None = None, base_url: str | None = None, workspace_id: str | None = None) -> None:
43
+ current = _load_file()
44
+ if api_key is not None:
45
+ current["api_key"] = api_key
46
+ if base_url is not None:
47
+ current["base_url"] = base_url
48
+ if workspace_id is not None:
49
+ current["workspace_id"] = workspace_id
50
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
51
+ with CONFIG_PATH.open("w") as f:
52
+ yaml.safe_dump(current, f)
53
+ # Restrict to owner-read (best-effort)
54
+ try:
55
+ CONFIG_PATH.chmod(0o600)
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def require_api_key(cfg: CliConfig) -> str:
61
+ if not cfg.api_key:
62
+ raise RuntimeError("No API key configured. Run `ontora auth login` or set ONTORA_API_KEY.")
63
+ return cfg.api_key
64
+
65
+
66
+ def require_workspace(cfg: CliConfig, cli_override: str | None) -> str:
67
+ ws = cli_override or cfg.workspace_id
68
+ if not ws:
69
+ raise RuntimeError(
70
+ "No workspace ID configured. Set ONTORA_WORKSPACE_ID or pass --workspace."
71
+ )
72
+ return ws
@@ -0,0 +1,84 @@
1
+ """Rendering helpers for rich output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ console = Console()
11
+
12
+
13
+ def print_campaigns(campaigns: list[dict[str, Any]]) -> None:
14
+ table = Table(title="Campaigns")
15
+ table.add_column("ID", style="dim")
16
+ table.add_column("Name")
17
+ table.add_column("Status")
18
+ table.add_column("Progress")
19
+ table.add_column("Updated")
20
+ for c in campaigns:
21
+ completed = c.get("completed_count", 0) or 0
22
+ total = c.get("contact_count", 0) or 0
23
+ prog = f"{completed}/{total}"
24
+ table.add_row(
25
+ str(c.get("id", ""))[:8],
26
+ c.get("name", ""),
27
+ str(c.get("status", "")),
28
+ prog,
29
+ str(c.get("updated_at", ""))[:16],
30
+ )
31
+ console.print(table)
32
+
33
+
34
+ def print_contacts(contacts: list[dict[str, Any]]) -> None:
35
+ table = Table(title="Contacts")
36
+ table.add_column("ID", style="dim")
37
+ table.add_column("Name")
38
+ table.add_column("Email")
39
+ table.add_column("Dept")
40
+ table.add_column("Status")
41
+ for c in contacts:
42
+ table.add_row(
43
+ str(c.get("id", ""))[:8],
44
+ c.get("name", ""),
45
+ c.get("email", "") or "",
46
+ c.get("department", "") or "",
47
+ str(c.get("status", "")),
48
+ )
49
+ console.print(table)
50
+
51
+
52
+ def print_transcripts(convs: list[dict[str, Any]]) -> None:
53
+ table = Table(title="Transcripts")
54
+ table.add_column("ID", style="dim")
55
+ table.add_column("Contact")
56
+ table.add_column("Status")
57
+ table.add_column("Duration")
58
+ table.add_column("Summary")
59
+ for c in convs:
60
+ table.add_row(
61
+ str(c.get("id", ""))[:8],
62
+ c.get("contact_name", "") or "",
63
+ str(c.get("status", "")),
64
+ f"{c.get('duration_minutes') or '—'} min",
65
+ (c.get("summary") or "")[:60],
66
+ )
67
+ console.print(table)
68
+
69
+
70
+ def print_webhooks(hooks: list[dict[str, Any]]) -> None:
71
+ table = Table(title="Webhooks")
72
+ table.add_column("ID", style="dim")
73
+ table.add_column("URL")
74
+ table.add_column("Events")
75
+ table.add_column("Active")
76
+ for h in hooks:
77
+ events = ", ".join(h.get("events") or []) or "*all*"
78
+ table.add_row(
79
+ str(h.get("id", ""))[:8],
80
+ h.get("url", ""),
81
+ events,
82
+ "yes" if h.get("active") else "no",
83
+ )
84
+ console.print(table)
@@ -0,0 +1,70 @@
1
+ """Ontora CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import httpx
8
+ import typer
9
+ from rich import print as rprint
10
+
11
+ from ontora_cli import __version__
12
+ from ontora_cli.client import ApiError
13
+ from ontora_cli.commands import auth, campaigns, contacts, query, reports, transcripts, webhooks
14
+
15
+ app = typer.Typer(
16
+ name="ontora",
17
+ help="Ontora CLI — manage interview campaigns, transcripts, and reports.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ app.add_typer(auth.app, name="auth")
22
+ app.add_typer(campaigns.app, name="campaigns")
23
+ app.add_typer(contacts.app, name="contacts")
24
+ app.add_typer(transcripts.app, name="transcripts")
25
+ app.add_typer(reports.app, name="reports")
26
+ app.add_typer(webhooks.app, name="webhooks")
27
+ app.add_typer(query.app, name="query")
28
+
29
+
30
+ def _version_callback(value: bool) -> None:
31
+ if value:
32
+ rprint(f"ontora-cli {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ @app.callback()
37
+ def root(
38
+ version: bool = typer.Option(
39
+ False,
40
+ "--version",
41
+ callback=_version_callback,
42
+ is_eager=True,
43
+ help="Print the CLI version and exit.",
44
+ ),
45
+ ) -> None:
46
+ """Manage interview campaigns, transcripts, and reports."""
47
+
48
+
49
+ @app.command()
50
+ def version() -> None:
51
+ """Print the CLI version."""
52
+ rprint(f"ontora-cli {__version__}")
53
+
54
+
55
+ def _main() -> None:
56
+ try:
57
+ app()
58
+ except ApiError as e:
59
+ rprint(f"[red]API error ({e.status}):[/red] {e.detail}")
60
+ sys.exit(1)
61
+ except httpx.RequestError as e:
62
+ rprint(f"[red]Network error:[/red] {e}")
63
+ sys.exit(1)
64
+ except RuntimeError as e:
65
+ rprint(f"[red]Error:[/red] {e}")
66
+ sys.exit(1)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ _main()
@@ -0,0 +1,46 @@
1
+ """YAML config schemas for campaign-as-code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class QuestionYaml(BaseModel):
9
+ text: str
10
+ required: bool = False
11
+
12
+
13
+ class TopicYaml(BaseModel):
14
+ name: str
15
+ goal: str | None = None
16
+ priority: str = "required" # required | important | optional
17
+ success_threshold: str | None = None
18
+ sub_topics: list[str] = Field(default_factory=list)
19
+ questions: list[QuestionYaml] = Field(default_factory=list)
20
+
21
+
22
+ class ContactYaml(BaseModel):
23
+ name: str | None = None
24
+ email: str | None = None
25
+ phone: str | None = None
26
+ job_title: str | None = None
27
+ department: str | None = None
28
+ seniority_level: int | None = None
29
+ file: str | None = None # Path to CSV for bulk contacts
30
+
31
+
32
+ class CampaignYaml(BaseModel):
33
+ name: str
34
+ goal: str
35
+ description: str | None = None
36
+ channel: str = "in_app_chat"
37
+ duration_minutes: int = 30
38
+ completion_threshold: float = 80.0
39
+ interview_context_for_employee: str | None = None
40
+ success_criteria: str | None = None
41
+ agent_behavior: str | None = None
42
+ welcome_screen: str | None = None
43
+ closing_message: str | None = None
44
+ timezone: str = "UTC"
45
+ topics: list[TopicYaml] = Field(default_factory=list)
46
+ contacts: list[ContactYaml] = Field(default_factory=list)
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: ontora-cli
3
+ Version: 0.1.0
4
+ Summary: Ontora CLI — manage interview campaigns, transcripts, reports, and webhooks from the terminal.
5
+ Author: Ontora
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Documentation, https://docs.ontora.com/cli/install
8
+ Project-URL: API, https://docs.ontora.com/api-reference/introduction
9
+ Project-URL: Homepage, https://docs.ontora.com
10
+ Keywords: ontora,interviews,research,synthesis,cli
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: typer>=0.12.0
21
+ Requires-Dist: httpx>=0.27.0
22
+ Requires-Dist: rich>=13.7.0
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: pydantic>=2.6.0
25
+
26
+ # Ontora CLI
27
+
28
+ Manage Ontora interview campaigns, transcripts, reports, and webhooks from the terminal.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install ontora-cli
34
+ # or, from this repo:
35
+ cd cli && pip install -e .
36
+ ```
37
+
38
+ ## Setup
39
+
40
+ ```bash
41
+ ontora auth login # Paste your API key (or set ONTORA_API_KEY)
42
+ ontora auth status # Verify connection
43
+ ```
44
+
45
+ Get an API key from the Ontora web app (Settings → API Keys) or via
46
+ `POST /v1/api-keys`. You'll also need to know your workspace ID.
47
+
48
+ ```bash
49
+ export ONTORA_WORKSPACE_ID=<uuid>
50
+ # or set per-command with --workspace
51
+ ```
52
+
53
+ ## Campaigns
54
+
55
+ ```bash
56
+ ontora campaigns list
57
+ ontora campaigns create --config campaign.yaml
58
+ ontora campaigns show <id>
59
+ ontora campaigns start <id>
60
+ ontora campaigns pause <id>
61
+ ```
62
+
63
+ ### Config-as-code (YAML)
64
+
65
+ ```yaml
66
+ name: "Q2 Procurement Discovery"
67
+ goal: "Map end-to-end procurement workflow to identify automation opportunities"
68
+ channel: in_app_chat
69
+ duration_minutes: 30
70
+ interview_context_for_employee: >
71
+ We're mapping how procurement works across the organization.
72
+ success_criteria: >
73
+ Complete process map with step-level pain points from every department.
74
+
75
+ topics:
76
+ - name: "Daily Workflow"
77
+ goal: "Understand the step-by-step procurement process"
78
+ priority: required
79
+ success_threshold: "Can describe process end-to-end with tools used"
80
+ sub_topics: ["intake", "approval", "vendor selection", "PO creation"]
81
+ questions:
82
+ - text: "Walk me through a typical purchase request"
83
+ required: true
84
+ - text: "What tools do you use at each step?"
85
+
86
+ contacts:
87
+ - name: "Jane Smith"
88
+ email: "jane@company.com"
89
+ department: "Procurement"
90
+ job_title: "Senior Buyer"
91
+ seniority_level: 4
92
+ # Or: { file: contacts.csv }
93
+ ```
94
+
95
+ ## Transcripts and reports
96
+
97
+ ```bash
98
+ ontora transcripts list <campaign-id>
99
+ ontora transcripts get <campaign-id> <conversation-id>
100
+ ontora transcripts export <campaign-id> [--output ./out] # ZIP of .md files
101
+ ontora reports show <campaign-id>
102
+ ontora reports export <campaign-id> [--output report.md]
103
+ ```
104
+
105
+ ## Graph RAG query
106
+
107
+ ```bash
108
+ ontora query <campaign-id> "What are the top pain points across procurement?"
109
+ ```
110
+
111
+ ## Webhooks
112
+
113
+ ```bash
114
+ ontora webhooks list
115
+ ontora webhooks add https://your-app.example/hook --events interview.completed,campaign.completed
116
+ ontora webhooks test <id>
117
+ ontora webhooks remove <id>
118
+ ```
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ ontora_cli/__init__.py
4
+ ontora_cli/client.py
5
+ ontora_cli/config.py
6
+ ontora_cli/formatters.py
7
+ ontora_cli/main.py
8
+ ontora_cli/schemas.py
9
+ ontora_cli.egg-info/PKG-INFO
10
+ ontora_cli.egg-info/SOURCES.txt
11
+ ontora_cli.egg-info/dependency_links.txt
12
+ ontora_cli.egg-info/entry_points.txt
13
+ ontora_cli.egg-info/requires.txt
14
+ ontora_cli.egg-info/top_level.txt
15
+ ontora_cli/commands/__init__.py
16
+ ontora_cli/commands/auth.py
17
+ ontora_cli/commands/campaigns.py
18
+ ontora_cli/commands/contacts.py
19
+ ontora_cli/commands/query.py
20
+ ontora_cli/commands/reports.py
21
+ ontora_cli/commands/transcripts.py
22
+ ontora_cli/commands/webhooks.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ontora = ontora_cli.main:_main
@@ -0,0 +1,5 @@
1
+ typer>=0.12.0
2
+ httpx>=0.27.0
3
+ rich>=13.7.0
4
+ pyyaml>=6.0
5
+ pydantic>=2.6.0
@@ -0,0 +1 @@
1
+ ontora_cli
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ontora-cli"
7
+ version = "0.1.0"
8
+ description = "Ontora CLI — manage interview campaigns, transcripts, reports, and webhooks from the terminal."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Ontora" }]
12
+ license = "LicenseRef-Proprietary"
13
+ keywords = ["ontora", "interviews", "research", "synthesis", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ ]
23
+ dependencies = [
24
+ "typer>=0.12.0",
25
+ "httpx>=0.27.0",
26
+ "rich>=13.7.0",
27
+ "pyyaml>=6.0",
28
+ "pydantic>=2.6.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ ontora = "ontora_cli.main:_main"
33
+
34
+ [project.urls]
35
+ Documentation = "https://docs.ontora.com/cli/install"
36
+ API = "https://docs.ontora.com/api-reference/introduction"
37
+ Homepage = "https://docs.ontora.com"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["."]
41
+ include = ["ontora_cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+