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.
- {devin_cli-1.3.4 → devin_cli-1.3.6}/PKG-INFO +3 -2
- {devin_cli-1.3.4 → devin_cli-1.3.6}/README.md +1 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/pyproject.toml +2 -2
- devin_cli-1.3.6/src/devin_cli/__init__.py +1 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/client.py +17 -2
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/sessions.py +4 -2
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/sessions.py +5 -2
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/cli.py +565 -28
- devin_cli-1.3.6/tests/test_cli_extended.py +191 -0
- devin_cli-1.3.4/src/devin_cli/__init__.py +0 -1
- {devin_cli-1.3.4 → devin_cli-1.3.6}/.github/workflows/pypi-publish.yml +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/.gitignore +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/CHANGELOG.md +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/LICENSE +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/assets/logo.png +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/generate_facades.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/setup.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/__init__.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/attachments.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/consumption.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/knowledge.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/members.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/organizations.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/playbooks.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/repositories.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/schedules.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/secrets.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/sessions.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/__init__.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/attachments.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/knowledge.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/playbooks.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v1/secrets.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/__init__.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/attachments.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/consumption.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/knowledge.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/members.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/organizations.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/playbooks.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/repositories.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/schedules.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/api/v3/secrets.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/src/devin_cli/config.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/__init__.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/conftest.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_attachments.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_knowledge.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_playbooks.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_repositories.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_schedules.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_secrets.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_api/test_sessions.py +0 -0
- {devin_cli-1.3.4 → devin_cli-1.3.6}/tests/test_cli.py +0 -0
- {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.
|
|
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"]
|
|
@@ -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=
|
|
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,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", "")
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
339
|
-
table.add_row(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
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
|
|
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
|