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 ADDED
@@ -0,0 +1,3 @@
1
+ """Agent Guidance Hub."""
2
+
3
+ __version__ = "0.1.0"
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)