devin-cli 1.3.5__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 (54) hide show
  1. {devin_cli-1.3.5 → devin_cli-1.3.6}/PKG-INFO +3 -2
  2. {devin_cli-1.3.5 → devin_cli-1.3.6}/README.md +1 -0
  3. {devin_cli-1.3.5 → devin_cli-1.3.6}/pyproject.toml +2 -2
  4. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/client.py +17 -2
  5. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/sessions.py +4 -2
  6. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/sessions.py +5 -2
  7. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/cli.py +267 -34
  8. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_cli_extended.py +70 -0
  9. {devin_cli-1.3.5 → devin_cli-1.3.6}/.github/workflows/pypi-publish.yml +0 -0
  10. {devin_cli-1.3.5 → devin_cli-1.3.6}/.gitignore +0 -0
  11. {devin_cli-1.3.5 → devin_cli-1.3.6}/CHANGELOG.md +0 -0
  12. {devin_cli-1.3.5 → devin_cli-1.3.6}/LICENSE +0 -0
  13. {devin_cli-1.3.5 → devin_cli-1.3.6}/assets/logo.png +0 -0
  14. {devin_cli-1.3.5 → devin_cli-1.3.6}/generate_facades.py +0 -0
  15. {devin_cli-1.3.5 → devin_cli-1.3.6}/setup.py +0 -0
  16. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/__init__.py +0 -0
  17. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/__init__.py +0 -0
  18. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/attachments.py +0 -0
  19. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/consumption.py +0 -0
  20. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/knowledge.py +0 -0
  21. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/members.py +0 -0
  22. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/organizations.py +0 -0
  23. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/playbooks.py +0 -0
  24. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/repositories.py +0 -0
  25. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/schedules.py +0 -0
  26. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/secrets.py +0 -0
  27. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/sessions.py +0 -0
  28. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/__init__.py +0 -0
  29. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/attachments.py +0 -0
  30. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/knowledge.py +0 -0
  31. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/playbooks.py +0 -0
  32. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/secrets.py +0 -0
  33. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/__init__.py +0 -0
  34. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/attachments.py +0 -0
  35. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/consumption.py +0 -0
  36. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/knowledge.py +0 -0
  37. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/members.py +0 -0
  38. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/organizations.py +0 -0
  39. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/playbooks.py +0 -0
  40. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/repositories.py +0 -0
  41. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/schedules.py +0 -0
  42. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/secrets.py +0 -0
  43. {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/config.py +0 -0
  44. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/__init__.py +0 -0
  45. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/conftest.py +0 -0
  46. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_attachments.py +0 -0
  47. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_knowledge.py +0 -0
  48. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_playbooks.py +0 -0
  49. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_repositories.py +0 -0
  50. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_schedules.py +0 -0
  51. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_secrets.py +0 -0
  52. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_sessions.py +0 -0
  53. {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_cli.py +0 -0
  54. {devin_cli-1.3.5 → 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.5
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.5"
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"]
@@ -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,35 +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
314
- def get_stable_terminal_state(status_enum: Optional[str]) -> str:
315
- if not status_enum:
316
- return "running"
317
- status = status_enum.lower()
318
- if status == "finished":
319
- return "completed"
320
- elif status in ("failed", "error"):
321
- return "failed"
322
- elif status in ("stopped", "cancelled", "terminated"):
323
- return "terminated"
324
- elif status == "blocked":
325
- return "blocked"
326
- else:
327
- return "running"
329
+
330
+ return resp
328
331
 
329
332
 
330
333
  @session_app.command("list")
@@ -333,28 +336,76 @@ def list_sessions_cmd(
333
336
  limit: int = 10,
334
337
  org: Optional[str] = typer.Option(None, "--org"),
335
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)"),
336
341
  ):
337
342
  """List sessions."""
338
343
  if org: config.temporary_org_id = org
339
- resp = sessions.list_sessions(limit=limit)
340
- sess_list = resp.get("items", resp.get("sessions", []))
341
-
342
- for s in sess_list:
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:
343
383
  if isinstance(s, dict):
344
384
  s["terminal_state"] = get_stable_terminal_state(s.get("status_enum"))
345
385
 
346
- if not sess_list and config.api_token and config.api_token.startswith("cog_"):
386
+ if not items and config.api_token and config.api_token.startswith("cog_"):
347
387
  console.print("[yellow]Warning: Service tokens (cog_) may only have visibility into sessions they explicitly created, not all organization sessions.[/yellow]")
348
388
 
349
- if json_output:
350
- 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
351
402
  else:
352
- table = Table(title="Devin Sessions")
403
+ table = Table(title="Devin Sessions" + (f" ({total} total)" if total is not None else ""))
353
404
  table.add_column("ID", style="cyan")
354
405
  table.add_column("Status", style="magenta")
355
406
  table.add_column("Terminal State", style="blue")
356
407
  table.add_column("Title")
357
- for s in sess_list:
408
+ for s in items:
358
409
  table.add_row(
359
410
  s.get("session_id"),
360
411
  s.get("status_enum"),
@@ -362,8 +413,12 @@ def list_sessions_cmd(
362
413
  s.get("title") or s.get("prompt", "")[:50]
363
414
  )
364
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]")
365
420
 
366
- return resp
421
+ return final_resp
367
422
 
368
423
 
369
424
  @session_app.command("get")
@@ -371,6 +426,7 @@ def list_sessions_cmd(
371
426
  def get_session_cmd(
372
427
  session_id: Optional[str] = typer.Argument(None),
373
428
  org: Optional[str] = typer.Option(None, "--org"),
429
+ json_output: bool = typer.Option(False, "--json"),
374
430
  ):
375
431
  """Get detailed session info."""
376
432
  if org: config.temporary_org_id = org
@@ -378,6 +434,13 @@ def get_session_cmd(
378
434
  resp = sessions.get_session(sid)
379
435
  if isinstance(resp, dict):
380
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
+
381
444
  console.print(Panel(json.dumps(resp, indent=2), title=f"Session {sid}"))
382
445
 
383
446
  return resp
@@ -387,11 +450,19 @@ def get_session_cmd(
387
450
  def session_insights_cmd(
388
451
  session_id: Optional[str] = typer.Argument(None),
389
452
  org: Optional[str] = typer.Option(None, "--org"),
453
+ json_output: bool = typer.Option(False, "--json"),
390
454
  ):
391
455
  """Get technical insights for a session (ACUs, etc)."""
392
456
  if org: config.temporary_org_id = org
393
457
  sid = session_id or get_current_session_id()
394
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
+
395
466
  if isinstance(resp, dict) and "error" in resp:
396
467
  console.print(f"[bold yellow]Note:[/bold yellow] {resp['error']}")
397
468
  console.print("Tip: Switch to v3 with [bold cyan]devin configure[/bold cyan] or use [bold cyan]--profile [v3-profile][/bold cyan]")
@@ -405,22 +476,63 @@ def session_insights_cmd(
405
476
  def session_cost_cmd(
406
477
  session_id: Optional[str] = typer.Argument(None, help="Specific session ID to check cost for"),
407
478
  org: Optional[str] = typer.Option(None, "--org"),
479
+ json_output: bool = typer.Option(False, "--json"),
408
480
  ):
409
481
  """View ACU consumption."""
410
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
+
411
487
  if session_id:
412
- 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
+
413
510
  acus = resp.get("acus_consumed") or resp.get("acu_used")
414
511
  cost_data = {
415
512
  "session_id": resp.get("session_id"),
416
513
  "status": resp.get("status_enum"),
417
514
  "acus_consumed": acus,
418
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
+
419
525
  console.print(Panel(json.dumps(cost_data, indent=2), title=f"Session Cost: {session_id}"))
420
526
  if acus is None:
421
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
422
529
  else:
423
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
+
424
536
  console.print(Panel(json.dumps(resp, indent=2), title="Daily Consumption"))
425
537
 
426
538
  return resp
@@ -455,19 +567,73 @@ def send_message_cmd(
455
567
  def list_messages_cmd(
456
568
  session_id: Optional[str] = typer.Argument(None),
457
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"),
458
574
  ):
459
575
  """List messages in a session."""
460
576
  if org: config.temporary_org_id = org
461
577
  sid = session_id or get_current_session_id()
462
- resp = sessions.get_session_messages(sid)
463
- 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
+
464
626
  for m in msgs:
465
627
  role = m.get("role", "unknown")
466
628
  content = m.get("message", "") or m.get("content", "")
467
629
  console.print(f"[bold cyan]{role}:[/bold cyan] {content}")
468
630
  console.print("---")
469
631
 
470
- 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
471
637
 
472
638
  @session_app.command("terminate")
473
639
  @handle_api_error
@@ -483,6 +649,72 @@ def terminate_session_cmd(
483
649
  console.print(f"[green]Session {sid} terminated.[/green]")
484
650
  return None
485
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
486
718
 
487
719
  def parse_duration(duration_str: str) -> float:
488
720
  if not duration_str:
@@ -994,9 +1226,10 @@ def status_repo_cmd(
994
1226
  else:
995
1227
  final_result = {"results": results}
996
1228
 
997
- if json_output or os.environ.get("DEVIN_OUTPUT_FORMAT") == "json":
998
- if os.environ.get("DEVIN_OUTPUT_FORMAT") != "json":
999
- print(json.dumps(final_result, indent=2))
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))
1000
1233
  return final_result
1001
1234
 
1002
1235
  if len(paths) == 1:
@@ -110,6 +110,9 @@ def test_repos_status_other_error(mock_status):
110
110
  mock_status.side_effect = APIError("Forbidden", 403)
111
111
 
112
112
  result = runner.invoke(app, ["repos", "status", "owner/repo"])
113
+ print(result.stdout)
114
+ if result.exception:
115
+ print(result.exception)
113
116
  assert result.exit_code == 3
114
117
  assert "API Error" in result.stdout or "Forbidden" in result.stdout
115
118
 
@@ -119,3 +122,70 @@ def test_sessions_wait_negative_timeout():
119
122
  assert result.exit_code == 1
120
123
  assert "cannot be negative" in result.stdout
121
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
+
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