Skip to content

feat(skills): add an Agent Skills base for driving the client - #1

Merged
baraline merged 16 commits into
mainfrom
feat/agent-skills
Aug 3, 2026
Merged

baraline merged 16 commits into
mainfrom
feat/agent-skills

Conversation

@baraline

Copy link
Copy Markdown
Owner

Adds a skills/ tree at the repository root: Agent Skills that let an AI agent
drive easyvista_python_client correctly without reading its source. Mirrors
the layout of the sister package
baraline/glpi_python_client.

What ships

skills/README.md (an index table) plus eight skill directories, each holding
one SKILL.md with Agent Skills frontmatter:

Skill Covers
easyvista-client-setup Config, auth, from_env, both clients, lifecycle, the error hierarchy
easyvista-search-syntax The search grammar and its three failure modes
easyvista-ticket-workflow Ticket create/read/update/close, pagination, counting
easyvista-ticket-actions The per-ticket action log and its two traps
easyvista-document-workflow Attach, list and download ticket files
easyvista-asset-workflow Asset create/get/search/iterate
easyvista-directory Departments and employees, fuzzy lookup, memos
easyvista-reporting-and-context Statistics, aggregation, context bundles

The search grammar earns its own skill because it is the biggest source of
silently wrong results on this API: a condition EasyVista cannot honour is
dropped without an error, so the whole table comes back. That skill also
documents the two counter-intuitive neighbours — a broken quote returns zero
rows rather than everything, and a type mismatch on an int column raises HTTP
590 — and gives the baseline-count technique for proving a filter was actually
applied.

How it stays honest

scripts/tests/test_skills_contract.py (98 tests, offline, no credentials)
parses every SKILL.md and checks its claims against the real public API:
frontmatter shape and metadata.version against __version__, every imported
symbol against __all__, every client.<method>() against both client classes,
every keyword against the real signature, every write-model keyword against the
pydantic fields, synthetic-hosts-only, no references to gitignored paths, README
index completeness, and that every repo path and cross-referenced skill a
document names actually exists.

It runs in the existing CI test job — testpaths already includes scripts, so
no config change was needed. Its docstring states plainly what it does not
check (prose claims, attribute reads, positional arguments, required fields), so
a future contributor does not over-trust it.

Packaging

/skills is allowlisted into the sdist; the wheel is unaffected. The
build-audit CI job now asserts both directions — every SKILL.md on disk
appears in the sdist, and no skills/ path appears in the wheel — so neither a
deleted allowlist entry nor a leak into the installed package can pass silently.

Notes for review

  • Every factual claim in a skill is traceable to the package source or to a
    tracked file under integration_tests/. Where a claim could not be
    substantiated it is either cut or explicitly marked unconfirmed — the
    sort-token behaviour (open item O-DIR-1) is the one such case, and it says
    so rather than borrowing the confidence of the live-verified claims around it.
  • Instance-specific ids never appear as plausible-looking values. Each write
    skill opens with a discovery procedure that reads the real ids off the
    instance first.
  • No change to easyvista_python_client/ itself. Coverage is unchanged at
    1270 statements / 99.21%.

Verification

  • pytest -m "not integration" --strict-markers --strict-config — 600 passed, 87 deselected
  • ruff check . — clean
  • mypy easyvista_python_client — clean, 37 source files
  • python unasync_build.py --check — _sync/ up to date
  • pre-commit run --all-files — green
  • python -m build — 9 skills/ entries in the sdist, 0 in the wheel

🤖 Generated with Claude Code

baraline and others added 16 commits July 31, 2026 10:00
…setup skill

Agent Skills let an AI agent drive this client correctly without reading
its source, but prose documentation drifts silently from the API it
describes -- a renamed method or a dropped keyword breaks an agent months
later against a live instance, with no signal at review time. This adds
the drift gate first: scripts/tests/test_skills_contract.py imports the
installed package and re-checks every SKILL.md's frontmatter shape,
metadata.version against __version__, and every fenced python block
against ast.parse, entirely offline.

TDD: the test module was written and run before skills/ existed, and
failed exactly as expected (missing directory, missing README, zero
parametrized cases). skills/easyvista-client-setup/SKILL.md was added
second to turn it green.

skills/README.md is the index Tasks 3-9 will each add one row to. The
first skill, easyvista-client-setup, covers building EasyvistaConfig
(server/account/api_version, token vs login+password, from_env), the
sync/async client split, and the EasyvistaError hierarchy including the
590-is-really-a-400 status code.

One line of the brief's verbatim test code (the README-completeness
assertion) was 89 columns against this repo's 88-column ruff limit;
wrapped it across two lines with identical message text so `ruff check .`
stays clean without changing the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1 checked that each SKILL.md is well-formed (frontmatter shape,
parseable python blocks). It never checked that the code inside those
blocks would actually work: an imported name that got renamed, a keyword
argument dropped from a client method, a write-model field that no longer
exists -- none of that failed a test, only a live agent months later.

This extends scripts/tests/test_skills_contract.py with five snippet-level
checks, each parametrized over every skill directory exactly like Task 1's
tests: imports resolve against easyvista_python_client.__all__ (and never
reach into a private submodule); every client.<method>() call and its
keyword arguments exist on both EasyvistaClient and AsyncEasyvistaClient,
verified via inspect.signature; every PostRequest/PostAction/... call sets
only real pydantic fields; no skill references a gitignored path invisible
to a reader of the published repo; and every URL literal in a snippet sits
under example.com so nothing that looks like a real endpoint leaks in.

`inspect` was added to the top-level import block (alphabetical, next to
`ast`) rather than imported locally inside the test, so ruff's import
rules stay satisfied without a follow-up hoist.

All five new tests pass immediately against the one existing skill,
easyvista-client-setup -- it was written to satisfy them, so this is not a
red-then-green change. To prove the assertions actually bite, each was
verified against a deliberate violation injected into that skill's
SKILL.md (bad import, nonexistent method, bad keyword on a real method,
bad write-model field, a gitignored-path reference, a non-example.com
host) and reverted after confirming a named, diagnosable failure; `git
diff -- skills/` is empty, confirming no residue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ching gaps

Review of the snippet-contract tests found two real defects, both present
in the plan's verbatim code rather than introduced by transcription:

EasyvistaClient and AsyncEasyvistaClient are deliberately asymmetric on
exactly one pair -- unasync renames aclose to close when it generates the
sync client from the async source, so only the sync class has close()
and only the async class has aclose(). Requiring every called method to
exist on both classes meant the first skill to turn the setup skill's own
advice ("call client.close() / await client.aclose()") into a snippet
would fail for a method that is correct by design. test_client_methods_
and_keywords_exist now exempts only that named pair (_ASYMMETRIC_METHODS,
with a comment on why), requires it exist on at least one class, and
still runs the keyword check against whichever class owns it -- every
other method keeps the strict both-classes rule unchanged.

Three checks also passed vacuously on shapes they didn't match, checking
nothing without saying so: _client_calls only matched a bare `client`
name, so `ctx.client.foo()` was invisible; the write-model check only
matched a bare constructor name, so `ev.PostRequest(bogus=1)` skipped
validation; and test_imported_symbols_are_public only looked at `from X
import Y`, so `import easyvista_python_client as ev` followed by
`ev._private(...)` evaded both this check and the private-reference
substring check. Fixed by matching attribute-chain receivers and callees
(_is_client_expr, _write_model_name) and by forbidding any import of
easyvista_python_client itself, aliased or not, in favor of the
from-import form the plan already requires. Also added the positive-
coverage assertion test_client_methods_and_keywords_exist was missing:
every skill must make at least one client.<method>() call, mirroring
test_python_snippets_parse's existing non-empty check.

Every fix was verified by injecting the specific defect it closes into
the real easyvista-client-setup/SKILL.md (or, for the no-client-call
case, a throwaway scratch skill directory), confirming a named failure,
and reverting -- git diff -- skills/ is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EasyVista's server-side search grammar is genuinely counter-intuitive and
every failure mode but one is silent: `~` is exact-match rather than
"contains" (the vendor docs are wrong), a condition on an unknown field or an
unsearchable-but-returned column (the `*_PATH` display columns, the nested
`STATUS_*` sub-keys) is dropped with no error and the whole table comes back,
and only a genuine type mismatch (e.g. a non-int value on `STATUS_ID`) raises
loudly as HTTP 590. A broken quote is the one case that looks like it should
fall into the silent-ignore trap but does not — it still parses as a field
expression and matches nothing.

Because two of the three failure modes give no signal at all, every other
skill in this set that builds a `search=` argument needs to point here rather
than re-deriving these rules per skill. This is also why the skill leads with
a baseline-comparison pattern (`count_tickets()` before and after) as the
only reliable way to tell "the filter matched everything" apart from "the
filter was ignored".

Every claim is traceable to `integration_tests/test_live_search_syntax.py`,
which characterized this grammar against a live instance; the skill cites it
as the authority rather than restating vendor documentation known to be
wrong. Also adds the `easyvista-search-syntax` row to `skills/README.md`.

Verified with:
.\.venv\Scripts\python.exe -m pytest scripts/tests/test_skills_contract.py -v --no-cov
(22 passed)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code review on the search-syntax skill caught two places where confidence
outran evidence, both against the skill's own stated bar: the intro promises
that everything below was characterized by
integration_tests/test_live_search_syntax.py, and that file is silent on
`sort` entirely and never actually searches STATUS_GUID.

- The Gotchas bullet claiming an unknown `sort` token is ignored rather than
  rejected was stated as flat fact alongside eight genuinely live-verified
  bullets, with no way for a reader to tell them apart. Its only basis in
  this repo is the open item O-DIR-1 comment in
  easyvista_python_client/directory.py (RECENT_TICKETS_SORT), which itself
  says the behavior is "not yet live-confirmed." Reworded to say so
  explicitly and to cite directory.py and O-DIR-1, without hedging the
  claims around it that are genuinely tested.

- The nested-sub-key parenthetical in "What is searchable" listed
  STATUS_GUID alongside STATUS_EN/STATUS_FR as silently dropped.
  test_a_nested_reference_subkey_is_not_searchable (line 579) explicitly
  excludes STATUS_GUID from its candidate pool and never searches on it, so
  no executed assertion covers it. Dropped it from the parenthetical rather
  than keep an untested claim next to tested ones.

Verified with:
.\.venv\Scripts\python.exe -m pytest scripts/tests/test_skills_contract.py -v --no-cov
(22 passed)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tickets are the operation an agent will reach for most, so this is Task 4
of the agent-skills build-out: create/read/search/paginate/update/close
via PostRequest, Request, RequestUpdate and the requests-resource client
methods. It establishes the "discover the ids first" pattern (read a real
ticket, pull ids off reference()/classify_fields(), then write) that the
asset, department and employee skills will reuse rather than re-deriving.

Every non-obvious claim was checked against source before being written
down, not copied from the brief on faith:
- create_ticket's HREF-only response deriving a usable rfc_number is
  backed by Request._derive_rfc_from_href's model validator (models/
  request.py) and by integration_tests/test_live_ticket_identity.py's
  test_rfc_number_is_derived_from_the_create_response_href, which asserts
  it against a real create response, not just a synthetic one.
- RequestUpdate(description=...) writing COMMENT rather than DESCRIPTION
  is stated verbatim in RequestUpdate's own docstring, and cross-checked
  against the create-time caveat on PostRequest.description.
- The one brief claim that could not be traced to package source or
  integration_tests/ (specific punctuation triggering a 590) was kept but
  demoted to a hedged "lead, not a rule," sourced explicitly to the
  content-fidelity probe script's defensive comment rather than stated
  with the same confidence as the verified claims.

scripts/tests/test_skills_contract.py passes all 32 cases (3 skills x the
per-skill checks, plus the 2 whole-suite ones), confirming every method/
keyword/import/write-model-field claim against the installed package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code review flagged the punctuation gotcha in the ticket-workflow skill as
"Needs fixes": it opened with "has produced a 590 in ad hoc content
probing," indicative past tense in the same voice the file uses for its
verified claims. Re-reading scripts/validate_live_content_fidelity.py:340-341
directly confirms the source is a precaution ("would abort the whole run"),
not a record of an observed rejection, and "ad hoc content probing" appears
nowhere in the tracked repository -- it was invented framing that lent the
claim borrowed empirical weight.

Rewrote the bullet so every clause is traceable to the script: what it does
(keeps its create payload plain ASCII, avoiding certain punctuation), why
(quoted from its own comment, not paraphrased upward), and an explicit
statement that no tracked test has actually observed the rejection it
guards against. Kept the bullet rather than cutting it, since the
underlying precaution is real and low-cost to pass on to a caller
debugging an unexpected 590 -- the fix corrects the confidence level, not
the presence, of the claim. No other bullet changed; the claims already
verified as accurate (HREF-only create/rfc_number derivation,
COMMENT-vs-DESCRIPTION, sequential create_tickets, 590/code-2013) are
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Actions are EasyVista's per-ticket work log, and the surface has two
genuine traps an agent needs spelled out before it writes one: a
created action's id is not recoverable from the create response (the
returned HREF names the parent request, not the action -- verified in
easyvista_python_client/models/action.py's href-derivation validator
and exercised live in integration_tests/test_live_ticket_history.py),
and list_actions never returns note text at all -- that only comes
back through get_action's DESCRIPTION memo href, which the caller must
resolve itself or via get_ticket_context.

Adds skills/easyvista-ticket-actions/SKILL.md: the discover-ids-first
pattern for action_type_id/group_id, the create-then-diff-list_actions
idiom (which Task 9's reporting-and-context skill will reference), and
a get_action/resolve_memo example for reading a note back. Adds the
matching row to skills/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents the ticket-scoped attachment surface (add_document,
list_documents, download_document) so an agent can upload, list and
fetch attachments without reading resources/documents.py or the
transport's redirect/host-check logic. Every gotcha here traces to
source: the ValueError from download_href() when no DDL_HREF/HREF is
set, the EasyvistaError from resolve_url() when a download URL points
off-instance (guarding against leaking the Bearer token to a foreign
redirect target, since httpx drops Authorization cross-origin), the
403-to-EasyvistaAuthError mapping shared between the JSON and binary
transport paths, and the filename fallback chain in models/document.py.
Adds the corresponding row to skills/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assets are the last per-resource CRUD-ish skill in the base set: register,
fetch, search and iterate equipment/CI records with create_asset, get_asset,
search_assets and iter_assets. Follows the "discover the ids first" pattern
established by easyvista-ticket-workflow and cross-references
easyvista-search-syntax for the search grammar instead of restating it.

Two claims from the task brief did not hold up against the source and were
adjusted rather than copied verbatim:

- The discovery snippet originally called asset.reference("CATALOG"). Asset
  (easyvista_python_client/models/asset.py) declares only asset_id,
  asset_tag, serial_number, status_id and href -- no catalog field, nested
  or otherwise -- so reference() has nothing to resolve a catalog id
  against, unlike Request's declared *_ID fields. The snippet now prints
  asset.classify_fields().official so a reader can find whatever raw key
  their instance actually uses.
- The profile-gated write claim ("commonly profile-gated") is softened: the
  403 -> EasyvistaAuthError mapping is generic and verified
  (easyvista_python_client/exceptions.py), but no tracked test performs a
  live asset create, so how commonly that specific restriction applies is
  not something this repository has verified.

The catalog_id-is-int-but-get_asset-takes-str asymmetry and the "no
update/delete" claim were confirmed directly against
easyvista_python_client/models/asset.py and the sync/async client method
lists, and kept as stated.

scripts/tests/test_skills_contract.py passes for all 6 skills (62 cases).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the asset-workflow skill (Task 7) came back "Needs fixes" with two
Important findings and one Minor, all in the discovery-section replacement I
wrote for the brief's unsupportable reference("CATALOG") call -- the two
deliberate brief deviations themselves (dropping reference("CATALOG"),
softening the profile-gating claim) were judged correct.

- The discovery snippet printed sorted(asset.classify_fields().official),
  which sorts a dict and so yields keys only -- never a value -- while the
  prose told the reader to "read its id from there." Since catalog_id is
  required on PostAsset, a reader following the recipe literally could never
  obtain it. Now prints the buckets themselves (official and links) so both
  keys and values are visible, and the prose matches what the code actually
  prints.

- The stated reason reference() couldn't help ("Asset declares no catalog
  field, so there is nothing to resolve against") was a wrong description of
  the mechanism: reference() resolves off model_dump(by_alias=True), and
  extra="allow" folds undeclared raw API keys into that dump exactly like
  declared ones (models/common.py:37-42). easyvista-ticket-workflow's own
  reference("STATUS") / reference("CATALOG_REQUEST") calls prove this --
  neither name is a field Request declares (models/request.py:19-72).
  Reworded to the real, narrower reason: no tracked source confirms an
  AM_ASSET payload carries any CATALOG-shaped key at all, declared or not.

- Added a check of classify_fields().links alongside .official, since an
  href-only catalog sub-resource would land there instead
  (field_model.py:47-50) and be invisible to the .official-only recipe.

scripts/tests/test_skills_contract.py still passes all 62 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Departments and employees are read-well-trodden but write-provisional:
no profile authorized for directory writes was available when the client
was built, so PostDepartment/DepartmentUpdate/PostEmployee/EmployeeUpdate
field sets are documented as a best guess rather than a verified contract.
The skill leads with find_departments' two-path resolution (exact id/code
fast path, client-side fuzzy scan otherwise, with the quote-character
carve-out that skips the fast path instead of raising) since resolving a
department by a human-typed name is the main reason this skill exists, and
calls out get_department_comment's None-vs-raise distinction, the
DEPARTMENT_PATH/DEPARTMENT_FR searchability asymmetry, and E_MAIL's status
as a declared official field so classify_fields() never misfiles it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the skills/ index: count_tickets, ticket_statistics and
aggregate_tickets for counts/breakdowns, plus get_ticket_context and
get_department_context for one-call bundles that degrade around profile
restrictions instead of failing. Every claim was re-verified against
easyvista_python_client/reporting.py, context.py, directory.py and
_async/client.py rather than trusted from the task brief as-is:

- ticket_statistics' max_records=100 default and truncation behavior
  (client.py) match the brief.
- The asymmetric degradation rules are real and were read directly, not
  assumed: get_ticket_context's two memos go through _safe_memo (catches
  EasyvistaNotFound and EasyvistaAuthError) while its actions/documents
  lists catch EasyvistaAuthError only; get_department_context's seven
  branches all catch both.
- aggregate_tickets sums to total per dimension and groups unresolvable
  labels under "(unknown)" (reporting.py); html_to_text only ever emits
  text-node data, confirming to_markdown's href-free guarantee at the
  parser level, not just by inspection of the assembled document.
- The recent_tickets descending-sort claim (RECENT_TICKETS_SORT,
  directory.py, open item O-DIR-1) is hedged to match the wording
  already used by easyvista-search-syntax, rather than restated as
  settled fact.

Links to easyvista-ticket-actions (the list_actions diff idiom, the
resolve_action_bodies flag name) and easyvista-directory
(find_departments, get_department_comment) instead of restating them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 1-9 built a complete skills/ tree (README plus eight SKILL.md
directories) but never wired it into the package build, so it would have
silently never shipped: the sdist manifest in
[tool.hatch.build.targets.sdist] is an explicit allowlist (adopted after an
earlier default-inclusion regime leaked local agent state into an artifact),
and anything not named there is dropped regardless of what git tracks.

Add "/skills" to that allowlist, and give the build-audit CI job a two-
directional assertion mirroring its existing _sync/-tree check: skills/
must be present in the sdist and absent from the wheel, so a future PR that
deletes the allowlist entry (skills silently stop shipping) or accidentally
widens the wheel's package boundary (skills leak into an installed package)
both fail CI instead of surfacing after a PyPI upload, which cannot be taken
back. Verified both failure modes fire by temporarily reverting the
allowlist entry and separately force-including skills into the wheel,
rebuilding each time, and confirming the assertion raises.

Point README, CONTRIBUTING and CHANGELOG at the finished tree so it is
discoverable and its maintenance obligations (SKILL.md updates on API
changes, version bumps on release, the scripts/tests/test_skills_contract.py
gate) are documented alongside the rest of the contributor guidance.

Full verification: ruff/mypy/unasync --check clean, 584 unit tests pass with
coverage unchanged at 1270 statements / 99.21%, pre-commit run --all-files
green (5/5 hooks), and python -m build confirmed by hand: 9 skills/ entries
in the sdist (66 total), 0 in the wheel (42 total).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndex

A final whole-branch review, reading all eight skills together, found defects
that per-skill review could not see. This is the prose half of the fix wave.

The download guard was actively wrong. resources/documents.py resolves the URL
as `document.download_href or document.href`, but three places in
easyvista-document-workflow encoded the rule that DDL_HREF alone decides: the
third example skipped any attachment whose download_href was unset, the Gotcha
said to check download_href first, and the model section described
download_href as *the* direct-download URL. An agent copying that example
silently dropped attachments the client would have fetched through HREF -- the
one defect here that produces wrong code rather than merely unhelpful prose.
All three now state the fallback, and the Gotcha says the ValueError fires only
when neither field is set.

Three authoring directives had leaked out of the plan into shipped skill
bodies, where an agent loads them as instructions. The one telling the reader
to state a case explicitly mattered most: a second-person imperative sitting
inside an agent-facing document is plausibly parseable as something the agent
should do. The facts are kept, the imperatives are gone, and ticket-actions'
placeholder note now matches the shape its two peers already use. A sweep of
all eight files for further leaks of the same shape found none -- the five
remaining imperatives are Procedure steps addressed to the agent doing the API
work.

Two index rows routed agents to skills that never covered the promised names.
SearchResult was the only __all__ entry no skill named at all, and
Reference/FieldClassification appeared in easyvista-directory's frontmatter but
never in its body -- they are taught in easyvista-ticket-workflow. Rather than
duplicate the teaching, each name now sits on the row of the skill that owns
it, ticket-workflow names SearchResult where it already returns one, and
directory gets the return types it was telling readers to call without ever
describing, plus a hand-off.

easyvista-directory was also the only search-using skill with no pointer to
easyvista-search-syntax despite building filters in two snippets, and it
restated two grammar facts search-syntax owns. Two copies of a fact with no
gate keeping them in sync is a drift source even while they agree, so the
general half is dropped and the department-specific half kept.

Finally, search-syntax's intro claimed everything below was characterized
against a live instance, which its own sort Gotcha correctly contradicts --
sort appears zero times in that suite. The intro is what licenses an agent to
trust the rest of the file unhedged, so it now carves out the exemption in the
same words the hedged bullet uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lind spots

The contract gate reads as though it checks the skills' claims. It does not:
it is a name-and-keyword gate over python code blocks, and every prose claim --
the Configuration and Errors tables, every Procedure step, every Gotcha -- is
unverified text. Attribute reads pass (ticket.rfc_numbr would too), positional
arguments and arity are unchecked, and nothing is ever instantiated, so
PostAsset(catalog_id="1") passes an int field. The docstring now says so, in a
"What this does not check" section, because a future contributor reading a
green run deserves to know what green means here.

Two blind spots were cheap enough to close rather than document.

Five skills cite a repo path as their evidence -- the live-suite modules they
were characterized against, the module whose sort token they hedge. A rename
turns all five into dead links that nothing notices, because no import or tool
reads a Markdown citation. The same is true of the cross-references that make
the eight skills a graph rather than eight documents: delegating the grammar to
easyvista-search-syntax breaks silently if that directory is renamed.

Both extractions are deliberately conservative, because a false failure here
would block a correct commit over prose -- worse than missing a dead link. A
path candidate must be an entire inline-code span, must contain a slash, and
must end in a known source extension; that rejects the five route templates and
URL fragments already in the skills (`/api`, `{server}/api/{api_version}/...`,
`requests/{rfc}/comment`) and a bare `SKILL.md` written about documents in
general. A pytest node-id suffix is stripped before the existence check. The
cost -- a broken bare-filename reference goes unnoticed -- is stated in the
comment. Cross-references exempt the distribution name, which shares the
easyvista- prefix with every skill name but is not one.

Both assertions were proved to fail: an injected nonexistent path and an
injected nonexistent skill each failed, naming the offending skill and the
offending reference.

_PY_BLOCK now accepts ```py as well as ```python. The two tags render
identically, so a block tagged the other way would have skipped every snippet
check with no visible difference in the document.

_UNPUBLISHED gains scripts/probe_ (the prefix form, since the check is a
substring test and the .gitignore glob would match nothing -- and it
deliberately does not catch the tracked validate_live_content_fidelity.py that
a skill cites), plus .claude/ and .superpowers/, which sit in the same
"local agent/tooling state" .gitignore block. The remaining ignores are build
artifacts, not documents a skill would cite as evidence.

Finally, the CI sdist check asserted only that skills/README.md ships, which
would pass on an sdist that shipped the index and none of the eight skills it
indexes. It now also counts SKILL.md entries against the directories on disk.
Both assertions are kept: the count alone would pass on an sdist that dropped
the index instead. Verified against a real locally built sdist (8 of 8) and
against synthetic manifests for the index-only and one-skill-missing
regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@baraline
baraline merged commit ce382a0 into main Aug 3, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant