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.
Files changed (55) hide show
  1. {devin_cli-1.0.0 → devin_cli-1.1.0}/PKG-INFO +11 -2
  2. {devin_cli-1.0.0 → devin_cli-1.1.0}/README.md +10 -1
  3. devin_cli-1.1.0/generate_facades.py +21 -0
  4. {devin_cli-1.0.0 → devin_cli-1.1.0}/pyproject.toml +1 -1
  5. devin_cli-1.1.0/src/devin_cli/api/attachments.py +13 -0
  6. {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/api/client.py +69 -27
  7. devin_cli-1.1.0/src/devin_cli/api/consumption.py +13 -0
  8. devin_cli-1.1.0/src/devin_cli/api/knowledge.py +13 -0
  9. devin_cli-1.1.0/src/devin_cli/api/members.py +13 -0
  10. devin_cli-1.1.0/src/devin_cli/api/organizations.py +13 -0
  11. devin_cli-1.1.0/src/devin_cli/api/playbooks.py +13 -0
  12. devin_cli-1.1.0/src/devin_cli/api/repositories.py +13 -0
  13. devin_cli-1.1.0/src/devin_cli/api/schedules.py +13 -0
  14. devin_cli-1.1.0/src/devin_cli/api/secrets.py +13 -0
  15. devin_cli-1.1.0/src/devin_cli/api/sessions.py +13 -0
  16. devin_cli-1.1.0/src/devin_cli/api/v1/__init__.py +0 -0
  17. devin_cli-1.1.0/src/devin_cli/api/v1/attachments.py +16 -0
  18. devin_cli-1.1.0/src/devin_cli/api/v1/knowledge.py +49 -0
  19. devin_cli-1.1.0/src/devin_cli/api/v1/playbooks.py +27 -0
  20. devin_cli-1.1.0/src/devin_cli/api/v1/secrets.py +7 -0
  21. devin_cli-1.1.0/src/devin_cli/api/v1/sessions.py +58 -0
  22. devin_cli-1.1.0/src/devin_cli/api/v3/__init__.py +0 -0
  23. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/playbooks.py +4 -8
  24. {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/cli.py +114 -19
  25. devin_cli-1.1.0/src/devin_cli/config.py +153 -0
  26. devin_cli-1.1.0/test_duplicate.py +19 -0
  27. devin_cli-1.1.0/tests/conftest.py +8 -0
  28. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_playbooks.py +1 -1
  29. devin_cli-1.0.0/src/devin_cli/config.py +0 -86
  30. {devin_cli-1.0.0 → devin_cli-1.1.0}/.github/workflows/pypi-publish.yml +0 -0
  31. {devin_cli-1.0.0 → devin_cli-1.1.0}/.gitignore +0 -0
  32. {devin_cli-1.0.0 → devin_cli-1.1.0}/CHANGELOG.md +0 -0
  33. {devin_cli-1.0.0 → devin_cli-1.1.0}/LICENSE +0 -0
  34. {devin_cli-1.0.0 → devin_cli-1.1.0}/assets/logo.png +0 -0
  35. {devin_cli-1.0.0 → devin_cli-1.1.0}/setup.py +0 -0
  36. {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/__init__.py +0 -0
  37. {devin_cli-1.0.0 → devin_cli-1.1.0}/src/devin_cli/api/__init__.py +0 -0
  38. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/attachments.py +0 -0
  39. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/consumption.py +0 -0
  40. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/knowledge.py +0 -0
  41. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/members.py +0 -0
  42. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/organizations.py +0 -0
  43. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/repositories.py +0 -0
  44. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/schedules.py +0 -0
  45. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/secrets.py +0 -0
  46. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.0/src/devin_cli/api/v3}/sessions.py +0 -0
  47. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/__init__.py +0 -0
  48. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_attachments.py +0 -0
  49. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_knowledge.py +0 -0
  50. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_repositories.py +0 -0
  51. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_schedules.py +0 -0
  52. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_secrets.py +0 -0
  53. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_api/test_sessions.py +0 -0
  54. {devin_cli-1.0.0 → devin_cli-1.1.0}/tests/test_cli.py +0 -0
  55. {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.0.0
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 fully upgraded to Devin API v3.**
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 fully upgraded to Devin API v3.**
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devin-cli"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "Unofficial CLI for Devin AI - The first AI Software Engineer"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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 APIClient:
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). Run 'devin configure'.", status)
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}: {e}", 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
- client = APIClient()
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,7 @@
1
+ from devin_cli.api.client import client
2
+
3
+ def list_secrets():
4
+ return client.get("secrets")
5
+
6
+ def delete_secret(secret_id: str):
7
+ return client.delete(f"secrets/{secret_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: Optional[str] = None, body: Optional[str] = None, macro: Optional[str] = None):
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: Optional[str] = None, body: Optional[str] = None, macro: Optional[str] = None):
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(ctx: typer.Context):
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(prompt=prompt_text, title=title, max_acu_limit=max_acu)
128
- sid = resp["session_id"]
129
- config.current_session_id = sid
130
- console.print(f"[green]Session created:[/green] {sid}")
131
- console.print(f"[bold cyan]URL:[/bold cyan] {resp.get('url')}")
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.Option(None, "--id"),
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 resp:
286
- table.add_row(item.get("playbook_id"), item.get("title"))
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)
@@ -0,0 +1,8 @@
1
+ import pytest
2
+ import os
3
+ from devin_cli.config import config
4
+
5
+ @pytest.fixture(autouse=True)
6
+ def ensure_v3_for_tests():
7
+ os.environ["DEVIN_API_VERSION"] = "v3"
8
+ config.api_version = "v3"
@@ -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