agh 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.
- agh/__init__.py +3 -0
- agh/cli/__init__.py +1 -0
- agh/cli/agent_integrations.py +98 -0
- agh/cli/config.py +186 -0
- agh/cli/main.py +986 -0
- agh/cli/pack_init.py +102 -0
- agh/cli/pack_publish.py +198 -0
- agh/cli/pull_markers.py +202 -0
- agh/cli/pull_plan.py +188 -0
- agh/cli/workspace_pull.py +1026 -0
- agh/cli/workspace_sync.py +231 -0
- agh/common/__init__.py +40 -0
- agh/common/checksums.py +14 -0
- agh/common/ids.py +26 -0
- agh/common/pack_manifest.py +66 -0
- agh/common/repo_url.py +40 -0
- agh/common/validation.py +68 -0
- agh/server/__init__.py +5 -0
- agh/server/app.py +116 -0
- agh/server/auth.py +162 -0
- agh/server/db.py +135 -0
- agh/server/migrations/001_initial_schema.sql +79 -0
- agh/server/migrations/__init__.py +0 -0
- agh/server/routes/__init__.py +0 -0
- agh/server/routes/packs.py +472 -0
- agh/server/routes/projects.py +849 -0
- agh/server/routes/users.py +330 -0
- agh-0.1.0.dist-info/METADATA +119 -0
- agh-0.1.0.dist-info/RECORD +32 -0
- agh-0.1.0.dist-info/WHEEL +5 -0
- agh-0.1.0.dist-info/entry_points.txt +2 -0
- agh-0.1.0.dist-info/top_level.txt +1 -0
agh/__init__.py
ADDED
agh/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AGH Typer CLI."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Advisory local agent integration detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AgentAvailability:
|
|
13
|
+
"""Advisory availability for a local agent integration."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
command: str
|
|
17
|
+
workspace_dir: str
|
|
18
|
+
available: bool
|
|
19
|
+
command_path: str | None
|
|
20
|
+
workspace_dir_exists: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_agent_availability(
|
|
24
|
+
*,
|
|
25
|
+
workspace: Path | None = None,
|
|
26
|
+
path: str | None = None,
|
|
27
|
+
) -> list[AgentAvailability]:
|
|
28
|
+
"""Detect known local agent integrations without creating or modifying files."""
|
|
29
|
+
root = Path.cwd() if workspace is None else workspace
|
|
30
|
+
return [
|
|
31
|
+
_detect_agent(
|
|
32
|
+
name="Claude Code",
|
|
33
|
+
command="claude",
|
|
34
|
+
workspace_dir=".claude",
|
|
35
|
+
workspace=root,
|
|
36
|
+
path=path,
|
|
37
|
+
),
|
|
38
|
+
_detect_agent(
|
|
39
|
+
name="OpenCode",
|
|
40
|
+
command="opencode",
|
|
41
|
+
workspace_dir=".opencode",
|
|
42
|
+
workspace=root,
|
|
43
|
+
path=path,
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_agent_availability(agents: list[AgentAvailability]) -> str:
|
|
49
|
+
"""Render sober, plain advisory output for `agh agent`."""
|
|
50
|
+
lines: list[str] = []
|
|
51
|
+
for agent in agents:
|
|
52
|
+
marker = "✓" if agent.available else "✗"
|
|
53
|
+
status = "available" if agent.available else "not found"
|
|
54
|
+
reasons: list[str] = []
|
|
55
|
+
if agent.command_path is not None:
|
|
56
|
+
reasons.append(f"command: {agent.command_path}")
|
|
57
|
+
if agent.workspace_dir_exists:
|
|
58
|
+
reasons.append(f"workspace: {agent.workspace_dir}/")
|
|
59
|
+
reason_text = f" ({', '.join(reasons)})" if reasons else ""
|
|
60
|
+
lines.append(f"{agent.name}: {marker} {status}{reason_text}")
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def relative_symlink_target(*, source: Path, target: Path) -> str:
|
|
65
|
+
"""Return a portable relative symlink target from target parent to source."""
|
|
66
|
+
return os.path.relpath(source, start=target.parent)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def symlink_points_to(path: Path, expected: Path) -> bool:
|
|
70
|
+
"""Return whether a symlink points to the expected path without writes."""
|
|
71
|
+
try:
|
|
72
|
+
raw_target = os.readlink(path)
|
|
73
|
+
except OSError:
|
|
74
|
+
return False
|
|
75
|
+
target = Path(raw_target)
|
|
76
|
+
if not target.is_absolute():
|
|
77
|
+
target = path.parent / target
|
|
78
|
+
return target.resolve(strict=False) == expected.resolve(strict=False)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _detect_agent(
|
|
82
|
+
*,
|
|
83
|
+
name: str,
|
|
84
|
+
command: str,
|
|
85
|
+
workspace_dir: str,
|
|
86
|
+
workspace: Path,
|
|
87
|
+
path: str | None,
|
|
88
|
+
) -> AgentAvailability:
|
|
89
|
+
command_path = shutil.which(command, path=path)
|
|
90
|
+
workspace_dir_exists = (workspace / workspace_dir).is_dir()
|
|
91
|
+
return AgentAvailability(
|
|
92
|
+
name=name,
|
|
93
|
+
command=command,
|
|
94
|
+
workspace_dir=workspace_dir,
|
|
95
|
+
available=command_path is not None or workspace_dir_exists,
|
|
96
|
+
command_path=command_path,
|
|
97
|
+
workspace_dir_exists=workspace_dir_exists,
|
|
98
|
+
)
|
agh/cli/config.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Local AGH CLI configuration and login helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
import tomllib
|
|
12
|
+
from typing import Any, NoReturn
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
20
|
+
"""Reject redirects so Bearer tokens are never forwarded to another URL."""
|
|
21
|
+
|
|
22
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler)
|
|
27
|
+
|
|
28
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "agh" / "config.toml"
|
|
29
|
+
CONFIG_PATH_ENV = "AGH_CONFIG_FILE"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(RuntimeError):
|
|
33
|
+
"""Raised when local CLI configuration cannot be read or written."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LoginValidationError(RuntimeError):
|
|
37
|
+
"""Raised when remote login validation fails."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class AghConfig:
|
|
42
|
+
"""Local AGH connection configuration."""
|
|
43
|
+
|
|
44
|
+
instance_url: str
|
|
45
|
+
email: str
|
|
46
|
+
token: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_config_path() -> Path:
|
|
50
|
+
"""Return the effective config path, allowing tests/users to override it."""
|
|
51
|
+
override = os.environ.get(CONFIG_PATH_ENV, "").strip()
|
|
52
|
+
if override:
|
|
53
|
+
return Path(override).expanduser()
|
|
54
|
+
return DEFAULT_CONFIG_PATH
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_instance_url(url: str) -> str:
|
|
58
|
+
"""Normalize an AGH instance URL for storage and request composition."""
|
|
59
|
+
normalized = url.strip().rstrip("/")
|
|
60
|
+
if not normalized:
|
|
61
|
+
raise ConfigError("Instance URL is required")
|
|
62
|
+
if not normalized.startswith(("http://", "https://")):
|
|
63
|
+
raise ConfigError("Instance URL must start with http:// or https://")
|
|
64
|
+
return normalized
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_config(path: Path | None = None) -> AghConfig:
|
|
68
|
+
"""Load local config from TOML."""
|
|
69
|
+
config_path = path or get_config_path()
|
|
70
|
+
try:
|
|
71
|
+
raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
72
|
+
except FileNotFoundError as exc:
|
|
73
|
+
raise ConfigError(
|
|
74
|
+
f"No AGH config found at {config_path}. Run 'agh login'."
|
|
75
|
+
) from exc
|
|
76
|
+
except tomllib.TOMLDecodeError as exc:
|
|
77
|
+
raise ConfigError(f"Invalid AGH config at {config_path}: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return AghConfig(
|
|
81
|
+
instance_url=str(raw["instance_url"]),
|
|
82
|
+
email=str(raw["email"]),
|
|
83
|
+
token=str(raw["token"]),
|
|
84
|
+
)
|
|
85
|
+
except KeyError as exc:
|
|
86
|
+
raise ConfigError(f"AGH config missing required field: {exc.args[0]}") from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_config(config: AghConfig, path: Path | None = None) -> None:
|
|
90
|
+
"""Atomically write local config with restrictive permissions where supported."""
|
|
91
|
+
config_path = path or get_config_path()
|
|
92
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
text = _format_config(config)
|
|
94
|
+
|
|
95
|
+
fd, temp_name = tempfile.mkstemp(
|
|
96
|
+
prefix=f".{config_path.name}.", suffix=".tmp", dir=config_path.parent
|
|
97
|
+
)
|
|
98
|
+
temp_path = Path(temp_name)
|
|
99
|
+
try:
|
|
100
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
101
|
+
handle.write(text)
|
|
102
|
+
handle.flush()
|
|
103
|
+
os.fsync(handle.fileno())
|
|
104
|
+
with suppress(OSError):
|
|
105
|
+
os.chmod(temp_path, 0o600)
|
|
106
|
+
os.replace(temp_path, config_path)
|
|
107
|
+
with suppress(OSError):
|
|
108
|
+
os.chmod(config_path, 0o600)
|
|
109
|
+
except Exception:
|
|
110
|
+
with suppress(FileNotFoundError):
|
|
111
|
+
temp_path.unlink()
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def validate_login(*, instance_url: str, email: str, token: str) -> dict[str, Any]:
|
|
116
|
+
"""Validate login credentials using GET /api/v1/me."""
|
|
117
|
+
request = urllib.request.Request(
|
|
118
|
+
f"{instance_url}/api/v1/me",
|
|
119
|
+
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
|
120
|
+
method="GET",
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
with _NO_REDIRECT_OPENER.open(request, timeout=10) as response: # noqa: S310 - user-provided AGH URL
|
|
124
|
+
status_code = response.status
|
|
125
|
+
body = response.read()
|
|
126
|
+
except urllib.error.HTTPError as exc:
|
|
127
|
+
if 300 <= exc.code < 400:
|
|
128
|
+
raise LoginValidationError(
|
|
129
|
+
"Login validation failed: /me redirects are not allowed"
|
|
130
|
+
) from exc
|
|
131
|
+
raise LoginValidationError(
|
|
132
|
+
f"Login validation failed: /me returned HTTP {exc.code}"
|
|
133
|
+
) from exc
|
|
134
|
+
except urllib.error.URLError as exc:
|
|
135
|
+
reason = getattr(exc, "reason", exc)
|
|
136
|
+
raise LoginValidationError(f"Login validation failed: {reason}") from exc
|
|
137
|
+
except TimeoutError as exc:
|
|
138
|
+
raise LoginValidationError(f"Login validation failed: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
if status_code != 200:
|
|
141
|
+
raise LoginValidationError(
|
|
142
|
+
f"Login validation failed: /me returned HTTP {status_code}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
payload = json.loads(body.decode("utf-8"))
|
|
147
|
+
except json.JSONDecodeError as exc:
|
|
148
|
+
raise LoginValidationError(
|
|
149
|
+
"Login validation failed: /me returned invalid JSON"
|
|
150
|
+
) from exc
|
|
151
|
+
|
|
152
|
+
actual_email = str(payload.get("email", ""))
|
|
153
|
+
if actual_email.lower() != email.lower():
|
|
154
|
+
raise LoginValidationError(
|
|
155
|
+
f"Login validation failed: /me email {actual_email!r} does not match {email!r}"
|
|
156
|
+
)
|
|
157
|
+
return payload
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def mask_token(token: str) -> str:
|
|
161
|
+
"""Return a display-safe token mask."""
|
|
162
|
+
if len(token) <= 4:
|
|
163
|
+
return "****"
|
|
164
|
+
if len(token) <= 8:
|
|
165
|
+
return f"{token[:2]}****"
|
|
166
|
+
return f"{token[:4]}****{token[-4:]}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _format_config(config: AghConfig) -> str:
|
|
170
|
+
return "".join(
|
|
171
|
+
[
|
|
172
|
+
f'instance_url = "{_toml_escape(config.instance_url)}"\n',
|
|
173
|
+
f'email = "{_toml_escape(config.email)}"\n',
|
|
174
|
+
f'token = "{_toml_escape(config.token)}"\n',
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _toml_escape(value: str) -> str:
|
|
180
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def fail(message: str, *, code: int = 1) -> NoReturn:
|
|
184
|
+
"""Print an error and exit with a stable non-zero status."""
|
|
185
|
+
typer.secho(f"Error: {message}", fg=typer.colors.RED, err=False)
|
|
186
|
+
raise typer.Exit(code)
|