diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index e4c6207..d8f56f9 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -23,7 +23,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index ebad293..bdea4c5 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 498bab4..a4716f3 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 4c46897..cb1e666 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index db789c2..9e671e0 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 8f09b67..d61208d 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index bcb4148..b29b527 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 6218e70..d0d8d0b 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 90ae182..cd17905 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/CHANGELOG.md b/CHANGELOG.md index fb23bb1..74ca2bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Chock changelog +## Unreleased + +- **`devin` plugin format**: `chock plugin build --format devin` packages a policy as a native + Devin plugin (`.devin-plugin/plugin.json` + `skills//SKILL.md` + a root-level `hooks.json`, + not the nested `hooks/hooks.json` every other format uses). Same guard, same adapter, + byte-identical to every other format -- only the envelope differs, and the hook command reaches + its own bundled copies via a shell expansion of `$DEVIN_PLUGIN_ROOT`, the environment variable + the vendor documents hook commands receive (agentseam records no `${...}` plugin-root token for + Devin, unlike Codex or Cursor -- that expansion is chock's own inference, not a vendor-recorded + token). Unlike every other hook format, the package claims no enforcement tier: the vendor's own + docs call plugin hooks "currently best effort and fail open ... so don't rely on them for + crucial guardrails yet," for local Devin sessions (the CLI and Devin Desktop) only, and the + posture text says so instead of claiming a block. +- **`chock marketplace build --tree devin`**: Devin has no marketplace index file -- `devin + plugins install` instead reads a repo's root `.devin-plugin/plugin.json` as a meta-plugin whose + `optionalPlugins` point `git-subdir` entries at each built plugin, so `--tree devin` writes that + root manifest in place of an index (a new `--url` is required; chock never reads `git remote` + for it). `chock-market.lock` and `PLUGINS.md` cover the devin tree the same way they cover every + other tree. +- **Pinned `agentseam==0.3.2`**, which records Devin's native plugin layout; the vendored runtime + goldens moved with it (version stamp only, no handler change). + ## 0.9.2 — `sync` no longer leaves a vendor's hook config pointing at a runtime it just deleted - **Fixed: narrowing `supported_agents` on 0.9.1 left dangling hook entries behind (#151).** diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d007a54..399d524 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -85,15 +85,13 @@ The one "make it so" verb (`uv sync` semantics): recompiles every enabled policy `.chock/compiled/`, reinstalls the git-hook dispatchers, policy wrappers and the in-agent hooks of the agents `supported_agents` names, uninstalls chock's entries from any vendor it no longer names (deleting a config file that held only chock's) before pruning that vendor's runtime, regenerates `INDEX.md` and the `AGENTS.md` pointer, refreshes -the registry, and rewrites `chock.lock`. Run it after editing a policy, toggling config by hand, or bumping the engine -version. A failed recompile never removes the existing compiled tree — the build is staged and swapped in only on -success — and a lockfile-write failure fails the command. An adopter-edited dispatcher is backed up to -`.chock-backup` before being regenerated; custom steps belong in `.d/`. - -- `--check` — write nothing; exit non-zero listing every compiled artifact that no longer - matches its manifest. This is the CI drift gate. -- `--ci` — additionally write the GitHub Actions workflow that runs every compiled `ci-gate` on pull requests. Idempotent; - refuses to overwrite a workflow it did not write. Until this runs, `ci-gate` output is compiled but not enforced. +the registry, and rewrites `chock.lock`. Run after editing a policy, toggling config, or bumping the engine version. A +failed recompile never removes the existing compiled tree — staged and swapped in only on success — and a +lockfile-write failure fails the command. An adopter-edited dispatcher is backed up to `.chock-backup` before +being regenerated; custom steps belong in `.d/`. + +- `--check` — write nothing; exit non-zero listing every compiled artifact that no longer matches its manifest. The CI drift gate. +- `--ci` — additionally write the GitHub Actions workflow running every compiled `ci-gate` on pull requests. Idempotent; refuses to overwrite a workflow it did not write. Until this runs, `ci-gate` output is compiled but not enforced. - `--skills` — additionally refresh the bundled authoring skills in `.agents/skills/`. - `--skip-hooks` — compile and refresh bookkeeping without touching `.git/hooks`. @@ -103,8 +101,7 @@ success — and a lockfile-write failure fails the command. An adopter-edited di chock check [--repo .] [--only validate,verify,evals,matrix,mechanisms,index,conflicts,baseline] [--mode MODE] [--event EVENT] [--base REF] ``` -Runs every truth check, read-only — `check` never regenerates what it measures (that is -`sync`'s job): +Runs every truth check, read-only — `check` never regenerates what it measures (that is `sync`'s job): | Target | What it proves | | :--- | :--- | @@ -197,7 +194,7 @@ diffs the result to catch a stale registry. See [Registry & Lockfile](registry-a ### `plugin build` — package policies as installable plugins ```bash -chock plugin build [--repo .] [--policies-dir base] [--format agent-plugins|claude|copilot|cursor|codex|all] [--out-dir DIST] [--policy ID ...] [--out PATH] [--check] +chock plugin build [--repo .] [--policies-dir base] [--format agent-plugins|claude|copilot|cursor|codex|devin|all] [--out-dir DIST] [--policy ID ...] [--out PATH] [--check] ``` Renders each policy as a plugin. The default `agent-plugins` format writes an @@ -205,28 +202,33 @@ Renders each policy as a plugin. The default `agent-plugins` format writes an `manifest.yaml` stays the source of truth, and a packaged policy is `advisory` wherever it is read: v1 defines no enforcement semantics, so packaging changes no value in `coverage.json`. -The four hook formats ship a byte-identical guard and adapter; only the envelope differs. +The five hook formats ship a byte-identical guard and adapter; only the envelope differs. `claude` (`.claude-plugin/`) is read natively by Claude Code, Copilot CLI, VS Code and Grok Build; `copilot` is the Agent Plugins 1.0 layout under `com.github.copilot/hooks/`; `cursor` (`.cursor-plugin/`, `beforeShellExecution`) and `codex` (`.codex-plugin/`, `PreToolUse`) each -reach a hook engine no other package can, failing **open** when `python3` is absent. They -require `--out-dir` (or `--out`); in-place output is refused so a policy folder is never -mistaken for a published plugin. `--policies-dir` packages a published directory; `--check` -judges without writing. `--policy ID` (repeatable; manifest `id` or directory name, else a -named error) narrows the build and skips `--out-dir` stale-package cleanup. `--out PATH` -(exactly one `--policy`) writes straight to `PATH` instead of `///`. +reach a hook engine no other package can, failing **open** when `python3` is absent. `devin` +(`.devin-plugin/plugin.json` + `hooks.json`, `PreToolUse`) is best-effort by the vendor's own +design, fail-open, not enforced. They require `--out-dir` (or `--out`); in-place output is +refused so a policy folder is never mistaken for a published plugin. `--policies-dir` packages +a published directory; `--check` judges without writing. `--policy ID` (repeatable; manifest +`id` or directory name, else a named error) narrows the build and skips `--out-dir` +stale-package cleanup. `--out PATH` (exactly one `--policy`) writes straight to `PATH` instead +of `///`. ### `marketplace build` — index a built plugin tree ```bash -chock marketplace build [--dist .] [--name chock] [--tree claude|cursor|codex] [--check] +chock marketplace build [--dist .] [--name chock] [--tree claude|cursor|codex|devin] [--url URL] [--check] ``` Scans `//*/` and writes that vendor's index: claude → `.claude-plugin/marketplace.json` + `.github/plugin/marketplace.json` (Copilot CLI's path), cursor → `.cursor-plugin/marketplace.json`, codex → the legacy `.claude-plugin/` -shape Codex reads from git marketplaces. Entries derive from built manifests, never -hand-listed; an empty tree exits 2; `--check` reports drift without writing. +shape Codex reads from git marketplaces. `devin` has no index format: it writes a root +`.devin-plugin/plugin.json` meta-plugin whose `optionalPlugins` point `git-subdir` entries at +each built plugin, and needs `--url` (the marketplace repo's own git URL — never guessed from +`git remote`). Entries derive from built manifests, never hand-listed; an empty tree exits 2; +`--check` reports drift without writing. ### `gateway run` -- the MCP gateway proxy @@ -251,20 +253,17 @@ claims print under **NOT verified**. `require` is the CI-side gate: present, val chock compliance report [--repo .] [--framework owasp_asi] [--json] ``` -Lists the framework controls and which installed policies claim to cover them. Each -control's state is `covered`, `partial`, or `uncovered` (per-claim `coverage` on a policy -is `partial` or `full`). The command fails closed with exit 2 on a missing `--repo`, an -unknown framework nothing claims, or an unknown subcommand. In a repo that has been -synced, a claim also requires the policy's compiled output to exist — a declared control -whose compiled mechanism was deleted is not counted. - -Builtin frameworks (one per data file in `src/chock/authoring/data/`, each enumerated -from its publisher's primary source): `owasp_asi` (ASI01–10), `mitre_atlas` (170 -techniques, from the official machine-readable dataset), `nist_ai_rmf` (the 72 AI RMF 1.0 -subcategories), `eu_ai_act` (a curated set of technical-obligation articles). A policy -claims controls in its manifest's `compliance:` block, keyed by framework name — unknown -framework names still validate, so private frameworks work with `--json` and your own -control list. +Lists the framework controls and which installed policies claim to cover them. Each control's state is `covered`, +`partial`, or `uncovered` (per-claim `coverage` on a policy is `partial` or `full`). The command fails closed with +exit 2 on a missing `--repo`, an unknown framework nothing claims, or an unknown subcommand. In a repo that has been +synced, a claim also requires the policy's compiled output to exist — a declared control whose compiled mechanism +was deleted is not counted. + +Builtin frameworks (one per data file in `src/chock/authoring/data/`, each enumerated from its publisher's primary +source): `owasp_asi` (ASI01–10), `mitre_atlas` (170 techniques, from the official machine-readable dataset), +`nist_ai_rmf` (the 72 AI RMF 1.0 subcategories), `eu_ai_act` (a curated set of technical-obligation articles). A +policy claims controls in its manifest's `compliance:` block, keyed by framework name — unknown framework names +still validate, so private frameworks work with `--json` and your own control list. ## Pre-launch aliases diff --git a/pyproject.toml b/pyproject.toml index ea72523..08e815e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ # It never ships to an adopter repo -- the vendored PreToolUse/SessionStart runners # stay self-contained stdlib-only files, unaffected by this dependency. Pinned exact # per plan/spine-a/contract.md: the wave boundary is the published PyPI artifact. - "agentseam==0.3.1", + "agentseam==0.3.2", ] [project.optional-dependencies] diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index 488253e..bd48b58 100644 --- a/requirements/brand-assets.in +++ b/requirements/brand-assets.in @@ -1,5 +1,5 @@ pyyaml>=6.0 jsonschema>=4.18,<5 referencing>=0.35,<0.38 -agentseam==0.3.1 +agentseam==0.3.2 cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index 6c258c6..1360237 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.3.1 \ - --hash=sha256:4e5b249162204f0bd90c60e2c67a8417c999881a786d4d18a4e85dca570c6564 \ - --hash=sha256:db7099395724122989ef8fbf1115d09da2cb0194fff9ce2a3ebe979793fc5271 +agentseam==0.3.2 \ + --hash=sha256:63616579111f24db9045113a3ef123f6cbb399333c2affb65f8bbb6956d2fb10 \ + --hash=sha256:869448a47184dc523491932557f4d87243a6fd7e05d5c51913804013395e643c # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 9d5bce0..7d969b0 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -98,9 +98,16 @@ def hook_entry(command: str, *, matcher: str | None = None) -> dict[str, Any]: def hooks_map_file(vendor: str, command: str) -> dict[str, Any]: - """A claude-plugin-format hooks file under `vendor`'s own pre-tool event spelling.""" + """A hooks file under `vendor`'s own pre-tool event spelling. + + Wrapped in a top-level `hooks` key (the claude-plugin format) unless `vendor`'s own + hook_entry is bare -- Devin's native `hooks.json` at the plugin root is the event map + itself, with no wrapper, unlike the nested `hooks/hooks.json` every other format here + shares. + """ matcher = vendors.shell_matcher(vendor) - return {"hooks": {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]}} + event_map = {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]} + return event_map if vendors.hook_entry_bare(vendor) else {"hooks": event_map} def cursor_entry(command: str) -> dict[str, Any]: diff --git a/src/chock/plugin/cli.py b/src/chock/plugin/cli.py index 3ddcb52..ae063c3 100644 --- a/src/chock/plugin/cli.py +++ b/src/chock/plugin/cli.py @@ -21,17 +21,19 @@ from chock.plugin.codex import build_codex_plugin, codex_plugin_differences from chock.plugin.copilot import build_copilot_plugin, copilot_plugin_differences from chock.plugin.cursor import build_cursor_plugin, cursor_plugin_differences +from chock.plugin.devin import build_devin_plugin, devin_plugin_differences from chock.scaffold.recompile import discover_policy_dirs -FORMATS = ("agent-plugins", "claude", "copilot", "cursor", "codex") +FORMATS = ("agent-plugins", "claude", "copilot", "cursor", "codex", "devin") -HOOK_FORMATS = frozenset({"claude", "copilot", "cursor", "codex"}) +HOOK_FORMATS = frozenset({"claude", "copilot", "cursor", "codex", "devin"}) HOOK_EMITTERS = { "claude": (claude_plugin_differences, build_claude_plugin), "copilot": (copilot_plugin_differences, build_copilot_plugin), "cursor": (cursor_plugin_differences, build_cursor_plugin), "codex": (codex_plugin_differences, build_codex_plugin), + "devin": (devin_plugin_differences, build_devin_plugin), } @@ -216,5 +218,7 @@ def main(argv: list[str] | None = None) -> int: print(" .cursor-plugin/plugin.json + hooks/ (beforeShellExecution) per guard policy") if "codex" in formats: print(" .codex-plugin/plugin.json + hooks/ (PreToolUse) per guard policy") + if "devin" in formats: + print(" .devin-plugin/plugin.json + hooks.json (PreToolUse, best-effort) per guard policy") print(" Skills are advisory in any client. Repo-level enforcement still needs `chock sync`.") return 0 diff --git a/src/chock/plugin/data/stores/devin.json b/src/chock/plugin/data/stores/devin.json new file mode 100644 index 0000000..80914e2 --- /dev/null +++ b/src/chock/plugin/data/stores/devin.json @@ -0,0 +1,3 @@ +{ + "owned_subtrees": ["scripts", "hooks.json"] +} diff --git a/src/chock/plugin/devin.py b/src/chock/plugin/devin.py new file mode 100644 index 0000000..7ea571e --- /dev/null +++ b/src/chock/plugin/devin.py @@ -0,0 +1,131 @@ +"""Emit a Devin plugin (`.devin-plugin/`) from a policy directory.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agentseam import packaging + +from chock.compile.emitters.in_agent import _guard_script, hooks_map_file +from chock.plugin import posture, store +from chock.plugin.build import ( + _ADVISORY_NOTE_HOOK, + _ADVISORY_NOTE_RULE, + LICENSE_REL, + _one_line, + build_skill, + license_text, + plugin_name, +) +from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source +from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE + +_LAYOUT = packaging.layout("devin") +HOOKS_REL = packaging.supports("devin", packaging.HOOKS) + +#: agentseam records no `declares` convention for Devin (unlike codex_cli/cursor's +#: "./skills"): the vendor's own manifest doc says only "plugin-root-relative, no ..", so +#: this is the plainest value that satisfies that, not a borrowed one. +SKILLS_DECLARED_REL = "skills" + +#: Devin's manifest fields, per the vendor's plugin reference: name, version, description, +#: skills. No author/license/repository/keywords/interface/hooks -- none of those are +#: documented for this manifest, and hooks.json is auto-discovered at the plugin root +#: rather than declared here (unlike codex_cli/cursor, whose manifests must point at +#: `./hooks/hooks.json`). +MANIFEST_KEYS = ( + "name", + "version", + "description", + "skills", +) + +POSTURE_BESTEFFORT_DEVIN = posture.enforced_devin() + +_BESTEFFORT_NOTE_DEVIN = ( + "This policy ships a PreToolUse hook in this plugin's hooks.json, best-effort in Devin: " + "fail-open by the vendor's own design, subject to the fail conditions stated in the " + "plugin description. Repo-wide git-hook and CI coverage still needs `chock sync`. " + "See https://github.com/open-coder-ai/chock" +) + +#: Devin documents that hook commands receive this environment variable, not a `${...}` token +#: expandable inside a hooks.json string -- agentseam's `plugin_root("devin")` is None, so +#: `packaging.executable_ref` returns None for this vendor, unlike codex_cli/cursor's +#: `${PLUGIN_ROOT}`/`${CURSOR_PLUGIN_ROOT}`. Devin runs `type: command` hook entries "the same +#: format as Claude Code hooks", which executes them through a shell, so a shell expansion of +#: the documented variable is chock's own construction here, not a vendor-recorded token. +DEVIN_PLUGIN_ROOT_VAR = "DEVIN_PLUGIN_ROOT" + + +def _hook_command(script: str) -> str: + """One interpreter invocation, via a shell expansion of `$DEVIN_PLUGIN_ROOT`.""" + adapter = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='devin.py')}" + guard = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name=script)}" + return f'python3 "{adapter}" --guard "{guard}"' + + +def build_devin_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: + """Derive `.devin-plugin/plugin.json` from a policy manifest.""" + policy_id = str(manifest.get("id") or Path(policy_dir).name) + posture_text = POSTURE_BESTEFFORT_DEVIN if enforced else POSTURE_ADVISORY + + description = _one_line(manifest.get("description")) + + data: dict[str, Any] = { + "name": plugin_name(policy_id), + "description": f"{description} [{posture_text}]".strip(), + "skills": SKILLS_DECLARED_REL, + } + if manifest.get("version"): + data["version"] = str(manifest["version"]) + return {key: data[key] for key in MANIFEST_KEYS if key in data} + + +def devin_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Path) -> dict[Path, str]: + """The Devin plugin's files as {relative path: content}, writing nothing.""" + policy_dir = Path(policy_dir) + policy_id = str(manifest.get("id") or policy_dir.name) + name = plugin_name(policy_id) + script = _guard_script(policy_dir, policy_id) + + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if script else None) + if script: + skill = skill.replace(_ADVISORY_NOTE_RULE, _BESTEFFORT_NOTE_DEVIN).replace( + _ADVISORY_NOTE_HOOK, _BESTEFFORT_NOTE_DEVIN + ) + + files: dict[Path, str] = { + Path(_LAYOUT["manifest"]): json.dumps( + build_devin_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + ) + + "\n", + Path(packaging.supports("devin", packaging.SKILL).format(name=name)): skill, + } + licence = license_text(manifest) + if licence: + files[LICENSE_REL] = licence + if script: + files[Path(HOOKS_REL)] = json.dumps(hooks_map_file("devin", _hook_command(script)), indent=2) + "\n" + files[Path(_SCRIPTS_TEMPLATE.format(name="devin.py"))] = _adapter_source("devin") + files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( + encoding="utf-8" + ) + return files + + +def stale_devin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path) -> list[Path]: + """Files under this package that the current manifest would no longer produce.""" + return store.stale_store_files("devin", devin_plugin_files, policy_dir, manifest, repo_root, out_dir) + + +def build_devin_plugin(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path) -> list[Path]: + """Write the Devin package for one policy into a distribution directory.""" + return store.build_store_plugin("devin", devin_plugin_files, policy_dir, manifest, repo_root, out_dir) + + +def devin_plugin_differences(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path) -> list[str]: + """Report where the on-disk Devin plugin disagrees with what the manifest would produce.""" + return store.store_plugin_differences("devin", devin_plugin_files, policy_dir, manifest, repo_root, out_dir) diff --git a/src/chock/plugin/marketplace.py b/src/chock/plugin/marketplace.py index 6e88a86..4d4755c 100644 --- a/src/chock/plugin/marketplace.py +++ b/src/chock/plugin/marketplace.py @@ -6,187 +6,31 @@ import json import sys from pathlib import Path -from typing import Any - -from agentseam import packaging from chock.emit import write_generated -from chock.lock import compute_pack_hash -from chock.vendors import CHOCK_AGENT - -OWNER = {"name": "open-coder-ai", "url": "https://github.com/open-coder-ai"} - -TREES: dict[str, dict[str, Any]] = { - "claude": { - "index_paths": (Path(".claude-plugin/marketplace.json"), Path(".github/plugin/marketplace.json")), - "style": "claude", - }, - "codex": { - "index_paths": (Path(".claude-plugin/marketplace.json"),), - "style": "claude", - }, - "cursor": { - "index_paths": (Path(".cursor-plugin/marketplace.json"),), - "style": "cursor", - }, -} - -CLAUDE_TREE = "claude" -INDEX_PATHS = TREES["claude"]["index_paths"] - - -def _manifest_rel(tree: str) -> str: - """This tree's plugin manifest path, from agentseam's packaging layout.""" - return packaging.layout(CHOCK_AGENT[tree])["manifest"] - - -def collect_entries(dist_root: Path, tree: str = CLAUDE_TREE) -> list[dict[str, Any]]: - """Index entries from the built plugin manifests, sorted by directory name.""" - manifest_rel = _manifest_rel(tree) - entries: list[dict[str, Any]] = [] - for manifest_path in sorted(Path(dist_root).glob(f"{tree}/*/{manifest_rel}")): - data = json.loads(manifest_path.read_text(encoding="utf-8")) - package_dir = manifest_path.parent.parent if manifest_rel.count("/") else manifest_path.parent - entry: dict[str, Any] = { - "name": data["name"], - "source": f"./{tree}/{package_dir.name}", - "description": data.get("description", ""), - } - if data.get("version"): - entry["version"] = data["version"] - entries.append(entry) - return entries - - -DESCRIPTION = ( - "Chock policies packaged as installable plugins. Generated from the chock-catalog; " - "each plugin states whether it enforces in this client or is advisory." +from chock.plugin.marketplace_core import ( + CATALOG_PAGE, + CLAUDE_TREE, + DESCRIPTION, + INDEX_PATHS, + LOCKFILE_NAME, + NEWLINE, + TREES, + build_index, + build_lock, + catalog_page_differences, + collect_entries, + index_differences, + lock_differences, + render_catalog_page, +) +from chock.plugin.marketplace_devin import ( + DEVIN_ROOT_MANIFEST_REL, + build_devin_root_manifest, + devin_root_manifest_differences, ) - -def build_index(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> dict[str, Any]: - entries = collect_entries(dist_root, tree) - if TREES[tree]["style"] == "cursor": - return { - "name": name, - "owner": {"name": OWNER["name"]}, - "metadata": {"description": DESCRIPTION}, - "plugins": [{k: e[k] for k in ("name", "source", "description")} for e in entries], - } - return { - "name": name, - "owner": OWNER, - "description": DESCRIPTION, - "plugins": entries, - } - - -LOCKFILE_NAME = "chock-market.lock" - -NEWLINE = chr(10) - - -def build_lock(dist_root: Path) -> dict[str, Any]: - """Hash every plugin directory in every format tree, sorted for a stable diff.""" - dist_root = Path(dist_root) - plugins: dict[str, str] = {} - for manifest in sorted(dist_root.glob("*/*/")): - if not manifest.is_dir() or manifest.parts[-2].startswith("."): - continue - rel = manifest.relative_to(dist_root).as_posix() - plugins[rel] = compute_pack_hash(manifest) - return {"lockfile_version": 1, "plugins": plugins} - - -def lock_differences(dist_root: Path) -> list[str]: - """Report where the on-disk lockfile disagrees with the tree it describes.""" - content = json.dumps(build_lock(dist_root), indent=2, sort_keys=True) + NEWLINE - dest = Path(dist_root) / LOCKFILE_NAME - if not dest.exists(): - return [f"missing: {LOCKFILE_NAME}"] - return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {LOCKFILE_NAME}"] - - -CATALOG_PAGE = "PLUGINS.md" - -CATALOG_DOCS = "https://github.com/open-coder-ai/chock-catalog/blob/main/docs" - - -#: Summaries longer than this are truncated (at _SUMMARY_TRUNCATE_AT) with an ellipsis. -_SUMMARY_MAX_LEN = 99 -_SUMMARY_TRUNCATE_AT = 96 - - -def _summary(description: str) -> str: - """First sentence of the description, with the bracketed posture note stripped.""" - text = description.split("[", maxsplit=1)[0].strip() - first = text.split(". ")[0].strip().rstrip(".") - return (first[:_SUMMARY_TRUNCATE_AT].rstrip() + "...") if len(first) > _SUMMARY_MAX_LEN else first - - -def render_catalog_page(dist_root: Path, tree: str = CLAUDE_TREE) -> str: - """The generated catalog: how many packages enforce, how many advise, and which.""" - dist_root = Path(dist_root) - rows = [] - enforcing = 0 - manifest_rel = _manifest_rel(tree) - for manifest_path in sorted(dist_root.glob(f"{tree}/*/{manifest_rel}")): - pkg = manifest_path.parent.parent - data = json.loads(manifest_path.read_text(encoding="utf-8")) - has_hook = (pkg / "hooks" / "hooks.json").exists() - enforcing += 1 if has_hook else 0 - posture = "enforces" if has_hook else "advisory" - name = data["name"] - rows.append( - f"| [`{name}`]({CATALOG_DOCS}/{name}/README.md) " - f"| {data.get('version', '-')} | {posture} | {_summary(data.get('description', ''))} |" - ) - - total = len(rows) - lines = [ - "# Published plugins", - "", - "", - "", - f"**{total} policies are published here: {enforcing} enforce in this client, " - f"{total - enforcing} are advisory.**", - "", - "An enforcing package ships a `PreToolUse` hook, a guard script and a stdlib-only", - "adapter, and can deny a shell command before the client runs it. It fails open when", - "`python3` or a usable `bash` is unavailable, and asks -- on Codex CLI, denies -- when", - "the guard crashes. An advisory package ships skill text; nothing stops a violation.", - "", - "| plugin | version | in this client | what it does |", - "| :--- | :--- | :--- | :--- |", - *rows, - "", - f"Each name links to its full policy page in the [catalog]({CATALOG_DOCS}): what it", - "solves, how it works, and its honest reach.", - "", - ] - return NEWLINE.join(lines) - - -def catalog_page_differences(dist_root: Path, tree: str = CLAUDE_TREE) -> list[str]: - """Report a catalog page that disagrees with the tree it describes.""" - dest = Path(dist_root) / CATALOG_PAGE - content = render_catalog_page(dist_root, tree) - if not dest.exists(): - return [f"missing: {CATALOG_PAGE}"] - return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {CATALOG_PAGE}"] - - -def index_differences(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> list[str]: - """Report where the on-disk index files disagree with the plugin tree.""" - content = json.dumps(build_index(dist_root, name, tree), indent=2) + "\n" - differences: list[str] = [] - for rel in TREES[tree]["index_paths"]: - dest = Path(dist_root) / rel - if not dest.exists(): - differences.append(f"missing: {rel.as_posix()}") - elif dest.read_text(encoding="utf-8") != content: - differences.append(f"differs: {rel.as_posix()}") - return differences +__all__ = ["CATALOG_PAGE", "DESCRIPTION", "INDEX_PATHS", "LOCKFILE_NAME", "main"] def main(argv: list[str] | None = None) -> int: @@ -208,8 +52,22 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Report index files that are missing or stale, and exit non-zero. Writes nothing.", ) + parser.add_argument( + "--url", + default=None, + help="Git URL of this marketplace's own repo. Required for --tree devin's root meta-plugin; " + "chock does not read `git remote` for this.", + ) args = parser.parse_args(argv) + if args.tree == "devin" and not args.url: + print( + "--tree devin requires --url: its root meta-plugin's git-subdir entries need this " + "marketplace's own repo URL, and chock will not guess it from `git remote`.", + file=sys.stderr, + ) + return 2 + dist_root = Path(args.dist).resolve() entries = collect_entries(dist_root, args.tree) if not entries: @@ -218,7 +76,11 @@ def main(argv: list[str] | None = None) -> int: if args.check: differences = ( - index_differences(dist_root, args.name, args.tree) + ( + devin_root_manifest_differences(dist_root, args.name, args.url) + if args.tree == "devin" + else index_differences(dist_root, args.name, args.tree) + ) + lock_differences(dist_root) + catalog_page_differences(dist_root, args.tree) ) @@ -231,19 +93,26 @@ def main(argv: list[str] | None = None) -> int: print(f"Marketplace index matches the plugin tree ({len(entries)} plugins).") return 0 - index_paths = TREES[args.tree]["index_paths"] - content = json.dumps(build_index(dist_root, args.name, args.tree), indent=2) + "\n" - for rel in index_paths: - dest = dist_root / rel + if args.tree == "devin": + content = json.dumps(build_devin_root_manifest(dist_root, args.name, args.url), indent=2) + "\n" + dest = dist_root / DEVIN_ROOT_MANIFEST_REL dest.parent.mkdir(parents=True, exist_ok=True) write_generated(dest, content) + print(f"Wrote {DEVIN_ROOT_MANIFEST_REL.as_posix()}: a meta-plugin referencing {len(entries)} plugins") + else: + index_paths = TREES[args.tree]["index_paths"] + content = json.dumps(build_index(dist_root, args.name, args.tree), indent=2) + "\n" + for rel in index_paths: + dest = dist_root / rel + dest.parent.mkdir(parents=True, exist_ok=True) + write_generated(dest, content) + print(f"Indexed {len(entries)} plugins into {' and '.join(p.as_posix() for p in index_paths)}") lock = json.dumps(build_lock(dist_root), indent=2, sort_keys=True) + NEWLINE write_generated(dist_root / LOCKFILE_NAME, lock) write_generated(dist_root / CATALOG_PAGE, render_catalog_page(dist_root, args.tree)) - print(f"Indexed {len(entries)} plugins into {' and '.join(p.as_posix() for p in index_paths)}") print(f"Wrote {LOCKFILE_NAME}: sha256 per published plugin directory") print(f"Wrote {CATALOG_PAGE}: {len(entries)} plugins with their posture") return 0 diff --git a/src/chock/plugin/marketplace_core.py b/src/chock/plugin/marketplace_core.py new file mode 100644 index 0000000..366bec1 --- /dev/null +++ b/src/chock/plugin/marketplace_core.py @@ -0,0 +1,224 @@ +"""Tree-generic marketplace logic: index files, the lockfile, and the catalog page. + +Split out of marketplace.py to keep both files under the 300-line review budget. Never +imports marketplace_devin, so devin's own import of this module stays acyclic. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agentseam import packaging + +from chock.lock import compute_pack_hash +from chock.vendors import CHOCK_AGENT + +OWNER = {"name": "open-coder-ai", "url": "https://github.com/open-coder-ai"} + +TREES: dict[str, dict[str, Any]] = { + "claude": { + "index_paths": (Path(".claude-plugin/marketplace.json"), Path(".github/plugin/marketplace.json")), + "style": "claude", + }, + "codex": { + "index_paths": (Path(".claude-plugin/marketplace.json"),), + "style": "claude", + }, + "cursor": { + "index_paths": (Path(".cursor-plugin/marketplace.json"),), + "style": "cursor", + }, + # Devin has no index-file format: the vendor's own template (CognitionAI/team-marketplace- + # template) is a repo whose root .devin-plugin/plugin.json IS the marketplace, a meta-plugin + # whose optionalPlugins point git-subdir entries at each plugin's own folder. index_paths + # stays empty; marketplace.main() routes "devin" to marketplace_devin instead. + "devin": {"index_paths": (), "style": "devin"}, +} + +CLAUDE_TREE = "claude" +INDEX_PATHS = TREES["claude"]["index_paths"] + + +def _manifest_rel(tree: str) -> str: + """This tree's plugin manifest path, from agentseam's packaging layout.""" + return packaging.layout(CHOCK_AGENT[tree])["manifest"] + + +def collect_entries(dist_root: Path, tree: str = CLAUDE_TREE) -> list[dict[str, Any]]: + """Index entries from the built plugin manifests, sorted by directory name.""" + manifest_rel = _manifest_rel(tree) + entries: list[dict[str, Any]] = [] + for manifest_path in sorted(Path(dist_root).glob(f"{tree}/*/{manifest_rel}")): + data = json.loads(manifest_path.read_text(encoding="utf-8")) + package_dir = manifest_path.parent.parent if manifest_rel.count("/") else manifest_path.parent + entry: dict[str, Any] = { + "name": data["name"], + "source": f"./{tree}/{package_dir.name}", + "description": data.get("description", ""), + } + if data.get("version"): + entry["version"] = data["version"] + entries.append(entry) + return entries + + +DESCRIPTION = ( + "Chock policies packaged as installable plugins. Generated from the chock-catalog; " + "each plugin states whether it enforces in this client or is advisory." +) + + +def build_index(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> dict[str, Any]: + entries = collect_entries(dist_root, tree) + if TREES[tree]["style"] == "cursor": + return { + "name": name, + "owner": {"name": OWNER["name"]}, + "metadata": {"description": DESCRIPTION}, + "plugins": [{k: e[k] for k in ("name", "source", "description")} for e in entries], + } + return { + "name": name, + "owner": OWNER, + "description": DESCRIPTION, + "plugins": entries, + } + + +LOCKFILE_NAME = "chock-market.lock" + +NEWLINE = chr(10) + + +def build_lock(dist_root: Path) -> dict[str, Any]: + """Hash every plugin directory in every format tree, sorted for a stable diff.""" + dist_root = Path(dist_root) + plugins: dict[str, str] = {} + for manifest in sorted(dist_root.glob("*/*/")): + if not manifest.is_dir() or manifest.parts[-2].startswith("."): + continue + rel = manifest.relative_to(dist_root).as_posix() + plugins[rel] = compute_pack_hash(manifest) + return {"lockfile_version": 1, "plugins": plugins} + + +def lock_differences(dist_root: Path) -> list[str]: + """Report where the on-disk lockfile disagrees with the tree it describes.""" + content = json.dumps(build_lock(dist_root), indent=2, sort_keys=True) + NEWLINE + dest = Path(dist_root) / LOCKFILE_NAME + if not dest.exists(): + return [f"missing: {LOCKFILE_NAME}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {LOCKFILE_NAME}"] + + +CATALOG_PAGE = "PLUGINS.md" + +CATALOG_DOCS = "https://github.com/open-coder-ai/chock-catalog/blob/main/docs" + +#: Summaries longer than this are truncated (at _SUMMARY_TRUNCATE_AT) with an ellipsis. +_SUMMARY_MAX_LEN = 99 +_SUMMARY_TRUNCATE_AT = 96 + + +def _summary(description: str) -> str: + """First sentence of the description, with the bracketed posture note stripped.""" + text = description.split("[", maxsplit=1)[0].strip() + first = text.split(". ")[0].strip().rstrip(".") + return (first[:_SUMMARY_TRUNCATE_AT].rstrip() + "...") if len(first) > _SUMMARY_MAX_LEN else first + + +def _hooks_rel(tree: str) -> str: + """This tree's hooks-file path, relative to a package directory (root or nested).""" + return packaging.supports(CHOCK_AGENT[tree], packaging.HOOKS) + + +#: Per-tree catalog-page vocabulary. Devin gets its own row/summary/explain text so the page +#: never claims "enforce"/"block" for a fail-open, best-effort client; others keep the original. +_CATALOG_WORDS: dict[str, dict[str, str]] = { + "devin": { + "row": "best-effort", + "summary": "{enforcing} are best-effort in this client, {advisory} are advisory", + "explain": ( + "A best-effort package ships a `PreToolUse` hook, a guard script and a stdlib-only " + "adapter. Devin's own docs call plugin hooks fail-open by design -- a hook that " + "fails to load or run lets the session continue without it -- so this is not a " + "guarantee. An advisory package ships skill text; nothing stops a violation." + ), + }, +} +_DEFAULT_CATALOG_WORDS = { + "row": "enforces", + "summary": "{enforcing} enforce in this client, {advisory} are advisory", + "explain": ( + "An enforcing package ships a `PreToolUse` hook, a guard script and a stdlib-only " + "adapter, and can deny a shell command before the client runs it. It fails open when " + "`python3` or a usable `bash` is unavailable, and asks -- on Codex CLI, denies -- when " + "the guard crashes. An advisory package ships skill text; nothing stops a violation." + ), +} + + +def render_catalog_page(dist_root: Path, tree: str = CLAUDE_TREE) -> str: + """The generated catalog: how many packages enforce, how many advise, and which.""" + dist_root = Path(dist_root) + words = _CATALOG_WORDS.get(tree, _DEFAULT_CATALOG_WORDS) + rows = [] + enforcing = 0 + manifest_rel = _manifest_rel(tree) + hooks_rel = _hooks_rel(tree) + for manifest_path in sorted(dist_root.glob(f"{tree}/*/{manifest_rel}")): + pkg = manifest_path.parent.parent + data = json.loads(manifest_path.read_text(encoding="utf-8")) + has_hook = (pkg / hooks_rel).exists() + enforcing += 1 if has_hook else 0 + posture = words["row"] if has_hook else "advisory" + name = data["name"] + rows.append( + f"| [`{name}`]({CATALOG_DOCS}/{name}/README.md) " + f"| {data.get('version', '-')} | {posture} | {_summary(data.get('description', ''))} |" + ) + + total = len(rows) + summary = words["summary"].format(enforcing=enforcing, advisory=total - enforcing) + lines = [ + "# Published plugins", + "", + "", + "", + f"**{total} policies are published here: {summary}.**", + "", + *words["explain"].splitlines(), + "", + "| plugin | version | in this client | what it does |", + "| :--- | :--- | :--- | :--- |", + *rows, + "", + f"Each name links to its full policy page in the [catalog]({CATALOG_DOCS}): what it", + "solves, how it works, and its honest reach.", + "", + ] + return NEWLINE.join(lines) + + +def catalog_page_differences(dist_root: Path, tree: str = CLAUDE_TREE) -> list[str]: + """Report a catalog page that disagrees with the tree it describes.""" + dest = Path(dist_root) / CATALOG_PAGE + content = render_catalog_page(dist_root, tree) + if not dest.exists(): + return [f"missing: {CATALOG_PAGE}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {CATALOG_PAGE}"] + + +def index_differences(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> list[str]: + """Report where the on-disk index files disagree with the plugin tree.""" + content = json.dumps(build_index(dist_root, name, tree), indent=2) + "\n" + differences: list[str] = [] + for rel in TREES[tree]["index_paths"]: + dest = Path(dist_root) / rel + if not dest.exists(): + differences.append(f"missing: {rel.as_posix()}") + elif dest.read_text(encoding="utf-8") != content: + differences.append(f"differs: {rel.as_posix()}") + return differences diff --git a/src/chock/plugin/marketplace_devin.py b/src/chock/plugin/marketplace_devin.py new file mode 100644 index 0000000..0755714 --- /dev/null +++ b/src/chock/plugin/marketplace_devin.py @@ -0,0 +1,40 @@ +"""Devin's marketplace shape: a root meta-plugin, not an index file. + +Kept apart from marketplace.py so neither file crosses the 300-line review budget; this +module depends on marketplace_core, never the reverse, so importing it stays acyclic. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from chock.plugin.marketplace_core import collect_entries + +#: Where a Devin marketplace's own meta-plugin manifest lives, at the dist root -- distinct +#: from a per-plugin `devin//.devin-plugin/plugin.json` built by `plugin build --format devin`. +DEVIN_ROOT_MANIFEST_REL = Path(".devin-plugin/plugin.json") + +#: Kept apart from marketplace_core.DESCRIPTION: Devin's text must never claim "enforces" +#: (the vendor's plugin hooks are fail-open by design). +DEVIN_DESCRIPTION = ( + "Chock policies packaged as installable plugins. Generated from the chock-catalog; " + "each plugin states whether it is best-effort in this client or advisory." +) + + +def build_devin_root_manifest(dist_root: Path, name: str, url: str) -> dict[str, Any]: + """Devin's root meta-plugin: `optionalPlugins` git-subdir entries in place of an index file.""" + entries = collect_entries(dist_root, "devin") + plugins = [{"source": "git-subdir", "url": url, "path": e["source"].removeprefix("./")} for e in entries] + return {"name": name, "description": DEVIN_DESCRIPTION, "optionalPlugins": plugins} + + +def devin_root_manifest_differences(dist_root: Path, name: str, url: str) -> list[str]: + """Report a Devin root manifest that is missing, stale, or hand-edited.""" + content = json.dumps(build_devin_root_manifest(dist_root, name, url), indent=2) + "\n" + dest = Path(dist_root) / DEVIN_ROOT_MANIFEST_REL + if not dest.exists(): + return [f"missing: {DEVIN_ROOT_MANIFEST_REL.as_posix()}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {DEVIN_ROOT_MANIFEST_REL.as_posix()}"] diff --git a/src/chock/plugin/posture.py b/src/chock/plugin/posture.py index f38cd99..17bb5fd 100644 --- a/src/chock/plugin/posture.py +++ b/src/chock/plugin/posture.py @@ -33,6 +33,21 @@ def enforced_codex(*, ledger=None) -> str: ) +def enforced_devin(*, ledger=None) -> str: + """Devin's best-effort posture: the vendor's own fail-open caveat, session-scoped and unwitnessed.""" + return ( + "Best-effort in Devin by a PreToolUse hook shipped with the plugin's hooks.json " + f"({witness_clause('devin', ledger=ledger)}). In the vendor's own words, plugin hooks " + "are 'currently best effort and fail open -- if a hook fails to load or run, the " + "session continues without it -- so don't rely on them for crucial guardrails yet', " + "documented for local Devin sessions (the CLI and Devin Desktop) only. The hook " + "command expands $DEVIN_PLUGIN_ROOT, an environment variable the vendor documents " + "hook commands receive; whether that expansion happens inside a hooks.json command " + "string is documented, not witnessed here. The hook needs python3 on PATH. Repo-wide " + "git-hook and CI coverage still needs `chock sync`." + ) + + def enforced_cursor(*, ledger=None) -> str: """Cursor's enforced posture: the interpreters it needs, and what it does without them.""" return ( diff --git a/src/chock/plugin/store.py b/src/chock/plugin/store.py index 0e213bc..bb90b2c 100644 --- a/src/chock/plugin/store.py +++ b/src/chock/plugin/store.py @@ -29,6 +29,22 @@ def owned_subtrees(store: str) -> tuple[str, ...]: return tuple(data["owned_subtrees"]) +def _owned_candidates(out_dir: Path, sub: str) -> list[Path]: + """Files an owned-subtree entry can name: everything under a directory, or itself. + + Every existing store's entries are directories (`hooks/`, `scripts/`); Devin's native + hooks file sits at the plugin root instead of nested under a directory (`hooks.json`, + not `hooks/hooks.json`), so an entry that is a plain file is checked directly rather + than walked. + """ + target = out_dir / sub + if target.is_dir(): + return sorted(target.rglob("*")) + if target.is_file(): + return [target] + return [] + + def stale_store_files( store: str, files_fn: FilesFn, policy_dir: Path, manifest: dict[str, Any], repo_root: Path, out_dir: Path ) -> list[Path]: @@ -39,7 +55,7 @@ def stale_store_files( expected = set(files_fn(Path(policy_dir), manifest, Path(repo_root))) stale: list[Path] = [] for sub in owned_subtrees(store): - for path in sorted((out_dir / sub).rglob("*")) if (out_dir / sub).is_dir() else []: + for path in _owned_candidates(out_dir, sub): if path.is_file() and path.relative_to(out_dir) not in expected: stale.append(path) return stale diff --git a/src/chock/vendors.py b/src/chock/vendors.py index ee5b057..869b3f3 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -134,6 +134,20 @@ def write_matcher(vendor: str) -> str | None: return "|".join(tools) if tools else None +def hook_entry_bare(vendor: str) -> bool: + """Whether `vendor`'s own hook_config is the bare event map, with no top-level `hooks` key. + + Every vendor with a recorded `hook_entry` wraps its event map in `{"hooks": {...}}`, + except Devin: its native `hooks.json` (unlike the nested `hooks/hooks.json` the Claude + layout writes) is the bare map itself, confirmed by + `agentseam.adapters.get("devin").CONFIG["hook_entry"] == {"bare": True, ...}`. A vendor + with no recorded `hook_entry` (vscode_copilot, witnessed rather than derived) is not bare. + """ + config = getattr(_adapters.get(vendor), "CONFIG", None) + entry = (config or {}).get("hook_entry") if isinstance(config, dict) else None + return bool((entry or {}).get("bare")) + + def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> dict[str, Any]: """The vendor's complete hook-config document gating pre-tool with `command`.""" return _adapters.get(vendor).hook_config((_contract.PRE_TOOL,), command, matcher) diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index bfaa5c8..b2c7877 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("antigravity"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index e4c6207..d8f56f9 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -23,7 +23,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index ebad293..bdea4c5 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 498bab4..a4716f3 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 4c46897..cb1e666 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index db789c2..9e671e0 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 8f09b67..d61208d 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index bcb4148..b29b527 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 6218e70..d0d8d0b 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 90ae182..cd17905 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.2 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.2) """Canonical event vocabulary, normalized envelope, and decision type.""" diff --git a/tests/test_devin_plugin.py b/tests/test_devin_plugin.py new file mode 100644 index 0000000..94859fb --- /dev/null +++ b/tests/test_devin_plugin.py @@ -0,0 +1,177 @@ +"""Devin packaging: native layout, root-level hooks.json, and the best-effort posture.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from chock.gate import runtime_bundle +from chock.plugin.cli import main as plugin_main +from chock.plugin.devin import ( + _BESTEFFORT_NOTE_DEVIN, + POSTURE_BESTEFFORT_DEVIN, + build_devin_plugin, + devin_plugin_files, +) + +GUARD_MANIFEST = { + "id": "block-destructive-commands", + "name": "Block Destructive Commands", + "version": "0.0.1", + "description": "Block rm -rf and friends before they run.", + "artifact": "hook", + "enforcement": "block", + "provenance": { + "author": "chock-core", + "license": "Apache-2.0", + "source_repo": "https://github.com/open-coder-ai/chock", + }, +} + +RULE_MANIFEST = { + "id": "code-safety", + "name": "Code Safety Rule", + "version": "0.0.1", + "description": "Advisory rule with no gate.", + "artifact": "rule", + "enforcement": "advise", + "rule": {"text": "never(commit): secrets|keys|tokens"}, +} + +GUARD_BODY = "#!/usr/bin/env bash\nexit 0 # test fixture guard\n" + + +@pytest.fixture +def policy(tmp_path: Path): + def _make(manifest: dict, guard: bool = False) -> Path: + pack = tmp_path / ".agents" / "policies" / manifest["id"] + pack.mkdir(parents=True) + (pack / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + if guard: + impl = pack / "implementations" + impl.mkdir() + (impl / f"{manifest['id']}.sh").write_text(GUARD_BODY, encoding="utf-8") + return pack + + return _make + + +def test_devin_guard_policy_layout_and_hook(policy, tmp_path: Path) -> None: + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert set(files) == { + Path(".devin-plugin/plugin.json"), + Path("skills/block-destructive-commands/SKILL.md"), + Path("hooks.json"), + Path("scripts/devin.py"), + Path("scripts/block-destructive-commands.sh"), + } + + hooks = json.loads(files[Path("hooks.json")]) + assert set(hooks) == {"PreToolUse"}, "devin's own hook_entry is bare: no top-level `hooks` wrapper" + entry = hooks["PreToolUse"][0] + assert "matcher" not in entry, "devin's shell-tool vocabulary is unrecorded, never invented" + inner = entry["hooks"][0] + assert inner["type"] == "command" + assert inner["command"] == ( + 'python3 "$DEVIN_PLUGIN_ROOT/scripts/devin.py" --guard "$DEVIN_PLUGIN_ROOT/scripts/block-destructive-commands.sh"' + ) + + +def test_devin_hooks_shape_matches_agentseams_own_hook_config(policy, tmp_path: Path) -> None: + """The emitted file must honour agentseam's `bare` flag, not chock's own wrapping default.""" + from agentseam import adapters, contract + + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + hooks = json.loads(files[Path("hooks.json")]) + command = hooks["PreToolUse"][0]["hooks"][0]["command"] + + upstream = adapters.get("devin").hook_config((contract.PRE_TOOL,), command, None) + assert set(hooks) == set(upstream), "same top-level shape as agentseam's own hook_config for devin" + upstream_inner = upstream["PreToolUse"][0]["hooks"][0] + ours_inner = hooks["PreToolUse"][0]["hooks"][0] + assert {**ours_inner, "timeout": None} == {**upstream_inner, "timeout": None}, ( + "identical modulo chock's own timeout key" + ) + + +def test_devin_manifest_is_native_layout(policy, tmp_path: Path) -> None: + """`.devin-plugin/plugin.json` only, with the documented fields -- no interface/hooks key.""" + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert Path("plugin.json") not in files, "a root plugin.json is the Agent Plugins 1.0 shape" + assert Path(".codex-plugin/plugin.json") not in files + data = json.loads(files[Path(".devin-plugin/plugin.json")]) + assert set(data) == {"name", "version", "description", "skills"}, "only documented manifest fields" + assert data["skills"] == "skills" + assert "hooks" not in data, "devin auto-discovers hooks.json at the plugin root; nothing declares it" + assert Path("assets/icon.svg") not in files, "devin's manifest has no interface/composerIcon field" + + +def test_devin_rule_policy_gets_no_hook(policy, tmp_path: Path) -> None: + files = devin_plugin_files(policy(RULE_MANIFEST), RULE_MANIFEST, tmp_path) + assert set(files) == {Path(".devin-plugin/plugin.json"), Path("skills/code-safety/SKILL.md")} + assert Path("LICENSE") not in files + assert Path("hooks.json") not in files + + +def test_devin_adapter_and_guard_are_verbatim_copies(policy, tmp_path: Path) -> None: + """Byte-identity is the contract: a plugin must not parse payloads differently from `chock sync`.""" + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert files[Path("scripts/devin.py")] == runtime_bundle.render("devin") + assert files[Path("scripts/block-destructive-commands.sh")] == GUARD_BODY + + +def test_devin_package_claims_match_the_package(policy, tmp_path: Path) -> None: + """Description, skill frontmatter and closing note must all agree with the hook.""" + files = devin_plugin_files(policy(GUARD_MANIFEST, guard=True), GUARD_MANIFEST, tmp_path) + assert POSTURE_BESTEFFORT_DEVIN in json.loads(files[Path(".devin-plugin/plugin.json")])["description"] + + skill = files[Path("skills/block-destructive-commands/SKILL.md")] + meta = yaml.safe_load(skill.split("---")[1])["metadata"] + assert meta["chock.hooks"] == "hooks.json" + assert "chock.coverage_without_chock" not in meta + assert "advisory: the client reading it has no mechanism to enforce it" not in skill + + +def test_devin_posture_never_claims_enforcement(policy, tmp_path: Path) -> None: + """The vendor's own fail-open caveat, and no 'enforced'/'block' anywhere in the emitted text.""" + for forbidden in ("enforced", "block"): + assert forbidden not in POSTURE_BESTEFFORT_DEVIN + + assert "best effort and fail open" in POSTURE_BESTEFFORT_DEVIN + assert "don't rely on them for crucial guardrails yet" in POSTURE_BESTEFFORT_DEVIN + assert "local Devin sessions (the CLI and Devin Desktop)" in POSTURE_BESTEFFORT_DEVIN + assert "not witnessed" in POSTURE_BESTEFFORT_DEVIN + assert "`chock sync`" in POSTURE_BESTEFFORT_DEVIN + + for forbidden in ("enforced", "enforces", "block"): + assert forbidden not in _BESTEFFORT_NOTE_DEVIN, "the skill's closing note names no enforcement tier" + + +def test_devin_cli_builds_and_refuses_in_place(policy, tmp_path: Path, capsys) -> None: + policy(GUARD_MANIFEST, guard=True) + out = tmp_path / "dist" + + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "devin"]) == 2, "in place must be refused" + capsys.readouterr() + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "devin", "--out-dir", str(out)]) == 0 + capsys.readouterr() + + assert (out / "devin" / "block-destructive-commands" / ".devin-plugin" / "plugin.json").exists() + assert (out / "devin" / "block-destructive-commands" / "hooks.json").exists() + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "devin", "--out-dir", str(out), "--check"]) == 0 + + +def test_devin_losing_a_guard_removes_the_root_hook(policy, tmp_path: Path) -> None: + """`hooks.json` sits at the plugin root; losing the guard must still remove it (store.py's""" + pack = policy(GUARD_MANIFEST, guard=True) + out = tmp_path / "dist" / "devin" / "block-destructive-commands" + build_devin_plugin(pack, GUARD_MANIFEST, tmp_path, out) + assert (out / "hooks.json").exists() + + (pack / "implementations" / "block-destructive-commands.sh").unlink() + build_devin_plugin(pack, GUARD_MANIFEST, tmp_path, out) + assert not (out / "hooks.json").exists() + assert not (out / "scripts").exists() diff --git a/tests/test_marketplace.py b/tests/test_marketplace.py index 128274b..7dc40d2 100644 --- a/tests/test_marketplace.py +++ b/tests/test_marketplace.py @@ -188,3 +188,44 @@ def test_codex_tree_reuses_the_witnessed_legacy_index(dist: Path) -> None: sources = [entry["source"] for entry in index["plugins"]] assert sources and all(s.startswith("./codex/") for s in sources) assert not (dist / ".github" / "plugin" / "marketplace.json").exists() + + +DEVIN_URL = "https://github.com/open-coder-ai/chock-devin-plugins" + + +def test_devin_tree_requires_url(dist: Path, capsys) -> None: + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin"]) == 2 + assert "requires --url" in capsys.readouterr().err + assert not (dist / ".devin-plugin" / "plugin.json").exists() + + +def test_devin_tree_writes_a_root_meta_plugin_not_an_index_file(dist: Path) -> None: + """`--tree devin` writes a root `.devin-plugin/plugin.json` meta-plugin, no index file.""" + assert ( + marketplace_main(["build", "--dist", str(dist), "--name", "chock-devin", "--tree", "devin", "--url", DEVIN_URL]) + == 0 + ) + manifest = json.loads((dist / ".devin-plugin" / "plugin.json").read_text(encoding="utf-8")) + + assert manifest["name"] == "chock-devin" + assert "enforces" not in manifest["description"] and "block" not in manifest["description"] + assert manifest["optionalPlugins"], "at least one plugin from the built tree" + for plugin in manifest["optionalPlugins"]: + assert set(plugin) == {"source", "url", "path"} + assert plugin["source"] == "git-subdir" + assert plugin["url"] == DEVIN_URL + assert plugin["path"].startswith("devin/") + assert not (dist / ".claude-plugin" / "marketplace.json").exists() + assert not (dist / ".cursor-plugin" / "marketplace.json").exists() + + +def test_devin_tree_check_catches_missing_stale_and_hand_edited(dist: Path, capsys) -> None: + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL, "--check"]) == 1 + assert "missing" in capsys.readouterr().out + + marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL]) + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL, "--check"]) == 0 + + (dist / ".devin-plugin" / "plugin.json").write_text("{}", encoding="utf-8") + assert marketplace_main(["build", "--dist", str(dist), "--tree", "devin", "--url", DEVIN_URL, "--check"]) == 1 + assert "differs" in capsys.readouterr().out