devin-cli 0.0.1__py3-none-any.whl
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/__init__.py +1 -0
- devin_cli/api/__init__.py +1 -0
- devin_cli/api/attachments.py +12 -0
- devin_cli/api/client.py +103 -0
- devin_cli/api/knowledge.py +46 -0
- devin_cli/api/playbooks.py +27 -0
- devin_cli/api/secrets.py +7 -0
- devin_cli/api/sessions.py +57 -0
- devin_cli/cli.py +565 -0
- devin_cli/config.py +63 -0
- devin_cli-0.0.1.dist-info/METADATA +255 -0
- devin_cli-0.0.1.dist-info/RECORD +15 -0
- devin_cli-0.0.1.dist-info/WHEEL +4 -0
- devin_cli-0.0.1.dist-info/entry_points.txt +2 -0
- devin_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
devin_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# API package initialization
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from devin_cli.api.client import client
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
def upload_file(file_path: str):
|
|
5
|
+
path = Path(file_path)
|
|
6
|
+
if not path.exists():
|
|
7
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
8
|
+
|
|
9
|
+
with open(path, "rb") as f:
|
|
10
|
+
files = {"file": f}
|
|
11
|
+
# client.post handles Content-Type removal for files
|
|
12
|
+
return client.post("attachments", files=files)
|
devin_cli/api/client.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from devin_cli.config import config
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional, Any, Dict
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
class APIError(Exception):
|
|
10
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
|
|
14
|
+
class APIClient:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._token: Optional[str] = None
|
|
17
|
+
self._headers: Dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def token(self) -> Optional[str]:
|
|
21
|
+
if not self._token:
|
|
22
|
+
self._token = config.api_token
|
|
23
|
+
return self._token
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def headers(self) -> Dict[str, str]:
|
|
27
|
+
if not self._headers:
|
|
28
|
+
t = self.token
|
|
29
|
+
if t:
|
|
30
|
+
self._headers = {
|
|
31
|
+
"Authorization": f"Bearer {t}",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
}
|
|
34
|
+
return self._headers
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def BASE_URL(self) -> str:
|
|
38
|
+
return config.base_url.rstrip("/")
|
|
39
|
+
|
|
40
|
+
def _ensure_token(self):
|
|
41
|
+
if not self.token:
|
|
42
|
+
# Only raise error if meaningful operation is attempted
|
|
43
|
+
raise APIError("API token not found. Run 'devin configure' to set your API token.")
|
|
44
|
+
|
|
45
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
46
|
+
try:
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
except httpx.HTTPStatusError as e:
|
|
49
|
+
status = e.response.status_code
|
|
50
|
+
if status == 401:
|
|
51
|
+
raise APIError("Invalid or expired API token (401). Run 'devin configure'.", status)
|
|
52
|
+
elif status == 403:
|
|
53
|
+
raise APIError("Insufficient permissions (403).", status)
|
|
54
|
+
elif status == 404:
|
|
55
|
+
raise APIError("Resource not found (404).", status)
|
|
56
|
+
elif status == 429:
|
|
57
|
+
raise APIError("Rate limit exceeded (429). Please try again later.", status)
|
|
58
|
+
elif status >= 500:
|
|
59
|
+
raise APIError(f"Server error ({status}).", status)
|
|
60
|
+
else:
|
|
61
|
+
raise APIError(f"HTTP Error {status}: {e}", status)
|
|
62
|
+
|
|
63
|
+
if response.status_code == 204:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return response.json()
|
|
68
|
+
except ValueError:
|
|
69
|
+
return response.text
|
|
70
|
+
|
|
71
|
+
def request(self, method: str, endpoint: str, **kwargs) -> Any:
|
|
72
|
+
self._ensure_token()
|
|
73
|
+
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
|
74
|
+
|
|
75
|
+
# Merge headers if needed, but usually self.headers is enough
|
|
76
|
+
headers = self.headers.copy()
|
|
77
|
+
if "headers" in kwargs:
|
|
78
|
+
headers.update(kwargs.pop("headers"))
|
|
79
|
+
|
|
80
|
+
# Handle file uploads (remove Content-Type)
|
|
81
|
+
if "files" in kwargs:
|
|
82
|
+
headers.pop("Content-Type", None)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
with httpx.Client() as client:
|
|
86
|
+
response = client.request(method, url, headers=headers, **kwargs)
|
|
87
|
+
return self._handle_response(response)
|
|
88
|
+
except httpx.RequestError as e:
|
|
89
|
+
raise APIError(f"Network error: {e}")
|
|
90
|
+
|
|
91
|
+
def get(self, endpoint: str, params: Optional[Dict] = None) -> Any:
|
|
92
|
+
return self.request("GET", endpoint, params=params)
|
|
93
|
+
|
|
94
|
+
def post(self, endpoint: str, data: Optional[Dict] = None, files: Optional[Dict] = None) -> Any:
|
|
95
|
+
return self.request("POST", endpoint, json=data, files=files)
|
|
96
|
+
|
|
97
|
+
def put(self, endpoint: str, data: Optional[Dict] = None) -> Any:
|
|
98
|
+
return self.request("PUT", endpoint, json=data)
|
|
99
|
+
|
|
100
|
+
def delete(self, endpoint: str) -> Any:
|
|
101
|
+
return self.request("DELETE", endpoint)
|
|
102
|
+
|
|
103
|
+
client = APIClient()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from devin_cli.api.client import client
|
|
3
|
+
|
|
4
|
+
def list_knowledge():
|
|
5
|
+
return client.get("knowledge")
|
|
6
|
+
|
|
7
|
+
def create_knowledge(
|
|
8
|
+
name: str,
|
|
9
|
+
body: str,
|
|
10
|
+
trigger_description: str,
|
|
11
|
+
macro: str = None,
|
|
12
|
+
parent_folder_id: str = None,
|
|
13
|
+
pinned_repo: str = None
|
|
14
|
+
):
|
|
15
|
+
data = {
|
|
16
|
+
"name": name,
|
|
17
|
+
"body": body,
|
|
18
|
+
"trigger_description": trigger_description
|
|
19
|
+
}
|
|
20
|
+
if macro:
|
|
21
|
+
data["macro"] = macro
|
|
22
|
+
if parent_folder_id:
|
|
23
|
+
data["parent_folder_id"] = parent_folder_id
|
|
24
|
+
if pinned_repo:
|
|
25
|
+
data["pinned_repo"] = pinned_repo
|
|
26
|
+
|
|
27
|
+
return client.post("knowledge", data=data)
|
|
28
|
+
|
|
29
|
+
def update_knowledge(
|
|
30
|
+
knowledge_id: str,
|
|
31
|
+
name: str = None,
|
|
32
|
+
body: str = None,
|
|
33
|
+
trigger_description: str = None
|
|
34
|
+
):
|
|
35
|
+
data = {}
|
|
36
|
+
if name:
|
|
37
|
+
data["name"] = name
|
|
38
|
+
if body:
|
|
39
|
+
data["body"] = body
|
|
40
|
+
if trigger_description:
|
|
41
|
+
data["trigger_description"] = trigger_description
|
|
42
|
+
|
|
43
|
+
return client.put(f"knowledge/{knowledge_id}", data=data)
|
|
44
|
+
|
|
45
|
+
def delete_knowledge(knowledge_id: str):
|
|
46
|
+
return client.delete(f"knowledge/{knowledge_id}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from devin_cli.api.client import client
|
|
3
|
+
|
|
4
|
+
def list_playbooks():
|
|
5
|
+
return client.get("playbooks")
|
|
6
|
+
|
|
7
|
+
def create_playbook(title: str, body: str, macro: str = None):
|
|
8
|
+
data = {"title": title, "body": body}
|
|
9
|
+
if macro:
|
|
10
|
+
data["macro"] = macro
|
|
11
|
+
return client.post("playbooks", data=data)
|
|
12
|
+
|
|
13
|
+
def get_playbook(playbook_id: str):
|
|
14
|
+
return client.get(f"playbooks/{playbook_id}")
|
|
15
|
+
|
|
16
|
+
def update_playbook(playbook_id: str, title: str = None, body: str = None, macro: str = None):
|
|
17
|
+
data = {}
|
|
18
|
+
if title:
|
|
19
|
+
data["title"] = title
|
|
20
|
+
if body:
|
|
21
|
+
data["body"] = body
|
|
22
|
+
if macro:
|
|
23
|
+
data["macro"] = macro
|
|
24
|
+
return client.put(f"playbooks/{playbook_id}", data=data)
|
|
25
|
+
|
|
26
|
+
def delete_playbook(playbook_id: str):
|
|
27
|
+
return client.delete(f"playbooks/{playbook_id}")
|
devin_cli/api/secrets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from devin_cli.api.client import client
|
|
3
|
+
|
|
4
|
+
def list_sessions(limit: int = 100, offset: int = 0, tags: List[str] = None):
|
|
5
|
+
params = {"limit": limit, "offset": offset}
|
|
6
|
+
if tags:
|
|
7
|
+
params["tags"] = tags
|
|
8
|
+
return client.get("sessions", params=params)
|
|
9
|
+
|
|
10
|
+
def create_session(
|
|
11
|
+
prompt: str,
|
|
12
|
+
idempotent: bool = False,
|
|
13
|
+
snapshot_id: str = None,
|
|
14
|
+
playbook_id: str = None,
|
|
15
|
+
unlisted: bool = False,
|
|
16
|
+
tags: List[str] = None,
|
|
17
|
+
session_secrets: List[dict] = None,
|
|
18
|
+
title: Optional[str] = None,
|
|
19
|
+
knowledge_ids: Optional[List[str]] = None,
|
|
20
|
+
secret_ids: Optional[List[str]] = None,
|
|
21
|
+
max_acu_limit: Optional[int] = None,
|
|
22
|
+
):
|
|
23
|
+
data = {
|
|
24
|
+
"prompt": prompt,
|
|
25
|
+
"idempotent": idempotent,
|
|
26
|
+
"unlisted": unlisted
|
|
27
|
+
}
|
|
28
|
+
if snapshot_id:
|
|
29
|
+
data["snapshot_id"] = snapshot_id
|
|
30
|
+
if playbook_id:
|
|
31
|
+
data["playbook_id"] = playbook_id
|
|
32
|
+
if tags:
|
|
33
|
+
data["tags"] = tags
|
|
34
|
+
if session_secrets:
|
|
35
|
+
data["session_secrets"] = session_secrets
|
|
36
|
+
if title:
|
|
37
|
+
data["title"] = title
|
|
38
|
+
if knowledge_ids:
|
|
39
|
+
data["knowledge_ids"] = knowledge_ids
|
|
40
|
+
if secret_ids:
|
|
41
|
+
data["secret_ids"] = secret_ids
|
|
42
|
+
if max_acu_limit:
|
|
43
|
+
data["max_acu_limit"] = max_acu_limit
|
|
44
|
+
|
|
45
|
+
return client.post("sessions", data=data)
|
|
46
|
+
|
|
47
|
+
def get_session(session_id: str):
|
|
48
|
+
return client.get(f"sessions/{session_id}")
|
|
49
|
+
|
|
50
|
+
def send_message(session_id: str, message: str):
|
|
51
|
+
return client.post(f"sessions/{session_id}/message", data={"message": message})
|
|
52
|
+
|
|
53
|
+
def update_session_tags(session_id: str, tags: List[str]):
|
|
54
|
+
return client.put(f"sessions/{session_id}/tags", data={"tags": tags})
|
|
55
|
+
|
|
56
|
+
def terminate_session(session_id: str):
|
|
57
|
+
return client.delete(f"sessions/{session_id}")
|
devin_cli/cli.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import yaml
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from devin_cli.config import config
|
|
14
|
+
from devin_cli.api import sessions, knowledge, playbooks, secrets, attachments
|
|
15
|
+
from devin_cli.api.client import client, APIError
|
|
16
|
+
import functools
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Unofficial CLI for Devin AI", no_args_is_help=True)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
def handle_api_error(func):
|
|
22
|
+
"""Decorator to handle API errors gracefully."""
|
|
23
|
+
@functools.wraps(func)
|
|
24
|
+
def wrapper(*args, **kwargs):
|
|
25
|
+
try:
|
|
26
|
+
return func(*args, **kwargs)
|
|
27
|
+
except APIError as e:
|
|
28
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
29
|
+
if e.status_code == 401:
|
|
30
|
+
console.print("Tip: Check your API token with 'devin configure'.")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
return wrapper
|
|
36
|
+
|
|
37
|
+
def get_current_session_id():
|
|
38
|
+
sid = config.current_session_id
|
|
39
|
+
if not sid:
|
|
40
|
+
console.print("[bold red]Error:[/bold red] No active session. Create one with [bold cyan]create-session[/bold cyan] or use [bold cyan]use-session[/bold cyan].")
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
return sid
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def configure(
|
|
46
|
+
token: str = typer.Option(..., prompt="Devin API Token (starts with apk_user_ or apk_)", help="Your Devin API Token"),
|
|
47
|
+
base_url: str = typer.Option("https://api.devin.ai/v1", prompt="Devin API Base URL", help="Devin API Base URL"),
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Configure the CLI with your Devin API token.
|
|
51
|
+
"""
|
|
52
|
+
if not (token.startswith("apk_user_") or token.startswith("apk_")):
|
|
53
|
+
console.print("[bold red]Error:[/bold red] Invalid token format. Must start with 'apk_user_' or 'apk_'.")
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
config.api_token = token
|
|
57
|
+
config.base_url = base_url
|
|
58
|
+
console.print(f"[green]Token and Base URL saved to {config.CONFIG_FILE}[/green]")
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
@handle_api_error
|
|
62
|
+
def create_session(
|
|
63
|
+
prompt: Optional[str] = typer.Argument(None, help="The prompt for the session"),
|
|
64
|
+
file: Optional[Path] = typer.Option(None, "--file", "-f", help="Read prompt from file"),
|
|
65
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="Custom session title"),
|
|
66
|
+
idempotent: bool = typer.Option(False, "--idempotent", "-i", help="Idempotent creation"),
|
|
67
|
+
secrets: List[str] = typer.Option(None, "--secret", "-s", help="Session secrets in KEY=VALUE format"),
|
|
68
|
+
knowledge_ids: List[str] = typer.Option(None, "--knowledge-id", "-k", help="Knowledge IDs to include"),
|
|
69
|
+
secret_ids: List[str] = typer.Option(None, "--secret-id", help="Stored secret IDs to include"),
|
|
70
|
+
max_acu_limit: Optional[int] = typer.Option(None, "--max-acu", help="Maximum ACU limit"),
|
|
71
|
+
unlisted: bool = typer.Option(False, help="Create unlisted session"),
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Create a new Devin session.
|
|
75
|
+
"""
|
|
76
|
+
if file:
|
|
77
|
+
if not file.exists():
|
|
78
|
+
console.print(f"[bold red]Error:[/bold red] File not found: {file}")
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
prompt_text = file.read_text()
|
|
81
|
+
elif prompt:
|
|
82
|
+
prompt_text = prompt
|
|
83
|
+
else:
|
|
84
|
+
console.print("[bold red]Error:[/bold red] Must provide prompt argument or --file option")
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
|
|
87
|
+
parsed_secrets = []
|
|
88
|
+
if secrets:
|
|
89
|
+
for s in secrets:
|
|
90
|
+
if "=" not in s:
|
|
91
|
+
console.print(f"[bold yellow]Warning:[/bold yellow] Invalid secret format '{s}', skipping. Use KEY=VALUE.")
|
|
92
|
+
continue
|
|
93
|
+
k, v = s.split("=", 1)
|
|
94
|
+
parsed_secrets.append({"key": k, "value": v})
|
|
95
|
+
|
|
96
|
+
with console.status("[bold green]Creating session...[/bold green]"):
|
|
97
|
+
resp = sessions.create_session(
|
|
98
|
+
prompt=prompt_text,
|
|
99
|
+
idempotent=idempotent,
|
|
100
|
+
unlisted=unlisted,
|
|
101
|
+
session_secrets=parsed_secrets,
|
|
102
|
+
title=title,
|
|
103
|
+
knowledge_ids=knowledge_ids,
|
|
104
|
+
secret_ids=secret_ids,
|
|
105
|
+
max_acu_limit=max_acu_limit
|
|
106
|
+
)
|
|
107
|
+
session_id = resp["session_id"]
|
|
108
|
+
config.current_session_id = session_id
|
|
109
|
+
console.print(f"[green]Session created:[/green] {session_id} (url: {resp['url']})")
|
|
110
|
+
|
|
111
|
+
@app.command()
|
|
112
|
+
@handle_api_error
|
|
113
|
+
def list_sessions(
|
|
114
|
+
limit: int = typer.Option(10, help="Number of sessions to list"),
|
|
115
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
116
|
+
):
|
|
117
|
+
"""
|
|
118
|
+
List your Devin sessions.
|
|
119
|
+
"""
|
|
120
|
+
resp = sessions.list_sessions(limit=limit)
|
|
121
|
+
sess_list = resp.get("sessions", [])
|
|
122
|
+
|
|
123
|
+
if json_output:
|
|
124
|
+
console.print(json.dumps(sess_list, indent=2))
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
table = Table(title="Devin Sessions")
|
|
128
|
+
table.add_column("ID", style="cyan")
|
|
129
|
+
table.add_column("Status", style="magenta")
|
|
130
|
+
table.add_column("Title")
|
|
131
|
+
table.add_column("Created At", style="dim")
|
|
132
|
+
|
|
133
|
+
for s in sess_list:
|
|
134
|
+
table.add_row(
|
|
135
|
+
s.get("session_id"),
|
|
136
|
+
s.get("status_enum"),
|
|
137
|
+
s.get("title") or s.get("prompt", "")[:50],
|
|
138
|
+
s.get("created_at")
|
|
139
|
+
)
|
|
140
|
+
console.print(table)
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
@handle_api_error
|
|
144
|
+
def get_session(
|
|
145
|
+
session_id: Optional[str] = typer.Argument(None, help="Session ID (defaults to current)"),
|
|
146
|
+
):
|
|
147
|
+
"""
|
|
148
|
+
Get details for a session.
|
|
149
|
+
"""
|
|
150
|
+
sid = session_id or get_current_session_id()
|
|
151
|
+
resp = sessions.get_session(sid)
|
|
152
|
+
console.print(Panel(
|
|
153
|
+
f"[bold]Status:[/bold] {resp.get('status_enum')}\n"
|
|
154
|
+
f"[bold]URL:[/bold] {resp.get('url')}\n"
|
|
155
|
+
f"[bold]Created:[/bold] {resp.get('created_at')}",
|
|
156
|
+
title=f"Session {sid}"
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
# recursive structured output display could be complex, keeping it simple for now
|
|
160
|
+
if "structured_output" in resp:
|
|
161
|
+
console.print("[bold]Structured Output:[/bold]")
|
|
162
|
+
console.print(json.dumps(resp["structured_output"], indent=2))
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
@handle_api_error
|
|
166
|
+
def message(
|
|
167
|
+
text: Optional[str] = typer.Argument(None, help="Message text"),
|
|
168
|
+
file: Optional[Path] = typer.Option(None, "--file", "-f", help="Read message from file"),
|
|
169
|
+
session_id: Optional[str] = typer.Option(None, "--session-id", help="Target session ID"),
|
|
170
|
+
):
|
|
171
|
+
"""
|
|
172
|
+
Send a message to a session.
|
|
173
|
+
"""
|
|
174
|
+
sid = session_id or get_current_session_id()
|
|
175
|
+
if file:
|
|
176
|
+
if not file.exists():
|
|
177
|
+
console.print(f"[bold red]Error:[/bold red] File not found: {file}")
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
msg_text = file.read_text()
|
|
180
|
+
elif text:
|
|
181
|
+
msg_text = text
|
|
182
|
+
else:
|
|
183
|
+
console.print("[bold red]Error:[/bold red] Must provide message text or --file")
|
|
184
|
+
raise typer.Exit(1)
|
|
185
|
+
|
|
186
|
+
sessions.send_message(sid, msg_text)
|
|
187
|
+
console.print(f"[green]Message sent to session {sid}[/green]")
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
@handle_api_error
|
|
191
|
+
def watch(
|
|
192
|
+
session_id: Optional[str] = typer.Argument(None, help="Session ID (defaults to current)"),
|
|
193
|
+
):
|
|
194
|
+
"""
|
|
195
|
+
Watch a session's progress live.
|
|
196
|
+
"""
|
|
197
|
+
sid = session_id or get_current_session_id()
|
|
198
|
+
console.print(f"Watching session {sid}. Press Ctrl+C to stop.")
|
|
199
|
+
|
|
200
|
+
backoff = 1
|
|
201
|
+
try:
|
|
202
|
+
with Live(console=console, refresh_per_second=4) as live:
|
|
203
|
+
while True:
|
|
204
|
+
resp = sessions.get_session(sid)
|
|
205
|
+
status = resp.get("status_enum")
|
|
206
|
+
|
|
207
|
+
content = Text()
|
|
208
|
+
content.append(f"Status: {status}\n", style="bold magenta")
|
|
209
|
+
|
|
210
|
+
so = resp.get("structured_output")
|
|
211
|
+
if so:
|
|
212
|
+
content.append(json.dumps(so, indent=2))
|
|
213
|
+
else:
|
|
214
|
+
content.append("(No structured output yet)")
|
|
215
|
+
|
|
216
|
+
live.update(Panel(content, title=f"Session {sid}"))
|
|
217
|
+
|
|
218
|
+
if status in ["blocked", "finished"]:
|
|
219
|
+
console.print(f"[bold green]Session {status}![/bold green]")
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
time.sleep(min(backoff, 30))
|
|
223
|
+
backoff = min(backoff * 1.5, 30)
|
|
224
|
+
|
|
225
|
+
except KeyboardInterrupt:
|
|
226
|
+
console.print("\nStopped watching.")
|
|
227
|
+
|
|
228
|
+
@app.command()
|
|
229
|
+
@handle_api_error
|
|
230
|
+
def update_tags(
|
|
231
|
+
session_id: Optional[str] = typer.Argument(None, help="Session ID (defaults to current)"),
|
|
232
|
+
tags: List[str] = typer.Option(..., "--tag", "-t", help="Tags to set (overwrites existing)"),
|
|
233
|
+
):
|
|
234
|
+
"""Update tags for a session."""
|
|
235
|
+
sid = session_id or get_current_session_id()
|
|
236
|
+
resp = sessions.update_session_tags(sid, tags)
|
|
237
|
+
console.print(f"[green]Tags updated for session {sid}.[/green]")
|
|
238
|
+
|
|
239
|
+
@app.command()
|
|
240
|
+
@handle_api_error
|
|
241
|
+
def terminate(
|
|
242
|
+
session_id: Optional[str] = typer.Argument(None, help="Session ID (defaults to current)"),
|
|
243
|
+
):
|
|
244
|
+
"""
|
|
245
|
+
Terminate a session.
|
|
246
|
+
"""
|
|
247
|
+
sid = session_id or get_current_session_id()
|
|
248
|
+
if typer.confirm(f"Are you sure you want to terminate session {sid}?"):
|
|
249
|
+
sessions.terminate_session(sid)
|
|
250
|
+
console.print(f"[green]Session {sid} terminated.[/green]")
|
|
251
|
+
|
|
252
|
+
# ... (rest of the file remains same, adding update-knowledge/playbook below create commands)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
@handle_api_error
|
|
257
|
+
def upload(
|
|
258
|
+
file: Path = typer.Argument(..., help="File to upload"),
|
|
259
|
+
):
|
|
260
|
+
"""
|
|
261
|
+
Upload a file to Devin (returns attachment URL).
|
|
262
|
+
"""
|
|
263
|
+
resp = attachments.upload_file(str(file))
|
|
264
|
+
# API returns raw string URL in response body, sometimes quoted?
|
|
265
|
+
# Let's clean it up if it's JSON string
|
|
266
|
+
url = resp
|
|
267
|
+
if isinstance(resp, str):
|
|
268
|
+
url = resp.strip('"')
|
|
269
|
+
|
|
270
|
+
console.print(f"[green]File uploaded:[/green] {url}")
|
|
271
|
+
return url
|
|
272
|
+
|
|
273
|
+
@app.command()
|
|
274
|
+
@handle_api_error
|
|
275
|
+
def attach(
|
|
276
|
+
file: Path = typer.Argument(..., help="File to attach"),
|
|
277
|
+
prompt: str = typer.Argument(..., help="Prompt for the session"),
|
|
278
|
+
):
|
|
279
|
+
"""
|
|
280
|
+
Upload a file and create a session with it attached.
|
|
281
|
+
"""
|
|
282
|
+
# Use api modules directly to avoid typer recursion issues
|
|
283
|
+
resp = attachments.upload_file(str(file))
|
|
284
|
+
url = resp if isinstance(resp, str) else str(resp)
|
|
285
|
+
url = url.strip('"')
|
|
286
|
+
|
|
287
|
+
full_prompt = f"{prompt}\n\nATTACHMENT: \"{url}\""
|
|
288
|
+
|
|
289
|
+
with console.status("[bold green]Creating session with attachment...[/bold green]"):
|
|
290
|
+
resp = sessions.create_session(prompt=full_prompt)
|
|
291
|
+
session_id = resp["session_id"]
|
|
292
|
+
config.current_session_id = session_id
|
|
293
|
+
console.print(f"[green]Session created:[/green] {session_id} (url: {resp['url']})")
|
|
294
|
+
|
|
295
|
+
@app.command()
|
|
296
|
+
def use_session(session_id: str):
|
|
297
|
+
"""
|
|
298
|
+
Switch the current active session.
|
|
299
|
+
"""
|
|
300
|
+
config.current_session_id = session_id
|
|
301
|
+
console.print(f"[green]Switched to session {session_id}[/green]")
|
|
302
|
+
|
|
303
|
+
@app.command()
|
|
304
|
+
@handle_api_error
|
|
305
|
+
def open():
|
|
306
|
+
"""
|
|
307
|
+
Open the current session in your browser.
|
|
308
|
+
"""
|
|
309
|
+
sid = get_current_session_id()
|
|
310
|
+
resp = sessions.get_session(sid)
|
|
311
|
+
url = resp.get("url")
|
|
312
|
+
if url:
|
|
313
|
+
webbrowser.open(url)
|
|
314
|
+
console.print(f"Opening {url}...")
|
|
315
|
+
else:
|
|
316
|
+
console.print("[yellow]No URL found for this session.[/yellow]")
|
|
317
|
+
|
|
318
|
+
@app.command()
|
|
319
|
+
@handle_api_error
|
|
320
|
+
def status():
|
|
321
|
+
"""
|
|
322
|
+
Get the status of the current session.
|
|
323
|
+
"""
|
|
324
|
+
sid = get_current_session_id()
|
|
325
|
+
resp = sessions.get_session(sid)
|
|
326
|
+
console.print(f"Session {sid}: [bold {GetStatusColor(resp.get('status_enum'))}]{resp.get('status_enum')}[/]")
|
|
327
|
+
|
|
328
|
+
def GetStatusColor(status):
|
|
329
|
+
if status == 'working': return 'green'
|
|
330
|
+
if status == 'blocked': return 'red'
|
|
331
|
+
if status == 'finished': return 'blue'
|
|
332
|
+
return 'white'
|
|
333
|
+
|
|
334
|
+
@app.command()
|
|
335
|
+
def history():
|
|
336
|
+
"""
|
|
337
|
+
Show locally known recent sessions (placeholder).
|
|
338
|
+
"""
|
|
339
|
+
sid = config.current_session_id
|
|
340
|
+
if sid:
|
|
341
|
+
console.print(f"Current local session: {sid}")
|
|
342
|
+
else:
|
|
343
|
+
console.print("No current local session.")
|
|
344
|
+
|
|
345
|
+
@app.command()
|
|
346
|
+
@handle_api_error
|
|
347
|
+
def messages(
|
|
348
|
+
session_id: Optional[str] = typer.Argument(None, help="Session ID"),
|
|
349
|
+
):
|
|
350
|
+
"""
|
|
351
|
+
Show conversation history for a session.
|
|
352
|
+
"""
|
|
353
|
+
sid = session_id or get_current_session_id()
|
|
354
|
+
resp = sessions.get_session(sid)
|
|
355
|
+
msgs = resp.get("messages", [])
|
|
356
|
+
console.print(f"[bold]Conversation for Session {sid}[/bold]")
|
|
357
|
+
console.print("โโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
358
|
+
for m in msgs:
|
|
359
|
+
console.print(m)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Knowledge Commands
|
|
363
|
+
@app.command()
|
|
364
|
+
@handle_api_error
|
|
365
|
+
def list_knowledge():
|
|
366
|
+
"""List all knowledge entries."""
|
|
367
|
+
resp = knowledge.list_knowledge()
|
|
368
|
+
items = resp.get("knowledge", [])
|
|
369
|
+
table = Table(title="Knowledge Base")
|
|
370
|
+
table.add_column("ID", style="cyan")
|
|
371
|
+
table.add_column("Name")
|
|
372
|
+
table.add_column("Created At", style="dim")
|
|
373
|
+
for item in items:
|
|
374
|
+
table.add_row(item.get("id"), item.get("name"), item.get("created_at"))
|
|
375
|
+
console.print(table)
|
|
376
|
+
|
|
377
|
+
@app.command()
|
|
378
|
+
@handle_api_error
|
|
379
|
+
def create_knowledge(
|
|
380
|
+
name: str = typer.Argument(..., help="Name of the knowledge entry"),
|
|
381
|
+
body: str = typer.Argument(..., help="Content/Body of the knowledge"),
|
|
382
|
+
trigger_description: str = typer.Argument(..., help="Description of when this knowledge should be used"),
|
|
383
|
+
):
|
|
384
|
+
"""Create a new knowledge entry."""
|
|
385
|
+
resp = knowledge.create_knowledge(name, body, trigger_description)
|
|
386
|
+
console.print(f"[green]Knowledge created:[/green] {resp.get('id') or resp}")
|
|
387
|
+
|
|
388
|
+
@app.command()
|
|
389
|
+
@handle_api_error
|
|
390
|
+
def update_knowledge(
|
|
391
|
+
knowledge_id: str = typer.Argument(..., help="ID of the knowledge to update"),
|
|
392
|
+
name: Optional[str] = typer.Option(None, help="New name"),
|
|
393
|
+
body: Optional[str] = typer.Option(None, help="New content/body"),
|
|
394
|
+
trigger: Optional[str] = typer.Option(None, "--trigger", help="New trigger description"),
|
|
395
|
+
):
|
|
396
|
+
"""Update an existing knowledge entry."""
|
|
397
|
+
resp = knowledge.update_knowledge(knowledge_id, name=name, body=body, trigger_description=trigger)
|
|
398
|
+
console.print(f"[green]Knowledge {knowledge_id} updated.[/green]")
|
|
399
|
+
|
|
400
|
+
@app.command()
|
|
401
|
+
@handle_api_error
|
|
402
|
+
def delete_knowledge(knowledge_id: str):
|
|
403
|
+
"""Delete a knowledge entry."""
|
|
404
|
+
if typer.confirm(f"Are you sure you want to delete knowledge {knowledge_id}?"):
|
|
405
|
+
knowledge.delete_knowledge(knowledge_id)
|
|
406
|
+
console.print(f"[green]Knowledge {knowledge_id} deleted.[/green]")
|
|
407
|
+
|
|
408
|
+
# Playbook Commands
|
|
409
|
+
@app.command()
|
|
410
|
+
@handle_api_error
|
|
411
|
+
def list_playbooks():
|
|
412
|
+
"""List all playbooks."""
|
|
413
|
+
resp = playbooks.list_playbooks()
|
|
414
|
+
if isinstance(resp, list):
|
|
415
|
+
table = Table(title="Playbooks")
|
|
416
|
+
table.add_column("ID", style="cyan")
|
|
417
|
+
table.add_column("Title")
|
|
418
|
+
table.add_column("Macro")
|
|
419
|
+
for item in resp:
|
|
420
|
+
table.add_row(item.get("playbook_id"), item.get("title"), item.get("macro") or "-")
|
|
421
|
+
console.print(table)
|
|
422
|
+
else:
|
|
423
|
+
console.print(json.dumps(resp, indent=2))
|
|
424
|
+
|
|
425
|
+
@app.command()
|
|
426
|
+
@handle_api_error
|
|
427
|
+
def create_playbook(
|
|
428
|
+
title: str = typer.Argument(..., help="Title of the playbook"),
|
|
429
|
+
body: str = typer.Argument(..., help="Instructions/Body of the playbook"),
|
|
430
|
+
macro: Optional[str] = typer.Option(None, help="Associated macro name"),
|
|
431
|
+
):
|
|
432
|
+
"""Create a new team playbook."""
|
|
433
|
+
resp = playbooks.create_playbook(title, body, macro)
|
|
434
|
+
console.print(f"[green]Playbook created:[/green] {resp.get('playbook_id') or resp}")
|
|
435
|
+
|
|
436
|
+
@app.command()
|
|
437
|
+
@handle_api_error
|
|
438
|
+
def update_playbook(
|
|
439
|
+
playbook_id: str = typer.Argument(..., help="ID of the playbook to update"),
|
|
440
|
+
title: Optional[str] = typer.Option(None, help="New title"),
|
|
441
|
+
body: Optional[str] = typer.Option(None, help="New instructions"),
|
|
442
|
+
macro: Optional[str] = typer.Option(None, help="New macro name"),
|
|
443
|
+
):
|
|
444
|
+
"""Update an existing team playbook."""
|
|
445
|
+
resp = playbooks.update_playbook(playbook_id, title=title, body=body, macro=macro)
|
|
446
|
+
console.print(f"[green]Playbook {playbook_id} updated.[/green]")
|
|
447
|
+
|
|
448
|
+
@app.command()
|
|
449
|
+
@handle_api_error
|
|
450
|
+
def delete_playbook(playbook_id: str):
|
|
451
|
+
"""Delete a team playbook."""
|
|
452
|
+
if typer.confirm(f"Are you sure you want to delete playbook {playbook_id}?"):
|
|
453
|
+
playbooks.delete_playbook(playbook_id)
|
|
454
|
+
console.print(f"[green]Playbook {playbook_id} deleted.[/green]")
|
|
455
|
+
|
|
456
|
+
# Secret Commands
|
|
457
|
+
@app.command()
|
|
458
|
+
@handle_api_error
|
|
459
|
+
def list_secrets():
|
|
460
|
+
"""List all organization secrets."""
|
|
461
|
+
resp = secrets.list_secrets()
|
|
462
|
+
if isinstance(resp, list):
|
|
463
|
+
table = Table(title="Organization Secrets")
|
|
464
|
+
table.add_column("ID", style="cyan")
|
|
465
|
+
table.add_column("Name")
|
|
466
|
+
for item in resp:
|
|
467
|
+
table.add_row(item.get("id"), item.get("name"))
|
|
468
|
+
console.print(table)
|
|
469
|
+
else:
|
|
470
|
+
console.print(json.dumps(resp, indent=2))
|
|
471
|
+
|
|
472
|
+
@app.command()
|
|
473
|
+
@handle_api_error
|
|
474
|
+
def delete_secret(secret_id: str):
|
|
475
|
+
"""Delete an organization secret."""
|
|
476
|
+
if typer.confirm(f"Are you sure you want to delete secret {secret_id}?"):
|
|
477
|
+
secrets.delete_secret(secret_id)
|
|
478
|
+
console.print(f"[green]Secret {secret_id} deleted.[/green]")
|
|
479
|
+
|
|
480
|
+
# Chain Command
|
|
481
|
+
@app.command()
|
|
482
|
+
@handle_api_error
|
|
483
|
+
def chain(
|
|
484
|
+
prompt: Optional[str] = typer.Argument(None, help="Initial prompt"),
|
|
485
|
+
playbooks_arg: Optional[str] = typer.Option(None, "--playbooks", help="Comma-separated playbook IDs"),
|
|
486
|
+
file: Optional[Path] = typer.Option(None, "--file", help="Workflow YAML file"),
|
|
487
|
+
):
|
|
488
|
+
"""
|
|
489
|
+
(Beta) Run a chain of playbooks.
|
|
490
|
+
"""
|
|
491
|
+
steps = []
|
|
492
|
+
|
|
493
|
+
if file:
|
|
494
|
+
if not file.exists():
|
|
495
|
+
console.print(f"[bold red]Error:[/bold red] File not found: {file}")
|
|
496
|
+
raise typer.Exit(1)
|
|
497
|
+
try:
|
|
498
|
+
workflow = yaml.safe_load(file.read_text())
|
|
499
|
+
steps = workflow.get("steps", [])
|
|
500
|
+
except Exception as e:
|
|
501
|
+
console.print(f"[bold red]Error parsing YAML:[/bold red] {e}")
|
|
502
|
+
raise typer.Exit(1)
|
|
503
|
+
elif prompt and playbooks_arg:
|
|
504
|
+
k_list = [p.strip() for p in playbooks_arg.split(",")]
|
|
505
|
+
# For inline chain, we assume same prompt? Or maybe prompt is just for first?
|
|
506
|
+
# The prompt is used to start the session.
|
|
507
|
+
# Then we apply playbooks?
|
|
508
|
+
# Wait, create_session takes ONE playbook_id.
|
|
509
|
+
# So chain probably means:
|
|
510
|
+
# 1. Create session with prompt + playbook[0]
|
|
511
|
+
# 2. Wait for finish
|
|
512
|
+
# 3. Message session "Proceed with playbook <playbook[1]>"?
|
|
513
|
+
# Or maybe we can't "apply" a playbook mid-session via API easily unless we use message instructions.
|
|
514
|
+
# "Use playbook X" might be a natural language instruction Devin understands if it has access to playbooks.
|
|
515
|
+
# Let's assume we pass prompt for step 1, then for subsequent steps we pass "Execute playbook X".
|
|
516
|
+
|
|
517
|
+
for i, pb in enumerate(k_list):
|
|
518
|
+
step_prompt = prompt if i == 0 else f"Execute playbook: {pb}"
|
|
519
|
+
steps.append({"prompt": step_prompt, "playbook": pb})
|
|
520
|
+
else:
|
|
521
|
+
console.print("[bold red]Error:[/bold red] Must provide --file OR (prompt and --playbooks)")
|
|
522
|
+
raise typer.Exit(1)
|
|
523
|
+
|
|
524
|
+
# Execute Chain
|
|
525
|
+
current_sid = None
|
|
526
|
+
|
|
527
|
+
for i, step in enumerate(steps):
|
|
528
|
+
step_prompt = step.get("prompt", "")
|
|
529
|
+
step_pb = step.get("playbook")
|
|
530
|
+
|
|
531
|
+
console.print(f"[bold cyan]Step {i+1}/{len(steps)}:[/bold cyan] Playbook={step_pb}")
|
|
532
|
+
|
|
533
|
+
if i == 0:
|
|
534
|
+
# Create session
|
|
535
|
+
with console.status(f"Starting session with playbook {step_pb}..."):
|
|
536
|
+
resp = sessions.create_session(prompt=step_prompt, playbook_id=step_pb)
|
|
537
|
+
current_sid = resp["session_id"]
|
|
538
|
+
config.current_session_id = current_sid
|
|
539
|
+
console.print(f"[green]Session started:[/green] {current_sid}")
|
|
540
|
+
else:
|
|
541
|
+
# Send message
|
|
542
|
+
# Note: API does not have "switch playbook" endpoint.
|
|
543
|
+
# We just send a message. Hope Devin understands "Use playbook X".
|
|
544
|
+
# Actually, if we want to change playbook strictly, maybe we can't?
|
|
545
|
+
# But the user asked for "chaining".
|
|
546
|
+
# Let's assume sending a message is the way.
|
|
547
|
+
console.print(f"Sending instruction for next step...")
|
|
548
|
+
sessions.send_message(current_sid, f"{step_prompt} (Playbook: {step_pb})")
|
|
549
|
+
|
|
550
|
+
# Watch
|
|
551
|
+
console.print(f"Watching step {i+1}...")
|
|
552
|
+
backoff = 1
|
|
553
|
+
while True:
|
|
554
|
+
resp = sessions.get_session(current_sid)
|
|
555
|
+
status = resp.get("status_enum")
|
|
556
|
+
if status in ["blocked", "finished"]:
|
|
557
|
+
console.print(f"Step {i+1} finished (status: {status}).")
|
|
558
|
+
break
|
|
559
|
+
time.sleep(min(backoff, 10))
|
|
560
|
+
backoff *= 1.5
|
|
561
|
+
|
|
562
|
+
console.print("[bold green]Chain completed![/bold green]")
|
|
563
|
+
|
|
564
|
+
if __name__ == "__main__":
|
|
565
|
+
app()
|
devin_cli/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".config" / "devin"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
8
|
+
|
|
9
|
+
class Config:
|
|
10
|
+
def __init__(self, config_dir: Path = None):
|
|
11
|
+
self._config_dir = config_dir or CONFIG_DIR
|
|
12
|
+
self._config_file = self._config_dir / "config.json"
|
|
13
|
+
self._ensure_config_exists()
|
|
14
|
+
self._load()
|
|
15
|
+
|
|
16
|
+
def _ensure_config_exists(self):
|
|
17
|
+
if not self._config_dir.exists():
|
|
18
|
+
self._config_dir.mkdir(parents=True, mode=0o700)
|
|
19
|
+
if not self._config_file.exists():
|
|
20
|
+
with open(self._config_file, "w") as f:
|
|
21
|
+
json.dump({}, f)
|
|
22
|
+
self._config_file.chmod(0o600)
|
|
23
|
+
|
|
24
|
+
def _load(self):
|
|
25
|
+
try:
|
|
26
|
+
with open(self._config_file, "r") as f:
|
|
27
|
+
self._data = json.load(f)
|
|
28
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
29
|
+
self._data = {}
|
|
30
|
+
|
|
31
|
+
def _save(self):
|
|
32
|
+
with open(self._config_file, "w") as f:
|
|
33
|
+
json.dump(self._data, f, indent=2)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def api_token(self) -> Optional[str]:
|
|
37
|
+
# Env var takes precedence
|
|
38
|
+
return os.environ.get("DEVIN_API_TOKEN") or self._data.get("api_token")
|
|
39
|
+
|
|
40
|
+
@api_token.setter
|
|
41
|
+
def api_token(self, value: str):
|
|
42
|
+
self._data["api_token"] = value
|
|
43
|
+
self._save()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def base_url(self) -> str:
|
|
47
|
+
return os.environ.get("DEVIN_BASE_URL") or self._data.get("base_url", "https://api.devin.ai/v1")
|
|
48
|
+
|
|
49
|
+
@base_url.setter
|
|
50
|
+
def base_url(self, value: str):
|
|
51
|
+
self._data["base_url"] = value
|
|
52
|
+
self._save()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def current_session_id(self) -> Optional[str]:
|
|
56
|
+
return self._data.get("current_session_id")
|
|
57
|
+
|
|
58
|
+
@current_session_id.setter
|
|
59
|
+
def current_session_id(self, value: str):
|
|
60
|
+
self._data["current_session_id"] = value
|
|
61
|
+
self._save()
|
|
62
|
+
|
|
63
|
+
config = Config()
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devin-cli
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Unofficial CLI for Devin AI - The first AI Software Engineer
|
|
5
|
+
Project-URL: Homepage, https://github.com/revanthpobala/devin-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/revanthpobala/devin-cli.git
|
|
7
|
+
Project-URL: Issues, https://github.com/revanthpobala/devin-cli/issues
|
|
8
|
+
Author-email: revanth <revanth@example.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,ai,automation,autonomous,cli,cognition,devin,software-engineer,terminal
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: pyyaml>=6.0
|
|
27
|
+
Requires-Dist: rich>=13.0.0
|
|
28
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: respx>=0.20.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: twine; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
<p align="center">
|
|
38
|
+
<img src="assets/logo.png" alt="Devin CLI Logo" width="300">
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
# Devin CLI (Unofficial) โ The Professional Terminal Interface for Devin AI
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/revanthpobala/devin-cli?style=for-the-badge&color=0294DE" alt="License"></a>
|
|
45
|
+
<a href="https://github.com/revanthpobala/devin-cli/stargazers"><img src="https://img.shields.io/github/stars/revanthpobala/devin-cli?style=for-the-badge&color=FAD000" alt="GitHub stars"></a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
> **The first unofficial CLI for the world's first AI Software Engineer.**
|
|
49
|
+
|
|
50
|
+
Devin CLI is designed for high-velocity engineering teams. It strips away the friction of the web UI, allowing you to orchestrate autonomous agents, manage complex contexts, and automate multi-step development workflows through a robust, terminal-first interface.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## โก Quick Start
|
|
55
|
+
|
|
56
|
+
### 1. Installation
|
|
57
|
+
```bash
|
|
58
|
+
# Recommended: Install via pipx for an isolated environment
|
|
59
|
+
pipx install devin-cli
|
|
60
|
+
|
|
61
|
+
# Or via standard pip
|
|
62
|
+
pip install devin-cli
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Configuration
|
|
66
|
+
```bash
|
|
67
|
+
devin configure
|
|
68
|
+
# Paste your API token from https://preview.devin.ai/settings
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 3. Your First Session
|
|
72
|
+
```bash
|
|
73
|
+
devin create-session "Identify and fix the race condition in our Redis cache layer"
|
|
74
|
+
devin watch
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## ๐ Command Cheat Sheet
|
|
80
|
+
|
|
81
|
+
### Core Workflow
|
|
82
|
+
| Command | Example Usage |
|
|
83
|
+
| :--- | :--- |
|
|
84
|
+
| **`create-session`** | `devin create-session "Refactor the Auth module"` |
|
|
85
|
+
| **`list-sessions`** | `devin list-sessions --limit 10` |
|
|
86
|
+
| **`watch`** | `devin watch` (Live terminal monitoring) |
|
|
87
|
+
| **`message`** | `devin message "Actually, use the standard library instead of the third-party package"` |
|
|
88
|
+
| **`open`** | `devin open` (Jump to the web UI) |
|
|
89
|
+
| **`status`** | `devin status` (Quick pulse check) |
|
|
90
|
+
| **`terminate`** | `devin terminate` |
|
|
91
|
+
|
|
92
|
+
### Context & Assets
|
|
93
|
+
| Command | Example Usage |
|
|
94
|
+
| :--- | :--- |
|
|
95
|
+
| **`attach`** | `devin attach ./specs/v2.md "Implement the new billing logic"` |
|
|
96
|
+
| **`upload`** | `devin upload ./db_dump.sql` |
|
|
97
|
+
| **`list-knowledge`** | `devin list-knowledge` |
|
|
98
|
+
|
|
99
|
+
## ๐ Detailed Command Reference
|
|
100
|
+
|
|
101
|
+
Every command supports the `--help` flag for real-time documentation. Below is an exhaustive reference for the core engineering workflow.
|
|
102
|
+
|
|
103
|
+
<details>
|
|
104
|
+
<summary><b>๐ create-session</b> โ Start a new autonomous agent</summary>
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
Usage: devin create-session [OPTIONS] [PROMPT]
|
|
108
|
+
|
|
109
|
+
Options:
|
|
110
|
+
-t, --title TEXT Custom session title
|
|
111
|
+
-f, --file PATH Read prompt from file
|
|
112
|
+
-s, --secret KEY=VALUE Inject session-specific secrets
|
|
113
|
+
-k, --knowledge-id TEXT Knowledge IDs to include
|
|
114
|
+
--secret-id TEXT Stored secret IDs to include
|
|
115
|
+
--max-acu INTEGER Maximum ACU limit
|
|
116
|
+
--unlisted Create unlisted session
|
|
117
|
+
-i, --idempotent Idempotent creation
|
|
118
|
+
```
|
|
119
|
+
</details>
|
|
120
|
+
|
|
121
|
+
<details>
|
|
122
|
+
<summary><b>๐ chain</b> โ Orchestrate multi-step workflows (Beta)</summary>
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
Usage: devin chain [OPTIONS] [PROMPT]
|
|
126
|
+
|
|
127
|
+
Options:
|
|
128
|
+
--playbooks TEXT Comma-separated playbook IDs
|
|
129
|
+
-f, --file PATH Workflow YAML file
|
|
130
|
+
```
|
|
131
|
+
</details>
|
|
132
|
+
|
|
133
|
+
<details>
|
|
134
|
+
<summary><b>๐ attach</b> โ Upload context and initiate task</summary>
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
Usage: devin attach [OPTIONS] FILE PROMPT
|
|
138
|
+
|
|
139
|
+
Arguments:
|
|
140
|
+
FILE File to upload and link (ZIP, PDF, Codebase) [required]
|
|
141
|
+
PROMPT Initial instruction for Devin [required]
|
|
142
|
+
```
|
|
143
|
+
</details>
|
|
144
|
+
|
|
145
|
+
<details>
|
|
146
|
+
<summary><b>๐ list-sessions</b> โ Manage your active agents</summary>
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
Usage: devin list-sessions [OPTIONS]
|
|
150
|
+
|
|
151
|
+
Options:
|
|
152
|
+
--limit INTEGER Number of sessions to list [default: 10]
|
|
153
|
+
--json Output as machine-readable JSON
|
|
154
|
+
```
|
|
155
|
+
</details>
|
|
156
|
+
|
|
157
|
+
<details>
|
|
158
|
+
<summary><b>โ๏ธ configure</b> โ Setup your environment</summary>
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
Usage: devin configure [OPTIONS]
|
|
162
|
+
|
|
163
|
+
Initializes your local config with the DEVIN_API_TOKEN.
|
|
164
|
+
```
|
|
165
|
+
</details>
|
|
166
|
+
|
|
167
|
+
<details>
|
|
168
|
+
<summary><b>๐ watch</b> โ Terminal-native live monitoring</summary>
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
Usage: devin watch [OPTIONS] [SESSION_ID]
|
|
172
|
+
|
|
173
|
+
Streams the live logs and terminal output from Devin directly to your console.
|
|
174
|
+
```
|
|
175
|
+
</details>
|
|
176
|
+
|
|
177
|
+
<details>
|
|
178
|
+
<summary><b>๐ terminate</b> โ Stop an active agent</summary>
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
Usage: devin terminate [OPTIONS] [SESSION_ID]
|
|
182
|
+
|
|
183
|
+
Permanently stops a session and releases all associated resources.
|
|
184
|
+
```
|
|
185
|
+
</details>
|
|
186
|
+
|
|
187
|
+
<details>
|
|
188
|
+
<summary><b>๐ open</b> โ Jump to the Web UI</summary>
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
Usage: devin open [OPTIONS] [SESSION_ID]
|
|
192
|
+
|
|
193
|
+
Instantly opens the specified session in your default web browser for visual debugging.
|
|
194
|
+
```
|
|
195
|
+
</details>
|
|
196
|
+
|
|
197
|
+
<details>
|
|
198
|
+
<summary><b>๐ง Knowledge & Playbooks</b> โ Advanced CRUD</summary>
|
|
199
|
+
|
|
200
|
+
| Command | Purpose |
|
|
201
|
+
| :--- | :--- |
|
|
202
|
+
| `list-knowledge` | View all shared organizational context. |
|
|
203
|
+
| `create-knowledge` | Add new documentation or code references. |
|
|
204
|
+
| `update-knowledge` | Refresh existing context. |
|
|
205
|
+
| `list-playbooks` | View all available team playbooks. |
|
|
206
|
+
| `create-playbook` | Design a new standardized workflow. |
|
|
207
|
+
|
|
208
|
+
</details>
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## ๐ Integration & Automation
|
|
213
|
+
|
|
214
|
+
### GitHub Actions Integration
|
|
215
|
+
Devin CLI is designed for CI/CD. Use environment variables to bypass the `configure` step.
|
|
216
|
+
```bash
|
|
217
|
+
# Example GitHub Action Step
|
|
218
|
+
env:
|
|
219
|
+
DEVIN_API_TOKEN: ${{ secrets.DEVIN_API_TOKEN }}
|
|
220
|
+
run: |
|
|
221
|
+
devin create-session "Review PR #${{ github.event.pull_request.number }}" --unlisted
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Advanced Scripting
|
|
225
|
+
Pipe Devin's intelligence into your existing toolchain.
|
|
226
|
+
```bash
|
|
227
|
+
# Close all blocked sessions
|
|
228
|
+
devin list-sessions --json | jq -r '.[] | select(.status_enum=="blocked") | .session_id' | xargs -I {} devin terminate {}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## โ๏ธ Engineering Specs
|
|
234
|
+
- **Config Storage**: `~/.config/devin/config.json`
|
|
235
|
+
- **Environment Variables**: `DEVIN_API_TOKEN`, `DEVIN_BASE_URL`
|
|
236
|
+
- **Platform Support**: Linux, macOS, WSL2
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## ๐งช Developer Hub
|
|
241
|
+
```bash
|
|
242
|
+
# Setup
|
|
243
|
+
pip install -e ".[dev]"
|
|
244
|
+
|
|
245
|
+
# Test Suite (100% path coverage)
|
|
246
|
+
PYTHONPATH=src pytest
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## ๐ License
|
|
252
|
+
MIT. **Devin CLI** is an unofficial community project and is not affiliated with Cognition AI.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
<!-- SEO Keywords: Devin AI, AI Software Engineer, Autonomous AI Agent, Devin CLI, Terminal AI, Coding Agent, AI Orchestration, Software Engineering Automation, GitHub Actions AI, Cognition AI, Devin API -->
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
devin_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
devin_cli/cli.py,sha256=j9uTsmLihgBHRFgCghck_NQsXFIrQx3E8GjqJN2s4YY,20678
|
|
3
|
+
devin_cli/config.py,sha256=zSbD6kzhSBjacqUoH1kXHgtjVOd2Onjx4PdPCSRfFEQ,1890
|
|
4
|
+
devin_cli/api/__init__.py,sha256=_hnXCeo1TrW6VgRvmXacLekJ3c5vJSMLS9zSzkQpUlQ,29
|
|
5
|
+
devin_cli/api/attachments.py,sha256=DsWpVBq2Jrn3sZvtGtc9Dz8247XhpaICWhKV2wsH6NY,401
|
|
6
|
+
devin_cli/api/client.py,sha256=wcnVjsf22uQFWoJDiAbRRMY7VLSTS947ecg5kONSLU8,3585
|
|
7
|
+
devin_cli/api/knowledge.py,sha256=7VZu1BpFxLWk6Lgm_k7_SCrhUO5IWlcruSK4EsYltKs,1134
|
|
8
|
+
devin_cli/api/playbooks.py,sha256=zVxjN4PmV8nwK9oOBXVB-0o8PuLOTafTAmey9_M-lDw,809
|
|
9
|
+
devin_cli/api/secrets.py,sha256=wa7z1DHDA937mTzQwTtdCucy0niKpvq9eGFawn07GI8,179
|
|
10
|
+
devin_cli/api/sessions.py,sha256=1xmzg0-TtFFqMcZCDlf1gVsRQFElpOgi2z9eu2vGNRc,1767
|
|
11
|
+
devin_cli-0.0.1.dist-info/METADATA,sha256=77jlq7X3iLFHcQzbCR7e9zRF_3xTpbs42JudxyAjaEQ,7757
|
|
12
|
+
devin_cli-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
+
devin_cli-0.0.1.dist-info/entry_points.txt,sha256=MFHbJI2lStROHPvhRG9qgf3xd0KlXuHOH3CpjcjLAXE,44
|
|
14
|
+
devin_cli-0.0.1.dist-info/licenses/LICENSE,sha256=wmlPjT66gd9bjW8KkOMCvfcEFhZnrYY0VuOiOLcXkp0,1079
|
|
15
|
+
devin_cli-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Devin CLI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|