agh 0.1.0__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.
- agh-0.1.0/PKG-INFO +119 -0
- agh-0.1.0/README.md +109 -0
- agh-0.1.0/agh/__init__.py +3 -0
- agh-0.1.0/agh/cli/__init__.py +1 -0
- agh-0.1.0/agh/cli/agent_integrations.py +98 -0
- agh-0.1.0/agh/cli/config.py +186 -0
- agh-0.1.0/agh/cli/main.py +986 -0
- agh-0.1.0/agh/cli/pack_init.py +102 -0
- agh-0.1.0/agh/cli/pack_publish.py +198 -0
- agh-0.1.0/agh/cli/pull_markers.py +202 -0
- agh-0.1.0/agh/cli/pull_plan.py +188 -0
- agh-0.1.0/agh/cli/workspace_pull.py +1026 -0
- agh-0.1.0/agh/cli/workspace_sync.py +231 -0
- agh-0.1.0/agh/common/__init__.py +40 -0
- agh-0.1.0/agh/common/checksums.py +14 -0
- agh-0.1.0/agh/common/ids.py +26 -0
- agh-0.1.0/agh/common/pack_manifest.py +66 -0
- agh-0.1.0/agh/common/repo_url.py +40 -0
- agh-0.1.0/agh/common/validation.py +68 -0
- agh-0.1.0/agh/server/__init__.py +5 -0
- agh-0.1.0/agh/server/app.py +116 -0
- agh-0.1.0/agh/server/auth.py +162 -0
- agh-0.1.0/agh/server/db.py +135 -0
- agh-0.1.0/agh/server/migrations/001_initial_schema.sql +79 -0
- agh-0.1.0/agh/server/migrations/__init__.py +0 -0
- agh-0.1.0/agh/server/routes/__init__.py +0 -0
- agh-0.1.0/agh/server/routes/packs.py +472 -0
- agh-0.1.0/agh/server/routes/projects.py +849 -0
- agh-0.1.0/agh/server/routes/users.py +330 -0
- agh-0.1.0/agh.egg-info/PKG-INFO +119 -0
- agh-0.1.0/agh.egg-info/SOURCES.txt +57 -0
- agh-0.1.0/agh.egg-info/dependency_links.txt +1 -0
- agh-0.1.0/agh.egg-info/entry_points.txt +2 -0
- agh-0.1.0/agh.egg-info/requires.txt +3 -0
- agh-0.1.0/agh.egg-info/top_level.txt +1 -0
- agh-0.1.0/pyproject.toml +27 -0
- agh-0.1.0/setup.cfg +4 -0
- agh-0.1.0/tests/test_agent_command.py +59 -0
- agh-0.1.0/tests/test_api_errors.py +172 -0
- agh-0.1.0/tests/test_auth_bootstrap.py +223 -0
- agh-0.1.0/tests/test_cli_admin_commands.py +558 -0
- agh-0.1.0/tests/test_cli_login.py +333 -0
- agh-0.1.0/tests/test_cli_pack_commands.py +817 -0
- agh-0.1.0/tests/test_cli_pull.py +720 -0
- agh-0.1.0/tests/test_common_helpers.py +157 -0
- agh-0.1.0/tests/test_db_migrations.py +215 -0
- agh-0.1.0/tests/test_docs_guidance.py +402 -0
- agh-0.1.0/tests/test_install_script.py +106 -0
- agh-0.1.0/tests/test_integration_smoke.py +282 -0
- agh-0.1.0/tests/test_pack_routes.py +352 -0
- agh-0.1.0/tests/test_project_pack_assignments.py +278 -0
- agh-0.1.0/tests/test_project_routes.py +340 -0
- agh-0.1.0/tests/test_pull_manifest_routes.py +282 -0
- agh-0.1.0/tests/test_pull_markers.py +189 -0
- agh-0.1.0/tests/test_pull_plan.py +206 -0
- agh-0.1.0/tests/test_scaffold.py +50 -0
- agh-0.1.0/tests/test_user_routes.py +368 -0
- agh-0.1.0/tests/test_workspace_pull.py +365 -0
- agh-0.1.0/tests/test_workspace_sync.py +253 -0
agh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent Guidance Hub
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi>=0.110
|
|
8
|
+
Requires-Dist: typer>=0.12
|
|
9
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
|
|
13
|
+
# Agent Guidance Hub (AGH)
|
|
14
|
+
|
|
15
|
+
<p><strong>Self-hosted agent instructions and skills, synced per repo.</strong></p>
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
[Español](README.es.md)
|
|
22
|
+
|
|
23
|
+
## What AGH is for
|
|
24
|
+
|
|
25
|
+
AGH gives teams one place to publish the instructions and reusable skills their coding agents use in repos: `AGENTS.md`, `CLAUDE.md`, and skill files placed under agent harness directories.
|
|
26
|
+
|
|
27
|
+
Without AGH, those files tend to drift from repo to repo. With AGH, you publish a versioned pack, assign it to a project, and apply the assigned files in each repo.
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
AGH Docker service
|
|
31
|
+
├─ /data/agh.sqlite3
|
|
32
|
+
├─ /data/packs/
|
|
33
|
+
├─ /data/logs/agh.log
|
|
34
|
+
└─ /data/secrets/initial_owner_token
|
|
35
|
+
↓ manifest + pack downloads
|
|
36
|
+
repository
|
|
37
|
+
├─ AGENTS.md / CLAUDE.md
|
|
38
|
+
├─ .claude/skills/.../SKILL.md
|
|
39
|
+
├─ .opencode/skills/.../SKILL.md
|
|
40
|
+
└─ .agh/lock.toml
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
Run the server with Docker:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
docker build -t agh .
|
|
49
|
+
docker run --rm -p 8912:8912 -v agh-data:/data \
|
|
50
|
+
-e AGH_BOOTSTRAP_OWNER_EMAIL=owner@example.com \
|
|
51
|
+
agh
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Install the local CLI, then log in with the first owner token:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv tool install --force .
|
|
58
|
+
|
|
59
|
+
agh login \
|
|
60
|
+
--url http://127.0.0.1:8912 \
|
|
61
|
+
--email owner@example.com \
|
|
62
|
+
--token "$(docker run --rm -v agh-data:/data busybox cat /data/secrets/initial_owner_token)"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then work from a repo:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
agh sync
|
|
69
|
+
agh pull --dry-run
|
|
70
|
+
agh pull
|
|
71
|
+
agh agent
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Docs
|
|
75
|
+
|
|
76
|
+
| Guide | Use it for |
|
|
77
|
+
|-------|------------|
|
|
78
|
+
| [Installation](docs/installation.md) | Install the local `agh` CLI and run the Docker server. |
|
|
79
|
+
| [Quickstart](docs/quickstart.md) | First Docker run, login, project link, and workspace apply flow. |
|
|
80
|
+
| [Packs](docs/packs.md) | Create, publish, and list instruction/skill packs. |
|
|
81
|
+
| [Projects](docs/projects.md) | Create projects and assign packs to repos. |
|
|
82
|
+
| [Admin](docs/admin.md) | Bootstrap owner, users, roles, tokens, and local config. |
|
|
83
|
+
| [Workspace guide](docs/workspace.md) | Repo setup, workspace apply behavior, markers, skills, lockfile, and Git rules. |
|
|
84
|
+
| [Operations](docs/operations.md) | Docker runtime layout, `/data`, logs, healthcheck, backup, and upgrades. |
|
|
85
|
+
|
|
86
|
+
## Core Concepts
|
|
87
|
+
|
|
88
|
+
| Concept | Meaning |
|
|
89
|
+
|---------|---------|
|
|
90
|
+
| Pack | Versioned set of instruction files and agent skills. |
|
|
91
|
+
| Project | AGH record linked to a git repository. |
|
|
92
|
+
| Pull manifest | Server plan for the files a repo should download and apply. |
|
|
93
|
+
| Lockfile | `.agh/lock.toml`; resolved versions, checksums, sources, and placement mode. |
|
|
94
|
+
| Cache | `.agh-cache/packs/`; downloaded pack files that AGH can rebuild. |
|
|
95
|
+
|
|
96
|
+
## Git Rule
|
|
97
|
+
|
|
98
|
+
Commit the stable project state:
|
|
99
|
+
|
|
100
|
+
- `.agh/project.toml`
|
|
101
|
+
- `.agh/lock.toml`
|
|
102
|
+
- `AGENTS.md` / `CLAUDE.md`
|
|
103
|
+
|
|
104
|
+
Ignore the cache:
|
|
105
|
+
|
|
106
|
+
```gitignore
|
|
107
|
+
.agh-cache/
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Skill targets under `.claude/skills/` and `.opencode/skills/` are generated by the workspace pull flow. Commit them only if your team wants agent skills reviewed in Git. If they are symlinks, refresh the workspace after clone to rebuild `.agh-cache/packs/`.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv sync
|
|
116
|
+
uv run pytest
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
For local server work without Docker, see [Operations](docs/operations.md#local-development).
|
agh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Agent Guidance Hub (AGH)
|
|
4
|
+
|
|
5
|
+
<p><strong>Self-hosted agent instructions and skills, synced per repo.</strong></p>
|
|
6
|
+
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
[Español](README.es.md)
|
|
12
|
+
|
|
13
|
+
## What AGH is for
|
|
14
|
+
|
|
15
|
+
AGH gives teams one place to publish the instructions and reusable skills their coding agents use in repos: `AGENTS.md`, `CLAUDE.md`, and skill files placed under agent harness directories.
|
|
16
|
+
|
|
17
|
+
Without AGH, those files tend to drift from repo to repo. With AGH, you publish a versioned pack, assign it to a project, and apply the assigned files in each repo.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
AGH Docker service
|
|
21
|
+
├─ /data/agh.sqlite3
|
|
22
|
+
├─ /data/packs/
|
|
23
|
+
├─ /data/logs/agh.log
|
|
24
|
+
└─ /data/secrets/initial_owner_token
|
|
25
|
+
↓ manifest + pack downloads
|
|
26
|
+
repository
|
|
27
|
+
├─ AGENTS.md / CLAUDE.md
|
|
28
|
+
├─ .claude/skills/.../SKILL.md
|
|
29
|
+
├─ .opencode/skills/.../SKILL.md
|
|
30
|
+
└─ .agh/lock.toml
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
Run the server with Docker:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
docker build -t agh .
|
|
39
|
+
docker run --rm -p 8912:8912 -v agh-data:/data \
|
|
40
|
+
-e AGH_BOOTSTRAP_OWNER_EMAIL=owner@example.com \
|
|
41
|
+
agh
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Install the local CLI, then log in with the first owner token:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv tool install --force .
|
|
48
|
+
|
|
49
|
+
agh login \
|
|
50
|
+
--url http://127.0.0.1:8912 \
|
|
51
|
+
--email owner@example.com \
|
|
52
|
+
--token "$(docker run --rm -v agh-data:/data busybox cat /data/secrets/initial_owner_token)"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then work from a repo:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
agh sync
|
|
59
|
+
agh pull --dry-run
|
|
60
|
+
agh pull
|
|
61
|
+
agh agent
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Docs
|
|
65
|
+
|
|
66
|
+
| Guide | Use it for |
|
|
67
|
+
|-------|------------|
|
|
68
|
+
| [Installation](docs/installation.md) | Install the local `agh` CLI and run the Docker server. |
|
|
69
|
+
| [Quickstart](docs/quickstart.md) | First Docker run, login, project link, and workspace apply flow. |
|
|
70
|
+
| [Packs](docs/packs.md) | Create, publish, and list instruction/skill packs. |
|
|
71
|
+
| [Projects](docs/projects.md) | Create projects and assign packs to repos. |
|
|
72
|
+
| [Admin](docs/admin.md) | Bootstrap owner, users, roles, tokens, and local config. |
|
|
73
|
+
| [Workspace guide](docs/workspace.md) | Repo setup, workspace apply behavior, markers, skills, lockfile, and Git rules. |
|
|
74
|
+
| [Operations](docs/operations.md) | Docker runtime layout, `/data`, logs, healthcheck, backup, and upgrades. |
|
|
75
|
+
|
|
76
|
+
## Core Concepts
|
|
77
|
+
|
|
78
|
+
| Concept | Meaning |
|
|
79
|
+
|---------|---------|
|
|
80
|
+
| Pack | Versioned set of instruction files and agent skills. |
|
|
81
|
+
| Project | AGH record linked to a git repository. |
|
|
82
|
+
| Pull manifest | Server plan for the files a repo should download and apply. |
|
|
83
|
+
| Lockfile | `.agh/lock.toml`; resolved versions, checksums, sources, and placement mode. |
|
|
84
|
+
| Cache | `.agh-cache/packs/`; downloaded pack files that AGH can rebuild. |
|
|
85
|
+
|
|
86
|
+
## Git Rule
|
|
87
|
+
|
|
88
|
+
Commit the stable project state:
|
|
89
|
+
|
|
90
|
+
- `.agh/project.toml`
|
|
91
|
+
- `.agh/lock.toml`
|
|
92
|
+
- `AGENTS.md` / `CLAUDE.md`
|
|
93
|
+
|
|
94
|
+
Ignore the cache:
|
|
95
|
+
|
|
96
|
+
```gitignore
|
|
97
|
+
.agh-cache/
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Skill targets under `.claude/skills/` and `.opencode/skills/` are generated by the workspace pull flow. Commit them only if your team wants agent skills reviewed in Git. If they are symlinks, refresh the workspace after clone to rebuild `.agh-cache/packs/`.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv sync
|
|
106
|
+
uv run pytest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For local server work without Docker, see [Operations](docs/operations.md#local-development).
|
|
@@ -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
|
+
)
|
|
@@ -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)
|