devin-cli 1.3.4__tar.gz → 1.3.6__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 (55) hide show
  1. {devin_cli-1.3.4 → devin_cli-1.3.6}/PKG-INFO +3 -2
  2. {devin_cli-1.3.4 → devin_cli-1.3.6}/README.md +1 -0
  3. {devin_cli-1.3.4 → devin_cli-1.3.6}/pyproject.toml +2 -2
  4. devin_cli-1.3.6/src/devin_cli/__init__.py +1 -0
  5. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/client.py +17 -2
  6. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/sessions.py +4 -2
  7. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/sessions.py +5 -2
  8. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/cli.py +565 -28
  9. devin_cli-1.3.6/tests/test_cli_extended.py +191 -0
  10. devin_cli-1.3.4/src/devin_cli/__init__.py +0 -1
  11. {devin_cli-1.3.4 → devin_cli-1.3.6}/.github/workflows/pypi-publish.yml +0 -0
  12. {devin_cli-1.3.4 → devin_cli-1.3.6}/.gitignore +0 -0
  13. {devin_cli-1.3.4 → devin_cli-1.3.6}/CHANGELOG.md +0 -0
  14. {devin_cli-1.3.4 → devin_cli-1.3.6}/LICENSE +0 -0
  15. {devin_cli-1.3.4 → devin_cli-1.3.6}/assets/logo.png +0 -0
  16. {devin_cli-1.3.4 → devin_cli-1.3.6}/generate_facades.py +0 -0
  17. {devin_cli-1.3.4 → devin_cli-1.3.6}/setup.py +0 -0
  18. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/__init__.py +0 -0
  19. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/attachments.py +0 -0
  20. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/consumption.py +0 -0
  21. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/knowledge.py +0 -0
  22. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/members.py +0 -0
  23. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/organizations.py +0 -0
  24. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/playbooks.py +0 -0
  25. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/repositories.py +0 -0
  26. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/schedules.py +0 -0
  27. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/secrets.py +0 -0
  28. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/sessions.py +0 -0
  29. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/__init__.py +0 -0
  30. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/attachments.py +0 -0
  31. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/knowledge.py +0 -0
  32. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/playbooks.py +0 -0
  33. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/secrets.py +0 -0
  34. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/__init__.py +0 -0
  35. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/attachments.py +0 -0
  36. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/consumption.py +0 -0
  37. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/knowledge.py +0 -0
  38. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/members.py +0 -0
  39. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/organizations.py +0 -0
  40. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/playbooks.py +0 -0
  41. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/repositories.py +0 -0
  42. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/schedules.py +0 -0
  43. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/secrets.py +0 -0
  44. {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/config.py +0 -0
  45. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/__init__.py +0 -0
  46. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/conftest.py +0 -0
  47. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_attachments.py +0 -0
  48. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_knowledge.py +0 -0
  49. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_playbooks.py +0 -0
  50. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_repositories.py +0 -0
  51. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_schedules.py +0 -0
  52. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_secrets.py +0 -0
  53. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_sessions.py +0 -0
  54. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_cli.py +0 -0
  55. {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_config.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devin-cli
3
- Version: 1.3.4
3
+ Version: 1.3.6
4
4
  Summary: Unofficial CLI for Devin AI - The first AI Software Engineer
5
5
  Project-URL: Homepage, https://github.com/revanthpobala/devin-cli
6
6
  Project-URL: Repository, https://github.com/revanthpobala/devin-cli.git
7
7
  Project-URL: Issues, https://github.com/revanthpobala/devin-cli/issues
8
- Author-email: revanth <revanth@example.com>
8
+ Author-email: Revanth Pobala <revanthpobala@gmail.com>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: agent,ai,automation,autonomous,cli,cognition,devin,software-engineer,terminal
@@ -42,6 +42,7 @@ Description-Content-Type: text/markdown
42
42
 
43
43
  <p align="center">
44
44
  <a href="https://pypi.org/project/devin-cli/"><img src="https://img.shields.io/pypi/v/devin-cli.svg?style=for-the-badge&color=0294DE" alt="PyPI version"></a>
45
+ <a href="https://pepy.tech/project/devin-cli"><img src="https://img.shields.io/pepy/dt/devin-cli?style=for-the-badge&color=4c1" alt="Downloads"></a>
45
46
  <a href="https://github.com/revanthpobala/homebrew-tap"><img src="https://img.shields.io/badge/Homebrew-Tap-orange?style=for-the-badge&logo=homebrew" alt="Homebrew Tap"></a>
46
47
  <a href="https://github.com/revanthpobala/devin-cli/actions/workflows/pypi-publish.yml"><img src="https://github.com/revanthpobala/devin-cli/actions/workflows/pypi-publish.yml/badge.svg" alt="Build Status"></a>
47
48
  </p>
@@ -6,6 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://pypi.org/project/devin-cli/"><img src="https://img.shields.io/pypi/v/devin-cli.svg?style=for-the-badge&color=0294DE" alt="PyPI version"></a>
9
+ <a href="https://pepy.tech/project/devin-cli"><img src="https://img.shields.io/pepy/dt/devin-cli?style=for-the-badge&color=4c1" alt="Downloads"></a>
9
10
  <a href="https://github.com/revanthpobala/homebrew-tap"><img src="https://img.shields.io/badge/Homebrew-Tap-orange?style=for-the-badge&logo=homebrew" alt="Homebrew Tap"></a>
10
11
  <a href="https://github.com/revanthpobala/devin-cli/actions/workflows/pypi-publish.yml"><img src="https://github.com/revanthpobala/devin-cli/actions/workflows/pypi-publish.yml/badge.svg" alt="Build Status"></a>
11
12
  </p>
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "devin-cli"
3
- version = "1.3.4"
3
+ version = "1.3.6"
4
4
  description = "Unofficial CLI for Devin AI - The first AI Software Engineer"
5
5
  readme = "README.md"
6
6
  authors = [
7
- { name = "revanth", email = "revanth@example.com" },
7
+ { name = "Revanth Pobala", email = "revanthpobala@gmail.com" },
8
8
  ]
9
9
  license = "MIT"
10
10
  keywords = ["devin", "ai", "agent", "software-engineer", "autonomous", "cli", "terminal", "cognition", "automation"]
@@ -0,0 +1 @@
1
+ __version__ = "1.3.5"
@@ -99,9 +99,24 @@ class BaseClient:
99
99
  if "files" in kwargs:
100
100
  headers.pop("Content-Type", None)
101
101
 
102
+ import os
103
+ verify_tls = True
104
+ if os.environ.get("DEVIN_INSECURE_TLS") == "1":
105
+ verify_tls = False
106
+ elif os.environ.get("DEVIN_CA_BUNDLE"):
107
+ verify_tls = os.environ.get("DEVIN_CA_BUNDLE")
108
+
102
109
  try:
103
- with httpx.Client(follow_redirects=False, verify=False, timeout=httpx.Timeout(120.0)) as client:
104
- response = client.request(method, url, headers=headers, **kwargs)
110
+ with httpx.Client(follow_redirects=True, verify=verify_tls, timeout=httpx.Timeout(120.0)) as client_httpx:
111
+ try:
112
+ response = client_httpx.request(method, url, headers=headers, **kwargs)
113
+ except httpx.RequestError as req_err:
114
+ if verify_tls is not False and ("SSL" in str(type(req_err).__name__) or "certificate" in str(req_err).lower() or isinstance(req_err, httpx.ConnectError)):
115
+ console.print(f"[bold yellow]Warning:[/bold yellow] TLS verification or connection failed ({req_err}). Retrying with verify=False... Set DEVIN_INSECURE_TLS=1 to suppress this warning.")
116
+ with httpx.Client(follow_redirects=True, verify=False, timeout=httpx.Timeout(120.0)) as insecure_client:
117
+ response = insecure_client.request(method, url, headers=headers, **kwargs)
118
+ else:
119
+ raise req_err
105
120
  return self._handle_response(response)
106
121
  except httpx.RequestError as e:
107
122
  raise APIError(f"Network error: {e}")
@@ -55,9 +55,11 @@ def create_session(
55
55
  def get_session(session_id: str):
56
56
  return client.get(f"sessions/{session_id}")
57
57
 
58
- def get_session_messages(session_id: str):
58
+ def get_session_messages(session_id: str, limit: int = 100, offset: int = 0):
59
59
  resp = get_session(session_id)
60
- return {"messages": resp.get("messages", [])}
60
+ msgs = resp.get("messages", [])
61
+ # Slice the messages array for basic offset pagination since v1 doesn't support an endpoint
62
+ return {"messages": msgs[offset:offset+limit]}
61
63
 
62
64
  def get_session_insights(session_id: str):
63
65
  return {"error": "Insights are not available in the v1 API. Please upgrade to v3."}
@@ -75,8 +75,11 @@ def get_session(session_id: str):
75
75
  raise
76
76
  raise
77
77
 
78
- def get_session_messages(session_id: str):
79
- return client.get(f"sessions/{session_id}/messages")
78
+ def get_session_messages(session_id: str, limit: int = 100, after: Optional[str] = None):
79
+ params = {"limit": limit}
80
+ if after:
81
+ params["after"] = after
82
+ return client.get(f"sessions/{session_id}/messages", params=params)
80
83
 
81
84
  def send_message(session_id: str, message: str):
82
85
  return client.post(f"sessions/{session_id}/messages", json={"message": message})
@@ -195,6 +195,21 @@ def configure(
195
195
  console.print(f"[green]Configuration saved to {config.config_file}[/green]")
196
196
  return None
197
197
 
198
+ def get_stable_terminal_state(status_enum: Optional[str]) -> str:
199
+ if not status_enum:
200
+ return "running"
201
+ status = status_enum.lower()
202
+ if status == "finished":
203
+ return "completed"
204
+ elif status in ("failed", "error"):
205
+ return "failed"
206
+ elif status in ("stopped", "cancelled", "terminated"):
207
+ return "terminated"
208
+ elif status == "blocked":
209
+ return "blocked"
210
+ else:
211
+ return "running"
212
+
198
213
  # --- Sessions ---
199
214
  @session_app.command("create")
200
215
  @handle_api_error
@@ -296,21 +311,23 @@ def create_session_cmd(
296
311
  console.print(f"[bold cyan]URL:[/bold cyan] {resp.get('url')}")
297
312
 
298
313
  if wait:
299
- terminal_statuses = {"stopped", "finished", "error", "cancelled", "failed"}
300
314
  console.print(f"[dim]Waiting for session to complete (polling every {interval}s)...[/dim]")
301
315
  with console.status("[bold green]Running...[/bold green]") as s:
302
316
  while True:
303
317
  time.sleep(interval)
304
318
  poll = sessions.get_session(sid)
305
- current_status = poll.get("status_enum", "").lower()
319
+ current_status = poll.get("status_enum", "")
320
+ stable_state = get_stable_terminal_state(current_status)
306
321
  s.update(f"[bold green]Status: {current_status}[/bold green]")
307
- if current_status in terminal_statuses:
322
+ if stable_state not in ("running", "blocked"):
308
323
  console.print(f"[bold green]Session finished:[/bold green] {current_status}")
309
324
  break
310
325
  else:
311
326
  console.print("[yellow]Session created, but no ID returned immediately (awaiting advanced mode setup).[/yellow]")
312
327
 
313
328
  return resp
329
+
330
+ return resp
314
331
 
315
332
 
316
333
  @session_app.command("list")
@@ -319,38 +336,111 @@ def list_sessions_cmd(
319
336
  limit: int = 10,
320
337
  org: Optional[str] = typer.Option(None, "--org"),
321
338
  json_output: bool = typer.Option(False, "--json"),
339
+ after: Optional[str] = typer.Option(None, "--after", help="Cursor for pagination (v3 only)"),
340
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages sequentially (v3 only)"),
322
341
  ):
323
342
  """List sessions."""
324
343
  if org: config.temporary_org_id = org
325
- resp = sessions.list_sessions(limit=limit)
326
- sess_list = resp.get("items", resp.get("sessions", []))
327
-
328
- if not sess_list and config.api_token and config.api_token.startswith("cog_"):
344
+
345
+ if (after or all_pages) and config.api_version == "v1":
346
+ console.print("[bold yellow]Warning:[/bold yellow] Pagination flags (--after, --all) are only supported in v3 API.")
347
+ raise typer.Exit(1)
348
+
349
+ items = []
350
+ current_after = after
351
+ current_offset = 0
352
+ has_next = False
353
+ next_cursor = None
354
+ total = None
355
+
356
+ while True:
357
+ kwargs = {"limit": limit}
358
+ if config.api_version == "v3" and current_after:
359
+ kwargs["after"] = current_after
360
+ elif config.api_version == "v1":
361
+ kwargs["offset"] = current_offset
362
+
363
+ resp = sessions.list_sessions(**kwargs)
364
+ page_items = resp.get("items", resp.get("sessions", []))
365
+ items.extend(page_items)
366
+
367
+ if total is None:
368
+ total = resp.get("total")
369
+
370
+ if config.api_version == "v3":
371
+ has_next = resp.get("has_next_page", False)
372
+ next_cursor = resp.get("end_cursor")
373
+ else:
374
+ has_next = len(page_items) == limit
375
+ current_offset += limit
376
+
377
+ if not all_pages or not has_next or (config.api_version == "v3" and not next_cursor):
378
+ break
379
+
380
+ current_after = next_cursor
381
+
382
+ for s in items:
383
+ if isinstance(s, dict):
384
+ s["terminal_state"] = get_stable_terminal_state(s.get("status_enum"))
385
+
386
+ if not items and config.api_token and config.api_token.startswith("cog_"):
329
387
  console.print("[yellow]Warning: Service tokens (cog_) may only have visibility into sessions they explicitly created, not all organization sessions.[/yellow]")
330
388
 
331
- if json_output:
332
- console.print(json.dumps(sess_list, indent=2))
389
+ final_resp = {
390
+ "items": items,
391
+ "total": total if total is not None else len(items),
392
+ }
393
+ if not all_pages:
394
+ final_resp["end_cursor"] = next_cursor
395
+ final_resp["has_next_page"] = has_next
396
+
397
+ import os
398
+ if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
399
+ if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
400
+ console.print(json.dumps(final_resp, indent=2))
401
+ return final_resp
333
402
  else:
334
- table = Table(title="Devin Sessions")
403
+ table = Table(title="Devin Sessions" + (f" ({total} total)" if total is not None else ""))
335
404
  table.add_column("ID", style="cyan")
336
405
  table.add_column("Status", style="magenta")
406
+ table.add_column("Terminal State", style="blue")
337
407
  table.add_column("Title")
338
- for s in sess_list:
339
- table.add_row(s.get("session_id"), s.get("status_enum"), s.get("title") or s.get("prompt", "")[:50])
408
+ for s in items:
409
+ table.add_row(
410
+ s.get("session_id"),
411
+ s.get("status_enum"),
412
+ s.get("terminal_state"),
413
+ s.get("title") or s.get("prompt", "")[:50]
414
+ )
340
415
  console.print(table)
416
+
417
+ if not all_pages and next_cursor:
418
+ console.print(f"[dim]More sessions available. Next cursor: {next_cursor}[/dim]")
419
+ console.print(f"[dim]Use --after {next_cursor} or --all to see them.[/dim]")
341
420
 
342
- return resp
421
+ return final_resp
422
+
343
423
 
344
424
  @session_app.command("get")
345
425
  @handle_api_error
346
426
  def get_session_cmd(
347
427
  session_id: Optional[str] = typer.Argument(None),
348
428
  org: Optional[str] = typer.Option(None, "--org"),
429
+ json_output: bool = typer.Option(False, "--json"),
349
430
  ):
350
431
  """Get detailed session info."""
351
432
  if org: config.temporary_org_id = org
352
433
  sid = session_id or get_current_session_id()
353
434
  resp = sessions.get_session(sid)
435
+ if isinstance(resp, dict):
436
+ resp["terminal_state"] = get_stable_terminal_state(resp.get("status_enum"))
437
+
438
+ import os
439
+ if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
440
+ if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
441
+ console.print(json.dumps(resp, indent=2))
442
+ return resp
443
+
354
444
  console.print(Panel(json.dumps(resp, indent=2), title=f"Session {sid}"))
355
445
 
356
446
  return resp
@@ -360,11 +450,19 @@ def get_session_cmd(
360
450
  def session_insights_cmd(
361
451
  session_id: Optional[str] = typer.Argument(None),
362
452
  org: Optional[str] = typer.Option(None, "--org"),
453
+ json_output: bool = typer.Option(False, "--json"),
363
454
  ):
364
455
  """Get technical insights for a session (ACUs, etc)."""
365
456
  if org: config.temporary_org_id = org
366
457
  sid = session_id or get_current_session_id()
367
458
  resp = sessions.get_session_insights(sid)
459
+
460
+ import os
461
+ if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
462
+ if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
463
+ console.print(json.dumps(resp, indent=2))
464
+ return resp
465
+
368
466
  if isinstance(resp, dict) and "error" in resp:
369
467
  console.print(f"[bold yellow]Note:[/bold yellow] {resp['error']}")
370
468
  console.print("Tip: Switch to v3 with [bold cyan]devin configure[/bold cyan] or use [bold cyan]--profile [v3-profile][/bold cyan]")
@@ -378,22 +476,63 @@ def session_insights_cmd(
378
476
  def session_cost_cmd(
379
477
  session_id: Optional[str] = typer.Argument(None, help="Specific session ID to check cost for"),
380
478
  org: Optional[str] = typer.Option(None, "--org"),
479
+ json_output: bool = typer.Option(False, "--json"),
381
480
  ):
382
481
  """View ACU consumption."""
383
482
  if org: config.temporary_org_id = org
483
+
484
+ import os
485
+ is_json = json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json"
486
+
384
487
  if session_id:
385
- resp = sessions.get_session(session_id)
488
+ if config.api_version == "v3":
489
+ import concurrent.futures
490
+
491
+ def fetch_session():
492
+ return sessions.get_session(session_id)
493
+
494
+ def fetch_consumption():
495
+ try:
496
+ return consumption.get_session_consumption(session_id)
497
+ except Exception:
498
+ return None
499
+
500
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
501
+ f_session = executor.submit(fetch_session)
502
+ f_cons = executor.submit(fetch_consumption)
503
+
504
+ resp = f_session.result()
505
+ cons_resp = f_cons.result()
506
+ else:
507
+ resp = sessions.get_session(session_id)
508
+ cons_resp = None
509
+
386
510
  acus = resp.get("acus_consumed") or resp.get("acu_used")
387
511
  cost_data = {
388
512
  "session_id": resp.get("session_id"),
389
513
  "status": resp.get("status_enum"),
390
514
  "acus_consumed": acus,
391
515
  }
516
+
517
+ if cons_resp:
518
+ cost_data["breakdown"] = cons_resp
519
+
520
+ if is_json:
521
+ if not os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
522
+ console.print(json.dumps(cost_data, indent=2))
523
+ return cost_data
524
+
392
525
  console.print(Panel(json.dumps(cost_data, indent=2), title=f"Session Cost: {session_id}"))
393
526
  if acus is None:
394
527
  console.print("[yellow]ACU data unavailable — this may be a v1 API session or a service token without cost visibility.[/yellow]")
528
+ return cost_data
395
529
  else:
396
530
  resp = consumption.get_daily_consumption_breakdown()
531
+ if is_json:
532
+ if not os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
533
+ console.print(json.dumps(resp, indent=2))
534
+ return resp
535
+
397
536
  console.print(Panel(json.dumps(resp, indent=2), title="Daily Consumption"))
398
537
 
399
538
  return resp
@@ -428,19 +567,73 @@ def send_message_cmd(
428
567
  def list_messages_cmd(
429
568
  session_id: Optional[str] = typer.Argument(None),
430
569
  org: Optional[str] = typer.Option(None, "--org"),
570
+ json_output: bool = typer.Option(False, "--json"),
571
+ limit: int = 100,
572
+ after: Optional[str] = typer.Option(None, "--after", help="Cursor for pagination (v3 only)"),
573
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages sequentially"),
431
574
  ):
432
575
  """List messages in a session."""
433
576
  if org: config.temporary_org_id = org
434
577
  sid = session_id or get_current_session_id()
435
- resp = sessions.get_session_messages(sid)
436
- msgs = resp.get("messages", [])
578
+
579
+ if (after or all_pages) and config.api_version == "v1":
580
+ console.print("[bold yellow]Warning:[/bold yellow] Pagination flags (--after, --all) for messages are limited in v1 API since it fetches the full session payload locally.")
581
+
582
+ msgs = []
583
+ current_after = after
584
+ current_offset = 0
585
+ has_next = False
586
+ next_cursor = None
587
+
588
+ while True:
589
+ kwargs = {"limit": limit}
590
+ if config.api_version == "v3" and current_after:
591
+ kwargs["after"] = current_after
592
+ elif config.api_version == "v1":
593
+ kwargs["offset"] = current_offset
594
+
595
+ resp = sessions.get_session_messages(sid, **kwargs)
596
+ page_msgs = resp.get("messages", [])
597
+ msgs.extend(page_msgs)
598
+
599
+ if config.api_version == "v3":
600
+ has_next = resp.get("has_next_page", False)
601
+ next_cursor = resp.get("end_cursor")
602
+ else:
603
+ has_next = len(page_msgs) == limit
604
+ current_offset += limit
605
+
606
+ if not all_pages or not has_next or (config.api_version == "v3" and not next_cursor):
607
+ break
608
+
609
+ current_after = next_cursor
610
+
611
+ import os
612
+ is_json = json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json"
613
+
614
+ final_resp = {
615
+ "messages": msgs,
616
+ }
617
+ if not all_pages and config.api_version == "v3":
618
+ final_resp["end_cursor"] = next_cursor
619
+ final_resp["has_next_page"] = has_next
620
+
621
+ if is_json:
622
+ if not os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
623
+ console.print(json.dumps(final_resp if all_pages or next_cursor else msgs, indent=2))
624
+ return final_resp if all_pages or next_cursor else resp
625
+
437
626
  for m in msgs:
438
627
  role = m.get("role", "unknown")
439
628
  content = m.get("message", "") or m.get("content", "")
440
629
  console.print(f"[bold cyan]{role}:[/bold cyan] {content}")
441
630
  console.print("---")
442
631
 
443
- return resp
632
+ if not all_pages and next_cursor:
633
+ console.print(f"[dim]More messages available. Next cursor: {next_cursor}[/dim]")
634
+ console.print(f"[dim]Use --after {next_cursor} or --all to see them.[/dim]")
635
+
636
+ return final_resp if all_pages or next_cursor else resp
444
637
 
445
638
  @session_app.command("terminate")
446
639
  @handle_api_error
@@ -456,6 +649,164 @@ def terminate_session_cmd(
456
649
  console.print(f"[green]Session {sid} terminated.[/green]")
457
650
  return None
458
651
 
652
+ @session_app.command("archive")
653
+ @handle_api_error
654
+ def archive_session_cmd(
655
+ session_id: Optional[str] = typer.Argument(None),
656
+ org: Optional[str] = typer.Option(None, "--org"),
657
+ ):
658
+ """Archive a session (v3 only)."""
659
+ if org: config.temporary_org_id = org
660
+ sid = session_id or get_current_session_id()
661
+ if typer.confirm(f"Archive session {sid}?"):
662
+ if config.api_version == "v1":
663
+ console.print("[bold red]Error:[/bold red] Archive is only supported in v3 API.")
664
+ raise typer.Exit(1)
665
+ sessions.archive_session(sid)
666
+ console.print(f"[green]Session {sid} archived.[/green]")
667
+ return None
668
+
669
+ @session_app.command("attachments")
670
+ @handle_api_error
671
+ def get_session_attachments_cmd(
672
+ session_id: Optional[str] = typer.Argument(None),
673
+ org: Optional[str] = typer.Option(None, "--org"),
674
+ json_output: bool = typer.Option(False, "--json"),
675
+ ):
676
+ """Get session attachments (v3 only)."""
677
+ if org: config.temporary_org_id = org
678
+ sid = session_id or get_current_session_id()
679
+ if config.api_version == "v1":
680
+ console.print("[bold red]Error:[/bold red] Attachments are only supported in v3 API.")
681
+ raise typer.Exit(1)
682
+
683
+ resp = sessions.get_session_attachments(sid)
684
+
685
+ import os
686
+ if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
687
+ if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
688
+ console.print(json.dumps(resp, indent=2))
689
+ return resp
690
+
691
+ console.print(Panel(json.dumps(resp, indent=2), title=f"Attachments for {sid}"))
692
+ return resp
693
+
694
+ @session_app.command("tags")
695
+ @handle_api_error
696
+ def get_session_tags_cmd(
697
+ session_id: Optional[str] = typer.Argument(None),
698
+ org: Optional[str] = typer.Option(None, "--org"),
699
+ json_output: bool = typer.Option(False, "--json"),
700
+ ):
701
+ """Get session tags (v3 only)."""
702
+ if org: config.temporary_org_id = org
703
+ sid = session_id or get_current_session_id()
704
+ if config.api_version == "v1":
705
+ console.print("[bold red]Error:[/bold red] Tags fetching is only supported in v3 API.")
706
+ raise typer.Exit(1)
707
+
708
+ resp = sessions.get_session_tags(sid)
709
+
710
+ import os
711
+ if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
712
+ if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
713
+ console.print(json.dumps(resp, indent=2))
714
+ return resp
715
+
716
+ console.print(Panel(json.dumps(resp, indent=2), title=f"Tags for {sid}"))
717
+ return resp
718
+
719
+ def parse_duration(duration_str: str) -> float:
720
+ if not duration_str:
721
+ return 0.0
722
+ duration_str = duration_str.strip().lower()
723
+ val = 0.0
724
+ if duration_str.endswith("s"):
725
+ val = float(duration_str[:-1])
726
+ elif duration_str.endswith("m"):
727
+ val = float(duration_str[:-1]) * 60
728
+ elif duration_str.endswith("h"):
729
+ val = float(duration_str[:-1]) * 3600
730
+ else:
731
+ try:
732
+ val = float(duration_str)
733
+ except ValueError:
734
+ raise ValueError(f"Invalid duration format: '{duration_str}'. Use format like 10s, 5m, 1h.")
735
+ if val < 0:
736
+ raise ValueError("Timeout duration cannot be negative.")
737
+ return val
738
+
739
+
740
+ @session_app.command("wait")
741
+ @handle_api_error
742
+ def wait_session_cmd(
743
+ session_id: Optional[str] = typer.Argument(None, help="Session ID to wait for (defaults to active session)"),
744
+ until: str = typer.Option("completed", "--until", help="Terminal state to wait for (completed | terminal)"),
745
+ timeout: str = typer.Option("1h", "--timeout", help="Timeout duration (e.g. 10s, 5m, 1h)"),
746
+ interval: int = typer.Option(5, "--interval", help="Polling interval in seconds"),
747
+ org: Optional[str] = typer.Option(None, "--org"),
748
+ ):
749
+ """Block until the session reaches a terminal state or the desired state."""
750
+ if org: config.temporary_org_id = org
751
+ sid = session_id or get_current_session_id()
752
+
753
+ until = until.lower()
754
+ if until not in ("completed", "terminal"):
755
+ console.print(f"[bold red]Error:[/bold red] --until must be 'completed' or 'terminal'.")
756
+ raise typer.Exit(1)
757
+
758
+ try:
759
+ timeout_seconds = parse_duration(timeout)
760
+ except ValueError as e:
761
+ console.print(f"[bold red]Error:[/bold red] {e}")
762
+ raise typer.Exit(1)
763
+
764
+ start_time = time.time()
765
+
766
+ console.print(f"[dim]Waiting for session {sid} to reach '{until}' (timeout {timeout}, polling every {interval}s)...[/dim]")
767
+
768
+ try:
769
+ with console.status(f"[bold green]Waiting for session {sid}...[/bold green]") as status:
770
+ while True:
771
+ elapsed = time.time() - start_time
772
+ if timeout_seconds > 0 and elapsed >= timeout_seconds:
773
+ console.print(f"\n[bold red]Timeout:[/bold red] Timed out waiting for session {sid} after {timeout}")
774
+ raise typer.Exit(1)
775
+
776
+ try:
777
+ resp = sessions.get_session(sid)
778
+ except Exception as e:
779
+ console.print(f"\n[bold red]Error fetching session:[/bold red] {e}")
780
+ raise typer.Exit(1)
781
+
782
+ status_enum = resp.get("status_enum")
783
+ term_state = get_stable_terminal_state(status_enum)
784
+
785
+ status.update(f"[bold green]Current Status: {status_enum} (Terminal State: {term_state})[/bold green]")
786
+
787
+ if term_state != "running" and term_state != "blocked":
788
+ if until == "completed" and term_state == "completed":
789
+ console.print(f"\n[bold green]Success:[/bold green] Session {sid} completed successfully.")
790
+ raise typer.Exit(0)
791
+ elif until == "completed" and term_state != "completed":
792
+ console.print(f"\n[bold red]Failed:[/bold red] Session {sid} finished but reached non-completed terminal state '{term_state}'")
793
+ raise typer.Exit(1)
794
+ elif until == "terminal":
795
+ console.print(f"\n[bold green]Terminal State Reached:[/bold green] Session {sid} reached state '{term_state}'.")
796
+ if term_state == "completed":
797
+ raise typer.Exit(0)
798
+ else:
799
+ raise typer.Exit(1)
800
+
801
+ time_to_wait = min(interval, timeout_seconds - elapsed) if timeout_seconds > 0 else interval
802
+ if time_to_wait <= 0:
803
+ continue
804
+ time.sleep(time_to_wait)
805
+ except KeyboardInterrupt:
806
+ console.print("\n[dim]Wait interrupted.[/dim]")
807
+ raise typer.Exit(130)
808
+
809
+
459
810
  # --- Knowledge ---
460
811
  @knowledge_app.command("list")
461
812
  @handle_api_error
@@ -694,19 +1045,51 @@ def create_schedule_cmd(
694
1045
  def list_repos_cmd(
695
1046
  org: Optional[str] = typer.Option(None, "--org"),
696
1047
  json_output: bool = typer.Option(False, "--json"),
1048
+ after: Optional[str] = typer.Option(None, "--after", help="Cursor for pagination"),
1049
+ all_pages: bool = typer.Option(False, "--all", help="Fetch all pages sequentially"),
697
1050
  ):
698
1051
  """List repositories indexed for Devin."""
699
1052
  if org: config.temporary_org_id = org
700
- resp = repositories.list_repositories()
701
- if isinstance(resp, list):
702
- items = resp
703
- elif isinstance(resp, dict):
704
- items = resp.get("repositories") or resp.get("items") or resp.get("data") or []
705
- else:
706
- items = []
707
- if json_output:
708
- console.print(json.dumps(items, indent=2))
709
- return
1053
+
1054
+ items = []
1055
+ current_after = after
1056
+ has_next = False
1057
+ next_cursor = None
1058
+
1059
+ while True:
1060
+ resp = repositories.list_repositories(after=current_after)
1061
+ page_items = []
1062
+ if isinstance(resp, list):
1063
+ page_items = resp
1064
+ has_next = False
1065
+ next_cursor = None
1066
+ elif isinstance(resp, dict):
1067
+ page_items = resp.get("repositories") or resp.get("items") or resp.get("data") or []
1068
+ has_next = resp.get("has_next_page", False)
1069
+ next_cursor = resp.get("end_cursor") or resp.get("next_cursor")
1070
+ else:
1071
+ page_items = []
1072
+ has_next = False
1073
+ next_cursor = None
1074
+
1075
+ items.extend(page_items)
1076
+
1077
+ if not all_pages or not has_next or not next_cursor:
1078
+ break
1079
+
1080
+ current_after = next_cursor
1081
+
1082
+ final_resp = {
1083
+ "repositories": items,
1084
+ "end_cursor": next_cursor if not all_pages else None,
1085
+ "has_next_page": has_next if not all_pages else False
1086
+ }
1087
+
1088
+ if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
1089
+ if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
1090
+ print(json.dumps(final_resp, indent=2))
1091
+ return final_resp
1092
+
710
1093
  table = Table(title="Repositories")
711
1094
  table.add_column("Path", style="cyan")
712
1095
  table.add_column("Indexed", style="green")
@@ -728,7 +1111,161 @@ def list_repos_cmd(
728
1111
  )
729
1112
  table.add_row(path, "Yes" if indexed else "No")
730
1113
  console.print(table)
731
- return resp
1114
+
1115
+ if has_next and not all_pages:
1116
+ console.print(f"[dim]More pages available. Use --after {next_cursor} to fetch next page, or --all to fetch all.[/dim]")
1117
+
1118
+ return final_resp
1119
+
1120
+
1121
+ @repo_app.command("status")
1122
+ @handle_api_error
1123
+ def status_repo_cmd(
1124
+ paths: List[str] = typer.Argument(..., help="Repository paths (owner/repo)"),
1125
+ org: Optional[str] = typer.Option(None, "--org"),
1126
+ json_output: bool = typer.Option(False, "--json"),
1127
+ ):
1128
+ """Get indexing status for one or more repositories."""
1129
+ if org: config.temporary_org_id = org
1130
+
1131
+ results = []
1132
+ exit_code = 0
1133
+
1134
+ from concurrent.futures import ThreadPoolExecutor
1135
+
1136
+ def fetch_status(path):
1137
+ try:
1138
+ resp = repositories.get_indexing_status(path)
1139
+ if isinstance(resp, dict):
1140
+ if "repo_path" not in resp:
1141
+ resp["repo_path"] = path
1142
+ indexing_enabled = resp.get("indexing_enabled", False)
1143
+ item_exit = 0 if indexing_enabled else 1
1144
+ else:
1145
+ resp = {"repo_path": path, "indexing_enabled": False, "raw": resp}
1146
+ item_exit = 1
1147
+ return (path, resp, item_exit, None)
1148
+ except Exception as e:
1149
+ return (path, None, None, e)
1150
+
1151
+ results_map = {}
1152
+ errors_map = {}
1153
+ exit_codes_map = {}
1154
+
1155
+ max_workers = min(10, len(paths))
1156
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1157
+ futures = {executor.submit(fetch_status, path): path for path in paths}
1158
+ for future in futures:
1159
+ path = futures[future]
1160
+ try:
1161
+ p, resp, item_exit, err = future.result()
1162
+ if err:
1163
+ errors_map[path] = err
1164
+ else:
1165
+ results_map[path] = resp
1166
+ exit_codes_map[path] = item_exit
1167
+ except Exception as ex:
1168
+ errors_map[path] = ex
1169
+
1170
+ for path in paths:
1171
+ if path in errors_map:
1172
+ e = errors_map[path]
1173
+ if isinstance(e, APIError):
1174
+ if e.status_code == 404:
1175
+ resp = {
1176
+ "repo_path": path,
1177
+ "indexing_enabled": False,
1178
+ "error": "Not registered in Devin (404)"
1179
+ }
1180
+ results.append(resp)
1181
+ if len(paths) == 1:
1182
+ exit_code = 2
1183
+ else:
1184
+ if len(paths) == 1:
1185
+ is_json = os.environ.get("DEVIN_OUTPUT_FORMAT") == "json" or json_output
1186
+ if is_json:
1187
+ try:
1188
+ err_json = e.response.json()
1189
+ except Exception:
1190
+ err_json = {"error": str(e)}
1191
+ print(json.dumps(err_json, indent=2))
1192
+ else:
1193
+ console.print(f"[bold red]API Error:[/bold red] {e}")
1194
+ raise typer.Exit(3)
1195
+ else:
1196
+ results.append({
1197
+ "repo_path": path,
1198
+ "indexing_enabled": False,
1199
+ "error": str(e)
1200
+ })
1201
+ exit_code = 3
1202
+ else:
1203
+ if len(paths) == 1:
1204
+ is_json = os.environ.get("DEVIN_OUTPUT_FORMAT") == "json" or json_output
1205
+ if is_json:
1206
+ print(json.dumps({"error": str(e)}, indent=2))
1207
+ else:
1208
+ console.print(f"[bold red]Error:[/bold red] {e}")
1209
+ raise typer.Exit(3)
1210
+ else:
1211
+ results.append({
1212
+ "repo_path": path,
1213
+ "indexing_enabled": False,
1214
+ "error": str(e)
1215
+ })
1216
+ exit_code = 3
1217
+ else:
1218
+ resp = results_map[path]
1219
+ item_exit = exit_codes_map[path]
1220
+ results.append(resp)
1221
+ if len(paths) == 1:
1222
+ exit_code = item_exit
1223
+
1224
+ if len(paths) == 1:
1225
+ final_result = results[0]
1226
+ else:
1227
+ final_result = {"results": results}
1228
+
1229
+ is_json = json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json"
1230
+ if is_json:
1231
+ if not os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
1232
+ console.print(json.dumps(final_result, indent=2))
1233
+ return final_result
1234
+
1235
+ if len(paths) == 1:
1236
+ res = results[0]
1237
+ if "error" in res:
1238
+ console.print(f"[bold red]Error:[/bold red] {res['error']}")
1239
+ else:
1240
+ console.print(Panel(json.dumps(res, indent=2), title=f"Status for {paths[0]}"))
1241
+ else:
1242
+ table = Table(title="Repository Indexing Status")
1243
+ table.add_column("Path", style="cyan")
1244
+ table.add_column("Indexing Enabled", style="magenta")
1245
+ table.add_column("Status / Details")
1246
+
1247
+ for res in results:
1248
+ path = res.get("repo_path", "")
1249
+ enabled = "Yes" if res.get("indexing_enabled") else "No"
1250
+
1251
+ if "error" in res:
1252
+ details = f"[red]{res['error']}[/red]"
1253
+ else:
1254
+ latest = res.get("latest_index") or {}
1255
+ status = latest.get("status", "unknown") if latest else "N/A"
1256
+ branch = latest.get("branch_name", "") if latest else ""
1257
+ details = f"Status: {status}" + (f" ({branch})" if branch else "")
1258
+
1259
+ table.add_row(path, enabled, details)
1260
+ console.print(table)
1261
+
1262
+ if len(paths) == 1:
1263
+ raise typer.Exit(exit_code)
1264
+ else:
1265
+ if exit_code >= 3:
1266
+ raise typer.Exit(exit_code)
1267
+ return final_result
1268
+
732
1269
 
733
1270
  @repo_app.command("index")
734
1271
  @handle_api_error
@@ -0,0 +1,191 @@
1
+ import pytest
2
+ from typer.testing import CliRunner
3
+ from unittest.mock import patch, MagicMock
4
+ from devin_cli.cli import app
5
+ from devin_cli.api.client import APIError
6
+
7
+ runner = CliRunner()
8
+
9
+ @patch("devin_cli.api.repositories.list_repositories")
10
+ def test_repos_list_pagination(mock_list):
11
+ # First call returns a page with has_next_page=True and end_cursor
12
+ # Second call returns a page with has_next_page=False
13
+ mock_list.side_effect = [
14
+ {
15
+ "repositories": [{"repo_path": "owner/repo1"}],
16
+ "has_next_page": True,
17
+ "end_cursor": "cursor123"
18
+ },
19
+ {
20
+ "repositories": [{"repo_path": "owner/repo2"}],
21
+ "has_next_page": False,
22
+ "end_cursor": None
23
+ }
24
+ ]
25
+
26
+ result = runner.invoke(app, ["repos", "list", "--all"])
27
+ assert result.exit_code == 0
28
+ assert "owner/repo1" in result.stdout
29
+ assert "owner/repo2" in result.stdout
30
+ assert mock_list.call_count == 2
31
+
32
+
33
+ @patch("devin_cli.api.repositories.get_indexing_status")
34
+ def test_repos_status_enabled(mock_status):
35
+ mock_status.return_value = {
36
+ "repo_path": "owner/repo",
37
+ "indexing_enabled": True,
38
+ "latest_index": {"status": "completed"}
39
+ }
40
+
41
+ result = runner.invoke(app, ["repos", "status", "owner/repo"])
42
+ assert result.exit_code == 0
43
+ assert "indexing_enabled" in result.stdout
44
+
45
+
46
+ @patch("devin_cli.api.repositories.get_indexing_status")
47
+ def test_repos_status_disabled(mock_status):
48
+ mock_status.return_value = {
49
+ "repo_path": "owner/repo",
50
+ "indexing_enabled": False
51
+ }
52
+
53
+ result = runner.invoke(app, ["repos", "status", "owner/repo"])
54
+ assert result.exit_code == 1
55
+
56
+
57
+ @patch("devin_cli.api.repositories.get_indexing_status")
58
+ def test_repos_status_not_registered(mock_status):
59
+ # Raise a 404 APIError
60
+ mock_status.side_effect = APIError("Not found", 404)
61
+
62
+ result = runner.invoke(app, ["repos", "status", "owner/repo"])
63
+ assert result.exit_code == 2
64
+ assert "Not registered in Devin" in result.stdout
65
+
66
+
67
+ @patch("devin_cli.api.repositories.get_indexing_status")
68
+ def test_repos_status_multi(mock_status):
69
+ mock_status.side_effect = [
70
+ {"repo_path": "owner/repo1", "indexing_enabled": True},
71
+ APIError("Not found", 404),
72
+ ]
73
+
74
+ result = runner.invoke(app, ["repos", "status", "owner/repo1", "owner/repo2"])
75
+ assert result.exit_code == 0 # Multi-path doesn't fail exit code on 404
76
+ assert "Repository Indexing Status" in result.stdout
77
+ assert "owner/repo1" in result.stdout
78
+ assert "owner/repo2" in result.stdout
79
+
80
+
81
+ @patch("devin_cli.api.sessions.get_session")
82
+ def test_sessions_get_terminal_state(mock_get):
83
+ mock_get.return_value = {
84
+ "session_id": "sess_123",
85
+ "status_enum": "finished",
86
+ "title": "Test"
87
+ }
88
+
89
+ result = runner.invoke(app, ["sessions", "get", "sess_123"])
90
+ assert result.exit_code == 0
91
+ assert '"terminal_state": "completed"' in result.stdout
92
+
93
+
94
+ @patch("devin_cli.api.sessions.get_session")
95
+ def test_sessions_wait(mock_get):
96
+ mock_get.side_effect = [
97
+ {"session_id": "sess_123", "status_enum": "running"},
98
+ {"session_id": "sess_123", "status_enum": "finished"}
99
+ ]
100
+
101
+ result = runner.invoke(app, ["sessions", "wait", "sess_123", "--interval", "0"])
102
+ assert result.exit_code == 0
103
+ assert "completed successfully" in result.stdout
104
+ assert mock_get.call_count == 2
105
+
106
+
107
+ @patch("devin_cli.api.repositories.get_indexing_status")
108
+ def test_repos_status_other_error(mock_status):
109
+ # Raise a non-404 APIError (e.g., 403 Forbidden)
110
+ mock_status.side_effect = APIError("Forbidden", 403)
111
+
112
+ result = runner.invoke(app, ["repos", "status", "owner/repo"])
113
+ print(result.stdout)
114
+ if result.exception:
115
+ print(result.exception)
116
+ assert result.exit_code == 3
117
+ assert "API Error" in result.stdout or "Forbidden" in result.stdout
118
+
119
+
120
+ def test_sessions_wait_negative_timeout():
121
+ result = runner.invoke(app, ["sessions", "wait", "sess_123", "--timeout", "-10s"])
122
+ assert result.exit_code == 1
123
+ assert "cannot be negative" in result.stdout
124
+
125
+ @patch("devin_cli.cli.sessions.list_sessions")
126
+ def test_sessions_list_pagination(mock_list):
127
+ mock_list.side_effect = [
128
+ {
129
+ "items": [{"session_id": "sess_1"}],
130
+ "has_next_page": True,
131
+ "end_cursor": "cur_1"
132
+ },
133
+ {
134
+ "items": [{"session_id": "sess_2"}],
135
+ "has_next_page": False,
136
+ "end_cursor": None
137
+ }
138
+ ]
139
+
140
+ with patch("devin_cli.cli.config") as mock_config:
141
+ mock_config.api_version = "v3"
142
+ result = runner.invoke(app, ["sessions", "list", "--all"])
143
+
144
+ assert result.exit_code == 0
145
+ assert "sess_1" in result.stdout
146
+ assert "sess_2" in result.stdout
147
+ assert mock_list.call_count == 2
148
+
149
+ @patch("devin_cli.cli.sessions.get_session_messages")
150
+ def test_sessions_messages_pagination(mock_get_messages):
151
+ mock_get_messages.side_effect = [
152
+ {
153
+ "messages": [{"message": "hello", "role": "user"}],
154
+ "has_next_page": True,
155
+ "end_cursor": "cur_1"
156
+ },
157
+ {
158
+ "messages": [{"message": "world", "role": "assistant"}],
159
+ "has_next_page": False,
160
+ "end_cursor": None
161
+ }
162
+ ]
163
+
164
+ with patch("devin_cli.cli.config") as mock_config:
165
+ mock_config.api_version = "v3"
166
+ result = runner.invoke(app, ["sessions", "messages", "sess_123", "--all"])
167
+
168
+ assert result.exit_code == 0
169
+ assert "hello" in result.stdout
170
+ assert "world" in result.stdout
171
+ assert mock_get_messages.call_count == 2
172
+
173
+ @patch("devin_cli.cli.consumption.get_session_consumption")
174
+ @patch("devin_cli.cli.sessions.get_session")
175
+ def test_sessions_cost_v3(mock_get_session, mock_consumption):
176
+ mock_get_session.return_value = {
177
+ "session_id": "sess_123",
178
+ "status_enum": "completed",
179
+ "acus_consumed": 15
180
+ }
181
+ mock_consumption.return_value = {"breakdown": "details"}
182
+
183
+ with patch("devin_cli.cli.config") as mock_config:
184
+ mock_config.api_version = "v3"
185
+ result = runner.invoke(app, ["sessions", "cost", "sess_123"])
186
+
187
+ assert result.exit_code == 0
188
+ assert "15" in result.stdout
189
+ assert mock_consumption.call_count == 1
190
+ assert "breakdown" in result.stdout
191
+
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes