devin-cli 1.0.0__tar.gz → 1.1.0__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.0.0 → devin_cli-1.1.0}/PKG-INFO +11 -2
- {devin_cli-1.0.0 → devin_cli-1.1.0}/README.md +10 -1
- devin_cli-1.1.0/generate_facades.py +21 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/pyproject.toml +1 -1
- devin_cli-1.1.0/src/devin_cli/api/attachments.py +13 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/api/client.py +69 -27
- devin_cli-1.1.0/src/devin_cli/api/consumption.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/knowledge.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/members.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/organizations.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/playbooks.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/repositories.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/schedules.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/secrets.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/sessions.py +13 -0
- devin_cli-1.1.0/src/devin_cli/api/v1/__init__.py +0 -0
- devin_cli-1.1.0/src/devin_cli/api/v1/attachments.py +16 -0
- devin_cli-1.1.0/src/devin_cli/api/v1/knowledge.py +49 -0
- devin_cli-1.1.0/src/devin_cli/api/v1/playbooks.py +27 -0
- devin_cli-1.1.0/src/devin_cli/api/v1/secrets.py +7 -0
- devin_cli-1.1.0/src/devin_cli/api/v1/sessions.py +58 -0
- devin_cli-1.1.0/src/devin_cli/api/v3/__init__.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/playbooks.py +4 -8
- {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/cli.py +114 -19
- devin_cli-1.1.0/src/devin_cli/config.py +153 -0
- devin_cli-1.1.0/test_duplicate.py +19 -0
- devin_cli-1.1.0/tests/conftest.py +8 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_playbooks.py +1 -1
- devin_cli-1.0.0/src/devin_cli/config.py +0 -86
- {devin_cli-1.0.0 → devin_cli-1.1.0}/.github/workflows/pypi-publish.yml +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/.gitignore +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/CHANGELOG.md +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/LICENSE +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/assets/logo.png +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/setup.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/__init__.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/api/__init__.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/attachments.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/consumption.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/knowledge.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/members.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/organizations.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/repositories.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/schedules.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/secrets.py +0 -0
- {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/sessions.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/__init__.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_attachments.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_knowledge.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_repositories.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_schedules.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_secrets.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_sessions.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_cli.py +0 -0
- {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devin-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
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
|
|
@@ -46,7 +46,7 @@ Description-Content-Type: text/markdown
|
|
|
46
46
|
<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
47
|
</p>
|
|
48
48
|
|
|
49
|
-
> **The first unofficial CLI for the world's first AI Software Engineer. Now
|
|
49
|
+
> **The first unofficial CLI for the world's first AI Software Engineer. Now upgraded to v1.1.0 with Multi-Profile support and legacy v1 backward-compatibility.**
|
|
50
50
|
|
|
51
51
|
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. Built for performance, SEO, and developer productivity.
|
|
52
52
|
|
|
@@ -84,6 +84,15 @@ devin configure
|
|
|
84
84
|
devin sessions create -t "Identify and fix the race condition in our Redis cache layer"
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
## 🌟 What's New in v1.1.0
|
|
88
|
+
|
|
89
|
+
- **Dual-Token Profiles:** Seamlessly switch between personal (`apk_user_`) and Service Account (`cog_`) tokens on the fly using `--profile`.
|
|
90
|
+
- `devin configure --profile service`
|
|
91
|
+
- `devin --profile service sessions list`
|
|
92
|
+
- **Legacy v1 Backward Compatibility:** Need to run an older integration that depends on the original Devin API shapes? The CLI now acts as a dynamic proxy. Simply configure a profile to use `api_version: v1`, and the CLI will route authentic payloads directly to the deprecated v1 endpoints without breaking your v3 integrations.
|
|
93
|
+
- **Session Deduplication (Anti-Spam):** Avoid burning ACUs on accidental retries. The CLI now actively caches a SHA-256 hash of your last 50 prompts per profile, immediately halting and alerting you if you attempt to launch a duplicate session.
|
|
94
|
+
- **Advanced Mode Auth:** Automatically traps `advanced_mode_url` requirements and securely prompts you to finish the connection handshake directly in your web browser.
|
|
95
|
+
|
|
87
96
|
---
|
|
88
97
|
|
|
89
98
|
## 🛠 Command Cheat Sheet (v3 Architecture)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<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
11
|
</p>
|
|
12
12
|
|
|
13
|
-
> **The first unofficial CLI for the world's first AI Software Engineer. Now
|
|
13
|
+
> **The first unofficial CLI for the world's first AI Software Engineer. Now upgraded to v1.1.0 with Multi-Profile support and legacy v1 backward-compatibility.**
|
|
14
14
|
|
|
15
15
|
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. Built for performance, SEO, and developer productivity.
|
|
16
16
|
|
|
@@ -48,6 +48,15 @@ devin configure
|
|
|
48
48
|
devin sessions create -t "Identify and fix the race condition in our Redis cache layer"
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
## 🌟 What's New in v1.1.0
|
|
52
|
+
|
|
53
|
+
- **Dual-Token Profiles:** Seamlessly switch between personal (`apk_user_`) and Service Account (`cog_`) tokens on the fly using `--profile`.
|
|
54
|
+
- `devin configure --profile service`
|
|
55
|
+
- `devin --profile service sessions list`
|
|
56
|
+
- **Legacy v1 Backward Compatibility:** Need to run an older integration that depends on the original Devin API shapes? The CLI now acts as a dynamic proxy. Simply configure a profile to use `api_version: v1`, and the CLI will route authentic payloads directly to the deprecated v1 endpoints without breaking your v3 integrations.
|
|
57
|
+
- **Session Deduplication (Anti-Spam):** Avoid burning ACUs on accidental retries. The CLI now actively caches a SHA-256 hash of your last 50 prompts per profile, immediately halting and alerting you if you attempt to launch a duplicate session.
|
|
58
|
+
- **Advanced Mode Auth:** Automatically traps `advanced_mode_url` requirements and securely prompts you to finish the connection handshake directly in your web browser.
|
|
59
|
+
|
|
51
60
|
---
|
|
52
61
|
|
|
53
62
|
## 🛠 Command Cheat Sheet (v3 Architecture)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
modules = ["attachments", "consumption", "knowledge", "members", "organizations", "playbooks", "repositories", "schedules", "secrets", "sessions"]
|
|
4
|
+
|
|
5
|
+
for mod in modules:
|
|
6
|
+
content = f"""from devin_cli.config import config
|
|
7
|
+
import importlib
|
|
8
|
+
|
|
9
|
+
def _get_impl():
|
|
10
|
+
if config.api_version == "v1":
|
|
11
|
+
try:
|
|
12
|
+
return importlib.import_module(f"devin_cli.api.v1.{mod}")
|
|
13
|
+
except ImportError:
|
|
14
|
+
raise NotImplementedError(f"'{mod}' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
15
|
+
return importlib.import_module(f"devin_cli.api.v3.{mod}")
|
|
16
|
+
|
|
17
|
+
def __getattr__(name):
|
|
18
|
+
return getattr(_get_impl(), name)
|
|
19
|
+
"""
|
|
20
|
+
with open(f"src/devin_cli/api/{mod}.py", "w") as f:
|
|
21
|
+
f.write(content)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.attachments")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'attachments' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.attachments")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -11,7 +11,7 @@ class APIError(Exception):
|
|
|
11
11
|
super().__init__(message)
|
|
12
12
|
self.status_code = status_code
|
|
13
13
|
|
|
14
|
-
class
|
|
14
|
+
class BaseClient:
|
|
15
15
|
def __init__(self):
|
|
16
16
|
pass
|
|
17
17
|
|
|
@@ -43,19 +43,33 @@ class APIClient:
|
|
|
43
43
|
response.raise_for_status()
|
|
44
44
|
except httpx.HTTPStatusError as e:
|
|
45
45
|
status = e.response.status_code
|
|
46
|
+
path = str(e.request.url.path)
|
|
47
|
+
|
|
48
|
+
error_detail = ""
|
|
49
|
+
try:
|
|
50
|
+
error_body = e.response.json()
|
|
51
|
+
error_detail = error_body.get("detail") or error_body.get("message") or error_body.get("error") or str(error_body)
|
|
52
|
+
except Exception:
|
|
53
|
+
error_detail = e.response.text.strip()
|
|
54
|
+
|
|
55
|
+
msg_suffix = f"\n Path: {path}"
|
|
56
|
+
if error_detail:
|
|
57
|
+
msg_suffix += f"\n Detail: {error_detail}"
|
|
58
|
+
|
|
46
59
|
if status == 401:
|
|
47
|
-
raise APIError("Invalid or expired API token (401).
|
|
60
|
+
raise APIError(f"Invalid or expired API token (401).{msg_suffix}", status)
|
|
48
61
|
elif status == 403:
|
|
49
|
-
raise APIError("Insufficient permissions (403).", status)
|
|
62
|
+
raise APIError(f"Insufficient permissions (403).{msg_suffix}", status)
|
|
50
63
|
elif status == 404:
|
|
51
|
-
raise APIError("Resource not found (404).", status)
|
|
64
|
+
raise APIError(f"Resource not found (404).{msg_suffix}", status)
|
|
65
|
+
elif status == 422:
|
|
66
|
+
raise APIError(f"Validation Error (422).{msg_suffix}", status)
|
|
52
67
|
elif status == 429:
|
|
53
|
-
raise APIError("Rate limit exceeded (429). Please try again later.", status)
|
|
68
|
+
raise APIError(f"Rate limit exceeded (429). Please try again later.", status)
|
|
54
69
|
elif status >= 500:
|
|
55
|
-
raise APIError(f"Server error ({status}).", status)
|
|
70
|
+
raise APIError(f"Server error ({status}).{msg_suffix}", status)
|
|
56
71
|
else:
|
|
57
|
-
raise APIError(f"HTTP Error {status}
|
|
58
|
-
|
|
72
|
+
raise APIError(f"HTTP Error {status}.{msg_suffix}", status)
|
|
59
73
|
if response.status_code == 204:
|
|
60
74
|
return None
|
|
61
75
|
|
|
@@ -72,25 +86,12 @@ class APIClient:
|
|
|
72
86
|
|
|
73
87
|
return response.text
|
|
74
88
|
|
|
89
|
+
def _format_url(self, endpoint: str) -> str:
|
|
90
|
+
raise NotImplementedError()
|
|
91
|
+
|
|
75
92
|
def request(self, method: str, endpoint: str, **kwargs) -> Any:
|
|
76
93
|
self._ensure_token()
|
|
77
|
-
|
|
78
|
-
endpoint = endpoint.lstrip("/")
|
|
79
|
-
|
|
80
|
-
# Determine the base URL and whether to inject organization
|
|
81
|
-
if endpoint.startswith("v3beta1/"):
|
|
82
|
-
base = config.base_url.replace("/v3", "").replace("/v2", "").replace("/v1", "").rstrip("/")
|
|
83
|
-
url = f"{base}/{endpoint}"
|
|
84
|
-
# Inject organization for v3beta1 if not present
|
|
85
|
-
if config.org_id and "/organizations/" not in url:
|
|
86
|
-
url = url.replace("v3beta1/", f"v3beta1/organizations/{config.org_id}/")
|
|
87
|
-
elif endpoint.startswith("enterprise/"):
|
|
88
|
-
url = f"{self.BASE_URL}/{endpoint}"
|
|
89
|
-
else:
|
|
90
|
-
# Standard path, inject organization if configured
|
|
91
|
-
if config.org_id and not endpoint.startswith("organizations/"):
|
|
92
|
-
endpoint = f"organizations/{config.org_id}/{endpoint}"
|
|
93
|
-
url = f"{self.BASE_URL}/{endpoint}"
|
|
94
|
+
url = self._format_url(endpoint)
|
|
94
95
|
|
|
95
96
|
# Merge headers if needed, but usually self.headers is enough
|
|
96
97
|
headers = self.headers.copy()
|
|
@@ -102,7 +103,7 @@ class APIClient:
|
|
|
102
103
|
headers.pop("Content-Type", None)
|
|
103
104
|
|
|
104
105
|
try:
|
|
105
|
-
with httpx.Client(follow_redirects=True) as client:
|
|
106
|
+
with httpx.Client(follow_redirects=True, verify=False, timeout=httpx.Timeout(120.0)) as client:
|
|
106
107
|
response = client.request(method, url, headers=headers, **kwargs)
|
|
107
108
|
return self._handle_response(response)
|
|
108
109
|
except httpx.RequestError as e:
|
|
@@ -120,4 +121,45 @@ class APIClient:
|
|
|
120
121
|
def delete(self, endpoint: str, **kwargs) -> Any:
|
|
121
122
|
return self.request("DELETE", endpoint, **kwargs)
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
class V3Client(BaseClient):
|
|
125
|
+
def _format_url(self, endpoint: str) -> str:
|
|
126
|
+
endpoint = endpoint.lstrip("/")
|
|
127
|
+
if endpoint.startswith("v3beta1/"):
|
|
128
|
+
base = config.base_url.replace("/v3", "").replace("/v2", "").replace("/v1", "").rstrip("/")
|
|
129
|
+
url = f"{base}/{endpoint}"
|
|
130
|
+
if config.org_id and "/organizations/" not in url:
|
|
131
|
+
url = url.replace("v3beta1/", f"v3beta1/organizations/{config.org_id}/")
|
|
132
|
+
return url
|
|
133
|
+
elif endpoint.startswith("enterprise/"):
|
|
134
|
+
return f"{self.BASE_URL}/{endpoint}"
|
|
135
|
+
else:
|
|
136
|
+
if config.org_id and not endpoint.startswith("organizations/"):
|
|
137
|
+
endpoint = f"organizations/{config.org_id}/{endpoint}"
|
|
138
|
+
return f"{self.BASE_URL}/{endpoint}"
|
|
139
|
+
|
|
140
|
+
class V1Client(BaseClient):
|
|
141
|
+
def _format_url(self, endpoint: str) -> str:
|
|
142
|
+
return f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
|
143
|
+
|
|
144
|
+
class ClientProxy:
|
|
145
|
+
def __init__(self):
|
|
146
|
+
self._v1 = V1Client()
|
|
147
|
+
self._v3 = V3Client()
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def _client(self) -> BaseClient:
|
|
151
|
+
return self._v1 if config.api_version == "v1" else self._v3
|
|
152
|
+
|
|
153
|
+
def get(self, endpoint: str, **kwargs) -> Any:
|
|
154
|
+
return self._client.get(endpoint, **kwargs)
|
|
155
|
+
|
|
156
|
+
def post(self, endpoint: str, **kwargs) -> Any:
|
|
157
|
+
return self._client.post(endpoint, **kwargs)
|
|
158
|
+
|
|
159
|
+
def put(self, endpoint: str, **kwargs) -> Any:
|
|
160
|
+
return self._client.put(endpoint, **kwargs)
|
|
161
|
+
|
|
162
|
+
def delete(self, endpoint: str, **kwargs) -> Any:
|
|
163
|
+
return self._client.delete(endpoint, **kwargs)
|
|
164
|
+
|
|
165
|
+
client = ClientProxy()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.consumption")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'consumption' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.consumption")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.knowledge")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'knowledge' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.knowledge")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.members")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'members' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.members")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.organizations")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'organizations' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.organizations")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.playbooks")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'playbooks' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.playbooks")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.repositories")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'repositories' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.repositories")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.schedules")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'schedules' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.schedules")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.secrets")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'secrets' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.secrets")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from devin_cli.config import config
|
|
2
|
+
import importlib
|
|
3
|
+
|
|
4
|
+
def _get_impl():
|
|
5
|
+
if config.api_version == "v1":
|
|
6
|
+
try:
|
|
7
|
+
return importlib.import_module(f"devin_cli.api.v1.sessions")
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise NotImplementedError(f"'sessions' feature is only available in Devin API v3, or is not implemented for v1. Run 'devin configure' to switch your API version to v3.")
|
|
10
|
+
return importlib.import_module(f"devin_cli.api.v3.sessions")
|
|
11
|
+
|
|
12
|
+
def __getattr__(name):
|
|
13
|
+
return getattr(_get_impl(), name)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from devin_cli.api.client import client
|
|
4
|
+
|
|
5
|
+
def upload_file(file_path: str, **kwargs):
|
|
6
|
+
path = Path(file_path)
|
|
7
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
8
|
+
mime_type = mime_type or "application/octet-stream"
|
|
9
|
+
|
|
10
|
+
with open(path, "rb") as f:
|
|
11
|
+
# V1 used multipart/form-data with 'file' argument
|
|
12
|
+
return client.post("attachments", files={"file": (path.name, f, mime_type)})
|
|
13
|
+
|
|
14
|
+
def download_attachment(attachment_id: str, file_name: str, **kwargs):
|
|
15
|
+
# Depending on exactly what v1 required, it might just be the direct path or stream
|
|
16
|
+
return client.get(f"attachments/{attachment_id}")
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
parent_folder_id: str = None,
|
|
12
|
+
pinned_repo: str = None,
|
|
13
|
+
title: str = None, # v3 fallback
|
|
14
|
+
**kwargs
|
|
15
|
+
):
|
|
16
|
+
data = {
|
|
17
|
+
"name": name or title,
|
|
18
|
+
"body": body,
|
|
19
|
+
"trigger_description": trigger_description or ""
|
|
20
|
+
}
|
|
21
|
+
if macro:
|
|
22
|
+
data["macro"] = macro
|
|
23
|
+
if parent_folder_id:
|
|
24
|
+
data["parent_folder_id"] = parent_folder_id
|
|
25
|
+
if pinned_repo:
|
|
26
|
+
data["pinned_repo"] = pinned_repo
|
|
27
|
+
|
|
28
|
+
return client.post("knowledge", json=data)
|
|
29
|
+
|
|
30
|
+
def update_knowledge(
|
|
31
|
+
knowledge_id: str,
|
|
32
|
+
name: str = None,
|
|
33
|
+
body: str = None,
|
|
34
|
+
trigger_description: str = None,
|
|
35
|
+
title: str = None, # v3 fallback
|
|
36
|
+
**kwargs
|
|
37
|
+
):
|
|
38
|
+
data = {}
|
|
39
|
+
if name or title:
|
|
40
|
+
data["name"] = name or title
|
|
41
|
+
if body:
|
|
42
|
+
data["body"] = body
|
|
43
|
+
if trigger_description:
|
|
44
|
+
data["trigger_description"] = trigger_description
|
|
45
|
+
|
|
46
|
+
return client.put(f"knowledge/{knowledge_id}", json=data)
|
|
47
|
+
|
|
48
|
+
def delete_knowledge(knowledge_id: str):
|
|
49
|
+
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, **kwargs):
|
|
8
|
+
data = {"title": title, "body": body}
|
|
9
|
+
if macro:
|
|
10
|
+
data["macro"] = macro
|
|
11
|
+
return client.post("playbooks", json=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, **kwargs):
|
|
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}", json=data)
|
|
25
|
+
|
|
26
|
+
def delete_playbook(playbook_id: str):
|
|
27
|
+
return client.delete(f"playbooks/{playbook_id}")
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
**kwargs
|
|
23
|
+
):
|
|
24
|
+
data = {
|
|
25
|
+
"prompt": prompt,
|
|
26
|
+
"idempotent": idempotent,
|
|
27
|
+
"unlisted": unlisted
|
|
28
|
+
}
|
|
29
|
+
if snapshot_id:
|
|
30
|
+
data["snapshot_id"] = snapshot_id
|
|
31
|
+
if playbook_id:
|
|
32
|
+
data["playbook_id"] = playbook_id
|
|
33
|
+
if tags:
|
|
34
|
+
data["tags"] = tags
|
|
35
|
+
if session_secrets:
|
|
36
|
+
data["session_secrets"] = session_secrets
|
|
37
|
+
if title:
|
|
38
|
+
data["title"] = title
|
|
39
|
+
if knowledge_ids:
|
|
40
|
+
data["knowledge_ids"] = knowledge_ids
|
|
41
|
+
if secret_ids:
|
|
42
|
+
data["secret_ids"] = secret_ids
|
|
43
|
+
if max_acu_limit:
|
|
44
|
+
data["max_acu_limit"] = max_acu_limit
|
|
45
|
+
|
|
46
|
+
return client.post("sessions", json=data)
|
|
47
|
+
|
|
48
|
+
def get_session(session_id: str):
|
|
49
|
+
return client.get(f"sessions/{session_id}")
|
|
50
|
+
|
|
51
|
+
def send_message(session_id: str, message: str):
|
|
52
|
+
return client.post(f"sessions/{session_id}/message", data={"message": message})
|
|
53
|
+
|
|
54
|
+
def update_session_tags(session_id: str, tags: List[str]):
|
|
55
|
+
return client.put(f"sessions/{session_id}/tags", data={"tags": tags})
|
|
56
|
+
|
|
57
|
+
def terminate_session(session_id: str):
|
|
58
|
+
return client.delete(f"sessions/{session_id}")
|
|
File without changes
|
|
@@ -13,10 +13,8 @@ def create_playbook(title: str, body: str, macro: Optional[str] = None):
|
|
|
13
13
|
def get_playbook(playbook_id: str):
|
|
14
14
|
return client.get(f"playbooks/{playbook_id}")
|
|
15
15
|
|
|
16
|
-
def update_playbook(playbook_id: str, title:
|
|
17
|
-
data = {}
|
|
18
|
-
if title:
|
|
19
|
-
data["title"] = title
|
|
16
|
+
def update_playbook(playbook_id: str, title: str, body: Optional[str] = None, macro: Optional[str] = None):
|
|
17
|
+
data = {"title": title}
|
|
20
18
|
if body:
|
|
21
19
|
data["body"] = body
|
|
22
20
|
if macro:
|
|
@@ -39,10 +37,8 @@ def create_enterprise_playbook(title: str, body: str, macro: Optional[str] = Non
|
|
|
39
37
|
def get_enterprise_playbook(playbook_id: str):
|
|
40
38
|
return client.get(f"enterprise/playbooks/{playbook_id}")
|
|
41
39
|
|
|
42
|
-
def update_enterprise_playbook(playbook_id: str, title:
|
|
43
|
-
data = {}
|
|
44
|
-
if title:
|
|
45
|
-
data["title"] = title
|
|
40
|
+
def update_enterprise_playbook(playbook_id: str, title: str, body: Optional[str] = None, macro: Optional[str] = None):
|
|
41
|
+
data = {"title": title}
|
|
46
42
|
if body:
|
|
47
43
|
data["body"] = body
|
|
48
44
|
if macro:
|
|
@@ -16,23 +16,44 @@ from devin_cli.api.client import client, APIError
|
|
|
16
16
|
import webbrowser
|
|
17
17
|
import functools
|
|
18
18
|
import sys
|
|
19
|
+
import hashlib
|
|
20
|
+
import importlib.metadata
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
__version__ = importlib.metadata.version("devin-cli")
|
|
24
|
+
except importlib.metadata.PackageNotFoundError:
|
|
25
|
+
__version__ = "unknown"
|
|
26
|
+
|
|
27
|
+
# Early profile detection for accurate --help menus
|
|
28
|
+
if "--profile" in sys.argv:
|
|
29
|
+
idx = sys.argv.index("--profile")
|
|
30
|
+
if idx + 1 < len(sys.argv):
|
|
31
|
+
config.active_profile = sys.argv[idx + 1]
|
|
32
|
+
elif "-p" in sys.argv:
|
|
33
|
+
idx = sys.argv.index("-p")
|
|
34
|
+
if idx + 1 < len(sys.argv):
|
|
35
|
+
config.active_profile = sys.argv[idx + 1]
|
|
36
|
+
|
|
37
|
+
IS_V1 = config.api_version == "v1"
|
|
38
|
+
v1_tag = " [bold yellow](Legacy v1 API)[/bold yellow]" if IS_V1 else ""
|
|
39
|
+
v3_only = " [bold red](v3 Only)[/bold red]" if IS_V1 else ""
|
|
19
40
|
|
|
20
41
|
app = typer.Typer(
|
|
21
|
-
help="Unofficial CLI for Devin AI v3",
|
|
42
|
+
help=f"Unofficial CLI for Devin AI{' (V1 Legacy Mode)' if IS_V1 else ' v3'}",
|
|
22
43
|
no_args_is_help=True,
|
|
23
44
|
rich_markup_mode="rich"
|
|
24
45
|
)
|
|
25
46
|
console = Console()
|
|
26
47
|
|
|
27
48
|
# --- Sub-Apps for Organization API ---
|
|
28
|
-
session_app = typer.Typer(help="Manage Devin sessions", no_args_is_help=True)
|
|
29
|
-
knowledge_app = typer.Typer(help="Manage knowledge notes", no_args_is_help=True)
|
|
30
|
-
playbook_app = typer.Typer(help="Manage team playbooks", no_args_is_help=True)
|
|
31
|
-
secret_app = typer.Typer(help="Manage organization secrets", no_args_is_help=True)
|
|
32
|
-
schedule_app = typer.Typer(help="Manage session schedules", no_args_is_help=True)
|
|
33
|
-
attachment_app = typer.Typer(help="Manage session attachments", no_args_is_help=True)
|
|
34
|
-
repo_app = typer.Typer(help="Manage organization repositories", no_args_is_help=True)
|
|
35
|
-
enterprise_app = typer.Typer(help="Enterprise-scoped operations", no_args_is_help=True)
|
|
49
|
+
session_app = typer.Typer(help=f"Manage Devin sessions{v1_tag}", no_args_is_help=True)
|
|
50
|
+
knowledge_app = typer.Typer(help=f"Manage knowledge notes{v1_tag}", no_args_is_help=True)
|
|
51
|
+
playbook_app = typer.Typer(help=f"Manage team playbooks{v1_tag}", no_args_is_help=True)
|
|
52
|
+
secret_app = typer.Typer(help=f"Manage organization secrets{v1_tag}", no_args_is_help=True)
|
|
53
|
+
schedule_app = typer.Typer(help=f"Manage session schedules{v3_only}", no_args_is_help=True)
|
|
54
|
+
attachment_app = typer.Typer(help=f"Manage session attachments{v1_tag}", no_args_is_help=True)
|
|
55
|
+
repo_app = typer.Typer(help=f"Manage organization repositories{v3_only}", no_args_is_help=True)
|
|
56
|
+
enterprise_app = typer.Typer(help=f"Enterprise-scoped operations{v3_only}", no_args_is_help=True)
|
|
36
57
|
|
|
37
58
|
app.add_typer(session_app, name="sessions", help="Manage Devin sessions")
|
|
38
59
|
app.add_typer(session_app, name="session", hidden=True)
|
|
@@ -53,11 +74,20 @@ ASCII_LOGO = r"""
|
|
|
53
74
|
[/bold cyan]
|
|
54
75
|
"""
|
|
55
76
|
|
|
56
|
-
@app.callback()
|
|
57
|
-
def main(
|
|
77
|
+
@app.callback(invoke_without_command=True)
|
|
78
|
+
def main(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
profile: str = typer.Option("default", "--profile", "-p", help="Configuration profile to use (e.g., service, personal)"),
|
|
81
|
+
version: Optional[bool] = typer.Option(None, "--version", "-v", help="Show the application's version and exit."),
|
|
82
|
+
):
|
|
58
83
|
"""
|
|
59
84
|
Unofficial CLI for Devin AI v3.
|
|
60
85
|
"""
|
|
86
|
+
if version:
|
|
87
|
+
console.print(f"devin CLI version: {__version__}")
|
|
88
|
+
raise typer.Exit()
|
|
89
|
+
|
|
90
|
+
config.active_profile = profile
|
|
61
91
|
if ctx.invoked_subcommand is None:
|
|
62
92
|
console.print(ASCII_LOGO)
|
|
63
93
|
|
|
@@ -89,16 +119,28 @@ def configure(
|
|
|
89
119
|
token: str = typer.Option(..., prompt="Devin API Token (starts with apk_ or cog_)", help="Your Devin API Token"),
|
|
90
120
|
base_url: str = typer.Option("https://api.devin.ai/v3", prompt="Devin API Base URL", help="Devin API Base URL"),
|
|
91
121
|
org_id: Optional[str] = typer.Option(None, "--org", prompt="Organization ID (optional)", help="Default Organization ID"),
|
|
122
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="Profile to configure (overrides global --profile)"),
|
|
92
123
|
):
|
|
93
124
|
"""
|
|
94
125
|
Configure the CLI with your Devin API token and organization.
|
|
95
126
|
"""
|
|
127
|
+
if profile:
|
|
128
|
+
config.active_profile = profile
|
|
129
|
+
|
|
96
130
|
# Validation based on v3 requirements
|
|
97
131
|
if not (token.startswith("apk_") or token.startswith("cog_")):
|
|
98
132
|
console.print("[bold yellow]Warning:[/bold yellow] Token format might be outdated. v3 tokens usually start with 'apk_' or 'cog_'.")
|
|
99
133
|
|
|
134
|
+
api_version_prompt = Prompt.ask(f"API Version for profile '{config.active_profile}' (v3/v1)", default=config.api_version or "v3")
|
|
135
|
+
|
|
136
|
+
if "api.devin.ai" in base_url and "v3" in base_url and api_version_prompt == "v1":
|
|
137
|
+
base_url = base_url.replace("/v3", "/v1")
|
|
138
|
+
elif "api.devin.ai" in base_url and "v1" in base_url and api_version_prompt == "v3":
|
|
139
|
+
base_url = base_url.replace("/v1", "/v3")
|
|
140
|
+
|
|
100
141
|
config.api_token = token
|
|
101
142
|
config.base_url = base_url
|
|
143
|
+
config.api_version = api_version_prompt
|
|
102
144
|
if org_id:
|
|
103
145
|
config.org_id = org_id
|
|
104
146
|
console.print(f"[green]Configuration saved to {config.config_file}[/green]")
|
|
@@ -112,6 +154,8 @@ def create_session_cmd(
|
|
|
112
154
|
title: Optional[str] = typer.Option(None, "--title", "-t", help="Custom session title"),
|
|
113
155
|
org: Optional[str] = typer.Option(None, "--org", help="Override organization ID"),
|
|
114
156
|
max_acu: Optional[int] = typer.Option(None, "--max-acu", help="Maximum ACU limit"),
|
|
157
|
+
advanced_mode: Optional[str] = typer.Option(None, "--advanced-mode", help="Enable advanced mode (e.g. CLI, browser) for the session"),
|
|
158
|
+
force: bool = typer.Option(False, "--force", help="Force creation even if duplicate prompt is detected"),
|
|
115
159
|
):
|
|
116
160
|
"""Create a new Devin session."""
|
|
117
161
|
if org: config.temporary_org_id = org
|
|
@@ -122,13 +166,44 @@ def create_session_cmd(
|
|
|
122
166
|
else:
|
|
123
167
|
console.print("[bold red]Error:[/bold red] Must provide prompt or --file")
|
|
124
168
|
raise typer.Exit(1)
|
|
169
|
+
|
|
170
|
+
prompt_hash = hashlib.sha256(prompt_text.encode('utf-8')).hexdigest()
|
|
171
|
+
existing_sid = config.get_session_by_prompt_hash(prompt_hash)
|
|
172
|
+
|
|
173
|
+
if existing_sid and not force:
|
|
174
|
+
console.print(f"[bold yellow]Duplicate Detected:[/bold yellow] You recently created a session with this exact prompt.")
|
|
175
|
+
console.print(f"Existing Session ID: [bold cyan]{existing_sid}[/bold cyan]")
|
|
176
|
+
import typer
|
|
177
|
+
if not typer.confirm("Are you sure you want to create a duplicate session?"):
|
|
178
|
+
console.print("Session creation cancelled. Use the existing session ID above to resume.")
|
|
179
|
+
raise typer.Exit()
|
|
125
180
|
|
|
126
181
|
with console.status("[bold green]Creating session...[/bold green]"):
|
|
127
|
-
resp = sessions.create_session(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
182
|
+
resp = sessions.create_session(
|
|
183
|
+
prompt=prompt_text,
|
|
184
|
+
title=title,
|
|
185
|
+
max_acu_limit=max_acu,
|
|
186
|
+
advanced_mode=advanced_mode
|
|
187
|
+
)
|
|
188
|
+
sid = resp.get("session_id")
|
|
189
|
+
|
|
190
|
+
if "advanced_mode_url" in resp:
|
|
191
|
+
adv_url = resp["advanced_mode_url"]
|
|
192
|
+
console.print(f"[bold yellow]Advanced Mode Authorization Required![/bold yellow]")
|
|
193
|
+
console.print(f"Please complete verify the advanced mode setup in your browser:")
|
|
194
|
+
console.print(f"[bold cyan]{adv_url}[/bold cyan]")
|
|
195
|
+
import typer
|
|
196
|
+
if typer.confirm("Open browser now?"):
|
|
197
|
+
import webbrowser
|
|
198
|
+
webbrowser.open(adv_url)
|
|
199
|
+
|
|
200
|
+
if sid:
|
|
201
|
+
config.current_session_id = sid
|
|
202
|
+
config.save_prompt_hash(prompt_hash, sid)
|
|
203
|
+
console.print(f"[green]Session created:[/green] {sid}")
|
|
204
|
+
console.print(f"[bold cyan]URL:[/bold cyan] {resp.get('url')}")
|
|
205
|
+
else:
|
|
206
|
+
console.print("[yellow]Session created, but no ID returned immediately (awaiting advanced mode setup).[/yellow]")
|
|
132
207
|
|
|
133
208
|
@session_app.command("list")
|
|
134
209
|
@handle_api_error
|
|
@@ -141,6 +216,10 @@ def list_sessions_cmd(
|
|
|
141
216
|
if org: config.temporary_org_id = org
|
|
142
217
|
resp = sessions.list_sessions(limit=limit)
|
|
143
218
|
sess_list = resp.get("sessions", [])
|
|
219
|
+
|
|
220
|
+
if not sess_list and config.api_token and config.api_token.startswith("cog_"):
|
|
221
|
+
console.print("[yellow]Warning: Service tokens (cog_) may only have visibility into sessions they explicitly created, not all organization sessions.[/yellow]")
|
|
222
|
+
|
|
144
223
|
if json_output:
|
|
145
224
|
console.print(json.dumps(sess_list, indent=2))
|
|
146
225
|
else:
|
|
@@ -179,7 +258,7 @@ def session_insights_cmd(
|
|
|
179
258
|
@session_app.command("cost")
|
|
180
259
|
@handle_api_error
|
|
181
260
|
def session_cost_cmd(
|
|
182
|
-
session_id: Optional[str] = typer.
|
|
261
|
+
session_id: Optional[str] = typer.Argument(None, help="Specific session ID to check cost for"),
|
|
183
262
|
org: Optional[str] = typer.Option(None, "--org"),
|
|
184
263
|
):
|
|
185
264
|
"""View ACU consumption."""
|
|
@@ -279,13 +358,29 @@ def list_playbooks_cmd(org: Optional[str] = typer.Option(None, "--org")):
|
|
|
279
358
|
"""List team playbooks."""
|
|
280
359
|
if org: config.temporary_org_id = org
|
|
281
360
|
resp = playbooks.list_playbooks()
|
|
361
|
+
items = resp.get("items", []) if isinstance(resp, dict) else resp
|
|
282
362
|
table = Table(title="Playbooks")
|
|
283
363
|
table.add_column("ID", style="cyan")
|
|
284
364
|
table.add_column("Title")
|
|
285
|
-
for item in
|
|
286
|
-
|
|
365
|
+
for item in items:
|
|
366
|
+
if isinstance(item, dict):
|
|
367
|
+
table.add_row(item.get("playbook_id", ""), item.get("title", ""))
|
|
287
368
|
console.print(table)
|
|
288
369
|
|
|
370
|
+
@playbook_app.command("update")
|
|
371
|
+
@handle_api_error
|
|
372
|
+
def update_playbook_cmd(
|
|
373
|
+
playbook_id: str,
|
|
374
|
+
title: str,
|
|
375
|
+
body: Optional[str] = typer.Option(None, "--body"),
|
|
376
|
+
macro: Optional[str] = typer.Option(None, "--macro"),
|
|
377
|
+
org: Optional[str] = typer.Option(None, "--org"),
|
|
378
|
+
):
|
|
379
|
+
"""Update a team playbook."""
|
|
380
|
+
if org: config.temporary_org_id = org
|
|
381
|
+
resp = playbooks.update_playbook(playbook_id, title=title, body=body, macro=macro)
|
|
382
|
+
console.print(f"[green]Playbook updated:[/green] {playbook_id}")
|
|
383
|
+
|
|
289
384
|
@playbook_app.command("create")
|
|
290
385
|
@handle_api_error
|
|
291
386
|
def create_playbook_cmd(
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
@property
|
|
17
|
+
def config_file(self) -> Path:
|
|
18
|
+
return self._config_file
|
|
19
|
+
|
|
20
|
+
def _ensure_config_exists(self):
|
|
21
|
+
if not self._config_dir.exists():
|
|
22
|
+
self._config_dir.mkdir(parents=True, mode=0o700)
|
|
23
|
+
if not self._config_file.exists():
|
|
24
|
+
with open(self._config_file, "w") as f:
|
|
25
|
+
json.dump({}, f)
|
|
26
|
+
self._config_file.chmod(0o600)
|
|
27
|
+
|
|
28
|
+
def _load(self):
|
|
29
|
+
try:
|
|
30
|
+
with open(self._config_file, "r") as f:
|
|
31
|
+
self._data = json.load(f)
|
|
32
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
33
|
+
self._data = {}
|
|
34
|
+
|
|
35
|
+
if "profiles" not in self._data:
|
|
36
|
+
# Migrate legacy flat config to default profile
|
|
37
|
+
legacy_keys = ["api_token", "base_url", "org_id", "api_version", "current_session_id"]
|
|
38
|
+
legacy_data = {}
|
|
39
|
+
for key in legacy_keys:
|
|
40
|
+
if key in self._data:
|
|
41
|
+
legacy_data[key] = self._data.pop(key)
|
|
42
|
+
self._data["profiles"] = {
|
|
43
|
+
"default": legacy_data
|
|
44
|
+
}
|
|
45
|
+
if "active_profile" not in self._data:
|
|
46
|
+
self._data["active_profile"] = "default"
|
|
47
|
+
self._save()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _save(self):
|
|
51
|
+
with open(self._config_file, "w") as f:
|
|
52
|
+
json.dump(self._data, f, indent=2)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def active_profile(self) -> str:
|
|
56
|
+
return getattr(self, "_runtime_profile", None) or self._data.get("active_profile", "default")
|
|
57
|
+
|
|
58
|
+
@active_profile.setter
|
|
59
|
+
def active_profile(self, value: str):
|
|
60
|
+
self._runtime_profile = value
|
|
61
|
+
if "profiles" not in self._data:
|
|
62
|
+
self._data["profiles"] = {}
|
|
63
|
+
if value not in self._data["profiles"]:
|
|
64
|
+
self._data["profiles"][value] = {}
|
|
65
|
+
|
|
66
|
+
def _get_profile_data(self) -> dict:
|
|
67
|
+
return self._data.get("profiles", {}).get(self.active_profile, {})
|
|
68
|
+
|
|
69
|
+
def _set_profile_data(self, key: str, value: str):
|
|
70
|
+
profile_name = self.active_profile
|
|
71
|
+
if "profiles" not in self._data:
|
|
72
|
+
self._data["profiles"] = {}
|
|
73
|
+
if profile_name not in self._data["profiles"]:
|
|
74
|
+
self._data["profiles"][profile_name] = {}
|
|
75
|
+
self._data["profiles"][profile_name][key] = value
|
|
76
|
+
self._save()
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def api_token(self) -> Optional[str]:
|
|
80
|
+
# Env var takes precedence
|
|
81
|
+
return os.environ.get("DEVIN_API_TOKEN") or self._get_profile_data().get("api_token")
|
|
82
|
+
|
|
83
|
+
@api_token.setter
|
|
84
|
+
def api_token(self, value: str):
|
|
85
|
+
self._set_profile_data("api_token", value)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def org_id(self) -> Optional[str]:
|
|
89
|
+
# Temporary override takes highest precedence (CLI flag)
|
|
90
|
+
# then env var, then config file
|
|
91
|
+
return getattr(self, "_temporary_org_id", None) or os.environ.get("DEVIN_ORG_ID") or self._get_profile_data().get("org_id")
|
|
92
|
+
|
|
93
|
+
@org_id.setter
|
|
94
|
+
def org_id(self, value: str):
|
|
95
|
+
self._set_profile_data("org_id", value)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def temporary_org_id(self) -> Optional[str]:
|
|
99
|
+
return getattr(self, "_temporary_org_id", None)
|
|
100
|
+
|
|
101
|
+
@temporary_org_id.setter
|
|
102
|
+
def temporary_org_id(self, value: Optional[str]):
|
|
103
|
+
self._temporary_org_id = value
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def base_url(self) -> str:
|
|
107
|
+
return os.environ.get("DEVIN_BASE_URL") or self._get_profile_data().get("base_url", "https://api.devin.ai/v3")
|
|
108
|
+
|
|
109
|
+
@base_url.setter
|
|
110
|
+
def base_url(self, value: str):
|
|
111
|
+
self._set_profile_data("base_url", value)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def api_version(self) -> str:
|
|
115
|
+
return os.environ.get("DEVIN_API_VERSION") or self._get_profile_data().get("api_version", "v3")
|
|
116
|
+
|
|
117
|
+
@api_version.setter
|
|
118
|
+
def api_version(self, value: str):
|
|
119
|
+
self._set_profile_data("api_version", value)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def current_session_id(self) -> Optional[str]:
|
|
123
|
+
return self._get_profile_data().get("current_session_id")
|
|
124
|
+
|
|
125
|
+
@current_session_id.setter
|
|
126
|
+
def current_session_id(self, value: str):
|
|
127
|
+
self._set_profile_data("current_session_id", value)
|
|
128
|
+
|
|
129
|
+
def get_session_by_prompt_hash(self, prompt_hash: str) -> Optional[str]:
|
|
130
|
+
return self._get_profile_data().get("prompt_hashes", {}).get(prompt_hash)
|
|
131
|
+
|
|
132
|
+
def save_prompt_hash(self, prompt_hash: str, session_id: str):
|
|
133
|
+
profile_name = self.active_profile
|
|
134
|
+
if "profiles" not in self._data:
|
|
135
|
+
self._data["profiles"] = {}
|
|
136
|
+
if profile_name not in self._data["profiles"]:
|
|
137
|
+
self._data["profiles"][profile_name] = {}
|
|
138
|
+
|
|
139
|
+
profile_data = self._data["profiles"][profile_name]
|
|
140
|
+
if "prompt_hashes" not in profile_data:
|
|
141
|
+
profile_data["prompt_hashes"] = {}
|
|
142
|
+
|
|
143
|
+
profile_data["prompt_hashes"][prompt_hash] = session_id
|
|
144
|
+
|
|
145
|
+
# Keep only the last 50 hashes to prevent config bloat
|
|
146
|
+
if len(profile_data["prompt_hashes"]) > 50:
|
|
147
|
+
keys_to_remove = list(profile_data["prompt_hashes"].keys())[:-50]
|
|
148
|
+
for k in keys_to_remove:
|
|
149
|
+
del profile_data["prompt_hashes"][k]
|
|
150
|
+
|
|
151
|
+
self._save()
|
|
152
|
+
|
|
153
|
+
config = Config()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import httpx
|
|
3
|
+
from devin_cli.api import sessions
|
|
4
|
+
from devin_cli.config import config
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
8
|
+
logging.getLogger("httpx").setLevel(logging.DEBUG)
|
|
9
|
+
|
|
10
|
+
print("Active Token:", config.api_token[:10] if config.api_token else "None")
|
|
11
|
+
print("Base URL:", config.base_url)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
print("Sending request...")
|
|
15
|
+
resp = sessions.create_session(prompt="Testing duplicate bug. Ignore.", title="Duplicate Test")
|
|
16
|
+
print("Response:")
|
|
17
|
+
print(json.dumps(resp, indent=2))
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print("Error:", e)
|
|
@@ -42,7 +42,7 @@ def test_update_playbook():
|
|
|
42
42
|
route = respx.put("https://api.devin.ai/v3/organizations/test_org/playbooks/pb_123").mock(
|
|
43
43
|
return_value=Response(200)
|
|
44
44
|
)
|
|
45
|
-
playbooks.update_playbook("pb_123", body="new body")
|
|
45
|
+
playbooks.update_playbook("pb_123", title="new title", body="new body")
|
|
46
46
|
assert route.called
|
|
47
47
|
|
|
48
48
|
@respx.mock
|
|
@@ -1,86 +0,0 @@
|
|
|
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
|
-
@property
|
|
17
|
-
def config_file(self) -> Path:
|
|
18
|
-
return self._config_file
|
|
19
|
-
|
|
20
|
-
def _ensure_config_exists(self):
|
|
21
|
-
if not self._config_dir.exists():
|
|
22
|
-
self._config_dir.mkdir(parents=True, mode=0o700)
|
|
23
|
-
if not self._config_file.exists():
|
|
24
|
-
with open(self._config_file, "w") as f:
|
|
25
|
-
json.dump({}, f)
|
|
26
|
-
self._config_file.chmod(0o600)
|
|
27
|
-
|
|
28
|
-
def _load(self):
|
|
29
|
-
try:
|
|
30
|
-
with open(self._config_file, "r") as f:
|
|
31
|
-
self._data = json.load(f)
|
|
32
|
-
except (json.JSONDecodeError, FileNotFoundError):
|
|
33
|
-
self._data = {}
|
|
34
|
-
|
|
35
|
-
def _save(self):
|
|
36
|
-
with open(self._config_file, "w") as f:
|
|
37
|
-
json.dump(self._data, f, indent=2)
|
|
38
|
-
|
|
39
|
-
@property
|
|
40
|
-
def api_token(self) -> Optional[str]:
|
|
41
|
-
# Env var takes precedence
|
|
42
|
-
return os.environ.get("DEVIN_API_TOKEN") or self._data.get("api_token")
|
|
43
|
-
|
|
44
|
-
@api_token.setter
|
|
45
|
-
def api_token(self, value: str):
|
|
46
|
-
self._data["api_token"] = value
|
|
47
|
-
self._save()
|
|
48
|
-
|
|
49
|
-
@property
|
|
50
|
-
def org_id(self) -> Optional[str]:
|
|
51
|
-
# Temporary override takes highest precedence (CLI flag)
|
|
52
|
-
# then env var, then config file
|
|
53
|
-
return getattr(self, "_temporary_org_id", None) or os.environ.get("DEVIN_ORG_ID") or self._data.get("org_id")
|
|
54
|
-
|
|
55
|
-
@org_id.setter
|
|
56
|
-
def org_id(self, value: str):
|
|
57
|
-
self._data["org_id"] = value
|
|
58
|
-
self._save()
|
|
59
|
-
|
|
60
|
-
@property
|
|
61
|
-
def temporary_org_id(self) -> Optional[str]:
|
|
62
|
-
return getattr(self, "_temporary_org_id", None)
|
|
63
|
-
|
|
64
|
-
@temporary_org_id.setter
|
|
65
|
-
def temporary_org_id(self, value: Optional[str]):
|
|
66
|
-
self._temporary_org_id = value
|
|
67
|
-
|
|
68
|
-
@property
|
|
69
|
-
def base_url(self) -> str:
|
|
70
|
-
return os.environ.get("DEVIN_BASE_URL") or self._data.get("base_url", "https://api.devin.ai/v3")
|
|
71
|
-
|
|
72
|
-
@base_url.setter
|
|
73
|
-
def base_url(self, value: str):
|
|
74
|
-
self._data["base_url"] = value
|
|
75
|
-
self._save()
|
|
76
|
-
|
|
77
|
-
@property
|
|
78
|
-
def current_session_id(self) -> Optional[str]:
|
|
79
|
-
return self._data.get("current_session_id")
|
|
80
|
-
|
|
81
|
-
@current_session_id.setter
|
|
82
|
-
def current_session_id(self, value: str):
|
|
83
|
-
self._data["current_session_id"] = value
|
|
84
|
-
self._save()
|
|
85
|
-
|
|
86
|
-
config = Config()
|
|
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
|