orpheus-cli 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ *.egg-info/
2
+ **/__pycache__/
3
+ **/node_modules/
4
+ **/.vscode/
5
+ *.code-workspace
6
+ .claude/*
7
+ !.claude/skills/
8
+ .mcp.json
9
+ instructions.md
10
+ .float/designer.md
11
+ .float/doc.md
12
+ .float/messages.jsonl
13
+ build/
14
+ .env
15
+ old
16
+ client/screenshots
17
+ client/screenshot-*
18
+ client/dist/
19
+ client/src/data
20
+ **/.DS_STORE
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: orpheus-cli
3
+ Version: 0.1.1
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,3 @@
1
+ # Orpheus CLI
2
+
3
+ Command-line interface for Orpheus by [Fulcrum](https://fulcrum.inc/).
@@ -0,0 +1 @@
1
+ """Orpheus CLI."""
@@ -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)
@@ -0,0 +1,201 @@
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_repos(self) -> list[dict[str, Any]]:
116
+ """List accessible repositories."""
117
+ response = self._client.get("/repos", headers=self._headers)
118
+ if response.status_code != 200:
119
+ raise APIError(response.text)
120
+ return response.json()["repos"]
121
+
122
+ def start_setup(self, repo_full_name: str, private: bool) -> dict[str, Any]:
123
+ """Start devbox setup for a repository."""
124
+ response = self._client.post(
125
+ "/setup/start",
126
+ json={"repo_full_name": repo_full_name, "private": private},
127
+ headers=self._headers,
128
+ )
129
+ if response.status_code != 200:
130
+ raise APIError(response.text)
131
+ return response.json()
132
+
133
+ def save_setup(self, repo_full_name: str) -> dict[str, Any]:
134
+ """Save devbox state by creating a snapshot."""
135
+ response = self._client.post(
136
+ "/setup/save",
137
+ json={"repo_full_name": repo_full_name},
138
+ headers=self._headers,
139
+ )
140
+ if response.status_code != 200:
141
+ raise APIError(response.text)
142
+ return response.json()
143
+
144
+ def download_file(
145
+ self,
146
+ path: str,
147
+ repo: str | None = None,
148
+ execution_slug: str | None = None,
149
+ agent_name: str | None = None,
150
+ ) -> httpx.Response:
151
+ """Download a file from a VM. Returns the raw response for streaming."""
152
+ params: dict[str, str] = {"path": path}
153
+ if repo:
154
+ params["repo"] = repo
155
+ if execution_slug:
156
+ params["execution_slug"] = execution_slug
157
+ if agent_name:
158
+ params["agent_name"] = agent_name
159
+ response = self._client.get("/files/download", params=params, headers=self._headers)
160
+ if response.status_code == 404:
161
+ raise NotFoundError("File", path)
162
+ if response.status_code != 200:
163
+ raise APIError(response.text)
164
+ return response
165
+
166
+ def upload_file(
167
+ self,
168
+ local_path: Path | None,
169
+ remote_path: str,
170
+ content: bytes | None = None,
171
+ repo: str | None = None,
172
+ execution_slug: str | None = None,
173
+ agent_name: str | None = None,
174
+ ) -> dict[str, Any]:
175
+ """Upload a file to a VM."""
176
+ params: dict[str, str] = {"path": remote_path}
177
+ if repo:
178
+ params["repo"] = repo
179
+ if execution_slug:
180
+ params["execution_slug"] = execution_slug
181
+ if agent_name:
182
+ params["agent_name"] = agent_name
183
+
184
+ if content is not None:
185
+ file_data = content
186
+ filename = Path(remote_path).name
187
+ elif local_path:
188
+ file_data = local_path.read_bytes()
189
+ filename = local_path.name
190
+ else:
191
+ raise ValueError("Either local_path or content must be provided")
192
+
193
+ response = self._client.post(
194
+ "/files/upload",
195
+ params=params,
196
+ files={"file": (filename, file_data)},
197
+ headers=self._headers,
198
+ )
199
+ if response.status_code != 200:
200
+ raise APIError(response.text)
201
+ 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]")
@@ -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-app"
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))
@@ -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}")
@@ -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