devin-cli 0.0.1__py3-none-any.whl

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