Folds in decisions made during the design review: flat sibling imports + PEP 723, CWD-based paths, git-remote inference with env overrides, workpad/tickets parameterization, project label overrides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Porting the gitea skill + scripts into reliquary
Hands-on design for moving dirigent's gitea skill (which is a thin wrapper around scripts/gitea/*.py in the dirigent repo) into a reliquary plugin where the scripts and the skill ship as one unit.
Current state in dirigent
dirigent/
├── .claude/skills/gitea/SKILL.md # 123 LOC — thin wrapper, points at scripts
└── scripts/gitea/
├── CLAUDE.md # agent reference for scripts (~127 LOC)
├── README.md # human setup guide (~178 LOC)
├── __init__.py
├── client.py # 186 LOC — GiteaClient REST wrapper
├── close_ticket.py # 39 LOC
├── config.py # 68 LOC — env loading
├── create_ticket.py # 115 LOC
├── fetch_ticket.py # 113 LOC
├── list_tickets.py # 56 LOC
├── models.py # 66 LOC — Pydantic models
├── post_comment.py # 69 LOC
├── setup_labels.py # 43 LOC
└── start_ticket.py # 163 LOC
Total: ~919 LOC of Python + ~430 LOC of docs.
The skill itself does almost nothing — it tells the agent to read scripts/gitea/CLAUDE.md and run uv run python -m scripts.gitea.<module>. Everything load-bearing lives in the dirigent project root: the venv, uv.lock, pyproject.toml, and .env.
Target shape in reliquary
plugins/gitea/
├── .claude-plugin/plugin.json
├── README.md
└── skills/gitea/
├── SKILL.md
├── labels.default.json # ships with plugin
└── scripts/
├── client.py # flat layout — no package
├── config.py
├── models.py
├── ping.py # standalone connectivity check
├── fetch_ticket.py
├── create_ticket.py
├── post_comment.py
├── start_ticket.py
├── list_tickets.py
├── close_ticket.py
└── setup_labels.py
Single SKILL.md, no separate CLAUDE.md. No __init__.py. Each entrypoint is a self-contained PEP 723 script.
Decisions (resolved during design review)
1. Module layout: flat sibling imports + PEP 723 per file
Each CLI entrypoint carries an inline-deps block:
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx", "pydantic>=2", "python-dotenv"]
# ///
Imports become flat — import client, from client import GiteaClient, from fetch_ticket import render_issue_markdown. The script's own directory is on sys.path when run as a file, so no package context is needed.
Invocation: uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/fetch_ticket.py 42.
Tradeoff: uv run builds an isolated env per script on first call (slow once, cached after). Worth it for hermetic execution against any project regardless of its own Python setup.
2. CWD-relative, not script-relative
The dirigent scripts derive PROJECT_ROOT from Path(__file__).resolve().parent.parent.parent. After the port that resolves to the plugin directory, which is wrong for every git/workpad/env operation.
All scripts must use Path.cwd() for:
- git operations (
subprocess.run([...], cwd=Path.cwd())or just nocwdarg) - workpad/ticket file writes
.envdiscovery (calldotenv.load_dotenv()with no args — finds.envin CWD and walks up)
Path(__file__) is only used to locate sibling resources shipped with the plugin (e.g. labels.default.json).
3. Where Gitea lives — infer from git remote, override via env
config.py resolves connection settings in this order:
- Parse
git remote get-url origin. Handle both forms:git@host:owner/repo.git→base_url=https://host,owner,repohttps://host/owner/repo(.git)?→ same fields
- Env vars override inferred values. Any of
GITEA_URL,GITEA_OWNER,GITEA_REPOset in env (or.env) wins over what's parsed from the remote. Covers mirrors, multi-remote setups, separate API host. GITEA_TOKENis always required — never inferable.
No host sniffing — the script doesn't try to guess whether the remote is Gitea, GitHub, or anything else. It just makes the API call; the connectivity check surfaces a wrong host as HTTP 404 or a malformed response.
If git remote get-url origin fails (not a git repo, no origin) and any of URL/owner/repo isn't set in env, exit with a clear error listing what's missing.
4. Workpad resolution lives in the skill, not the script
Scripts take a required --workpad <path> flag. They do not resolve the workpad themselves.
The skill resolves it before invoking:
WORKPAD_FOLDERenv var (or.env-loaded value).- If unset, ask the user — or accept a value already established in conversation.
No /docs/workpad auto-fallback in this skill — the workpad skill itself handles project-level placement; gitea just consumes the resolved path.
5. Tickets folder is independently overridable
The workpad's tickets/ subfolder is not the only valid home for per-ticket scratch. In some projects, tickets live outside the workpad entirely.
Resolution:
--tickets-dir <path>flag if passed (skill can populate fromGITEA_TICKETS_DIRenv).- Else
<workpad>/tickets/<n>/.
When --tickets-dir is given, write directly to <tickets-dir>/<n>/context.md — no workpad/ join. mkdir(parents=True, exist_ok=True) on demand.
6. Labels: plugin default + project override
plugins/gitea/skills/gitea/labels.default.jsonships with the plugin and contains the de-dirigent-ed default set..gitea-labels.jsonin CWD, if present, fully replaces the default (not merged — keeps semantics simple).setup_labels.pyloads whichever applies, creates missing labels, skips existing ones (case-insensitive name match).
The label description "Created from dirigent workpad" becomes "Created from workpad".
7. Connectivity check becomes a script
The SKILL's Step 1 inline python -c doesn't survive the package rename. Replace with:
uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/ping.py
ping.py does what test_connection() did: load config, hit /repos/{owner}/{repo}, print OK or a categorized error (missing env, 401, 404, connection refused) and exit non-zero on failure.
Concrete port checklist
Phase 1 — copy + flatten
- Copy
dirigent/scripts/gitea/*.py→plugins/gitea/skills/gitea/scripts/. - Delete
__init__.py. - Rewrite every
from .client import …→from client import …. Same for.config,.models,.fetch_ticket. - Add PEP 723 header to each CLI entrypoint (
fetch_ticket.py,create_ticket.py,post_comment.py,start_ticket.py,list_tickets.py,close_ticket.py,setup_labels.py,ping.py). Library files (client.py,config.py,models.py) don't need it.
Phase 2 — CWD + remote inference
- Replace
PROJECT_ROOT = Path(__file__)...instart_ticket.pywithPath.cwd()usage. Remove the constant. - Switch
config.py.envloading from custom code todotenv.load_dotenv()(no path arg). - Add
git_remote_origin()helper inconfig.py: runsgit remote get-url origin, parses SSH/HTTPS forms, returns(base_url, owner, repo)orNone. Use it as the base inload_config(); env vars override. - Drop the "See scripts/gitea/README.md" hint in the missing-env error and replace with skill-aware wording.
Phase 3 — parameterize workpad + tickets + labels
- Add
--workpad <path>(required) and--tickets-dir <path>(optional) tofetch_ticket.pyandstart_ticket.py. Default tickets dir =<workpad>/tickets/. - Add
--workpad <path>tocreate_ticket.py(for--from-goalpath resolution — though goals are typically given as explicit paths, so this may be unnecessary; decide during implementation). - Build
labels.default.jsonfrom the current hardcoded list, de-dirigent-ed. setup_labels.py: load.gitea-labels.jsonfrom CWD if present, elsePath(__file__).parent.parent / "labels.default.json".
Phase 4 — skill markdown + manifest
- Rewrite
SKILL.mdfrom scratch:- Inline capability summary for each script (no separate CLAUDE.md).
- Step 0 (read CLAUDE.md) deleted.
- Step 1 (connectivity) calls
ping.py. - All invocations use
uv run ${CLAUDE_PLUGIN_ROOT}/skills/gitea/scripts/<name>.py. - Workpad resolution paragraph: env → ask, then pass via
--workpad. - Keep the error-recovery table; update path references.
plugin.json: namegitea, version0.1.0, author Gabor Körber, description matches the marketplace tone of other plugins in this repo.README.mdat plugin root: install, token setup, env var reference (onlyGITEA_TOKENmandatory now), example session.
Phase 5 — manual end-to-end
- Real Gitea + a non-dirigent project. Test:
ping,setup_labels(with project override file present and absent),list_tickets,fetch_ticket,start_ticket(cwd-relative git!),post_comment,close_ticket. This is where bugs surface.
What we are not doing
- No
python -minvocation, no shared venv, no project-level dependency installation. - No host sniffing — the script doesn't decide whether a remote is "Gitea-shaped" before calling.
- No merge between project label file and plugin default — override is total replacement.
- No PR-related endpoints. Issues only, same as the dirigent scripts.
- No workflow glue (write-plan-and-comment, ticket-to-branch-with-implementation, etc.). That's a separate sibling plugin, addressed below.
Sibling "mini plugin" — future scope, not in this port
A separate plugin (working name: gitea-workflow or similar) layers opinionated flows on top of the API skill:
- kanban skill — list/move issues across columns, mark in-progress, query "what am I working on right now".
- workpad-flow skill — fetch ticket → write plan in workpad → post plan back as comment → branch via
start_ticket→ final commit withcloses #N. - bulk skill — relabel many issues, close stale ones, milestone grooming.
Out of scope for this port. The core gitea plugin stays a thin, opinion-light API wrapper so the workflow plugin can be swapped or extended without touching the API surface.
Estimated work
| Phase | Effort | Risk |
|---|---|---|
| Phase 1 (copy + flatten + PEP 723) | ~45min | Low — mechanical |
| Phase 2 (CWD + remote inference) | ~1h | Medium — git remote parsing has edge cases |
| Phase 3 (workpad/tickets/labels params) | ~1h | Low |
| Phase 4 (SKILL.md + manifest + README) | ~45min | Low |
| Phase 5 (end-to-end test) | ~1h | This is where bugs hide |
Total: ~4.5h focused work. Highest-risk pieces: start_ticket (touches git state + workpad + API in one call) and the remote-URL parser (SSH alias forms, port numbers, trailing .git, etc.).