# Design: agtag CLI v0.1 scaffold

**Status:** Approved (brainstorm 2026-05-09)
**Target version:** `0.1.0`
**Approach:** Single-PR scaffold + verbs (Section 6).

## 1. Purpose

Stand up `agtag` as a published, agent-first Python CLI on PyPI under the
`agentculture` GitHub org, with the noun/verb surface and release pipeline
its sibling projects (`steward`, `daria`, `afi-cli`) already use. v0.1
ships:

- The afi-template universals — `learn`, `explain`, `--version`, `--json`,
  structured errors, exit-code policy.
- An `issue` noun group with three verbs (`post`, `fetch`, `reply`) that
  wraps the `gh` CLI and signs every authored body with the repo's nick.
- The release pipeline (lint, test, version-check, TestPyPI dev publish on
  PR, PyPI publish on push to main).
- Three sibling skills vendored from `steward` (`version-bump`,
  `run-tests`, `pypi-maintainer`) so the CI gates the workflows enforce
  have the agent-side tooling to satisfy them.

The `culture agent message` migration becomes `agtag message` in **v0.2**
and is explicitly out of scope here. PR-related agent tooling
(`cicd/pr-reply.sh` etc.) is the future domain of `agex-cli`, a separate
sibling project, and is also out of scope.

## 2. Module layout

```
agtag/
├── pyproject.toml          # name="agtag", hatchling, single-source version
├── CHANGELOG.md            # Keep-a-Changelog, starts at 0.1.0
├── culture.yaml            # agents: [{suffix: agtag, backend: claude}]
├── README.md               # install via `uv tool install agtag`; quickstart
├── agtag/
│   ├── __init__.py         # __version__ via importlib.metadata
│   ├── __main__.py         # delegates to cli.main
│   ├── nick.py             # resolve_nick(cwd) — culture.yaml → repo basename
│   ├── cli/
│   │   ├── __init__.py     # parser + _dispatch (shape-adapt from afi)
│   │   ├── _errors.py      # AgtagError + EXIT_SUCCESS/USER/ENV (stable-contract)
│   │   ├── _output.py      # emit_result/emit_error/emit_diagnostic, --json (stable-contract)
│   │   └── _commands/
│   │       ├── __init__.py
│   │       ├── learn.py
│   │       ├── explain.py
│   │       ├── issue_post.py
│   │       ├── issue_fetch.py
│   │       └── issue_reply.py
│   ├── explain/
│   │   ├── __init__.py     # resolve() + known_paths() (stable-contract)
│   │   └── catalog.py      # ENTRIES dict for agtag's grammar
│   └── issue/
│       ├── __init__.py
│       ├── post.py         # post(repo, title, body, *, nick) -> dict
│       ├── fetch.py        # fetch(repo, number) -> dict
│       ├── reply.py        # reply(repo, number, body, *, nick) -> dict
│       └── _gh.py          # ONLY file that shells out to `gh`
├── docs/
│   ├── skill-sources.md      # vendored-skill ledger (existing; updated)
│   ├── purpose.md            # what agtag is, ecosystem map
│   ├── features.md           # release-keyed feature list + roadmap
│   ├── commands.md           # human-readable CLI reference (mirrors `agtag explain`)
│   └── culture.md            # how agtag fits the AgentCulture mesh
└── tests/
    ├── __init__.py
    ├── test_cli.py             # smoke
    ├── test_nick.py
    ├── test_issue_post.py
    ├── test_issue_fetch.py
    ├── test_issue_reply.py
    └── test_explain_catalog.py
```

**Boundary rule.** `agtag/issue/` is pure: takes args, returns dicts,
raises `AgtagError`. `agtag/cli/_commands/` only translates argparse →
kwargs and dict → stdout. `agtag/issue/_gh.py` is the only file that
shells out via `subprocess`. Verb-level tests mock at `_gh._run_gh`, never
at the verb function itself, so the test suite never invokes a real `gh`
binary.

## 3. CLI surface

All commands honor `--json`. In JSON mode, machine output goes to stdout;
errors go to stderr as `{"code","message","remediation"}`. Exit codes:
`0` success, `1` user-input error, `2` environment/setup error.

| Command | Args | Exit triggers | stdout (text) | stdout (`--json`) |
|---|---|---|---|---|
| `agtag --version` | — | — | `agtag 0.1.0` | `{"version":"0.1.0"}` |
| `agtag learn` | `[--json]` | — | self-teaching prompt (markdown) | structured prompt |
| `agtag explain <path…>` | `[--json]` | unknown path → 1 | catalog entry markdown | catalog entry dict |
| `agtag issue post` | `--repo OWNER/REPO --title T (--body B \| --body-file F) [--as NICK] [--json]` | missing `gh` → 2; auth fail → 2; bad repo → 1 | `https://github.com/.../issues/N` | `{"url":…, "number":N, "signed_as":"agtag"}` |
| `agtag issue fetch` | `--repo OWNER/REPO --number N [--json]` (or single positional issue URL) | missing `gh` → 2; not found → 1 | `## #N: <title>` + body + per-comment `### @user — date` | `{"number":N,"title":…,"body":…,"author":…,"comments":[…]}` |
| `agtag issue reply` | `--repo OWNER/REPO --number N (--body B \| --body-file F) [--as NICK] [--json]` | missing `gh` → 2; auth fail → 2; not found → 1 | comment URL | `{"url":…,"signed_as":"agtag"}` |

### Signing rule

For `issue post` and `issue reply`: the supplied body is taken verbatim,
then a trailing blank line and `\n- <nick> (Claude)` are appended. `<nick>`
resolves via `agtag.nick.resolve_nick(cwd)`. `--as NICK` overrides.
`issue fetch` is read-only and emits no signature.

**Idempotent.** If the supplied body already ends with the exact signature
line that would be appended, no second copy is added. Matches the bash
`pr-reply.sh` line 33–37 behavior so an agent can pre-sign and re-pipe
without doubling.

### Issue fetch — URL positional form

`agtag issue fetch https://github.com/agentculture/agtag/issues/12` is
equivalent to `agtag issue fetch --repo agentculture/agtag --number 12`.
Parsing the URL is done with a single regex; on no match, exit 1 with a
JSON-mode-aware error pointing to the explicit `--repo`/`--number` form.

### `explain` catalog (v0.1)

Entries: `agtag`, `learn`, `explain`, `issue` (group page that lists its
verbs), `issue post`, `issue fetch`, `issue reply`. The catalog is the
source of truth for command discovery; `docs/commands.md` is a hand-tended
mirror that links each section back to `agtag explain <path>`.

## 4. Nick resolution + `culture.yaml`

```python
def resolve_nick(cwd: Path | None = None) -> str:
    """Resolve the signing nick.

    Order:
      1. <cwd>/culture.yaml → agents[0].suffix (if present and non-empty)
      2. Path(cwd).name (repo dir basename)
    cwd defaults to Path.cwd().
    """
```

Uses `pyyaml`. Malformed YAML: silent fallback to basename, with a
diagnostic on stderr explaining the parse failure. Matches the bash
`_resolve-nick.sh` contract behaviorally.

**This repo's `culture.yaml`:**

```yaml
agents:
- suffix: agtag
  backend: claude
```

Once present, both the python `agtag/nick.py` and the bash
`cicd/_resolve-nick.sh` (used by other cicd scripts) read the same source
of truth.

## 5. CI / release pipeline

Three workflows, ported from steward and adapted to `agtag`:

### `.github/workflows/tests.yml`

Runs on PR and push to main. Three jobs:

- **`test`** — `uv sync` → `uv run pytest -n auto --cov=agtag --cov-report=xml:coverage.xml --cov-report=term -v` → SonarCloud scan (gated on a `SONAR_TOKEN_PRESENT` flag promoted to job env; the real `SONAR_TOKEN` is injected only at the scan step's `env:` to keep it out of `pytest`'s environment). The current standalone `sonar.yml` is **deleted** and its scan step folds into this job.
- **`lint`** — `black --check`, `isort --check-only`, `flake8`, `bandit -c pyproject.toml -r agtag`, `markdownlint-cli2 "**/*.md"`.
- **`version-check`** — PR-only; compares `pyproject.toml` version against `origin/main`. Sticky comment if equal. Lifted verbatim from steward; only project name interpolation changes.

### `.github/workflows/publish.yml`

Runs on `push: main` and PR, paths-filtered to `pyproject.toml` + `agtag/**`. Three jobs:

- **`test`** — minimal smoke (same as above).
- **`test-publish`** — PR-only, non-fork-only, `environment: testpypi`, OIDC trusted publishing. Sets dev version `${BASE}.dev${{ github.run_number }}`, `uv build`, `uv publish --publish-url https://test.pypi.org/legacy/ --trusted-publishing always --check-url https://test.pypi.org/simple/`. Prints a notice with the install hint: `uv tool install --index-url https://test.pypi.org/simple/ --index-strategy unsafe-best-match agtag==${DEV_VERSION}`.
- **`publish`** — push-to-main only, `environment: pypi`, `uv build` + `uv publish --trusted-publishing always --check-url https://pypi.org/simple/`.

### Pre-merge gate

Verify on (Test)PyPI:

1. `agtag` project exists or is reservable under the `agentculture` GitHub org's trusted-publisher claim.
2. Trusted-publisher records list workflow `publish.yml` and environments `testpypi` (TestPyPI) / `pypi` (PyPI).
3. GitHub repo has the matching `testpypi` and `pypi` deployment environments configured.

Without these, the first publish run fails. The brainstorm flagged this as
unverified — the user said the project name is "already prepared" but `pip
index versions agtag` returns nothing on either index. The implementation
plan must verify before merge.

### Versioning

Initial version `0.1.0`. CHANGELOG.md gets a single `## [0.1.0] - 2026-05-09`
entry under "Added" listing scaffold, verbs, vendored skills, and workflows.

## 6. Skills vendored + adaptations

| Skill | Status | Adaptation |
|---|---|---|
| `cicd` | already vendored | **untouched.** No file changes. |
| `communicate` | already vendored — modified | Delete `scripts/post-issue.sh`. Rewrite SKILL.md so the "post an issue" path tells the agent to run `agtag issue post --repo … --title … --body-file …`. Preserve `gh` env-error remediation hints, just relocate them. `fetch-issues.sh` + `mesh-message.sh` untouched. Update `docs/skill-sources.md` ledger entry. |
| `version-bump` | newly vendored | `scripts/bump.py` is generic on package name; copy verbatim. SKILL.md: replace `steward/__init__.py` reference with `agtag/__init__.py` (importlib.metadata-driven, same shape). Add `docs/skill-sources.md` row. |
| `run-tests` | newly vendored | `scripts/test.sh` is package-agnostic (reads `[tool.coverage.run]`); copy verbatim. SKILL.md unchanged. Add ledger row. |
| `pypi-maintainer` | newly vendored | `scripts/switch-source.sh` takes pkg name as first arg; copy verbatim. SKILL.md unchanged. Add ledger row. |

After this PR `docs/skill-sources.md` has 5 rows.

## 7. PR plan + tracking issue

1. **File tracking issue** on `agentculture/agtag` via the *current* bash `post-issue.sh` (last call before deletion). Title: `Scaffold agtag CLI v0.1 (afi template + issue verbs + release pipeline)`. Body summarizes scope, surface, skills, version, deferred items. Signed `- agtag (Claude)`.
2. **Branch** `scaffold/v0.1` off `main`.
3. **Run `afi cli cite .`** to drop the reference tree into `.afi/reference/python-cli/`. Not committed (added to `.gitignore`); used as source-of-truth template for token substitution.
4. **Apply scaffold** per Sections 2–6.
5. **Local verify:** `uv sync && uv run pytest -n auto -v --cov=agtag`, `uv run black --check agtag tests`, `uv run flake8 agtag tests`, `markdownlint-cli2 "**/*.md"`, `agtag --version`, `agtag explain issue post`, smoke-test `agtag issue post --repo agentculture/agtag --title test --body test --json` against a throwaway issue (then close).
6. **Pre-merge gate** — verify trusted-publisher / environment configuration per §5.
7. **Open PR** via `cicd` skill (`create-pr-and-wait.sh`). PR body includes `Closes #<tracking issue>`.
8. **Merge** → `publish.yml` runs on push-to-main → first PyPI release of `agtag 0.1.0`.
9. **Steward broadcast** (post-merge follow-up, separate work): announce the `post-issue.sh` → `agtag issue post` migration to other `communicate`-skill consumers via `steward announce-skill-update`.

## 8. Testing strategy

`agtag/issue/_gh.py` is the only file that shells out. Tests mock at that
boundary, so verb-level tests are fast and require no `gh` binary. **No
live network calls in the suite.** Coverage gate `fail_under = 80` in
`[tool.coverage.report]`.

- `tests/test_cli.py` — `--version` (text + json), `learn` (parseable JSON in `--json` mode), `explain issue post` (returns the catalog entry), `explain bogus path` (exit 1, json-mode-aware error), `agtag` with no args (prints help, exit 0).
- `tests/test_nick.py` — `culture.yaml` present → `"agtag"`; absent → `Path.cwd().name`; malformed YAML → fallback + diagnostic on stderr.
- `tests/test_issue_post.py` — happy path: mocked `gh` returns issue URL, verb appends `\n\n- agtag (Claude)`. Error: `gh` missing → `AgtagError(code=ENV)`. Override: `--as steward` → suffix `\n\n- steward (Claude)`. Idempotent suffix: passing a body that already ends with the signature does not double-append (matches `pr-reply.sh` line 33–37 behavior).
- `tests/test_issue_fetch.py` — mocked `gh issue view --json` returns a canned issue + comments; assert dict shape; assert positional URL parsing equivalent to `--repo`+`--number`.
- `tests/test_issue_reply.py` — symmetric to post: signature appended, idempotent, env-error path, `--as` override.
- `tests/test_explain_catalog.py` — every catalog entry resolves; `known_paths()` lists all of them; `--json` output for each entry has the documented keys.

## 9. Out of scope

- Mesh transport (`agtag message` migrating `culture agent message`) → v0.2.
- PR review-thread tooling (migrating `cicd/pr-reply.sh` and `cicd/pr-batch.sh`) → `agex-cli` repo, when it lands. `cicd` is untouched in this PR.
- `communicate` skill's `fetch-issues.sh` and `mesh-message.sh` — left as-is for now; future symmetric migration is possible but not required by this PR.

## 10. Risks / unknowns

- **Trusted-publisher state on (Test)PyPI is unverified.** First publish run fails if the OIDC claim is missing. Pre-merge verification gate exists in §5 to catch this.
- **PR size.** This is a large single PR. Mitigation: most files are template-substitution from the afi reference tree, not original logic; reviewers can audit Section 2–6 of this design first and use it as a checklist.
- **Coverage gate at 80% for a small surface.** With ~6 verbs and small modules, hitting 80% is achievable but tight; may require explicit `# pragma: no cover` on the `__main__.py` shim and the `if TYPE_CHECKING:` blocks already excluded by `[tool.coverage.report]`.