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.
Files changed (59) hide show
  1. agh-0.1.0/PKG-INFO +119 -0
  2. agh-0.1.0/README.md +109 -0
  3. agh-0.1.0/agh/__init__.py +3 -0
  4. agh-0.1.0/agh/cli/__init__.py +1 -0
  5. agh-0.1.0/agh/cli/agent_integrations.py +98 -0
  6. agh-0.1.0/agh/cli/config.py +186 -0
  7. agh-0.1.0/agh/cli/main.py +986 -0
  8. agh-0.1.0/agh/cli/pack_init.py +102 -0
  9. agh-0.1.0/agh/cli/pack_publish.py +198 -0
  10. agh-0.1.0/agh/cli/pull_markers.py +202 -0
  11. agh-0.1.0/agh/cli/pull_plan.py +188 -0
  12. agh-0.1.0/agh/cli/workspace_pull.py +1026 -0
  13. agh-0.1.0/agh/cli/workspace_sync.py +231 -0
  14. agh-0.1.0/agh/common/__init__.py +40 -0
  15. agh-0.1.0/agh/common/checksums.py +14 -0
  16. agh-0.1.0/agh/common/ids.py +26 -0
  17. agh-0.1.0/agh/common/pack_manifest.py +66 -0
  18. agh-0.1.0/agh/common/repo_url.py +40 -0
  19. agh-0.1.0/agh/common/validation.py +68 -0
  20. agh-0.1.0/agh/server/__init__.py +5 -0
  21. agh-0.1.0/agh/server/app.py +116 -0
  22. agh-0.1.0/agh/server/auth.py +162 -0
  23. agh-0.1.0/agh/server/db.py +135 -0
  24. agh-0.1.0/agh/server/migrations/001_initial_schema.sql +79 -0
  25. agh-0.1.0/agh/server/migrations/__init__.py +0 -0
  26. agh-0.1.0/agh/server/routes/__init__.py +0 -0
  27. agh-0.1.0/agh/server/routes/packs.py +472 -0
  28. agh-0.1.0/agh/server/routes/projects.py +849 -0
  29. agh-0.1.0/agh/server/routes/users.py +330 -0
  30. agh-0.1.0/agh.egg-info/PKG-INFO +119 -0
  31. agh-0.1.0/agh.egg-info/SOURCES.txt +57 -0
  32. agh-0.1.0/agh.egg-info/dependency_links.txt +1 -0
  33. agh-0.1.0/agh.egg-info/entry_points.txt +2 -0
  34. agh-0.1.0/agh.egg-info/requires.txt +3 -0
  35. agh-0.1.0/agh.egg-info/top_level.txt +1 -0
  36. agh-0.1.0/pyproject.toml +27 -0
  37. agh-0.1.0/setup.cfg +4 -0
  38. agh-0.1.0/tests/test_agent_command.py +59 -0
  39. agh-0.1.0/tests/test_api_errors.py +172 -0
  40. agh-0.1.0/tests/test_auth_bootstrap.py +223 -0
  41. agh-0.1.0/tests/test_cli_admin_commands.py +558 -0
  42. agh-0.1.0/tests/test_cli_login.py +333 -0
  43. agh-0.1.0/tests/test_cli_pack_commands.py +817 -0
  44. agh-0.1.0/tests/test_cli_pull.py +720 -0
  45. agh-0.1.0/tests/test_common_helpers.py +157 -0
  46. agh-0.1.0/tests/test_db_migrations.py +215 -0
  47. agh-0.1.0/tests/test_docs_guidance.py +402 -0
  48. agh-0.1.0/tests/test_install_script.py +106 -0
  49. agh-0.1.0/tests/test_integration_smoke.py +282 -0
  50. agh-0.1.0/tests/test_pack_routes.py +352 -0
  51. agh-0.1.0/tests/test_project_pack_assignments.py +278 -0
  52. agh-0.1.0/tests/test_project_routes.py +340 -0
  53. agh-0.1.0/tests/test_pull_manifest_routes.py +282 -0
  54. agh-0.1.0/tests/test_pull_markers.py +189 -0
  55. agh-0.1.0/tests/test_pull_plan.py +206 -0
  56. agh-0.1.0/tests/test_scaffold.py +50 -0
  57. agh-0.1.0/tests/test_user_routes.py +368 -0
  58. agh-0.1.0/tests/test_workspace_pull.py +365 -0
  59. 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,3 @@
1
+ """Agent Guidance Hub."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)