devin-cli 1.0.0__tar.gz → 1.1.1__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 (56) hide show
  1. devin_cli-1.1.1/CHANGELOG.md +35 -0
  2. {devin_cli-1.0.0 → devin_cli-1.1.1}/PKG-INFO +11 -2
  3. {devin_cli-1.0.0 → devin_cli-1.1.1}/README.md +10 -1
  4. devin_cli-1.1.1/generate_facades.py +21 -0
  5. {devin_cli-1.0.0 → devin_cli-1.1.1}/pyproject.toml +1 -1
  6. devin_cli-1.1.1/src/devin_cli/api/attachments.py +13 -0
  7. {devin_cli-1.0.0 → devin_cli-1.1.1}/src/devin_cli/api/client.py +69 -27
  8. devin_cli-1.1.1/src/devin_cli/api/consumption.py +13 -0
  9. devin_cli-1.1.1/src/devin_cli/api/knowledge.py +13 -0
  10. devin_cli-1.1.1/src/devin_cli/api/members.py +13 -0
  11. devin_cli-1.1.1/src/devin_cli/api/organizations.py +13 -0
  12. devin_cli-1.1.1/src/devin_cli/api/playbooks.py +13 -0
  13. devin_cli-1.1.1/src/devin_cli/api/repositories.py +13 -0
  14. devin_cli-1.1.1/src/devin_cli/api/schedules.py +13 -0
  15. devin_cli-1.1.1/src/devin_cli/api/secrets.py +13 -0
  16. devin_cli-1.1.1/src/devin_cli/api/sessions.py +13 -0
  17. devin_cli-1.1.1/src/devin_cli/api/v1/__init__.py +0 -0
  18. devin_cli-1.1.1/src/devin_cli/api/v1/attachments.py +16 -0
  19. devin_cli-1.1.1/src/devin_cli/api/v1/knowledge.py +49 -0
  20. devin_cli-1.1.1/src/devin_cli/api/v1/playbooks.py +27 -0
  21. devin_cli-1.1.1/src/devin_cli/api/v1/secrets.py +7 -0
  22. devin_cli-1.1.1/src/devin_cli/api/v1/sessions.py +65 -0
  23. devin_cli-1.1.1/src/devin_cli/api/v3/__init__.py +0 -0
  24. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/playbooks.py +4 -8
  25. {devin_cli-1.0.0 → devin_cli-1.1.1}/src/devin_cli/cli.py +130 -20
  26. devin_cli-1.1.1/src/devin_cli/config.py +153 -0
  27. devin_cli-1.1.1/test_duplicate.py +19 -0
  28. devin_cli-1.1.1/tests/conftest.py +8 -0
  29. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_playbooks.py +1 -1
  30. devin_cli-1.0.0/CHANGELOG.md +0 -12
  31. devin_cli-1.0.0/src/devin_cli/config.py +0 -86
  32. {devin_cli-1.0.0 → devin_cli-1.1.1}/.github/workflows/pypi-publish.yml +0 -0
  33. {devin_cli-1.0.0 → devin_cli-1.1.1}/.gitignore +0 -0
  34. {devin_cli-1.0.0 → devin_cli-1.1.1}/LICENSE +0 -0
  35. {devin_cli-1.0.0 → devin_cli-1.1.1}/assets/logo.png +0 -0
  36. {devin_cli-1.0.0 → devin_cli-1.1.1}/setup.py +0 -0
  37. {devin_cli-1.0.0 → devin_cli-1.1.1}/src/devin_cli/__init__.py +0 -0
  38. {devin_cli-1.0.0 → devin_cli-1.1.1}/src/devin_cli/api/__init__.py +0 -0
  39. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/attachments.py +0 -0
  40. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/consumption.py +0 -0
  41. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/knowledge.py +0 -0
  42. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/members.py +0 -0
  43. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/organizations.py +0 -0
  44. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/repositories.py +0 -0
  45. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/schedules.py +0 -0
  46. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/secrets.py +0 -0
  47. {devin_cli-1.0.0/src/devin_cli/api → devin_cli-1.1.1/src/devin_cli/api/v3}/sessions.py +0 -0
  48. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/__init__.py +0 -0
  49. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_attachments.py +0 -0
  50. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_knowledge.py +0 -0
  51. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_repositories.py +0 -0
  52. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_schedules.py +0 -0
  53. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_secrets.py +0 -0
  54. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_api/test_sessions.py +0 -0
  55. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_cli.py +0 -0
  56. {devin_cli-1.0.0 → devin_cli-1.1.1}/tests/test_config.py +0 -0
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.1] - 2026-03-11
9
+ ### Fixed
10
+ - Fixed v1 API HTTP redirects stripping authorization headers on org-scoped endpoints (403 errors).
11
+ - Fixed `devin configure` crash due to a missing `Prompt` module import.
12
+ - Fixed v1 `devin sessions messages` command by creating a polyfill data extractor for legacy payloads.
13
+ - Fixed v1 `devin sessions insights` to gracefully exit instead of throwing a generic missing endpoint error.
14
+ - Fixed v1 API payload formats by universally switching from URL-encoded `data=` to strictly typed `json=` bodies.
15
+ - Fixed message display loop to properly fallback between v3 `content` and v1 `message` string definitions.
16
+
17
+ ## [1.1.0] - 2026-03-11
18
+ ### Added
19
+ - **Dual-Token Profiles**: Support for securely managing Personal (`apk_user_`) and Service Account (`cog_`) tokens via the `--profile` global flag.
20
+ - **Legacy API Proxy**: Native v1 API backward compatibility routing, allowing older legacy scripts to execute gracefully over the new v3 architecture.
21
+ - **Session Deduplication (Anti-Spam)**: Built-in SHA-256 caching of previous prompts to intercept and prevent accidental duplicate session creation loops.
22
+ - **Advanced Mode Auth**: Secure web browser intersection for `advanced_mode_url` requirements when initiating CLI sessions.
23
+ - **Playbook Safeguards**: Implemented strict 500KB payload limit warnings with manual overrides for Playbook creation and updates to prevent 413 HTTP crashes.
24
+ - **Dynamic Help Menus**: Typer help commands successfully dynamically reflect `(V1 Legacy Mode)` tags based on active profile switching.
25
+ - **Global `--version`**: New binary flag reporting accurate package metadata matching PyPI.
26
+
27
+ ## [0.1.0] - 2024-05-23
28
+ ### Added
29
+ - Initial release
30
+ - Session management (create, list, get, terminate)
31
+ - Message sending and history viewing
32
+ - Watch command for live status polling
33
+ - File attachments
34
+ - Knowledge, Playbook, and Secret management commands
35
+ - Shell completion support
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devin-cli
3
- Version: 1.0.0
3
+ Version: 1.1.1
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.1"
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=False, 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,65 @@
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 get_session_messages(session_id: str):
52
+ resp = get_session(session_id)
53
+ return {"messages": resp.get("messages", [])}
54
+
55
+ def get_session_insights(session_id: str):
56
+ return {"error": "Insights are not available in the v1 API. Please upgrade to v3."}
57
+
58
+ def send_message(session_id: str, message: str):
59
+ return client.post(f"sessions/{session_id}/message", json={"message": message})
60
+
61
+ def update_session_tags(session_id: str, tags: List[str]):
62
+ return client.put(f"sessions/{session_id}/tags", json={"tags": tags})
63
+
64
+ def terminate_session(session_id: str):
65
+ 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,45 @@ 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
+ from rich.prompt import Prompt
22
+
23
+ try:
24
+ __version__ = importlib.metadata.version("devin-cli")
25
+ except importlib.metadata.PackageNotFoundError:
26
+ __version__ = "unknown"
27
+
28
+ # Early profile detection for accurate --help menus
29
+ if "--profile" in sys.argv:
30
+ idx = sys.argv.index("--profile")
31
+ if idx + 1 < len(sys.argv):
32
+ config.active_profile = sys.argv[idx + 1]
33
+ elif "-p" in sys.argv:
34
+ idx = sys.argv.index("-p")
35
+ if idx + 1 < len(sys.argv):
36
+ config.active_profile = sys.argv[idx + 1]
37
+
38
+ IS_V1 = config.api_version == "v1"
39
+ v1_tag = " [bold yellow](Legacy v1 API)[/bold yellow]" if IS_V1 else ""
40
+ v3_only = " [bold red](v3 Only)[/bold red]" if IS_V1 else ""
19
41
 
20
42
  app = typer.Typer(
21
- help="Unofficial CLI for Devin AI v3",
43
+ help=f"Unofficial CLI for Devin AI{' (V1 Legacy Mode)' if IS_V1 else ' v3'}",
22
44
  no_args_is_help=True,
23
45
  rich_markup_mode="rich"
24
46
  )
25
47
  console = Console()
26
48
 
27
49
  # --- 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)
50
+ session_app = typer.Typer(help=f"Manage Devin sessions{v1_tag}", no_args_is_help=True)
51
+ knowledge_app = typer.Typer(help=f"Manage knowledge notes{v1_tag}", no_args_is_help=True)
52
+ playbook_app = typer.Typer(help=f"Manage team playbooks{v1_tag}", no_args_is_help=True)
53
+ secret_app = typer.Typer(help=f"Manage organization secrets{v1_tag}", no_args_is_help=True)
54
+ schedule_app = typer.Typer(help=f"Manage session schedules{v3_only}", no_args_is_help=True)
55
+ attachment_app = typer.Typer(help=f"Manage session attachments{v1_tag}", no_args_is_help=True)
56
+ repo_app = typer.Typer(help=f"Manage organization repositories{v3_only}", no_args_is_help=True)
57
+ enterprise_app = typer.Typer(help=f"Enterprise-scoped operations{v3_only}", no_args_is_help=True)
36
58
 
37
59
  app.add_typer(session_app, name="sessions", help="Manage Devin sessions")
38
60
  app.add_typer(session_app, name="session", hidden=True)
@@ -53,11 +75,20 @@ ASCII_LOGO = r"""
53
75
  [/bold cyan]
54
76
  """
55
77
 
56
- @app.callback()
57
- def main(ctx: typer.Context):
78
+ @app.callback(invoke_without_command=True)
79
+ def main(
80
+ ctx: typer.Context,
81
+ profile: str = typer.Option("default", "--profile", "-p", help="Configuration profile to use (e.g., service, personal)"),
82
+ version: Optional[bool] = typer.Option(None, "--version", "-v", help="Show the application's version and exit."),
83
+ ):
58
84
  """
59
85
  Unofficial CLI for Devin AI v3.
60
86
  """
87
+ if version:
88
+ console.print(f"devin CLI version: {__version__}")
89
+ raise typer.Exit()
90
+
91
+ config.active_profile = profile
61
92
  if ctx.invoked_subcommand is None:
62
93
  console.print(ASCII_LOGO)
63
94
 
@@ -89,16 +120,28 @@ def configure(
89
120
  token: str = typer.Option(..., prompt="Devin API Token (starts with apk_ or cog_)", help="Your Devin API Token"),
90
121
  base_url: str = typer.Option("https://api.devin.ai/v3", prompt="Devin API Base URL", help="Devin API Base URL"),
91
122
  org_id: Optional[str] = typer.Option(None, "--org", prompt="Organization ID (optional)", help="Default Organization ID"),
123
+ profile: Optional[str] = typer.Option(None, "--profile", help="Profile to configure (overrides global --profile)"),
92
124
  ):
93
125
  """
94
126
  Configure the CLI with your Devin API token and organization.
95
127
  """
128
+ if profile:
129
+ config.active_profile = profile
130
+
96
131
  # Validation based on v3 requirements
97
132
  if not (token.startswith("apk_") or token.startswith("cog_")):
98
133
  console.print("[bold yellow]Warning:[/bold yellow] Token format might be outdated. v3 tokens usually start with 'apk_' or 'cog_'.")
99
134
 
135
+ api_version_prompt = Prompt.ask(f"API Version for profile '{config.active_profile}' (v3/v1)", default=config.api_version or "v3")
136
+
137
+ if "api.devin.ai" in base_url and "v3" in base_url and api_version_prompt == "v1":
138
+ base_url = base_url.replace("/v3", "/v1")
139
+ elif "api.devin.ai" in base_url and "v1" in base_url and api_version_prompt == "v3":
140
+ base_url = base_url.replace("/v1", "/v3")
141
+
100
142
  config.api_token = token
101
143
  config.base_url = base_url
144
+ config.api_version = api_version_prompt
102
145
  if org_id:
103
146
  config.org_id = org_id
104
147
  console.print(f"[green]Configuration saved to {config.config_file}[/green]")
@@ -112,6 +155,8 @@ def create_session_cmd(
112
155
  title: Optional[str] = typer.Option(None, "--title", "-t", help="Custom session title"),
113
156
  org: Optional[str] = typer.Option(None, "--org", help="Override organization ID"),
114
157
  max_acu: Optional[int] = typer.Option(None, "--max-acu", help="Maximum ACU limit"),
158
+ advanced_mode: Optional[str] = typer.Option(None, "--advanced-mode", help="Enable advanced mode (e.g. CLI, browser) for the session"),
159
+ force: bool = typer.Option(False, "--force", help="Force creation even if duplicate prompt is detected"),
115
160
  ):
116
161
  """Create a new Devin session."""
117
162
  if org: config.temporary_org_id = org
@@ -122,13 +167,44 @@ def create_session_cmd(
122
167
  else:
123
168
  console.print("[bold red]Error:[/bold red] Must provide prompt or --file")
124
169
  raise typer.Exit(1)
170
+
171
+ prompt_hash = hashlib.sha256(prompt_text.encode('utf-8')).hexdigest()
172
+ existing_sid = config.get_session_by_prompt_hash(prompt_hash)
173
+
174
+ if existing_sid and not force:
175
+ console.print(f"[bold yellow]Duplicate Detected:[/bold yellow] You recently created a session with this exact prompt.")
176
+ console.print(f"Existing Session ID: [bold cyan]{existing_sid}[/bold cyan]")
177
+ import typer
178
+ if not typer.confirm("Are you sure you want to create a duplicate session?"):
179
+ console.print("Session creation cancelled. Use the existing session ID above to resume.")
180
+ raise typer.Exit()
125
181
 
126
182
  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')}")
183
+ resp = sessions.create_session(
184
+ prompt=prompt_text,
185
+ title=title,
186
+ max_acu_limit=max_acu,
187
+ advanced_mode=advanced_mode
188
+ )
189
+ sid = resp.get("session_id")
190
+
191
+ if "advanced_mode_url" in resp:
192
+ adv_url = resp["advanced_mode_url"]
193
+ console.print(f"[bold yellow]Advanced Mode Authorization Required![/bold yellow]")
194
+ console.print(f"Please complete verify the advanced mode setup in your browser:")
195
+ console.print(f"[bold cyan]{adv_url}[/bold cyan]")
196
+ import typer
197
+ if typer.confirm("Open browser now?"):
198
+ import webbrowser
199
+ webbrowser.open(adv_url)
200
+
201
+ if sid:
202
+ config.current_session_id = sid
203
+ config.save_prompt_hash(prompt_hash, sid)
204
+ console.print(f"[green]Session created:[/green] {sid}")
205
+ console.print(f"[bold cyan]URL:[/bold cyan] {resp.get('url')}")
206
+ else:
207
+ console.print("[yellow]Session created, but no ID returned immediately (awaiting advanced mode setup).[/yellow]")
132
208
 
133
209
  @session_app.command("list")
134
210
  @handle_api_error
@@ -141,6 +217,10 @@ def list_sessions_cmd(
141
217
  if org: config.temporary_org_id = org
142
218
  resp = sessions.list_sessions(limit=limit)
143
219
  sess_list = resp.get("sessions", [])
220
+
221
+ if not sess_list and config.api_token and config.api_token.startswith("cog_"):
222
+ console.print("[yellow]Warning: Service tokens (cog_) may only have visibility into sessions they explicitly created, not all organization sessions.[/yellow]")
223
+
144
224
  if json_output:
145
225
  console.print(json.dumps(sess_list, indent=2))
146
226
  else:
@@ -179,7 +259,7 @@ def session_insights_cmd(
179
259
  @session_app.command("cost")
180
260
  @handle_api_error
181
261
  def session_cost_cmd(
182
- session_id: Optional[str] = typer.Option(None, "--id"),
262
+ session_id: Optional[str] = typer.Argument(None, help="Specific session ID to check cost for"),
183
263
  org: Optional[str] = typer.Option(None, "--org"),
184
264
  ):
185
265
  """View ACU consumption."""
@@ -216,7 +296,7 @@ def list_messages_cmd(
216
296
  msgs = resp.get("messages", [])
217
297
  for m in msgs:
218
298
  role = m.get("role", "unknown")
219
- content = m.get("content", "")
299
+ content = m.get("message", "") or m.get("content", "")
220
300
  console.print(f"[bold cyan]{role}:[/bold cyan] {content}")
221
301
  console.print("---")
222
302
 
@@ -279,13 +359,36 @@ def list_playbooks_cmd(org: Optional[str] = typer.Option(None, "--org")):
279
359
  """List team playbooks."""
280
360
  if org: config.temporary_org_id = org
281
361
  resp = playbooks.list_playbooks()
362
+ items = resp.get("items", []) if isinstance(resp, dict) else resp
282
363
  table = Table(title="Playbooks")
283
364
  table.add_column("ID", style="cyan")
284
365
  table.add_column("Title")
285
- for item in resp:
286
- table.add_row(item.get("playbook_id"), item.get("title"))
366
+ for item in items:
367
+ if isinstance(item, dict):
368
+ table.add_row(item.get("playbook_id", ""), item.get("title", ""))
287
369
  console.print(table)
288
370
 
371
+ @playbook_app.command("update")
372
+ @handle_api_error
373
+ def update_playbook_cmd(
374
+ playbook_id: str,
375
+ title: str,
376
+ body: Optional[str] = typer.Option(None, "--body"),
377
+ macro: Optional[str] = typer.Option(None, "--macro"),
378
+ org: Optional[str] = typer.Option(None, "--org"),
379
+ ):
380
+ """Update a team playbook."""
381
+ body_bytes = len(body.encode('utf-8')) if body else 0
382
+ if body_bytes > 500 * 1024:
383
+ console.print(f"[bold yellow]Warning:[/bold yellow] Playbook body is {body_bytes / 1024:.1f}KB. Devin API may reject payloads > 500KB.")
384
+ import typer
385
+ if not typer.confirm("Attempt to send anyway?"):
386
+ raise typer.Exit()
387
+
388
+ if org: config.temporary_org_id = org
389
+ resp = playbooks.update_playbook(playbook_id, title=title, body=body, macro=macro)
390
+ console.print(f"[green]Playbook updated:[/green] {playbook_id}")
391
+
289
392
  @playbook_app.command("create")
290
393
  @handle_api_error
291
394
  def create_playbook_cmd(
@@ -295,6 +398,13 @@ def create_playbook_cmd(
295
398
  org: Optional[str] = typer.Option(None, "--org"),
296
399
  ):
297
400
  """Create a new team playbook."""
401
+ body_bytes = len(body.encode('utf-8')) if body else 0
402
+ if body_bytes > 500 * 1024:
403
+ console.print(f"[bold yellow]Warning:[/bold yellow] Playbook body is {body_bytes / 1024:.1f}KB. Devin API may reject payloads > 500KB.")
404
+ import typer
405
+ if not typer.confirm("Attempt to send anyway?"):
406
+ raise typer.Exit()
407
+
298
408
  if org: config.temporary_org_id = org
299
409
  resp = playbooks.create_playbook(title, body, macro)
300
410
  console.print(f"[green]Playbook created:[/green] {resp.get('playbook_id')}")
@@ -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,12 +0,0 @@
1
- # Changelog
2
-
3
- ## [0.1.0] - 2024-05-23
4
-
5
- ### Added
6
- - Initial release
7
- - Session management (create, list, get, terminate)
8
- - Message sending and history viewing
9
- - Watch command for live status polling
10
- - File attachments
11
- - Knowledge, Playbook, and Secret management commands
12
- - Shell completion support
@@ -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