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 +1 -0
- orpheus/auth.py +94 -0
- orpheus/client.py +208 -0
- orpheus/commands/auth.py +85 -0
- orpheus/commands/setup.py +132 -0
- orpheus/config.py +47 -0
- orpheus/display.py +21 -0
- orpheus/git.py +39 -0
- orpheus/main.py +319 -0
- orpheus_cli-0.1.0.dist-info/METADATA +16 -0
- orpheus_cli-0.1.0.dist-info/RECORD +13 -0
- orpheus_cli-0.1.0.dist-info/WHEEL +4 -0
- orpheus_cli-0.1.0.dist-info/entry_points.txt +2 -0
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()
|
orpheus/commands/auth.py
ADDED
|
@@ -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,,
|