steward-cli 0.1.2__tar.gz → 0.2.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.
- steward_cli-0.2.0/.claude/skills/doc-test-alignment/SKILL.md +55 -0
- steward_cli-0.2.0/.claude/skills/doc-test-alignment/scripts/check.sh +24 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.markdownlint-cli2.yaml +2 -2
- {steward_cli-0.1.2 → steward_cli-0.2.0}/CHANGELOG.md +38 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/CLAUDE.md +24 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/PKG-INFO +1 -1
- steward_cli-0.2.0/docs/sibling-pattern.md +80 -0
- steward_cli-0.2.0/docs/skill-sources.md +43 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/pyproject.toml +1 -1
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/__init__.py +2 -0
- steward_cli-0.2.0/steward/cli/_commands/verify.py +228 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/tests/test_cli.py +3 -1
- steward_cli-0.2.0/tests/test_cli_verify.py +78 -0
- steward_cli-0.2.0/tests/test_skills_convention.py +106 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/uv.lock +1 -1
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/agent-config/SKILL.md +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/SKILL.md +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/version-bump/SKILL.md +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills.local.yaml.example +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.flake8 +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.github/workflows/tests.yml +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/.gitignore +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/LICENSE +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/README.md +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/__init__.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/__main__.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_commands/__init__.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_commands/show.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_errors.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/steward/cli/_output.py +0 -0
- {steward_cli-0.1.2 → steward_cli-0.2.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-test-alignment
|
|
3
|
+
description: >
|
|
4
|
+
Verify that committed docs (README.md, CLAUDE.md, SKILL.md descriptions) still
|
|
5
|
+
describe what the code and tests actually do. Use at the end of a plan, before
|
|
6
|
+
PR creation, or when the user says "check doc-test alignment", "verify docs",
|
|
7
|
+
or "do the docs still match the code". STUB — `scripts/check.sh` exits with a
|
|
8
|
+
not-yet-implemented error today; the contract for what it will do lives in
|
|
9
|
+
this file.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# doc-test-alignment (stub)
|
|
13
|
+
|
|
14
|
+
This skill is a stub. The real workflow is intentionally not yet implemented —
|
|
15
|
+
the file exists so that `steward verify` can find it and so contributors who
|
|
16
|
+
land here know it is on the roadmap, not forgotten.
|
|
17
|
+
|
|
18
|
+
## How to run
|
|
19
|
+
|
|
20
|
+
`scripts/check.sh` is the entry point. Today it prints a not-yet-implemented
|
|
21
|
+
notice and exits non-zero. When the workflow lands, the script will gate
|
|
22
|
+
PR-readiness on the alignment contract below; until then, treat any green
|
|
23
|
+
exit code from this script as a bug.
|
|
24
|
+
|
|
25
|
+
## What it will check
|
|
26
|
+
|
|
27
|
+
The skill is the contract for four narrow alignments. README.md command
|
|
28
|
+
examples must still execute against the current checkout and produce output
|
|
29
|
+
that matches the surrounding prose. The "build/test/publish" command lines in
|
|
30
|
+
CLAUDE.md must do the same. For each `.claude/skills/<name>/`, the SKILL.md
|
|
31
|
+
`description` frontmatter must agree with what the scripts under
|
|
32
|
+
`scripts/` actually do — surfacing disagreements (e.g. SKILL.md claims the
|
|
33
|
+
skill bumps versions but `scripts/` has no bump script). And for each test,
|
|
34
|
+
the test name should still describe the assertions the test makes — flagging
|
|
35
|
+
drift where the name advertises a feature the assertions no longer touch.
|
|
36
|
+
|
|
37
|
+
## Why it ships as a stub
|
|
38
|
+
|
|
39
|
+
Each of those four checks is independently non-trivial. Shipping a partial
|
|
40
|
+
implementation would either silently pass when it shouldn't, or false-positive
|
|
41
|
+
on intentional doc-vs-code differences. The right path is to land the checks
|
|
42
|
+
one at a time, with their own tests, behind a
|
|
43
|
+
`steward verify --check doc-test-alignment` flag. The parent verbs (`verify`,
|
|
44
|
+
`doctor`) are named in the "Roadmap" section of `CLAUDE.md`; the broader
|
|
45
|
+
sibling-pattern contract lives in `docs/sibling-pattern.md`.
|
|
46
|
+
|
|
47
|
+
## What this stub guarantees today
|
|
48
|
+
|
|
49
|
+
- The skill directory exists, so `steward verify`'s skills-convention check
|
|
50
|
+
finds the standard layout (SKILL.md + `scripts/` with an entry-point).
|
|
51
|
+
- `scripts/check.sh` is the entry-point script, satisfying the steward skills
|
|
52
|
+
convention requirement that every skill ships an executable script.
|
|
53
|
+
- This `SKILL.md` is the contract for what the skill will do — when the
|
|
54
|
+
implementation lands, it must satisfy this description or the description
|
|
55
|
+
must move first.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# doc-test-alignment skill — entry point.
|
|
3
|
+
#
|
|
4
|
+
# STUB: the real workflow is not implemented yet. This script exists so the
|
|
5
|
+
# steward skills convention is satisfied (every skill ships an executable
|
|
6
|
+
# entry-point script); when the real implementation lands here, it must
|
|
7
|
+
# satisfy the contract documented in ../SKILL.md.
|
|
8
|
+
#
|
|
9
|
+
# Exits 2 (EXIT_USER_ERROR-ish for "you asked for something that isn't
|
|
10
|
+
# wired up yet") so callers can tell the difference between "checks passed"
|
|
11
|
+
# (would be 0) and "stub".
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
cat >&2 <<'EOF'
|
|
16
|
+
doc-test-alignment: not yet implemented.
|
|
17
|
+
|
|
18
|
+
This skill is a stub; the contract for what `check.sh` will assert lives in
|
|
19
|
+
.claude/skills/doc-test-alignment/SKILL.md. Until the implementation lands,
|
|
20
|
+
treat any green exit code from this script as a bug.
|
|
21
|
+
|
|
22
|
+
Roadmap: see CLAUDE.md ("Roadmap (CLI surface)") and docs/sibling-pattern.md.
|
|
23
|
+
EOF
|
|
24
|
+
exit 2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# markdownlint-cli2 config for steward.
|
|
2
|
-
# markdownlint-cli2 stops walking at the git root, so
|
|
3
|
-
#
|
|
2
|
+
# markdownlint-cli2 stops walking at the git root, so a per-user global
|
|
3
|
+
# config in the home directory isn't picked up from inside the repo.
|
|
4
4
|
# Mirrors the afi-cli / cfafi preset for workspace consistency.
|
|
5
5
|
|
|
6
6
|
config:
|
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
Format follows [Keep a Changelog](https://keepachangelog.com/). This project
|
|
6
6
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-04-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `steward verify <path>` — read-only diagnosis of a sibling repo against the
|
|
13
|
+
AgentCulture sibling pattern. Two checks today: `portability` (runs steward's
|
|
14
|
+
own vendored `portability-lint.sh --all` with `cwd=<target>`, so the target
|
|
15
|
+
doesn't need to vendor it and `verify` only ever executes a known-trusted
|
|
16
|
+
script) and `skills-convention` (every `SKILL.md` has a sibling `scripts/`
|
|
17
|
+
directory and a matching frontmatter `name`). Aggregates findings across all
|
|
18
|
+
selected checks; human-readable findings go to stderr, `--json` puts the
|
|
19
|
+
structured findings list on stdout. `--check <name>` repeatable. Exits 1 if
|
|
20
|
+
any finding was reported.
|
|
21
|
+
- `docs/sibling-pattern.md` — single source of truth for the AgentCulture
|
|
22
|
+
sibling pattern (12 required artifacts, 5 machine-checkable invariants,
|
|
23
|
+
5 deterministic repairs). Consumed by `steward verify`; will be consumed
|
|
24
|
+
by the future `steward doctor`.
|
|
25
|
+
- `docs/skill-sources.md` — per-skill upstream declarations and vendoring
|
|
26
|
+
policy so `doctor` can vendor deterministically.
|
|
27
|
+
- `.claude/skills/doc-test-alignment/` — stub skill describing the intended
|
|
28
|
+
doc/test alignment workflow. Implementation TBD.
|
|
29
|
+
- `tests/test_skills_convention.py` — repo-level invariants for steward's own
|
|
30
|
+
skills (every skill has SKILL.md + scripts/, frontmatter name matches dir,
|
|
31
|
+
no per-user/home-dir paths in skill scripts).
|
|
32
|
+
- `tests/test_cli_verify.py` — end-to-end tests for the new verb, including a
|
|
33
|
+
dogfood test that runs `steward verify` against steward itself.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `CLAUDE.md` gains a "Roadmap (CLI surface)" section naming `verify` and
|
|
38
|
+
`doctor` as the next two verbs.
|
|
39
|
+
- `.markdownlint-cli2.yaml` header comment reworded to avoid tripping the
|
|
40
|
+
portability lint with its own self-reference (caught by the new
|
|
41
|
+
`tests/test_cli_verify.py` dogfood test on first run).
|
|
42
|
+
- `tests/test_cli.py` help-output assertion loosened to match individual
|
|
43
|
+
verb names instead of the literal `{show}` group, so adding verbs doesn't
|
|
44
|
+
break it.
|
|
45
|
+
|
|
8
46
|
## [0.1.2] - 2026-04-26
|
|
9
47
|
|
|
10
48
|
### Added
|
|
@@ -61,6 +61,30 @@ Per-machine paths (Culture server manifest location, sibling-project paths, etc.
|
|
|
61
61
|
|
|
62
62
|
Steward is a "skills supplier" for the Culture mesh. When a skill stabilizes here, the next step is propagating it to sibling projects (`culture`, `daria`, etc.) — the all-backends rule applied to skills.
|
|
63
63
|
|
|
64
|
+
## Roadmap (CLI surface)
|
|
65
|
+
|
|
66
|
+
The current CLI ships one verb (`steward show`). The next two verbs are
|
|
67
|
+
`verify` (read-only diagnosis against the AgentCulture sibling pattern) and
|
|
68
|
+
`doctor` (diagnose-and-fix; default dry-run, `--apply` to commit). Together
|
|
69
|
+
they encode steward's mission as code instead of prose:
|
|
70
|
+
|
|
71
|
+
- `steward verify <path>` — score a target repo against `docs/sibling-pattern.md`.
|
|
72
|
+
Aggregates findings across all selected checks, then exits non-zero if any
|
|
73
|
+
finding was reported. Human-readable findings go to stderr; `--json` emits
|
|
74
|
+
the structured findings list to stdout. The first cut runs steward's own
|
|
75
|
+
vendored `.claude/skills/pr-review/scripts/portability-lint.sh` against the
|
|
76
|
+
target (so the target doesn't need to vendor it), plus a skills-convention
|
|
77
|
+
check (every `SKILL.md` has a sibling `scripts/` entry-point).
|
|
78
|
+
- `steward doctor <path>` — repair what `verify` flagged, where the repair is
|
|
79
|
+
unambiguous (missing `scripts/` directory, missing `.markdownlint-cli2.yaml`,
|
|
80
|
+
missing `.claude/skills.local.yaml.example`, etc.). Larger emissions (CLI
|
|
81
|
+
scaffold) land later as additional repair handlers, eventually consuming
|
|
82
|
+
`../afi-cli/afi/cite/_engine.py` rather than re-implementing it.
|
|
83
|
+
|
|
84
|
+
Per-skill upstreams (which repo owns the canonical copy of `version-bump`,
|
|
85
|
+
`pr-review`, etc.) are recorded in `docs/skill-sources.md` so `doctor` can
|
|
86
|
+
vendor deterministically.
|
|
87
|
+
|
|
64
88
|
## Working with Culture from here
|
|
65
89
|
|
|
66
90
|
Steward will need to read or write Culture artifacts (agent definitions, server configs, mesh links). Useful entry points:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: steward-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Steward — aligns and maintains resident agents across Culture projects.
|
|
5
5
|
Project-URL: Homepage, https://github.com/agentculture/steward
|
|
6
6
|
Project-URL: Issues, https://github.com/agentculture/steward/issues
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# AgentCulture sibling pattern
|
|
2
|
+
|
|
3
|
+
The shape every AgentCulture sibling repo (`steward`, `cfafi`, `ghafi`, `daria`,
|
|
4
|
+
…) is expected to wear. This document is the single source of truth that
|
|
5
|
+
`steward verify` and `steward doctor` consume.
|
|
6
|
+
|
|
7
|
+
The companion file `sibling-pattern.json` (TBD; emit from this doc) is the
|
|
8
|
+
machine-readable form. Until it lands, the checks `verify` runs are hard-coded
|
|
9
|
+
in `steward/cli/_commands/verify.py`; this document remains the human-readable
|
|
10
|
+
contract that those hard-coded checks are expected to honor.
|
|
11
|
+
|
|
12
|
+
## Required artifacts
|
|
13
|
+
|
|
14
|
+
| # | Artifact | Path | Why |
|
|
15
|
+
|---|----------|------|-----|
|
|
16
|
+
| 1 | Toolchain | `pyproject.toml` (hatchling, Python ≥3.12, zero runtime deps where possible) | Uniform install/build/publish across the mesh. |
|
|
17
|
+
| 2 | Top-level package | `<pkg>/__init__.py`, `<pkg>/__main__.py` | `__version__` via `importlib.metadata`; `python -m <pkg>` works. |
|
|
18
|
+
| 3 | CLI scaffolding | `<pkg>/cli/__init__.py`, `cli/_errors.py`, `cli/_output.py`, `cli/_commands/` | The afi-cli pattern: structured errors, stdout/stderr split, `--json`. |
|
|
19
|
+
| 4 | Agent-first verbs | `cli/_commands/{learn,explain,whoami}.py` | `learn`/`explain` are the agent-affordance verbs; `whoami` is the smallest auth probe. |
|
|
20
|
+
| 5 | Mutation safety | Any write verb defaults to dry-run; `--apply` to commit | Agents call CLIs in loops; safe-by-default is mandatory. |
|
|
21
|
+
| 6 | Tests | `tests/test_cli_*.py`, pytest-xdist, coverage | CI gate; no untested verb ships. |
|
|
22
|
+
| 7 | CI | `.github/workflows/tests.yml`, `.github/workflows/publish.yml` | Tests + lint + version-check; PyPI/TestPyPI via Trusted Publishing. |
|
|
23
|
+
| 8 | Changelog | `CHANGELOG.md` (Keep-a-Changelog) | Bumped on every PR by the `version-bump` skill. |
|
|
24
|
+
| 9 | Skills | `.claude/skills/<name>/SKILL.md` + `scripts/` entry-point per skill | Convention: no external path deps, no per-user dotfile refs. |
|
|
25
|
+
| 10 | Per-machine config | `.claude/skills.local.yaml.example` (committed) + `.claude/skills.local.yaml` (git-ignored) | Skills read the local file, fall back to the example. |
|
|
26
|
+
| 11 | Lint configs | `.flake8`, `.markdownlint-cli2.yaml` (repo-local) | No reliance on per-user home-directory configs. |
|
|
27
|
+
| 12 | `CLAUDE.md` | Project shape, build/test/publish commands, conventions | What future Claude instances need that isn't discoverable from a 30-second `ls`. |
|
|
28
|
+
|
|
29
|
+
## Invariants (machine-checkable)
|
|
30
|
+
|
|
31
|
+
The full set of invariants the AgentCulture sibling pattern asserts. The
|
|
32
|
+
**Status** column reflects what is wired into `steward verify` *today*; items
|
|
33
|
+
marked `(planned)` are described here as the contract `verify` is expected to
|
|
34
|
+
grow into.
|
|
35
|
+
|
|
36
|
+
- **portability** *(implemented as `--check portability`)* — no
|
|
37
|
+
`/home/<user>/...` paths in tracked files; no `~/.<dotfile>` config refs in
|
|
38
|
+
committed `.md`/`.yaml`/`.toml`/`.json`/`.jsonc` outside the carve-outs
|
|
39
|
+
(`~/.claude/skills/.../scripts/`, `~/.culture/`).
|
|
40
|
+
*Source:* `.claude/skills/pr-review/scripts/portability-lint.sh`.
|
|
41
|
+
- **skills-convention** *(implemented as `--check skills-convention`)* —
|
|
42
|
+
every `.claude/skills/<name>/SKILL.md` has a sibling
|
|
43
|
+
`.claude/skills/<name>/scripts/` directory, **and** the SKILL.md frontmatter
|
|
44
|
+
`name` equals the directory name. (The "every skill has at least one
|
|
45
|
+
entry-point script" invariant is satisfied by the directory existing today
|
|
46
|
+
to keep the check noise-free; tightening to "directory has ≥1 file" is
|
|
47
|
+
*(planned)*.)
|
|
48
|
+
- **changelog-format** *(planned)* — `CHANGELOG.md` has at least one
|
|
49
|
+
`## [x.y.z] - YYYY-MM-DD` heading.
|
|
50
|
+
- **lint-config-local** *(planned)* — `.markdownlint-cli2.yaml` exists at the
|
|
51
|
+
repo root (no reliance on per-user home configs).
|
|
52
|
+
|
|
53
|
+
## Repairs (machine-fixable, run by `steward doctor`)
|
|
54
|
+
|
|
55
|
+
`steward doctor` is **not yet implemented** (see `CLAUDE.md`'s Roadmap
|
|
56
|
+
section); the table below is the contract it will honor when it lands. A
|
|
57
|
+
repair is included only if it is **deterministic and idempotent**. Where the
|
|
58
|
+
right answer depends on judgement, `doctor` will report the gap and stop.
|
|
59
|
+
|
|
60
|
+
| Invariant violated | Planned repair |
|
|
61
|
+
|--------------------|----------------|
|
|
62
|
+
| `.claude/skills/<name>/scripts/` missing | Create the empty directory + a stub entry-point script. |
|
|
63
|
+
| `.markdownlint-cli2.yaml` missing | Vendor steward's copy verbatim. |
|
|
64
|
+
| `.claude/skills.local.yaml.example` missing | Vendor a minimal template documenting the `culture_server_yaml` and `sibling_projects` keys. |
|
|
65
|
+
| `CHANGELOG.md` missing | Create a Keep-a-Changelog skeleton with one `## [Unreleased]` heading. |
|
|
66
|
+
| `SKILL.md` frontmatter `name` ≠ dir name | Reported only — too many false-positive renames to auto-correct. |
|
|
67
|
+
| Hard-coded `/home/...` path in tracked file | Reported only — fix requires understanding intent. |
|
|
68
|
+
|
|
69
|
+
## Skill upstream policy
|
|
70
|
+
|
|
71
|
+
Per-skill upstream declarations live in `docs/skill-sources.md`. `doctor`
|
|
72
|
+
consults that file when vendoring a skill into a target sibling: each skill
|
|
73
|
+
has exactly one canonical source repo, and `doctor` copies from there.
|
|
74
|
+
|
|
75
|
+
## Out of scope (for the pattern, not for steward)
|
|
76
|
+
|
|
77
|
+
- Pre-commit hooks (suggested but not required; siblings vary on this).
|
|
78
|
+
- Specific CI runners or Python versions beyond ≥3.12.
|
|
79
|
+
- Anything Culture-mesh-specific (server manifest, agent definitions) — that
|
|
80
|
+
belongs in `docs/` of the relevant Culture-side project, not in this pattern.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Skill upstream sources
|
|
2
|
+
|
|
3
|
+
Each skill has exactly one canonical source repo. `steward doctor` consults
|
|
4
|
+
this file when vendoring a skill into a target sibling so the choice is
|
|
5
|
+
deterministic.
|
|
6
|
+
|
|
7
|
+
When a skill exists in multiple repos, the **upstream** column wins. Other
|
|
8
|
+
repos are downstream copies that may lag and should periodically re-sync from
|
|
9
|
+
upstream.
|
|
10
|
+
|
|
11
|
+
| Skill | Upstream | Downstream copies (known) | Notes |
|
|
12
|
+
|-------|----------|---------------------------|-------|
|
|
13
|
+
| `version-bump` | `steward` (`.claude/skills/version-bump/`) | `cfafi`, `afi-cli` | Pure Python, prepends Keep-a-Changelog entry; no per-repo customization needed. |
|
|
14
|
+
| `pr-review` | `steward` (`.claude/skills/pr-review/`) | `cfafi` (variant) | Steward owns the canonical workflow; downstream copies may add reviewer-specific wiring (Qodo/Copilot, etc.). |
|
|
15
|
+
| `agent-config` | `steward` (`.claude/skills/agent-config/`) | — | Steward-specific (resolves Culture agent suffixes); not portable as-is. |
|
|
16
|
+
| `doc-test-alignment` | `steward` (`.claude/skills/doc-test-alignment/`) | — | Stub; real implementation TBD. |
|
|
17
|
+
| `cfafi`, `cfafi-write` | `cfafi` (`.claude/skills/cfafi*/`) | — | CloudFlare-specific; not vendored elsewhere. |
|
|
18
|
+
| `poll` | `cfafi` (`.claude/skills/poll/`) | — | Background-reviewer subagent; candidate for promotion to `steward` if it stabilizes. |
|
|
19
|
+
|
|
20
|
+
## Vendoring policy
|
|
21
|
+
|
|
22
|
+
- **Cite, don't import.** Skills are copied into the consuming repo, not
|
|
23
|
+
symlinked or installed as a dependency. Each consumer owns and may modify
|
|
24
|
+
their copy.
|
|
25
|
+
- **Re-sync explicitly.** When upstream changes, downstream copies do not
|
|
26
|
+
auto-update. `steward doctor --skill <name>` is the intended re-sync path
|
|
27
|
+
(TBD).
|
|
28
|
+
- **Diverge intentionally.** A downstream copy may diverge for repo-specific
|
|
29
|
+
reasons (e.g. `cfafi`'s `pr-review` adds CloudFlare-API reviewers). Record
|
|
30
|
+
the divergence in the downstream `SKILL.md`'s frontmatter `description`.
|
|
31
|
+
|
|
32
|
+
## When a skill should be promoted upstream
|
|
33
|
+
|
|
34
|
+
A skill currently owned downstream (e.g. `poll` in `cfafi`) should be promoted
|
|
35
|
+
to `steward` when:
|
|
36
|
+
|
|
37
|
+
1. At least one other sibling has copy-pasted it, OR
|
|
38
|
+
2. Its scripts have no repo-specific assumptions (no hard-coded API
|
|
39
|
+
credentials, no per-product paths), AND
|
|
40
|
+
3. Its `SKILL.md` describes a pattern (not a single product's workflow).
|
|
41
|
+
|
|
42
|
+
Promotion is a manual decision — `steward doctor` will not move skills
|
|
43
|
+
between repos.
|
|
@@ -33,6 +33,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
33
33
|
# Deferred import to avoid coupling the parser module to the command modules
|
|
34
34
|
# at import time (matches afi-cli's pattern; cheap insurance).
|
|
35
35
|
from steward.cli._commands import show as _show_cmd
|
|
36
|
+
from steward.cli._commands import verify as _verify_cmd
|
|
36
37
|
|
|
37
38
|
parser = _StewardArgumentParser(
|
|
38
39
|
prog="steward",
|
|
@@ -46,6 +47,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
46
47
|
sub = parser.add_subparsers(dest="command", parser_class=_StewardArgumentParser)
|
|
47
48
|
|
|
48
49
|
_show_cmd.register(sub)
|
|
50
|
+
_verify_cmd.register(sub)
|
|
49
51
|
|
|
50
52
|
return parser
|
|
51
53
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""``steward verify`` — read-only diagnosis of a sibling repo against the
|
|
2
|
+
AgentCulture sibling pattern (`docs/sibling-pattern.md`).
|
|
3
|
+
|
|
4
|
+
First cut: two checks — `portability` (delegates to steward's own vendored
|
|
5
|
+
`portability-lint.sh --all` run with the target as cwd) and `skills-convention`
|
|
6
|
+
(every `SKILL.md` has a sibling `scripts/` directory). All findings are
|
|
7
|
+
aggregated; the command exits non-zero if any check produced findings.
|
|
8
|
+
`--json` emits structured findings to stdout.
|
|
9
|
+
|
|
10
|
+
Future checks land here behind `--check <name>` flags. The full set of
|
|
11
|
+
invariants is enumerated in `docs/sibling-pattern.md` ("Invariants").
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json as json_mod
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from steward.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, StewardError
|
|
24
|
+
from steward.cli._output import emit_diagnostic, emit_result
|
|
25
|
+
|
|
26
|
+
FRONTMATTER_NAME_RE = re.compile(r"^name:\s*(\S+)\s*$", re.MULTILINE)
|
|
27
|
+
PORTABILITY_LINT_RELPATH = Path(".claude/skills/pr-review/scripts/portability-lint.sh")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Finding:
|
|
32
|
+
check: str
|
|
33
|
+
path: str
|
|
34
|
+
message: str
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, str]:
|
|
37
|
+
return {"check": self.check, "path": self.path, "message": self.message}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_target(raw: str) -> Path:
|
|
41
|
+
target = Path(raw).expanduser().resolve()
|
|
42
|
+
if not target.is_dir():
|
|
43
|
+
raise StewardError(
|
|
44
|
+
code=EXIT_USER_ERROR,
|
|
45
|
+
message=f"target is not a directory: {raw}",
|
|
46
|
+
remediation="pass a path to a sibling repo checkout",
|
|
47
|
+
)
|
|
48
|
+
return target
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
52
|
+
for directory in (start, *start.parents):
|
|
53
|
+
if (directory / ".git").exists():
|
|
54
|
+
return directory
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_steward_portability_lint() -> Path:
|
|
59
|
+
"""Locate steward's own vendored ``portability-lint.sh``.
|
|
60
|
+
|
|
61
|
+
Walks up from cwd, but **stops at the git repository boundary** (mirrors
|
|
62
|
+
the resolver in :mod:`steward.cli._commands.show`). Running steward's
|
|
63
|
+
own copy — instead of executing whatever script the *target* repo ships —
|
|
64
|
+
keeps ``verify`` to a fixed, known-trusted code surface.
|
|
65
|
+
"""
|
|
66
|
+
start = Path.cwd().resolve()
|
|
67
|
+
repo_root = _find_git_root(start)
|
|
68
|
+
|
|
69
|
+
current = start
|
|
70
|
+
while True:
|
|
71
|
+
candidate = current / PORTABILITY_LINT_RELPATH
|
|
72
|
+
if candidate.is_file():
|
|
73
|
+
return candidate
|
|
74
|
+
if current == repo_root or current.parent == current:
|
|
75
|
+
break
|
|
76
|
+
if repo_root is None:
|
|
77
|
+
break
|
|
78
|
+
current = current.parent
|
|
79
|
+
|
|
80
|
+
hint = f"run from inside a Steward git checkout that contains {PORTABILITY_LINT_RELPATH}"
|
|
81
|
+
raise StewardError(
|
|
82
|
+
code=EXIT_ENV_ERROR,
|
|
83
|
+
message="steward's portability-lint.sh not found",
|
|
84
|
+
remediation=hint,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_skills_convention(target: Path) -> list[Finding]:
|
|
89
|
+
"""Every `.claude/skills/<name>/SKILL.md` has a sibling `scripts/` dir,
|
|
90
|
+
and the SKILL.md frontmatter `name` matches the directory name."""
|
|
91
|
+
findings: list[Finding] = []
|
|
92
|
+
skills_dir = target / ".claude" / "skills"
|
|
93
|
+
if not skills_dir.is_dir():
|
|
94
|
+
return findings # No skills is fine; not every sibling has them yet.
|
|
95
|
+
for skill_dir in sorted(p for p in skills_dir.iterdir() if p.is_dir()):
|
|
96
|
+
skill_md = skill_dir / "SKILL.md"
|
|
97
|
+
if not skill_md.is_file():
|
|
98
|
+
findings.append(
|
|
99
|
+
Finding(
|
|
100
|
+
check="skills-convention",
|
|
101
|
+
path=str(skill_dir.relative_to(target)),
|
|
102
|
+
message="missing SKILL.md",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
if not (skill_dir / "scripts").is_dir():
|
|
107
|
+
findings.append(
|
|
108
|
+
Finding(
|
|
109
|
+
check="skills-convention",
|
|
110
|
+
path=str(skill_dir.relative_to(target)),
|
|
111
|
+
message="missing scripts/ directory",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
match = FRONTMATTER_NAME_RE.search(skill_md.read_text())
|
|
115
|
+
if not match:
|
|
116
|
+
findings.append(
|
|
117
|
+
Finding(
|
|
118
|
+
check="skills-convention",
|
|
119
|
+
path=str(skill_md.relative_to(target)),
|
|
120
|
+
message="no `name:` field in frontmatter",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
elif match.group(1) != skill_dir.name:
|
|
124
|
+
findings.append(
|
|
125
|
+
Finding(
|
|
126
|
+
check="skills-convention",
|
|
127
|
+
path=str(skill_md.relative_to(target)),
|
|
128
|
+
message=f"frontmatter name {match.group(1)!r} != dir {skill_dir.name!r}",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return findings
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _check_portability(target: Path) -> list[Finding]:
|
|
135
|
+
"""Run steward's own vendored ``portability-lint.sh --all`` against the
|
|
136
|
+
target's working tree.
|
|
137
|
+
|
|
138
|
+
The script is resolved from the steward checkout (not the target), then
|
|
139
|
+
invoked with ``cwd=target`` so its ``git ls-files`` lists target files.
|
|
140
|
+
This means ``verify`` works whether or not the target has vendored its
|
|
141
|
+
own copy of the lint, and limits subprocess execution to a known-trusted
|
|
142
|
+
script.
|
|
143
|
+
"""
|
|
144
|
+
script = _resolve_steward_portability_lint()
|
|
145
|
+
# bandit S603: argv is a fixed two-element list (resolved script path +
|
|
146
|
+
# literal "--all"); no shell, no expansion. Script path comes from
|
|
147
|
+
# _resolve_steward_portability_lint() which is constrained to the
|
|
148
|
+
# current git checkout, so an attacker can't substitute a different
|
|
149
|
+
# portability-lint.sh from an ancestor directory.
|
|
150
|
+
try:
|
|
151
|
+
completed = subprocess.run( # noqa: S603
|
|
152
|
+
[str(script), "--all"],
|
|
153
|
+
cwd=target,
|
|
154
|
+
check=False,
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
)
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
raise StewardError(
|
|
160
|
+
code=EXIT_ENV_ERROR,
|
|
161
|
+
message=f"could not execute {script}: {exc}",
|
|
162
|
+
remediation="ensure the script is executable (chmod +x)",
|
|
163
|
+
) from exc
|
|
164
|
+
if completed.returncode == 0:
|
|
165
|
+
return []
|
|
166
|
+
return [
|
|
167
|
+
Finding(
|
|
168
|
+
check="portability",
|
|
169
|
+
path=".",
|
|
170
|
+
message=(completed.stdout + completed.stderr).strip()
|
|
171
|
+
or f"portability-lint exited {completed.returncode}",
|
|
172
|
+
)
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
CHECKS = {
|
|
177
|
+
"skills-convention": _check_skills_convention,
|
|
178
|
+
"portability": _check_portability,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def register(sub: argparse._SubParsersAction) -> None:
|
|
183
|
+
parser = sub.add_parser(
|
|
184
|
+
"verify",
|
|
185
|
+
help="Diagnose a sibling repo against the AgentCulture sibling pattern.",
|
|
186
|
+
description=(
|
|
187
|
+
"Read-only diagnosis. Aggregates findings across selected checks, "
|
|
188
|
+
"then exits 0 if there are none and 1 if there are any. See "
|
|
189
|
+
"docs/sibling-pattern.md for the invariants."
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"target",
|
|
194
|
+
help="Path to a sibling repo directory.",
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--json",
|
|
198
|
+
action="store_true",
|
|
199
|
+
help="Emit findings as JSON to stdout instead of human-readable lines on stderr.",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--check",
|
|
203
|
+
action="append",
|
|
204
|
+
choices=sorted(CHECKS.keys()),
|
|
205
|
+
help="Run only the named check (repeatable). Default: run all checks.",
|
|
206
|
+
)
|
|
207
|
+
parser.set_defaults(func=_handle)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _handle(args: argparse.Namespace) -> int:
|
|
211
|
+
target = _resolve_target(args.target)
|
|
212
|
+
selected = args.check or sorted(CHECKS.keys())
|
|
213
|
+
findings: list[Finding] = []
|
|
214
|
+
for name in selected:
|
|
215
|
+
findings.extend(CHECKS[name](target))
|
|
216
|
+
|
|
217
|
+
if args.json:
|
|
218
|
+
# Structured output is the command's *result* — goes to stdout per
|
|
219
|
+
# the steward stdout/stderr split.
|
|
220
|
+
emit_result(json_mod.dumps([f.to_dict() for f in findings], indent=2))
|
|
221
|
+
elif findings:
|
|
222
|
+
# Human-readable findings are diagnostics — stderr by default.
|
|
223
|
+
for f in findings:
|
|
224
|
+
emit_diagnostic(f"{f.check}: {f.path}: {f.message}")
|
|
225
|
+
else:
|
|
226
|
+
emit_result(f"verify clean ({len(selected)} checks against {target})")
|
|
227
|
+
|
|
228
|
+
return 0 if not findings else 1
|
|
@@ -30,7 +30,9 @@ def test_no_args_prints_help_and_exits_zero(capsys: pytest.CaptureFixture[str])
|
|
|
30
30
|
assert rc == 0
|
|
31
31
|
captured = capsys.readouterr()
|
|
32
32
|
assert "usage: steward" in captured.out
|
|
33
|
-
|
|
33
|
+
# Subcommand list — match loosely so adding more verbs doesn't break this.
|
|
34
|
+
assert "show" in captured.out
|
|
35
|
+
assert "verify" in captured.out
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
def test_unknown_command_exits_with_user_error_code(
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""End-to-end tests for `steward verify`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from steward.cli import main
|
|
11
|
+
|
|
12
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_verify_against_steward_repo_passes(capsys: pytest.CaptureFixture[str]) -> None:
|
|
16
|
+
"""Steward should pass `steward verify` on itself.
|
|
17
|
+
|
|
18
|
+
This is the dog-food test: if steward can't verify steward, the pattern
|
|
19
|
+
isn't internally consistent.
|
|
20
|
+
"""
|
|
21
|
+
rc = main(["verify", str(REPO_ROOT)])
|
|
22
|
+
captured = capsys.readouterr()
|
|
23
|
+
assert rc == 0, f"verify failed:\n{captured.out}\n{captured.err}"
|
|
24
|
+
assert "verify clean" in captured.out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_verify_unknown_target_fails_user_error(
|
|
28
|
+
capsys: pytest.CaptureFixture[str],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""A non-directory target exits 1 with a structured error on stderr."""
|
|
31
|
+
rc = main(["verify", "/nonexistent/path/that/should/not/exist"])
|
|
32
|
+
captured = capsys.readouterr()
|
|
33
|
+
assert rc == 1
|
|
34
|
+
assert "error: target is not a directory" in captured.err
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_verify_json_output_is_parseable(
|
|
38
|
+
capsys: pytest.CaptureFixture[str],
|
|
39
|
+
) -> None:
|
|
40
|
+
"""`--json` emits a JSON list (empty when clean)."""
|
|
41
|
+
rc = main(["verify", "--json", str(REPO_ROOT)])
|
|
42
|
+
captured = capsys.readouterr()
|
|
43
|
+
assert rc == 0
|
|
44
|
+
parsed = json.loads(captured.out)
|
|
45
|
+
assert isinstance(parsed, list)
|
|
46
|
+
assert parsed == []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_verify_skills_convention_catches_missing_scripts(
|
|
50
|
+
tmp_path: Path,
|
|
51
|
+
capsys: pytest.CaptureFixture[str],
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Skill with SKILL.md but no scripts/ dir is reported on stderr."""
|
|
54
|
+
skill = tmp_path / ".claude" / "skills" / "broken"
|
|
55
|
+
skill.mkdir(parents=True)
|
|
56
|
+
(skill / "SKILL.md").write_text("---\nname: broken\ndescription: x\n---\n")
|
|
57
|
+
rc = main(["verify", "--check", "skills-convention", str(tmp_path)])
|
|
58
|
+
captured = capsys.readouterr()
|
|
59
|
+
assert rc == 1
|
|
60
|
+
# Findings are diagnostics → stderr, per the stdout/stderr split in
|
|
61
|
+
# steward.cli._output.
|
|
62
|
+
assert "missing scripts/ directory" in captured.err
|
|
63
|
+
assert captured.out == ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_verify_skills_convention_catches_name_mismatch(
|
|
67
|
+
tmp_path: Path,
|
|
68
|
+
capsys: pytest.CaptureFixture[str],
|
|
69
|
+
) -> None:
|
|
70
|
+
"""SKILL.md whose frontmatter name differs from the dir name is reported."""
|
|
71
|
+
skill = tmp_path / ".claude" / "skills" / "real-name"
|
|
72
|
+
(skill / "scripts").mkdir(parents=True)
|
|
73
|
+
(skill / "SKILL.md").write_text("---\nname: wrong-name\ndescription: x\n---\n")
|
|
74
|
+
rc = main(["verify", "--check", "skills-convention", str(tmp_path)])
|
|
75
|
+
captured = capsys.readouterr()
|
|
76
|
+
assert rc == 1
|
|
77
|
+
assert "frontmatter name 'wrong-name' != dir 'real-name'" in captured.err
|
|
78
|
+
assert captured.out == ""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Repo-level invariants for steward's own skills.
|
|
2
|
+
|
|
3
|
+
These are the same checks `steward verify` will run against any sibling repo,
|
|
4
|
+
applied here to steward itself so we eat our own dog food and CI catches
|
|
5
|
+
regressions before PR review.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
SKILLS_DIR = REPO_ROOT / ".claude" / "skills"
|
|
17
|
+
FRONTMATTER_NAME_RE = re.compile(r"^name:\s*(\S+)\s*$", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _skill_dirs() -> list[Path]:
|
|
21
|
+
return sorted(p for p in SKILLS_DIR.iterdir() if p.is_dir())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.parametrize("skill_dir", _skill_dirs(), ids=lambda p: p.name)
|
|
25
|
+
def test_skill_has_skill_md(skill_dir: Path) -> None:
|
|
26
|
+
"""Every skill directory ships a SKILL.md."""
|
|
27
|
+
assert (skill_dir / "SKILL.md").is_file(), f"missing SKILL.md in {skill_dir}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("skill_dir", _skill_dirs(), ids=lambda p: p.name)
|
|
31
|
+
def test_skill_has_scripts_directory(skill_dir: Path) -> None:
|
|
32
|
+
"""Every skill directory ships a `scripts/` directory.
|
|
33
|
+
|
|
34
|
+
Per the skills convention in CLAUDE.md: "Following the skill should be
|
|
35
|
+
'run this script,' not 'do these ten manual steps.'" An empty `scripts/`
|
|
36
|
+
(with `.gitkeep`) is acceptable for stub skills that document the
|
|
37
|
+
contract before the implementation lands.
|
|
38
|
+
"""
|
|
39
|
+
scripts = skill_dir / "scripts"
|
|
40
|
+
assert scripts.is_dir(), f"missing scripts/ in {skill_dir}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("skill_dir", _skill_dirs(), ids=lambda p: p.name)
|
|
44
|
+
def test_skill_frontmatter_name_matches_dir(skill_dir: Path) -> None:
|
|
45
|
+
"""SKILL.md frontmatter `name` equals the directory name."""
|
|
46
|
+
text = (skill_dir / "SKILL.md").read_text()
|
|
47
|
+
match = FRONTMATTER_NAME_RE.search(text)
|
|
48
|
+
assert match, f"no `name:` field in {skill_dir / 'SKILL.md'}"
|
|
49
|
+
assert (
|
|
50
|
+
match.group(1) == skill_dir.name
|
|
51
|
+
), f"SKILL.md name {match.group(1)!r} != dir {skill_dir.name!r}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_HOME_RE = re.compile(r"/home/[a-z][a-z0-9_-]+/")
|
|
55
|
+
# Match every per-user dotfile ref; carve_outs below allow specific shapes.
|
|
56
|
+
_DOTFILE_RE = re.compile(r"~/\.[A-Za-z][A-Za-z0-9_-]*")
|
|
57
|
+
_DOTFILE_CARVE_OUTS = (
|
|
58
|
+
re.compile(r"~/\.claude/skills/[^\s\"]+/scripts/"),
|
|
59
|
+
re.compile(r"~/\.culture/"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _scan_line_for_offenses(path: Path, lineno: int, line: str) -> list[str]:
|
|
64
|
+
offenses: list[str] = []
|
|
65
|
+
if _HOME_RE.search(line):
|
|
66
|
+
offenses.append(f"{path}:{lineno}: hard-coded /home/ path: {line.strip()}")
|
|
67
|
+
for hit in _DOTFILE_RE.finditer(line):
|
|
68
|
+
if any(c.match(line, hit.start()) for c in _DOTFILE_CARVE_OUTS):
|
|
69
|
+
continue
|
|
70
|
+
offenses.append(f"{path}:{lineno}: per-user dotfile ref: {line.strip()}")
|
|
71
|
+
return offenses
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_text_or_none(path: Path) -> str | None:
|
|
75
|
+
try:
|
|
76
|
+
return path.read_text()
|
|
77
|
+
except UnicodeDecodeError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _iter_script_files() -> list[Path]:
|
|
82
|
+
files: list[Path] = []
|
|
83
|
+
for skill_dir in _skill_dirs():
|
|
84
|
+
for path in (skill_dir / "scripts").rglob("*"):
|
|
85
|
+
if path.is_file():
|
|
86
|
+
files.append(path)
|
|
87
|
+
return files
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_no_per_user_paths_in_skill_scripts() -> None:
|
|
91
|
+
"""No `/home/<user>/...` or per-user `~/.dotfile` refs in skill scripts.
|
|
92
|
+
|
|
93
|
+
This is the same rule `portability-lint.sh` enforces on PR diffs, applied
|
|
94
|
+
here at the unit-test level so a single-file change can never reintroduce
|
|
95
|
+
a leak that's missed by the diff lint (e.g. a brand-new file added in a
|
|
96
|
+
commit but the lint only ran on a different range).
|
|
97
|
+
"""
|
|
98
|
+
offenders: list[str] = []
|
|
99
|
+
for path in _iter_script_files():
|
|
100
|
+
text = _read_text_or_none(path)
|
|
101
|
+
if text is None:
|
|
102
|
+
continue
|
|
103
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
104
|
+
offenders.extend(_scan_line_for_offenses(path, lineno, line))
|
|
105
|
+
|
|
106
|
+
assert not offenders, "skills/scripts portability violations:\n " + "\n ".join(offenders)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{steward_cli-0.1.2 → steward_cli-0.2.0}/.claude/skills/pr-review/scripts/portability-lint.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|