Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,5 @@ tools = ["claude", "cursor"]
### Always Do
- agr and agrx should always be unified and synced.
- include in the plan to write tests for what is implemented
- Save all skills in `skills/` directory (not `.claude/skills/` which is gitignored)
- Save all skills in `skills/` directory (not `.claude/skills/` which is gitignored)
- Run all checks and tests before creating PR or commiting any code.
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,5 @@ tools = ["claude", "cursor"]
### Always Do
- agr and agrx should always be unified and synced.
- include in the plan to write tests for what is implemented
- Save all skills in `skills/` directory (not `.claude/skills/` which is gitignored)
- Save all skills in `skills/` directory (not `.claude/skills/` which is gitignored)
- Run all checks and tests before creating PR or commiting any code.
10 changes: 5 additions & 5 deletions agr.lock
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,27 @@ installed-name = "github-issue-triage"
[[ralph]]
handle = "computerlovetech/research/conduct-research"
source = "github"
commit = "637025a5e221264845bff7e6a89aaba7a7bdb919"
commit = "36b92ec43ded18b9609e69c87fe5696346b6af27"
content-hash = "sha256:3a9c1ef64234ccb61d9b681afc4305ffadad5735f0536f77759a41366e75c3a2"
installed-name = "conduct-research"

[[ralph]]
handle = "computerlovetech/ralphs/improve-codebase"
source = "github"
commit = "70a0dabf5a59ae1a3acaf50a9e619c83e174a35f"
content-hash = "sha256:0afc55d5b2d4061fa82c270a147c66c8bc21589ab62c47d9747ddb5f382c708d"
commit = "82c5c4dd1e2e244e86e2ddad3f987b4d9a6d27f2"
content-hash = "sha256:637929bdfe382116efaac98e3a46cfac1de4978978b9f1d984c51d65b49d408a"
installed-name = "improve-codebase"

[[ralph]]
handle = "computerlovetech/ralphs/bug-hunter"
source = "github"
commit = "70a0dabf5a59ae1a3acaf50a9e619c83e174a35f"
commit = "82c5c4dd1e2e244e86e2ddad3f987b4d9a6d27f2"
content-hash = "sha256:ab2e4924cbb999f677523211af9cfda48ce8433b17545854bd770f29d7664c64"
installed-name = "bug-hunter"

[[ralph]]
handle = "computerlovetech/ralphs/security"
source = "github"
commit = "70a0dabf5a59ae1a3acaf50a9e619c83e174a35f"
commit = "82c5c4dd1e2e244e86e2ddad3f987b4d9a6d27f2"
content-hash = "sha256:2b025b4e5546f2a04367a79e5f4201892bc155d22f53a55cfc0cd21071922de1"
installed-name = "security"
258 changes: 258 additions & 0 deletions agr/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Protocol

from agr.exceptions import AgrError

AUTH_DIR_NAME = ".agr"
AUTH_FILE_NAME = "auth.json"
AUTH_FILE_MODE = 0o600


@dataclass(frozen=True)
class StoredGitHubCredential:
method: str
token: str
username: str | None = None


@dataclass(frozen=True)
class StoredToken:
token: str


@dataclass(frozen=True)
class AuthStatus:
authenticated: bool
source: str | None
method: str | None = None


@dataclass(frozen=True)
class DeviceAuthorization:
device_code: str
user_code: str
verification_uri: str
expires_in: int
interval: int


class CredentialStore(Protocol):
def read_credential(self) -> StoredGitHubCredential | None: ...

def write_credential(self, credential: StoredGitHubCredential) -> None: ...

def delete_token(self) -> bool: ...


class TokenStore(CredentialStore, Protocol):
def read_token(self) -> str | None: ...

def write_token(self, token: str) -> None: ...


class GitHubLoginStrategy(Protocol):
def login(self) -> StoredGitHubCredential: ...


class DeviceOAuthClient(Protocol):
def request_device_authorization(self) -> DeviceAuthorization: ...

def poll_for_token(self, authorization: DeviceAuthorization) -> str: ...


class AuthStatusChecker(Protocol):
def get_status(self) -> AuthStatus: ...


DevicePromptHandler = Callable[[DeviceAuthorization], None]
StringPrompt = Callable[[], str]


class FileTokenStore:
def __init__(self, auth_file: Path | None = None) -> None:
self.auth_file = auth_file or default_auth_file()

def read_credential(self) -> StoredGitHubCredential | None:
if not self.auth_file.exists():
return None
try:
data = json.loads(self.auth_file.read_text())
except (OSError, json.JSONDecodeError):
return None
token = data.get("github_token")
if not isinstance(token, str) or not token.strip():
return None
method = data.get("method")
username = data.get("username")
normalized_method = (
method if isinstance(method, str) and method.strip() else "oauth"
)
normalized_username = (
username.strip() if isinstance(username, str) and username.strip() else None
)
return StoredGitHubCredential(
method=normalized_method.strip(),
token=token.strip(),
username=normalized_username,
)

def write_credential(self, credential: StoredGitHubCredential) -> None:
token = credential.token.strip()
method = credential.method.strip()
username = credential.username.strip() if credential.username else None
if not token:
raise AgrError("Cannot store an empty GitHub token.")
if not method:
raise AgrError("Cannot store a GitHub credential without a method.")
if method == "username_password" and not username:
raise AgrError(
"Cannot store a username/password credential without a username."
)
data: dict[str, str] = {"github_token": token, "method": method}
if username:
data["username"] = username
self.auth_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(
self.auth_file,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
AUTH_FILE_MODE,
)
try:
with os.fdopen(fd, "w") as file:
json.dump(data, file)
finally:
os.chmod(self.auth_file, AUTH_FILE_MODE)

def read_token(self) -> str | None:
credential = self.read_credential()
return credential.token if credential else None

def write_token(self, token: str) -> None:
self.write_credential(StoredGitHubCredential(method="oauth", token=token))

def delete_token(self) -> bool:
try:
self.auth_file.unlink()
except FileNotFoundError:
return False
return True


class GitHubAuthStatusChecker:
def __init__(
self,
store: CredentialStore | None = None,
env: dict[str, str] | None = None,
) -> None:
self.store = store or FileTokenStore()
self.env = env

def get_status(self) -> AuthStatus:
environ = self.env if self.env is not None else os.environ
for env_var in ("GITHUB_TOKEN", "GH_TOKEN"):
token = environ.get(env_var, "")
if token.strip():
return AuthStatus(authenticated=True, source=env_var, method="env")
credential = self.store.read_credential()
if credential:
return AuthStatus(
authenticated=True, source="stored", method=credential.method
)
return AuthStatus(authenticated=False, source=None, method=None)


class UsernamePasswordGitHubLoginStrategy:
def __init__(
self,
username: str | None = None,
password: str | None = None,
username_prompt: StringPrompt | None = None,
password_prompt: StringPrompt | None = None,
) -> None:
self.username = username
self.password = password
self.username_prompt = username_prompt
self.password_prompt = password_prompt

def login(self) -> StoredGitHubCredential:
username = (
self.username if self.username is not None else self._prompt_username()
)
password = (
self.password if self.password is not None else self._prompt_password()
)
normalized_username = username.strip()
normalized_password = password.strip()
if not normalized_username:
raise AgrError("GitHub username cannot be empty.")
if not normalized_password:
raise AgrError("GitHub password or token cannot be empty.")
return StoredGitHubCredential(
method="username_password",
token=normalized_password,
username=normalized_username,
)

def _prompt_username(self) -> str:
if self.username_prompt is None:
raise AgrError("GitHub username prompt is not configured.")
return self.username_prompt()

def _prompt_password(self) -> str:
if self.password_prompt is None:
raise AgrError("GitHub password prompt is not configured.")
return self.password_prompt()


class OAuthGitHubLoginStrategy:
def __init__(
self,
oauth_client: DeviceOAuthClient,
prompt_handler: DevicePromptHandler | None = None,
) -> None:
self.oauth_client = oauth_client
self.prompt_handler = prompt_handler

def login(self) -> StoredGitHubCredential:
authorization = self.oauth_client.request_device_authorization()
if self.prompt_handler:
self.prompt_handler(authorization)
token = self.oauth_client.poll_for_token(authorization)
return StoredGitHubCredential(method="oauth", token=token)


def default_auth_file() -> Path:
return Path.home() / AUTH_DIR_NAME / AUTH_FILE_NAME


def read_stored_github_credential(
store: CredentialStore | None = None,
) -> StoredGitHubCredential | None:
return (store or FileTokenStore()).read_credential()


def read_stored_github_token(store: TokenStore | None = None) -> str | None:
return (store or FileTokenStore()).read_token()


def login(
strategy: GitHubLoginStrategy,
store: CredentialStore | None = None,
) -> StoredGitHubCredential:
token_store = store or FileTokenStore()
credential = strategy.login()
token_store.write_credential(credential)
return credential


def status(
store: CredentialStore | None = None, env: dict[str, str] | None = None
) -> AuthStatus:
return GitHubAuthStatusChecker(store, env).get_status()


def logout(store: CredentialStore | None = None) -> bool:
return (store or FileTokenStore()).delete_token()
4 changes: 3 additions & 1 deletion agr/commands/remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ def _uninstall_from_filesystem(
Returns True if anything was removed.
"""
if is_ralph:
return uninstall_ralph(handle, repo_root, source_name, default_repo=default_repo)
return uninstall_ralph(
handle, repo_root, source_name, default_repo=default_repo
)
removed = False
for tool in tools:
if uninstall_skill(
Expand Down
Loading
Loading