devin-cli 1.1.7__tar.gz → 1.1.9__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 (53) hide show
  1. {devin_cli-1.1.7 → devin_cli-1.1.9}/CHANGELOG.md +14 -0
  2. {devin_cli-1.1.7 → devin_cli-1.1.9}/PKG-INFO +1 -1
  3. {devin_cli-1.1.7 → devin_cli-1.1.9}/pyproject.toml +1 -1
  4. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/cli.py +418 -2
  5. {devin_cli-1.1.7 → devin_cli-1.1.9}/.github/workflows/pypi-publish.yml +0 -0
  6. {devin_cli-1.1.7 → devin_cli-1.1.9}/.gitignore +0 -0
  7. {devin_cli-1.1.7 → devin_cli-1.1.9}/LICENSE +0 -0
  8. {devin_cli-1.1.7 → devin_cli-1.1.9}/README.md +0 -0
  9. {devin_cli-1.1.7 → devin_cli-1.1.9}/assets/logo.png +0 -0
  10. {devin_cli-1.1.7 → devin_cli-1.1.9}/generate_facades.py +0 -0
  11. {devin_cli-1.1.7 → devin_cli-1.1.9}/setup.py +0 -0
  12. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/__init__.py +0 -0
  13. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/__init__.py +0 -0
  14. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/attachments.py +0 -0
  15. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/client.py +0 -0
  16. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/consumption.py +0 -0
  17. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/knowledge.py +0 -0
  18. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/members.py +0 -0
  19. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/organizations.py +0 -0
  20. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/playbooks.py +0 -0
  21. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/repositories.py +0 -0
  22. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/schedules.py +0 -0
  23. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/secrets.py +0 -0
  24. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/sessions.py +0 -0
  25. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v1/__init__.py +0 -0
  26. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v1/attachments.py +0 -0
  27. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v1/knowledge.py +0 -0
  28. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v1/playbooks.py +0 -0
  29. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v1/secrets.py +0 -0
  30. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v1/sessions.py +0 -0
  31. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/__init__.py +0 -0
  32. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/attachments.py +0 -0
  33. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/consumption.py +0 -0
  34. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/knowledge.py +0 -0
  35. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/members.py +0 -0
  36. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/organizations.py +0 -0
  37. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/playbooks.py +0 -0
  38. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/repositories.py +0 -0
  39. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/schedules.py +0 -0
  40. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/secrets.py +0 -0
  41. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/api/v3/sessions.py +0 -0
  42. {devin_cli-1.1.7 → devin_cli-1.1.9}/src/devin_cli/config.py +0 -0
  43. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/__init__.py +0 -0
  44. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/conftest.py +0 -0
  45. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_attachments.py +0 -0
  46. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_knowledge.py +0 -0
  47. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_playbooks.py +0 -0
  48. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_repositories.py +0 -0
  49. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_schedules.py +0 -0
  50. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_secrets.py +0 -0
  51. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_api/test_sessions.py +0 -0
  52. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_cli.py +0 -0
  53. {devin_cli-1.1.7 → devin_cli-1.1.9}/tests/test_config.py +0 -0
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.9] - 2026-03-11
9
+ ### Fixed
10
+ - `devin watch` terminal status message was printed inside a `Live` context, causing output corruption. Now printed after the `Live` block exits.
11
+ - `update-playbook` was sending an empty string title when `--title` was not passed, overwriting the existing playbook title. Now only sends fields that are explicitly provided.
12
+ - `chain` used bare `resp["session_id"]` key access which would raise `KeyError` on unexpected API responses. Now uses `.get()` with an explicit error guard.
13
+
14
+ ## [1.1.8] - 2026-03-11
15
+ ### Added
16
+ - Restored all 0.1.x flat commands: `watch`, `status`, `open`, `message`, `terminate`, `list-sessions`, `create-session`, `upload`, `list-knowledge`, `attach`, `update-tags`, `history`, `messages`, `get-session`, `update-knowledge`, `update-playbook`, `delete-playbook`, `list-secrets`, `delete-secret`, `chain`.
17
+ - `--wait` / `-w` flag on `create-session` and `sessions create` — blocks until the session reaches a terminal status.
18
+ - `sessions watch` now displays `structured_output` inline and uses exponential backoff (caps at 30s).
19
+ - `chain` command supports both inline `--playbooks` chaining and YAML workflow files.
20
+ - `attach` command correctly embeds the uploaded file URL in the session prompt.
21
+
8
22
  ## [1.1.7] - 2026-03-11
9
23
  ### Fixed
10
24
  - `knowledge list` now correctly reads `notes` key from the v3 `knowledge/notes` endpoint, with `knowledge` as fallback. Also reads `title` in addition to `name` for display.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devin-cli
3
- Version: 1.1.7
3
+ Version: 1.1.9
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devin-cli"
3
- version = "1.1.7"
3
+ version = "1.1.9"
4
4
  description = "Unofficial CLI for Devin AI - The first AI Software Engineer"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -157,6 +157,8 @@ def create_session_cmd(
157
157
  max_acu: Optional[int] = typer.Option(None, "--max-acu", help="Maximum ACU limit"),
158
158
  advanced_mode: Optional[str] = typer.Option(None, "--advanced-mode", help="Enable advanced mode (e.g. CLI, browser) for the session"),
159
159
  force: bool = typer.Option(False, "--force", help="Force creation even if duplicate prompt is detected"),
160
+ wait: bool = typer.Option(False, "--wait", "-w", help="Block until the session reaches a terminal status"),
161
+ interval: int = typer.Option(5, "--interval", help="Polling interval in seconds when --wait is used"),
160
162
  ):
161
163
  """Create a new Devin session."""
162
164
  if org: config.temporary_org_id = org
@@ -201,6 +203,19 @@ def create_session_cmd(
201
203
  config.save_prompt_hash(prompt_hash, sid)
202
204
  console.print(f"[green]Session created:[/green] {sid}")
203
205
  console.print(f"[bold cyan]URL:[/bold cyan] {resp.get('url')}")
206
+
207
+ if wait:
208
+ terminal_statuses = {"stopped", "finished", "error", "cancelled", "failed"}
209
+ console.print(f"[dim]Waiting for session to complete (polling every {interval}s)...[/dim]")
210
+ with console.status("[bold green]Running...[/bold green]") as s:
211
+ while True:
212
+ time.sleep(interval)
213
+ poll = sessions.get_session(sid)
214
+ current_status = poll.get("status_enum", "").lower()
215
+ s.update(f"[bold green]Status: {current_status}[/bold green]")
216
+ if current_status in terminal_statuses:
217
+ console.print(f"[bold green]Session finished:[/bold green] {current_status}")
218
+ break
204
219
  else:
205
220
  console.print("[yellow]Session created, but no ID returned immediately (awaiting advanced mode setup).[/yellow]")
206
221
 
@@ -284,14 +299,25 @@ def session_cost_cmd(
284
299
  @session_app.command("message")
285
300
  @handle_api_error
286
301
  def send_message_cmd(
287
- text: str = typer.Argument(...),
302
+ text: Optional[str] = typer.Argument(None, help="Message text"),
303
+ file: Optional[Path] = typer.Option(None, "--file", "-f", help="Read message from file"),
288
304
  session_id: Optional[str] = typer.Option(None, "--id"),
289
305
  org: Optional[str] = typer.Option(None, "--org"),
290
306
  ):
291
307
  """Send a message to a session."""
292
308
  if org: config.temporary_org_id = org
293
309
  sid = session_id or get_current_session_id()
294
- sessions.send_message(sid, text)
310
+ if file:
311
+ if not file.exists():
312
+ console.print(f"[bold red]Error:[/bold red] File not found: {file}")
313
+ raise typer.Exit(1)
314
+ msg_text = file.read_text()
315
+ elif text:
316
+ msg_text = text
317
+ else:
318
+ console.print("[bold red]Error:[/bold red] Provide message text or --file")
319
+ raise typer.Exit(1)
320
+ sessions.send_message(sid, msg_text)
295
321
  console.print(f"[green]Message sent to {sid}[/green]")
296
322
 
297
323
  @session_app.command("messages")
@@ -612,5 +638,395 @@ def use_session_cmd(session_id: str):
612
638
  config.current_session_id = session_id
613
639
  console.print(f"[green]Switched to session {session_id}[/green]")
614
640
 
641
+ @app.command("status")
642
+ @handle_api_error
643
+ def status_cmd():
644
+ """Show the status of the current active session."""
645
+ sid = get_current_session_id()
646
+ resp = sessions.get_session(sid)
647
+ status = resp.get("status_enum", "unknown")
648
+ title = resp.get("title") or resp.get("prompt", "")[:60]
649
+ url = resp.get("url", "")
650
+ acus = resp.get("acus_consumed") or resp.get("acu_used")
651
+
652
+ color = {"running": "green", "stopped": "red", "paused": "yellow"}.get(status, "cyan")
653
+ console.print(f"[bold]Session:[/bold] [cyan]{sid}[/cyan]")
654
+ console.print(f"[bold]Status:[/bold] [{color}]{status}[/{color}]")
655
+ if title:
656
+ console.print(f"[bold]Title:[/bold] {title}")
657
+ if acus is not None:
658
+ console.print(f"[bold]ACUs:[/bold] {acus}")
659
+ if url:
660
+ console.print(f"[bold]URL:[/bold] [underline]{url}[/underline]")
661
+
662
+ @app.command("open")
663
+ @handle_api_error
664
+ def open_cmd(
665
+ session_id: Optional[str] = typer.Argument(None, help="Session ID to open (defaults to active session)"),
666
+ ):
667
+ """Open the active session in your browser."""
668
+ sid = session_id or get_current_session_id()
669
+ resp = sessions.get_session(sid)
670
+ url = resp.get("url")
671
+ if not url:
672
+ console.print("[bold red]Error:[/bold red] No URL available for this session.")
673
+ raise typer.Exit(1)
674
+ webbrowser.open(url)
675
+ console.print(f"[green]Opened:[/green] {url}")
676
+
677
+ @session_app.command("watch")
678
+ @handle_api_error
679
+ def watch_session_cmd(
680
+ session_id: Optional[str] = typer.Argument(None, help="Session ID to watch (defaults to active session)"),
681
+ interval: int = typer.Option(3, "--interval", "-i", help="Polling interval in seconds"),
682
+ org: Optional[str] = typer.Option(None, "--org"),
683
+ ):
684
+ """Live-watch a session — polls status and streams new messages."""
685
+ if org: config.temporary_org_id = org
686
+ sid = session_id or get_current_session_id()
687
+
688
+ terminal_statuses = {"stopped", "finished", "error", "cancelled", "failed", "blocked"}
689
+ backoff = interval
690
+
691
+ def build_status_panel(resp: dict) -> Panel:
692
+ status_val = resp.get("status_enum", "unknown")
693
+ title_val = resp.get("title") or resp.get("prompt", "")[:60]
694
+ acus = resp.get("acus_consumed") or resp.get("acu_used", "n/a")
695
+ color = {"running": "green", "stopped": "red", "paused": "yellow",
696
+ "finished": "blue", "blocked": "red"}.get(status_val, "cyan")
697
+ text = Text()
698
+ text.append("Session: ", style="bold")
699
+ text.append(f"{sid}\n", style="cyan")
700
+ text.append("Status: ", style="bold")
701
+ text.append(f"{status_val}\n", style=color)
702
+ if title_val:
703
+ text.append(f"Title: {title_val}\n")
704
+ text.append(f"ACUs: {acus}\n")
705
+ so = resp.get("structured_output")
706
+ if so:
707
+ text.append("\nStructured Output:\n", style="bold")
708
+ text.append(json.dumps(so, indent=2))
709
+ text.append("\nPress Ctrl+C to stop watching.", style="dim")
710
+ return Panel(text, title="[bold cyan]Devin Watch[/bold cyan]", border_style="cyan")
711
+
712
+ console.print(f"[bold cyan]Watching session {sid}[/bold cyan] (Ctrl+C to stop)")
713
+
714
+ with Live(console=console, refresh_per_second=4) as live:
715
+ try:
716
+ while True:
717
+ resp = sessions.get_session(sid)
718
+ live.update(build_status_panel(resp))
719
+
720
+ if resp.get("status_enum", "").lower() in terminal_statuses:
721
+ live.update(build_status_panel(resp))
722
+ break
723
+
724
+ time.sleep(min(backoff, 30))
725
+ backoff = min(backoff * 1.5, 30)
726
+ except KeyboardInterrupt:
727
+ console.print("\n[dim]Watch stopped.[/dim]")
728
+ return
729
+
730
+ console.print(f"[bold green]Session {resp.get('status_enum')}![/bold green]")
731
+
732
+ # --- Flat top-level aliases (backward compat with 0.1.x command structure) ---
733
+
734
+ @app.command("watch", hidden=True)
735
+ @handle_api_error
736
+ def watch_cmd(
737
+ session_id: Optional[str] = typer.Argument(None),
738
+ interval: int = typer.Option(3, "--interval", "-i"),
739
+ ):
740
+ """Alias for: devin sessions watch"""
741
+ watch_session_cmd(session_id=session_id, interval=interval, org=None)
742
+
743
+ @app.command("message")
744
+ @handle_api_error
745
+ def message_cmd(
746
+ text: Optional[str] = typer.Argument(None, help="Message to send"),
747
+ file: Optional[Path] = typer.Option(None, "--file", "-f", help="Read message from file"),
748
+ session_id: Optional[str] = typer.Option(None, "--id"),
749
+ ):
750
+ """Send a message to the active session."""
751
+ sid = session_id or get_current_session_id()
752
+ if file:
753
+ if not file.exists():
754
+ console.print(f"[bold red]Error:[/bold red] File not found: {file}")
755
+ raise typer.Exit(1)
756
+ msg_text = file.read_text()
757
+ elif text:
758
+ msg_text = text
759
+ else:
760
+ console.print("[bold red]Error:[/bold red] Provide message text or --file")
761
+ raise typer.Exit(1)
762
+ sessions.send_message(sid, msg_text)
763
+ console.print(f"[green]Message sent to {sid}[/green]")
764
+
765
+ @app.command("terminate")
766
+ @handle_api_error
767
+ def terminate_cmd(
768
+ session_id: Optional[str] = typer.Argument(None),
769
+ ):
770
+ """Terminate the active session."""
771
+ sid = session_id or get_current_session_id()
772
+ if typer.confirm(f"Terminate session {sid}?"):
773
+ sessions.terminate_session(sid)
774
+ console.print(f"[green]Session {sid} terminated.[/green]")
775
+
776
+ @app.command("list-sessions")
777
+ @handle_api_error
778
+ def list_sessions_top_cmd(
779
+ limit: int = typer.Option(10, "--limit", "-n"),
780
+ json_output: bool = typer.Option(False, "--json"),
781
+ ):
782
+ """List recent sessions (alias for: devin sessions list)."""
783
+ resp = sessions.list_sessions(limit=limit)
784
+ sess_list = resp.get("sessions", [])
785
+ if json_output:
786
+ console.print(json.dumps(sess_list, indent=2))
787
+ return
788
+ table = Table(title="Devin Sessions")
789
+ table.add_column("ID", style="cyan")
790
+ table.add_column("Status", style="magenta")
791
+ table.add_column("Title")
792
+ for s in sess_list:
793
+ table.add_row(s.get("session_id"), s.get("status_enum"), s.get("title") or s.get("prompt", "")[:50])
794
+ console.print(table)
795
+
796
+ @app.command("create-session")
797
+ @handle_api_error
798
+ def create_session_top_cmd(
799
+ prompt: str = typer.Argument(..., help="The prompt for the session"),
800
+ title: Optional[str] = typer.Option(None, "--title", "-t"),
801
+ wait: bool = typer.Option(False, "--wait", "-w", help="Block until the session completes"),
802
+ interval: int = typer.Option(5, "--interval"),
803
+ force: bool = typer.Option(False, "--force"),
804
+ ):
805
+ """Create a new session (alias for: devin sessions create)."""
806
+ create_session_cmd(prompt=prompt, file=None, title=title, org=None, max_acu=None,
807
+ advanced_mode=None, force=force, wait=wait, interval=interval)
808
+
809
+ @app.command("upload")
810
+ @handle_api_error
811
+ def upload_cmd(path: Path = typer.Argument(..., help="File to upload")):
812
+ """Upload a file to Devin."""
813
+ resp = attachments.upload_file(str(path))
814
+ console.print(f"[green]Uploaded:[/green] {resp}")
815
+
816
+ @app.command("list-knowledge")
817
+ @handle_api_error
818
+ def list_knowledge_top_cmd():
819
+ """List knowledge notes (alias for: devin knowledge list)."""
820
+ resp = knowledge.list_knowledge()
821
+ items = resp.get("notes", resp.get("knowledge", []))
822
+ table = Table(title="Knowledge Base")
823
+ table.add_column("ID", style="cyan")
824
+ table.add_column("Title")
825
+ for item in items:
826
+ table.add_row(item.get("id"), item.get("title") or item.get("name"))
827
+ console.print(table)
828
+
829
+ @app.command("attach")
830
+ @handle_api_error
831
+ def attach_cmd(
832
+ file: Path = typer.Argument(..., help="Context file to upload and attach"),
833
+ prompt: str = typer.Argument(..., help="Task prompt to include with the attachment"),
834
+ title: Optional[str] = typer.Option(None, "--title", "-t"),
835
+ wait: bool = typer.Option(False, "--wait", "-w"),
836
+ interval: int = typer.Option(5, "--interval"),
837
+ ):
838
+ """Upload a context file and start a session using it."""
839
+ with console.status("[bold green]Uploading file...[/bold green]"):
840
+ upload_resp = attachments.upload_file(str(file))
841
+ url = upload_resp.strip('"') if isinstance(upload_resp, str) else str(upload_resp)
842
+ console.print(f"[green]Uploaded:[/green] {url}")
843
+ full_prompt = f"{prompt}\n\nATTACHMENT: \"{url}\""
844
+ create_session_cmd(prompt=full_prompt, file=None, title=title, org=None, max_acu=None,
845
+ advanced_mode=None, force=False, wait=wait, interval=interval)
846
+
847
+ @app.command("update-tags")
848
+ @handle_api_error
849
+ def update_tags_cmd(
850
+ session_id: Optional[str] = typer.Argument(None),
851
+ tags: List[str] = typer.Option(..., "--tag", "-t", help="Tags to set (can repeat)"),
852
+ ):
853
+ """Update tags for a session."""
854
+ sid = session_id or get_current_session_id()
855
+ sessions.update_session_tags(sid, tags)
856
+ console.print(f"[green]Tags updated for session {sid}.[/green]")
857
+
858
+ @app.command("history")
859
+ def history_cmd():
860
+ """Show the locally cached current session ID."""
861
+ sid = config.current_session_id
862
+ if sid:
863
+ console.print(f"Current local session: [cyan]{sid}[/cyan]")
864
+ else:
865
+ console.print("No current local session.")
866
+
867
+ @app.command("messages")
868
+ @handle_api_error
869
+ def messages_cmd(
870
+ session_id: Optional[str] = typer.Argument(None),
871
+ ):
872
+ """Show conversation history for a session."""
873
+ sid = session_id or get_current_session_id()
874
+ resp = sessions.get_session_messages(sid)
875
+ msgs = resp.get("messages", [])
876
+ console.print(f"[bold]Conversation for Session {sid}[/bold]")
877
+ console.print("─" * 40)
878
+ for m in msgs:
879
+ role = m.get("role", "unknown")
880
+ content = m.get("message", "") or m.get("content", "")
881
+ console.print(f"[bold cyan]{role}:[/bold cyan] {content}")
882
+
883
+ @app.command("get-session")
884
+ @handle_api_error
885
+ def get_session_top_cmd(
886
+ session_id: Optional[str] = typer.Argument(None),
887
+ ):
888
+ """Get details for a session."""
889
+ sid = session_id or get_current_session_id()
890
+ resp = sessions.get_session(sid)
891
+ console.print(Panel(
892
+ f"[bold]Status:[/bold] {resp.get('status_enum')}\n"
893
+ f"[bold]URL:[/bold] {resp.get('url')}\n"
894
+ f"[bold]Created:[/bold] {resp.get('created_at')}",
895
+ title=f"Session {sid}"
896
+ ))
897
+ if "structured_output" in resp:
898
+ console.print("[bold]Structured Output:[/bold]")
899
+ console.print(json.dumps(resp["structured_output"], indent=2))
900
+
901
+ @app.command("update-knowledge")
902
+ @handle_api_error
903
+ def update_knowledge_cmd(
904
+ knowledge_id: str = typer.Argument(...),
905
+ name: Optional[str] = typer.Option(None, "--name"),
906
+ body: Optional[str] = typer.Option(None, "--body"),
907
+ trigger: Optional[str] = typer.Option(None, "--trigger"),
908
+ ):
909
+ """Update an existing knowledge entry."""
910
+ knowledge.update_knowledge(knowledge_id, title=name, body=body)
911
+ console.print(f"[green]Knowledge {knowledge_id} updated.[/green]")
912
+
913
+ @app.command("update-playbook")
914
+ @handle_api_error
915
+ def update_playbook_top_cmd(
916
+ playbook_id: str = typer.Argument(...),
917
+ title: Optional[str] = typer.Option(None, "--title"),
918
+ body: Optional[str] = typer.Option(None, "--body"),
919
+ macro: Optional[str] = typer.Option(None, "--macro"),
920
+ ):
921
+ """Update an existing playbook."""
922
+ data: dict = {}
923
+ if title is not None:
924
+ data["title"] = title
925
+ if body is not None:
926
+ data["body"] = body
927
+ if macro is not None:
928
+ data["macro"] = macro
929
+ if not data:
930
+ console.print("[yellow]Nothing to update. Pass --title, --body, or --macro.[/yellow]")
931
+ return
932
+ playbooks.update_playbook(playbook_id, title=title or "", body=body, macro=macro)
933
+ console.print(f"[green]Playbook {playbook_id} updated.[/green]")
934
+
935
+ @app.command("delete-playbook")
936
+ @handle_api_error
937
+ def delete_playbook_top_cmd(playbook_id: str = typer.Argument(...)):
938
+ """Delete a playbook."""
939
+ if typer.confirm(f"Delete playbook {playbook_id}?"):
940
+ playbooks.delete_playbook(playbook_id)
941
+ console.print(f"[green]Playbook {playbook_id} deleted.[/green]")
942
+
943
+ @app.command("list-secrets")
944
+ @handle_api_error
945
+ def list_secrets_top_cmd():
946
+ """List all organization secrets."""
947
+ resp = secrets.list_secrets()
948
+ items = resp if isinstance(resp, list) else resp.get("secrets", resp.get("items", []))
949
+ table = Table(title="Organization Secrets")
950
+ table.add_column("ID", style="cyan")
951
+ table.add_column("Name")
952
+ for item in items:
953
+ if isinstance(item, dict):
954
+ table.add_row(item.get("id", ""), item.get("name", ""))
955
+ console.print(table)
956
+
957
+ @app.command("delete-secret")
958
+ @handle_api_error
959
+ def delete_secret_top_cmd(secret_id: str = typer.Argument(...)):
960
+ """Delete an organization secret."""
961
+ if typer.confirm(f"Delete secret {secret_id}?"):
962
+ secrets.delete_secret(secret_id)
963
+ console.print(f"[green]Secret {secret_id} deleted.[/green]")
964
+
965
+ @app.command("chain")
966
+ @handle_api_error
967
+ def chain_cmd(
968
+ prompt: Optional[str] = typer.Argument(None, help="Initial prompt"),
969
+ playbooks_arg: Optional[str] = typer.Option(None, "--playbooks", help="Comma-separated playbook IDs"),
970
+ file: Optional[Path] = typer.Option(None, "--file", help="Workflow YAML file"),
971
+ ):
972
+ """(Beta) Orchestrate a sequential chain of playbooks.
973
+
974
+ Method 1 \u2014 Inline:
975
+ devin chain "Refactor utils.py" --playbooks "lint_check,unit_tests"
976
+
977
+ Method 2 \u2014 YAML workflow file:
978
+ devin chain --file workflow.yml
979
+ """
980
+ steps = []
981
+
982
+ if file:
983
+ if not file.exists():
984
+ console.print(f"[bold red]Error:[/bold red] File not found: {file}")
985
+ raise typer.Exit(1)
986
+ try:
987
+ workflow = yaml.safe_load(file.read_text())
988
+ steps = workflow.get("steps", [])
989
+ except Exception as e:
990
+ console.print(f"[bold red]Error parsing YAML:[/bold red] {e}")
991
+ raise typer.Exit(1)
992
+ elif prompt and playbooks_arg:
993
+ pb_list = [p.strip() for p in playbooks_arg.split(",")]
994
+ for i, pb in enumerate(pb_list):
995
+ step_prompt = prompt if i == 0 else f"Execute playbook: {pb}"
996
+ steps.append({"prompt": step_prompt, "playbook": pb})
997
+ else:
998
+ console.print("[bold red]Error:[/bold red] Provide --file OR (prompt + --playbooks)")
999
+ raise typer.Exit(1)
1000
+
1001
+ current_sid = None
1002
+ for i, step in enumerate(steps):
1003
+ step_prompt = step.get("prompt", "")
1004
+ step_pb = step.get("playbook")
1005
+ console.print(f"[bold cyan]Step {i+1}/{len(steps)}:[/bold cyan] Playbook={step_pb}")
1006
+
1007
+ if i == 0:
1008
+ with console.status("Starting session..."):
1009
+ resp = sessions.create_session(prompt=step_prompt, playbook_id=step_pb)
1010
+ current_sid = resp.get("session_id")
1011
+ if not current_sid:
1012
+ console.print("[bold red]Error:[/bold red] No session ID returned from API.")
1013
+ raise typer.Exit(1)
1014
+ config.current_session_id = current_sid
1015
+ console.print(f"[green]Session started:[/green] {current_sid}")
1016
+ else:
1017
+ sessions.send_message(current_sid, f"{step_prompt} (Playbook: {step_pb})")
1018
+
1019
+ backoff = 2
1020
+ while True:
1021
+ resp = sessions.get_session(current_sid)
1022
+ status = resp.get("status_enum", "")
1023
+ if status in {"blocked", "finished", "stopped", "error"}:
1024
+ console.print(f"Step {i+1} done (status: {status}).")
1025
+ break
1026
+ time.sleep(min(backoff, 10))
1027
+ backoff = min(backoff * 1.5, 10)
1028
+
1029
+ console.print("[bold green]Chain completed![/bold green]")
1030
+
615
1031
  if __name__ == "__main__":
616
1032
  app()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes