orpheus-cli 0.1.0__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.
orpheus/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Orpheus CLI."""
orpheus/auth.py ADDED
@@ -0,0 +1,94 @@
1
+ """GitHub device flow authentication."""
2
+
3
+ import time
4
+
5
+ import httpx
6
+ from pydantic import BaseModel, HttpUrl
7
+
8
+ from orpheus.config import get_config
9
+
10
+
11
+ class AuthError(Exception):
12
+ """Authentication failed."""
13
+
14
+ pass
15
+
16
+
17
+ class DeviceCodeResponse(BaseModel):
18
+ device_code: str
19
+ user_code: str
20
+ verification_uri: HttpUrl
21
+ expires_in: int
22
+ interval: int
23
+
24
+
25
+ class AccessTokenResponse(BaseModel):
26
+ access_token: str
27
+ token_type: str
28
+ scope: str
29
+
30
+
31
+ class ErrorResponse(BaseModel):
32
+ error: str
33
+ error_description: str | None = None
34
+ interval: int | None = None
35
+
36
+ def check(self) -> int:
37
+ """Return interval for retryable errors, raise AuthError for fatal."""
38
+ match self.error:
39
+ case "authorization_pending":
40
+ return self.interval or 5
41
+ case "slow_down":
42
+ return self.interval or 10
43
+ case "expired_token":
44
+ raise AuthError("Device code expired. Please try again.")
45
+ case "access_denied":
46
+ raise AuthError("Authorization denied by user.")
47
+ case _:
48
+ raise AuthError(f"Unexpected error: {self.error}")
49
+
50
+
51
+ TokenResponse = AccessTokenResponse | ErrorResponse
52
+
53
+
54
+ def parse_token_response(data: dict) -> TokenResponse:
55
+ """Parse GitHub's token endpoint response."""
56
+ if "access_token" in data:
57
+ return AccessTokenResponse.model_validate(data)
58
+ return ErrorResponse.model_validate(data)
59
+
60
+
61
+ def request_device_code() -> DeviceCodeResponse:
62
+ """Request a device code from GitHub."""
63
+ config = get_config()
64
+ response = httpx.post(
65
+ "https://github.com/login/device/code",
66
+ data={"client_id": config.github_client_id},
67
+ headers={"Accept": "application/json"},
68
+ )
69
+ response.raise_for_status()
70
+ return DeviceCodeResponse.model_validate(response.json())
71
+
72
+
73
+ def poll_for_user_access_token(device_code: str, interval: int) -> str:
74
+ """Poll GitHub until user authorizes, then return user access token."""
75
+ config = get_config()
76
+
77
+ while True:
78
+ response = httpx.post(
79
+ "https://github.com/login/oauth/access_token",
80
+ data={
81
+ "client_id": config.github_client_id,
82
+ "device_code": device_code,
83
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
84
+ },
85
+ headers={"Accept": "application/json"},
86
+ )
87
+ response.raise_for_status()
88
+
89
+ match parse_token_response(response.json()):
90
+ case AccessTokenResponse(access_token=token):
91
+ return token
92
+ case ErrorResponse() as error:
93
+ interval = error.check()
94
+ time.sleep(interval)
orpheus/client.py ADDED
@@ -0,0 +1,208 @@
1
+ """HTTP client for Orpheus server API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+ from pydantic import HttpUrl
10
+
11
+ from orpheus.config import load_config
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from orpheus.config import Config
16
+
17
+
18
+ class NotFoundError(Exception):
19
+ """Raised when a resource is not found."""
20
+
21
+ def __init__(self, resource_type: str, identifier: str):
22
+ self.resource_type = resource_type
23
+ self.identifier = identifier
24
+ super().__init__(f"{resource_type} '{identifier}' not found")
25
+
26
+
27
+ class APIError(Exception):
28
+ """Raised when an API request fails."""
29
+
30
+ def __init__(self, message: str):
31
+ super().__init__(message)
32
+
33
+
34
+ class OrpheusClient:
35
+ """Orpheus API client."""
36
+
37
+ def __init__(self, config: Config | None = None):
38
+ self.config = config or load_config()
39
+ self._client = httpx.Client(base_url=str(self.base_url), timeout=300)
40
+
41
+ @property
42
+ def base_url(self) -> HttpUrl:
43
+ """Base URL for the Orpheus API."""
44
+ return self.config.base_url
45
+
46
+ @property
47
+ def user_access_token(self) -> str | None:
48
+ """User access token for the Orpheus API."""
49
+ return self.config.user_access_token
50
+
51
+ @property
52
+ def _headers(self) -> dict[str, str]:
53
+ """Headers for API requests."""
54
+ headers = {}
55
+ if self.user_access_token:
56
+ headers["Authorization"] = f"Bearer {self.user_access_token}"
57
+ return headers
58
+
59
+ def create_task(
60
+ self,
61
+ spec: str,
62
+ repo_full_name: str,
63
+ snapshot_id: str | None = None,
64
+ programs: list[str] | None = None,
65
+ git_branch: str | None = None,
66
+ ) -> dict[str, Any]:
67
+ """Create a new task."""
68
+ body: dict[str, Any] = {"spec": spec, "repo_full_name": repo_full_name}
69
+ if snapshot_id:
70
+ body["snapshot_id"] = snapshot_id
71
+ if programs:
72
+ body["programs"] = programs
73
+ if git_branch:
74
+ body["git_branch"] = git_branch
75
+ response = self._client.post("/tasks", json=body, headers=self._headers)
76
+ if response.status_code != 200:
77
+ raise APIError(response.text)
78
+ return response.json()
79
+
80
+ def get_task(self, task_slug: str) -> dict[str, Any]:
81
+ """Get task details by slug."""
82
+ response = self._client.get(f"/tasks/{task_slug}", headers=self._headers)
83
+ if response.status_code == 404:
84
+ raise NotFoundError("Task", task_slug)
85
+ if response.status_code != 200:
86
+ raise APIError(response.text)
87
+ return response.json()
88
+
89
+ def stop_task(self, task_slug: str) -> dict[str, Any]:
90
+ """Stop a task by slug."""
91
+ response = self._client.delete(f"/tasks/{task_slug}", headers=self._headers)
92
+ if response.status_code == 404:
93
+ raise NotFoundError("Task", task_slug)
94
+ if response.status_code != 200:
95
+ raise APIError(response.text)
96
+ return response.json()
97
+
98
+ def list_tasks(self, active_only: bool = True) -> list[dict[str, Any]]:
99
+ """List tasks."""
100
+ params = {} if active_only else {"active_only": "false"}
101
+ response = self._client.get("/tasks", headers=self._headers, params=params)
102
+ if response.status_code != 200:
103
+ raise APIError(response.text)
104
+ return response.json()["tasks"]
105
+
106
+ def get_execution_diff(self, execution_slug: str) -> str:
107
+ """Get diff for an execution by slug."""
108
+ response = self._client.get(f"/executions/{execution_slug}/diff", headers=self._headers)
109
+ if response.status_code == 404:
110
+ raise NotFoundError("Execution", execution_slug)
111
+ if response.status_code != 200:
112
+ raise APIError(response.text)
113
+ return response.json()["diff"]
114
+
115
+ def list_programs(self) -> list[str]:
116
+ """List available programs."""
117
+ response = self._client.get("/available-programs", headers=self._headers)
118
+ if response.status_code != 200:
119
+ raise APIError(response.text)
120
+ return response.json()["programs"]
121
+
122
+ def list_repos(self) -> list[dict[str, Any]]:
123
+ """List accessible repositories."""
124
+ response = self._client.get("/repos", headers=self._headers)
125
+ if response.status_code != 200:
126
+ raise APIError(response.text)
127
+ return response.json()["repos"]
128
+
129
+ def start_setup(self, repo_full_name: str, private: bool) -> dict[str, Any]:
130
+ """Start devbox setup for a repository."""
131
+ response = self._client.post(
132
+ "/setup/start",
133
+ json={"repo_full_name": repo_full_name, "private": private},
134
+ headers=self._headers,
135
+ )
136
+ if response.status_code != 200:
137
+ raise APIError(response.text)
138
+ return response.json()
139
+
140
+ def save_setup(self, repo_full_name: str) -> dict[str, Any]:
141
+ """Save devbox state by creating a snapshot."""
142
+ response = self._client.post(
143
+ "/setup/save",
144
+ json={"repo_full_name": repo_full_name},
145
+ headers=self._headers,
146
+ )
147
+ if response.status_code != 200:
148
+ raise APIError(response.text)
149
+ return response.json()
150
+
151
+ def download_file(
152
+ self,
153
+ path: str,
154
+ repo: str | None = None,
155
+ execution_slug: str | None = None,
156
+ agent_name: str | None = None,
157
+ ) -> httpx.Response:
158
+ """Download a file from a VM. Returns the raw response for streaming."""
159
+ params: dict[str, str] = {"path": path}
160
+ if repo:
161
+ params["repo"] = repo
162
+ if execution_slug:
163
+ params["execution_slug"] = execution_slug
164
+ if agent_name:
165
+ params["agent_name"] = agent_name
166
+ response = self._client.get("/files/download", params=params, headers=self._headers)
167
+ if response.status_code == 404:
168
+ raise NotFoundError("File", path)
169
+ if response.status_code != 200:
170
+ raise APIError(response.text)
171
+ return response
172
+
173
+ def upload_file(
174
+ self,
175
+ local_path: Path | None,
176
+ remote_path: str,
177
+ content: bytes | None = None,
178
+ repo: str | None = None,
179
+ execution_slug: str | None = None,
180
+ agent_name: str | None = None,
181
+ ) -> dict[str, Any]:
182
+ """Upload a file to a VM."""
183
+ params: dict[str, str] = {"path": remote_path}
184
+ if repo:
185
+ params["repo"] = repo
186
+ if execution_slug:
187
+ params["execution_slug"] = execution_slug
188
+ if agent_name:
189
+ params["agent_name"] = agent_name
190
+
191
+ if content is not None:
192
+ file_data = content
193
+ filename = Path(remote_path).name
194
+ elif local_path:
195
+ file_data = local_path.read_bytes()
196
+ filename = local_path.name
197
+ else:
198
+ raise ValueError("Either local_path or content must be provided")
199
+
200
+ response = self._client.post(
201
+ "/files/upload",
202
+ params=params,
203
+ files={"file": (filename, file_data)},
204
+ headers=self._headers,
205
+ )
206
+ if response.status_code != 200:
207
+ raise APIError(response.text)
208
+ return response.json()
@@ -0,0 +1,85 @@
1
+ """Authentication commands."""
2
+
3
+ import webbrowser
4
+
5
+ import typer
6
+ from orpheus.auth import AuthError, poll_for_user_access_token, request_device_code
7
+ from orpheus.config import load_config, save_config
8
+ from orpheus.display import console, print_error, print_success
9
+
10
+
11
+ auth = typer.Typer(help="Manage authentication.", no_args_is_help=True)
12
+
13
+
14
+ def has_browser() -> bool:
15
+ """Check if a browser is available."""
16
+ try:
17
+ webbrowser.get()
18
+ return True
19
+ except webbrowser.Error:
20
+ return False
21
+
22
+
23
+ def _prompt_app_installation(app_slug: str) -> None:
24
+ """Print the installation link so the user can add the app to their repos."""
25
+ install_url = f"https://github.com/apps/{app_slug}/installations/new"
26
+ console.print()
27
+ console.print("Install the Orpheus GitHub App on any repos you want agents to work on:")
28
+ console.print(f" {install_url}")
29
+ console.print()
30
+
31
+
32
+ @auth.command()
33
+ def login():
34
+ """Authenticate with Orpheus via GitHub."""
35
+ config = load_config()
36
+
37
+ # Request device code
38
+ response = request_device_code()
39
+
40
+ # Prompt user
41
+ console.print(f"Your one-time code is [bold green]{response.user_code}[/bold green]")
42
+ console.print()
43
+
44
+ if has_browser():
45
+ console.print("Press Enter to open `github.com` in your browser...")
46
+ input()
47
+ webbrowser.open(str(response.verification_uri))
48
+ else:
49
+ console.print(f"Open this URL in a browser: [bold blue]{response.verification_uri}[/bold blue]")
50
+ console.print("Enter the code above, then authorize the application.")
51
+ console.print()
52
+
53
+ # Poll for token
54
+ console.print("Waiting for authorization...")
55
+ try:
56
+ user_access_token = poll_for_user_access_token(response.device_code, response.interval)
57
+ except AuthError as error:
58
+ print_error(str(error))
59
+ raise typer.Exit(1)
60
+
61
+ # Save
62
+ config.user_access_token = user_access_token
63
+ save_config(config)
64
+ print_success("Authenticated successfully.")
65
+
66
+ _prompt_app_installation(config.github_app_slug)
67
+
68
+
69
+ @auth.command()
70
+ def logout():
71
+ """Clear stored credentials."""
72
+ config = load_config()
73
+ config.user_access_token = None
74
+ save_config(config)
75
+ print_success("Logged out.")
76
+
77
+
78
+ @auth.command()
79
+ def status():
80
+ """Show current authentication status."""
81
+ config = load_config()
82
+ if config.user_access_token:
83
+ print_success("Authenticated.")
84
+ else:
85
+ print_error("Not authenticated. Run `orpheus auth login`.")
@@ -0,0 +1,132 @@
1
+ """Setup commands for devbox configuration."""
2
+
3
+ from pathlib import Path
4
+
5
+ import questionary
6
+ import typer
7
+ from orpheus.client import APIError, OrpheusClient
8
+ from orpheus.config import get_config
9
+ from orpheus.display import console, print_error, print_success, print_warning
10
+ from orpheus.git import get_repo_from_cwd
11
+
12
+
13
+ setup = typer.Typer(help="Manage devbox setup.", no_args_is_help=True)
14
+
15
+
16
+ def get_keys_dir() -> Path:
17
+ """Get the directory for SSH keys."""
18
+ keys_dir = Path.home() / ".orpheus" / "keys"
19
+ keys_dir.mkdir(parents=True, exist_ok=True)
20
+ return keys_dir
21
+
22
+
23
+ @setup.command()
24
+ def start():
25
+ """Start devbox setup by selecting a repository."""
26
+ config = get_config()
27
+
28
+ if not config.user_access_token:
29
+ print_error("Not authenticated. Run 'orpheus auth login' first.")
30
+ raise typer.Exit(1)
31
+
32
+ client = OrpheusClient(config)
33
+
34
+ console.print("Fetching your repositories...")
35
+ try:
36
+ repos = client.list_repos()
37
+ except APIError as e:
38
+ print_error(f"Failed to fetch repos: {e}")
39
+ raise typer.Exit(1)
40
+
41
+ if not repos:
42
+ print_error("No repositories found.")
43
+ raise typer.Exit(1)
44
+
45
+ # Try to auto-detect repo from cwd
46
+ detected_repo = get_repo_from_cwd()
47
+ selected = None
48
+
49
+ if detected_repo:
50
+ selected = next((r for r in repos if r["full_name"] == detected_repo), None)
51
+ if selected:
52
+ console.print(f"Detected repository: [bold]{detected_repo}[/bold]")
53
+ else:
54
+ print_error(f"Detected repo '{detected_repo}' not in your accessible repos.")
55
+ raise typer.Exit(1)
56
+
57
+ if not selected:
58
+ choices = [questionary.Choice(title=r["full_name"], value=r) for r in repos]
59
+ selected = questionary.select(
60
+ "Select a repository:",
61
+ choices=choices,
62
+ use_shortcuts=False,
63
+ use_arrow_keys=True,
64
+ use_jk_keys=False,
65
+ use_search_filter=True,
66
+ ).ask()
67
+
68
+ if selected is None:
69
+ raise typer.Exit(1)
70
+
71
+ repo_full_name = selected["full_name"]
72
+ is_private = selected["private"]
73
+
74
+ console.print()
75
+ console.print(f"Provisioning devbox for [bold]{repo_full_name}[/bold]...")
76
+
77
+ try:
78
+ data = client.start_setup(repo_full_name, is_private)
79
+ except APIError as e:
80
+ print_error(f"Failed to start setup: {e}")
81
+ raise typer.Exit(1)
82
+
83
+ # Save SSH key
84
+ keys_dir = get_keys_dir()
85
+ key_path = keys_dir / data["instance_id"]
86
+ key_path.write_text(data["ssh_private_key"])
87
+ key_path.chmod(0o600)
88
+
89
+ # Display SSH info
90
+ console.print()
91
+ print_success("Your devbox is ready!")
92
+ console.print()
93
+ console.print("[bold]SSH command:[/bold]")
94
+ console.print(f" ssh -i {key_path} {data['ssh_user']}@{data['ssh_host']}")
95
+ console.print()
96
+ console.print("[bold]Or use password:[/bold]")
97
+ console.print(f" {data['ssh_password']}")
98
+ console.print()
99
+ print_warning("When you're done configuring, run:")
100
+ console.print(" [bold]orpheus setup save[/bold]")
101
+
102
+
103
+ @setup.command()
104
+ def save():
105
+ """Save devbox state by creating a snapshot."""
106
+ config = get_config()
107
+
108
+ if not config.user_access_token:
109
+ print_error("Not authenticated. Run 'orpheus auth login' first.")
110
+ raise typer.Exit(1)
111
+
112
+ # Detect repo from cwd
113
+ repo_full_name = get_repo_from_cwd()
114
+ if not repo_full_name:
115
+ print_error("Not in a git repository with a GitHub remote.")
116
+ print_error("Run this command from within your cloned repo directory.")
117
+ raise typer.Exit(1)
118
+
119
+ client = OrpheusClient(config)
120
+
121
+ console.print(f"Saving devbox snapshot for [bold]{repo_full_name}[/bold]...")
122
+
123
+ try:
124
+ data = client.save_setup(repo_full_name)
125
+ except APIError as e:
126
+ print_error(f"Failed to save devbox: {e}")
127
+ raise typer.Exit(1)
128
+
129
+ print_success(f"Snapshot saved: {data['snapshot_id']}")
130
+ console.print()
131
+ console.print("You can now run tasks with:")
132
+ console.print(" [bold]orpheus exec spec.txt[/bold]")
orpheus/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """Configuration management for `~/.orpheus/config.json`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from pydantic import HttpUrl
9
+ from pydantic_settings import BaseSettings
10
+
11
+
12
+ _config: Config | None = None
13
+
14
+
15
+ class Config(BaseSettings):
16
+ """Orpheus CLI configuration."""
17
+
18
+ base_url: HttpUrl = "https://api.orpheus.dev"
19
+ github_client_id: str = "Iv23liwHHi6oub0QWFau"
20
+ github_app_slug: str = "orpheus-by-fulcrum"
21
+ user_access_token: str | None = None
22
+
23
+
24
+ def get_config() -> Config:
25
+ """Get the configuration."""
26
+ global _config
27
+ if _config is None:
28
+ _config = load_config()
29
+ return _config
30
+
31
+
32
+ def load_config() -> Config:
33
+ """Load config from `~/.orpheus/config.json`."""
34
+ home = Path.home()
35
+ config_path = home / ".orpheus" / "config.json"
36
+ if config_path.exists():
37
+ data = json.loads(config_path.read_text())
38
+ return Config(**data)
39
+ return Config()
40
+
41
+
42
+ def save_config(config: Config) -> None:
43
+ """Save config to `~/.orpheus/config.json`."""
44
+ home = Path.home()
45
+ config_path = home / ".orpheus" / "config.json"
46
+ config_path.parent.mkdir(exist_ok=True)
47
+ config_path.write_text(config.model_dump_json(indent=2))
orpheus/display.py ADDED
@@ -0,0 +1,21 @@
1
+ """Display helpers using Rich."""
2
+
3
+ from rich.console import Console
4
+
5
+
6
+ console = Console()
7
+
8
+
9
+ def print_success(message: str) -> None:
10
+ """Print a success message."""
11
+ console.print(f"[green]✓[/green] {message}")
12
+
13
+
14
+ def print_error(message: str) -> None:
15
+ """Print an error message."""
16
+ console.print(f"[red]✗[/red] {message}")
17
+
18
+
19
+ def print_warning(message: str) -> None:
20
+ """Print a warning message."""
21
+ console.print(f"[yellow]![/yellow] {message}")
orpheus/git.py ADDED
@@ -0,0 +1,39 @@
1
+ """Git helpers for the Orpheus CLI."""
2
+
3
+ import re
4
+ import subprocess
5
+
6
+
7
+ def get_repo_from_cwd() -> str | None:
8
+ """Get GitHub repo (user/repo) from current directory's git remote.
9
+
10
+ Returns None if not in a git repo or origin remote is not GitHub.
11
+ """
12
+ try:
13
+ result = subprocess.run(
14
+ ["git", "remote", "get-url", "origin"],
15
+ capture_output=True,
16
+ text=True,
17
+ check=True,
18
+ )
19
+ url = result.stdout.strip()
20
+ except subprocess.CalledProcessError:
21
+ return None
22
+
23
+ # Parse GitHub URLs:
24
+ # - https://github.com/user/repo.git
25
+ # - https://github.com/user/repo
26
+ # - git@github.com:user/repo.git
27
+ # - git@github.com:user/repo
28
+
29
+ # HTTPS pattern
30
+ https_match = re.match(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", url)
31
+ if https_match:
32
+ return f"{https_match.group(1)}/{https_match.group(2)}"
33
+
34
+ # SSH pattern
35
+ ssh_match = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
36
+ if ssh_match:
37
+ return f"{ssh_match.group(1)}/{ssh_match.group(2)}"
38
+
39
+ return None
orpheus/main.py ADDED
@@ -0,0 +1,319 @@
1
+ """Orpheus CLI entry point."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from orpheus.client import APIError, NotFoundError, OrpheusClient
10
+ from orpheus.commands.auth import auth
11
+ from orpheus.commands.setup import setup
12
+ from orpheus.config import get_config
13
+ from orpheus.display import console, print_error, print_success
14
+ from orpheus.git import get_repo_from_cwd
15
+
16
+
17
+ app = typer.Typer(name="orpheus", help="A command-line interface for orchestrating AI agents.", no_args_is_help=True)
18
+ app.add_typer(auth, name="auth")
19
+ app.add_typer(setup, name="setup")
20
+
21
+
22
+ def get_authenticated_client() -> OrpheusClient:
23
+ """Get an authenticated client or exit with error."""
24
+ config = get_config()
25
+ if not config.user_access_token:
26
+ print_error("Not authenticated. Run 'orpheus auth login' first.")
27
+ raise typer.Exit(1)
28
+ return OrpheusClient(config)
29
+
30
+
31
+ @app.command()
32
+ def exec(
33
+ spec_file: Path = typer.Argument(..., help="Path to spec file"),
34
+ snapshot: str | None = typer.Option(None, "--snapshot", "-s", help="Base snapshot ID"),
35
+ repo: str | None = typer.Option(None, "--repo", "-r", help="GitHub repo (owner/repo)"),
36
+ branch: str | None = typer.Option(None, "--branch", "-b", help="Git branch to checkout"),
37
+ program: list[str] | None = typer.Option(
38
+ None, "--program", "-p", help="Program to run. Repeatable. Default: orchestrator_with_review."
39
+ ),
40
+ all_programs: bool = typer.Option(False, "--all", help="Run all available programs in parallel"),
41
+ ):
42
+ """Run a SWE agent to implement the given spec."""
43
+ client = get_authenticated_client()
44
+
45
+ if not spec_file.exists():
46
+ print_error(f"Spec file not found: {spec_file}")
47
+ raise typer.Exit(1)
48
+
49
+ repo_full_name = repo or get_repo_from_cwd()
50
+ if not repo_full_name:
51
+ print_error("Could not detect repo. Run from inside a git repo or use --repo flag.")
52
+ raise typer.Exit(1)
53
+
54
+ spec_content = spec_file.read_text()
55
+
56
+ # --all sends no filter (server runs every discovered program).
57
+ # --program sends an explicit list. Default is orchestrator_with_review.
58
+ if all_programs:
59
+ selected = None
60
+ elif program:
61
+ selected = program
62
+ else:
63
+ selected = ["orchestrator_with_review"]
64
+
65
+ programs_label = ", ".join(selected) if selected else "all"
66
+ typer.echo(f"Starting task for: {spec_file.name} (repo: {repo_full_name}, programs: {programs_label})")
67
+
68
+ try:
69
+ data = client.create_task(spec_content, repo_full_name, snapshot, programs=selected, git_branch=branch)
70
+ except APIError as e:
71
+ print_error(f"Error: {e}")
72
+ raise typer.Exit(1)
73
+
74
+ task_slug = data["task_slug"]
75
+ print_success(f"Task created: [bold]{task_slug}[/bold]")
76
+ console.print(f" Executions: {', '.join(data['execution_slugs'])}")
77
+ console.print(f" Run 'orpheus status {task_slug}' to check progress.")
78
+
79
+
80
+ @app.command()
81
+ def programs():
82
+ """List available programs."""
83
+ config = get_config()
84
+
85
+ if not config.user_access_token:
86
+ print_error("Not authenticated. Run 'orpheus auth login' first.")
87
+ raise typer.Exit(1)
88
+
89
+ client = OrpheusClient(config)
90
+
91
+ try:
92
+ program_list = client.list_programs()
93
+ except APIError as e:
94
+ print_error(f"Error: {e}")
95
+ raise typer.Exit(1)
96
+
97
+ typer.echo("Available programs:")
98
+ for p in program_list:
99
+ typer.echo(f" {p}")
100
+
101
+
102
+ @app.command()
103
+ def status(
104
+ task_slug: str = typer.Argument(..., help="Task slug"),
105
+ ):
106
+ """Check status of a task."""
107
+ client = get_authenticated_client()
108
+
109
+ try:
110
+ data = client.get_task(task_slug)
111
+ except NotFoundError as e:
112
+ print_error(str(e))
113
+ raise typer.Exit(1)
114
+ except APIError as e:
115
+ print_error(f"Error: {e}")
116
+ raise typer.Exit(1)
117
+
118
+ console.print(f"[bold]Task:[/bold] {data['task_slug']}")
119
+ console.print(f"[bold]Status:[/bold] {'active' if data['is_active'] else 'stopped'}")
120
+
121
+ if data.get("executions"):
122
+ console.print("[bold]Executions:[/bold]")
123
+ for ex in data["executions"]:
124
+ status_color = "green" if ex["status"] == "running" else "yellow" if ex["status"] == "completed" else "red"
125
+ console.print(f" [{status_color}]{ex['slug']}[/{status_color}] ({ex['program_name']}) - {ex['status']}")
126
+ if ex.get("pr_url"):
127
+ console.print(f" PR: {ex['pr_url']}")
128
+ for svc in ex.get("exposed_services", []):
129
+ console.print(f" {svc['service_name']} -> {svc['url']}")
130
+
131
+
132
+ @app.command()
133
+ def stop(
134
+ task_slug: str = typer.Argument(..., help="Task slug"),
135
+ ):
136
+ """Stop a task."""
137
+ client = get_authenticated_client()
138
+
139
+ try:
140
+ data = client.stop_task(task_slug)
141
+ except NotFoundError as e:
142
+ print_error(str(e))
143
+ raise typer.Exit(1)
144
+ except APIError as e:
145
+ print_error(f"Error: {e}")
146
+ raise typer.Exit(1)
147
+
148
+ print_success(f"Task {data['task_slug']} stopped")
149
+
150
+
151
+ @app.command()
152
+ def tasks(
153
+ all_tasks: bool = typer.Option(False, "--all", "-a", help="Include stopped/inactive tasks"),
154
+ ):
155
+ """List all tasks and their executions."""
156
+ client = get_authenticated_client()
157
+
158
+ try:
159
+ task_list = client.list_tasks(active_only=not all_tasks)
160
+ except APIError as e:
161
+ print_error(f"Error: {e}")
162
+ raise typer.Exit(1)
163
+
164
+ if not task_list:
165
+ typer.echo("No tasks found")
166
+ return
167
+
168
+ for task in task_list:
169
+ status = "[green]active[/green]" if task["is_active"] else "[dim]stopped[/dim]"
170
+ console.print(f"\n{status} [bold]{task['slug']}[/bold]")
171
+ console.print(f" Created: {task['created_at']}")
172
+ if task.get("executions"):
173
+ for ex in task["executions"]:
174
+ instance = ex.get("root_instance_id") or "no instance"
175
+ pr = f" → {ex['pr_url']}" if ex.get("pr_url") else ""
176
+ console.print(f" [dim]{ex['slug']}[/dim] ({ex['program_name']}) [{ex['status']}] {instance}{pr}")
177
+
178
+
179
+ @app.command()
180
+ def apply(
181
+ execution_slug: str = typer.Argument(..., help="Execution slug"),
182
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
183
+ ):
184
+ """Apply diff from an execution's VM to local repo."""
185
+ client = get_authenticated_client()
186
+
187
+ try:
188
+ diff = client.get_execution_diff(execution_slug)
189
+ except NotFoundError as e:
190
+ print_error(str(e))
191
+ raise typer.Exit(1)
192
+ except APIError as e:
193
+ print_error(f"Error: {e}")
194
+ raise typer.Exit(1)
195
+
196
+ if not diff.strip():
197
+ typer.echo("No changes to apply")
198
+ raise typer.Exit(0)
199
+
200
+ typer.echo(diff)
201
+ typer.echo()
202
+
203
+ # Find repo root
204
+ repo_root_result = subprocess.run(
205
+ ["git", "rev-parse", "--show-toplevel"],
206
+ capture_output=True,
207
+ text=True,
208
+ )
209
+ if repo_root_result.returncode != 0:
210
+ print_error("Not in a git repository")
211
+ raise typer.Exit(1)
212
+ repo_root = repo_root_result.stdout.strip()
213
+
214
+ apply_args = ["git", "apply", "-v"]
215
+ if force:
216
+ apply_args.append("--reject")
217
+
218
+ if not force:
219
+ result = subprocess.run(
220
+ ["git", "apply", "--check"],
221
+ input=diff,
222
+ text=True,
223
+ capture_output=True,
224
+ cwd=repo_root,
225
+ )
226
+ if result.returncode != 0:
227
+ print_error(f"Diff cannot be applied cleanly:\n{result.stderr}")
228
+ print_error("Use --force to apply anyway")
229
+ raise typer.Exit(1)
230
+
231
+ result = subprocess.run(
232
+ apply_args,
233
+ input=diff,
234
+ text=True,
235
+ capture_output=True,
236
+ cwd=repo_root,
237
+ )
238
+
239
+ if result.returncode != 0:
240
+ print_error(f"Failed to apply diff:\n{result.stderr}")
241
+ raise typer.Exit(1)
242
+
243
+ if result.stderr:
244
+ typer.echo(result.stderr)
245
+ print_success("Changes applied")
246
+
247
+
248
+ @app.command()
249
+ def download(
250
+ remote_path: str = typer.Argument(..., help="Remote file path on the VM"),
251
+ repo: str | None = typer.Option(None, "--repo", "-r", help="Repo full name (devbox target)"),
252
+ exec_slug: str | None = typer.Option(None, "--exec", "-e", help="Execution slug"),
253
+ agent: str | None = typer.Option(None, "--agent", "-a", help="Agent name"),
254
+ ):
255
+ """Download a file from a VM to stdout."""
256
+ client = get_authenticated_client()
257
+
258
+ # Auto-detect repo from cwd if no explicit target
259
+ target_repo = repo
260
+ if not exec_slug and not target_repo:
261
+ target_repo = get_repo_from_cwd()
262
+ if not target_repo and not exec_slug:
263
+ print_error("Could not detect repo. Use --repo or --exec/--agent.")
264
+ raise typer.Exit(1)
265
+
266
+ try:
267
+ response = client.download_file(remote_path, repo=target_repo, execution_slug=exec_slug, agent_name=agent)
268
+ except NotFoundError as e:
269
+ print_error(str(e))
270
+ raise typer.Exit(1)
271
+ except APIError as e:
272
+ print_error(f"Error: {e}")
273
+ raise typer.Exit(1)
274
+
275
+ sys.stdout.buffer.write(response.content)
276
+
277
+
278
+ @app.command()
279
+ def upload(
280
+ local_path: str = typer.Argument(..., help="Local file path (use '-' for stdin)"),
281
+ remote_path: str = typer.Argument(..., help="Remote file path on the VM"),
282
+ repo: str | None = typer.Option(None, "--repo", "-r", help="Repo full name (devbox target)"),
283
+ exec_slug: str | None = typer.Option(None, "--exec", "-e", help="Execution slug"),
284
+ agent: str | None = typer.Option(None, "--agent", "-a", help="Agent name"),
285
+ ):
286
+ """Upload a file to a VM."""
287
+ client = get_authenticated_client()
288
+
289
+ # Auto-detect repo from cwd if no explicit target
290
+ target_repo = repo
291
+ if not exec_slug and not target_repo:
292
+ target_repo = get_repo_from_cwd()
293
+ if not target_repo and not exec_slug:
294
+ print_error("Could not detect repo. Use --repo or --exec/--agent.")
295
+ raise typer.Exit(1)
296
+
297
+ if local_path == "-":
298
+ content = sys.stdin.buffer.read()
299
+ file_path = None
300
+ else:
301
+ file_path = Path(local_path)
302
+ if not file_path.exists():
303
+ print_error(f"File not found: {local_path}")
304
+ raise typer.Exit(1)
305
+ content = None
306
+
307
+ try:
308
+ client.upload_file(
309
+ file_path, remote_path, content=content, repo=target_repo, execution_slug=exec_slug, agent_name=agent
310
+ )
311
+ except APIError as e:
312
+ print_error(f"Error: {e}")
313
+ raise typer.Exit(1)
314
+
315
+ print_success(f"Uploaded to {remote_path}")
316
+
317
+
318
+ if __name__ == "__main__":
319
+ app()
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: orpheus-cli
3
+ Version: 0.1.0
4
+ Summary: A command-line interface for orchestrating AI agents
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.28.1
7
+ Requires-Dist: pydantic-settings>=2.12.0
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: questionary>=2.1.0
10
+ Requires-Dist: rich>=14.3.2
11
+ Requires-Dist: typer>=0.21.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Orpheus CLI
15
+
16
+ Command-line interface for Orpheus by [Fulcrum](https://fulcrum.inc/).
@@ -0,0 +1,13 @@
1
+ orpheus/__init__.py,sha256=6gffQg-yymrWVJGzkeudaP8-x1FfXcuzwDMRlkND7n4,19
2
+ orpheus/auth.py,sha256=XOVxxHeiIKw-rWmqY5UHn4vVLo7oI5jEAa8_wcpvXzM,2716
3
+ orpheus/client.py,sha256=LUq-4SzBZZoYpmRkb9OpEU0bnZ_jaQa924oHMU665pw,7204
4
+ orpheus/config.py,sha256=3emCR7vcQL86UG5N65glI7R4PL40muJTtbYCCIn3q18,1218
5
+ orpheus/display.py,sha256=02yEQDtgYeMj6ubsLlaeHXEkhd9rdYOX-U9xs5jPgYY,470
6
+ orpheus/git.py,sha256=UspJpa6D-NPymFM5n084csWfidGhUgVeYkDvTimj8XA,1097
7
+ orpheus/main.py,sha256=TYDrTuEbpN8H7VP38BKXMa3vrQ9t1xtTvW2n2QjADpg,10452
8
+ orpheus/commands/auth.py,sha256=aB4BzLSWjkzI41eyhHUw8SnJ-nw-t8hpIzE8Lqp8AyI,2485
9
+ orpheus/commands/setup.py,sha256=0ezDZSjIsZxAc9rfmkmtAE1AG_e-DugOKDleAMjAuj4,4027
10
+ orpheus_cli-0.1.0.dist-info/METADATA,sha256=1XMMhMnRDz13tMg485fbuyQlLnEq29xUY9u8qcbMtyw,461
11
+ orpheus_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ orpheus_cli-0.1.0.dist-info/entry_points.txt,sha256=AJja123jnod7B9XzdO1OM29vTC04ljDJapq1SG_f1r4,45
13
+ orpheus_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orpheus = orpheus.main:app