diff --git a/.ai/.claude-plugin/plugin.json b/.ai/.claude-plugin/plugin.json new file mode 100644 index 000000000000..6195e465f66b --- /dev/null +++ b/.ai/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "diffusers", + "description": "Conventions and task skills for contributing to diffusers", + "author": { + "name": "The Hugging Face team" + }, + "homepage": "https://huggingface.co/docs/diffusers/main/en/conceptual/contribution#coding-with-ai-agents", + "repository": "https://github.com/huggingface/diffusers", + "license": "Apache-2.0", + "keywords": [ + "diffusers", + "diffusion-models", + "contributing" + ] +} diff --git a/.ai/.codex-plugin/plugin.json b/.ai/.codex-plugin/plugin.json new file mode 100644 index 000000000000..ac038c440f77 --- /dev/null +++ b/.ai/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "diffusers", + "version": "0.1.0", + "description": "Conventions and task skills for contributing to diffusers", + "skills": "./skills/" +} diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index ae611feb4bb6..311061ee5eb1 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -2,8 +2,44 @@ ## Setup -- Local Claude Code agents: run `make claude` after cloning to wire the [skills](#skills) under `.claude/`. -- Local OpenAI Codex agents: run `make codex` after cloning to wire the [skills](#skills) under `.agents/`. +We recommend developing in a virtual environment managed by [uv](https://docs.astral.sh/uv/): + +```bash +uv venv && source .venv/bin/activate +uv pip install -e . # provides diffusers-cli +``` + +List the available skills, and what each one is for, with: + +```bash +diffusers-cli skills list +``` + +Install them with: + +```bash +diffusers-cli skills add # install one, or --all for every skill + # --claude / --codex / --cursor to pick one target +``` + +`diffusers-cli skills update` refreshes what you installed. + +`make claude` and `make codex` used to symlink `.claude/skills` and `.agents/skills` at `.ai/skills`. If those symlinks +are still around, remove them before installing — otherwise the install writes through them into `.ai/` itself: + +```bash +rm .claude/skills .agents/skills +``` + +Claude Code and Codex can also install via plugins + +```bash +claude plugin marketplace add huggingface/diffusers +claude plugin install diffusers@diffusers-skills --scope project + +codex plugin marketplace add huggingface/diffusers # then install from the Plugins Directory +``` + ## Coding style @@ -27,10 +63,10 @@ Strive to write code as simple and explicit as possible. ## Reference guides -- **Models** — see [models.md](models.md) for model conventions, attention pattern, implementation rules, dependencies, and gotchas. For adding or converting a model, use the [model-integration](./skills/model-integration/SKILL.md) skill. -- **Pipelines** — see [pipelines.md](pipelines.md) for pipeline conventions, patterns, and gotchas. -- **Modular pipelines** — see [modular.md](modular.md) for modular pipeline conventions, patterns, and gotchas. -- **Tests** — see [testing.md](testing.md) for test conventions: required test layers, tester mixins, and dummy-component rules. +- **Models** — see [models.md](references/models.md) for model conventions, attention pattern, implementation rules, dependencies, and gotchas. For adding or converting a model, use the [model-integration](./skills/model-integration/SKILL.md) skill. +- **Pipelines** — see [pipelines.md](references/pipelines.md) for pipeline conventions, patterns, and gotchas. +- **Modular pipelines** — see [modular.md](references/modular.md) for modular pipeline conventions, patterns, and gotchas. +- **Tests** — see [testing.md](references/testing.md) for test conventions: required test layers, tester mixins, and dummy-component rules. ## Skills @@ -43,4 +79,4 @@ Task-specific guides live in `.ai/skills/` and are loaded on demand by AI agents ## Self-review before a PR -Before opening a PR, run self-review against [review-rules.md](review-rules.md). The [self-review skill](skills/self-review/SKILL.md) runs this as the same pass the `@claude` CI reviewer uses. Share the final report on the PR (description or comment) — see the skill for details. +Before opening a PR, run self-review against [review-rules.md](references/review-rules.md). The [self-review skill](./skills/self-review/SKILL.md) runs this as the same pass the `@claude` CI reviewer uses. Share the final report on the PR (description or comment) — see the skill for details. diff --git a/.ai/plugin.json b/.ai/plugin.json new file mode 100644 index 000000000000..93c863f7931f --- /dev/null +++ b/.ai/plugin.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "diffusers", + "description": "Conventions and task skills for contributing to diffusers", + "author": { + "name": "The Hugging Face team" + }, + "homepage": "https://huggingface.co/docs/diffusers/main/en/conceptual/contribution#coding-with-ai-agents", + "repository": "https://github.com/huggingface/diffusers", + "license": "Apache-2.0", + "keywords": [ + "diffusers", + "diffusion-models", + "contributing" + ] +} diff --git a/.ai/models.md b/.ai/references/models.md similarity index 99% rename from .ai/models.md rename to .ai/references/models.md index bdc6464c9aec..01396a8ca8fb 100644 --- a/.ai/models.md +++ b/.ai/references/models.md @@ -1,7 +1,6 @@ # Model conventions and rules Shared reference for model-related conventions, patterns, and gotchas. -Linked from `AGENTS.md`, `skills/model-integration/SKILL.md`, and `review-rules.md`. ## Coding style diff --git a/.ai/modular.md b/.ai/references/modular.md similarity index 98% rename from .ai/modular.md rename to .ai/references/modular.md index b68129d08536..bcbd501d5ab7 100644 --- a/.ai/modular.md +++ b/.ai/references/modular.md @@ -211,7 +211,7 @@ Prefer flat sequences over nested compositions. Put the `Auto` / `Conditional` s ## InputParam / OutputParam -Use `.template("")` for params with a canonical meaning (`prompt`, `negative_prompt`, `image`, `generator`, `num_inference_steps`, `latents`, `prompt_embeds`, `images`, `videos`, etc.) — the template carries a vetted description and type hint. The full registry lives in [`src/diffusers/modular_pipelines/modular_pipeline_utils.py`](../src/diffusers/modular_pipelines/modular_pipeline_utils.py) (`INPUT_PARAM_TEMPLATES`, `OUTPUT_PARAM_TEMPLATES`); read that file rather than relying on a hardcoded list here, since names get added. +Use `.template("")` for params with a canonical meaning (`prompt`, `negative_prompt`, `image`, `generator`, `num_inference_steps`, `latents`, `prompt_embeds`, `images`, `videos`, etc.) — the template carries a vetted description and type hint. The full registry lives in [`src/diffusers/modular_pipelines/modular_pipeline_utils.py`](https://github.com/huggingface/diffusers/blob/main/src/diffusers/modular_pipelines/modular_pipeline_utils.py) (`INPUT_PARAM_TEMPLATES`, `OUTPUT_PARAM_TEMPLATES`); read that file rather than relying on a hardcoded list here, since names get added. For params that don't match a template (model-specific names, custom semantics), declare the field directly: diff --git a/.ai/pipelines.md b/.ai/references/pipelines.md similarity index 98% rename from .ai/pipelines.md rename to .ai/references/pipelines.md index 7384808a1cda..fdf6b56ad862 100644 --- a/.ai/pipelines.md +++ b/.ai/references/pipelines.md @@ -1,7 +1,6 @@ # Pipeline conventions and rules Shared reference for pipeline-related conventions, patterns, and gotchas. -Linked from `AGENTS.md`, `skills/model-integration/SKILL.md`, and `review-rules.md`. > **Prefer modular for new pipelines.** [Modular Diffusers](modular.md) is the preferred way to add a new pipeline; the standard `DiffusionPipeline` covered below is still supported but is no longer the default. We prefer modular especially for models that don't fit a fixed task-based structure (e.g. modality baked into the checkpoint) or that are actively evolving. The conventions below apply when you do build or review a standard pipeline. @@ -71,7 +70,7 @@ src/diffusers/pipelines// 4. **Subclassing an existing pipeline for a variant.** Don't use an existing pipeline class (e.g. `FluxPipeline`) to override another (e.g. `FluxImg2ImgPipeline`) inside the core `src/` codebase. Each pipeline lives in its own file with its own class, even if it shares 90% of `__call__` with a sibling. Convention across diffusers — flux, sdxl, wan, qwenimage — is duplicated `__call__` between img2img / text2img / inpaint variants, not subclassing. Reuse private utilities (shared schedulers, prep functions) but not the pipeline class itself. -5. **Copying a method from another pipeline without `# Copied from`.** When you reuse a method like `encode_prompt`, `prepare_latents`, `check_inputs`, or `_prepare_latent_image_ids` from another pipeline, add a `# Copied from` annotation so `make fix-copies` keeps the two in sync. Forgetting it means future refactors to the source drift away from your copy silently — and reviewers waste time spotting near-identical code that should have been linked. The annotation grammar (decorator placement, rename syntax with `with old->new`, etc.) is implemented in [`utils/check_copies.py`](../utils/check_copies.py) — read it for the exact rules. +5. **Copying a method from another pipeline without `# Copied from`.** When you reuse a method like `encode_prompt`, `prepare_latents`, `check_inputs`, or `_prepare_latent_image_ids` from another pipeline, add a `# Copied from` annotation so `make fix-copies` keeps the two in sync. Forgetting it means future refactors to the source drift away from your copy silently — and reviewers waste time spotting near-identical code that should have been linked. The annotation grammar (decorator placement, rename syntax with `with old->new`, etc.) is implemented in [`utils/check_copies.py`](https://github.com/huggingface/diffusers/blob/main/utils/check_copies.py) — read it for the exact rules. 6. **Be deliberate about methods on the pipeline.** `__call__` is the user's mental model. The methods on the class are how they navigate it. Diffusers convention (flux, sdxl, wan, qwenimage) is a flat class body of public lifecycle methods (`__init__`, `check_inputs`, `encode_prompt`, `prepare_latents`, `__call__`). Two principles, not strict rules — use judgment: - **If a method is called from `__call__`, and it's a step in the pipeline lifecycle, make it public.** Each call from `__call__` should correspond to a step a user can identify: either a standard one (`encode_prompt`, `prepare_latents`, `set_timesteps`, …) or a pipeline-specific one (`prepare_src_latents`, `prepare_reference_audio_latents`, …). Don't gate these behind a `_`; they're part of the pipeline's API surface alongside their standard siblings. diff --git a/.ai/review-rules.md b/.ai/references/review-rules.md similarity index 77% rename from .ai/review-rules.md rename to .ai/references/review-rules.md index f554e4e5caaf..03a2a9d972b4 100644 --- a/.ai/review-rules.md +++ b/.ai/references/review-rules.md @@ -3,16 +3,16 @@ Review-specific rules for Claude. Focus on correctness — style is handled by ruff. Before reviewing, read and apply the guidelines in: -- [AGENTS.md](AGENTS.md) — coding style, copied code +- the repo's `AGENTS.md` — coding style, copied code - [models.md](models.md) — model conventions, attention pattern, implementation rules, dependencies, gotchas - [pipelines.md](pipelines.md) — pipeline conventions, coding style, gotchas - [modular.md](modular.md) — modular pipeline conventions, patterns, common mistakes - [testing.md](testing.md) — test conventions: required test layers, tester mixins, dummy-component rules. When a PR adds or changes tests, check them against this guide. -- [skills/model-integration/pitfalls.md](skills/model-integration/pitfalls.md) — known pitfalls causing numerical discrepancies between the reference implementation and the diffusers port (dtype mismatches, config assumptions, etc.) +- [skills/model-integration/pitfalls.md](https://github.com/huggingface/diffusers/blob/main/.ai/skills/model-integration/pitfalls.md) — known pitfalls causing numerical discrepancies between the reference implementation and the diffusers port (dtype mismatches, config assumptions, etc.) ## Common mistakes -Common mistakes are covered in the common-mistakes / gotcha sections in [AGENTS.md](AGENTS.md), [models.md](models.md), [pipelines.md](pipelines.md), and [modular.md](modular.md). Additionally, watch for below patterns that aren't covered there: +Common mistakes are covered in the common-mistakes / gotcha sections in `AGENTS.md`, [models.md](models.md), [pipelines.md](pipelines.md), and [modular.md](modular.md). Additionally, watch for below patterns that aren't covered there: - **Ephemeral context.** Comments, docstrings, and files that only made sense to the current PR's author or reviewer don't help a future reader/user/developer. Examples: `# per reviewer comment on PR #NNNN`, `# as discussed in review`, `# TODO from offline chat`, debug printouts. Same for files: parity harnesses, comparison scripts, anything in `scripts/` with hardcoded developer paths or imports from the reference repo. State the *reason* so the comment stands alone, or drop it. @@ -21,7 +21,7 @@ Common mistakes are covered in the common-mistakes / gotcha sections in [AGENTS. A PR can leave existing docs stale or surface a pattern worth recording. Scan the docs related to what the PR touches and flag updates as a **suggestions / additional info** section (not blocking): - **Usage docs.** New or changed public behavior — a new pipeline/model, a new argument, changed defaults, a renamed API — should have matching updates in `docs/`, docstrings, and examples. Flag any that now describe outdated behavior or that are missing for the new surface. -- **Agent docs.** If the review turns up a rule, pattern, or common gotcha that isn't written down yet — especially one the author got wrong or that you had to reason out — propose adding it to the relevant agent guide ([AGENTS.md](AGENTS.md), [models.md](models.md), [pipelines.md](pipelines.md), [modular.md](modular.md), a skill, or this file) so the next contributor/agent gets it for free instead of repeating the mistake. Human review comments on the PR are a good source for these: if a human reviewer pointed something out and your review missed it, that usually indicates a doc gap — figure out what's missing and propose the addition. +- **Agent docs.** If the review turns up a rule, pattern, or common gotcha that isn't written down yet — especially one the author got wrong or that you had to reason out — propose adding it to the relevant agent guide (`AGENTS.md`, [models.md](models.md), [pipelines.md](pipelines.md), [modular.md](modular.md), a skill, or this file) so the next contributor/agent gets it for free instead of repeating the mistake. Human review comments on the PR are a good source for these: if a human reviewer pointed something out and your review missed it, that usually indicates a doc gap — figure out what's missing and propose the addition. ## Dead code analysis (new models) diff --git a/.ai/testing.md b/.ai/references/testing.md similarity index 92% rename from .ai/testing.md rename to .ai/references/testing.md index 24d39da3bd68..03df2520a42c 100644 --- a/.ai/testing.md +++ b/.ai/references/testing.md @@ -36,7 +36,7 @@ Follow the style introduced in [#14113](https://github.com/huggingface/diffusers - Location: `tests/modular_pipelines//test_modular_pipeline_.py` (one test class per blockset / pipeline variant). - Subclass `ModularPipelineTesterMixin` (from `..test_modular_pipelines_common`) — it runs the pipeline end-to-end (call signature, batch consistency, float16, device placement) against a tiny checkpoint. -- Set `pipeline_class`, `pipeline_blocks_class`, `pretrained_model_name_or_path`, `params` / `batch_params`, and implement `get_dummy_inputs(seed=0)`. Set `expected_workflow_blocks` to pin the block name → class ordering per workflow. +- Set `pipeline_class`, `pipeline_blocks_class`, `pretrained_model_name_or_path`, `params` / `batch_params`, and implement `get_dummy_inputs(seed=0)`. Set `expected_workflow_blocks` to pin the block name → class ordering per workflow (only for blocksets with a `_workflow_map` — with a single workflow the list would just restate the class definition), and `expected_workflow_defaults` to pin each workflow's components, pipeline configs, and inputs — required ones by name, optional ones with their defaults. A pipeline without workflows pins its full blockset under the `None` key. An optional `component_configs` entry pins config values of `from_config` components against their creating spec (e.g. the guider scale that tells a base and a distilled preset apart); pretrained components take their config from the repo, so there is nothing block-level to pin. - `pretrained_model_name_or_path` is a tiny repo with real components (tiny transformer, real scheduler / VAE / tokenizer configs). Develop against a personal repo; tiny repos ultimately live under `hf-internal-testing/` — not merge-blocking, a maintainer moves it before or after merge. - **The tiny repo must mirror the real checkpoint's shape** — same index file type, same pipeline-level config keys, a scheduler configured like the real one. A fixture that doesn't look like the published repos tests a loading/config path no user will ever hit, while the path users *do* hit stays uncovered. If the model ships variants with different configs (base/distilled, different schedules), make one tiny repo and test class per variant — see the flux2 klein base/distilled split. - **Bespoke tests go on the tester class as methods**, not as module-level functions — the mixin is pytest-style, so fixtures (`tmp_path`, `pytest.raises`, parametrize) all work in methods. diff --git a/.ai/skills/custom-blocks/SKILL.md b/.ai/skills/custom-blocks/SKILL.md index 09ac19ddb21d..734fa7823a21 100644 --- a/.ai/skills/custom-blocks/SKILL.md +++ b/.ai/skills/custom-blocks/SKILL.md @@ -146,12 +146,11 @@ diffusers-cli run --model my-user/my-denoise-block --trust-remote-code \ ## Verifying the install -If `diffusers-cli` isn't on PATH, see the install verification section of -[`../diffusers-cli/SKILL.md`](../diffusers-cli/SKILL.md#verifying-the-cli-is-installed). +If `diffusers-cli` isn't on PATH, see the install verification section of the `diffusers-cli` skill. ## Related -- [`diffusers-cli` skill](../diffusers-cli/SKILL.md) — once your block is uploaded, `schema`/`run` +- the `diffusers-cli` skill — once your block is uploaded, `schema`/`run` let you call it from the terminal without writing Python. -- diffusers' [modular pipelines docs](../../../docs/source/en/modular_diffusers) — for writing the block +- diffusers' [modular pipelines docs](https://huggingface.co/docs/diffusers/main/en/modular_diffusers/overview) — for writing the block class itself. diff --git a/.ai/skills/model-integration/SKILL.md b/.ai/skills/model-integration/SKILL.md index 856549085899..287a8973e8b8 100644 --- a/.ai/skills/model-integration/SKILL.md +++ b/.ai/skills/model-integration/SKILL.md @@ -1,5 +1,5 @@ --- -name: integrating-models +name: model-integration description: > Use when adding a new model or pipeline to diffusers, setting up file structure for a new model, converting a pipeline to modular format, or @@ -16,7 +16,7 @@ Before writing any code, gather info in this order: 1. **Reference repo** — ask for the github link. If they've already set it up locally, ask for the path. Otherwise, ask what setup steps are needed (install deps, download checkpoints, set env vars, etc.) and run through them before proceeding. 2. **Inference script** — ask for a runnable end-to-end script for a basic workflow first (e.g. T2V). Then ask what other workflows they want to support (I2V, V2V, etc.) and agree on the full implementation order together. -3. **Standard vs modular** — **default to modular.** [Modular Diffusers](../../modular.md) is the preferred implementation for new pipelines; the standard `DiffusionPipeline` is still supported but no longer the default. We prefer modular especially for models that don't fit a fixed task-based structure (modality baked into the checkpoint) or that are actively evolving. +3. **Standard vs modular** — **default to modular.** [Modular Diffusers](references/modular.md) is the preferred implementation for new pipelines; the standard `DiffusionPipeline` is still supported but no longer the default. We prefer modular especially for models that don't fit a fixed task-based structure (modality baked into the checkpoint) or that are actively evolving. Ask step 3 as an `AskUserQuestion`, with modular marked as the recommended default. @@ -29,7 +29,7 @@ Then work through the **Integration checklist** below A pipeline in Diffusers (be it standard or modular) will have multiple components. These components can be models, schedulers, processors, etc. - [ ] **Transformer model** - - [ ] Implement the model with `from_pretrained` support (conventions: [models.md](../../models.md)) + - [ ] Implement the model with `from_pretrained` support (conventions: [models.md](references/models.md)) - [ ] Convert weights (see **Weight / Checkpoint Conversion**) - [ ] Parity test against the reference (internal, not shipped — see **Model parity test**) - [ ] Register in the relevant `__init__.py` files (lazy imports) @@ -37,7 +37,7 @@ A pipeline in Diffusers (be it standard or modular) will have multiple component - [ ] **VAE** (if applicable) — reuse an existing `AutoencoderKL*` if possible; if a new one is needed, follow the same sub-steps as the transformer - [ ] **Scheduler** — reuse an existing scheduler, or add a custom one - [ ] **Pipeline** - - [ ] Implement the pipeline — see [modular.md](../../modular.md) for modular pipeline, or [pipelines.md](../../pipelines.md) for standard pipeline + - [ ] Implement the pipeline — see [modular.md](references/modular.md) for modular pipeline, or [pipelines.md](references/pipelines.md) for standard pipeline - [ ] Add a LoRA mixin if applicable - [ ] Register in the relevant `__init__.py` files (lazy imports) - [ ] Pipeline-level tests (see **Testing**) @@ -66,7 +66,7 @@ docs/source/en/ ## Model integration specific rules -**Match the reference's numerical logic.** Restructuring code to fit diffusers APIs (`ModelMixin`, `ConfigMixin`, blocks for modular, etc.) is expected, and required diffusers conventions (e.g. the attention pattern in [models.md](../../models.md)) take precedence. Beyond those, keep the actual computation as close to the reference as possible — don't reorder operations, change the math, or rename internals for aesthetics, even if it looks unclean. Small deviations make output mismatches very hard to track down. +**Match the reference's numerical logic.** Restructuring code to fit diffusers APIs (`ModelMixin`, `ConfigMixin`, blocks for modular, etc.) is expected, and required diffusers conventions (e.g. the attention pattern in [models.md](references/models.md)) take precedence. Beyond those, keep the actual computation as close to the reference as possible — don't reorder operations, change the math, or rename internals for aesthetics, even if it looks unclean. Small deviations make output mismatches very hard to track down. ## Weight / Checkpoint Conversion @@ -76,7 +76,7 @@ Convert the original checkpoint into diffusers format with a standalone script u 2. Instantiate the diffusers model from its config and load the converted state dict. 3. `save_pretrained(...)` to a local path, then load it back with `from_pretrained` to confirm it round-trips. -All weights load through the standard paths — `from_pretrained`, or `from_single_file` (add `FromSingleFileMixin` + a weight-mapping) for an original-format single checkpoint. No custom `from_pretrained`, no manual runtime loading. See the loading rule in [models.md](../../models.md). +All weights load through the standard paths — `from_pretrained`, or `from_single_file` (add `FromSingleFileMixin` + a weight-mapping) for an original-format single checkpoint. No custom `from_pretrained`, no manual runtime loading. See the loading rule in [models.md](references/models.md). Common conversion patterns to watch for model-level components: - Fused QKV weights that need splitting into separate Q, K, V @@ -87,13 +87,13 @@ Common conversion patterns to watch for model-level components: ## Testing -Two test layers must be added for any new pipeline: pipeline-level tests, and (if a new model is introduced) model-level tests. Conventions for both layers — file locations, tester mixins, dummy-component rules — live in [testing.md](../../testing.md); follow it when writing the tests. +Two test layers must be added for any new pipeline: pipeline-level tests, and (if a new model is introduced) model-level tests. Conventions for both layers — file locations, tester mixins, dummy-component rules — live in [testing.md](references/testing.md); follow it when writing the tests. ## Model parity test Confirm the diffusers implementation matches the reference. Test each component on **CPU/float32** with a strict tolerance (`max_diff < 1e-3`), comparing the **freshly converted** weights against the reference in a single script — both sides side by side, nothing saved to disk in between. See [pitfalls.md](pitfalls.md) for the common sources of numerical discrepancy. -This is an **internal verification tool for integration — it should not be shipped in the PR** (it imports the reference repo). The tests that ship with the PR are the model-level and pipeline-level tests in [testing.md](../../testing.md). +This is an **internal verification tool for integration — it should not be shipped in the PR** (it imports the reference repo). The tests that ship with the PR are the model-level and pipeline-level tests in [testing.md](references/testing.md). The example below is schematic (placeholder names). `ReferenceModel` is the component **imported from the original repo**, and `convert_my_component` is **the same conversion function you wrote for the conversion script for the component**. You should make sure both load the *same* checkpoint weights and run the *same* input, so any difference is a conversion or implementation bug — not a difference in inputs. diff --git a/.ai/skills/self-review/SKILL.md b/.ai/skills/self-review/SKILL.md index 7a8536d32e54..088b2649c368 100644 --- a/.ai/skills/self-review/SKILL.md +++ b/.ai/skills/self-review/SKILL.md @@ -3,7 +3,7 @@ name: self-review description: > Use before opening a PR, or whenever asked to self-review a diffusers contribution. Applies the same rubric as the `@claude` CI (checks the diff - against .ai/review-rules.md, traces call paths for dead code). Reports findings grouped by + against references/review-rules.md, traces call paths for dead code). Reports findings grouped by severity, flagging what to fix before submitting (blocking issues + dead code) vs what to leave for the actual review. Report-only — does not edit files. --- @@ -30,14 +30,14 @@ files, scope to your own commits: `git log main..HEAD --oneline`, then ## 2. Read the rubric -`.ai/review-rules.md` is the canonical rubric (the CI pins it from `main`) — read +`references/review-rules.md` is the canonical rubric (the CI pins it from `main`) — read it and review against it; don't rely on a remembered copy. For the areas you -touched, also read `.ai/models.md`, `.ai/pipelines.md`, or `.ai/modular.md`. +touched, also read `references/models.md`, `references/pipelines.md`, `references/modular.md`, or `references/testing.md`. ## 3. Report - **Blocking issues** — numbered. Each: title → explanation → `file.py:line` → - impact. Cite the rule, e.g. *Per `.ai/models.md`: "…only keep the inference path."* + impact. Cite the rule, e.g. *Per `references/models.md`: "…only keep the inference path."* - **Non-blocking issues** — same format, lower severity. - **Dead code (advisory)** — a table: `path:line` · Likely-dead / Used · reason. - **Summary** — short synthesis and a verdict (**READY** / **NEEDS CHANGES**), diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000000..2aa81aa24f55 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "diffusers-skills", + "description": "Agent plugins published from the diffusers repository", + "owner": { + "name": "The Hugging Face team" + }, + "plugins": [ + { + "name": "diffusers", + "source": "./.ai", + "description": "Conventions and task skills for contributing to diffusers" + } + ] +} diff --git a/.github/workflows/claude_review.yml b/.github/workflows/claude_review.yml index 6f0510c923d1..5baf3a378dbf 100644 --- a/.github/workflows/claude_review.yml +++ b/.github/workflows/claude_review.yml @@ -62,8 +62,8 @@ jobs: EOF_DELIMITER="GITHUB_ENV_$(openssl rand -hex 8)" { echo "REVIEW_RULES<<${EOF_DELIMITER}" - git show "origin/${DEFAULT_BRANCH}:.ai/review-rules.md" 2>/dev/null \ - || echo "No .ai/review-rules.md found. Apply Python correctness standards." + git show "origin/${DEFAULT_BRANCH}:.ai/references/review-rules.md" 2>/dev/null \ + || echo "No .ai/references/review-rules.md found. Apply Python correctness standards." echo "${EOF_DELIMITER}" } >> "$GITHUB_ENV" diff --git a/Makefile b/Makefile index ebf6b202b24c..1d92e692f0d8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps_table_update modified_only_fixup extra_style_checks quality style fixup fix-copies test test-examples codex claude clean-ai +.PHONY: deps_table_update modified_only_fixup extra_style_checks quality style fixup fix-copies test test-examples # make sure to test the local checkout in scripts and not the pre-installed one (don't use quotes!) export PYTHONPATH = src @@ -104,17 +104,3 @@ post-release: post-patch: python utils/release.py --post_release --patch -# AI agent symlinks - -codex: - mkdir -p .agents - rm -rf .agents/skills - ln -snf ../.ai/skills .agents/skills - -claude: - mkdir -p .claude - rm -rf .claude/skills - ln -snf ../.ai/skills .claude/skills - -clean-ai: - rm -rf .agents/skills .claude/skills diff --git a/README.md b/README.md index f9f494e393c6..9d6a086d6492 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Check out the [Quickstart](https://huggingface.co/docs/diffusers/quicktour) to l We ❤️ contributions from the open-source community! If you want to contribute to this library, please check out our [Contribution guide](https://huggingface.co/docs/diffusers/main/en/conceptual/contribution). -If you are using an AI agent, please point it at the project conventions in [`.ai/`](https://github.com/huggingface/diffusers/tree/main/.ai) first (run `make claude` or `make codex`) — see [Coding with AI agents](https://huggingface.co/docs/diffusers/main/en/conceptual/contribution#coding-with-ai-agents). +If you are using an AI agent, please point it at the project conventions in [`.ai/`](https://github.com/huggingface/diffusers/tree/main/.ai) first (install them as a plugin with `claude plugin marketplace add huggingface/diffusers`, or a skill at a time with `diffusers-cli skills add `) — see [Coding with AI agents](https://huggingface.co/docs/diffusers/main/en/conceptual/contribution#coding-with-ai-agents). You can look out for [issues](https://github.com/huggingface/diffusers/issues) you'd like to tackle to contribute to the library. - See [Good first issues](https://github.com/huggingface/diffusers/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) for general opportunities to contribute - See [New model/pipeline](https://github.com/huggingface/diffusers/issues?q=is%3Aopen+is%3Aissue+label%3A%22New+pipeline%2Fmodel%22) to contribute exciting new diffusion models / diffusion pipelines diff --git a/docs/source/en/conceptual/contribution.md b/docs/source/en/conceptual/contribution.md index 2eb3275fa95a..5e56afe1e68c 100644 --- a/docs/source/en/conceptual/contribution.md +++ b/docs/source/en/conceptual/contribution.md @@ -578,32 +578,32 @@ For documentation strings, 🧨 Diffusers follows the [Google style](https://goo ## Coding with AI agents -The repository keeps AI-agent configuration in [`.ai/`](https://github.com/huggingface/diffusers/tree/main/.ai). Run `make claude` / `make codex` to additionally wire up the on-demand task skills for your tool. +The repository keeps AI-agent configuration in [`.ai/`](https://github.com/huggingface/diffusers/tree/main/.ai), which is also published as an agent plugin so the on-demand task skills install in one step. -- **Read-only for contributors** — `.ai/` is maintained by the core maintainers. Please do not edit files under `.ai/` (or the root-level `AGENTS.md` / `CLAUDE.md` symlinks, or the generated `.agents/skills` / `.claude/skills`) in your PR. If you find something missing or wrong, open an issue or flag it on the PR and a maintainer will update it. -- **Guidelines** (loaded into every agent session): +- **Read-only for contributors** — `.ai/` is maintained by the core maintainers. Please do not edit files under `.ai/` (or the root-level `AGENTS.md` / `CLAUDE.md` symlinks, or the installed `.agents/skills` / `.claude/skills`) in your PR. If you find something missing or wrong, open an issue or flag it on the PR and a maintainer will update it. +- **Guidelines** — `.ai/AGENTS.md` is loaded into every agent session through the root `AGENTS.md` / `CLAUDE.md` + symlinks; the reference guides under `.ai/references/` are read on demand when a link is followed: - [`.ai/AGENTS.md`](https://github.com/huggingface/diffusers/blob/main/.ai/AGENTS.md) — top-level coding guidelines - - [`.ai/models.md`](https://github.com/huggingface/diffusers/blob/main/.ai/models.md) — attention pattern, model implementation rules, common conventions - - [`.ai/pipelines.md`](https://github.com/huggingface/diffusers/blob/main/.ai/pipelines.md) — pipeline conventions - - [`.ai/modular.md`](https://github.com/huggingface/diffusers/blob/main/.ai/modular.md) — modular pipeline conventions and conversion checklist - - [`.ai/review-rules.md`](https://github.com/huggingface/diffusers/blob/main/.ai/review-rules.md) — what reviewers look for + - [`.ai/references/models.md`](https://github.com/huggingface/diffusers/blob/main/.ai/references/models.md) — attention pattern, model implementation rules, common conventions + - [`.ai/references/pipelines.md`](https://github.com/huggingface/diffusers/blob/main/.ai/references/pipelines.md) — pipeline conventions + - [`.ai/references/modular.md`](https://github.com/huggingface/diffusers/blob/main/.ai/references/modular.md) — modular pipeline conventions and conversion checklist + - [`.ai/references/testing.md`](https://github.com/huggingface/diffusers/blob/main/.ai/references/testing.md) — required test layers, tester mixins, dummy-component rules + - [`.ai/references/review-rules.md`](https://github.com/huggingface/diffusers/blob/main/.ai/references/review-rules.md) — what reviewers look for - **Skills** (under [`.ai/skills/`](https://github.com/huggingface/diffusers/tree/main/.ai/skills), loaded on demand for specific tasks): - `model-integration` — adding a new model or pipeline to diffusers end-to-end (file structure, integration checklist, testing layout, weight conversion) - `self-review` — review your changes against the project rules before opening a PR -- **Setup commands**: - - `make codex` — wire up skills for OpenAI Codex (under `.agents/`) - - `make claude` — wire up skills for Claude Code (under `.claude/`) - - `make clean-ai` — remove the generated skills symlinks + - `diffusers-cli` — running pipelines and inspecting schemas from the terminal + - `custom-blocks` — packaging a `ModularPipelineBlocks` subclass for the Hub ### AI-assisted and agentic contributions -AI agents are welcome for contributing to Diffusers. We encourage you to set up your agent with the Diffusers [agent guide](https://github.com/huggingface/diffusers/blob/main/.ai/AGENTS.md) and use the relevant task-specific skills, such as `model-integration` and `self-review`. Run `make codex` or `make claude` to use the skills with your agent, then follow the guide to scope, implement, test, and review your contribution. You remain responsible for understanding, testing, and maintaining the changes in your PR. +AI agents are welcome for contributing to Diffusers. We encourage you to set up your agent with the Diffusers [agent guide](https://github.com/huggingface/diffusers/blob/main/.ai/AGENTS.md) and use the relevant task-specific skills, such as `model-integration` and `self-review`. Install the plugin (or the individual skills) as described above, then follow the guide to scope, implement, test, and review your contribution. You remain responsible for understanding, testing, and maintaining the changes in your PR. AI-assisted contributions are welcome, but they must be coordinated, scoped, and verified to keep review load manageable. PRs that do not follow these guidelines may be closed without detailed review. - **Coordinate before opening a PR.** Find or open an issue, review similar PRs (open and recently closed), and wait for an explicit acknowledgment from a maintainer on that issue before opening a PR. This gives us a chance to discuss scope, avoid duplicate work, and confirm the approach. - **Fix patterns, not one-offs.** If you spot an recurring issue, search the codebase for similar instances and open a *single* issue with a clear, systematic scope (e.g. "fix mutable defaults across all schedulers") rather than many issues or PRs for individual instances. -- **Self-review before opening.** Run the [`self-review`](https://github.com/huggingface/diffusers/blob/main/.ai/skills/self-review/SKILL.md) skill — it reviews your diff against [`.ai/review-rules.md`](https://github.com/huggingface/diffusers/blob/main/.ai/review-rules.md), the same rubric the `@claude` CI reviewer uses — and address what it reports — it's a helper, not authoritative, and can be wrong. Focus on the blocking issues that make sense to you, and clean up dead/unused code as much as possible. If you disagree with a suggestion, it's fine to leave it for the reviewer to discuss after the PR is opened — the notes you share (see below) tell the reviewer it was a deliberate call. +- **Self-review before opening.** Run the [`self-review`](https://github.com/huggingface/diffusers/blob/main/.ai/skills/self-review/SKILL.md) skill — it reviews your diff against [`.ai/references/review-rules.md`](https://github.com/huggingface/diffusers/blob/main/.ai/references/review-rules.md), the same rubric the `@claude` CI reviewer uses — and address what it reports — it's a helper, not authoritative, and can be wrong. Focus on the blocking issues that make sense to you, and clean up dead/unused code as much as possible. If you disagree with a suggestion, it's fine to leave it for the reviewer to discuss after the PR is opened — the notes you share (see below) tell the reviewer it was a deliberate call. - **Share your self-review notes.** Please post the final self-review report — the round that reflects the diff you're submitting — on the PR, in the description or as a comment, including findings you intentionally did not fix and why. It helps the reviewer see what has already been checked and which calls were deliberate, and usually saves a few rounds of back-and-forth. - **Include in the PR description:** - A **coordination link** to the issue or discussion where a maintainer acknowledged the work. diff --git a/docs/source/en/using-diffusers/cli.md b/docs/source/en/using-diffusers/cli.md index 4a8127888c73..5cefc9376b75 100644 --- a/docs/source/en/using-diffusers/cli.md +++ b/docs/source/en/using-diffusers/cli.md @@ -276,7 +276,7 @@ diffusers-cli skills add "" # Install every skill in the registry diffusers-cli skills add --all -# List available skills +# List available skills with a one-line summary of each diffusers-cli skills list # Preview a skill's SKILL.md without installing @@ -287,6 +287,13 @@ diffusers-cli skills update # Install to the user-level directory instead of the current project diffusers-cli skills add diffusers-cli --global + +# Install for one agent instead of detecting it from the environment +diffusers-cli skills add --all --claude # or --codex / --cursor ``` +Without a target flag, the CLI installs for whichever agent launched it, or for every agent when it can't tell. +For Claude Code the skills are written as a plugin bundle at `.claude/skills/diffusers/`, so they are namespaced +as `/diffusers:`; Codex and Cursor get `.agents/skills//`. + diff --git a/src/diffusers/commands/skills.py b/src/diffusers/commands/skills.py index 60d2e40e883f..455c1f0eee0a 100644 --- a/src/diffusers/commands/skills.py +++ b/src/diffusers/commands/skills.py @@ -14,14 +14,17 @@ """`diffusers-cli skills` — install Agent Skills bundles. Skill bundles live under `.ai/skills//` in the diffusers repo and follow the Agent Skills standard: a directory -containing `SKILL.md` (plus optional resources). Installs to `.agents/skills//` which Claude, Codex, and Cursor -all discover. +containing `SKILL.md` (plus optional resources), including the `references/` copies of the guides it cites. Claude Code +gets a plugin bundle under `.claude/skills/`; Codex and Cursor get `.agents/skills//`. """ from __future__ import annotations +import json import os +import re import shutil +import textwrap from argparse import ArgumentParser, Namespace, _SubParsersAction from pathlib import Path @@ -37,8 +40,13 @@ _REGISTRY_BASE = "https://api.github.com/repos/huggingface/diffusers/contents/.ai/skills" +_RAW_BASE = "https://raw.githubusercontent.com/huggingface/diffusers" _REGISTRY_REF = "main" +# How a skill cites a guide, e.g. `](references/models.md)` or `` `references/testing.md` ``. The install copies exactly +# the guides named this way, so a skill only has to cite one to get it. +_CITATION = re.compile(r"(?` and matches what the +# published plugin gives contributors. Codex and Cursor have no equivalent, so they keep the plain +# one-skill-per-directory layout their discovery expects. +_PLUGIN_NAME = "diffusers" +_PLUGIN_MANIFEST = { + "name": _PLUGIN_NAME, + "description": "Conventions and task skills for contributing to diffusers", +} + # --------------------------------------------------------------------------- # Registry fetch @@ -72,6 +90,11 @@ def _registry_url(name: str = "") -> str: return f"{_REGISTRY_BASE}{path}?ref={_REGISTRY_REF}" +def _skill_md_url(name: str) -> str: + """Raw URL of a skill's SKILL.md, so `list` can read descriptions without walking each bundle.""" + return f"{_RAW_BASE}/{_REGISTRY_REF}/.ai/skills/{name}/SKILL.md" + + def _fetch_json(url: str) -> list[dict]: try: resp = httpx.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT) @@ -99,6 +122,32 @@ def _walk(api_url: str, prefix: str) -> None: return files +def _skill_description(skill_md: str) -> str: + """The `description` from a SKILL.md YAML frontmatter, collapsed onto one line. + + Handles both `description: text` and the folded `description: >` form the skills in this repo use. Returns an empty + string when the file has no frontmatter or no description. + """ + lines = skill_md.splitlines() + if not lines or lines[0].strip() != "---": + return "" + for index, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + return "" + if not line.startswith("description:"): + continue + value = line[len("description:") :].strip() + if value and value not in (">", "|", ">-", "|-"): + return value + folded = [] + for continuation in lines[index + 1 :]: + if continuation.strip() == "---" or not continuation.startswith((" ", "\t")): + break + folded.append(continuation.strip()) + return " ".join(folded) + return "" + + def _download_skill_bundle(name: str) -> dict[str, bytes]: files = _walk_skill_files(name) if not files: @@ -128,8 +177,30 @@ def _detect_install_dirs() -> tuple[Path, ...]: return _ALL_INSTALL_DIRS +def _skill_dir(root: Path, skills_dir: Path, name: str) -> Path: + """Where a single skill lands, which differs by target: a plugin bundle for Claude Code, a bare + directory for Codex and Cursor.""" + if skills_dir == _CLAUDE_SKILLS_DIR: + return root / skills_dir / _PLUGIN_NAME / "skills" / name + return root / skills_dir / name + + +def _install_references(skill_dir: Path, skill_md: bytes) -> None: + """Copy the guides a skill cites into its own `references/` directory. + + A skill cites a guide as `references/.md`, so the guides it needs are exactly the ones its SKILL.md names. + The repo keeps one copy of each under `.ai/references/`; an install gets its own so the skill is self-contained. + """ + references_dir = skill_dir / "references" + for guide in sorted(set(_CITATION.findall(skill_md.decode()))): + resp = httpx.get(f"{_RAW_BASE}/{_REGISTRY_REF}/.ai/references/{guide}", timeout=DIFFUSERS_REQUEST_TIMEOUT) + resp.raise_for_status() + references_dir.mkdir(parents=True, exist_ok=True) + (references_dir / guide).write_bytes(resp.content) + + def _install_skill(name: str, bundle: dict[str, bytes], root: Path, skills_dir: Path, force: bool) -> Path: - skill_dir = root / skills_dir / name + skill_dir = _skill_dir(root, skills_dir, name) if skill_dir.exists(): if not force: raise SystemExit(f"Skill already installed at {skill_dir}. Use --force to reinstall.") @@ -140,19 +211,29 @@ def _install_skill(name: str, bundle: dict[str, bytes], root: Path, skills_dir: target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) (skill_dir / _MANAGED_MARKER_FILE).touch() + _install_references(skill_dir, bundle["SKILL.md"]) + + if skills_dir == _CLAUDE_SKILLS_DIR: + manifest = root / skills_dir / _PLUGIN_NAME / ".claude-plugin" / "plugin.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text(json.dumps(_PLUGIN_MANIFEST, indent=2) + "\n") + return skill_dir def _has_local_changes(skill_dir: Path, bundle: dict[str, bytes]) -> bool: """True if the installed skill has any file that differs from `bundle` or has extra files. - The marker file is ignored. Compares raw bytes so a whitespace-only edit still counts as dirty. + The marker file and the copied `references/` guides are ignored — neither comes from the skill bundle. Compares raw + bytes so a whitespace-only edit still counts as dirty. """ on_disk: dict[str, bytes] = {} for path in skill_dir.rglob("*"): if not path.is_file(): continue rel = str(path.relative_to(skill_dir)) + if rel.startswith("references/"): + continue if rel == _MANAGED_MARKER_FILE: continue on_disk[rel] = path.read_bytes() @@ -164,6 +245,8 @@ def _discover_installed(root: Path) -> list[tuple[Path, str]]: found: list[tuple[Path, str]] = [] for skills_dir in _ALL_INSTALL_DIRS: skills_root = root / skills_dir + if skills_dir == _CLAUDE_SKILLS_DIR: + skills_root = skills_root / _PLUGIN_NAME / "skills" if not skills_root.exists(): continue for d in sorted(skills_root.iterdir()): @@ -196,6 +279,28 @@ def register_subcommand(subparsers: _SubParsersAction) -> None: action="store_true", help="Install every skill in the registry. Mutually exclusive with a positional name.", ) + target = add.add_mutually_exclusive_group() + target.add_argument( + "--claude", + dest="install_dirs", + action="store_const", + const=(_CLAUDE_SKILLS_DIR,), + help="Install for Claude Code only, instead of detecting the agent from the environment.", + ) + target.add_argument( + "--codex", + dest="install_dirs", + action="store_const", + const=(_AGENTS_SKILLS_DIR,), + help="Install for Codex only.", + ) + target.add_argument( + "--cursor", + dest="install_dirs", + action="store_const", + const=(_AGENTS_SKILLS_DIR,), + help="Install for Cursor only.", + ) add.add_argument( "--global", "-g", @@ -254,7 +359,7 @@ def _add(self) -> None: raise SystemExit("Pass a skill name (e.g. diffusers-cli) or --all to install every skill.") root = Path.home() if self.args.install_global else Path.cwd() - install_dirs = _detect_install_dirs() + install_dirs = self.args.install_dirs or _detect_install_dirs() names = self._resolve_names() installed: list[str] = [] @@ -272,6 +377,7 @@ def _add(self) -> None: if not installed: raise SystemExit(f"No skills installed. Failed: {failed}") + out.result( f"Installed {len(installed)} skill(s)", installed=", ".join(installed), @@ -302,7 +408,7 @@ def _update(self) -> None: try: bundle = _download_skill_bundle(name) for skills_dir in dirs: - skill_dir = root / skills_dir / name + skill_dir = _skill_dir(root, skills_dir, name) if not self.args.force and _has_local_changes(skill_dir, bundle): logger.warning( f"Skill {name!r} at {skill_dir} has local modifications; " @@ -332,10 +438,18 @@ def _preview(self) -> None: def _list(self) -> None: entries = _fetch_json(_registry_url()) - skills = [{"name": e["name"]} for e in entries if e["type"] == "dir" and not e["name"].startswith(".")] - if not skills: + names = sorted(e["name"] for e in entries if e["type"] == "dir" and not e["name"].startswith(".")) + if not names: raise SystemExit("No skills found in registry.") - out.table(skills, headers=["name"]) + + # Descriptions run several sentences, so they get a wrapped block under each name rather than a table cell. + width = min(shutil.get_terminal_size().columns, 100) + for name in names: + resp = httpx.get(_skill_md_url(name), timeout=DIFFUSERS_REQUEST_TIMEOUT) + resp.raise_for_status() + description = _skill_description(resp.text) + print(f"\n{name}") + print(textwrap.fill(description, width=width, initial_indent=" ", subsequent_indent=" ")) def _resolve_names(self) -> list[str]: if self.args.install_all: diff --git a/tests/others/test_cli_skills.py b/tests/others/test_cli_skills.py index d5e87ed61d44..645e416d2ef8 100644 --- a/tests/others/test_cli_skills.py +++ b/tests/others/test_cli_skills.py @@ -17,6 +17,9 @@ (``_fetch_json`` / ``_download_skill_bundle``) is left out — it needs the network. """ +import json +from argparse import ArgumentParser + import pytest from diffusers.commands.skills import ( @@ -25,12 +28,21 @@ _ALL_INSTALL_DIRS, _CLAUDE_SKILLS_DIR, _MANAGED_MARKER_FILE, + _PLUGIN_NAME, + SkillsCommand, _detect_install_dirs, _discover_installed, _install_skill, + _skill_description, ) +def _parse_skills_args(argv): + parser = ArgumentParser() + SkillsCommand.register_subcommand(parser.add_subparsers()) + return parser.parse_args(argv) + + SAMPLE_BUNDLE = { "SKILL.md": b"---\nname: example-skill\ndescription: An example skill.\n---\n\n# Example\n", "extra.md": b"# Extra reference file\n", @@ -41,13 +53,23 @@ class TestInstallSkill: def test_writes_bundle_and_marker(self, tmp_path): location = _install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False) - skill_dir = tmp_path / _CLAUDE_SKILLS_DIR / "example-skill" + skill_dir = tmp_path / _CLAUDE_SKILLS_DIR / _PLUGIN_NAME / "skills" / "example-skill" assert location == skill_dir assert (skill_dir / "SKILL.md").read_bytes() == SAMPLE_BUNDLE["SKILL.md"] assert (skill_dir / "extra.md").read_bytes() == SAMPLE_BUNDLE["extra.md"] assert (skill_dir / "reference" / "script.py").read_bytes() == SAMPLE_BUNDLE["reference/script.py"] assert (skill_dir / _MANAGED_MARKER_FILE).exists() + def test_claude_install_writes_plugin_manifest(self, tmp_path): + _install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False) + manifest = tmp_path / _CLAUDE_SKILLS_DIR / _PLUGIN_NAME / ".claude-plugin" / "plugin.json" + assert json.loads(manifest.read_text())["name"] == _PLUGIN_NAME + + def test_agents_install_stays_flat(self, tmp_path): + location = _install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _AGENTS_SKILLS_DIR, force=False) + assert location == tmp_path / _AGENTS_SKILLS_DIR / "example-skill" + assert not (tmp_path / _AGENTS_SKILLS_DIR / _PLUGIN_NAME).exists() + def test_errors_without_force_on_existing_dir(self, tmp_path): _install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False) with pytest.raises(SystemExit, match="Use --force to reinstall"): @@ -57,7 +79,7 @@ def test_force_replaces_existing(self, tmp_path): _install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False) new_bundle = {"SKILL.md": b"different content"} _install_skill("example-skill", new_bundle, tmp_path, _CLAUDE_SKILLS_DIR, force=True) - skill_dir = tmp_path / _CLAUDE_SKILLS_DIR / "example-skill" + skill_dir = tmp_path / _CLAUDE_SKILLS_DIR / _PLUGIN_NAME / "skills" / "example-skill" assert (skill_dir / "SKILL.md").read_bytes() == b"different content" assert not (skill_dir / "extra.md").exists() assert (skill_dir / _MANAGED_MARKER_FILE).exists() @@ -85,12 +107,48 @@ def test_env_var_picks_specific_target(self, monkeypatch, env_var, expected): assert _detect_install_dirs() == (expected,) +class TestSkillDescription: + def test_reads_folded_block(self): + skill_md = "---\nname: demo\ndescription: >\n First line\n second line.\n---\n\n# Body\n" + assert _skill_description(skill_md) == "First line second line." + + def test_reads_single_line(self): + assert _skill_description("---\nname: demo\ndescription: Does a thing.\n---\n") == "Does a thing." + + def test_returns_empty_without_frontmatter(self): + assert _skill_description("# Just a heading\n") == "" + + def test_returns_empty_without_description(self): + assert _skill_description("---\nname: demo\n---\n") == "" + + +class TestTargetFlags: + @pytest.mark.parametrize( + ("flag", "expected"), + [ + ("--claude", (_CLAUDE_SKILLS_DIR,)), + ("--codex", (_AGENTS_SKILLS_DIR,)), + ("--cursor", (_AGENTS_SKILLS_DIR,)), + ], + ) + def test_flag_pins_install_dirs(self, flag, expected): + args = _parse_skills_args(["skills", "add", "self-review", flag]) + assert args.install_dirs == expected + + def test_no_flag_defers_to_detection(self): + assert _parse_skills_args(["skills", "add", "self-review"]).install_dirs is None + + def test_flags_are_mutually_exclusive(self): + with pytest.raises(SystemExit): + _parse_skills_args(["skills", "add", "self-review", "--claude", "--codex"]) + + class TestDiscoverInstalled: def test_returns_managed_installs_from_all_paths(self, tmp_path): _install_skill("skill-a", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False) _install_skill("skill-b", SAMPLE_BUNDLE, tmp_path, _AGENTS_SKILLS_DIR, force=False) # User-placed skill without our marker — should be ignored. - unmanaged = tmp_path / _CLAUDE_SKILLS_DIR / "user-placed" + unmanaged = tmp_path / _CLAUDE_SKILLS_DIR / _PLUGIN_NAME / "skills" / "user-placed" unmanaged.mkdir(parents=True) (unmanaged / "SKILL.md").write_bytes(b"user content") diff --git a/utils/check_ai.py b/utils/check_ai.py new file mode 100644 index 000000000000..4d116617fc53 --- /dev/null +++ b/utils/check_ai.py @@ -0,0 +1,83 @@ +# Copyright 2026 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Check that the agent guides and skills under `.ai/` still hold together. + +Skills are installed outside a checkout, so a link that only resolves in the repo is a link an installed skill cannot +follow. This checks the three things that break when files move: + +1. No relative link escapes its own directory — those cannot resolve once a skill is installed, so link to a stable URL + instead. +2. A guide a skill cites as `references/.md` exists in `.ai/references/`, since that is where the installer + copies it from. +3. Every other relative link resolves on disk, and each skill's frontmatter `name` matches its directory. + +Run `python utils/check_ai.py`. +""" + +import re +import sys +from pathlib import Path + + +AI_DIR = Path(__file__).parent.parent / ".ai" +REFERENCES_DIR = AI_DIR / "references" +LINK = re.compile(r"\]\(([^)]+)\)") +# Fenced code is prose to us — Python like `attn.to_out[0](hidden_states)` otherwise reads as a link. +CODE_FENCE = re.compile(r"^```.*?^```", re.M | re.S) +# Matches how the installer finds the guides a skill needs, so this checks exactly what gets copied. +CITATION = re.compile(r"(? int: + problems: list[str] = [] + + for path in sorted(AI_DIR.rglob("*.md")): + where = path.relative_to(AI_DIR.parent) + prose = CODE_FENCE.sub("", path.read_text()) + + for link in LINK.findall(prose): + target = link.split("#")[0] + if not target or target.startswith(("http://", "https://")): + continue + if ".." in Path(target).parts: + problems.append(f"{where}: '{link}' escapes its directory — link to a stable URL instead") + elif path.name == "SKILL.md" and target.startswith("references/"): + continue # covered below, which also catches guides cited outside a markdown link + elif not (path.parent / target).exists(): + problems.append(f"{where}: '{link}' does not exist") + + if path.name == "SKILL.md": + for guide in sorted(set(CITATION.findall(prose))): + if not (REFERENCES_DIR / guide).exists(): + problems.append(f"{where}: cites 'references/{guide}', which is not in {REFERENCES_DIR.name}/") + + for skill_md in sorted(AI_DIR.glob("skills/*/SKILL.md")): + declared = FRONTMATTER_NAME.search(skill_md.read_text()) + if declared is None: + problems.append(f"{skill_md.relative_to(AI_DIR.parent)}: no 'name' in frontmatter") + elif declared.group(1) != skill_md.parent.name: + problems.append( + f"{skill_md.relative_to(AI_DIR.parent)}: frontmatter name '{declared.group(1)}' " + f"does not match directory '{skill_md.parent.name}'" + ) + + if problems: + print("\n".join(problems), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())