diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc3a99e..275665e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,33 +3,139 @@ name: Release on: push: tags: - - "v*" + - "v[0-9]*" + +permissions: + contents: read jobs: - build-and-publish: - name: Build & Publish to PyPI + verify-and-build: + name: Verify & Build Release Artifacts runs-on: ubuntu-latest - permissions: - id-token: write # For PyPI trusted publishing steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Require tag at current main + shell: bash + run: | + git fetch origin main + test "$GITHUB_SHA" = "$(git rev-parse origin/main)" - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - - name: Install build tools - run: pip install build + - name: Install release dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" build hatchling twine - - name: Build package - run: python -m build + - name: Build Python distributions and Agent plugin bundle + shell: bash + run: | + python -m build --sdist --wheel --outdir dist + python scripts/build_plugin_bundle.py \ + --tag "$GITHUB_REF_NAME" \ + --output-dir release-assets + python -m twine check dist/* + ( + cd dist + sha256sum * + ) > ../release-assets/SHA256SUMS + ( + cd release-assets + sha256sum celltypepilot-plugin-*.zip + ) >> SHA256SUMS - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Run release verification suite + run: | + ruff check src/ tests/ scripts/build_plugin_bundle.py + ruff format --check src/ tests/ scripts/build_plugin_bundle.py + pytest --cov=celltypepilot --cov-report=term-missing + + - name: Smoke-test installed wheel and plugin bundle + shell: bash + run: | + python -m venv --system-site-packages .release-smoke + .release-smoke/bin/python -m pip install --no-deps --force-reinstall dist/*.whl + .release-smoke/bin/python -c \ + "import celltypepilot; assert celltypepilot.__version__ == '${GITHUB_REF_NAME#v}'" + .release-smoke/bin/celltypepilot --version + .release-smoke/bin/celltypepilot doctor --json + + mkdir plugin-smoke + python -m zipfile -e release-assets/celltypepilot-plugin-*.zip plugin-smoke + .release-smoke/bin/python -m pip install \ + --no-build-isolation --no-deps --force-reinstall \ + plugin-smoke/celltypepilot-plugin-* + .release-smoke/bin/celltypepilot --version + + - name: Upload Python distributions + uses: actions/upload-artifact@v7 + with: + name: python-distributions + path: dist/* + if-no-files-found: error + retention-days: 7 + + - name: Upload Agent plugin bundle + uses: actions/upload-artifact@v7 + with: + name: agent-plugin-bundle + path: release-assets/* + if-no-files-found: error + retention-days: 7 + + github-release: + name: Publish GitHub Plugin Release + needs: verify-and-build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Python distributions + uses: actions/download-artifact@v8 + with: + name: python-distributions + path: release-assets/python - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + - name: Download Agent plugin bundle + uses: actions/download-artifact@v8 + with: + name: agent-plugin-bundle + path: release-assets/plugin + + - name: Create GitHub Release before PyPI publication + uses: softprops/action-gh-release@v3 with: generate_release_notes: true - files: dist/* + prerelease: ${{ startsWith(github.ref_name, 'v0.') }} + fail_on_unmatched_files: true + files: | + release-assets/python/* + release-assets/plugin/* + + pypi-publish: + name: Publish Python Backend to PyPI + needs: github-release + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/celltypepilot + permissions: + id-token: write + steps: + - name: Download verified Python distributions + uses: actions/download-artifact@v8 + with: + name: python-distributions + path: dist + + - name: Publish backend with Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + print-hash: true diff --git a/.gitignore b/.gitignore index 4c7f2cc..0d494b7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ *.egg-info/ dist/ build/ +release-assets/ +plugin-smoke/ +.release-smoke/ .eggs/ *.egg diff --git a/AGENTS.md b/AGENTS.md index b25f44b..2b88de7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,17 +27,19 @@ celltypepilot doctor If core dependencies are missing, install them: ```bash -pip install -e . +pip install celltypepilot ``` +For a repository checkout used in development, `pip install -e .` remains appropriate. + CellTypePilot runs on CPU, requires Python >= 3.10, scanpy, anndata, and matplotlib. No MCP servers, no pixi, no conda environment needed for the basic path. Optional extras: -- `pip install -e ".[web]"` — Web Inspector (Flask-based interactive review panel) -- `pip install -e ".[mcp]"` — Native local CellTypePilot MCP facade for Agent hosts -- `pip install -e ".[seurat]"` — Seurat .rds conversion (requires rpy2 or R) -- `pip install -e ".[all]"` — All optional features +- `pip install "celltypepilot[web]"` — Web Inspector (Flask-based interactive review panel) +- `pip install "celltypepilot[mcp]"` — Native local CellTypePilot MCP facade for Agent hosts +- `pip install "celltypepilot[seurat]"` — Seurat .rds conversion (requires rpy2 or R) +- `pip install "celltypepilot[all]"` — All optional features ## Workflow — Four Stages @@ -176,7 +178,7 @@ All commands support `--json` for structured output. ## Supported tissues -Built-in Marker Knowledge Graph (MKG mkg-2026.08) covers: +Built-in Marker Knowledge Graph (MKG mkg-2026.08.1) covers: - **Blood/PBMC**: T cells (CD4/CD8/naive/memory/Treg/Th1/Th17), B cells, NK cells, monocytes, DCs, platelets - **Lung**: alveolar macrophages, AT1/AT2, ciliated, club, goblet, basal - **Liver**: hepatocytes, Kupffer, cholangiocytes, endothelial, stellate @@ -189,7 +191,7 @@ Built-in Marker Knowledge Graph (MKG mkg-2026.08) covers: - **Skeletal muscle**: myofibers, satellite cells, FAPs - **General**: endothelial, pericytes, fibroblasts, macrophages, mast cells, epithelial -Premium atlas (requires academic/commercial license): +Bundled extended atlas (historical directory name `premium`, MIT-licensed and open to all users): - **Tumor microenvironment**: TAMs, CAFs, Tregs, exhausted T cells, MDSCs, malignant cells - **Developing brain**: radial glia, intermediate progenitors, migrating neurons - **Inflamed tissue**: activated fibroblasts, M1/M2 macrophages @@ -258,7 +260,7 @@ celltypepilot/ │ ├── constants.py # Thresholds, species/tissue constants │ ├── data/ │ │ ├── marker_atlas.json # Built-in marker knowledge graph (80+ types) -│ │ ├── packs/premium/ # First-party premium pack (academic license) +│ │ ├── packs/premium/ # MIT first-party extended pack (legacy name) │ │ └── state_atlas.json # Versioned exploratory cell-state modules │ ├── templates/ # Jinja2 templates (HTML report, web dashboard) │ ├── marker_scorer.py # DE + marker overlap scoring @@ -269,7 +271,7 @@ celltypepilot/ │ ├── visualizer.py # UMAP, dotplot, confidence figures │ ├── web_inspector.py # Flask web review panel │ ├── literature.py # PubMed literature validation -│ ├── license_manager.py # Tiered license system +│ ├── license_manager.py # Legacy service-entitlement compatibility │ ├── provenance.py # manifest.json generation │ ├── reporter.py # HTML report + methodology text │ └── doctor.py # Environment check diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4492158 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to CellTypePilot are documented here. The project follows +[Semantic Versioning](https://semver.org/). Release claims remain bounded by the +validation scope recorded in the repository and generated manifests. + +## [0.3.0] - 2026-08-10 + +### Added + +- Four-step Agent golden workflow with the `celltypepilot.agent-decision.v1` protocol. +- Actionable evidence gaps for `Unknown` decisions and contrastive top-two evidence. +- Independent Identity, State, and Novelty/OOD review axes with human sign-off boundaries. +- Governed context packs, data-only extension packs, atlas governance, and evidence promotion. +- Donor-aware benchmark infrastructure, comparator adapters, resumable execution, and QC diagnostics. +- Native MCP facade, Web Review audit artifacts, and dual Codex/Claude plugin manifests. +- Reproducible GitHub plugin bundle alongside the Python backend distribution. + +### Changed + +- The historical `premium` first-party atlas is explicitly MIT-licensed and available to all users. +- Python packaging now represents the deterministic backend; the complete Agent plugin is distributed + as a separate GitHub Release bundle. +- Release automation verifies version alignment, tests, package metadata, and installed-wheel behavior + before creating the GitHub Release and publishing to PyPI through Trusted Publishing. + +### Validation boundary + +- This is a technical preview release of an auditable annotation-review plugin. +- It does not establish biological superiority over CellTypist, SingleR, Azimuth, popV, or expert review. +- A qualified human remains responsible for final annotations and biological claims. + +[0.3.0]: https://github.com/HERRY423/CellTypePilot/releases/tag/v0.3.0 diff --git a/README.md b/README.md index 6923b4d..33cc31b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ > **Local-first single-cell annotation review plugin for your existing coding workspace.** > For individual researchers and small labs — no standalone app, no heavy infrastructure. +CellTypePilot uses two coordinated distributions: PyPI provides the deterministic Python +backend, while each GitHub Release provides the complete Agent plugin bundle for Codex and +Claude Code. Installing the backend alone does not install the host plugin manifests or skills. + **CellTypePilot** is a **plugin** for Claude Code / OpenAI Codex. It turns pre-clustered single-cell data into auditable draft cell-type annotations — with a governed context interface, **dual-engine** identity scoring (marker overlap + reference embedding), an independent cell-state @@ -50,7 +54,7 @@ painful — not because the algorithms don't exist, but because: | Pain point | What CellTypePilot does | |---|---| | **No one reviews your annotations** | A rules-based Annotation Critic checks evidence sufficiency, negative marker conflicts, doublet signals, and ontology consistency — *before* you trust a label | -| **Config barrier is too high** (MCP, pixi, conda...) | `git clone` + `pip install -e .` + run. Zero MCP required for the basic path. `doctor` tells you what you have *before* anything fails | +| **Config barrier is too high** (MCP, pixi, conda...) | Plugin bundle + `pip install celltypepilot` + run. Zero MCP required for the basic path. `doctor` tells you what you have *before* anything fails | | **Workflow fragmentation** (scripts here, tool there) | Runs inside your Claude Code / Codex session — no context switch to a separate app | | **Can't explain *why* a cluster got its label** | Every annotation ships with: supporting markers, expression stats, critic flags, and a draft methods paragraph | | **Rare / transitional states forced into a label** | Low or conflicting evidence produces an explicit `Unknown`/`abstain`; the best candidate is retained separately for review | @@ -93,8 +97,8 @@ Web Review also writes governance artifacts when manual overrides are used: derived evidence/report/figure/manifest artifacts are stale after overrides. ```bash -# 1. Install -pip install -e . +# 1. Install the deterministic backend +pip install celltypepilot # 2. Environment check — tells you what works and what's missing celltypepilot doctor @@ -257,30 +261,40 @@ separate annotation application or autonomous analysis service. All three modes call the same Python backend. The `skills/` directory is shared between both platforms — each reads `SKILL.md` for workflow orchestration. -### Installation +### Distribution and installation + +CellTypePilot deliberately separates runtime packaging from Agent-host packaging: + +| Distribution | Contains | Intended use | +|---|---|---| +| **PyPI `celltypepilot`** | Python backend, CLI/MCP entry points, bundled MIT atlases, templates | Reproducible runtime installation and upgrades | +| **GitHub plugin bundle** | Codex/Claude manifests, skills, commands, rules, hooks, MCP config, and installable backend source | Complete Agent plugin installation | + +The release asset is named `celltypepilot-plugin-.zip` and includes a hashed +`BUNDLE_MANIFEST.json`. Its version must match the PyPI backend version. The repository source +archive is for development; it is not the curated plugin bundle. ```bash -# Claude Code — install as a plugin +# Install the backend from PyPI +pip install celltypepilot +celltypepilot doctor + +# Development/source checkout git clone https://github.com/HERRY423/CellTypePilot ~/.claude/plugins/marketplaces/local/plugins/celltypepilot cd ~/.claude/plugins/marketplaces/local/plugins/celltypepilot pip install -e . -# Claude Code discovers .claude-plugin/plugin.json → skills/ + commands/ + hooks/ - -# Codex — install as a plugin -git clone https://github.com/HERRY423/CellTypePilot ~/.codex/plugins/cache/local/celltypepilot -cd ~/.codex/plugins/cache/local/celltypepilot -pip install -e . -# Codex discovers .codex-plugin/plugin.json → skills/ + agents/openai.yaml - -# Standalone CLI — no agent needed -git clone https://github.com/HERRY423/CellTypePilot && cd CellTypePilot -pip install -e . -celltypepilot doctor ``` -Optional extras: `pip install -e ".[web]"` (Web Inspector), `"[mcp]"` (native -CellTypePilot MCP server), `"[seurat]"` (.rds support), `"[reference]"` (CellTypist), -`"[embedding]"` (scVI/scANVI), `"[all]"` (everything). +For a release installation, download and extract `celltypepilot-plugin-.zip` from +[GitHub Releases](https://github.com/HERRY423/CellTypePilot/releases), run +`python -m pip install .` inside the extracted directory, then register that directory with +the Agent host. Codex discovers `.codex-plugin/plugin.json`; Claude Code discovers +`.claude-plugin/plugin.json`. Both use the bundled `skills/` and the same Python backend. + +Optional backend extras: `pip install "celltypepilot[web]"` (Web Inspector), +`"celltypepilot[mcp]"` (native MCP server), `"celltypepilot[seurat]"` (.rds support), +`"celltypepilot[reference]"` (CellTypist), `"celltypepilot[embedding]"` (scVI/scANVI), +or `"celltypepilot[all]"`. ### Agent-native MCP @@ -340,7 +354,7 @@ The CI badge reports the current default-branch state; it is not biological vali ## Built-in Marker Knowledge Graph -The Marker Knowledge Graph (`mkg-2026.08`) covers 80+ cell types across 11 tissues, +The Marker Knowledge Graph (`mkg-2026.08.1`) covers 80+ cell types across 11 tissues, with positive/negative markers, Cell Ontology IDs, and synonyms. Human and mouse are both supported with automatic gene symbol conversion. @@ -499,8 +513,8 @@ and marks the critic result `AGGREGATE_PROVENANCE_ONLY`. - [x] **Phase 1 (MVP)** — h5ad adapter, marker knowledge graph, Wilcoxon DE scoring, Annotation Critic, doctor, figures, JSON output, HTML report, methodology draft, manifest provenance, literature validation (PubMed) - [x] **Phase 2** — Dual-platform plugin packaging (Claude Code `.claude-plugin/` + Codex `.codex-plugin/`), commands, hooks, rules, and optional literature integration -- [x] **Phase 3** — Web Inspector (Flask interactive panel), Seurat .rds adapter, tiered license system (free/academic/commercial), premium atlas (tumor/brain/immune) -- [x] **Phase 4** — Reference Embedding + Ensemble fusion (CellTypist / scANVI / KNN / Correlation backends), adaptive weighting, transitional state detection, ensemble-aware critic, RSA-2048 license security, sparse-preserving Seurat conversion, Web Inspector override API +- [x] **Phase 3** — Web Inspector (Flask interactive panel), Seurat .rds adapter, and extended first-party atlas (tumor/brain/immune) +- [x] **Phase 4** — Reference Embedding + Ensemble fusion (CellTypist / scANVI / KNN / Correlation backends), adaptive weighting, transitional state detection, ensemble-aware critic, sparse-preserving Seurat conversion, Web Inspector override API - [x] **Architecture hardening** — Orchestrator layer (pipeline logic extracted from CLI), Jinja2 templates, multi-species detection, synonym-based tissue detection, and Python 3.10–3.12 CI - [x] **Phase 5** — Governed Context Pack, custom marker trust boundary, legal identity ontology IDs, and independent Identity × State outputs - [ ] **Validation release** — The immutable public registry, donor-aware release builder, @@ -511,4 +525,10 @@ and marks the critic result `AGGREGATE_PROVENANCE_ONLY`. ## License -MIT +CellTypePilot source code, the core atlas, and the historical `premium` first-party atlas are +released under the [MIT License](LICENSE). The `premium` directory name is retained for +backward compatibility; it no longer denotes a paid or license-gated content tier. + +Third-party references, imported datasets, and community extension packs retain their own +licenses and provenance. An installed pack's license metadata does not convert that content to +MIT. Biological outputs remain reviewable drafts requiring qualified human adjudication. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..d047ccb --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,52 @@ +# CellTypePilot release procedure + +CellTypePilot has two coordinated release artifacts with the same semantic version: + +1. `celltypepilot` on PyPI is the deterministic Python backend. +2. `celltypepilot-plugin-.zip` on GitHub Releases is the complete Codex/Claude plugin. + +The GitHub Release is created before the irreversible PyPI upload. A failed verification job +must not create either release. + +## One-time Trusted Publisher setup + +Create a `pypi` environment in the GitHub repository, then configure a PyPI Trusted Publisher +with exactly these values: + +| Field | Value | +|---|---| +| PyPI project | `celltypepilot` | +| GitHub owner | `HERRY423` | +| Repository | `CellTypePilot` | +| Workflow | `release.yml` | +| Environment | `pypi` | + +For the first upload, configure a pending publisher in PyPI account settings. Do not add a PyPI +API token to GitHub. The publish job receives only `id-token: write`; build and GitHub Release +jobs do not receive that permission. + +## Release checklist + +1. Confirm the intended commit is the current `main` HEAD and its CI is green. +2. Confirm `pyproject.toml`, `celltypepilot.__version__`, both plugin manifests, CHANGELOG, and + the proposed `v` tag agree. +3. Confirm the CHANGELOG heading contains the actual release date and retains the scientific + validation boundary. +4. Build locally with `python -m build` and run `python -m twine check dist/*`. +5. Build the plugin bundle with + `python scripts/build_plugin_bundle.py --tag v --output-dir release-assets`. +6. Create the annotated tag at current `main`, then push only that tag. The workflow rejects a + tag that does not point to current `origin/main`. +7. Verify the `Verify & Build Release Artifacts` and `Publish GitHub Plugin Release` jobs before + allowing the protected `pypi` environment deployment. +8. After publication, verify the GitHub assets and hashes, install the wheel from PyPI in a clean + environment, and confirm the PyPI/GitHub versions match. + +## Non-negotiable failure behavior + +- Never move or overwrite a published tag. +- Never use `skip-existing` for the production PyPI release. +- Never upload a rebuilt file under an already published PyPI version. +- Never describe a technical release as biological superiority or a completed validation release. +- If PyPI publishing fails after the GitHub Release exists, keep the evidence, correct the + publisher configuration, and rerun only through the audited workflow; do not publish manually. diff --git a/hooks/hooks.json b/hooks/hooks.json index 204bbd7..2f89797 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -7,7 +7,7 @@ "hooks": [ { "type": "command", - "command": "python -c \"import celltypepilot; print(f'CellTypePilot {celltypepilot.__version__} ready')\" 2>&1 || echo 'CellTypePilot not installed — run: pip install -e .'", + "command": "python -c \"import celltypepilot; print(f'CellTypePilot {celltypepilot.__version__} ready')\" 2>&1 || echo 'CellTypePilot backend not installed — run: pip install celltypepilot'", "timeout": 10 } ] diff --git a/pyproject.toml b/pyproject.toml index ebaacd9..8957851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,31 @@ build-backend = "hatchling.build" [project] name = "celltypepilot" version = "0.3.0" -description = "Local-first single-cell annotation review plugin" +description = "Deterministic single-cell annotation backend for Agent plugins" readme = "README.md" license = "MIT" requires-python = ">=3.10" -authors = [{ name = "CellTypePilot Contributors" }] +authors = [{ name = "HERRY423" }] +maintainers = [{ name = "HERRY423" }] +keywords = [ + "single-cell", + "scrna-seq", + "cell-type-annotation", + "bioinformatics", + "agent-plugin", + "mcp", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Bio-Informatics", +] dependencies = [ "typer>=0.9.0", @@ -36,6 +56,7 @@ benchmark = [ "celltypist>=1.6.0", "scikit-learn>=1.4.0", ] + all = [ "python-docx>=1.0.0", "flask>=3.0.0", @@ -55,6 +76,12 @@ dev = [ "fastmcp>=2.0.0", ] +[project.urls] +Homepage = "https://github.com/HERRY423/CellTypePilot" +Repository = "https://github.com/HERRY423/CellTypePilot" +Issues = "https://github.com/HERRY423/CellTypePilot/issues" +Changelog = "https://github.com/HERRY423/CellTypePilot/blob/main/CHANGELOG.md" + [project.scripts] celltypepilot = "celltypepilot.cli:app" celltypepilot-mcp = "celltypepilot.mcp_server:main" @@ -62,6 +89,15 @@ celltypepilot-mcp = "celltypepilot.mcp_server:main" [tool.hatch.build.targets.wheel] packages = ["src/celltypepilot"] +[tool.hatch.build.targets.sdist] +include = [ + "/src/celltypepilot", + "/pyproject.toml", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", +] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/scripts/build_plugin_bundle.py b/scripts/build_plugin_bundle.py new file mode 100644 index 0000000..11e4ebe --- /dev/null +++ b/scripts/build_plugin_bundle.py @@ -0,0 +1,143 @@ +"""Build a deterministic CellTypePilot Agent plugin release bundle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VERSION_PATTERN = re.compile(r'^version = "([^"]+)"$', re.MULTILINE) +INIT_VERSION_PATTERN = re.compile(r'^__version__ = "([^"]+)"$', re.MULTILINE) +BUNDLE_COMPONENTS = ( + Path(".claude-plugin"), + Path(".codex-plugin"), + Path("skills"), + Path("commands"), + Path("hooks"), + Path("rules"), + Path("src/celltypepilot"), + Path(".mcp.json"), + Path("AGENTS.md"), + Path("CHANGELOG.md"), + Path("LICENSE"), + Path("README.md"), + Path("pyproject.toml"), +) +EXCLUDED_PARTS = {"__pycache__", ".pytest_cache", ".ruff_cache"} +EXCLUDED_SUFFIXES = {".pyc", ".pyo"} +ZIP_TIMESTAMP = (2020, 1, 1, 0, 0, 0) + + +def _read_version(root: Path = REPO_ROOT) -> str: + pyproject = (root / "pyproject.toml").read_text(encoding="utf-8") + match = VERSION_PATTERN.search(pyproject) + if not match: + raise ValueError("project version not found in pyproject.toml") + return match.group(1) + + +def _validate_versions(root: Path, version: str, tag: str | None) -> None: + init_text = (root / "src/celltypepilot/__init__.py").read_text(encoding="utf-8") + init_match = INIT_VERSION_PATTERN.search(init_text) + if not init_match or init_match.group(1) != version: + raise ValueError("src/celltypepilot/__init__.py version does not match pyproject.toml") + + for manifest_path in (Path(".codex-plugin/plugin.json"), Path(".claude-plugin/plugin.json")): + manifest = json.loads((root / manifest_path).read_text(encoding="utf-8")) + if manifest.get("version") != version: + raise ValueError(f"{manifest_path} version does not match pyproject.toml") + + if tag is not None and tag != f"v{version}": + raise ValueError(f"release tag {tag!r} does not match project version v{version}") + if tag is not None: + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + dated_heading = re.compile( + rf"^## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}$", re.MULTILINE + ) + if not dated_heading.search(changelog): + raise ValueError(f"CHANGELOG.md needs a dated {version} release heading") + + +def _bundle_files(root: Path) -> list[Path]: + files: set[Path] = set() + for component in BUNDLE_COMPONENTS: + source = root / component + if not source.exists(): + raise FileNotFoundError(f"required bundle component is missing: {component}") + candidates = [source] if source.is_file() else source.rglob("*") + for candidate in candidates: + if not candidate.is_file(): + continue + relative = candidate.relative_to(root) + if any(part in EXCLUDED_PARTS for part in relative.parts): + continue + if relative.suffix in EXCLUDED_SUFFIXES: + continue + if candidate.is_symlink(): + raise ValueError(f"symbolic links are not allowed in the plugin bundle: {relative}") + files.add(relative) + return sorted(files, key=lambda path: path.as_posix()) + + +def _zip_info(name: str) -> zipfile.ZipInfo: + info = zipfile.ZipInfo(name, date_time=ZIP_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + return info + + +def build_bundle(output_dir: Path, tag: str | None = None, root: Path = REPO_ROOT) -> Path: + """Build the plugin bundle and return its path.""" + version = _read_version(root) + _validate_versions(root, version, tag) + payload_files = _bundle_files(root) + archive_root = f"celltypepilot-plugin-{version}" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"celltypepilot-plugin-{version}.zip" + + manifest_files = [] + payloads: list[tuple[Path, bytes]] = [] + for relative in payload_files: + data = (root / relative).read_bytes() + payloads.append((relative, data)) + manifest_files.append( + { + "path": relative.as_posix(), + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + } + ) + + manifest = { + "schema_version": "celltypepilot.plugin-bundle.v1", + "name": "celltypepilot", + "version": version, + "distribution": "agent_plugin_bundle", + "backend_install": "python -m pip install .", + "pypi_backend": f"celltypepilot=={version}", + "files": manifest_files, + } + manifest_data = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode() + + with zipfile.ZipFile(output_path, "w") as archive: + for relative, data in payloads: + archive.writestr(_zip_info(f"{archive_root}/{relative.as_posix()}"), data) + archive.writestr(_zip_info(f"{archive_root}/BUNDLE_MANIFEST.json"), manifest_data) + + return output_path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=Path("release-assets")) + parser.add_argument("--tag", help="Release tag; must equal v") + args = parser.parse_args() + print(build_bundle(args.output_dir, tag=args.tag)) + + +if __name__ == "__main__": + main() diff --git a/skills/celltypepilot/SKILL.md b/skills/celltypepilot/SKILL.md index 951949a..6852ba8 100644 --- a/skills/celltypepilot/SKILL.md +++ b/skills/celltypepilot/SKILL.md @@ -21,7 +21,7 @@ artifacts; a qualified human remains responsible for final biological adjudicati ## Operating principles -- **Zero-friction entry.** `git clone` + `pip install -e .` + run. No MCP servers, no pixi, no +- **Zero-friction entry.** GitHub plugin bundle + `pip install celltypepilot` + run. No MCP servers, no pixi, no conda environment to configure for the basic path. The `doctor` command tells users exactly what they have and what's missing, *before* anything fails. - **Evidence over black-box.** Every annotation comes with: which markers support it, what @@ -230,7 +230,7 @@ All commands support `--json` for structured output that the host integration ca ## Marker Knowledge Graph (MKG) -The built-in atlas (`mkg-2026.08`) covers: +The built-in atlas (`mkg-2026.08.1`) covers: - **Blood/PBMC**: T cells (CD4/CD8/naive/memory/Treg/Th1/Th17), B cells (naive/memory/plasma), NK cells, monocytes (classical/non-classical), dendritic cells (cDC1/cDC2/pDC), platelets - **Lung**: alveolar macrophages, AT1/AT2, ciliated, club, goblet, basal cells - **Liver**: hepatocytes, Kupffer cells, cholangiocytes, endothelial, stellate cells @@ -282,7 +282,7 @@ Mouse gene symbols are auto-converted from human conventions. # As a Claude Code plugin (recommended) git clone https://github.com/HERRY423/CellTypePilot ~/.claude/plugins/marketplaces/local/plugins/celltypepilot cd ~/.claude/plugins/marketplaces/local/plugins/celltypepilot -pip install -e . +pip install celltypepilot # Verify installation celltypepilot doctor diff --git a/skills/celltypepilot/agents/openai.yaml b/skills/celltypepilot/agents/openai.yaml index fbdcb14..a7ee963 100644 --- a/skills/celltypepilot/agents/openai.yaml +++ b/skills/celltypepilot/agents/openai.yaml @@ -1,7 +1,5 @@ interface: display_name: "CellTypePilot" short_description: "Evidence-based single-cell annotation with critic review" - icon_small: "./assets/logo.png" - icon_large: "./assets/logo.png" brand_color: "#2E86AB" default_prompt: "Use $celltypepilot to produce auditable draft labels for pre-clustered single-cell data with conservative abstention and rules-based critic review." diff --git a/skills/celltypepilot/reference/codex_workflow.md b/skills/celltypepilot/reference/codex_workflow.md index 4acd715..3e42462 100644 --- a/skills/celltypepilot/reference/codex_workflow.md +++ b/skills/celltypepilot/reference/codex_workflow.md @@ -6,7 +6,7 @@ ```bash # Install -pip install -e . +pip install celltypepilot # Run everything in one step celltypepilot annotate --input data.h5ad --cluster-key leiden --tissue blood --output ./results @@ -163,8 +163,8 @@ celltypepilot markers --tissue blood --json | Problem | Solution | |---|---| -| `celltypepilot: command not found` | Run `pip install -e .` from the project root | +| `celltypepilot: command not found` | Run `pip install celltypepilot`, or `pip install -e .` from a development checkout | | `No cluster key found` | Check `celltypepilot inspect` output for available keys | | `No annotations generated` | Marker gene overlap too low — check species/tissue match | -| `GBK/Unicode encoding error` | Fixed in v0.1.0; update with `pip install -e .` | +| `GBK/Unicode encoding error` | Fixed in v0.1.0; update with `pip install --upgrade celltypepilot` | | `leidenalg not found` | Use louvain clusters or assign cluster labels manually | diff --git a/skills/celltypepilot/reference/outputs.md b/skills/celltypepilot/reference/outputs.md index 690ab1b..9de7022 100644 --- a/skills/celltypepilot/reference/outputs.md +++ b/skills/celltypepilot/reference/outputs.md @@ -84,7 +84,7 @@ does not contribute marker evidence. ```json { "celltypepilot_version": "0.3.0", - "mkg_version": "mkg-2026.08", + "mkg_version": "mkg-2026.08.1", "timestamp": "2026-08-06T12:00:00+00:00", "input": { "path": "/path/to/data.h5ad", @@ -128,7 +128,7 @@ A plain-text paragraph suitable for adaptation into a paper's Methods section. E > Cell type annotation was performed using CellTypePilot (v0.3.0), an evidence-driven > annotation pipeline with built-in critic review. Marker gene evidence was sourced from -> the CellTypePilot Marker Knowledge Graph (MKG mkg-2026.08), a curated atlas integrating +> the CellTypePilot Marker Knowledge Graph (MKG mkg-2026.08.1), a curated atlas integrating > PanglaoDB, CellMarker, and Cell Ontology resources. For each of the N clusters identified > by leiden clustering, marker gene overlap, expression specificity, fold-change magnitude, > and negative marker conflict were scored to generate candidate annotations with confidence diff --git a/src/celltypepilot/cli.py b/src/celltypepilot/cli.py index 74a6297..69673c8 100644 --- a/src/celltypepilot/cli.py +++ b/src/celltypepilot/cli.py @@ -1725,6 +1725,7 @@ def license( ACADEMIC_FEATURES, COMMERCIAL_FEATURES, FREE_FEATURES, + TRIAL_FEATURES, LicenseTier, activate_license, load_license, @@ -1739,22 +1740,32 @@ def license( data["tier"] = lic.tier.value console.print(json.dumps(data, indent=2)) else: + available_features = { + LicenseTier.FREE: FREE_FEATURES, + LicenseTier.ACADEMIC: ACADEMIC_FEATURES, + LicenseTier.COMMERCIAL: COMMERCIAL_FEATURES, + LicenseTier.TRIAL: TRIAL_FEATURES, + }[lic.tier] console.print("[bold]CellTypePilot License[/bold]") console.print(f" Tier: {lic.tier.value}") console.print(f" Holder: {lic.holder or 'N/A'}") console.print(f" Email: {lic.email or 'N/A'}") console.print(f" Expires: {lic.expires_at or 'Never'}") - console.print(f" Features: {len(lic.features)} enabled") + console.print(f" Features: {len(available_features)} available") if lic.is_expired(): console.print(" [red]EXPIRED[/red]") console.print() console.print("[bold]Tier comparison:[/bold]") - console.print(f" Free: {len(FREE_FEATURES)} features (basic atlas, 11 tissues)") console.print( - f" Academic: {len(ACADEMIC_FEATURES)} features (extended atlas, disease states)" + f" Free: {len(FREE_FEATURES)} features (all bundled MIT-licensed atlases)" ) console.print( - f" Commercial:{len(COMMERCIAL_FEATURES)} features (full atlas, custom panels, API)" + f" Academic: {len(ACADEMIC_FEATURES)} features " + "(literature and review workflow services)" + ) + console.print( + f" Commercial:{len(COMMERCIAL_FEATURES)} features " + "(custom-panel services, team sharing, API)" ) elif action == "activate": diff --git a/src/celltypepilot/data/packs/premium/pack.json b/src/celltypepilot/data/packs/premium/pack.json index 9295266..5291de9 100644 --- a/src/celltypepilot/data/packs/premium/pack.json +++ b/src/celltypepilot/data/packs/premium/pack.json @@ -2,7 +2,7 @@ "schema_version": "celltypepilot.pack.v1", "name": "premium", "version": "premium-atlas-2026.08.1", - "description": "CellTypePilot Premium Atlas — Extended cell types for academic and commercial users", + "description": "MIT-licensed first-party extended atlas for CellTypePilot users", "species": [ "human", "mouse" @@ -13,7 +13,8 @@ "inflamed_tissue", "tumor_microenvironment" ], - "license_tier": "academic", + "license": "MIT", + "license_tier": "community", "files": [ "marker_atlas.json" ] diff --git a/src/celltypepilot/license_manager.py b/src/celltypepilot/license_manager.py index 93679da..9646edc 100644 --- a/src/celltypepilot/license_manager.py +++ b/src/celltypepilot/license_manager.py @@ -1,9 +1,10 @@ """CellTypePilot — Tiered license manager. -Manages license tiers and gates access to premium features: -- Free tier: Built-in MKG (80+ cell types, 11 tissues) -- Academic tier: Extended atlas (200+ cell types, disease states, developmental stages) -- Commercial tier: Full atlas + priority support + custom tissue panels +Manages legacy license tiers for service and workflow features. All bundled +marker atlases, including the historical ``premium`` pack, are MIT-licensed +and available to every user. Academic/commercial tiers may still represent +support, collaboration, or custom-panel services without restricting the +bundled biological content. Security model: - RSA-2048 asymmetric signing: public key embedded, private key offline @@ -49,8 +50,8 @@ class LicenseInfo: issued_at: str = "" expires_at: str = "" features: list[str] = field(default_factory=list) - max_tissues: int = 11 # Free tier limit - max_cell_types: int = 80 # Free tier limit + max_tissues: int = 0 # 0 = no license-based limit on bundled tissues + max_cell_types: int = 0 # 0 = no license-based limit on bundled cell types machine_id: str = "" # Bound machine fingerprint valid: bool = True _signature_valid: bool = False # Internal: RSA signature check result @@ -84,6 +85,9 @@ def has_feature(self, feature: str) -> bool: FREE_FEATURES = { "basic_atlas", # 80+ cell types, 11 tissues + "extended_atlas", # Bundled MIT-licensed extended cell types + "developmental_atlas", # Bundled developmental marker content + "disease_atlas", # Bundled disease-context marker content "marker_scoring", # Wilcoxon DE + 5-dim scoring "critic_review", # Annotation Critic "basic_visualization", # UMAP, dotplot, confidence @@ -92,9 +96,6 @@ def has_feature(self, feature: str) -> bool: } ACADEMIC_FEATURES = FREE_FEATURES | { - "extended_atlas", # 200+ cell types, disease states - "developmental_atlas", # Developmental stage markers - "disease_atlas", # Disease-specific cell states "literature_validation", # PubMed integration "advanced_visualization", # Cross-sample comparisons "docx_export", # Word document export @@ -604,15 +605,16 @@ def check_feature_access(feature: str) -> tuple[bool, str]: # ────────────────────────────────────────────── -# Premium atlas gating +# Bundled atlas availability # ────────────────────────────────────────────── def get_atlas_access(tissue: str) -> tuple[bool, str]: """Check if a tissue is accessible in the atlas. - Free tier: 11 basic tissues - Academic/Commercial: All tissues including extended panels + All first-party bundled tissues are MIT-licensed and available to every + user. Unknown tissues still fail closed and require an explicit extension + pack; a license tier never implies biological support. Args: tissue: Tissue name to check @@ -620,10 +622,7 @@ def get_atlas_access(tissue: str) -> tuple[bool, str]: Returns: Tuple of (has_access, message) """ - license_info = load_license() - - # Basic tissues available to all tiers - basic_tissues = { + bundled_tissues = { "blood", "lung", "liver", @@ -635,17 +634,17 @@ def get_atlas_access(tissue: str) -> tuple[bool, str]: "pancreas", "muscle", "general", + "developing_brain", + "immune_activation", + "inflamed_tissue", + "tumor_microenvironment", } - if tissue in basic_tissues: - return True, f"Tissue '{tissue}' available (all tiers)" - - # Extended tissues require academic+ tier - if license_info.tier in (LicenseTier.ACADEMIC, LicenseTier.COMMERCIAL, LicenseTier.TRIAL): - return True, f"Tissue '{tissue}' available ({license_info.tier.value} tier)" + if tissue in bundled_tissues: + return True, f"Tissue '{tissue}' available in the MIT-licensed bundled atlas" return False, ( - f"Tissue '{tissue}' requires Academic or Commercial license. " - f"Current: {license_info.tier.value}. " - f"Available basic tissues: {', '.join(sorted(basic_tissues))}" + f"Tissue '{tissue}' is not bundled. Install and explicitly select a governed " + "extension pack before annotation. " + f"Available bundled tissues: {', '.join(sorted(bundled_tissues))}" ) diff --git a/tests/test_license_manager.py b/tests/test_license_manager.py index 4ee593b..37189ff 100644 --- a/tests/test_license_manager.py +++ b/tests/test_license_manager.py @@ -40,6 +40,8 @@ def test_default_is_free(self): info = LicenseInfo() assert info.tier == LicenseTier.FREE assert info.valid is True + assert info.max_tissues == 0 + assert info.max_cell_types == 0 def test_is_expired_no_expiry(self): info = LicenseInfo(expires_at="") @@ -63,7 +65,9 @@ def test_has_feature_free(self): info = LicenseInfo(tier=LicenseTier.FREE) assert info.has_feature("basic_atlas") assert info.has_feature("marker_scoring") - assert not info.has_feature("extended_atlas") + assert info.has_feature("extended_atlas") + assert info.has_feature("developmental_atlas") + assert info.has_feature("disease_atlas") assert not info.has_feature("custom_tissue_panels") def test_has_feature_academic(self): @@ -104,9 +108,9 @@ def test_free_has_core_features(self): core = {"basic_atlas", "marker_scoring", "critic_review", "html_report"} assert core.issubset(FREE_FEATURES) - def test_academic_adds_extended(self): + def test_academic_adds_workflow_features(self): extra = ACADEMIC_FEATURES - FREE_FEATURES - assert "extended_atlas" in extra + assert "extended_atlas" not in extra assert "literature_validation" in extra def test_commercial_adds_exclusive(self): @@ -214,13 +218,14 @@ def test_basic_tissue_free_tier(self): ok, msg = get_atlas_access("blood") assert ok - def test_extended_tissue_free_tier_denied(self): + def test_extended_tissue_free_tier_allowed(self): with patch( "celltypepilot.license_manager.load_license", return_value=LicenseInfo(tier=LicenseTier.FREE), ): ok, msg = get_atlas_access("tumor_microenvironment") - assert not ok + assert ok + assert "MIT-licensed" in msg def test_extended_tissue_academic_allowed(self): with patch( @@ -230,6 +235,15 @@ def test_extended_tissue_academic_allowed(self): ok, msg = get_atlas_access("tumor_microenvironment") assert ok + def test_unknown_tissue_is_not_unlocked_by_license(self): + with patch( + "celltypepilot.license_manager.load_license", + return_value=LicenseInfo(tier=LicenseTier.COMMERCIAL), + ): + ok, msg = get_atlas_access("unsupported_tissue") + assert not ok + assert "extension pack" in msg + class TestCheckFeatureAccess: def test_free_feature_available(self): @@ -240,14 +254,14 @@ def test_free_feature_available(self): ok, msg = check_feature_access("basic_atlas") assert ok - def test_academic_feature_blocked_free(self): + def test_extended_atlas_is_available_to_free_tier(self): with patch( "celltypepilot.license_manager.load_license", return_value=LicenseInfo(tier=LicenseTier.FREE), ): ok, msg = check_feature_access("extended_atlas") - assert not ok - assert "Academic" in msg + assert ok + assert "free" in msg def test_expired_license_blocked(self): past = (datetime.now() - timedelta(days=1)).isoformat() diff --git a/tests/test_pack_manager.py b/tests/test_pack_manager.py index ee2afc7..63e0789 100644 --- a/tests/test_pack_manager.py +++ b/tests/test_pack_manager.py @@ -352,7 +352,8 @@ def test_list_includes_first_party_premium_pack(pack_env): premium = [entry for entry in entries if entry["name"] == "premium"] assert len(premium) == 1 assert premium[0]["origin"] == "first_party" - assert premium[0]["license_tier"] == "academic" + assert premium[0]["license_tier"] == "community" + assert premium[0]["license"] == "MIT" assert "tumor_microenvironment" in premium[0]["tissues"] diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py new file mode 100644 index 0000000..eee25ed --- /dev/null +++ b/tests/test_release_packaging.py @@ -0,0 +1,72 @@ +"""Release packaging contract tests.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[1] +BUNDLE_SCRIPT = REPO_ROOT / "scripts/build_plugin_bundle.py" + + +def test_pyproject_keeps_extras_out_of_project_urls(): + pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + optional_block = pyproject.split("[project.optional-dependencies]", 1)[1].split( + "[project.urls]", 1 + )[0] + urls_block = pyproject.split("[project.urls]", 1)[1].split("[project.scripts]", 1)[0] + assert "all = [" in optional_block + assert "dev = [" in optional_block + assert "all = [" not in urls_block + assert "dev = [" not in urls_block + + +def test_plugin_bundle_contains_agent_surface_and_installable_backend(tmp_path): + result = subprocess.run( + [sys.executable, str(BUNDLE_SCRIPT), "--output-dir", str(tmp_path), "--tag", "v0.3.0"], + check=True, + capture_output=True, + text=True, + ) + bundle = Path(result.stdout.strip()) + assert bundle.is_file() + + prefix = "celltypepilot-plugin-0.3.0/" + with zipfile.ZipFile(bundle) as archive: + names = set(archive.namelist()) + required = { + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + "skills/celltypepilot/SKILL.md", + "src/celltypepilot/__init__.py", + "src/celltypepilot/data/packs/premium/marker_atlas.json", + "pyproject.toml", + "BUNDLE_MANIFEST.json", + } + assert {prefix + path for path in required}.issubset(names) + assert not any("/tests/" in name or "/benchmarks/" in name for name in names) + assert not any(name.endswith((".h5ad", ".pyc")) for name in names) + + manifest = json.loads(archive.read(prefix + "BUNDLE_MANIFEST.json")) + assert manifest["schema_version"] == "celltypepilot.plugin-bundle.v1" + assert manifest["version"] == "0.3.0" + assert manifest["distribution"] == "agent_plugin_bundle" + for record in manifest["files"]: + payload = archive.read(prefix + record["path"]) + assert len(payload) == record["size"] + assert hashlib.sha256(payload).hexdigest() == record["sha256"] + + +def test_plugin_bundle_rejects_mismatched_release_tag(tmp_path): + result = subprocess.run( + [sys.executable, str(BUNDLE_SCRIPT), "--output-dir", str(tmp_path), "--tag", "v9.9.9"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "does not match project version v0.3.0" in result.stderr