podojo-cli 1.4.0__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/CHANGELOG.md +5 -0
  2. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/CLAUDE.md +0 -2
  3. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/PKG-INFO +1 -1
  4. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/pyproject.toml +1 -1
  5. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/client.py +43 -0
  6. podojo_cli-1.5.0/src/podojo_cli/commands/aiinterviews.py +323 -0
  7. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/config.py +4 -0
  8. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/main.py +2 -0
  9. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/conftest.py +1 -0
  10. podojo_cli-1.5.0/tests/test_aiinterviews.py +328 -0
  11. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/uv.lock +1 -1
  12. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/.github/workflows/publish.yml +0 -0
  13. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/.gitignore +0 -0
  14. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/LICENSE +0 -0
  15. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/README.md +0 -0
  16. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/__init__.py +0 -0
  17. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/__init__.py +0 -0
  18. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/auth.py +0 -0
  19. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/interviews.py +0 -0
  20. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/projects.py +0 -0
  21. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/showreel.py +0 -0
  22. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/synth.py +0 -0
  23. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/transcripts.py +0 -0
  24. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/usertests.py +0 -0
  25. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/commands/videos.py +0 -0
  26. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/synth/__init__.py +0 -0
  27. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/synth/driver.py +0 -0
  28. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/synth/session.py +0 -0
  29. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/version_check.py +0 -0
  30. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/video/__init__.py +0 -0
  31. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/src/podojo_cli/video/showreel.py +0 -0
  32. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/test_auth.py +0 -0
  33. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/test_interviews.py +0 -0
  34. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/test_projects.py +0 -0
  35. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/test_showreel.py +0 -0
  36. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/test_transcripts.py +0 -0
  37. {podojo_cli-1.4.0 → podojo_cli-1.5.0}/tests/test_usertests.py +0 -0
@@ -5,6 +5,11 @@ All notable changes to the Podojo CLI will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org).
7
7
 
8
+ ## [1.5.0] - 2026-07-02
9
+
10
+ ### Added
11
+ - New `aiinterviews` command group to manage AI interview (voice-interviewer) study configs: `example`, `validate`, `create`, `get`, `update`, `delete`, and `list`. Participant Preview/Live URLs are built from the new `ai_interviews_url` config value (`~/.podojo.toml` key `ai_interviews_url`, env `PODOJO_AI_INTERVIEWS_URL`, or the default frontend URL).
12
+
8
13
  ## [1.4.0] - 2026-05-31
9
14
 
10
15
  ### Added
@@ -77,6 +77,4 @@ api_key = "your-api-key"
77
77
 
78
78
  ## Git Commits
79
79
 
80
- - Do NOT add "Co-Authored-By: Claude" trailers to commit messages
81
- - Keep commit messages concise and focused on the "why"
82
80
  - Commit directly to `main` — this is a solo project, no branching needed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: podojo-cli
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: CLI for the Podojo user research platform
5
5
  Project-URL: Homepage, https://github.com/podojo/cli-podojo
6
6
  Project-URL: Source, https://github.com/podojo/cli-podojo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "podojo-cli"
3
- version = "1.4.0"
3
+ version = "1.5.0"
4
4
  description = "CLI for the Podojo user research platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -181,3 +181,46 @@ class PodojoClient:
181
181
  )
182
182
  r.raise_for_status()
183
183
  return r.json()
184
+
185
+ def list_ai_interviews(self, skip: int = 0, limit: int = 50) -> dict:
186
+ r = httpx.get(
187
+ f"{self.base_url}/ai-interviews",
188
+ params={"skip": skip, "limit": limit},
189
+ headers=self._headers(),
190
+ )
191
+ r.raise_for_status()
192
+ return r.json()
193
+
194
+ def get_ai_interview(self, interview_id: str) -> dict:
195
+ r = httpx.get(
196
+ f"{self.base_url}/ai-interviews/{interview_id}",
197
+ headers=self._headers(),
198
+ )
199
+ r.raise_for_status()
200
+ return r.json()
201
+
202
+ def create_ai_interview(self, data: dict) -> dict:
203
+ r = httpx.post(
204
+ f"{self.base_url}/ai-interviews",
205
+ json=data,
206
+ headers=self._headers(),
207
+ )
208
+ r.raise_for_status()
209
+ return r.json()
210
+
211
+ def update_ai_interview(self, interview_id: str, data: dict) -> dict:
212
+ r = httpx.put(
213
+ f"{self.base_url}/ai-interviews/{interview_id}",
214
+ json=data,
215
+ headers=self._headers(),
216
+ )
217
+ r.raise_for_status()
218
+ return r.json()
219
+
220
+ def delete_ai_interview(self, interview_id: str) -> dict:
221
+ r = httpx.delete(
222
+ f"{self.base_url}/ai-interviews/{interview_id}",
223
+ headers=self._headers(),
224
+ )
225
+ r.raise_for_status()
226
+ return r.json()
@@ -0,0 +1,323 @@
1
+ from pathlib import Path
2
+
3
+ import httpx
4
+ import typer
5
+ import yaml
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..client import PodojoClient
10
+ from ..config import load_config
11
+
12
+ app = typer.Typer(help="Manage AI voice interviews")
13
+ console = Console()
14
+
15
+ REQUIRED_FIELDS = ["interview_id", "title", "questions", "closing_message"]
16
+ REQUIRED_QUESTION_FIELDS = ["text"]
17
+
18
+ EXAMPLE_YAML = """\
19
+ # Podojo AI Interview Configuration
20
+ #
21
+ # One file = one study. This YAML is the source of truth: editing it edits the
22
+ # interview. The AI interviewer conducts a voice conversation built from the
23
+ # questions below.
24
+ #
25
+ # Required fields: interview_id, title, questions, closing_message
26
+ # Optional fields: language (default en-US), project_name, overview,
27
+ # decision, live
28
+ #
29
+ # Each question:
30
+ # text (required) the main question, asked verbatim-ish in order
31
+ # section (optional) researcher-facing grouping label
32
+ # max_follow_ups (optional, default 2) adaptive follow-up budget
33
+ # probe_for (optional) what a concrete answer must cover — drives follow-ups
34
+
35
+ interview_id: checkout-experience-v1
36
+ title: Checkout Experience Research
37
+ language: en-US
38
+
39
+ # Optional: link interview to a project
40
+ project_name: checkout-redesign-q1
41
+
42
+ # Optional: research context that grounds the interviewer's follow-ups
43
+ overview: |
44
+ We're redesigning the checkout flow of our online store. This study targets
45
+ recent customers to understand how they decide what to buy, what slows them
46
+ down during checkout, and what would make them complete a purchase.
47
+
48
+ Key questions:
49
+ - How do customers move from browsing to buying?
50
+ - What friction shows up during checkout (payment, shipping, account creation)?
51
+ - What would make customers abandon a purchase at the last step?
52
+
53
+ # Optional: the decision this research informs
54
+ decision: >
55
+ Redesign the checkout flow to reduce cart abandonment.
56
+
57
+ # Optional: set interview live (default: false)
58
+ # live: true
59
+
60
+ questions:
61
+ - section: Shopping Habits
62
+ text: >
63
+ Think about the last time you bought something online. Walk me through
64
+ how you went from finding the product to completing the purchase.
65
+ max_follow_ups: 3
66
+ probe_for: >
67
+ Specific steps taken (search, comparison, reviews), devices used, and
68
+ anything that slowed them down or almost made them give up.
69
+
70
+ - section: Checkout Friction
71
+ text: >
72
+ Think about a time you abandoned an online purchase at checkout.
73
+ What made you stop?
74
+ max_follow_ups: 3
75
+ probe_for: >
76
+ Unexpected costs, forced account creation, missing payment options,
77
+ delivery times. Ask what would have changed their mind.
78
+
79
+ - section: Checkout Friction
80
+ text: >
81
+ When you reach a checkout page, what's the first thing you look at
82
+ before entering your details?
83
+ max_follow_ups: 1
84
+
85
+ closing_message: >
86
+ Thank you for sharing your experience! Your feedback is incredibly valuable
87
+ and will help us improve the shopping experience.
88
+ """
89
+
90
+
91
+ def validate_ai_interview_data(data: dict) -> list[str]:
92
+ """Validate AI interview YAML data, return list of error strings."""
93
+ errors = []
94
+ for field in REQUIRED_FIELDS:
95
+ if field not in data or data[field] is None:
96
+ errors.append(f"Missing required field: '{field}'")
97
+
98
+ questions = data.get("questions")
99
+ if questions is not None:
100
+ if not isinstance(questions, list):
101
+ errors.append("'questions' must be a list")
102
+ elif len(questions) == 0:
103
+ errors.append("'questions' must contain at least one question")
104
+ else:
105
+ for i, question in enumerate(questions, 1):
106
+ if not isinstance(question, dict):
107
+ errors.append(f"Question {i}: must be a mapping with 'text'")
108
+ continue
109
+ for field in REQUIRED_QUESTION_FIELDS:
110
+ if field not in question:
111
+ errors.append(f"Question {i}: missing required field '{field}'")
112
+ max_follow_ups = question.get("max_follow_ups")
113
+ if max_follow_ups is not None and (
114
+ isinstance(max_follow_ups, bool)
115
+ or not isinstance(max_follow_ups, int)
116
+ or max_follow_ups < 0
117
+ ):
118
+ errors.append(
119
+ f"Question {i}: 'max_follow_ups' must be an integer >= 0, got '{max_follow_ups}'"
120
+ )
121
+ return errors
122
+
123
+
124
+ def _load_yaml(path: Path) -> dict:
125
+ """Load and parse YAML file."""
126
+ if not path.exists():
127
+ console.print(f"[red]Error:[/red] File not found: {path}")
128
+ raise typer.Exit(1)
129
+ try:
130
+ with open(path) as f:
131
+ data = yaml.safe_load(f)
132
+ except yaml.YAMLError as e:
133
+ console.print(f"[red]Error:[/red] Invalid YAML syntax:\n{e}")
134
+ raise typer.Exit(1)
135
+ if not isinstance(data, dict):
136
+ console.print("[red]Error:[/red] YAML file must contain a mapping (key-value pairs)")
137
+ raise typer.Exit(1)
138
+ return data
139
+
140
+
141
+ def _format_api_error(e: httpx.HTTPStatusError) -> str:
142
+ """Format API error into actionable message."""
143
+ try:
144
+ detail = e.response.json().get("detail", "")
145
+ if isinstance(detail, list):
146
+ messages = []
147
+ for err in detail:
148
+ loc = " -> ".join(str(x) for x in err.get("loc", []))
149
+ messages.append(f" {loc}: {err.get('msg', '')}")
150
+ return "Validation errors:\n" + "\n".join(messages)
151
+ return str(detail)
152
+ except Exception:
153
+ return e.response.text
154
+
155
+
156
+ def _ai_interviews_url() -> str:
157
+ return load_config()["ai_interviews_url"].rstrip("/")
158
+
159
+
160
+ @app.command("list")
161
+ def list_ai_interviews(
162
+ skip: int = typer.Option(0, help="Number of AI interviews to skip"),
163
+ limit: int = typer.Option(50, help="Max AI interviews to return"),
164
+ ):
165
+ """List all AI interviews."""
166
+ client = PodojoClient()
167
+ try:
168
+ result = client.list_ai_interviews(skip=skip, limit=limit)
169
+ except httpx.HTTPStatusError as e:
170
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
171
+ raise typer.Exit(1)
172
+
173
+ ai_interviews = result.get("ai_interviews", [])
174
+ if not ai_interviews:
175
+ console.print("No AI interviews found.")
176
+ return
177
+
178
+ table = Table(title="AI Interviews")
179
+ table.add_column("Interview ID")
180
+ table.add_column("Title")
181
+ table.add_column("Language")
182
+ table.add_column("Questions", justify="right")
183
+ table.add_column("Live")
184
+ table.add_column("Last Updated")
185
+
186
+ for s in ai_interviews:
187
+ live = "[green]Yes[/green]" if s.get("live") else "[dim]No[/dim]"
188
+ table.add_row(
189
+ s.get("interview_id", ""),
190
+ s.get("title", ""),
191
+ s.get("language", ""),
192
+ str(s.get("question_count", "")),
193
+ live,
194
+ s.get("last_updated", ""),
195
+ )
196
+
197
+ console.print(table)
198
+
199
+
200
+ @app.command("get")
201
+ def get_ai_interview(
202
+ interview_id: str = typer.Argument(help="AI interview ID to retrieve"),
203
+ ):
204
+ """Get an AI interview and output as YAML."""
205
+ client = PodojoClient()
206
+ try:
207
+ interview = client.get_ai_interview(interview_id)
208
+ except httpx.HTTPStatusError as e:
209
+ if e.response.status_code == 404:
210
+ console.print(f"[red]Error:[/red] AI interview '{interview_id}' not found")
211
+ else:
212
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
213
+ raise typer.Exit(1)
214
+
215
+ # Remove server-managed fields for a clean editable output
216
+ group = interview.pop("group", "")
217
+ for key in ("id", "created_at", "created_by", "last_updated"):
218
+ interview.pop(key, None)
219
+
220
+ console.print(yaml.dump(interview, default_flow_style=False, sort_keys=False, allow_unicode=True))
221
+ if group:
222
+ base = _ai_interviews_url()
223
+ console.print(f"Preview: {base}/preview/{group}/{interview_id}")
224
+ console.print(f"Live: {base}/{group}/{interview_id}")
225
+
226
+
227
+ @app.command("create")
228
+ def create_ai_interview(
229
+ from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with AI interview config"),
230
+ ):
231
+ """Create a new AI interview from a YAML file."""
232
+ data = _load_yaml(from_file)
233
+
234
+ errors = validate_ai_interview_data(data)
235
+ if errors:
236
+ console.print("[red]Validation errors:[/red]")
237
+ for err in errors:
238
+ console.print(f" {err}")
239
+ raise typer.Exit(1)
240
+
241
+ client = PodojoClient()
242
+ try:
243
+ result = client.create_ai_interview(data)
244
+ except httpx.HTTPStatusError as e:
245
+ if e.response.status_code == 409:
246
+ console.print(f"[red]Error:[/red] AI interview '{data.get('interview_id')}' already exists")
247
+ else:
248
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
249
+ raise typer.Exit(1)
250
+
251
+ interview_id = result.get("interview_id", "")
252
+ group = result.get("group", "")
253
+ console.print(f"[green]Created AI interview:[/green] {interview_id}")
254
+ if group:
255
+ base = _ai_interviews_url()
256
+ console.print(f" Preview: {base}/preview/{group}/{interview_id}")
257
+ console.print(f" Live: {base}/{group}/{interview_id}")
258
+
259
+
260
+ @app.command("update")
261
+ def update_ai_interview(
262
+ interview_id: str = typer.Argument(help="AI interview ID to update"),
263
+ from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with fields to update"),
264
+ ):
265
+ """Update an AI interview from a YAML file (partial updates OK)."""
266
+ data = _load_yaml(from_file)
267
+
268
+ client = PodojoClient()
269
+ try:
270
+ result = client.update_ai_interview(interview_id, data)
271
+ except httpx.HTTPStatusError as e:
272
+ if e.response.status_code == 404:
273
+ console.print(f"[red]Error:[/red] AI interview '{interview_id}' not found")
274
+ else:
275
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
276
+ raise typer.Exit(1)
277
+
278
+ console.print(f"[green]Updated AI interview:[/green] {result.get('interview_id', interview_id)}")
279
+
280
+
281
+ @app.command("delete")
282
+ def delete_ai_interview(
283
+ interview_id: str = typer.Argument(help="AI interview ID to delete"),
284
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
285
+ ):
286
+ """Delete an AI interview."""
287
+ if not yes:
288
+ typer.confirm(f"Delete AI interview '{interview_id}'?", abort=True)
289
+
290
+ client = PodojoClient()
291
+ try:
292
+ client.delete_ai_interview(interview_id)
293
+ except httpx.HTTPStatusError as e:
294
+ if e.response.status_code == 404:
295
+ console.print(f"[red]Error:[/red] AI interview '{interview_id}' not found")
296
+ else:
297
+ console.print(f"[red]Error:[/red] {_format_api_error(e)}")
298
+ raise typer.Exit(1)
299
+
300
+ console.print(f"[green]Deleted AI interview:[/green] {interview_id}")
301
+
302
+
303
+ @app.command("validate")
304
+ def validate(
305
+ file: Path = typer.Argument(help="YAML file to validate"),
306
+ ):
307
+ """Validate an AI interview YAML file without creating it."""
308
+ data = _load_yaml(file)
309
+
310
+ errors = validate_ai_interview_data(data)
311
+ if errors:
312
+ console.print("[red]Validation errors:[/red]")
313
+ for err in errors:
314
+ console.print(f" {err}")
315
+ raise typer.Exit(1)
316
+
317
+ console.print("[green]Valid AI interview config.[/green]")
318
+
319
+
320
+ @app.command("example")
321
+ def example():
322
+ """Print an example AI interview YAML template."""
323
+ print(EXAMPLE_YAML)
@@ -8,6 +8,7 @@ KEYRING_SERVICE = "podojo-cli"
8
8
  KEYRING_USERNAME = "api_key"
9
9
  CONFIG_PATH = Path.home() / ".podojo.toml"
10
10
  DEFAULT_BASE_URL = "https://podojo-fastapi-mcp.onrender.com"
11
+ DEFAULT_AI_INTERVIEWS_URL = "https://podojo-unmod-discover-frontend.onrender.com"
11
12
 
12
13
 
13
14
  def load_config() -> dict:
@@ -15,6 +16,9 @@ def load_config() -> dict:
15
16
  if CONFIG_PATH.exists():
16
17
  config = tomllib.loads(CONFIG_PATH.read_text())
17
18
  config.setdefault("base_url", os.getenv("PODOJO_BASE_URL", DEFAULT_BASE_URL))
19
+ config.setdefault(
20
+ "ai_interviews_url", os.getenv("PODOJO_AI_INTERVIEWS_URL", DEFAULT_AI_INTERVIEWS_URL)
21
+ )
18
22
  api_key = (
19
23
  os.getenv("PODOJO_API_KEY")
20
24
  or keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
@@ -1,6 +1,7 @@
1
1
  import typer
2
2
 
3
3
  from .commands import (
4
+ aiinterviews,
4
5
  auth,
5
6
  interviews,
6
7
  projects,
@@ -28,6 +29,7 @@ app.add_typer(auth.app, name="auth")
28
29
  app.add_typer(projects.app, name="projects")
29
30
  app.add_typer(interviews.app, name="interviews")
30
31
  app.add_typer(usertests.app, name="usertests")
32
+ app.add_typer(aiinterviews.app, name="aiinterviews")
31
33
  app.add_typer(synth.app, name="synth")
32
34
  app.add_typer(transcripts.app, name="transcripts")
33
35
  app.add_typer(videos.app, name="videos")
@@ -41,6 +41,7 @@ def mock_keyring():
41
41
  @pytest.fixture(autouse=True)
42
42
  def mock_config(monkeypatch):
43
43
  monkeypatch.setenv("PODOJO_BASE_URL", "http://test.local")
44
+ monkeypatch.setenv("PODOJO_AI_INTERVIEWS_URL", "http://interviews.test.local")
44
45
  monkeypatch.setenv("PODOJO_API_KEY", "test-key")
45
46
  monkeypatch.setattr("podojo_cli.config.CONFIG_PATH", Path("/nonexistent/.podojo.toml"))
46
47
 
@@ -0,0 +1,328 @@
1
+ import yaml
2
+
3
+ from podojo_cli.commands.aiinterviews import EXAMPLE_YAML
4
+ from podojo_cli.main import app
5
+
6
+
7
+ VALID_AI_INTERVIEW_YAML = """\
8
+ interview_id: test-interview-1
9
+ title: Test AI Interview
10
+ questions:
11
+ - section: Intro
12
+ text: Walk me through your last purchase.
13
+ max_follow_ups: 3
14
+ probe_for: Specific steps and pain points.
15
+ - text: What would you improve?
16
+ closing_message: Thank you for your time!
17
+ """
18
+
19
+ LIST_RESPONSE = {
20
+ "ai_interviews": [
21
+ {
22
+ "id": "abc1",
23
+ "interview_id": "ai-1",
24
+ "title": "Interview One",
25
+ "language": "en-US",
26
+ "question_count": 3,
27
+ "project_name": "proj-1",
28
+ "live": True,
29
+ "created_at": "2026-06-01T12:00:00",
30
+ "created_by": "user@example.com",
31
+ "last_updated": "2026-06-01T12:00:00",
32
+ },
33
+ {
34
+ "id": "abc2",
35
+ "interview_id": "ai-2",
36
+ "title": "Interview Two",
37
+ "language": "de-DE",
38
+ "question_count": 5,
39
+ "project_name": "proj-2",
40
+ "live": False,
41
+ "created_at": "2026-06-02T12:00:00",
42
+ "created_by": "user@example.com",
43
+ "last_updated": "2026-06-02T12:00:00",
44
+ },
45
+ ],
46
+ "total": 2,
47
+ "skip": 0,
48
+ "limit": 50,
49
+ }
50
+
51
+ GET_RESPONSE = {
52
+ "id": "abc123",
53
+ "interview_id": "ai-1",
54
+ "title": "Interview One",
55
+ "language": "en-US",
56
+ "project_name": "proj-1",
57
+ "overview": "Study overview",
58
+ "decision": "Improve the thing",
59
+ "questions": [{"text": "Walk me through your last purchase.", "max_follow_ups": 2}],
60
+ "closing_message": "Thanks!",
61
+ "live": False,
62
+ "group": "test-group",
63
+ "created_at": "2026-06-01T12:00:00",
64
+ "created_by": "user@example.com",
65
+ "last_updated": "2026-06-01T12:00:00",
66
+ }
67
+
68
+
69
+ def test_list_ai_interviews(runner, httpx_mock):
70
+ httpx_mock.add_response(
71
+ url="http://test.local/api/v1/ai-interviews?skip=0&limit=50",
72
+ json=LIST_RESPONSE,
73
+ )
74
+
75
+ result = runner.invoke(app, ["aiinterviews", "list"])
76
+
77
+ assert result.exit_code == 0
78
+ assert "Interview One" in result.output
79
+ assert "Interview Two" in result.output
80
+
81
+
82
+ def test_list_ai_interviews_empty(runner, httpx_mock):
83
+ httpx_mock.add_response(
84
+ url="http://test.local/api/v1/ai-interviews?skip=0&limit=50",
85
+ json={"ai_interviews": [], "total": 0, "skip": 0, "limit": 50},
86
+ )
87
+
88
+ result = runner.invoke(app, ["aiinterviews", "list"])
89
+
90
+ assert result.exit_code == 0
91
+ assert "No AI interviews found" in result.output
92
+
93
+
94
+ def test_get_ai_interview(runner, httpx_mock):
95
+ httpx_mock.add_response(
96
+ url="http://test.local/api/v1/ai-interviews/ai-1",
97
+ json=GET_RESPONSE,
98
+ )
99
+
100
+ result = runner.invoke(app, ["aiinterviews", "get", "ai-1"])
101
+
102
+ assert result.exit_code == 0
103
+ assert "interview_id: ai-1" in result.output
104
+ assert "title: Interview One" in result.output
105
+ # Server-managed fields should be stripped
106
+ assert "created_at" not in result.output
107
+ assert "created_by" not in result.output
108
+ assert "group: test-group" not in result.output
109
+ # URLs should be displayed
110
+ assert "http://interviews.test.local/preview/test-group/ai-1" in result.output
111
+ assert "http://interviews.test.local/test-group/ai-1" in result.output
112
+
113
+
114
+ def test_get_ai_interview_not_found(runner, httpx_mock):
115
+ httpx_mock.add_response(
116
+ url="http://test.local/api/v1/ai-interviews/nonexistent",
117
+ status_code=404,
118
+ json={"detail": "AI interview not found"},
119
+ )
120
+
121
+ result = runner.invoke(app, ["aiinterviews", "get", "nonexistent"])
122
+
123
+ assert result.exit_code == 1
124
+ assert "not found" in result.output
125
+
126
+
127
+ def test_create_ai_interview(runner, httpx_mock, tmp_path):
128
+ yaml_file = tmp_path / "interview.yaml"
129
+ yaml_file.write_text(VALID_AI_INTERVIEW_YAML)
130
+
131
+ httpx_mock.add_response(
132
+ url="http://test.local/api/v1/ai-interviews",
133
+ method="POST",
134
+ json={
135
+ "id": "abc123",
136
+ "interview_id": "test-interview-1",
137
+ "title": "Test AI Interview",
138
+ "group": "test-group",
139
+ },
140
+ )
141
+
142
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
143
+
144
+ assert result.exit_code == 0
145
+ assert "test-interview-1" in result.output
146
+ assert "http://interviews.test.local/preview/test-group/test-interview-1" in result.output
147
+ assert "http://interviews.test.local/test-group/test-interview-1" in result.output
148
+
149
+
150
+ def test_create_ai_interview_file_not_found(runner):
151
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", "/nonexistent/interview.yaml"])
152
+
153
+ assert result.exit_code == 1
154
+ assert "File not found" in result.output
155
+
156
+
157
+ def test_create_ai_interview_invalid_yaml(runner, tmp_path):
158
+ yaml_file = tmp_path / "bad.yaml"
159
+ yaml_file.write_text(":\n invalid: [yaml\n broken")
160
+
161
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
162
+
163
+ assert result.exit_code == 1
164
+ assert "Invalid YAML" in result.output
165
+
166
+
167
+ def test_create_ai_interview_missing_fields(runner, tmp_path):
168
+ yaml_file = tmp_path / "incomplete.yaml"
169
+ yaml_file.write_text("title: Just a title\n")
170
+
171
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
172
+
173
+ assert result.exit_code == 1
174
+ assert "Missing required field" in result.output
175
+ assert "'interview_id'" in result.output
176
+
177
+
178
+ def test_create_ai_interview_question_missing_text(runner, tmp_path):
179
+ yaml_file = tmp_path / "badquestion.yaml"
180
+ yaml_file.write_text(
181
+ "interview_id: test\n"
182
+ "title: Test\n"
183
+ "closing_message: Thanks!\n"
184
+ "questions:\n"
185
+ " - section: Intro\n"
186
+ )
187
+
188
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
189
+
190
+ assert result.exit_code == 1
191
+ assert "Question 1: missing required field 'text'" in result.output
192
+
193
+
194
+ def test_create_ai_interview_invalid_max_follow_ups(runner, tmp_path):
195
+ yaml_file = tmp_path / "badfollowups.yaml"
196
+ yaml_file.write_text(
197
+ "interview_id: test\n"
198
+ "title: Test\n"
199
+ "closing_message: Thanks!\n"
200
+ "questions:\n"
201
+ " - text: A question\n"
202
+ " max_follow_ups: -1\n"
203
+ )
204
+
205
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
206
+
207
+ assert result.exit_code == 1
208
+ assert "'max_follow_ups' must be an integer >= 0" in result.output
209
+
210
+
211
+ def test_create_ai_interview_duplicate(runner, httpx_mock, tmp_path):
212
+ yaml_file = tmp_path / "interview.yaml"
213
+ yaml_file.write_text(VALID_AI_INTERVIEW_YAML)
214
+
215
+ httpx_mock.add_response(
216
+ url="http://test.local/api/v1/ai-interviews",
217
+ method="POST",
218
+ status_code=409,
219
+ json={"detail": "AI interview with this ID already exists"},
220
+ )
221
+
222
+ result = runner.invoke(app, ["aiinterviews", "create", "-f", str(yaml_file)])
223
+
224
+ assert result.exit_code == 1
225
+ assert "already exists" in result.output
226
+
227
+
228
+ def test_update_ai_interview(runner, httpx_mock, tmp_path):
229
+ yaml_file = tmp_path / "update.yaml"
230
+ yaml_file.write_text("title: Updated Title\n")
231
+
232
+ httpx_mock.add_response(
233
+ url="http://test.local/api/v1/ai-interviews/ai-1",
234
+ method="PUT",
235
+ json={"interview_id": "ai-1", "title": "Updated Title"},
236
+ )
237
+
238
+ result = runner.invoke(app, ["aiinterviews", "update", "ai-1", "-f", str(yaml_file)])
239
+
240
+ assert result.exit_code == 0
241
+ assert "Updated AI interview" in result.output
242
+
243
+
244
+ def test_update_ai_interview_not_found(runner, httpx_mock, tmp_path):
245
+ yaml_file = tmp_path / "update.yaml"
246
+ yaml_file.write_text("title: Updated Title\n")
247
+
248
+ httpx_mock.add_response(
249
+ url="http://test.local/api/v1/ai-interviews/nonexistent",
250
+ method="PUT",
251
+ status_code=404,
252
+ json={"detail": "AI interview not found"},
253
+ )
254
+
255
+ result = runner.invoke(app, ["aiinterviews", "update", "nonexistent", "-f", str(yaml_file)])
256
+
257
+ assert result.exit_code == 1
258
+ assert "not found" in result.output
259
+
260
+
261
+ def test_delete_ai_interview(runner, httpx_mock):
262
+ httpx_mock.add_response(
263
+ url="http://test.local/api/v1/ai-interviews/ai-1",
264
+ method="DELETE",
265
+ json={"status": "deleted", "interview_id": "ai-1"},
266
+ )
267
+
268
+ result = runner.invoke(app, ["aiinterviews", "delete", "ai-1", "--yes"])
269
+
270
+ assert result.exit_code == 0
271
+ assert "Deleted AI interview" in result.output
272
+
273
+
274
+ def test_delete_ai_interview_not_found(runner, httpx_mock):
275
+ httpx_mock.add_response(
276
+ url="http://test.local/api/v1/ai-interviews/nonexistent",
277
+ method="DELETE",
278
+ status_code=404,
279
+ json={"detail": "AI interview not found"},
280
+ )
281
+
282
+ result = runner.invoke(app, ["aiinterviews", "delete", "nonexistent", "--yes"])
283
+
284
+ assert result.exit_code == 1
285
+ assert "not found" in result.output
286
+
287
+
288
+ def test_validate_valid(runner, tmp_path):
289
+ yaml_file = tmp_path / "interview.yaml"
290
+ yaml_file.write_text(VALID_AI_INTERVIEW_YAML)
291
+
292
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
293
+
294
+ assert result.exit_code == 0
295
+ assert "Valid AI interview config" in result.output
296
+
297
+
298
+ def test_validate_invalid(runner, tmp_path):
299
+ yaml_file = tmp_path / "bad.yaml"
300
+ yaml_file.write_text("title: Just a title\n")
301
+
302
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
303
+
304
+ assert result.exit_code == 1
305
+ assert "Missing required field" in result.output
306
+
307
+
308
+ def test_example(runner):
309
+ result = runner.invoke(app, ["aiinterviews", "example"])
310
+
311
+ assert result.exit_code == 0
312
+ assert "interview_id:" in result.output
313
+ assert "questions:" in result.output
314
+ assert "closing_message:" in result.output
315
+ assert "max_follow_ups:" in result.output
316
+
317
+
318
+ def test_example_validates(runner, tmp_path):
319
+ data = yaml.safe_load(EXAMPLE_YAML)
320
+ assert isinstance(data, dict)
321
+
322
+ yaml_file = tmp_path / "example.yaml"
323
+ yaml_file.write_text(EXAMPLE_YAML)
324
+
325
+ result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
326
+
327
+ assert result.exit_code == 0
328
+ assert "Valid AI interview config" in result.output
@@ -369,7 +369,7 @@ wheels = [
369
369
 
370
370
  [[package]]
371
371
  name = "podojo-cli"
372
- version = "1.4.0"
372
+ version = "1.5.0"
373
373
  source = { editable = "." }
374
374
  dependencies = [
375
375
  { name = "httpx" },
File without changes
File without changes
File without changes