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.
- {devin_cli-1.3.5 → devin_cli-1.3.6}/PKG-INFO +3 -2
- {devin_cli-1.3.5 → devin_cli-1.3.6}/README.md +1 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/pyproject.toml +2 -2
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/client.py +17 -2
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/sessions.py +4 -2
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/sessions.py +5 -2
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/cli.py +267 -34
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_cli_extended.py +70 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/.github/workflows/pypi-publish.yml +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/.gitignore +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/CHANGELOG.md +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/LICENSE +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/assets/logo.png +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/generate_facades.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/setup.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/__init__.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/__init__.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/attachments.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/consumption.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/knowledge.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/members.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/organizations.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/playbooks.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/repositories.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/schedules.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/secrets.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/sessions.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/__init__.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/attachments.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/knowledge.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/playbooks.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v1/secrets.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/__init__.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/attachments.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/consumption.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/knowledge.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/members.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/organizations.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/playbooks.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/repositories.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/schedules.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/api/v3/secrets.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/src/devin_cli/config.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/__init__.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/conftest.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_attachments.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_knowledge.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_playbooks.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_repositories.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_schedules.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_secrets.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_api/test_sessions.py +0 -0
- {devin_cli-1.3.5 → devin_cli-1.3.6}/tests/test_cli.py +0 -0
- {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.
|
|
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:
|
|
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.
|
|
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 = "
|
|
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=
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", "")
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|