diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6307b7e0f2..edba931c9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,6 +135,19 @@ fix to a broken entry is still an update and needs the same validation. Always p `download_url` to a release tag (e.g. `.../releases/download//...` or `.../archive/refs/tags/.zip`); never use `releases/latest/`. +### External agent adapters + +External integrations adapt the host's commands and extension/preset +contributions; they do not redistribute a core command inventory. Publish a +standalone package with root `integration.yml` and `__init__.py`, then advertise +its pinned archive URL and preferably its SHA-256 in a catalog. Registering a +catalog only enables discovery/download; importing executable adapter code +requires the user's trust decision and an install-enabled source. Follow the +[integration API and lifecycle design](design/integration.md#external-adapter-package-contract) +and [integration catalog contribution guide](integrations/CONTRIBUTING.md). +Add public-path positive and negative tests rather than injecting test classes +directly into the registry; include fresh-process loading and rollback evidence. + ### Branch naming When an issue exists, name the branch `/-`. diff --git a/design/integration.md b/design/integration.md index 117641f0d9..715088a355 100644 --- a/design/integration.md +++ b/design/integration.md @@ -4,7 +4,8 @@ Integrations adapt the shared Spec Kit workflows to an AI coding agent. Their **availability** (built-in, generic, or catalog-only) is separate from their **output format** (commands, recipes, skills, or a custom layout). The Python integration registry owns installation behavior; catalogs provide discovery, -not executable integration implementations. +not executable integration implementations. Trusted external adapter packages +are installed separately and loaded into that registry for their project. ## Availability @@ -12,13 +13,15 @@ not executable integration implementations. |---|---|---| | Built-in | A registered class under `src/specify_cli/integrations/` and, for discovery, an entry in `integrations/catalog.json` | `specify init my-project --integration copilot` or, in an initialized project, `specify integration install copilot` | | Generic | The registered `generic` integration, with a user-supplied `--commands-dir` and optional `--skills` | `specify init my-project --integration generic --integration-options="--commands-dir .agent/commands"` | -| Community | Metadata in `integrations/catalog.community.json` pointing to an external project | Discover with `specify integration list --catalog` or `search`; obtain and vet it from its source | +| External | A catalog entry pointing to an adapter archive | Register a trusted catalog, review the adapter, then `specify integration install sample-agent` | +| Community discovery | Metadata in the default `integrations/catalog.community.json` | Discover with `specify integration list --catalog` or `search`; review its source before configuring an install-enabled catalog | -The default community catalog is discovery-only. A catalog entry (including -one in a custom catalog) does **not** register a Python integration or make -`specify integration install ` work: installation resolves keys through -`INTEGRATION_REGISTRY`. See [catalog contribution guidance](../integrations/CONTRIBUTING.md) -for descriptor and submission details. +The default community catalog is discovery-only. Listing, searching, or +fetching catalog metadata never imports catalog code. An install-enabled +catalog permits download, not execution without consent: installation prompts +before downloading/importing an adapter, or accepts explicit +`--trust-integration` authorization. Users must review external code; a catalog +listing is not a code audit or security endorsement. ## Built-in contract @@ -75,6 +78,250 @@ adding per-agent wrapper scripts. For `generic`, extension registration resolves the persisted `--commands-dir` rather than the static registry placeholder; `--skills` emits skills into that same directory. +## External adapter package contract + +A standalone ZIP, tar.gz, or tgz archive needs only these root files (a single +enclosing archive directory is also accepted): + +```text +integration.yml +__init__.py +``` + +The descriptor is adapter metadata, not an inventory of Spec Kit commands: + +```yaml +schema_version: "1.0" +integration: + id: sample-agent + name: Sample Agent + version: "1.0.0" + description: Adapter for Sample Agent +requires: + speckit_version: ">=1.1.2.dev0" +``` + +`integration.author`, `repository`, and `license` are optional metadata. +When present in a descriptor or catalog entry, each must be a non-empty string. +Their types are validated before importing package code and on installed reload. +`requires.tools` is an optional list of mappings with a non-empty `name`, +optional boolean `required` (default `true`), and optional PEP 440 `version` +constraint. Installation checks required tools on PATH; tool version detection +is adapter-specific, not a generic invocation of arbitrary `--version` +commands. The Spec Kit constraint is parsed and enforced on install and load, +including development versions. Legacy `provides.commands` and +`provides.scripts` remain accepted and validated as optional metadata, but +are neither required nor used to install core commands. + +The root module exports exactly one concrete `IntegrationBase` subclass with +`key == integration.id`. It can inherit a host format base and use relative +imports from helper modules in its own package: + +```python +from specify_cli.integrations.base import SkillsIntegration + + +class SampleIntegration(SkillsIntegration): + key = "sample-agent" + config = { + "name": "Sample Agent", + "folder": ".sample-agent", + "commands_subdir": "skills", + "install_url": "https://example.com/sample-agent", + "requires_cli": False, + } + registrar_config = { + "dir": ".sample-agent/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + multi_install_safe = True +``` + +`config.name` must match the descriptor. An optional class `version` must match +its version. `config` and `registrar_config` must provide the fields above; +the registration directory must match `folder/commands_subdir`. Output paths +must be canonical, project-local, and outside `.git` and `.specify`. +Registration extensions must be plain dotted filename suffixes (such as +`.md`) or, for Markdown skills, `/SKILL.md`; path traversal is not allowed. +The public class attribute `invoke_separator` must be a non-empty string; +`dev_no_symlink` and `multi_install_safe` must be booleans, including when +registrar configuration supplies its own optional values. +An explicit `registrar_config.invoke_separator` takes precedence over the class +default. `dev_no_symlink` is enabled when either the class or registrar +configuration enables it. Healthy registration and persisted recovery metadata +use the same resolved configuration. +An optional `registrar_config.legacy_dir` must be a non-empty canonical +project-relative directory under the same reserved-root and symlink restrictions; +home-relative destinations are not supported for external adapters. +An adapter's primary root and optional legacy destination must not overlap, +regardless of whether it supports multi-install. Multi-install-safe adapters +also cannot overlap another integration's agent roots or legacy destinations. +Comparisons use case-folded path components on every platform so +packages remain safe on case-insensitive filesystems. Custom +setup must keep generated agent files under its declared root, track writes +with `IntegrationManifest`, and leave shared infrastructure ownership to the +host. Use the host's format bases rather than copied core templates. + +CLI adapters override `build_exec_args(prompt, *, model=None, +output_json=True, integration_args=None, integration_options=None, +project_root=None)` as appropriate. An adapter using only the host API and +standard library needs no pip installation or source-registry edit. +Import-side-effect registration is rejected. + +Catalog entries require matching identity, name, version, and description, +plus `download_url`. Optional descriptor metadata and `requires`, when present +in the catalog, must match too. `sha256` is an optional 64-character hexadecimal +digest of the archive bytes; publishing a pinned URL and digest is recommended. +Downloads use the host authentication configuration, including authenticated +GitHub release assets. HTTPS is mandatory except for loopback HTTP development +servers. Redirects, archive format declarations, traversal, symlinks, and +bounded download/extraction limits are enforced. + +### Storage, loading, and lifecycle + +Trusted executable packages live in +`.specify/integrations/packages//`; their provenance, descriptor metadata, +and per-file hashes live in +`.specify/integrations/packages.json`. They are **not** generated agent files +and do not appear in `.manifest.json`. The managed `.specify/.gitignore` +excludes both executable packages and their registry. +Authoritative execution consent lives in `~/.specify/integration-trust.json`, +never in project metadata. Projects containing that canonical trust path, +including a project rooted at `~/.specify`, cannot install or load adapters. +The trust path and local state are validated before importing a candidate. +Each grant binds the canonical project root, +integration ID, and the complete verified package file-hash mapping. Copying +a project, changing users, changing package bytes, or deleting a grant requires +a new local decision; a legacy project `trusted` field grants no authority. +Consent is checked before every load, including configuration-cache reuse. +The trust reader and writer share a 1 MiB byte limit. An update that would +exceed it fails before replacement, leaving the previous grants and recovery +records readable; existing grants are never evicted implicitly. +Only recorded, locally trusted packages are loaded, and hash/descriptor +validation precedes import. Missing, modified, +incompatible, or unimportable implementations produce explicit errors. +Python source is verified again when imported, including relative helper +modules; cached bytecode is never used to execute package code. + +Execution entry points load installed adapters before setup, agent configuration, +extension/preset registration, artifact resolution, status, and workflow dispatch. Fresh CLI +processes use the persisted package, not the catalog. Changing projects unloads +external registry entries, synthetic Python packages and their submodules, and +refreshes agent configuration/registrar caches in place. Built-in keys cannot +be replaced by packages. +Registry loading and configuration snapshots are synchronized. Each runtime +dispatch pins a context-local project registry and its verified Python namespaces +until dispatch finishes, including lazy relative imports. Switching another +thread to a different project cannot replace that dispatch's adapter, and +independent agent processes are not serialized by the registry lock. +Command and skill registration also pin the target project's registry for the +entire rendering operation, including implementation hooks and lazy relative +imports. A retained registrar or interleaved registration in another project +cannot substitute another project's adapter while rendering. +Catalog listing, catalog discovery, and `integration info` read metadata without +importing installed adapters. Merely checking that a directory is a Spec Kit +project does not load adapter code; registration managers load it when they +actually need the adapter's rendering configuration. +Workflow metadata inspection (`workflow status` and `workflow info`) also leaves +adapters unloaded, even if an installed package is damaged. Workflow run/resume +load adapters within their error-handling boundary and reload before dispatch. +Extension-native event refresh also loads and pins the project's trusted adapters, +including fresh-process event-only extension add/remove and enable/disable. +Adapter loading failures are reported as event-refresh failures, not skipped. + +`init --integration`, `integration install`, and `integration switch` can +resolve an uninstalled adapter from an install-enabled catalog. `use` selects +an already installed adapter. `upgrade` downloads the catalog's current +version and requires a new trust decision; it still refuses modified generated +files unless `--force` is supplied. `uninstall` removes executable code and its +registration while retaining modified generated files unless forced. +Adapter lifecycle mutations are project-locked and journal operation-owned +file changes, including package code, metadata, shared infrastructure, and +generated artifacts. Initial inventories retain only names and filesystem +metadata; they do not copy agent trees, user data, or the installed package +store. Content snapshots are created lazily before the first observed mutation, +with an aggregate limit of 128 MiB of file content and 4,096 entries per +transaction. Exceeding either limit fails before the affected write or removal. +Failed setup or durable package commit restores those +changes, not entire agent directories or all of `.specify`. Independent workflow +progress and unowned user files are left untouched. +While the lifecycle journal is active, install/switch failure handlers defer +cleanup to it rather than forcing teardown or rewriting fallback state first. +This preserves pending-file conflicts and the original directory inventory. +Rollback removes newly created empty parent directories only until the first +pre-existing parent; +original empty directories are preserved in project and built-in home scopes. +This also applies when an adapter records an already-written file. +Custom writes to existing files must use `IntegrationManifest.record_file()` +or the host's before-write primitives, such as +`IntegrationBase.write_file_and_record()`. `record_existing()` alone remains +supported for new files and unchanged existing files; it cannot recover bytes +already overwritten outside the journal. Such an unobserved overwrite fails +explicitly, retains the resulting file rather than deleting it, and reports +that its original bytes cannot be restored. +Concurrent edits to a managed file are preserved and reported with retained +recovery snapshots; a later host +write refuses to overwrite an edit made after its previous write. Extensions and presets +remain independently installed and follow the active integration as before. +If a write fails before its completion is observed, changed or newly present +bytes cannot be attributed to that operation. Rollback leaves those pending +paths untouched and retains recovery snapshots, including when a previously +existing path was deleted. An unchanged pending path needs no restoration. +Host settings merges, native event updates/removal, legacy migrations, and +extension/preset rendering caches participate in the journal without becoming +uninstall-owned files. Existing built-in home-scoped destinations are journaled +only for participating built-ins; external adapters remain project-local. +Host write helpers reject symlinked destinations and ancestors before creating +directories or writing. Removing an owned leaf symlink unlinks the link itself +without following its target; declared output directories cannot be symlinks. +Cancelling initialization discards the prepared adapter without installing code +or reporting success. Stable lifecycle locks are user-local and keyed by +canonical project root, so cancellation does not create target lock scaffolding; +rollback removes a newly created target root only when it remains empty. +Local consent is atomic and user-local; a grant for an explicitly authorized +package may remain after failed setup, but cannot authorize different bytes or +a different project. +Metadata changed by another operation during preparation is not overwritten; +the operation exits with an explicit retry error. +External uninstall unregisters the adapter's owned extension/preset artifacts +before unloading its code, preserves user-modified contributions, and +re-registers contributions for a remaining default integration. +If filesystem recovery itself fails, the error reports retained snapshot +paths for manual recovery rather than deleting the only backup. +`upgrade --force` and `uninstall --force` can recover a recorded adapter whose +implementation is missing, modified, incompatible, or fails to import. Recovery +excludes only that adapter, validates the others, and uses validated manifest +ownership and registrar configuration saved in the user-local trust store for +cleanup. Recovery records bind the project and adapter to the previously trusted +package identity, verified registrar configuration, and generated paths. +New records retain the verified package file-hash mapping locally, so recovery +checks the package identity against its original project, key, and hashes, +not damaged bytes or edited project metadata. The identity must still have a +local trust grant; otherwise cleanup treats ownership proof as unavailable and +preserves generated files. Older hash-less ownership records remain supported +when their package identity has a local grant. +Ownership is replaced only after the durable package loads successfully, so a +failed upgrade cannot replace the previous adapter's recovery authority. +Edited project configuration or forged manifest ownership is rejected, as is +cleanup overlapping another integration's root. It reports this +recovery explicitly and never bypasses catalog source policy or the replacement +package's trust decision. Ordinary selection and lifecycle operations still fail +explicitly for damaged installed implementations. +After copying a project or changing users, review the adapter and run +`specify integration upgrade --force --trust-integration` using an +install-enabled catalog to establish local consent. Forced uninstall can remove +an untrusted or missing package without importing it. +When local ownership proof is unavailable, including copied projects or older +grant-only trust stores, recovery preserves old generated files from cleanup and +warns that manual cleanup may be needed. A newly trusted replacement still +renders its declared destination under normal `upgrade --force` semantics; +old-only destinations are not deleted using unverified project metadata. + +See the [catalog contract](../integrations/README.md) and +[user reference](../docs/reference/integrations.md) for public commands. + ## Ownership and lifecycle An installation records its files and SHA-256 hashes in diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 4bbbb4704e..fbed1cf9e3 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -77,11 +77,12 @@ specify integration list | Option | Description | | ----------- | ----------------------------------------------------------------------------------------------------------------------- | -| `--catalog` | Also browse the catalog (built-in **and** community). Community integrations that are not built in are only shown here. | +| `--catalog` | Also browse the catalog, including external integrations not installed in this project. | -Shows the built-in integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based. +Shows built-in and trusted installed external integrations, which one is +currently installed, and whether each requires a CLI tool or is IDE-based. When multiple integrations are installed, the list marks the default integration separately from the other installed integrations. -The list also shows whether each built-in integration is declared multi-install safe. +The list also shows whether each integration is declared multi-install safe. ## Search Available Integrations @@ -119,14 +120,15 @@ specify integration install | `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) | | `--force` | Opt in to installing alongside integrations that are not declared multi-install safe | | `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | +| `--trust-integration` | After reviewing the code, pre-authorize an external adapter's Python execution without the interactive trust prompt | Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state. **Catalog history is metadata only.** `integration install` still resolves -registered built-in implementations, not historical catalog records. There is -no `integration install --version` or catalog-based integration distribution -contract, even when a catalog source is marked install-allowed. Community -catalogs remain discovery-only. +registered built-in implementations directly, or the current external adapter +release from an install-enabled catalog, not historical catalog records. +There is no `integration install --version`. The default community catalog +remains discovery-only. Installing an additional integration does not change the default integration. Use `specify integration use ` to change the default. @@ -134,6 +136,77 @@ Installed extensions and presets are not registered for a non-default integratio > **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead. +### Catalog-installed external adapters + +Register a reviewed catalog in the initialized project, then install its adapter: + +```bash +specify integration catalog add https://example.com/catalog.json --name samples +specify integration install sample-agent +specify integration use sample-agent +``` + +Installation prompts for trust **before** downloading/importing Python. For +automation, explicitly authorize code you have reviewed: + +```bash +specify integration install sample-agent --trust-integration +specify integration upgrade sample-agent --trust-integration +``` + +The source must have `install_allowed: true`. The default community catalog is +discovery-only; neither `--force` nor the trust flag bypasses that policy. +Catalog listing/search/info do not import catalog code. Required external entry +fields are a map key matching the descriptor ID, name, version, description, +and an archive `download_url`; an optional explicit `id` must match the map key; +an archive `sha256` digest is recommended. Downloads support ZIP, tar.gz, and tgz, +HTTPS or loopback HTTP, and the existing authenticated GitHub asset flow. +See the [catalog schema](../../integrations/README.md#catalog-schema). +Search advertises `specify integration install ` for install-enabled sources; +discovery-only results do not advertise installation. + +A package contains root `integration.yml` and `__init__.py`, not a copied +inventory of Spec Kit's commands. The host renders shared templates through the +adapter and registers installed extension/preset contributions for the default +integration. Code persists under `.specify/integrations/packages//`, +separately from generated agent files and their manifests. New CLI processes +load the trusted package without consulting the catalog. Missing, modified, or +incompatible code is an error, not a silent fallback. Upgrade installs the +catalog's current adapter version; uninstall removes its persisted code while +preserving modified generated files by default. +Metadata-only `workflow status` and `workflow info` remain available without +loading adapter code, including when an installed adapter is damaged. Workflow +execution and resume still report adapter-loading failures explicitly. + +Execution consent is stored in `~/.specify/integration-trust.json`, bound to +the canonical project root, integration ID, and complete verified package +digest. It is checked before loading, even when adapter configuration is +cached. A project's `packages.json` is provenance, not permission; copying +it cannot transfer consent. The managed `.specify/.gitignore` excludes +`integrations/packages/` and `integrations/packages.json`. +Trust-registry updates that would exceed the 1 MiB read limit fail explicitly +before replacing the existing store; previously granted packages remain usable. +After copying a project or changing users, review the adapter and reauthorize +from an install-enabled catalog: + +```bash +specify integration upgrade sample-agent --force --trust-integration +``` + +Forced uninstall also works when package code is untrusted or its entire +directory is missing; it does not import that code. + +For initialization, a project/user catalog or `SPECKIT_INTEGRATION_CATALOG_URL` +can supply an external adapter: + +```bash +specify init my-project --integration sample-agent --trust-integration +``` + +Review the [external adapter API](../../design/integration.md#external-adapter-package-contract) +before publishing a package. No pip installation or source-registry edit is +needed for adapters using the host API and standard library. + **Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install ` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`. ## Uninstall an Integration @@ -192,13 +265,42 @@ specify integration upgrade [] | `--force` | Overwrite files even if they have been modified | | `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) | | `--integration-options` | Options for the integration | +| `--trust-integration` | Authorize downloading/importing the reviewed replacement external adapter | Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration. Enabled extensions and presets are re-registered only when upgrading the currently active (default) integration. A non-default upgrade still refreshes that integration's core commands, but does not re-register its extension or preset layers — `use`/`switch` that integration afterward to rescaffold them. +If the generated-file manifest is missing, upgrade reports that there is nothing +to upgrade and leaves the installed adapter package, generated files, and local +recovery ownership unchanged, including with `--force`. Replacement code is +persisted only after the upgrade regenerates the managed files successfully. + If an upgrade would change an integration between command and skills layouts while preset artifacts are registered for it, the upgrade is rejected before changing files. Remove the affected presets, run the layout-changing upgrade, then reinstall them. +For external adapters, `upgrade --force` and `uninstall --force` can also recover +missing, modified, incompatible, or import-failing installed code using validated +user-local registrar/path ownership metadata, rejecting edited project cleanup +claims and overlap with another integration's root. Without local ownership +proof, old-only generated files are preserved with a manual-cleanup warning. +A trusted replacement can still overwrite files at its declared destination +under `upgrade --force`. Recovery is reported explicitly and does not bypass source policy +or the replacement package's trust decision. Failed lifecycle operations restore +only operation-owned changes. Independent workflow progress and unowned user +files are preserved; conflicting concurrent managed-file edits are reported with +retained recovery snapshots. +Rollback snapshots are lazy and bounded to 128 MiB of file content and 4,096 +entries per operation; an oversized snapshot refuses the affected mutation. +No-op operations do not copy agent directories or the installed package store. +Custom adapters must journal writes to existing files through the host's +before-write helpers or `IntegrationManifest.record_file()`. Recording a new +or unchanged file afterward remains supported; an unobserved overwrite is +reported as unrecoverable rather than deleting the resulting file. +Host writes reject symlinked destinations and ancestors before writing; forced +removal of an owned leaf symlink unlinks only the link. Concurrent workflow +dispatch pins the requested project's adapter and verified imports until the +dispatch finishes, without serializing independent agent processes. + ## Report Integration Status ```bash @@ -224,6 +326,10 @@ list, or records no installed integrations. Integration catalogs control where the discovery commands (`search` and `info`) look for integrations. Catalogs are checked in priority order. +Catalog management, `integration list --catalog`, and `integration info` do not +execute adapter code. Ordinary integration listing, setup, selection, status, +registration, and workflow dispatch load trusted installed implementations. + ### List Catalogs ```bash diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index b99fbea42c..862faff051 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -54,6 +54,12 @@ For `failed` and `aborted` runs, the payload includes an `error` field carrying `completed` and `paused` runs omit the `error` field. The error is persisted in the run's `state.json`, so `specify workflow status --json` surfaces the same message after the fact. +Adapter-load failures before run creation have no run ID. I/O failures during +execution or resume instead report the actual run and workflow IDs with a +failed JSON outcome; they are not classified as adapter-load failures. If +saving state fails, the on-disk status may still reflect the last successful +save rather than the reported I/O failure. + > **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run `, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs//`. ## Resume a Workflow diff --git a/integrations/CONTRIBUTING.md b/integrations/CONTRIBUTING.md index f1bf40d3ac..cb3087be5a 100644 --- a/integrations/CONTRIBUTING.md +++ b/integrations/CONTRIBUTING.md @@ -43,7 +43,9 @@ Community integrations are contributed by external developers and listed in `int ### Prerequisites -1. **Working external integration** — distributed from its own repository; a community catalog listing alone does not make it installable through `specify integration install` +1. **Working external integration** — distribute a standalone ZIP or tar.gz + with root `integration.yml` and `__init__.py`; a discovery-only community + listing does not grant installation permission 2. **Public repository** — hosted on GitHub or similar 3. **`integration.yml` descriptor** — valid descriptor file (see below) 4. **Documentation** — README with usage instructions @@ -56,39 +58,55 @@ Every community integration must include an `integration.yml`: ```yaml schema_version: "1.0" integration: - id: "my-agent" - name: "My Agent" + id: "sample-agent" + name: "Sample Agent" version: "1.0.0" - description: "Integration for My Agent" + description: "Adapter for Sample Agent" author: "your-name" - repository: "https://github.com/your-name/speckit-my-agent" + repository: "https://github.com/your-name/speckit-sample-agent" license: "MIT" requires: - speckit_version: ">=0.6.0" + speckit_version: ">=1.1.2.dev0" tools: - - name: "my-agent" - version: ">=1.0.0" + - name: "sample-agent" required: true -provides: - commands: - - name: "speckit.specify" - file: "templates/speckit.specify.md" - scripts: - - update-context.sh ``` +The root module exports exactly one adapter subclass, whose `key` and +`config.name` match the descriptor. Prefer `SkillsIntegration`, +`MarkdownIntegration`, `TomlIntegration`, or `YamlIntegration` to render the +host templates. Do not duplicate or enumerate Spec Kit's core commands. +Additional relative-import helper modules are allowed; external packages using +only the host API and standard library require neither pip installation nor +changes to the source registry. See the complete +[external adapter contract](../design/integration.md#external-adapter-package-contract). + ### Descriptor Validation Rules | Field | Rule | |-------|------| | `schema_version` | Must be `"1.0"` | -| `integration.id` | Lowercase alphanumeric + hyphens (`^[a-z0-9-]+$`) | +| `integration.id` | External package IDs start with a lowercase letter/digit, then lowercase alphanumeric + hyphens (`^[a-z0-9][a-z0-9-]*$`); built-in and Windows device names are reserved | | `integration.version` | Valid PEP 440 version (parsed with `packaging.version.Version()`) | -| `requires.speckit_version` | Required field; specify a version constraint such as `>=0.6.0` (current validation checks presence only) | -| `provides` | Must include at least one command or script | +| `requires.speckit_version` | Required valid PEP 440 constraint, enforced during install and load | +| `requires.tools` | Optional list; required executables are checked on PATH; version detection belongs to the adapter | +| `provides` | Optional legacy metadata; an adapter need not provide commands or scripts | | `provides.commands[].name` | String identifier | | `provides.commands[].file` | Relative path to template file | +Publish a pinned archive `download_url`, preferably with a hexadecimal archive +`sha256` digest. The catalog's ID/name/version/description and any optional +descriptor metadata or requirements must match `integration.yml`. +Never rely on importing a catalog to register your class: discovery does not +execute code. Installation from an install-enabled source requires an explicit +trust decision before import. Catalog maintainers review listing metadata, not +adapter implementations; users must vet the code. +Consent is user-local in `~/.specify/integration-trust.json`, bound to the +canonical project root, adapter ID, and verified package digest. Do not ship a +trust registry or rely on project metadata to authorize execution. A copied +project must reauthorize through a reviewed, install-enabled catalog using +`specify integration upgrade sample-agent --force --trust-integration`. + ### Submitting to the Community Catalog 1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit) @@ -98,13 +116,14 @@ provides: { "schema_version": "1.0", "integrations": { - "my-agent": { - "id": "my-agent", - "name": "My Agent", + "sample-agent": { + "id": "sample-agent", + "name": "Sample Agent", "version": "1.0.0", - "description": "Integration for My Agent", + "description": "Adapter for Sample Agent", "author": "your-name", - "repository": "https://github.com/your-name/speckit-my-agent", + "repository": "https://github.com/your-name/speckit-sample-agent", + "download_url": "https://github.com/your-name/speckit-sample-agent/releases/download/v1.0.0/sample-agent.zip", "tags": ["cli"] } } @@ -121,7 +140,7 @@ provides: To update your integration version in the catalog: 1. Release a new version of your integration -2. Open a PR updating the `version` field in `catalog.community.json` +2. Open a PR updating the version, pinned archive URL, and digest (if provided) 3. Ensure backward compatibility or document breaking changes ## Upgrade Workflow @@ -139,4 +158,34 @@ specify integration upgrade # Force upgrade (overwrites modified files) specify integration upgrade --force + +# Upgrade a reviewed external adapter without a trust prompt +specify integration upgrade sample-agent --trust-integration ``` + +Test your package through the public path: register a local loopback test +catalog, install its neutral adapter, start a fresh CLI process, register +extension/preset contributions, and exercise command/prompt workflow dispatch +with a harmless process double. Include failures for invalid metadata/classes, +trust denial, unsafe archives, setup errors, and upgrades/uninstall. Keep +package code separate from generated-file manifests and verify rollback and +modified-file preservation. + +Use manifest/base-class write helpers so failed lifecycle operations can restore +only the files your adapter changed. Legacy `record_existing()` writes are +covered within the adapter's declared output root; writes elsewhere must use +`record_file()` or host write helpers. Do not claim unrelated user files. Test +metadata-only commands without import side effects, forced recovery of damaged +installed packages, and rollback that preserves independent workflow progress +and concurrent user edits. +Host helpers reject symlinked write destinations; owned leaf links may be +unlinked without following them. Exercise overlapping project dispatch and lazy +relative imports: the host pins the correct adapter for each dispatch without +serializing independent agent processes. Forced recovery uses user-local +registrar/path ownership, not editable project metadata. Without that proof, +old-only artifacts are preserved with an explicit manual-cleanup warning. +Validate optional legacy destinations as canonical project-relative paths; they +cannot use reserved roots, symlinked directories, or home-relative syntax. +Primary and legacy output overlap is checked case-insensitively on all platforms. +For event-capable adapters, test event-only extension add/remove and enable/disable +in fresh CLI processes, including explicit errors when installed code cannot load. diff --git a/integrations/README.md b/integrations/README.md index b755e0416d..f6aec129ca 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -10,7 +10,9 @@ Contains integrations that ship with Spec Kit. These are maintained by the core ### Community Catalog (`catalog.community.json`) -Community-contributed integrations. Listed for discovery only — users install from the source repositories. +Community-contributed integrations. The default community source is +discovery-only: listing an adapter is neither installation permission nor a +code audit. Review external code before using an install-enabled catalog. ## Catalog Configuration @@ -34,7 +36,7 @@ catalogs: ## CLI Commands ```bash -# List built-in integrations (default) +# List built-in and trusted installed integrations specify integration list # Browse full catalog (built-in + community) @@ -43,6 +45,16 @@ specify integration list --catalog # Install an integration specify integration install copilot +# Register a reviewed private catalog and install its external adapter +specify integration catalog add https://example.com/catalog.json --name samples +specify integration install sample-agent + +# Non-interactive install, after reviewing and trusting the adapter +specify integration install sample-agent --trust-integration + +# Make an installed adapter the default +specify integration use sample-agent + # Upgrade the current integration (diff-aware) specify integration upgrade @@ -52,35 +64,30 @@ specify integration upgrade --force ## Integration Descriptor (`integration.yml`) -Each integration can include an `integration.yml` descriptor that documents its metadata, requirements, and provided commands/scripts: +Each external integration package includes an adapter-only `integration.yml` +descriptor and a root `__init__.py` exporting an `IntegrationBase` subclass. +No command inventory or copied core templates are needed: ```yaml schema_version: "1.0" integration: - id: "my-agent" - name: "My Agent" + id: "sample-agent" + name: "Sample Agent" version: "1.0.0" - description: "Integration for My Agent" - author: "my-org" - repository: "https://github.com/my-org/speckit-my-agent" + description: "Adapter for Sample Agent" license: "MIT" requires: - speckit_version: ">=0.6.0" + speckit_version: ">=1.1.2.dev0" tools: - - name: "my-agent" - version: ">=1.0.0" + - name: "sample-agent" required: true -provides: - commands: - - name: "speckit.specify" - file: "templates/speckit.specify.md" - - name: "speckit.plan" - file: "templates/speckit.plan.md" - scripts: - - update-context.sh - - update-context.ps1 ``` +`requires.tools` is optional; omit it for adapters with no required executable. +Optional legacy `provides` metadata remains valid but does not supply host +commands. See [integration design](../design/integration.md#external-adapter-package-contract) +for class metadata, runtime methods, tools, and storage requirements. + ## Catalog Schema Both catalog files follow the same JSON schema: @@ -91,13 +98,12 @@ Both catalog files follow the same JSON schema: "updated_at": "2026-04-08T00:00:00Z", "catalog_url": "https://...", "integrations": { - "my-agent": { - "id": "my-agent", - "name": "My Agent", + "sample-agent": { + "id": "sample-agent", + "name": "Sample Agent", "version": "1.0.0", - "description": "Integration for My Agent", - "author": "my-org", - "repository": "https://github.com/my-org/speckit-my-agent", + "description": "Adapter for Sample Agent", + "download_url": "https://example.com/sample-agent/1.0.0/sample-agent.zip", "tags": ["cli"] } } @@ -109,20 +115,59 @@ Both catalog files follow the same JSON schema: | Field | Type | Description | |-------|------|-------------| | `schema_version` | string | Must be `"1.0"` | -| `updated_at` | string | ISO 8601 timestamp | +| `updated_at` | string | Optional ISO 8601 timestamp | | `integrations` | object | Map of integration ID → metadata | ### Integration Entry Fields | Field | Type | Required | Description | |-------|------|----------|-------------| -| `id` | string | Yes | Unique ID (lowercase alphanumeric + hyphens) | +| `id` | string | No | Optional explicit ID; must match the map key | | `name` | string | Yes | Human-readable display name | | `version` | string | Yes | PEP 440 version (e.g., `1.0.0`, `1.0.0a1`) | | `description` | string | Yes | One-line description | | `author` | string | No | Author name or organization | | `repository` | string | No | Source repository URL | +| `license` | string | No | License identifier matching the descriptor | | `tags` | array | No | Searchable tags (e.g., `["cli", "ide"]`) | +| `download_url` | string | External installs | Pinned ZIP, tar.gz, or tgz archive URL; HTTPS or loopback HTTP | +| `sha256` | string | No | 64-character hexadecimal SHA-256 of the archive | +| `requires` | object | No | Must match descriptor requirements when supplied | + +The map key and any declared `id` must match `integration.id`. Name, version, +description, and optional author/repository/license metadata must match the +descriptor. Built-in entries need no download fields because their +implementations ship with the CLI. + +Registering a catalog with `integration catalog add` creates an install-enabled +project source. Set `install_allowed: false` in its configuration to permit +discovery only. This policy cannot be overridden with `--trust-integration` or +`--force`. Each external install/update prompts before downloading/importing +Python unless explicitly pre-authorized with `--trust-integration`. +Authenticated GitHub assets use the existing Spec Kit authentication providers. + +Installed code is stored in `.specify/integrations/packages//` with +provenance and hashes in `packages.json`; both are excluded by the managed +`.specify/.gitignore`. Execution consent is stored separately in +`~/.specify/integration-trust.json`, bound to the canonical project root, +integration ID, and verified package digest. Project metadata cannot grant +consent. Copying a project or changing users requires a new local decision: +review the package and run +`specify integration upgrade sample-agent --force --trust-integration` +from an install-enabled catalog. +Generated files have a separate +hash-tracked `.manifest.json`; new CLI processes load the trusted package +without fetching the catalog. An upgrade fetches the catalog's current version +and checks its descriptor again. Do not edit installed package code in place: +publish a new archive/version and upgrade instead. +Catalog management/discovery and `integration info` remain metadata-only and do +not import adapters. Forced upgrade/uninstall can recover damaged installed code +using user-local registrar and generated-path ownership records, without +bypassing source policy or trust. Edited project cleanup claims are rejected. +Without local ownership proof, old-only generated files are preserved with a +manual-cleanup warning; trusted replacement setup still targets its declared +destination. Concurrent dispatch pins each project's adapter and verified +imports independently. ## Contributing diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 68ea5e4985..d241a37646 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -33,6 +33,7 @@ import typer from rich.align import Align +from rich.markup import escape as _escape_markup from .shared_infra import ( install_shared_infra as _install_shared_infra_impl, refresh_shared_templates as _refresh_shared_templates_impl, @@ -280,7 +281,10 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: return project_path / registration_directory(project_path).relative_to( project_path.resolve() ) - agent_config = AGENT_CONFIG.get(selected_ai, {}) + from .integrations import get_integration + + integration = get_integration(selected_ai) + agent_config = (integration.config or {}) if integration is not None else {} agent_folder = agent_config.get("folder", "") if agent_folder: return project_path / agent_folder.rstrip("/") / "skills" @@ -429,7 +433,7 @@ def _print_cli_warning( from ._project import _resolve_init_dir_override as _resolve_init_dir_override # noqa: E402 -def _require_specify_project() -> Path: +def _require_specify_project(*, load_integrations: bool = False) -> Path: """Return the project root if it is a spec-kit project, else exit. Honors the ``SPECIFY_INIT_DIR`` override (same validation rules as the shell @@ -441,10 +445,16 @@ def _require_specify_project() -> Path: the current directory, as before. """ override = _resolve_init_dir_override() - if override is not None: - return override - project_root = Path.cwd() + project_root = override if override is not None else Path.cwd() if (project_root / ".specify").is_dir(): + if load_integrations: + from .integrations.installer import IntegrationInstallError, load_installed_integrations + + try: + load_installed_integrations(project_root) + except (IntegrationInstallError, OSError) as exc: + err_console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc return project_root err_console.print("[red]Error:[/red] Not a Spec Kit project (no .specify/ directory)") err_console.print( diff --git a/src/specify_cli/_init_options.py b/src/specify_cli/_init_options.py index 9f509da256..39003a7e7c 100644 --- a/src/specify_cli/_init_options.py +++ b/src/specify_cli/_init_options.py @@ -22,11 +22,15 @@ def __repr__(self) -> str: # pragma: no cover - debug aid only def save_init_options(project_path: Path, options: dict[str, Any]) -> None: """Persist the CLI options used during ``specify init``.""" dest = project_path / INIT_OPTIONS_FILE + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(dest) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text( json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False) + "\n", encoding="utf-8", ) + after_file_change(dest) def load_init_options(project_path: Path) -> dict[str, Any]: diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6016773e51..72966998a5 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -20,29 +20,20 @@ from ._toml_string import escape_toml_basic as _escape_toml_basic from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control from ._utils import relative_extension_path_violation +from .integrations._registration import project_registration def _build_agent_configs() -> dict[str, Any]: """Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY.""" from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations.base import resolve_registrar_config configs: dict[str, dict[str, Any]] = {} for key, integration in INTEGRATION_REGISTRY.items(): if key == "generic": continue if integration.registrar_config: - config = dict(integration.registrar_config) - # Propagate invoke_separator from the integration class when the - # registrar_config dict doesn't already declare it explicitly. - # SkillsIntegration subclasses (claude, codex, …) set - # invoke_separator="-" as a class attribute but omit it from - # registrar_config, so without this they would fall back to "." - # when register_commands() resolves __SPECKIT_COMMAND_*__ tokens. - if "invoke_separator" not in config: - config["invoke_separator"] = integration.invoke_separator - if integration.dev_no_symlink: - config["dev_no_symlink"] = True - configs[key] = config + configs[key] = resolve_registrar_config(integration) return configs @@ -59,10 +50,20 @@ class CommandRegistrar: AGENT_CONFIGS: dict[str, dict[str, Any]] = {} _configs_loaded: bool = False - def __init__(self, project_root: Path | None = None) -> None: - self._ensure_configs() - self.AGENT_CONFIGS = dict(self.AGENT_CONFIGS) - if project_root is not None: + def __init__(self, project_root: Path | None = None, *, include_generic: bool = True) -> None: + from .integrations.installer import registry_synchronized + + @registry_synchronized + def snapshot_configs(): + if project_root is not None: + from .integrations import load_installed_integrations + + load_installed_integrations(project_root) + self._ensure_configs() + self.AGENT_CONFIGS = dict(self.AGENT_CONFIGS) + + snapshot_configs() + if project_root is not None and include_generic: from .integrations.generic import registration_directory from ._init_options import load_init_options @@ -85,7 +86,8 @@ def __init_subclass__(cls, **kwargs: Any) -> None: def _ensure_configs(cls) -> None: if not cls._configs_loaded: try: - cls.AGENT_CONFIGS = _build_agent_configs() + cls.AGENT_CONFIGS.clear() + cls.AGENT_CONFIGS.update(_build_agent_configs()) cls._configs_loaded = True except ImportError: pass # Circular import during module init; retry on next access @@ -663,6 +665,7 @@ def _active_skills_agent(project_root: Path) -> Optional[str]: return None return agent + @project_registration def register_commands( self, agent_name: str, @@ -1025,10 +1028,18 @@ def _write_registered_output( agent_config: dict[str, Any] | None = None, ) -> None: """Write a rendered agent artifact, optionally as a dev-mode symlink.""" + from .integrations._file_changes import after_file_change, before_file_change + + if dest_file.is_symlink(): + before_file_change(dest_file, removal=True) + dest_file.unlink() + after_file_change(dest_file) + before_file_change(dest_file) if not link_outputs or (agent_config or {}).get("dev_no_symlink"): if dest_file.is_symlink(): dest_file.unlink() dest_file.write_text(content, encoding="utf-8") + after_file_change(dest_file) return rel_output = Path(f"{output_name}{extension}") @@ -1037,8 +1048,10 @@ def _write_registered_output( CommandRegistrar._ensure_inside(cache_file, cache_root) try: + before_file_change(cache_file) cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(content, encoding="utf-8") + after_file_change(cache_file) if dest_file.exists() or dest_file.is_symlink(): dest_file.unlink() target = os.path.relpath(cache_file, dest_file.parent) @@ -1050,6 +1063,7 @@ def _write_registered_output( if dest_file.is_symlink(): dest_file.unlink() dest_file.write_text(content, encoding="utf-8") + after_file_change(dest_file) @staticmethod def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: @@ -1069,7 +1083,11 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: prompt_file = prompts_dir / f"{cmd_name}.prompt.md" CommandRegistrar._ensure_inside(prompt_file, prompts_dir) prompt_file.parent.mkdir(parents=True, exist_ok=True) + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(prompt_file) prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8") + after_file_change(prompt_file) @staticmethod def _resolve_agent_dir( @@ -1403,7 +1421,11 @@ def unregister_commands( except ValueError: continue if cmd_file.exists() or cmd_file.is_symlink(): + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(cmd_file, removal=True) cmd_file.unlink() + after_file_change(cmd_file) # For SKILL.md agents each command lives in its own # subdirectory (e.g. .agents/skills/speckit-ext-cmd/ # SKILL.md). Remove the parent dir when it becomes @@ -1420,7 +1442,11 @@ def unregister_commands( project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" ) if prompt_file.exists(): + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(prompt_file, removal=True) prompt_file.unlink() + after_file_change(prompt_file) # Populate AGENT_CONFIGS after class definition. diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 7b49a825e0..c6cdb25777 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -16,6 +16,7 @@ from . import ( ArtifactError, + ArtifactResolutionError, NotASpecKitProjectError, ) @@ -43,9 +44,19 @@ def _resolve_project_root() -> Path: with contextlib.redirect_stderr(io.StringIO()): try: - return _require_specify_project() + root = _require_specify_project() except typer.Exit: raise NotASpecKitProjectError() from None + from ..integrations.installer import ( + IntegrationInstallError, + load_installed_integrations, + ) + + try: + load_installed_integrations(root) + except (IntegrationInstallError, OSError) as exc: + raise ArtifactResolutionError(str(exc)) from exc + return root def _emit_error_and_exit(exc: ArtifactError) -> None: diff --git a/src/specify_cli/artifacts/models.py b/src/specify_cli/artifacts/models.py index 9d012830da..737cd2df62 100644 --- a/src/specify_cli/artifacts/models.py +++ b/src/specify_cli/artifacts/models.py @@ -159,8 +159,10 @@ def __init__(self) -> None: class ArtifactResolutionError(ArtifactError): - def __init__(self) -> None: + def __init__(self, detail: str | None = None) -> None: self.message = "artifact resolution failed" + if detail: + self.message += f": {detail}" super().__init__(self.message) diff --git a/src/specify_cli/artifacts/resolution.py b/src/specify_cli/artifacts/resolution.py index 707a1706bf..43eea57f61 100644 --- a/src/specify_cli/artifacts/resolution.py +++ b/src/specify_cli/artifacts/resolution.py @@ -202,7 +202,12 @@ def _materialized_command_source_path( except ImportError: return None - registrar = CommandRegistrar() + from ..integrations.installer import IntegrationInstallError + + try: + registrar = CommandRegistrar(project_root) + except (IntegrationInstallError, OSError) as exc: + raise ArtifactResolutionError(str(exc)) from exc registrar._ensure_configs() registered_commands = metadata.get("registered_commands") @@ -351,7 +356,10 @@ class ``PresetManager.list_installed()`` and ``specify preset list`` use — manifest file is missing or fails manifest validation (for example, an older flat-layout manifest with no ``preset:`` section at all). """ - from ..presets import PresetManifest, PresetValidationError # lazy: avoids circular import + from ..presets import ( # lazy: avoids circular import + PresetManifest, + PresetValidationError, + ) manifest_path = pack_dir / "preset.yml" if not manifest_path.is_file(): diff --git a/src/specify_cli/command_check.py b/src/specify_cli/command_check.py index 6527427278..e20ff51f87 100644 --- a/src/specify_cli/command_check.py +++ b/src/specify_cli/command_check.py @@ -2,7 +2,10 @@ from __future__ import annotations +from pathlib import Path + import typer +from rich.markup import escape from ._agent_config import AGENT_CONFIG from ._console import StepTracker, console, show_banner @@ -11,6 +14,14 @@ def check() -> None: """Check that all required tools are installed.""" from . import check_tool + from ._project import _resolve_init_dir_override + from .integrations.installer import IntegrationInstallError, load_installed_integrations + + try: + load_installed_integrations(_resolve_init_dir_override() or Path.cwd()) + except (IntegrationInstallError, OSError) as exc: + console.print(f"[red]Error:[/red] {escape(str(exc))}") + raise typer.Exit(1) from exc show_banner() console.print("[bold]Checking for installed tools...[/bold]\n") diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index c2630f0ef8..5e0d8536f7 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -230,9 +230,14 @@ def ensure_constitution_from_template( return try: + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(memory_constitution) materialization = _materialize_constitution_template( project_path, memory_constitution ) + if memory_constitution.is_file(): + after_file_change(memory_constitution) if materialization is None: if tracker: tracker.add("constitution", "Constitution setup") @@ -257,7 +262,10 @@ def ensure_constitution_from_template( def register(app: typer.Typer) -> None: + from .integrations._lifecycle import external_lifecycle, initial_directory_state, lifecycle_success + @app.command() + @external_lifecycle("init") def init( project_name: str = typer.Argument( None, @@ -340,6 +348,10 @@ def init( "--trust-extension-urls", help="Pre-authorize installing extensions from external URLs without the interactive trust prompt (required for non-interactive URL installs).", ), + trust_integration: bool = typer.Option( + False, "--trust-integration", + help="Authorize executing a reviewed external integration package without prompting.", + ), ): """ Initialize a new Specify project. @@ -432,7 +444,8 @@ def init( project_path = Path.cwd() dir_existed_before = True - existing_items = list(project_path.iterdir()) + original_directory = initial_directory_state(project_path) + existing_items = original_directory[1] if original_directory else list(project_path.iterdir()) if existing_items: console.print( f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)" @@ -487,15 +500,16 @@ def init( raise typer.Exit(0) else: project_path = Path(project_name).resolve() - dir_existed_before = project_path.exists() - if project_path.exists(): + original_directory = initial_directory_state(project_path) + dir_existed_before = original_directory[0] if original_directory else project_path.exists() + if dir_existed_before: safe_name = _escape_markup(str(project_name)) if not project_path.is_dir(): console.print( f"[red]Error:[/red] '{safe_name}' exists but is not a directory." ) raise typer.Exit(1) - existing_items = list(project_path.iterdir()) + existing_items = original_directory[1] if original_directory else list(project_path.iterdir()) if force: if existing_items: console.print( @@ -782,10 +796,14 @@ def init( project_path / ".specify" / "workflows" / "speckit" ) dest_wf.mkdir(parents=True, exist_ok=True) + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(dest_wf / "workflow.yml") _shutil.copy2( bundled_wf / "workflow.yml", dest_wf / "workflow.yml", ) + after_file_change(dest_wf / "workflow.yml") definition = WorkflowDefinition.from_yaml( dest_wf / "workflow.yml" ) @@ -966,7 +984,10 @@ def init( border_style="magenta", ) ) - if not here and project_path.exists() and not dir_existed_before: + if ( + not here and project_path.exists() and not dir_existed_before + and initial_directory_state(project_path) is None + ): shutil.rmtree(project_path) raise typer.Exit(1) finally: @@ -974,7 +995,7 @@ def init( if _transient: console.print(tracker.render()) - console.print("\n[bold green]Project ready.[/bold green]") + lifecycle_success("\n[bold green]Project ready.[/bold green]") agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: diff --git a/src/specify_cli/events/__init__.py b/src/specify_cli/events/__init__.py index 613dba5702..fec6ea67d1 100644 --- a/src/specify_cli/events/__init__.py +++ b/src/specify_cli/events/__init__.py @@ -29,6 +29,8 @@ import yaml import typer +from ..integrations._file_changes import unlink as _unlink_file, write_text as _write_text + if TYPE_CHECKING: from ..integrations.base import IntegrationBase from ..integrations.manifest import IntegrationManifest @@ -1394,7 +1396,7 @@ def install_integration_events( dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME _ensure_safe_destination(dispatcher_path) dispatcher_dir.mkdir(parents=True, exist_ok=True) - dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8") + _write_text(dispatcher_path, _EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8") dispatcher_path.chmod(0o755) manifest.record_file( str(dispatcher_path.relative_to(project_root)), @@ -1416,7 +1418,8 @@ def install_integration_events( plugin_path = project_root / plugin_rel _ensure_safe_destination(plugin_path) plugin_path.parent.mkdir(parents=True, exist_ok=True) - plugin_path.write_text( + _write_text( + plugin_path, _build_opencode_plugin(filtered, canonical_to_native), encoding="utf-8", ) @@ -1747,7 +1750,7 @@ def _cleanup_shared_dispatcher( dispatcher_path = project_root / dispatcher_rel if dispatcher_path.exists(): _ensure_safe_destination(dispatcher_path) - dispatcher_path.unlink(missing_ok=True) + _unlink_file(dispatcher_path, missing_ok=True) def remove_integration_events( @@ -1770,7 +1773,7 @@ def remove_integration_events( plugin_path = project_root / plugin_rel if plugin_path.exists(): _ensure_safe_destination(plugin_path) - plugin_path.unlink(missing_ok=True) + _unlink_file(plugin_path, missing_ok=True) manifest.remove(plugin_rel) @@ -1826,6 +1829,17 @@ def refresh_integration_events(project_root: Path) -> None: the lifecycle command can't claim the extension was fully deactivated while a stale native hook may still be active (R3). """ + from ..integrations.installer import IntegrationInstallError, project_integrations + + try: + with project_integrations(project_root): + _refresh_loaded_integration_events(project_root) + except (IntegrationInstallError, OSError) as exc: + raise EventRefreshError([("installed adapters", str(exc))]) from exc + + +def _refresh_loaded_integration_events(project_root: Path) -> None: + """Refresh against the pinned project registry.""" from ..integrations import get_integration from ..integrations._helpers import _read_integration_json, _resolve_integration_options from ..integrations.manifest import IntegrationManifest @@ -2145,7 +2159,7 @@ def _remove_opencode_entries(config_path: Path) -> bool: else: existing.pop("plugin", None) if not existing: - config_path.unlink(missing_ok=True) + _unlink_file(config_path, missing_ok=True) return True _safe_write_json(config_path, existing) return False @@ -2184,7 +2198,7 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool: if not fragment and stripped == existing: return False dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(stripped.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") + _write_text(dst, stripped.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") return True @@ -2220,7 +2234,7 @@ def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool: flags=re.DOTALL, ) dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") + _write_text(dst, existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8") return True @@ -2269,9 +2283,9 @@ def _remove_toml_entries(dst: Path) -> bool: if line.strip() and not line.strip().startswith("#") ) if not stripped: - dst.unlink(missing_ok=True) + _unlink_file(dst, missing_ok=True) return True - dst.write_text(cleaned, encoding="utf-8") + _write_text(dst, cleaned, encoding="utf-8") return False @@ -2306,9 +2320,9 @@ def _remove_vibe_toml_entries(dst: Path) -> bool: if line.strip() and not line.strip().startswith("#") ) if not stripped: - dst.unlink(missing_ok=True) + _unlink_file(dst, missing_ok=True) return True - dst.write_text(cleaned, encoding="utf-8") + _write_text(dst, cleaned, encoding="utf-8") return False @@ -2377,7 +2391,7 @@ def _remove_copilot_entries(dst: Path) -> bool: # ``version`` key would remain — no user content to preserve. user_keys = {k for k in existing if k != "version"} if not user_keys: - dst.unlink(missing_ok=True) + _unlink_file(dst, missing_ok=True) return True _safe_write_json(dst, existing) return False @@ -2474,7 +2488,7 @@ def _merge_json_root(dst: Path, new_hooks: dict) -> bool: else: existing = {} if not existing: - dst.unlink(missing_ok=True) + _unlink_file(dst, missing_ok=True) return True _safe_write_json(dst, existing) return True @@ -2500,7 +2514,7 @@ def _remove_json_root_entries(dst: Path) -> bool: if kept_entries: cleaned[event] = kept_entries if not cleaned: - dst.unlink(missing_ok=True) + _unlink_file(dst, missing_ok=True) return True _safe_write_json(dst, cleaned) return False @@ -2565,7 +2579,8 @@ def _safe_write_json(dst: Path, data: dict) -> None: _ensure_safe_destination(dst) dst.parent.mkdir(parents=True, exist_ok=True) # A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape. - dst.write_text( + _write_text( + dst, json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", errors="backslashreplace", @@ -2631,7 +2646,7 @@ def _remove_json_entries(dst: Path) -> bool: # doesn't leave a generated stub behind. user_keys = {k for k in existing if k != "version"} if not user_keys: - dst.unlink(missing_ok=True) + _unlink_file(dst, missing_ok=True) return True _safe_write_json(dst, existing) return False diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6242dc2c05..9815129870 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -53,6 +53,7 @@ integration_setting, try_read_integration_json, ) +from ..integrations._registration import project_registration from ..shared_infra import verify_archive_sha256 _FALLBACK_CORE_COMMAND_NAMES = frozenset( @@ -825,9 +826,13 @@ def is_corrupt(self) -> bool: def _save(self): """Save registry to disk.""" + from ..integrations._file_changes import after_file_change, before_file_change + + before_file_change(self.registry_path) self.extensions_dir.mkdir(parents=True, exist_ok=True) with open(self.registry_path, "w", encoding="utf-8") as f: json.dump(self.data, f, indent=2) + after_file_change(self.registry_path) def add(self, extension_id: str, metadata: dict): """Add extension to registry. @@ -1332,6 +1337,7 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]: return _ignore + @project_registration def _get_skills_dir(self, *, create: bool = True) -> Optional[Path]: """Return the active skills directory for extension skill registration. @@ -1570,6 +1576,7 @@ def _register_commands_for_active_agent( only_agent=active_agent, ) + @project_registration def _register_extension_skills( self, manifest: ExtensionManifest, @@ -1608,6 +1615,7 @@ def _register_extension_skills( from ..agents import CommandRegistrar from ..integrations import get_integration from ..integrations.base import IntegrationBase + from ..integrations._file_changes import changing_file, unlink, write_text written: List[str] = [] opts = load_init_options(self.project_root) @@ -1765,19 +1773,20 @@ def _replacement(match: re.Match[str]) -> str: if use_dev_symlink: try: cache_file.parent.mkdir(parents=True, exist_ok=True) - cache_file.write_text(skill_content, encoding="utf-8") + write_text(cache_file, skill_content, encoding="utf-8") if skill_file.exists() or skill_file.is_symlink(): - skill_file.unlink() + unlink(skill_file) target = os.path.relpath(cache_file, skill_file.parent) - os.symlink(target, skill_file) + with changing_file(skill_file): + os.symlink(target, skill_file) except (OSError, ValueError): if skill_file.is_symlink(): - skill_file.unlink() - skill_file.write_text(skill_content, encoding="utf-8") + unlink(skill_file) + write_text(skill_file, skill_content, encoding="utf-8") else: if skill_file.is_symlink(): - skill_file.unlink() - skill_file.write_text(skill_content, encoding="utf-8") + unlink(skill_file) + write_text(skill_file, skill_content, encoding="utf-8") written.append(skill_name) return written @@ -1897,8 +1906,20 @@ def _extension_skill_trusted_root(self, candidate: Path) -> Optional[Path]: def _extension_skill_candidate_dirs(self) -> Dict[Path, Path]: """Return every configured skill output and its trusted root.""" - from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR + from .. import DEFAULT_SKILLS_DIR from ..agents import CommandRegistrar + from ..integrations import get_integration + from ..integrations.installer import project_integrations + + with project_integrations(self.project_root): + registrar = CommandRegistrar(self.project_root, include_generic=False) + folders = [] + for key in registrar.AGENT_CONFIGS: + integration = get_integration(key) + if integration is not None: + folder = (integration.config or {}).get("folder") + if folder: + folders.append(folder) candidates: Dict[Path, Path] = {} @@ -1908,12 +1929,10 @@ def add_candidate(candidate: Path) -> None: if trusted_root is not None: candidates[candidate] = trusted_root - for cfg in AGENT_CONFIG.values(): - folder = cfg.get("folder", "") - if folder: - add_candidate( - self.project_root / folder.rstrip("/") / "skills" - ) + for folder in folders: + add_candidate( + self.project_root / folder.rstrip("/") / "skills" + ) add_candidate(self.project_root / DEFAULT_SKILLS_DIR) from ..integration_state import integration_setting, try_read_integration_json @@ -1928,7 +1947,6 @@ def add_candidate(candidate: Path) -> None: # Recorded paths and static roots still allow safe cleanup. pass - registrar = CommandRegistrar() for agent_name, agent_config in registrar.AGENT_CONFIGS.items(): if agent_config.get("extension") != "/SKILL.md": continue @@ -2042,6 +2060,7 @@ def _restore_generic_refresh_artifacts( _ensure_safe_shared_directory, _validate_safe_shared_directory, ) + from ..integrations._file_changes import changing_file, unlink, write_bytes root = self.project_root.resolve() source = (self.extensions_dir / extension_id).resolve() @@ -2054,7 +2073,7 @@ def _restore_generic_refresh_artifacts( raise ValueError("unexpected symlink at output path") if os.readlink(path) == link: continue - path.unlink() + unlink(path) elif path.exists() and not path.is_file(): raise ValueError("output path is no longer a file") elif content is not None and link is None and path.is_file(): @@ -2062,7 +2081,7 @@ def _restore_generic_refresh_artifacts( continue if content is None: if path.is_file(): - path.unlink() + unlink(path) if not parent_existed and path.parent.is_dir(): try: path.parent.rmdir() @@ -2072,10 +2091,11 @@ def _restore_generic_refresh_artifacts( _ensure_safe_shared_directory(root, path.parent) if link is not None: if path.is_file(): - path.unlink() - path.symlink_to(link) + unlink(path) + with changing_file(path): + path.symlink_to(link) else: - path.write_bytes(content) + write_bytes(path, content) except (OSError, ValueError) as exc: errors.append(f"{path}: {exc}") if errors: @@ -2143,6 +2163,7 @@ def _remove_generic_artifact_paths( ) -> None: """Clean recorded generic paths even after the configured directory moves.""" from ..shared_infra import _validate_safe_shared_directory + from ..integrations._file_changes import unlink, write_bytes manifest = self.get_extension(extension_id) registered = metadata.get("registered_commands", {}) @@ -2192,10 +2213,10 @@ def _remove_generic_artifact_paths( content = path.read_bytes() if hashlib.sha256(content).hexdigest() != expected: if path.is_symlink(): - path.unlink() - path.write_bytes(content) + unlink(path) + write_bytes(path, content) continue - path.unlink() + unlink(path) if skill_output: try: path.parent.rmdir() @@ -2230,6 +2251,8 @@ def _unregister_extension_skills( every configured agent's skills directory is scanned instead of resolving just the currently active one. """ + from ..integrations._file_changes import changing_file, unlink + generic_roots = { Path(path).parent.parent for path in (generic_hashes or {}) @@ -2256,13 +2279,14 @@ def _unregister_extension_skills( skill_file.read_bytes() ).hexdigest(): continue - skill_file.unlink() + unlink(skill_file) try: skill_subdir.rmdir() except OSError: pass continue - shutil.rmtree(skill_subdir) + with changing_file(skill_subdir, removal=True): + shutil.rmtree(skill_subdir) def _extension_owned_skill_names( self, skill_names: List[str], extension_id: str @@ -3518,7 +3542,7 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: if "generic" in safe_commands: safe_commands.pop("generic") if safe_commands: - CommandRegistrar().unregister_commands( + CommandRegistrar(self.project_root, include_generic=False).unregister_commands( safe_commands, self.project_root ) if metadata: @@ -3808,6 +3832,7 @@ def unregister_agent_artifacts( if updates: self.registry.update(ext_id, updates) + @project_registration def _retire_legacy_flat_extension_commands( self, agent_name: str, @@ -4344,10 +4369,10 @@ class CommandRegistrar: AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS - def __init__(self, project_root: Path | None = None): + def __init__(self, project_root: Path | None = None, *, include_generic: bool = True): from ..agents import CommandRegistrar as _Registrar - self._registrar = _Registrar(project_root) + self._registrar = _Registrar(project_root, include_generic=include_generic) self.AGENT_CONFIGS = self._registrar.AGENT_CONFIGS # Delegate static/utility methods diff --git a/src/specify_cli/integration_state.py b/src/specify_cli/integration_state.py index 26e1c24b68..45df5394ef 100644 --- a/src/specify_cli/integration_state.py +++ b/src/specify_cli/integration_state.py @@ -249,4 +249,8 @@ def write_integration_json( data["integration"] = integration_key data["default_integration"] = integration_key + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(dest) dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + after_file_change(dest) diff --git a/src/specify_cli/integration_status.py b/src/specify_cli/integration_status.py index 050f0629d3..99714c74a5 100644 --- a/src/specify_cli/integration_status.py +++ b/src/specify_cli/integration_status.py @@ -356,6 +356,23 @@ def build_integration_status_report(project_root: Path) -> dict[str, Any]: project_root, findings, ) + from .integrations.installer import ( + IntegrationInstallError, load_installed_integrations, read_records, unload_installed_integrations, + ) + + try: + if project_root_is_resolved: + load_installed_integrations(project_root_resolved) + else: + unload_installed_integrations() + if read_records(project_root): + raise IntegrationInstallError("Cannot safely load adapters from an unresolved project root") + except (IntegrationInstallError, OSError) as exc: + findings.append( + _finding("error", "integration-package-invalid", str(exc), + suggestion="Restore the installed adapter package and its registry from a trusted backup.") + ) + return _build_report(None, [], findings, {}, None) state, raw_state, error = try_read_integration_json_with_raw(project_root) if error is not None: findings.append( diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 38af83b116..de32902832 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -45,6 +45,11 @@ def _register(integration: IntegrationBase) -> None: def get_integration(key: str) -> IntegrationBase | None: """Return the integration for *key*, or ``None`` if not registered.""" + from .installer import dispatch_registry + + scoped = dispatch_registry.get() + if scoped is not None: + return scoped.get(key) return INTEGRATION_REGISTRY.get(key) @@ -150,6 +155,14 @@ def _register_builtins() -> None: _register_builtins() +BUILTIN_INTEGRATION_KEYS: frozenset[str] = frozenset(INTEGRATION_REGISTRY) + + +def load_installed_integrations(project_root: Path) -> list[str]: + """Load trusted project adapters, refreshing derived agent configuration.""" + from .installer import load_installed_integrations as load + + return load(project_root) # --------------------------------------------------------------------------- @@ -168,6 +181,15 @@ class IntegrationDescriptorError(Exception): """Raised when an integration.yml descriptor is invalid.""" +def _optional_metadata_error(metadata: dict[str, Any]) -> str | None: + for field in ("author", "repository", "license"): + if field in metadata and ( + not isinstance(metadata[field], str) or not metadata[field].strip() + ): + return f"{field} must be a non-empty string" + return None + + def _catalog_shape_error(payload: Any) -> Optional[str]: """Return a human-readable reason if *payload* is not a valid integration catalog document, else ``None``. @@ -422,6 +444,7 @@ def _get_merged_integrations( merged[integ_id] = { **integ_data, "id": integ_id, + "_declared_id": integ_data.get("id", integ_id), "_catalog_name": entry.name, "_install_allowed": entry.install_allowed, } @@ -809,13 +832,13 @@ class IntegrationDescriptor: requires: speckit_version: ">=0.6.0" tools: [...] - provides: - commands: [...] - scripts: [...] + + Optional legacy ``provides`` metadata is validated but is not an adapter + command inventory. Commands are rendered from the host's shared templates. """ SCHEMA_VERSION = "1.0" - REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"] + REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires"] def __init__(self, descriptor_path: Path) -> None: self.path = descriptor_path @@ -893,6 +916,10 @@ def _validate(self) -> None: f"integration.{field} must be a string, got {type(integ[field]).__name__}" ) + optional_error = _optional_metadata_error(integ) + if optional_error: + raise IntegrationDescriptorError(f"integration.{optional_error}") + if not re.match(r"^[a-z0-9-]+$", integ["id"]): raise IntegrationDescriptorError( f"Invalid integration ID '{integ['id']}': " @@ -919,6 +946,14 @@ def _validate(self) -> None: raise IntegrationDescriptorError( "requires.speckit_version must be a non-empty string" ) + from packaging.specifiers import InvalidSpecifier, SpecifierSet + + try: + SpecifierSet(requires["speckit_version"]) + except InvalidSpecifier as exc: + raise IntegrationDescriptorError( + f"Invalid requires.speckit_version: {exc}" + ) from exc tools = requires.get("tools") if tools is not None: if not isinstance(tools, list): @@ -935,8 +970,23 @@ def _validate(self) -> None: raise IntegrationDescriptorError( "requires.tools entry 'name' must be a non-empty string" ) - - provides = self.data["provides"] + if "required" in tool and not isinstance(tool["required"], bool): + raise IntegrationDescriptorError( + "requires.tools entry 'required' must be a boolean" + ) + if "version" in tool: + if not isinstance(tool["version"], str): + raise IntegrationDescriptorError( + "requires.tools entry 'version' must be a string" + ) + try: + SpecifierSet(tool["version"]) + except InvalidSpecifier as exc: + raise IntegrationDescriptorError( + f"Invalid requires.tools version: {exc}" + ) from exc + + provides = self.data.get("provides", {}) if not isinstance(provides, dict): raise IntegrationDescriptorError( "'provides' must be a mapping" @@ -951,10 +1001,6 @@ def _validate(self) -> None: raise IntegrationDescriptorError( "Invalid provides.scripts: expected a list" ) - if not commands and not scripts: - raise IntegrationDescriptorError( - "Integration must provide at least one command or script" - ) for cmd in commands: if not isinstance(cmd, dict): raise IntegrationDescriptorError( diff --git a/src/specify_cli/integrations/_file_changes.py b/src/specify_cli/integrations/_file_changes.py new file mode 100644 index 0000000000..9ed0ebc62f --- /dev/null +++ b/src/specify_cli/integrations/_file_changes.py @@ -0,0 +1,47 @@ +"""Observe integration-owned writes only while a lifecycle transaction is active.""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path + +file_change_observer: ContextVar[Callable[[Path, bool, bool], None] | None] = ContextVar( + "integration_file_change_observer", default=None +) + + +def before_file_change(path: Path, *, removal: bool = False) -> None: + observer = file_change_observer.get() + if observer is not None: + observer(path, True, removal) + + +def after_file_change(path: Path) -> None: + observer = file_change_observer.get() + if observer is not None: + observer(path, False, False) + + +@contextmanager +def changing_file(path: Path, *, removal: bool = False): + """Journal a host mutation without claiming the file for uninstall.""" + before_file_change(path, removal=removal) + yield + after_file_change(path) + + +def write_text(path: Path, content: str, *, encoding: str = "utf-8", errors: str | None = None) -> int: + with changing_file(path): + return path.write_text(content, encoding=encoding, errors=errors) + + +def write_bytes(path: Path, content: bytes) -> int: + with changing_file(path): + return path.write_bytes(content) + + +def unlink(path: Path, *, missing_ok: bool = False) -> None: + with changing_file(path, removal=True): + path.unlink(missing_ok=missing_ok) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 38e87294ec..36769c88d8 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -121,7 +121,11 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str def _remove_integration_json(project_root: Path) -> None: """Remove ``.specify/integration.json`` if it exists.""" path = project_root / INTEGRATION_JSON + from ._file_changes import after_file_change, before_file_change + + before_file_change(path, removal=True) path.unlink(missing_ok=True) + after_file_change(path) # --------------------------------------------------------------------------- diff --git a/src/specify_cli/integrations/_lifecycle.py b/src/specify_cli/integrations/_lifecycle.py new file mode 100644 index 0000000000..2e94e4f6dd --- /dev/null +++ b/src/specify_cli/integrations/_lifecycle.py @@ -0,0 +1,537 @@ +"""External-adapter preparation and rollback shared by public lifecycle commands.""" + +from __future__ import annotations + +import errno +import hashlib +import inspect +import os +import shutil +import stat +import tempfile +from contextlib import ExitStack, contextmanager +from contextvars import ContextVar +from functools import wraps +from pathlib import Path +from typing import Any + +import typer +from rich.markup import escape + +from .._console import console +from ..integration_state import ( + default_integration_key, + installed_integration_keys, + try_read_integration_json, +) +from . import BUILTIN_INTEGRATION_KEYS, INTEGRATION_REGISTRY, installer +from ._file_changes import file_change_observer + +_init_directory: ContextVar[tuple[Path, bool, list[Path]] | None] = ContextVar( + "external_integration_init_directory", default=None +) +_success_messages: ContextVar[list[str] | None] = ContextVar( + "external_integration_success_messages", default=None +) +_MAX_BACKUP_BYTES = 128 * 1024 * 1024 +_MAX_BACKUP_ENTRIES = 4096 + + +def lifecycle_owns_rollback() -> bool: + """Leave failure cleanup to the active transaction's mutation journal.""" + return file_change_observer.get() is not None + + +def lifecycle_success(message: str) -> None: + """Report success only after an external package transaction commits.""" + pending = _success_messages.get() + if pending is None: + console.print(message) + else: + pending.append(message) + + +def initial_directory_state(path: Path) -> tuple[bool, list[Path]] | None: + """Init validation sees the directory before the lifecycle lock created it.""" + state = _init_directory.get() + if state is not None and state[0] == path: + return state[1], state[2] + return None + + +def _file_identity(path: Path): + if path.is_symlink(): + return ("link", os.readlink(path)) + try: + mode = path.stat().st_mode + except FileNotFoundError: + return None + if stat.S_ISDIR(mode): + return ("directory", tuple( + (child.name, _file_identity(child)) for child in sorted(path.iterdir()) + )) + if not stat.S_ISREG(mode): + raise installer.IntegrationInstallError(f"Unsupported integration output: {path}") + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return ("file", digest) + + +def _entry_state(path: Path) -> tuple[int, ...]: + entry = path.lstat() + return ( + entry.st_mode, entry.st_dev, entry.st_ino, entry.st_size, + entry.st_mtime_ns, entry.st_ctime_ns, + ) + + +def _initial_entries(paths: list[Path]) -> dict[Path, tuple[int, ...]]: + """Census names and metadata without reading or copying file contents.""" + entries = {} + + def fail_walk(error: OSError) -> None: + raise error + + for path in paths: + try: + entries[path] = _entry_state(path) + except FileNotFoundError: + continue + if stat.S_ISDIR(entries[path][0]): + for directory, folders, files in os.walk(path, onerror=fail_walk, followlinks=False): + for name in folders + files: + child = Path(directory) / name + entries[child] = _entry_state(child) + return entries + + +class _FileJournal: + """Restore observed owned writes, never every file under an output root.""" + + def __init__( + self, root: Path, paths: list[Path], backup: Path, initial: dict[Path, tuple[int, ...]], + home_scopes: tuple[Path, ...] = (), + ): + self.root = root + self.paths = paths + self.backup = backup + self.initial = initial + self.home_scopes = home_scopes + self.changes: dict[Path, tuple[Path | None, Any]] = {} + self.pending: set[Path] = set() + self.backup_bytes = 0 + self.backup_entries = 0 + self.directory_states: dict[Path, bool] = {} + for path in paths: + self._remember_parents(path) + + def _remember_parents(self, path: Path) -> None: + boundary = self.root if path.is_relative_to(self.root) else Path.home().absolute() + for parent in path.parents: + if parent == boundary: + break + if parent in self.directory_states: + continue + for scope in self.paths: + if parent == scope or scope in parent.parents: + original = self.initial.get(parent) + self.directory_states[parent] = original is not None and stat.S_ISDIR(original[0]) + break + else: + self.directory_states[parent] = parent.is_dir() + + def _snapshot(self, source: Path, destination: Path) -> None: + self.backup_entries += 1 + if self.backup_entries > _MAX_BACKUP_ENTRIES: + raise installer.IntegrationInstallError("Integration rollback snapshot budget exceeded (entries)") + mode = source.lstat().st_mode + if stat.S_ISLNK(mode): + destination.symlink_to(os.readlink(source)) + elif stat.S_ISDIR(mode): + destination.mkdir() + for child in source.iterdir(): + self._snapshot(child, destination / child.name) + shutil.copystat(source, destination, follow_symlinks=False) + elif stat.S_ISREG(mode): + if source.stat().st_size > _MAX_BACKUP_BYTES - self.backup_bytes: + raise installer.IntegrationInstallError("Integration rollback snapshot budget exceeded (bytes)") + with source.open("rb") as incoming, destination.open("wb") as outgoing: + while content := incoming.read(min(65536, _MAX_BACKUP_BYTES - self.backup_bytes + 1)): + self.backup_bytes += len(content) + if self.backup_bytes > _MAX_BACKUP_BYTES: + raise installer.IntegrationInstallError("Integration rollback snapshot budget exceeded (bytes)") + outgoing.write(content) + shutil.copystat(source, destination, follow_symlinks=False) + else: + raise installer.IntegrationInstallError(f"Unsupported integration output: {source}") + + def safe_path(self, path: Path, *, allow_leaf_symlink: bool = True) -> Path: + path = path.absolute() + boundary = self.root + if not path.is_relative_to(boundary): + if not any(path.is_relative_to(scope) for scope in self.home_scopes): + raise installer.IntegrationInstallError(f"Integration output escapes trusted scopes: {path}") + boundary = Path.home().absolute() + relative = path.relative_to(boundary).as_posix() + installer.safe_project_path(boundary, relative, allow_leaf_symlink=allow_leaf_symlink) + return path + + def observe(self, path: Path, before: bool, removal: bool = False) -> None: + path = self.safe_path(path, allow_leaf_symlink=not before or removal) + self._remember_parents(path) + if ( + before and path in self.changes and path not in self.pending + and _file_identity(path) != self.changes[path][1] + ): + raise installer.IntegrationInstallError( + f"Integration output changed during the operation; refusing to overwrite {path}" + ) + if path not in self.changes: + saved = None + if before: + if path.exists() or path.is_symlink(): + saved = self.backup / "writes" / str(len(self.changes)) + saved.parent.mkdir(parents=True, exist_ok=True) + self._snapshot(path, saved) + else: + if not any(path == scope or scope in path.parents for scope in self.paths): + raise installer.IntegrationInstallError( + f"Integration output {path} must use manifest.record_file() " + "or the host file-writing helpers outside its declared output root" + ) + original = self.initial.get(path) + if original is not None: + if _entry_state(path) == original: + return + raise installer.IntegrationInstallError( + f"Integration output {path} changed without before-write observation; " + "cannot restore its original bytes. Use manifest.record_file() " + "or IntegrationBase.write_file_and_record() before overwriting existing files." + ) + self.changes[path] = (saved, _file_identity(path)) + if not before: + saved, _ = self.changes[path] + self.changes[path] = (saved, _file_identity(path)) + self.pending.discard(path) + else: + self.pending.add(path) + + +def _restore_snapshots(root: Path, journal: _FileJournal) -> list[Path]: + conflicts = [] + for path, (saved, written_identity) in reversed(tuple(journal.changes.items())): + journal.safe_path(path) + current_identity = _file_identity(path) + if path in journal.pending: + original_identity = _file_identity(saved) if saved is not None else None + if current_identity != original_identity: + conflicts.append(path) + continue + if path not in journal.pending and current_identity != written_identity: + conflicts.append(path) + continue + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + elif path.exists() or path.is_symlink(): + path.unlink() + if saved is not None: + path.parent.mkdir(parents=True, exist_ok=True) + if saved.is_symlink(): + path.symlink_to(os.readlink(saved)) + elif saved.is_dir(): + shutil.copytree(saved, path, symlinks=True) + else: + shutil.copy2(saved, path) + else: + parent = path.parent + boundary = root if path.is_relative_to(root) else Path.home().absolute() + while parent != boundary and not journal.directory_states[parent]: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + return conflicts + + +@contextmanager +def _transaction( + root: Path, target: str | None, + expected_state: dict[str, Any] | None, expected_records: dict[str, dict[str, Any]], +): + """Journal operation-owned writes under an inter-process integration lock.""" + from ..shared_infra import _exclusive_project_lock + from .manifest import IntegrationManifest + + folders = { + ".specify/integrations", ".specify/templates", ".specify/scripts", + ".specify/.gitignore", + } + manifest_leaves: set[str] = set() + state, error = try_read_integration_json(root) + if error: + raise installer.IntegrationInstallError(f"Cannot read integration state: {error.detail}") + for key in installed_integration_keys(state or {}): + integration = INTEGRATION_REGISTRY.get(key) + folder = (integration.config or {}).get("folder") if integration else None + if folder: + folders.add(folder.rstrip("/")) + manifest_path = root / ".specify" / "integrations" / f"{key}.manifest.json" + if manifest_path.exists(): + manifest = IntegrationManifest.load(key, root) + for relative in manifest.files: + installer.safe_project_path(root, relative, allow_leaf_symlink=True) + manifest_leaves.add(relative) + for integration in INTEGRATION_REGISTRY.values(): + if type(integration).__module__.startswith(installer._MODULE_PREFIX): + folders.add(integration.config["folder"].rstrip("/")) + target_integration = INTEGRATION_REGISTRY.get(target) + target_folder = (target_integration.config or {}).get("folder") if target_integration else None + if target_folder: + folders.add(target_folder.rstrip("/")) + paths = [ + installer.safe_project_path( + root, folder, allow_leaf_symlink=folder in manifest_leaves and folder not in folders + ) + for folder in sorted(folders | manifest_leaves) + ] + home_scopes = [] + for key in {*installed_integration_keys(state or {}), target} & BUILTIN_INTEGRATION_KEYS: + integration = INTEGRATION_REGISTRY[key] + directory = (integration.registrar_config or {}).get("dir", "") + if directory.startswith("~/"): + scope = installer.safe_project_path(Path.home().absolute(), directory[2:]) + home_scopes.append(scope) + paths.extend(home_scopes) + paths = [path for path in paths if not any(other != path and other in path.parents for other in paths)] + root_existed = root.exists() + lock_root = installer._trust_store(root).parent.parent + lock_id = hashlib.sha256(os.path.normcase(str(root.resolve())).encode()).hexdigest() + backup = Path(tempfile.mkdtemp(prefix="speckit-integration-rollback-")) + preserve_backup = False + try: + # Keep the stable project-keyed lock outside an uninitialized target. + with _exclusive_project_lock(lock_root, f".integration-install-{lock_id}.lock", context="integration"): + root.mkdir(parents=True, exist_ok=True) + current_state, state_error = try_read_integration_json(root) + if state_error or current_state != expected_state or installer.read_records(root) != expected_records: + raise installer.IntegrationInstallError( + "Integration state changed while preparing the operation; retry with the current project state" + ) + journal = _FileJournal(root, paths, backup, _initial_entries(paths), tuple(home_scopes)) + token = file_change_observer.set(journal.observe) + try: + yield + except BaseException as operation_error: + try: + # Restoration itself must not add writes to the journal. + file_change_observer.reset(token) + token = None + conflicts = _restore_snapshots(root, journal) + if conflicts: + preserve_backup = True + console.print( + "[yellow]Warning:[/yellow] Preserved concurrent edits or incomplete writes to " + f"{escape(str(conflicts))}. Recovery snapshots retained at {escape(str(backup))}" + ) + except (OSError, installer.IntegrationInstallError) as restore_error: + preserve_backup = True + raise installer.IntegrationInstallError( + f"Integration operation failed ({operation_error}); rollback failed " + f"({restore_error}). Recovery snapshots retained at {backup}; " + f"write snapshot indexes correspond to {[str(path) for path in journal.changes]}" + ) from operation_error + raise + finally: + if token is not None: + file_change_observer.reset(token) + finally: + if not preserve_backup: + shutil.rmtree(backup) + if not root_existed: + try: + root.rmdir() + except FileNotFoundError: + pass + except OSError as exc: + if exc.errno not in (errno.ENOTEMPTY, errno.EEXIST): + raise + + +def external_lifecycle(operation: str): + """Add package preparation to existing handlers without duplicating setup.""" + def decorate(handler): + signature = inspect.signature(handler) + + @wraps(handler) + @installer.registry_synchronized + def invoke(*args, **kwargs): + values = signature.bind_partial(*args, **kwargs).arguments + loaded = False + if operation == "init": + project_name = values.get("project_name") + here = values.get("here", False) or project_name == "." + if (here and project_name not in (None, ".")) or (not here and not project_name): + return handler(*args, **kwargs) + root = Path.cwd() if here else Path(project_name).resolve() + key = values.get("integration") + else: + from .. import _require_specify_project + + root = _require_specify_project() + key = values.get("target" if operation == "switch" else "key") + recovery_token = None + recovery_binding = None + try: + records = installer.read_records(root) + state, error = try_read_integration_json(root) + if error: + raise installer.IntegrationInstallError(f"Cannot read integration state: {error.detail}") + key = key or default_integration_key(state or {}) + try: + installer.load_installed_integrations(root) + except installer.IntegrationInstallError: + if operation not in {"upgrade", "uninstall"} or not values.get("force") or key not in records: + raise + recovery_token = installer.recovery_exclusion.set((root.resolve(), key)) + installer.load_installed_integrations(root) + recovery_binding = installer.recovery_metadata(root, key, records[key]) + console.print( + f"[yellow]Warning:[/yellow] Recovering integration '{escape(key)}' " + "from validated ownership metadata without loading its failed implementation." + ) + loaded = True + download = ( + isinstance(key, str) + and key not in BUILTIN_INTEGRATION_KEYS + and ( + (operation == "upgrade" and key in records) + or ( + operation in {"init", "install", "switch"} + and key not in INTEGRATION_REGISTRY + ) + ) + ) + if not records and not download: + return handler(*args, **kwargs) + with ExitStack() as stack: + messages: list[str] = [] + message_token = _success_messages.set(messages) + stack.callback(_success_messages.reset, message_token) + candidate = None + if download: + package, record = stack.enter_context( + installer.catalog_package( + root, key, trusted=values.get("trust_integration", False) + ) + ) + candidate = (package, record) + stack.enter_context(installer.prepared_adapter(root, key, package, record)) + if recovery_token is not None: + installer.recovery_exclusion.set(None) + if operation == "init": + token = _init_directory.set( + (root, root.exists(), list(root.iterdir()) if root.is_dir() else []) + ) + stack.callback(_init_directory.reset, token) + with _transaction(root, key, state, records): + if recovery_token is not None and recovery_binding is None: + from .manifest import IntegrationManifest + + path = root / ".specify/integrations" / f"{key}.manifest.json" + if path.exists(): + manifest = IntegrationManifest.load(key, root) + IntegrationManifest(key, root, version=manifest.version).save() + console.print( + "[yellow]Warning:[/yellow] No local recovery ownership record; " + "preserving old generated files from cleanup. They may require manual cleanup. " + "A trusted replacement can overwrite files at its declared destination." + ) + try: + result = handler(*args, **kwargs) + except typer.Exit as exc: + if exc.exit_code != 0 or (candidate and operation == "upgrade"): + raise + if candidate: + exited_state, exited_error = try_read_integration_json(root) + if exited_error: + raise installer.IntegrationInstallError(exited_error.detail) from exc + if key not in installed_integration_keys(exited_state or {}): + raise + result = None + new_state, new_error = try_read_integration_json(root) + if new_error: + raise installer.IntegrationInstallError( + f"Cannot read integration state after {operation}: {new_error.detail}" + ) + remaining = installed_integration_keys(new_state or {}) + if candidate: + if key not in remaining: + raise installer.IntegrationInstallError( + f"Integration {operation} did not install the requested adapter '{key}'" + ) + # End temporary registration before loading durable code. + installer._pending_root = None + installer.unload_installed_integrations() + installer.persist_package(root, key, *candidate) + for removed in records.keys() - set(remaining): + if operation == "uninstall": + from ._helpers import ( + _unregister_extensions_for_agent, + _unregister_presets_for_agent, + ) + + _unregister_extensions_for_agent( + root, removed, + continuing="The adapter was removed, but extension artifacts may need manual cleanup.", + ) + _unregister_presets_for_agent( + root, removed, + continuing="The adapter was removed, but preset artifacts may need manual cleanup.", + ) + installer.remove_package(root, removed) + if operation == "uninstall" and key in records and key == default_integration_key(state or {}): + fallback = default_integration_key(new_state or {}) + if fallback: + from ._helpers import ( + _register_extensions_for_agent, + _register_presets_for_agent, + ) + + _register_extensions_for_agent( + root, fallback, + continuing="The fallback integration was selected, but extensions may need re-registration.", + ) + _register_presets_for_agent( + root, fallback, + continuing="The fallback integration was selected, but presets may need re-registration.", + ) + if not candidate: + installer.load_installed_integrations(root) + for message in messages: + console.print(message) + return result + except typer.Exit: + raise + except (Exception, SystemExit) as exc: + console.print(f"[red]Error:[/red] Integration {operation} failed: {escape(str(exc))}") + raise typer.Exit(1) from exc + finally: + # prepared_adapter's context exits after the transaction; restore + # durable registrations rather than leave the candidate cached. + if loaded: + try: + installer.load_installed_integrations(root) + except (installer.IntegrationInstallError, OSError) as exc: + console.print( + f"[red]Error:[/red] Could not reload integration state after {operation}: {escape(str(exc))}" + ) + raise typer.Exit(1) from exc + finally: + if recovery_token is not None: + installer.recovery_exclusion.reset(recovery_token) + elif recovery_token is not None: + installer.recovery_exclusion.reset(recovery_token) + + return invoke + return decorate diff --git a/src/specify_cli/integrations/_registration.py b/src/specify_cli/integrations/_registration.py new file mode 100644 index 0000000000..f1b12253cb --- /dev/null +++ b/src/specify_cli/integrations/_registration.py @@ -0,0 +1,24 @@ +"""Pin project-local implementations throughout command and skill rendering.""" + +from __future__ import annotations + +import inspect +from functools import wraps + + +def project_registration(handler): + signature = inspect.signature(handler) + + @wraps(handler) + def invoke(*args, **kwargs): + from .installer import project_integrations + + values = signature.bind(*args, **kwargs).arguments + root = ( + values["project_root"] if "project_root" in signature.parameters + else values["self"].project_root + ) + with project_integrations(root): + return handler(*args, **kwargs) + + return invoke diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e698b9e289..6eee0513d6 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -71,6 +71,15 @@ def yaml_quote(value: str) -> str: ).strip() +def resolve_registrar_config(integration: IntegrationBase) -> dict[str, Any]: + """Resolve static registrar overrides and integration-class defaults.""" + config = dict(integration.registrar_config or {}) + config.setdefault("invoke_separator", integration.invoke_separator) + if integration.dev_no_symlink: + config["dev_no_symlink"] = True + return config + + # --------------------------------------------------------------------------- # IntegrationOption # --------------------------------------------------------------------------- @@ -603,9 +612,13 @@ def copy_command_to_directory( written file. The caller can post-process the file before recording it in the manifest. """ - dest_dir.mkdir(parents=True, exist_ok=True) dst = dest_dir / filename + from ._file_changes import after_file_change, before_file_change + + before_file_change(dst) + dest_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) + after_file_change(dst) return dst @staticmethod @@ -635,9 +648,13 @@ def write_file_and_record( ``\r\n`` sequences in *content* are normalised to ``\n`` before writing. Returns *dest*. """ - dest.parent.mkdir(parents=True, exist_ok=True) normalized = content.replace("\r\n", "\n") + from ._file_changes import after_file_change, before_file_change + + before_file_change(dest) + dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(normalized.encode("utf-8")) + after_file_change(dest) rel = dest.resolve().relative_to(project_root.resolve()) manifest.record_existing(rel) return dest @@ -673,14 +690,17 @@ def install_scripts( if not scripts_src: return [] + from ._file_changes import before_file_change + created: list[Path] = [] scripts_dest = project_root / ".specify" / "integrations" / self.key / "scripts" - scripts_dest.mkdir(parents=True, exist_ok=True) for src_script in sorted(scripts_src.iterdir()): if not src_script.is_file(): continue dst_script = scripts_dest / src_script.name + before_file_change(dst_script) + scripts_dest.mkdir(parents=True, exist_ok=True) shutil.copy2(src_script, dst_script) if dst_script.suffix in (".sh", ".py"): dst_script.chmod(dst_script.stat().st_mode | 0o111) diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c48a4dd5f8..db6961b5eb 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -194,7 +194,9 @@ def setup( updated = self.post_process_command_content(content) if updated != content: - path.write_bytes(updated.encode("utf-8")) + from .._file_changes import write_bytes + + write_bytes(path, updated.encode("utf-8")) self.record_file_in_manifest(path, project_root, manifest) return created diff --git a/src/specify_cli/integrations/command_info.py b/src/specify_cli/integrations/command_info.py index 9ebdef06a8..f21051e753 100644 --- a/src/specify_cli/integrations/command_info.py +++ b/src/specify_cli/integrations/command_info.py @@ -19,18 +19,29 @@ def integration_info( versions: bool = typer.Option(False, "--versions", help="List catalog versions"), ): """Show catalog details for a single integration.""" + from .. import _require_specify_project from . import ( + BUILTIN_INTEGRATION_KEYS, INTEGRATION_REGISTRY, IntegrationCatalog, IntegrationCatalogError, IntegrationValidationError, ) - from .. import _require_specify_project project_root = _require_specify_project() catalog = IntegrationCatalog(project_root) installed_key = _default_integration_key(_read_integration_json(project_root)) safe_integration_id = _rich_escape(str(integration_id)) + from .installer import IntegrationInstallError, read_records + + try: + packages = read_records(project_root) + except (IntegrationInstallError, OSError) as exc: + from .installer import unload_installed_integrations + + unload_installed_integrations() + console.print(f"[red]Error:[/red] {_rich_escape(str(exc))}") + raise typer.Exit(1) from exc try: info = catalog.get_integration_info(integration_id) @@ -106,18 +117,28 @@ def integration_info( f" [dim]Repository:[/dim] {_rich_escape(str(info['repository']))}" ) + if integration_id in packages: + console.print(f" [dim]Installed package version:[/dim] {_rich_escape(packages[integration_id]['version'])}") if integration_id == installed_key: console.print("\n [green]✓ Installed[/green] (currently active)") - elif integration_id in INTEGRATION_REGISTRY: + elif integration_id in BUILTIN_INTEGRATION_KEYS: console.print("\n [dim]Built-in integration (not currently active)[/dim]") + elif integration_id in packages: + console.print("\n [dim]Installed external integration (not currently active)[/dim]") return - if integration_id in INTEGRATION_REGISTRY: - integration = INTEGRATION_REGISTRY[integration_id] - cfg = integration.config or {} + if integration_id in BUILTIN_INTEGRATION_KEYS or integration_id in packages: + cfg = ( + INTEGRATION_REGISTRY[integration_id].config or {} + if integration_id in BUILTIN_INTEGRATION_KEYS + else packages[integration_id] + ) name = cfg.get("name", integration_id) console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") - console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") + label = "Built-in integration" if integration_id in BUILTIN_INTEGRATION_KEYS else "Installed external integration" + console.print(f" [dim]{label} (not listed in catalog)[/dim]") + if integration_id in packages: + console.print(f" [dim]Package version:[/dim] {_rich_escape(packages[integration_id]['version'])}") if integration_id == installed_key: console.print("\n [green]✓ Installed[/green] (currently active)") if catalog_error: diff --git a/src/specify_cli/integrations/command_install.py b/src/specify_cli/integrations/command_install.py index f666928c39..1059ad6518 100644 --- a/src/specify_cli/integrations/command_install.py +++ b/src/specify_cli/integrations/command_install.py @@ -18,15 +18,18 @@ integration_settings as _integration_settings, ) from ._commands import integration_app +from ._lifecycle import external_lifecycle, lifecycle_owns_rollback, lifecycle_success from ._helpers import _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _update_init_options_for_integration, _write_integration_json @integration_app.command("install") +@external_lifecycle("install") def integration_install( key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"), force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), + trust_integration: bool = typer.Option(False, "--trust-integration", help="Authorize executing a reviewed external integration package without prompting"), ): """Install an integration into an existing project.""" from . import INTEGRATION_REGISTRY, get_integration @@ -168,25 +171,25 @@ def integration_install( _refresh_init_options_speckit_version(project_root) except Exception as exc: - # Attempt rollback of any files written by setup - try: - integration.teardown(project_root, manifest, force=True) - except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure - from .. import _print_cli_warning - _print_cli_warning( - "rollback", - "integration", - key, - rollback_err, - continuing="The original install failure is still the primary error.", - ) - if installed_keys: - _write_integration_json( - project_root, default_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) + if not lifecycle_owns_rollback(): + try: + integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + from .. import _print_cli_warning + _print_cli_warning( + "rollback", + "integration", + key, + rollback_err, + continuing="The original install failure is still the primary error.", + ) + if installed_keys: + _write_integration_json( + project_root, default_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) console.print( f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: " f"{_cli_error_detail(exc)}" @@ -194,6 +197,6 @@ def integration_install( raise typer.Exit(1) name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") + lifecycle_success(f"\n[green]✓[/green] Integration '{name}' installed successfully") if default_key: console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") diff --git a/src/specify_cli/integrations/command_list.py b/src/specify_cli/integrations/command_list.py index 7850e6cb1b..dd013ef70e 100644 --- a/src/specify_cli/integrations/command_list.py +++ b/src/specify_cli/integrations/command_list.py @@ -1,13 +1,14 @@ """The ``specify integration list`` command.""" from __future__ import annotations - import typer from rich.table import Table from .._console import console from ..integration_state import ( default_integration_key as _default_integration_key, +) +from ..integration_state import ( installed_integration_keys as _installed_integration_keys, ) from ._commands import integration_app @@ -19,10 +20,10 @@ def integration_list( catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), ): """List available integrations and installed status.""" - from . import INTEGRATION_REGISTRY from .. import _require_specify_project + from . import BUILTIN_INTEGRATION_KEYS, INTEGRATION_REGISTRY - project_root = _require_specify_project() + project_root = _require_specify_project(load_integrations=not catalog) current = _read_integration_json(project_root) default_key = _default_integration_key(current) installed_keys = set(_installed_integration_keys(current)) @@ -57,14 +58,14 @@ def integration_list( status = "[green]installed (default)[/green]" elif eid in installed_keys: status = "[green]installed[/green]" - elif eid in INTEGRATION_REGISTRY: + elif eid in BUILTIN_INTEGRATION_KEYS: status = "built-in" elif install_allowed is False: status = "discovery-only" else: status = "" safe = "" - if eid in INTEGRATION_REGISTRY: + if eid in BUILTIN_INTEGRATION_KEYS: reg_integ = INTEGRATION_REGISTRY[eid] safe = "yes" if getattr(reg_integ, "multi_install_safe", False) else "no" table.add_row( diff --git a/src/specify_cli/integrations/command_search.py b/src/specify_cli/integrations/command_search.py index dac8f31a7f..a43b0bbc2a 100644 --- a/src/specify_cli/integrations/command_search.py +++ b/src/specify_cli/integrations/command_search.py @@ -21,7 +21,6 @@ def integration_search( ): """Search for integrations in the active catalog stack.""" from . import ( - INTEGRATION_REGISTRY, IntegrationCatalog, IntegrationCatalogError, IntegrationValidationError, @@ -95,13 +94,8 @@ def integration_search( if iid_value == installed_key: console.print("\n [green]✓ Installed[/green] (currently active)") - elif iid_value in INTEGRATION_REGISTRY: - console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") elif install_allowed: - console.print( - "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " - "can be installed with 'specify integration install'." - ) + console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") else: console.print( f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." diff --git a/src/specify_cli/integrations/command_status.py b/src/specify_cli/integrations/command_status.py index ff43662caa..7d83a5848e 100644 --- a/src/specify_cli/integrations/command_status.py +++ b/src/specify_cli/integrations/command_status.py @@ -79,7 +79,7 @@ def integration_status( from .. import _require_specify_project from ..integration_status import build_integration_status_report - project_root = _require_specify_project() + project_root = _require_specify_project(load_integrations=False) report = build_integration_status_report(project_root) if json_output: diff --git a/src/specify_cli/integrations/command_switch.py b/src/specify_cli/integrations/command_switch.py index d2e85602f5..f0c1976f47 100644 --- a/src/specify_cli/integrations/command_switch.py +++ b/src/specify_cli/integrations/command_switch.py @@ -14,16 +14,19 @@ integration_settings as _integration_settings, ) from ._commands import integration_app +from ._lifecycle import external_lifecycle, lifecycle_owns_rollback, lifecycle_success from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _clear_init_options_for_integration, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json @integration_app.command("switch") +@external_lifecycle("switch") def integration_switch( target: str = typer.Argument(help="Integration key to switch to"), script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"), force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"), refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"), integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), + trust_integration: bool = typer.Option(False, "--trust-integration", help="Authorize executing a reviewed external integration package without prompting"), ): """Switch from the current integration to a different one.""" from . import INTEGRATION_REGISTRY, get_integration @@ -115,7 +118,7 @@ def integration_switch( "need re-registration." ), ) - console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") + lifecycle_success(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") raise typer.Exit(0) selected_script = _resolve_script_type(project_root, script) @@ -287,64 +290,64 @@ def integration_switch( ) except Exception as exc: - # Attempt rollback of any files written by setup - try: - target_integration.teardown(project_root, manifest, force=True) - except Exception as rollback_err: - # Suppress so the original setup error remains the primary failure - _print_cli_warning( - "rollback", - "integration", - target, - rollback_err, - continuing="The original switch failure is still the primary error.", - ) - if installed_keys: - fallback_key = installed_keys[0] - fallback_integration = get_integration(fallback_key) - if fallback_integration is not None: - raw_options, parsed_options = _resolve_integration_options( - fallback_integration, current, fallback_key, None + if not lifecycle_owns_rollback(): + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + _print_cli_warning( + "rollback", + "integration", + target, + rollback_err, + continuing="The original switch failure is still the primary error.", ) - try: - _set_default_integration( - project_root, - current, - fallback_key, - fallback_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - ) - except _SharedTemplateRefreshError as restore_err: - console.print( - f"[yellow]Warning:[/yellow] Failed to restore default " - f"integration '{fallback_key}': {restore_err}" + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None ) + try: + _set_default_integration( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + except _SharedTemplateRefreshError as restore_err: + console.print( + f"[yellow]Warning:[/yellow] Failed to restore default " + f"integration '{fallback_key}': {restore_err}" + ) + else: + # Under active-only registration the fallback may never + # have received any extension/preset artifacts (it was + # installed while another integration was active), and + # Phase 1 already unregistered the outgoing agent's + # artifacts. Rescaffold so the restored default is + # actually usable. Both helpers are best-effort and + # cannot raise past this point. + _register_extensions_for_agent( + project_root, + fallback_key, + continuing="The switch was rolled back; installed extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + fallback_key, + continuing="The switch was rolled back; installed presets may need re-registration.", + ) else: - # Under active-only registration the fallback may never - # have received any extension/preset artifacts (it was - # installed while another integration was active), and - # Phase 1 already unregistered the outgoing agent's - # artifacts. Rescaffold so the restored default is - # actually usable. Both helpers are best-effort and - # cannot raise past this point. - _register_extensions_for_agent( - project_root, - fallback_key, - continuing="The switch was rolled back; installed extensions may need re-registration.", - ) - _register_presets_for_agent( - project_root, - fallback_key, - continuing="The switch was rolled back; installed presets may need re-registration.", + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) ) else: - _write_integration_json( - project_root, fallback_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) + _remove_integration_json(project_root) console.print( f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} " f"during switch: {_cli_error_detail(exc)}" @@ -366,4 +369,4 @@ def integration_switch( ) name = (target_integration.config or {}).get("name", target) - console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + lifecycle_success(f"\n[green]✓[/green] Switched to integration '{name}'") diff --git a/src/specify_cli/integrations/command_uninstall.py b/src/specify_cli/integrations/command_uninstall.py index 0bd37ad7d1..e564a031db 100644 --- a/src/specify_cli/integrations/command_uninstall.py +++ b/src/specify_cli/integrations/command_uninstall.py @@ -8,10 +8,12 @@ from .._utils import _display_project_path from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys, integration_settings as _integration_settings from ._commands import integration_app +from ._lifecycle import external_lifecycle, lifecycle_success from ._helpers import _MANIFEST_READ_ERRORS, _clear_init_options_for_integration, _read_integration_json, _remove_integration_json, _resolve_integration_options, _set_default_integration_or_exit, _write_integration_json @integration_app.command("uninstall") +@external_lifecycle("uninstall") def integration_uninstall( key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), force: bool = typer.Option(False, "--force", help="Remove files even if modified"), @@ -116,7 +118,7 @@ def integration_uninstall( _clear_init_options_for_integration(project_root, key) name = (integration.config or {}).get("name", key) if integration else key - console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") + lifecycle_success(f"\n[green]✓[/green] Integration '{name}' uninstalled") if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index b4682b6825..ec594b8974 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -22,15 +22,18 @@ _manifest_tracks_skill_layout, ) from ._commands import integration_app +from ._lifecycle import external_lifecycle, lifecycle_success from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _resync_manifest_after_registration, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json @integration_app.command("upgrade") +@external_lifecycle("upgrade") def integration_upgrade( key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"), integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), + trust_integration: bool = typer.Option(False, "--trust-integration", help="Authorize executing a reviewed external integration update without prompting"), ): """Upgrade an integration by reinstalling with diff-aware file handling. @@ -353,4 +356,4 @@ def integration_upgrade( ) name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") + lifecycle_success(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") diff --git a/src/specify_cli/integrations/command_use.py b/src/specify_cli/integrations/command_use.py index d26eeb1926..97ba3172b5 100644 --- a/src/specify_cli/integrations/command_use.py +++ b/src/specify_cli/integrations/command_use.py @@ -7,6 +7,7 @@ from .._console import console from ..integration_state import installed_integration_keys as _installed_integration_keys from ._commands import integration_app +from ._lifecycle import external_lifecycle, lifecycle_success from ._helpers import ( _read_integration_json, _register_extensions_for_agent, @@ -17,6 +18,7 @@ @integration_app.command("use") +@external_lifecycle("use") def integration_use( key: str = typer.Argument(help="Installed integration key to make the default"), force: bool = typer.Option(False, "--force", help="Overwrite existing shared infrastructure files, including customizations, while changing the default"), @@ -66,4 +68,4 @@ def integration_use( key, continuing="The integration was selected, but installed presets may need re-registration.", ) - console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") + lifecycle_success(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0ea251d0af..d997db85d4 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -574,13 +574,16 @@ def _setup_commands( settings_src = self._vscode_settings_path() if settings_src and settings_src.is_file(): dst_settings = project_root / ".vscode" / "settings.json" - dst_settings.parent.mkdir(parents=True, exist_ok=True) if dst_settings.exists(): # Merge into existing — don't track since we can't safely # remove the user's settings file on uninstall. self._merge_vscode_settings(settings_src, dst_settings) else: - shutil.copy2(settings_src, dst_settings) + from .._file_changes import changing_file + + with changing_file(dst_settings): + dst_settings.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(settings_src, dst_settings) self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) @@ -613,7 +616,9 @@ def _setup_skills( content = path.read_text(encoding="utf-8") updated = self.post_process_skill_content(content) if updated != content: - path.write_bytes(updated.encode("utf-8")) + from .._file_changes import write_bytes + + write_bytes(path, updated.encode("utf-8")) self.record_file_in_manifest(path, project_root, manifest) return created @@ -677,7 +682,10 @@ def _merge_vscode_settings(src: Path, dst: Path) -> None: return # A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape. - dst.write_text( + from .._file_changes import write_text + + write_text( + dst, json.dumps(existing, indent=4, ensure_ascii=False) + "\n", encoding="utf-8", errors="backslashreplace", diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 582a7bf66e..5b3d1977cc 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -204,17 +204,25 @@ def setup( # Write directly to global ~/.hermes/skills/speckit-/SKILL.md skill_dir = global_skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) skill_file = skill_dir / "SKILL.md" normalized = skill_content.replace("\r\n", "\n") - skill_file.write_bytes(normalized.encode("utf-8")) + from .._file_changes import changing_file + + with changing_file(skill_file): + skill_dir.mkdir(parents=True, exist_ok=True) + skill_file.write_bytes(normalized.encode("utf-8")) created.append(skill_file) # Create project-local marker directory so extension commands # (e.g. git) can detect Hermes as an active integration. # Hermes itself ignores this directory — skills live globally. - (project_root / ".hermes" / "skills").mkdir(parents=True, exist_ok=True) + marker = project_root / ".hermes" / "skills" + if not marker.exists(): + from .._file_changes import changing_file + + with changing_file(marker): + marker.mkdir(parents=True, exist_ok=True) return created @@ -245,10 +253,14 @@ def teardown( # Remove project-local marker directory if empty local_skills_dir = project_root / ".hermes" / "skills" if local_skills_dir.is_dir() and not any(local_skills_dir.iterdir()): - local_skills_dir.rmdir() + from .._file_changes import changing_file + + with changing_file(local_skills_dir, removal=True): + local_skills_dir.rmdir() hermes_dir = project_root / ".hermes" if hermes_dir.is_dir() and not any(hermes_dir.iterdir()): - hermes_dir.rmdir() + with changing_file(hermes_dir, removal=True): + hermes_dir.rmdir() # Remove all global Hermes skills for speckit — these are always # removed on uninstall regardless of the force flag, matching the @@ -258,7 +270,10 @@ def teardown( for skill_dir in sorted(global_skills_dir.iterdir()): if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"): try: - rmtree(skill_dir) + from .._file_changes import changing_file + + with changing_file(skill_dir, removal=True): + rmtree(skill_dir) removed.append(skill_dir) except OSError: skipped.append(skill_dir) diff --git a/src/specify_cli/integrations/installer.py b/src/specify_cli/integrations/installer.py new file mode 100644 index 0000000000..c8ebc7f837 --- /dev/null +++ b/src/specify_cli/integrations/installer.py @@ -0,0 +1,912 @@ +"""Trusted project-local adapter packages, separate from generated artifacts.""" + +from __future__ import annotations + +import hashlib +import importlib.abc +import importlib.machinery +import importlib.util +import inspect +import itertools +import json +import os +import re +import shutil +import stat +import sys +import tempfile +import threading +from collections import Counter +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps +from pathlib import Path, PureWindowsPath +from typing import Any + +from packaging.specifiers import SpecifierSet +from packaging.version import Version + +from .._download_security import ( + archive_format_from_name, + is_https_or_localhost_http, + is_safe_download_redirect, + normalize_archive_member_name, + read_response_limited, + safe_extract_archive, +) +from . import ( + BUILTIN_INTEGRATION_KEYS, + INTEGRATION_REGISTRY, + IntegrationCatalog, + IntegrationDescriptor, + IntegrationDescriptorError, + _optional_metadata_error, +) +from ._file_changes import after_file_change, before_file_change +from .base import IntegrationBase, resolve_registrar_config + +_MODULE_PREFIX = "_speckit_installed_integration_" +_loaded_identity: tuple[Any, ...] | None = None +_loading: ContextVar[bool] = ContextVar("integration_loading", default=False) +_registry_lock = threading.RLock() +_module_sequence = itertools.count() +_pinned_names: Counter[str] = Counter() +dispatch_registry: ContextVar[dict[str, IntegrationBase] | None] = ContextVar( + "integration_dispatch_registry", default=None +) +_pending_root: Path | None = None +_RECORD = ".specify/integrations/packages.json" +_PACKAGES = ".specify/integrations/packages" +_MAX_TRUST_STATE_BYTES = 1024 * 1024 +_source_packages: dict[str, tuple[Path, dict[str, str]]] = {} +recovery_exclusion: ContextVar[tuple[Path, str] | None] = ContextVar( + "integration_recovery_exclusion", default=None +) + + +class IntegrationInstallError(ValueError): + """An external integration cannot safely be installed or loaded.""" + + +def registry_synchronized(handler): + @wraps(handler) + def invoke(*args, **kwargs): + with _registry_lock: + return handler(*args, **kwargs) + return invoke + + +def _namespace(integration: IntegrationBase) -> str: + return type(integration).__module__.split(".", 1)[0] + + +def _discard_unused_imports() -> None: + retained = {_namespace(value) for value in INTEGRATION_REGISTRY.values()} | set(_pinned_names) + for name in tuple(sys.modules): + if name.startswith(_MODULE_PREFIX) and name.split(".", 1)[0] not in retained: + del sys.modules[name] + for name in tuple(_source_packages): + if name not in retained: + del _source_packages[name] + if not _source_packages and _source_finder in sys.meta_path: + sys.meta_path.remove(_source_finder) + + +@contextmanager +def project_integrations(project_root: Path): + """Pin a project snapshot without serializing independent agent processes.""" + with _registry_lock: + load_installed_integrations(project_root) + snapshot = dict(INTEGRATION_REGISTRY) + names = { + _namespace(value) for value in snapshot.values() + if _namespace(value).startswith(_MODULE_PREFIX) + } + _pinned_names.update(names) + token = dispatch_registry.set(snapshot) + try: + yield + finally: + dispatch_registry.reset(token) + with _registry_lock: + _pinned_names.subtract(names) + for name in names: + if _pinned_names[name] == 0: + del _pinned_names[name] + _discard_unused_imports() + + +def project_dispatch(handler): + signature = inspect.signature(handler) + + @wraps(handler) + def invoke(*args, **kwargs): + context = signature.bind(*args, **kwargs).arguments["context"] + root = Path(context.project_root) if context.project_root else Path.cwd() + with project_integrations(root): + return handler(*args, **kwargs) + return invoke + + +class _VerifiedSourceLoader(importlib.machinery.SourceFileLoader): + """Compile the verified bytes, never untracked or stale cached bytecode.""" + + def __init__(self, name: str, package: Path, relative: str, digest: str): + path = safe_project_path(package, relative) + super().__init__(name, str(path)) + self.digest = digest + + def get_code(self, fullname: str): + source = self.get_data(self.path) + if hashlib.sha256(source).hexdigest() != self.digest: + raise IntegrationInstallError(f"Integration source has been modified: {self.path}") + return self.source_to_code(source, self.path) + + +class _VerifiedSourceFinder(importlib.abc.MetaPathFinder): + """Scope relative imports to the installed package's recorded Python files.""" + + @registry_synchronized + def find_spec(self, fullname, path=None, target=None): + for namespace, (package, hashes) in _source_packages.items(): + if not fullname.startswith(namespace + "."): + continue + stem = fullname[len(namespace) + 1:].replace(".", "/") + for relative, locations in ( + (f"{stem}/__init__.py", [str(package / stem)]), + (f"{stem}.py", None), + ): + if relative in hashes: + loader = _VerifiedSourceLoader(fullname, package, relative, hashes[relative]) + return importlib.util.spec_from_file_location( + fullname, loader.path, loader=loader, + submodule_search_locations=locations, + ) + if any(relative.startswith(stem + "/") for relative in hashes): + spec = importlib.machinery.ModuleSpec(fullname, loader=None, is_package=True) + spec.submodule_search_locations = [str(safe_project_path(package, stem))] + return spec + raise ModuleNotFoundError( + f"Integration module {fullname!r} requires a recorded Python source file" + ) + return None + + +_source_finder = _VerifiedSourceFinder() + + +def validate_key(key: str) -> None: + if ( + not isinstance(key, str) + or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", key) + or key in {"con", "prn", "aux", "nul"} + or re.fullmatch(r"(com|lpt)[1-9]", key) + ): + raise IntegrationInstallError(f"Invalid integration ID: {key!r}") + + +def safe_project_path(root: Path, relative: str, *, allow_leaf_symlink: bool = False) -> Path: + """Reject non-canonical paths and symlinked ancestors before accessing them.""" + path = Path(relative) + if ( + not relative + or "\\" in relative + or ":" in relative + or path.is_absolute() + or PureWindowsPath(relative).drive + or any(part in {"", ".", ".."} for part in relative.split("/")) + ): + raise IntegrationInstallError(f"Unsafe integration path: {relative!r}") + normalize_archive_member_name(relative, error_type=IntegrationInstallError) + current = root + for part in path.parts: + current /= part + if current.is_symlink(): + if allow_leaf_symlink and current == root / path: + continue + if current == root / ".specify": + raise IntegrationInstallError(f"Refusing to use symlinked .specify directory: {current}") + raise IntegrationInstallError(f"Symlinked integration path: {current}") + return current + + +def package_hashes(package: Path) -> dict[str, str]: + """Bound package traversal and reject links or non-regular retained files.""" + if package.is_symlink() or not package.is_dir(): + raise IntegrationInstallError(f"Invalid integration package directory: {package}") + hashes: dict[str, str] = {} + count = total = 0 + pending = [(package, 0)] + while pending: + directory, depth = pending.pop() + if depth > 32: + raise IntegrationInstallError("Integration package exceeds depth limit (32)") + with os.scandir(directory) as entries: + for entry in entries: + relative = Path(entry.path).relative_to(package).as_posix() + safe_project_path(package, relative) + mode = entry.stat(follow_symlinks=False).st_mode + if stat.S_ISLNK(mode): + raise IntegrationInstallError(f"Integration package contains symlink: {entry.path}") + if entry.name == "__pycache__" and stat.S_ISDIR(mode): + continue + count += 1 + if count > 512: + raise IntegrationInstallError("Integration package exceeds entry limit (512)") + if stat.S_ISDIR(mode): + pending.append((Path(entry.path), depth + 1)) + elif stat.S_ISREG(mode): + size = entry.stat(follow_symlinks=False).st_size + total += size + if size > 10 * 1024 * 1024 or total > 50 * 1024 * 1024: + raise IntegrationInstallError("Integration package exceeds size limit") + with open(entry.path, "rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + hashes[relative] = digest + else: + raise IntegrationInstallError(f"Unsupported integration file: {entry.path}") + if not {"integration.yml", "__init__.py"} <= hashes.keys(): + raise IntegrationInstallError("Integration package requires root integration.yml and __init__.py") + return hashes + + +def read_records(root: Path) -> dict[str, dict[str, Any]]: + safe_project_path(root, _PACKAGES) + path = safe_project_path(root, _RECORD) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (OSError, ValueError, UnicodeError) as exc: + raise IntegrationInstallError(f"Cannot read installed integration packages: {exc}") from exc + if not isinstance(data, dict) or data.get("schema_version") != "1.0" or not isinstance(data.get("packages"), dict): + raise IntegrationInstallError("Invalid installed integration package registry") + records = data["packages"] + for key, record in records.items(): + validate_key(key) + if key in BUILTIN_INTEGRATION_KEYS: + raise IntegrationInstallError(f"External package collides with built-in integration '{key}'") + if not isinstance(record, dict): + raise IntegrationInstallError(f"Integration '{key}' has invalid package metadata") + for field in ("id", "name", "version", "description", "catalog", "download_url"): + if not isinstance(record.get(field), str) or not record[field].strip(): + raise IntegrationInstallError( + f"Integration '{key}' has invalid package metadata: {field}" + ) + if not isinstance(record.get("requires"), dict): + raise IntegrationInstallError(f"Integration '{key}' has invalid package metadata: requires") + if not isinstance(record.get("files"), dict): + raise IntegrationInstallError(f"Integration '{key}' has invalid package hashes") + for relative, digest in record["files"].items(): + if not isinstance(relative, str) or not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{64}", digest): + raise IntegrationInstallError(f"Integration '{key}' has invalid package hashes") + normalize_archive_member_name(relative, error_type=IntegrationInstallError) + return records + + +def write_records(root: Path, records: dict[str, dict[str, Any]]) -> None: + path = safe_project_path(root, _RECORD) + before_file_change(path, removal=not records) + path.parent.mkdir(parents=True, exist_ok=True) + if not records: + path.unlink(missing_ok=True) + after_file_change(path) + return + with tempfile.NamedTemporaryFile(dir=path.parent, mode="w", encoding="utf-8", delete=False) as stream: + temporary = Path(stream.name) + json.dump({"schema_version": "1.0", "packages": records}, stream, indent=2) + stream.write("\n") + try: + os.replace(temporary, path) + after_file_change(path) + finally: + temporary.unlink(missing_ok=True) + + +def _trust_store(root: Path) -> Path: + home = Path.home().absolute() + if any(path.is_symlink() for path in (home, *home.parents)): + raise IntegrationInstallError("Refusing symlinked integration trust directory") + home = home.resolve() + project = root.resolve() + trust = safe_project_path(home, ".specify/integration-trust.json") + if trust.is_relative_to(project): + raise IntegrationInstallError("Integration local trust state must be outside the project") + return trust + + +def _trust_identity(root: Path, key: str, hashes: dict[str, str]) -> str: + payload = json.dumps( + [os.path.normcase(str(root.resolve())), key, hashes], + sort_keys=True, separators=(",", ":"), ensure_ascii=True, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _read_trust_state(path: Path) -> dict[str, Any]: + try: + with path.open("rb") as stream: + content = stream.read(_MAX_TRUST_STATE_BYTES + 1) + if len(content) > _MAX_TRUST_STATE_BYTES: + raise ValueError("trust registry exceeds size limit") + data = json.loads(content) + except FileNotFoundError: + return {"schema_version": "1.0", "grants": [], "recovery": {}} + except (OSError, ValueError, UnicodeError) as exc: + raise IntegrationInstallError(f"Cannot read integration local trust state: {exc}") from exc + if ( + not isinstance(data, dict) or data.get("schema_version") != "1.0" + or not isinstance(data.get("grants"), list) + or any(not isinstance(item, str) or not re.fullmatch(r"[a-f0-9]{64}", item) for item in data["grants"]) + ): + raise IntegrationInstallError("Invalid integration local trust state") + recovery = data.get("recovery", {}) + if not isinstance(recovery, dict): + raise IntegrationInstallError("Invalid integration local recovery ownership") + for identity, binding in recovery.items(): + if ( + not isinstance(identity, str) + or not re.fullmatch(r"[a-f0-9]{64}", identity) + or not isinstance(binding, dict) + or not isinstance(binding.get("package"), str) + or not re.fullmatch(r"[a-f0-9]{64}", binding["package"]) + or not isinstance(binding.get("registrar_config"), dict) + or not isinstance(binding.get("paths"), list) + or any(not isinstance(path, str) for path in binding["paths"]) + ): + raise IntegrationInstallError("Invalid integration local recovery ownership") + if "files" in binding and ( + not isinstance(binding["files"], dict) + or any( + not isinstance(name, str) or not isinstance(digest, str) + or not re.fullmatch(r"[a-f0-9]{64}", digest) + for name, digest in binding["files"].items() + ) + ): + raise IntegrationInstallError("Invalid integration local recovery package hashes") + data["recovery"] = recovery + return data + + +def _read_trust(path: Path) -> set[str]: + return set(_read_trust_state(path)["grants"]) + + +def _recovery_identity(root: Path, key: str) -> str: + value = json.dumps([os.path.normcase(str(root.resolve())), key]) + return hashlib.sha256(value.encode()).hexdigest() + + +def _grant_trust( + root: Path, key: str, record: dict[str, Any], *, record_ownership: bool = False, +) -> None: + """Persist this user's explicit consent, bound to the project and package.""" + from ..shared_infra import _exclusive_project_lock + from .manifest import IntegrationManifest + + path = _trust_store(root) + with _exclusive_project_lock(path.parent.parent, ".integration-trust.lock", context="integration trust"): + path = _trust_store(root) + data = _read_trust_state(path) + identity = _trust_identity(root, key, record["files"]) + data["grants"] = sorted(set(data["grants"]) | {identity}) + if record_ownership: + data["recovery"][_recovery_identity(root, key)] = { + "package": identity, + "files": dict(record["files"]), + "registrar_config": record["registrar_config"], + "paths": sorted(IntegrationManifest.load(key, root).files), + } + content = json.dumps(data, indent=2) + "\n" + if len(content.encode("utf-8")) > _MAX_TRUST_STATE_BYTES: + raise IntegrationInstallError( + "Cannot write integration local trust state: trust registry exceeds size limit" + ) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, mode="w", encoding="utf-8", delete=False) as stream: + temporary = Path(stream.name) + stream.write(content) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def recovery_metadata(root: Path, key: str, record: dict[str, Any]) -> dict[str, Any] | None: + """Authorize cleanup from user-local ownership, never mutable project claims.""" + from .manifest import IntegrationManifest + + trust = _read_trust_state(_trust_store(root)) + binding = trust["recovery"].get(_recovery_identity(root, key)) + if binding is None: + return None + if "files" in binding and binding["package"] != _trust_identity(root, key, binding["files"]): + raise IntegrationInstallError(f"Integration '{key}' recovery package identity has been modified") + if binding["package"] not in trust["grants"]: + return None + if record.get("registrar_config") != binding["registrar_config"]: + raise IntegrationInstallError(f"Integration '{key}' recovery ownership metadata has been modified") + config = binding["registrar_config"] + _validate_registrar_config(key, config) + + def validate_cleanup(relative: str, *, leaf: bool = False) -> None: + path = safe_project_path(root, relative, allow_leaf_symlink=leaf) + parts = tuple(part.casefold() for part in path.relative_to(root).parts) + if not leaf and parts[0] in {".git", ".specify"}: + raise IntegrationInstallError("Reserved recovery ownership directory") + for other in INTEGRATION_REGISTRY.values(): + if other.key != key and any(_paths_overlap(relative, folder) for folder in _output_roots(other)): + raise IntegrationInstallError( + f"Integration '{key}' recovery ownership overlaps '{other.key}'" + ) + + validate_cleanup(config["dir"]) + if "legacy_dir" in config: + validate_cleanup(config["legacy_dir"]) + manifest = safe_project_path(root, f".specify/integrations/{key}.manifest.json") + if manifest.exists(): + paths = IntegrationManifest.load(key, root).files + if not set(paths) <= set(binding["paths"]): + raise IntegrationInstallError(f"Integration '{key}' manifest recovery ownership has been modified") + for relative in paths: + validate_cleanup(relative, leaf=True) + return binding + + +def _refresh_configs() -> None: + """Mutate cached dictionaries in place so compatibility aliases stay live.""" + agent_module = sys.modules.get("specify_cli._agent_config") + if agent_module is not None: + agent_module.AGENT_CONFIG.clear() + agent_module.AGENT_CONFIG.update(agent_module._build_agent_config()) + agents = sys.modules.get("specify_cli.agents") + if agents is not None: + registrar = agents.CommandRegistrar + configs = agents._build_agent_configs() + for cls in (registrar, *registrar.__subclasses__()): + cls.AGENT_CONFIGS.clear() + cls.AGENT_CONFIGS.update(configs) + cls._configs_loaded = True + + +@registry_synchronized +def unload_installed_integrations() -> None: + global _loaded_identity + for key in tuple(INTEGRATION_REGISTRY): + if type(INTEGRATION_REGISTRY[key]).__module__.startswith(_MODULE_PREFIX): + del INTEGRATION_REGISTRY[key] + _discard_unused_imports() + _loaded_identity = None + _refresh_configs() + + +def _descriptor(package: Path, key: str, info: dict[str, Any]) -> IntegrationDescriptor: + optional_error = _optional_metadata_error(info) + if optional_error: + raise IntegrationInstallError(f"Integration '{key}' catalog {optional_error}") + try: + descriptor = IntegrationDescriptor(package / "integration.yml") + except IntegrationDescriptorError as exc: + raise IntegrationInstallError(str(exc)) from exc + validate_key(descriptor.id) + if descriptor.id != key or info.get("_declared_id", info.get("id", key)) != key: + raise IntegrationInstallError(f"Integration descriptor/catalog identity does not match '{key}'") + metadata = descriptor.data["integration"] + for field in ("name", "version", "description", "author", "repository", "license"): + if field in info and info[field] != metadata.get(field): + raise IntegrationInstallError(f"Integration '{key}' catalog/descriptor {field} mismatch") + if "requires" in info and info["requires"] != descriptor.data["requires"]: + raise IntegrationInstallError(f"Integration '{key}' catalog/descriptor requirements mismatch") + from .._assets import get_speckit_version + + host_version = get_speckit_version() + if not SpecifierSet(descriptor.requires_speckit_version).contains(host_version, prereleases=True): + raise IntegrationInstallError( + f"Integration '{key}' requires Spec Kit {descriptor.requires_speckit_version}; installed {host_version}" + ) + return descriptor + + +def _paths_overlap(first: str, second: str) -> bool: + left = tuple(part.casefold() for part in Path(first).parts) + right = tuple(part.casefold() for part in Path(second).parts) + return left[:len(right)] == right or right[:len(left)] == left + + +def _output_roots(integration: IntegrationBase) -> list[str]: + return [ + path.rstrip("/") for path in ( + (integration.config or {}).get("folder"), + (integration.registrar_config or {}).get("legacy_dir"), + ) + if isinstance(path, str) and path + ] + + +def _validate_output_paths(integration: IntegrationBase, project_root: Path) -> None: + config = integration.config + folder = config["folder"].rstrip("/") + destination = f"{folder}/{config['commands_subdir']}" + paths = [folder, destination, integration.registrar_config["dir"]] + if "legacy_dir" in integration.registrar_config: + paths.append(integration.registrar_config["legacy_dir"]) + for relative in paths: + if relative.startswith("~"): + raise IntegrationInstallError(f"Integration '{integration.key}' output must be project-local") + safe_project_path(project_root, relative) + if Path(relative).parts[0].casefold() in {".specify", ".git"}: + raise IntegrationInstallError(f"Integration '{integration.key}' output uses reserved directory") + roots = _output_roots(integration) + for index, current in enumerate(roots): + if any(_paths_overlap(current, other) for other in roots[index + 1:]): + raise IntegrationInstallError(f"Integration '{integration.key}' own output roots overlap") + + +def _validate_registrar_config(key: str, registrar: Any) -> None: + if not isinstance(registrar, dict): + raise IntegrationInstallError(f"Integration '{key}' requires a registrar_config mapping") + for field in ("dir", "format", "args", "extension"): + if not isinstance(registrar.get(field), str) or not registrar[field].strip(): + raise IntegrationInstallError(f"Integration '{key}' registrar_config.{field} must be a non-empty string") + if registrar["format"] not in {"markdown", "toml", "yaml"}: + raise IntegrationInstallError(f"Integration '{key}' has unsupported registration format") + if not re.fullmatch(r"\.[A-Za-z0-9][A-Za-z0-9_.-]*", registrar["extension"]) and not ( + registrar["format"] == "markdown" and registrar["extension"] == "/SKILL.md" + ): + raise IntegrationInstallError(f"Integration '{key}' has unsafe registration extension") + if "invoke_separator" in registrar and ( + not isinstance(registrar["invoke_separator"], str) or not registrar["invoke_separator"] + ): + raise IntegrationInstallError(f"Integration '{key}' registrar_config.invoke_separator must be a non-empty string") + if "dev_no_symlink" in registrar and not isinstance(registrar["dev_no_symlink"], bool): + raise IntegrationInstallError(f"Integration '{key}' registrar_config.dev_no_symlink must be a boolean") + if "legacy_dir" in registrar and ( + not isinstance(registrar["legacy_dir"], str) or not registrar["legacy_dir"].strip() + ): + raise IntegrationInstallError(f"Integration '{key}' registrar_config.legacy_dir must be a non-empty string") + + +def _validate_implementation( + integration: IntegrationBase, descriptor: IntegrationDescriptor, project_root: Path, +) -> None: + key = descriptor.id + config = integration.config + registrar = integration.registrar_config + if not isinstance(config, dict): + raise IntegrationInstallError(f"Integration '{key}' requires a config mapping") + _validate_registrar_config(key, registrar) + if config.get("name") != descriptor.name: + raise IntegrationInstallError(f"Integration '{key}' class/descriptor name mismatch") + if getattr(integration, "version", descriptor.version) != descriptor.version: + raise IntegrationInstallError(f"Integration '{key}' class/descriptor version mismatch") + for field in ("folder", "commands_subdir", "install_url"): + if not isinstance(config.get(field), str) or not config[field].strip(): + raise IntegrationInstallError(f"Integration '{key}' config.{field} must be a non-empty string") + if not isinstance(config.get("requires_cli"), bool): + raise IntegrationInstallError(f"Integration '{key}' config.requires_cli must be a boolean") + folder = config["folder"].rstrip("/") + destination = f"{folder}/{config['commands_subdir']}" + _validate_output_paths(integration, project_root) + if registrar["dir"] != destination: + raise IntegrationInstallError(f"Integration '{key}' registration directory does not match config") + if not isinstance(integration.multi_install_safe, bool): + raise IntegrationInstallError(f"Integration '{key}' multi_install_safe must be a boolean") + if not isinstance(integration.invoke_separator, str) or not integration.invoke_separator: + raise IntegrationInstallError(f"Integration '{key}' invoke_separator must be a non-empty string") + if not isinstance(integration.dev_no_symlink, bool): + raise IntegrationInstallError(f"Integration '{key}' dev_no_symlink must be a boolean") + if integration.multi_install_safe: + roots = _output_roots(integration) + for other in INTEGRATION_REGISTRY.values(): + if other.key != key and any( + _paths_overlap(path, other_folder) + for path in roots for other_folder in _output_roots(other) + ): + raise IntegrationInstallError(f"Integration '{key}' multi-install root overlaps '{other.key}'") + signature = inspect.signature(integration.build_exec_args) + execution_parameters = ("model", "output_json", "integration_args", "integration_options", "project_root") + for parameter in execution_parameters: + if parameter not in signature.parameters and not any( + item.kind == inspect.Parameter.VAR_KEYWORD for item in signature.parameters.values() + ): + raise IntegrationInstallError(f"Integration '{key}' build_exec_args must accept {parameter}") + try: + signature.bind("Sample prompt", **dict.fromkeys(execution_parameters)) + except TypeError as exc: + raise IntegrationInstallError( + f"Integration '{key}' build_exec_args must accept the host execution signature: {exc}" + ) from exc + + +def _import_package( + package: Path, descriptor: IntegrationDescriptor, hashes: dict[str, str], project_root: Path, +) -> IntegrationBase: + key = descriptor.id + if key in BUILTIN_INTEGRATION_KEYS or key in INTEGRATION_REGISTRY: + raise IntegrationInstallError(f"Integration '{key}' is already registered or built-in") + identity = hashlib.sha256(str(package.resolve()).encode()).hexdigest()[:16] + module_name = f"{_MODULE_PREFIX}{key.replace('-', '_')}_{identity}_{next(_module_sequence)}" + loader = _VerifiedSourceLoader(module_name, package, "__init__.py", hashes["__init__.py"]) + spec = importlib.util.spec_from_file_location( + module_name, loader.path, loader=loader, submodule_search_locations=[str(package)] + ) + if spec is None or spec.loader is None: + raise IntegrationInstallError(f"Cannot load integration '{key}'") + before = dict(INTEGRATION_REGISTRY) + module = importlib.util.module_from_spec(spec) + _source_packages[module_name] = (package, hashes) + if _source_finder not in sys.meta_path: + sys.meta_path.insert(0, _source_finder) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + if INTEGRATION_REGISTRY != before: + raise IntegrationInstallError("External integrations must not register through import side effects") + classes = { + value for value in vars(module).values() + if isinstance(value, type) and issubclass(value, IntegrationBase) + and not inspect.isabstract(value) + and (value.__module__ == module_name or value.__module__.startswith(module_name + ".")) + } + if len(classes) != 1: + raise IntegrationInstallError(f"Integration '{key}' must export exactly one IntegrationBase subclass") + cls = classes.pop() + if cls.key != key: + raise IntegrationInstallError(f"Integration '{key}' class key mismatch: {cls.key!r}") + integration = cls() + _validate_implementation(integration, descriptor, project_root) + return integration + except BaseException as exc: + INTEGRATION_REGISTRY.clear() + INTEGRATION_REGISTRY.update(before) + for name in tuple(sys.modules): + if name == module_name or name.startswith(module_name + "."): + del sys.modules[name] + _source_packages.pop(module_name, None) + if isinstance(exc, (KeyboardInterrupt, GeneratorExit)): + raise + raise IntegrationInstallError(f"Failed to load integration '{key}': {exc}") from exc + + +@registry_synchronized +def load_installed_integrations(project_root: Path) -> list[str]: + global _loaded_identity + if _loading.get(): + return [] + root = Path(project_root).resolve() + if _pending_root is not None: + if root != _pending_root: + raise IntegrationInstallError("Cannot change projects during integration installation") + return [ + key for key, integration in INTEGRATION_REGISTRY.items() + if type(integration).__module__.startswith(_MODULE_PREFIX) + ] + loading_token = _loading.set(True) + try: + records = read_records(root) + recovery = recovery_exclusion.get() + excluded = recovery[1] if recovery is not None and recovery[0] == root else None + selected = {key: info for key, info in records.items() if key != excluded} + base = safe_project_path(root, _PACKAGES) + if base.is_dir(): + for child in base.iterdir(): + if child.name.startswith(".install-"): + continue + if child.name not in records: + raise IntegrationInstallError(f"Unregistered integration package: {child.name}") + identity: list[Any] = [str(root), excluded] + descriptors = {} + grants = _read_trust(_trust_store(root)) if selected else set() + for key, info in sorted(selected.items()): + package = safe_project_path(root, f"{_PACKAGES}/{key}") + hashes = package_hashes(package) + if hashes != info["files"]: + raise IntegrationInstallError(f"Integration '{key}' installed package has been modified") + if _trust_identity(root, key, hashes) not in grants: + raise IntegrationInstallError( + f"Integration '{key}' has no local trust decision for this project and package. " + f"Review its source and run integration upgrade {key} --force --trust-integration " + "from an install-enabled catalog to authorize it." + ) + descriptors[key] = _descriptor(package, key, info) + identity.append((key, tuple(sorted(hashes.items())))) + if tuple(identity) == _loaded_identity and all( + key in INTEGRATION_REGISTRY for key in selected + ): + for key in selected: + _validate_output_paths(INTEGRATION_REGISTRY[key], root) + return list(selected) + unload_installed_integrations() + for key in sorted(selected): + INTEGRATION_REGISTRY[key] = _import_package( + safe_project_path(root, f"{_PACKAGES}/{key}"), descriptors[key], records[key]["files"], root + ) + safe_project_path(root, INTEGRATION_REGISTRY[key].config["folder"].rstrip("/")) + _loaded_identity = tuple(identity) + _refresh_configs() + if excluded is not None and excluded in records: + binding = recovery_metadata(root, excluded, records[excluded]) + config = binding["registrar_config"] if binding is not None else None + if config is not None: + _validate_registrar_config(excluded, config) + directory = config.get("dir") + if not isinstance(directory, str): + raise IntegrationInstallError("Invalid persisted adapter registration directory") + safe_project_path(root, directory) + if Path(directory).parts[0].casefold() in {".git", ".specify"}: + raise IntegrationInstallError("Reserved persisted adapter registration directory") + agents = sys.modules.get("specify_cli.agents") + if agents is not None: + for registrar in (agents.CommandRegistrar, *agents.CommandRegistrar.__subclasses__()): + registrar.AGENT_CONFIGS[excluded] = dict(config) + return list(selected) + except BaseException: + unload_installed_integrations() + raise + finally: + _loading.reset(loading_token) + + +@contextmanager +def catalog_package(root: Path, key: str, *, trusted: bool = False): + """Download and validate without executing code before explicit consent.""" + validate_key(key) + if key in BUILTIN_INTEGRATION_KEYS: + raise IntegrationInstallError(f"Cannot replace built-in integration '{key}'") + catalog = IntegrationCatalog(root) + with tempfile.TemporaryDirectory(prefix="speckit-integration-catalog-") as cache: + catalog.cache_dir = Path(cache) + info = catalog.get_integration_info(key) + if info is None: + raise IntegrationInstallError(f"Unknown integration '{key}' (not found in catalog)") + if info.get("_install_allowed") is not True: + raise IntegrationInstallError(f"Integration '{key}' is from a discovery-only catalog") + url = info.get("download_url") + if not isinstance(url, str) or not is_https_or_localhost_http(url): + raise IntegrationInstallError("Integration download_url must use HTTPS or loopback HTTP") + digest = info.get("sha256") + if digest is not None and (not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{64}", digest)): + raise IntegrationInstallError("Integration sha256 must be a 64-character hex digest") + for field in ("name", "version", "description"): + if not isinstance(info.get(field), str) or not info[field].strip(): + raise IntegrationInstallError(f"Integration catalog entry requires {field}") + optional_error = _optional_metadata_error(info) + if optional_error: + raise IntegrationInstallError(f"Integration '{key}' catalog {optional_error}") + try: + Version(info["version"]) + except ValueError as exc: + raise IntegrationInstallError(f"Invalid catalog integration version: {exc}") from exc + if not trusted: + import typer + + from .._console import console + + console.print( + "External integration packages execute Python with your user permissions. " + "Review the package and source before trusting it." + ) + try: + consent = typer.confirm(f"Trust integration '{key}' from {url}?", default=False) + except (typer.Abort, EOFError) as exc: + raise IntegrationInstallError("Installation requires consent or --trust-integration") from exc + if not consent: + raise IntegrationInstallError("Integration installation cancelled; source not trusted") + from ..authentication.github_http import resolve_github_release_asset_api_url + from ..authentication.http import github_provider_hosts, open_url + + def reject_insecure_download_redirect(old_url: str, new_url: str) -> None: + if not is_safe_download_redirect(old_url, new_url): + raise IntegrationInstallError("Integration download has an unsafe redirect") + + resolved_url = resolve_github_release_asset_api_url( + url, open_url, timeout=30, github_hosts=github_provider_hosts(), + redirect_validator=reject_insecure_download_redirect, + ) + with tempfile.TemporaryDirectory(prefix="speckit-integration-") as temporary: + directory = Path(temporary) + with open_url( + resolved_url or url, timeout=30, + extra_headers={"Accept": "application/octet-stream"} if resolved_url else None, + redirect_validator=reject_insecure_download_redirect, + ) as response: + final_url = response.geturl() + if not is_https_or_localhost_http(final_url): + raise IntegrationInstallError("Integration download redirected to an insecure URL") + requested_format = archive_format_from_name(url) + final_format = archive_format_from_name(final_url) + if requested_format and final_format and requested_format != final_format: + raise IntegrationInstallError("Integration archive URL format mismatch") + content_type = response.headers.get("Content-Type") + content = read_response_limited(response, error_type=IntegrationInstallError, label="integration archive") + if digest and hashlib.sha256(content).hexdigest() != digest.lower(): + raise IntegrationInstallError("Integration archive SHA-256 mismatch") + archive = directory / "download.archive" + archive.write_bytes(content) + extracted = directory / "extracted" + safe_extract_archive( + archive, extracted, source_name=url if requested_format else final_url, + content_type=content_type, error_type=IntegrationInstallError, + ) + package = extracted + if not (package / "integration.yml").is_file(): + children = list(extracted.iterdir()) + if len(children) == 1 and children[0].is_dir(): + package = children[0] + hashes = package_hashes(package) + descriptor = _descriptor(package, key, info) + for tool in descriptor.tools: + if tool.get("required", True) and shutil.which(tool["name"]) is None: + raise IntegrationInstallError(f"Integration '{key}' requires missing tool '{tool['name']}'") + yield package, { + **descriptor.data["integration"], + "requires": descriptor.data["requires"], + "catalog": info["_catalog_name"], + "download_url": url, + "sha256": digest, + "files": hashes, + } + + +def persist_package(root: Path, key: str, package: Path, record: dict[str, Any]) -> None: + """Stage on the destination filesystem; caller owns lifecycle rollback.""" + base = safe_project_path(root, _PACKAGES) + base.mkdir(parents=True, exist_ok=True) + destination = safe_project_path(root, f"{_PACKAGES}/{key}") + with tempfile.TemporaryDirectory(dir=base, prefix=".install-") as temporary: + staged = Path(temporary) / key + shutil.copytree(package, staged, ignore=shutil.ignore_patterns("__pycache__")) + if package_hashes(staged) != record["files"]: + raise IntegrationInstallError("Integration package changed while staging") + before_file_change(destination) + if destination.exists(): + shutil.rmtree(destination) + os.replace(staged, destination) + after_file_change(destination) + records = read_records(root) + records[key] = record + write_records(root, records) + _grant_trust(root, key, record) + load_installed_integrations(root) + _grant_trust(root, key, record, record_ownership=True) + + +def remove_package(root: Path, key: str) -> None: + records = read_records(root) + if key not in records: + return + directory = safe_project_path(root, f"{_PACKAGES}/{key}") + before_file_change(directory, removal=True) + try: + shutil.rmtree(directory) + except FileNotFoundError: + if directory.exists(): + raise + after_file_change(directory) + del records[key] + write_records(root, records) + load_installed_integrations(root) + + +@contextmanager +def prepared_adapter(root: Path, key: str, package: Path, record: dict[str, Any]): + """Expose a trusted candidate only for its project's lifecycle transaction.""" + global _pending_root, _loaded_identity + _read_trust_state(_trust_store(root)) + _loaded_identity = None + INTEGRATION_REGISTRY.pop(key, None) + descriptor = _descriptor(package, key, record) + integration = _import_package(package, descriptor, record["files"], root) + safe_project_path(root, integration.config["folder"].rstrip("/")) + INTEGRATION_REGISTRY[key] = integration + record["registrar_config"] = resolve_registrar_config(integration) + record["registrar_config"].setdefault("dev_no_symlink", False) + _pending_root = root.resolve() + try: + _refresh_configs() + yield integration + finally: + if _pending_root is not None: + _pending_root = None + unload_installed_integrations() diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 2d4a6b32d9..21ecac1f83 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -172,7 +172,9 @@ def setup( updated = self.post_process_command_content(content) if updated != content: - path.write_bytes(updated.encode("utf-8")) + from .._file_changes import write_bytes + + write_bytes(path, updated.encode("utf-8")) self.record_file_in_manifest(path, project_root, manifest) return created diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3a289d60ed..edb6d81d7a 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -146,7 +146,10 @@ def teardown( continue if _is_speckit_generated_skill(legacy_dir): try: - shutil.rmtree(legacy_dir) + from .._file_changes import changing_file + + with changing_file(legacy_dir, removal=True): + shutil.rmtree(legacy_dir) removed.append(legacy_dir) except OSError: skipped.append(legacy_dir) @@ -258,7 +261,10 @@ def _migrate_legacy_kimi_skills_dir( if not target_dir.exists(): target_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(legacy_dir), str(target_dir)) + from .._file_changes import changing_file + + with changing_file(legacy_dir, removal=True), changing_file(target_dir): + shutil.move(str(legacy_dir), str(target_dir)) migrated_count += 1 continue @@ -278,7 +284,10 @@ def _migrate_legacy_kimi_skills_dir( child.name != "SKILL.md" for child in legacy_dir.iterdir() ) if not has_extra: - shutil.rmtree(legacy_dir) + from .._file_changes import changing_file + + with changing_file(legacy_dir, removal=True): + shutil.rmtree(legacy_dir) removed_count += 1 except OSError: pass diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py index bde83f000f..72d71caf8b 100644 --- a/src/specify_cli/integrations/manifest.py +++ b/src/specify_cli/integrations/manifest.py @@ -16,6 +16,8 @@ from pathlib import Path from typing import Any +from ._file_changes import after_file_change, before_file_change + def _sha256(path: Path) -> str: """Return the hex SHA-256 digest of *path*.""" @@ -152,6 +154,7 @@ def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: """ rel = Path(rel_path) abs_path = _validate_rel_path(rel, self.project_root) + before_file_change(abs_path) abs_path.parent.mkdir(parents=True, exist_ok=True) if isinstance(content, str): @@ -163,6 +166,7 @@ def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: # ``record_file`` writes *produced* content, so any prior # recovered marker for this path is no longer accurate. self._recovered_files.discard(normalized) + after_file_change(abs_path) return abs_path def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> None: @@ -231,6 +235,8 @@ def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> N # recovered marker so future is_recovered() queries reflect the # transition. ``discard`` is a no-op when the key is absent. self._recovered_files.discard(normalized) + if not recovered: + after_file_change(abs_path) def remove(self, rel_path: str | Path) -> bool: """Drop *rel_path* from the tracked file set and any recovered marker. @@ -383,11 +389,13 @@ def uninstall( skipped.append(path) continue try: + before_file_change(path, removal=True) path.unlink() except OSError: skipped.append(path) continue removed.append(path) + after_file_change(path) # Clean up empty parent directories up to project root parent = path.parent while parent != root: @@ -401,7 +409,9 @@ def uninstall( manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json" if remove_manifest and manifest.exists(): try: + before_file_change(manifest, removal=True) manifest.unlink() + after_file_change(manifest) except OSError: # An undeletable manifest (read-only file, a directory left at # the path, a Windows lock) must not abort the uninstall after @@ -441,6 +451,7 @@ def save(self) -> Path: } path = self.manifest_path content = json.dumps(data, indent=2) + "\n" + before_file_change(path) _ensure_safe_manifest_destination(self.project_root, path) fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) temp_path = Path(temp_name) @@ -450,6 +461,7 @@ def save(self) -> Path: temp_path.chmod(0o644) _ensure_safe_manifest_destination(self.project_root, path) os.replace(temp_path, path) + after_file_change(path) finally: temp_path.unlink(missing_ok=True) return path diff --git a/src/specify_cli/presets/_manager.py b/src/specify_cli/presets/_manager.py index 5793d8512a..fd92aa83ee 100644 --- a/src/specify_cli/presets/_manager.py +++ b/src/specify_cli/presets/_manager.py @@ -163,6 +163,10 @@ def __init__(self, project_root: Path): self.presets_dir = project_root / ".specify" / "presets" self.registry = PresetRegistry(self.presets_dir) + def _command_registrar(self): + from ..agents import CommandRegistrar + return CommandRegistrar(self.project_root, include_generic=False) + def check_compatibility( self, manifest: PresetManifest, @@ -708,7 +712,7 @@ def remove(self, pack_id: str) -> bool: # winner for the current agent, leaving the inactive integration # with a missing/stale file (#2948). try: - from ..agents import CommandRegistrar as _CommandRegistrarForScope + _CommandRegistrarForScope = self._command_registrar() except ImportError: _CommandRegistrarForScope = None affected_command_agents = { @@ -835,7 +839,7 @@ def remove(self, pack_id: str) -> bool: restore_from_bundled_core=True, ) try: - from ..agents import CommandRegistrar + CommandRegistrar = self._command_registrar() except ImportError: CommandRegistrar = None if CommandRegistrar is not None: diff --git a/src/specify_cli/presets/_manager_commands.py b/src/specify_cli/presets/_manager_commands.py index 4d665b45dc..d171eb28dd 100644 --- a/src/specify_cli/presets/_manager_commands.py +++ b/src/specify_cli/presets/_manager_commands.py @@ -15,6 +15,7 @@ resolve_active_agent_for_registration, ) from ..extensions import ExtensionRegistry +from ..integrations._file_changes import write_text as _write_text from ._manifest import PresetManifest from ._resolver import PresetResolver @@ -149,7 +150,7 @@ def _register_commands( composed_dir = preset_dir / ".composed" composed_dir.mkdir(parents=True, exist_ok=True) composed_file = composed_dir / f"{cmd['name']}.md" - composed_file.write_text(composed, encoding="utf-8") + _write_text(composed_file, composed, encoding="utf-8") commands_to_register.append({ **cmd, "file": f".composed/{cmd['name']}.md", @@ -183,12 +184,10 @@ def _register_commands( commands_to_register.append(cmd) try: - from ..agents import CommandRegistrar + registrar = self._command_registrar() except ImportError: return {} - registrar = CommandRegistrar() - # Single-active rule (#2948): preset command overrides register for # the active integration only. A project without a recorded active # integration (init-options.json does not exist at all — a legacy @@ -263,9 +262,7 @@ def register_enabled_presets_for_agent(self, agent_name: str) -> None: # for them by design, so this restriction only applies to # command-backed integrations. try: - from ..agents import CommandRegistrar - - agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name) + agent_config = self._command_registrar().AGENT_CONFIGS.get(agent_name) except ImportError: agent_config = None is_command_backed = bool(agent_config) and agent_config.get("extension") != "/SKILL.md" @@ -694,9 +691,7 @@ def unregister_agent_artifacts(self, agent_name: str) -> None: return try: - from ..agents import CommandRegistrar - - registrar = CommandRegistrar() + registrar = self._command_registrar() agent_config = registrar.AGENT_CONFIGS.get(agent_name) except ImportError: registrar = None @@ -823,11 +818,9 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registered_commands: Dict mapping agent names to command name lists """ try: - from ..agents import CommandRegistrar + registrar = self._command_registrar() except ImportError: return - - registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) def _merge_pack_registered_commands( @@ -947,12 +940,11 @@ def _reconcile_composed_commands( # uncomposable stale file gets unregistered. The loop already skips # names that resolve to no layers at all (``if not layers: continue``). try: - from ..agents import CommandRegistrar + registrar = self._command_registrar() except ImportError: return set() resolver = PresetResolver(self.project_root) - registrar = CommandRegistrar() reconciled_commands: set[str] = set() def record_written(written: Dict[str, List[str]]) -> None: @@ -1141,7 +1133,7 @@ def record_written(written: Dict[str, List[str]]) -> None: composed_dir = pack_dir / ".composed" composed_dir.mkdir(parents=True, exist_ok=True) composed_file = composed_dir / f"{cmd_name}.md" - composed_file.write_text(composed, encoding="utf-8") + _write_text(composed_file, composed, encoding="utf-8") written = self._register_for_non_skill_agents( registrar, [{**tmpl, "file": f".composed/{cmd_name}.md"}], @@ -1161,7 +1153,7 @@ def record_written(written: Dict[str, List[str]]) -> None: shared_composed = self.presets_dir / ".composed" shared_composed.mkdir(parents=True, exist_ok=True) composed_file = shared_composed / f"{cmd_name}.md" - composed_file.write_text(composed, encoding="utf-8") + _write_text(composed_file, composed, encoding="utf-8") source = layers[0]["source"] if source.startswith("extension:"): source_id = source.split(":", 1)[1].split(" ", 1)[0] diff --git a/src/specify_cli/presets/_manager_skills.py b/src/specify_cli/presets/_manager_skills.py index e6246232af..5a4a4fb739 100644 --- a/src/specify_cli/presets/_manager_skills.py +++ b/src/specify_cli/presets/_manager_skills.py @@ -14,6 +14,7 @@ from .._invocation_style import get_invocation_prefix from .._utils import dump_frontmatter from ..integrations.base import IntegrationBase +from ..integrations._registration import project_registration from ._manager_commands import _substitute_core_template from ._manifest import PresetManifest, PresetValidationError from ._resolver import PresetResolver @@ -96,6 +97,7 @@ def _merge_pack_registered_skills( if changed: self.registry.update(pack_id, {"registered_skills": merged_skills}) + @project_registration def _reconcile_skills( self, command_names: List[str], @@ -241,9 +243,8 @@ def apply_to_dir( continue try: from .. import SKILL_DESCRIPTIONS - from ..agents import CommandRegistrar from ..shared_infra import _write_shared_text - registrar = CommandRegistrar() + registrar = self._command_registrar() content = top_layer["path"].read_text(encoding="utf-8") fm, body = registrar.parse_frontmatter(content) short_name = cmd_name @@ -420,9 +421,8 @@ def apply_to_dir( def _resolve_agent_skills_dir(self, agent_name: str) -> Path: """Resolve the real skill output directory for an integration.""" from .. import _get_skills_dir as _project_skills_dir - from ..agents import CommandRegistrar - registrar = CommandRegistrar() + registrar = self._command_registrar() agent_config = registrar.AGENT_CONFIGS.get(agent_name) if agent_config and agent_config.get("extension") == "/SKILL.md": return registrar._resolve_agent_dir( @@ -437,6 +437,7 @@ def _skills_validation_root(self, skills_dir: Path) -> Optional[Path]: return root return None + @project_registration def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -617,6 +618,7 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: return restore_index + @project_registration def _register_skills( self, manifest: "PresetManifest", @@ -674,7 +676,6 @@ def _register_skills( resolver = PresetResolver(self.project_root) from .. import SKILL_DESCRIPTIONS, load_init_options - from ..agents import CommandRegistrar from ..integrations import get_integration from ..shared_infra import _write_shared_text @@ -691,7 +692,7 @@ def _register_skills( # only controls whether brand-new skill subdirectories may be # created below, which is only meaningful for the active agent. ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts) - registrar = CommandRegistrar() + registrar = self._command_registrar() integration = get_integration(selected_ai) agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new @@ -869,9 +870,8 @@ def _infer_legacy_skill_provenance( ``fallback_agent``, preserving the previous best-effort behaviour for the unrecoverable case. """ - from ..agents import CommandRegistrar - registrar = CommandRegistrar() + registrar = self._command_registrar() candidate_agents = sorted(registrar.AGENT_CONFIGS) # Multiple agent names can resolve to the same physical directory @@ -989,10 +989,9 @@ def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]: touched; directories that don't exist or fail validation are skipped rather than raising. """ - from ..agents import CommandRegistrar from ..shared_infra import _ensure_safe_shared_directory - if agent_name not in CommandRegistrar.AGENT_CONFIGS: + if agent_name not in self._command_registrar().AGENT_CONFIGS: return None skills_dir = self._resolve_agent_skills_dir(agent_name) validation_root = self._skills_validation_root(skills_dir) @@ -1229,9 +1228,8 @@ def _delete_agent_preset_skills( if skills_dir is None: return - from ..agents import CommandRegistrar - registrar = CommandRegistrar() + registrar = self._command_registrar() marker = f"preset:{pack_id}" override_sources: Dict[str, str] = {} manifest = PresetResolver(self.project_root)._get_manifest( @@ -1277,7 +1275,11 @@ def _delete_agent_preset_skills( if override_source: owned_sources.add(override_source) if source in owned_sources: + from ..integrations._file_changes import after_file_change, before_file_change + + before_file_change(skill_subdir, removal=True) shutil.rmtree(skill_subdir) + after_file_change(skill_subdir) @staticmethod def _warn_unrestored_skill( @@ -1303,6 +1305,7 @@ def _warn_unrestored_skill( stacklevel=2, ) + @project_registration def _unregister_skills_in_dir( self, skill_names: List[str], @@ -1328,13 +1331,12 @@ def _unregister_skills_in_dir( Skill names whose files were restored or removed. """ from .. import SKILL_DESCRIPTIONS - from ..agents import CommandRegistrar from ..integrations import get_integration from ..shared_infra import _write_shared_text # Locate core command templates from the project's installed templates core_templates_dir = self.project_root / ".specify" / "templates" / "commands" - registrar = CommandRegistrar() + registrar = self._command_registrar() integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None extension_restore_index = self._build_extension_skill_restore_index() mutated_names: List[str] = [] @@ -1540,7 +1542,11 @@ def _unregister_skills_in_dir( mutated_names.append(skill_name) else: # No core or extension template — remove the skill entirely + from ..integrations._file_changes import after_file_change, before_file_change + + before_file_change(skill_subdir, removal=True) shutil.rmtree(skill_subdir) + after_file_change(skill_subdir) mutated_names.append(skill_name) return mutated_names diff --git a/src/specify_cli/presets/_registry.py b/src/specify_cli/presets/_registry.py index 766e7a694b..a774f724f1 100644 --- a/src/specify_cli/presets/_registry.py +++ b/src/specify_cli/presets/_registry.py @@ -59,9 +59,13 @@ def _load(self) -> dict: def _save(self): """Save registry to disk.""" + from ..integrations._file_changes import after_file_change, before_file_change + + before_file_change(self.registry_path) self.packs_dir.mkdir(parents=True, exist_ok=True) with open(self.registry_path, 'w', encoding='utf-8') as f: json.dump(self.data, f, indent=2) + after_file_change(self.registry_path) def add(self, pack_id: str, metadata: dict): """Add preset to registry. diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 9649fe697a..134e329758 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -31,6 +31,11 @@ # Per-machine extension config overrides. extensions/*/local-config.yml + +# Executable adapter packages and their per-checkout provenance registry. +# Execution consent is stored separately in the user's local trust registry. +integrations/packages/ +integrations/packages.json """ # Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal @@ -324,6 +329,9 @@ def _write_shared_bytes( mode: int = 0o644, ) -> None: _ensure_safe_shared_destination(project_path, dest) + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(dest) fd, temp_name = tempfile.mkstemp(prefix=f".{dest.name}.", dir=dest.parent) temp_path = Path(temp_name) try: @@ -332,6 +340,7 @@ def _write_shared_bytes( temp_path.chmod(mode) _ensure_safe_shared_destination(project_path, dest) os.replace(temp_path, dest) + after_file_change(dest) finally: temp_path.unlink(missing_ok=True) @@ -801,7 +810,11 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if not _safe_dest_or_bucket(dst, rel): continue try: + from .integrations._file_changes import after_file_change, before_file_change + + before_file_change(dst, removal=True) dst.unlink() + after_file_change(dst) except OSError as exc: console.print(f"[yellow]⚠[/yellow] could not remove stale {rel}: {exc}") continue diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index e3dfb0bb2d..90626bad5f 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -46,6 +46,39 @@ def _error_console(json_output: bool): return err_console if json_output else console +def _fail_integration_load(exc: Exception, *, json_output: bool, run_id: str | None = None): + """Surface adapter failures before a workflow run state can be created.""" + if json_output: + _emit_workflow_json({ + "run_id": run_id, + "workflow_id": None, + "status": "failed", + "current_step_id": None, + "current_step_index": None, + "error": str(exc), + }) + else: + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc + + +def _fail_workflow_io( + exc: OSError, *, json_output: bool, state: Any, resume: bool = False, +): + """Report execution I/O failures with the actual, context-local run state.""" + if json_output: + payload = _workflow_run_payload(state) if state is not None else { + "run_id": None, "workflow_id": None, + "current_step_id": None, "current_step_index": None, + } + payload.update(status="failed", error=str(exc)) + _emit_workflow_json(payload) + else: + label = "Resume failed" if resume else "Workflow failed" + console.print(f"[red]{label}:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc + + def _open_workflow_registry(project_root: Path, out=None): """Construct a WorkflowRegistry, exiting cleanly on an unreadable file. diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 352618fee3..7ba839715a 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -148,6 +148,9 @@ def _load(self) -> dict[str, Any]: def save(self) -> None: """Persist registry to disk atomically.""" + from ...integrations._file_changes import after_file_change, before_file_change + + before_file_change(self.registry_path) # Refuse to write through symlinked parents (mirrors StepRegistry.save # and the CLI-level _reject_unsafe_dir guard). if self._has_symlinked_parent() or self.registry_path.is_symlink(): @@ -209,6 +212,7 @@ def save(self) -> None: os.close(fd) fd = -1 os.replace(tmp, self.registry_path) + after_file_change(self.registry_path) except BaseException: if fd >= 0: try: diff --git a/src/specify_cli/workflows/command_resume.py b/src/specify_cli/workflows/command_resume.py index 3be24f9582..4c0a7e626f 100644 --- a/src/specify_cli/workflows/command_resume.py +++ b/src/specify_cli/workflows/command_resume.py @@ -2,8 +2,8 @@ from __future__ import annotations -from . import _commands as cli from . import _command_resume_state as resume_state +from . import _commands as cli @cli.workflow_app.command("resume") @@ -19,12 +19,18 @@ def workflow_resume( ), ): """Resume a paused or failed workflow run.""" + from ..integrations.installer import IntegrationInstallError from . import load_custom_steps from .engine import RunState, WorkflowEngine - project_root = cli._require_specify_project() - load_custom_steps(project_root) - engine = WorkflowEngine(project_root) + project_root = cli._require_specify_project(load_integrations=False) + try: + with cli._stdout_to_stderr_when(json_output): + load_custom_steps(project_root) + engine = WorkflowEngine(project_root) + engine._load_integrations() + except (IntegrationInstallError, OSError) as exc: + cli._fail_integration_load(exc, json_output=json_output, run_id=run_id) if not json_output: # Escape the literal bracket (\[) so Rich renders `[]` instead # of parsing it as a style tag named after the step id -- which it @@ -83,9 +89,20 @@ def workflow_resume( try: with cli._stdout_to_stderr_when(json_output): state = engine.resume(run_id, inputs or None) - except FileNotFoundError: - err.print(f"[red]Error:[/red] Run not found: {run_id}") - raise cli.typer.Exit(1) + except FileNotFoundError as exc: + if engine._execution_state.get() is not None: + cli._fail_workflow_io( + exc, json_output=json_output, state=engine._execution_state.get(), resume=True, + ) + cli._fail_integration_load( + FileNotFoundError(f"Run not found: {run_id}"), json_output=json_output, run_id=run_id, + ) + except IntegrationInstallError as exc: + cli._fail_integration_load(exc, json_output=json_output, run_id=run_id) + except OSError as exc: + cli._fail_workflow_io( + exc, json_output=json_output, state=engine._execution_state.get(), resume=True, + ) except ValueError as exc: err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/command_run.py b/src/specify_cli/workflows/command_run.py index d966fd71e1..2c0a59c1a3 100644 --- a/src/specify_cli/workflows/command_run.py +++ b/src/specify_cli/workflows/command_run.py @@ -2,8 +2,8 @@ from __future__ import annotations -from . import _commands as cli from . import _command_run_ownership as run_ownership +from . import _commands as cli @cli.workflow_app.command("run") @@ -19,6 +19,7 @@ def workflow_run( ), ): """Run a workflow from an installed ID or local YAML path.""" + from ..integrations.installer import IntegrationInstallError from . import load_custom_steps from .engine import WorkflowEngine @@ -35,10 +36,15 @@ def workflow_run( project_root = override if override is not None else cli.Path.cwd() cli._reject_unsafe_workflow_storage(project_root) else: - project_root = cli._require_specify_project() + project_root = cli._require_specify_project(load_integrations=False) - load_custom_steps(project_root) - engine = WorkflowEngine(project_root) + try: + with cli._stdout_to_stderr_when(json_output): + load_custom_steps(project_root) + engine = WorkflowEngine(project_root) + engine._load_integrations() + except (IntegrationInstallError, OSError) as exc: + cli._fail_integration_load(exc, json_output=json_output) if not json_output: # Escape the literal bracket (\[) so Rich renders `[]` instead # of parsing it as a style tag named after the step id -- which it @@ -135,6 +141,10 @@ def workflow_run( else None ), ) + except IntegrationInstallError as exc: + cli._fail_integration_load(exc, json_output=json_output) + except OSError as exc: + cli._fail_workflow_io(exc, json_output=json_output, state=engine._execution_state.get()) except ValueError as exc: err.print(f"[red]Error:[/red] {cli._escape_markup(str(exc))}") raise cli.typer.Exit(1) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index d7ad0fb857..129b0fa82e 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -18,6 +18,7 @@ import threading import uuid from concurrent.futures import Future, ThreadPoolExecutor +from contextvars import ContextVar from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -946,6 +947,18 @@ def __init__(self, project_root: Path | None = None) -> None: # callback's output (the CLI sets it to a console.print lambda). Uncontended # for sequential runs. self._callback_lock = threading.Lock() + self._execution_state: ContextVar[RunState | None] = ContextVar( + "workflow_execution_state", default=None + ) + + def _load_integrations(self) -> None: + from ..integrations import load_installed_integrations + from ..integrations.installer import IntegrationInstallError + + try: + load_installed_integrations(self.project_root) + except OSError as exc: + raise IntegrationInstallError(f"Cannot load installed integrations: {exc}") from exc def load_workflow(self, source: str | Path) -> WorkflowDefinition: """Load a workflow from an installed ID or a local YAML path. @@ -1032,6 +1045,8 @@ def execute( ------- The final ``RunState`` after execution completes (or pauses). """ + self._execution_state.set(None) + self._load_integrations() dispatch_default_errors = _dispatch_default_errors(definition) if dispatch_default_errors: raise ValueError(" ".join(dispatch_default_errors)) @@ -1055,6 +1070,7 @@ def execute( else None ), ) + self._execution_state.set(state) # Persist a copy of the workflow definition so resume can # reload it even if the original source is no longer available @@ -1122,7 +1138,10 @@ def resume( workflow inputs. Keys not supplied keep their persisted values; an empty/``None`` ``inputs`` leaves the run's inputs unchanged. """ + self._execution_state.set(None) + self._load_integrations() state = RunState.load(run_id, self.project_root) + self._execution_state.set(state) if state.status not in (RunStatus.PAUSED, RunStatus.FAILED): msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}." raise ValueError(msg) diff --git a/src/specify_cli/workflows/step/command/__init__.py b/src/specify_cli/workflows/step/command/__init__.py index eb719d9cd4..f836e91929 100644 --- a/src/specify_cli/workflows/step/command/__init__.py +++ b/src/specify_cli/workflows/step/command/__init__.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any +from specify_cli.integrations.installer import project_dispatch from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus from specify_cli.workflows.expressions import evaluate_expression @@ -199,6 +200,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: ) @staticmethod + @project_dispatch def _try_dispatch( command: str, integration_key: str | None, diff --git a/src/specify_cli/workflows/step/prompt/__init__.py b/src/specify_cli/workflows/step/prompt/__init__.py index 4cfb16fb0e..1d6b988c5a 100644 --- a/src/specify_cli/workflows/step/prompt/__init__.py +++ b/src/specify_cli/workflows/step/prompt/__init__.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any +from specify_cli.integrations.installer import project_dispatch from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus from specify_cli.workflows.expressions import evaluate_expression @@ -181,6 +182,7 @@ def _timeout_error(config: dict[str, Any]) -> str | None: return None @staticmethod + @project_dispatch def _try_dispatch( prompt: str, integration_key: str | None, diff --git a/tests/specify_cli/integrations/test_catalog.py b/tests/specify_cli/integrations/test_catalog.py index b496a56b6d..876edf8f42 100644 --- a/tests/specify_cli/integrations/test_catalog.py +++ b/tests/specify_cli/integrations/test_catalog.py @@ -678,8 +678,9 @@ def test_missing_speckit_version(self, tmp_path): def test_no_commands_or_scripts(self, tmp_path): data = {**VALID_DESCRIPTOR, "provides": {}} p = self._write(tmp_path, data) - with pytest.raises(IntegrationDescriptorError, match="at least one command or script"): - IntegrationDescriptor(p) + descriptor = IntegrationDescriptor(p) + assert descriptor.commands == [] + assert descriptor.scripts == [] def test_command_missing_name(self, tmp_path): data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}} diff --git a/tests/specify_cli/integrations/test_command_search.py b/tests/specify_cli/integrations/test_command_search.py index d478a95108..684ba30ce8 100644 --- a/tests/specify_cli/integrations/test_command_search.py +++ b/tests/specify_cli/integrations/test_command_search.py @@ -29,8 +29,8 @@ def test_search_lists_all(self, tmp_path, monkeypatch): assert "Found 2 integration(s)" in result.output assert "acme-coder" in result.output assert "stellar-agent" in result.output - assert "specify integration install stellar-agent" not in normalized_output - assert "Only built-in integration IDs can be installed" in normalized_output + assert "specify integration install stellar-agent" in normalized_output + assert "Only built-in integration IDs can be installed" not in normalized_output def test_search_validates_integration_json_before_catalog_lookup( self, tmp_path, monkeypatch @@ -113,6 +113,7 @@ def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch): assert result.exit_code == 0, result.output # acme-coder is flagged _install_allowed=False, so we should warn assert "Not directly installable" in result.output + assert "specify integration install acme-coder" not in _normalize_cli_output(result.output) def test_search_escapes_catalog_markup(self, tmp_path, monkeypatch): project = self._make_project(tmp_path) diff --git a/tests/specify_cli/integrations/test_installed_adapters.py b/tests/specify_cli/integrations/test_installed_adapters.py new file mode 100644 index 0000000000..0b09d73cec --- /dev/null +++ b/tests/specify_cli/integrations/test_installed_adapters.py @@ -0,0 +1,3560 @@ +"""Public-path regressions for neutral, catalog-installed adapter packages.""" + +from __future__ import annotations + +import hashlib +import io +import json +import marshal +import os +import shutil +import stat +import struct +import subprocess +import sys +import tarfile +import threading +import zipfile +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import AGENT_CONFIG, app +from specify_cli.agents import CommandRegistrar +from specify_cli.integrations import ( + INTEGRATION_REGISTRY, + IntegrationDescriptor, + IntegrationDescriptorError, +) +from specify_cli.integrations.installer import ( + IntegrationInstallError, + load_installed_integrations, + read_records, + unload_installed_integrations, +) + +KEY = "sample-agent" +runner = CliRunner() + + +def descriptor(version="1.0.0", key=KEY): + return { + "schema_version": "1.0", + "integration": { + "id": key, "name": "Sample Agent", "version": version, + "description": "A neutral test adapter", + }, + "requires": {"speckit_version": ">=0.6.0"}, + } + + +def implementation(key=KEY, *, flavor="skills", body="", folder=".sample-agent"): + base = "SkillsIntegration" if flavor == "skills" else "MarkdownIntegration" + subdir = "skills" if flavor == "skills" else "commands" + extension = "/SKILL.md" if flavor == "skills" else ".md" + return f'''from specify_cli.integrations.base import {base} + +class SampleIntegration({base}): + key = {key!r} + config = {{ + "name": "Sample Agent", "folder": {folder!r}, + "commands_subdir": {subdir!r}, + "install_url": "https://example.com/sample-agent", + "requires_cli": False, + }} + registrar_config = {{ + "dir": "{folder}/{subdir}", "format": "markdown", + "args": "$ARGUMENTS", "extension": {extension!r}, + }} + multi_install_safe = True +{body} +''' + + +@pytest.fixture(autouse=True) +def isolated_registry(monkeypatch, tmp_path): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.delenv("SPECIFY_INIT_DIR", raising=False) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + unload_installed_integrations() + yield + unload_installed_integrations() + + +@pytest.fixture +def server(tmp_path): + downloads = tmp_path / "downloads" + downloads.mkdir() + requests = [] + + class Handler(SimpleHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + super().do_GET() + + def log_message(self, *args): + pass + + http = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(downloads))) + thread = threading.Thread(target=http.serve_forever, daemon=True) + thread.start() + result = SimpleNamespace( + root=downloads, url=f"http://127.0.0.1:{http.server_port}", requests=requests, + ) + try: + yield result + finally: + http.shutdown() + http.server_close() + thread.join() + + +def publish(server, *, metadata=None, code=None, version="1.0.0", members=None, archive="zip"): + data = descriptor(version) if metadata is None else metadata + source = implementation() if code is None else code + content = { + "integration.yml": yaml.safe_dump(data).encode(), + "__init__.py": source.encode(), + **(members or {}), + } + path = server.root / f"sample-agent-{version}.{archive}" + if archive == "zip": + with zipfile.ZipFile(path, "w") as package: + for name, value in content.items(): + package.writestr(name, value) + else: + with tarfile.open(path, "w:gz") as package: + for name, value in content.items(): + entry = tarfile.TarInfo(name) + entry.size = len(value) + package.addfile(entry, io.BytesIO(value)) + info = { + **data["integration"], + "download_url": f"{server.url}/{path.name}", + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + write_catalog(server, info) + return info, path + + +def write_catalog(server, info): + (server.root / "catalog.json").write_text( + json.dumps({"schema_version": "1.0", "integrations": {KEY: info}}) + ) + + +def run(project, arguments, *, input=None): + previous = Path.cwd() + os.chdir(project) + try: + return runner.invoke(app, arguments, input=input, catch_exceptions=False) + finally: + os.chdir(previous) + + +def catalog_project(tmp_path, server, *, install_allowed=True): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + result = run(project, ["integration", "catalog", "add", f"{server.url}/catalog.json", "--name", "samples"]) + assert result.exit_code == 0, result.output + if not install_allowed: + path = project / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(path.read_text()) + data["catalogs"][0]["install_allowed"] = False + path.write_text(yaml.safe_dump(data)) + return project + + +def install(project): + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 0, result.output + return result + + +def snapshot(project): + return { + path.relative_to(project).as_posix(): path.read_bytes() + for path in project.rglob("*") + if path.is_file() and "__pycache__" not in path.parts + and path.name != ".integration-install.lock" + } + + +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("change", ["unchanged", "partial", "concurrent", "deleted"]) +def test_round9_pending_write_preserves_unattributed_changes(tmp_path, server, monkeypatch, existing, change): + from specify_cli.integrations import _lifecycle + + body = f''' def setup(self, project_root, manifest, **kwargs): + from threading import Thread + from specify_cli.integrations._file_changes import before_file_change + target = project_root / ".sample-agent/skills/sample-pending.md" + before_file_change(target) + if {change!r} in ("partial", "concurrent"): + target.write_text("partial write") + if {change!r} == "concurrent": + writer = Thread(target=lambda: target.write_text("concurrent edit")) + writer.start() + writer.join() + if {change!r} == "deleted": + target.unlink(missing_ok=True) + raise OSError("sample write interrupted before completion") +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + target = project / ".sample-agent/skills/sample-pending.md" + target.parent.mkdir(parents=True) + if existing: + target.write_text("original bytes") + before = snapshot(project) + backups = [] + original_mkdtemp = _lifecycle.tempfile.mkdtemp + + def record_backup(*args, **kwargs): + directory = original_mkdtemp(*args, **kwargs) + if kwargs.get("prefix") == "speckit-integration-rollback-": + backups.append(Path(directory)) + return directory + + monkeypatch.setattr(_lifecycle.tempfile, "mkdtemp", record_backup) + conflict = change in {"partial", "concurrent"} or (change == "deleted" and existing) + try: + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "sample write interrupted" in " ".join(result.output.split()) + assert ("Preserved concurrent edits" in result.output) == conflict + if change in {"partial", "concurrent"}: + assert target.read_text() == ("partial write" if change == "partial" else "concurrent edit") + elif change == "deleted": + assert not target.exists() + else: + assert snapshot(project) == before + assert len(backups) == 1 + assert backups[0].exists() == conflict + if conflict and existing: + assert any(path.read_bytes() == b"original bytes" for path in backups[0].rglob("*") if path.is_file()) + assert KEY not in read_records(project) + assert KEY not in INTEGRATION_REGISTRY + finally: + for backup in backups: + if backup.exists(): + shutil.rmtree(backup) + + +@pytest.mark.parametrize("field", ["author", "repository", "license"]) +@pytest.mark.parametrize("value", [None, "", " \t", [], {"value": "sample"}, True, 12]) +@pytest.mark.parametrize("source", ["descriptor", "catalog", "both"]) +def test_round9_optional_metadata_rejects_invalid_values_before_import(tmp_path, server, field, value, source): + marker = tmp_path / "unexpected-import" + metadata = descriptor() + if source in {"descriptor", "both"}: + metadata["integration"][field] = value + code = f"from pathlib import Path\nPath({str(marker)!r}).write_text('unexpected import')\n" + implementation() + info, _ = publish(server, metadata=metadata, code=code) + if source == "descriptor": + info.pop(field) + else: + info[field] = value + write_catalog(server, info) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert field in result.output + assert "non-empty string" in " ".join(result.output.split()) + assert not marker.exists() + assert snapshot(project) == before + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("optional", [False, True]) +def test_round9_optional_metadata_preserves_valid_install_and_reload(tmp_path, server, optional): + metadata = descriptor() + fields = {"author": "Sample Publisher", "repository": "https://example.com/sample-agent", "license": "MIT"} + if optional: + metadata["integration"].update(fields) + publish(server, metadata=metadata) + project = catalog_project(tmp_path, server) + install(project) + unload_installed_integrations() + load_installed_integrations(project) + record = read_records(project)[KEY] + for field, value in fields.items(): + assert (record[field] == value) if optional else field not in record + assert INTEGRATION_REGISTRY[KEY].config["name"] == "Sample Agent" + + +@pytest.mark.parametrize("operation", ["run", "resume"]) +@pytest.mark.parametrize("json_output", [False, True]) +@pytest.mark.parametrize("failure", ["step", "save"]) +@pytest.mark.parametrize("error_type", [OSError, FileNotFoundError]) +def test_round9_workflow_runtime_io_keeps_run_context( + tmp_path, monkeypatch, operation, json_output, failure, error_type, +): + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState, WorkflowEngine + + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + source = project / "sample-workflow.yml" + source.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [{"id": "sample-gate", "type": "gate", "message": "Sample review", "options": ["approve", "reject"]}], + })) + if operation == "resume": + started = run(project, ["workflow", "run", str(source), "--json"]) + assert started.exit_code == 0, started.output + run_id = json.loads(started.stdout)["run_id"] + observed = [] + original_save = RunState.save + + def save_with_runtime_failure(state): + observed.append(state.run_id) + if failure == "save" and state.status == RunStatus.RUNNING and state.current_step_id: + raise error_type("sample state save failed") + return original_save(state) + + def failing_steps(engine, steps, context, state, registry, **kwargs): + state.current_step_id = steps[0]["id"] + state.save() + raise error_type("sample step I/O failed") + + monkeypatch.setattr(RunState, "save", save_with_runtime_failure) + if failure == "step": + monkeypatch.setattr(WorkflowEngine, "_execute_steps", failing_steps) + arguments = ["workflow", operation, str(source) if operation == "run" else run_id] + if json_output: + arguments.append("--json") + result = run(project, arguments) + assert result.exit_code == 1, result.output + assert observed, result.output + actual_run_id = observed[0] + error = "sample state save failed" if failure == "save" else "sample step I/O failed" + if json_output: + payload = json.loads(result.stdout) + assert payload["run_id"] == actual_run_id + assert payload["workflow_id"] == "sample-workflow" + assert payload["status"] == "failed" + assert payload["error"] == error + assert result.stderr == "" + else: + assert ("Workflow failed" if operation == "run" else "Resume failed") in result.output + assert error in result.output + persisted = RunState.load(actual_run_id, project) + assert persisted.workflow_id == "sample-workflow" + if failure == "step" or operation == "run": + assert persisted.status == RunStatus.FAILED + assert persisted.error == error + else: + assert persisted.status == RunStatus.PAUSED + + +def test_round9_execution_state_is_context_local_and_cleared_before_loading(tmp_path, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + engine = WorkflowEngine(project) + definition = WorkflowDefinition.from_string(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [{"id": "sample-gate", "type": "gate", "message": "Sample review", "options": ["approve", "reject"]}], + })) + barrier = threading.Barrier(2) + + def fail_steps(self, steps, context, state, registry, **kwargs): + barrier.wait(timeout=10) + raise OSError("sample execution failure") + + def execute(run_id): + with pytest.raises(OSError, match="sample execution failure"): + engine.execute(definition, run_id=run_id) + return engine._execution_state.get().run_id + + with monkeypatch.context() as patches: + patches.setattr(WorkflowEngine, "_execute_steps", fail_steps) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(execute, "sample-first") + second = pool.submit(execute, "sample-second") + assert first.result(timeout=20) == "sample-first" + assert second.result(timeout=20) == "sample-second" + assert engine._execution_state.get() is None + engine.execute(definition) + assert engine._execution_state.get() is not None + + def fail_loading(): + raise IntegrationInstallError("sample adapter load failure") + + monkeypatch.setattr(engine, "_load_integrations", fail_loading) + with pytest.raises(IntegrationInstallError, match="sample adapter load failure"): + engine.execute(definition) + assert engine._execution_state.get() is None + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("missing_manifest", [False, True]) +def test_round8_upgrade_persists_only_after_regenerating_files(tmp_path, server, force, missing_manifest): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + manifest = project / f".specify/integrations/{KEY}.manifest.json" + if missing_manifest: + manifest.unlink() + before = snapshot(project) + trust_path = Path.home() / ".specify/integration-trust.json" + ownership = json.loads(trust_path.read_text())["recovery"] + publish(server, version="2.0.0", code=implementation(folder=".sample-next")) + arguments = ["integration", "upgrade", KEY, "--trust-integration"] + if force: + arguments.append("--force") + result = run(project, arguments) + assert result.exit_code == 0, result.output + assert installer._pending_root is None + if missing_manifest: + assert "Nothing to upgrade" in result.output + assert snapshot(project) == before + assert json.loads(trust_path.read_text())["recovery"] == ownership + assert read_records(project)[KEY]["version"] == "1.0.0" + assert INTEGRATION_REGISTRY[KEY].config["folder"] == ".sample-agent" + assert CommandRegistrar(project).AGENT_CONFIGS[KEY]["dir"] == ".sample-agent/skills" + assert not (project / ".sample-next").exists() + else: + assert "Nothing to upgrade" not in result.output + assert read_records(project)[KEY]["version"] == "2.0.0" + assert (project / ".sample-next/skills/speckit-plan/SKILL.md").is_file() + assert not (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() + assert INTEGRATION_REGISTRY[KEY].config["folder"] == ".sample-next" + assert CommandRegistrar(project).AGENT_CONFIGS[KEY]["dir"] == ".sample-next/skills" + + +@pytest.mark.parametrize("class_separator,registrar_separator", [(".", "_"), ("_", "."), ("-", None)]) +@pytest.mark.parametrize("class_no_symlink,registrar_no_symlink", [ + (False, False), (False, True), (True, False), (True, True), (False, None), (True, None), +]) +def test_round8_recovery_retains_effective_registrar_settings( + tmp_path, server, class_separator, registrar_separator, class_no_symlink, registrar_no_symlink, +): + from specify_cli.integrations.installer import recovery_metadata + + overrides = {} + if registrar_separator is not None: + overrides["invoke_separator"] = registrar_separator + if registrar_no_symlink is not None: + overrides["dev_no_symlink"] = registrar_no_symlink + body = ( + f" invoke_separator = {class_separator!r}\n" + f" dev_no_symlink = {class_no_symlink!r}\n" + f" registrar_config = {{**registrar_config, **{overrides!r}}}\n" + ) + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + healthy = CommandRegistrar(project).AGENT_CONFIGS[KEY] + expected_separator = registrar_separator or class_separator + expected_no_symlink = class_no_symlink or bool(registrar_no_symlink) + assert healthy["invoke_separator"] == expected_separator + assert healthy.get("dev_no_symlink", False) == expected_no_symlink + record = read_records(project)[KEY] + expected = {**healthy, "dev_no_symlink": expected_no_symlink} + assert record["registrar_config"] == expected + binding = recovery_metadata(project, KEY, record) + assert binding["registrar_config"] == expected + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + unload_installed_integrations() + assert recovery_metadata(project, KEY, read_records(project)[KEY])["registrar_config"] == expected + removed = run(project, ["integration", "uninstall", KEY, "--force"]) + assert removed.exit_code == 0, removed.output + assert not (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() + assert KEY not in read_records(project) + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("command", ["install", "use", "switch"]) +def test_round7_noop_lifecycle_does_not_copy_output_roots_or_package_store(tmp_path, server, monkeypatch, command): + from specify_cli.integrations import _lifecycle + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + unowned = project / ".sample-agent/unowned-data.bin" + unowned.write_bytes(b"sample user data\n" * 100_000) + before = snapshot(project) + original_copytree = _lifecycle.shutil.copytree + original_journal = _lifecycle._FileJournal + + def journal_without_eager_bytes(*args, **kwargs): + journal = original_journal(*args, **kwargs) + assert sum(path.stat().st_size for path in journal.backup.rglob("*") if path.is_file()) == 0 + return journal + + def refuse_eager_snapshot(source, destination, *args, **kwargs): + assert Path(source) not in { + project / ".sample-agent", project / ".specify/integrations", + }, "an untouched output root must not be copied" + return original_copytree(source, destination, *args, **kwargs) + + monkeypatch.setattr(_lifecycle.shutil, "copytree", refuse_eager_snapshot) + monkeypatch.setattr(_lifecycle, "_FileJournal", journal_without_eager_bytes) + result = run(project, ["integration", command, KEY]) + assert result.exit_code == 0, result.output + assert snapshot(project) == before + + +@pytest.mark.parametrize("cache", ["agent", "registrar"]) +def test_round7_failed_candidate_cache_refresh_cleans_pending_state(tmp_path, server, monkeypatch, cache): + from specify_cli import _agent_config, agents + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + before = snapshot(project) + module = _agent_config if cache == "agent" else agents + name = "_build_agent_config" if cache == "agent" else "_build_agent_configs" + original = getattr(module, name) + failed = False + + def fail_candidate_refresh(): + nonlocal failed + if installer._pending_root is not None and not failed: + failed = True + raise RuntimeError("sample cache refresh failure") + return original() + + monkeypatch.setattr(module, name, fail_candidate_refresh) + try: + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "sample cache refresh failure" in " ".join(result.output.split()) + assert failed + assert installer._pending_root is None + assert KEY not in INTEGRATION_REGISTRY + assert KEY not in AGENT_CONFIG + assert KEY not in CommandRegistrar.AGENT_CONFIGS + assert not installer._source_packages + assert not any(name.startswith(installer._MODULE_PREFIX) for name in sys.modules) + assert snapshot(project) == before + other = catalog_project(tmp_path / "other", server) + install(other) + assert KEY in INTEGRATION_REGISTRY + finally: + installer._pending_root = None + unload_installed_integrations() + + +@pytest.mark.parametrize("budget", ["bytes", "entries", "exact"]) +def test_round7_snapshot_budget_is_checked_before_overwriting_existing_output(tmp_path, server, monkeypatch, budget): + from specify_cli.integrations import _lifecycle + + body = ''' def setup(self, project_root, manifest, **kwargs): + manifest.record_file(".sample-agent/skills/speckit-sample/SKILL.md", "new sample output") + return [] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + target = project / ".sample-agent/skills/speckit-sample/SKILL.md" + target.parent.mkdir(parents=True) + original = "original sample output" + target.write_text(original) + before = snapshot(project) + monkeypatch.setattr( + _lifecycle, "_MAX_BACKUP_BYTES", len(original.encode()) if budget == "exact" else 4, + raising=False, + ) + monkeypatch.setattr(_lifecycle, "_MAX_BACKUP_ENTRIES", 0 if budget == "entries" else 1, raising=False) + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + if budget == "exact": + assert result.exit_code == 0, result.output + assert target.read_text() == "new sample output" + else: + assert result.exit_code == 1, result.output + assert "snapshot budget" in " ".join(result.output.split()) + assert snapshot(project) == before + assert KEY not in INTEGRATION_REGISTRY + + +def test_round7_recording_unchanged_existing_output_needs_no_content_snapshot(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + + body = ''' def setup(self, project_root, manifest, **kwargs): + manifest.record_existing(".sample-agent/skills/sample-existing.md", recovered=True) + return [] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + target = project / ".sample-agent/skills/sample-existing.md" + target.parent.mkdir(parents=True) + target.write_text("original sample output") + before = snapshot(project) + + def fail_commit(*args, **kwargs): + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert snapshot(project) == before + + +def test_round7_unobserved_overwrite_reports_unrecoverable_output_instead_of_deleting_it(tmp_path, server): + body = ''' def setup(self, project_root, manifest, **kwargs): + target = project_root / ".sample-agent/skills/sample-existing.md" + target.write_text("unobserved sample write") + manifest.record_existing(".sample-agent/skills/sample-existing.md") + return [] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + target = project / ".sample-agent/skills/sample-existing.md" + target.parent.mkdir(parents=True) + target.write_text("original sample output") + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "before-write" in " ".join(result.output.split()) + assert "cannot restore" in " ".join(result.output.split()) + assert target.read_text() == "unobserved sample write" + assert KEY not in read_records(project) + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("operation", ["install", "load"]) +@pytest.mark.parametrize("alias", [False, True]) +def test_round6_project_cannot_supply_its_local_trust_state(tmp_path, server, operation, alias): + from specify_cli.integrations.installer import _trust_identity + + marker = tmp_path / "adapter-imported" + publish(server, code=f"from pathlib import Path\nPath({str(marker)!r}).touch()\n" + implementation()) + project = Path.home() / ".specify" + if operation == "load": + original = catalog_project(tmp_path, server) + install(original) + shutil.copytree(original, project, dirs_exist_ok=True) + trust = project / "integration-trust.json" + data = json.loads(trust.read_text()) + data["grants"].append(_trust_identity(project, KEY, read_records(project)[KEY]["files"])) + trust.write_text(json.dumps(data)) + marker.unlink() + else: + (project / ".specify").mkdir(parents=True) + added = run(project, [ + "integration", "catalog", "add", f"{server.url}/catalog.json", "--name", "samples", + ]) + assert added.exit_code == 0, added.output + if alias: + link = tmp_path / "project-alias" + try: + link.symlink_to(project, target_is_directory=True) + except OSError as exc: + pytest.skip(f"Symlinks unavailable: {exc}") + project = link + before = snapshot(project) + arguments = ( + ["integration", "list"] if operation == "load" + else ["integration", "install", KEY, "--trust-integration"] + ) + result = run(project, arguments) + assert result.exit_code == 1, result.output + assert "outside the project" in " ".join(result.output.split()) + assert not marker.exists() + assert snapshot(project) == before + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("directory", ["projects", ".specify/projects", ".specify-other"]) +def test_round6_home_projects_outside_trust_path_remain_supported(server, directory): + publish(server) + project = catalog_project(Path.home() / directory, server) + install(project) + unload_installed_integrations() + result = run(project, ["integration", "list"]) + assert result.exit_code == 0, result.output + assert KEY in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("content", ["{", '{"schema_version":"1.0","grants":[false]}']) +def test_round6_invalid_local_trust_state_fails_before_candidate_import(tmp_path, server, content): + marker = tmp_path / "adapter-imported" + publish(server, code=f"from pathlib import Path\nPath({str(marker)!r}).touch()\n" + implementation()) + project = catalog_project(tmp_path, server) + trust = Path.home() / ".specify/integration-trust.json" + trust.parent.mkdir() + trust.write_text(content) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "local trust state" in " ".join(result.output.split()) + assert not marker.exists() + assert snapshot(project) == before + assert trust.read_text() == content + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("multi_install_safe", [False, True]) +@pytest.mark.parametrize("folder,legacy", [ + (".sample-agent", ".sample-agent"), + (".sample-agent", ".sample-agent/skills"), + (".sample-agent", ".sample-agent/skills/previous"), + (".sample-agent", ".SAMPLE-AGENT"), + (".sample-agent", ".SAMPLE-AGENT/previous"), + (".sample-agent/current", ".sample-agent"), + (".sample-agent/current", ".SAMPLE-AGENT"), +]) +def test_round6_overlapping_own_output_roots_fail_before_setup( + tmp_path, server, multi_install_safe, folder, legacy, +): + body = f''' multi_install_safe = {multi_install_safe!r} + registrar_config = {{**registrar_config, "legacy_dir": {legacy!r}}} + def setup(self, project_root, manifest, **kwargs): + (project_root / "setup-ran").touch() + return [] +''' + publish(server, code=implementation(folder=folder, body=body)) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "overlap" in result.output.lower(), result.output + assert not (project / "setup-ran").exists() + assert snapshot(project) == before + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("multi_install_safe", [False, True]) +def test_round6_distinct_own_output_roots_with_shared_name_prefix_are_supported( + tmp_path, server, multi_install_safe, +): + body = f''' multi_install_safe = {multi_install_safe!r} + registrar_config = {{**registrar_config, "legacy_dir": ".sample-agent-previous/skills"}} +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + assert CommandRegistrar(project).AGENT_CONFIGS[KEY]["legacy_dir"] == ".sample-agent-previous/skills" + + +@pytest.mark.parametrize("recording", ["record_file", "record_existing"]) +@pytest.mark.parametrize("preexisting", [ + None, ".sample-agent", ".sample-agent/skills", ".sample-agent/skills/speckit-sample", +]) +def test_round6_failed_install_preserves_original_empty_output_directories( + tmp_path, server, monkeypatch, recording, preexisting, +): + from specify_cli.integrations import installer + + writing = ( + ' manifest.record_file(relative, "sample output")\n' + if recording == "record_file" else + ''' path = project_root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("sample output") + manifest.record_existing(relative) +''' + ) + body = ''' def setup(self, project_root, manifest, **kwargs): + for name in ("SKILL.md", "extra.md"): + relative = f".sample-agent/skills/speckit-sample/{name}" +''' + writing + ' return []\n' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + if preexisting: + (project / preexisting).mkdir(parents=True) + original_directories = {path for path in project.rglob("*") if path.is_dir()} + before = snapshot(project) + + def fail_commit(*args, **kwargs): + assert (project / ".sample-agent/skills/speckit-sample/SKILL.md").is_file() + assert (project / ".sample-agent/skills/speckit-sample/extra.md").is_file() + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "sample package commit failure" in result.output + assert snapshot(project) == before + assert all(path.is_dir() for path in original_directories) + if preexisting: + assert not any((project / preexisting).iterdir()) + else: + assert not (project / ".sample-agent").exists() + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("integration,relative,home_scoped", [ + ("copilot", ".vscode", False), + ("hermes", ".hermes/skills/speckit-plan", True), +]) +@pytest.mark.parametrize("existing", [False, True]) +def test_round6_failed_switch_preserves_directory_ownership_outside_adapter_root( + tmp_path, server, monkeypatch, integration, relative, home_scoped, existing, +): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + directory = (Path.home() if home_scoped else project) / relative + if existing: + directory.mkdir(parents=True) + before = snapshot(project) + + def fail_commit(*args, **kwargs): + assert any(directory.iterdir()) + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, [ + "integration", "switch", integration, + *([] if home_scoped else ["--integration-options=--commands"]), + ]) + assert result.exit_code == 1, result.output + assert "sample package commit failure" in result.output + assert snapshot(project) == before + assert directory.is_dir() == existing + if existing: + assert not any(directory.iterdir()) + + +@pytest.mark.parametrize("damaged", [False, True]) +@pytest.mark.parametrize("command", ["status", "status-json", "status-run-json", "info"]) +def test_round4_workflow_metadata_does_not_load_adapter(tmp_path, server, damaged, command, monkeypatch): + from specify_cli.integrations import installer + from specify_cli.workflows.engine import RunState + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + state = RunState(run_id="sample-run", workflow_id="sample-workflow", project_root=project) + state.save() + source = project / "sample-workflow.yml" + source.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [{"id": "sample-shell", "type": "shell", "run": "echo sample"}], + })) + if damaged: + (project / ".specify/integrations/packages/sample-agent/__init__.py").write_text("damaged") + + def forbidden_load(*args, **kwargs): + raise AssertionError("metadata inspection must not execute installed adapters") + + monkeypatch.setattr(installer, "load_installed_integrations", forbidden_load) + arguments = { + "status": ["workflow", "status"], + "status-json": ["workflow", "status", "--json"], + "status-run-json": ["workflow", "status", "sample-run", "--json"], + "info": ["workflow", "info", str(source)], + }[command] + result = run(project, arguments) + assert result.exit_code == 0, result.output + assert result.stderr == "" + if command == "status-json": + assert json.loads(result.stdout)["runs"][0]["run_id"] == "sample-run" + elif command == "status-run-json": + assert json.loads(result.stdout)["run_id"] == "sample-run" + else: + assert "sample-workflow" in result.stdout + + +@pytest.mark.parametrize("existing", [False, True]) +def test_round4_failed_switch_restores_builtin_settings(tmp_path, server, monkeypatch, existing): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + settings = project / ".vscode/settings.json" + if existing: + settings.parent.mkdir() + settings.write_text('{"user.setting": true}\n') + before = snapshot(project) + + def fail_commit(*args, **kwargs): + assert json.loads(settings.read_text())["chat.promptFilesRecommendations"] + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, [ + "integration", "switch", "copilot", "--integration-options=--commands", + ]) + assert result.exit_code == 1, result.output + assert "sample package commit failure" in result.output + assert snapshot(project) == before + + +@pytest.mark.parametrize("install_allowed", [False, True]) +def test_round4_search_advertises_only_allowed_external_installation(tmp_path, server, install_allowed): + publish(server, code='raise RuntimeError("discovery must not import sample code")') + project = catalog_project(tmp_path, server, install_allowed=install_allowed) + result = run(project, ["integration", "search", KEY]) + assert result.exit_code == 0, result.output + output = " ".join(result.output.split()) + assert (f"specify integration install {KEY}" in output) == install_allowed + assert "Only built-in" not in output + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("target", ["hermes", "kimi"]) +def test_round4_failed_switch_restores_builtin_global_and_legacy_outputs(tmp_path, server, monkeypatch, target): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + if target == "hermes": + scope = Path.home() / ".hermes/skills" + managed = scope / "speckit-plan/SKILL.md" + else: + scope = project / ".kimi/skills" + managed = scope / "speckit.sample/SKILL.md" + managed.parent.mkdir(parents=True) + original = ( + "---\nmetadata:\n author: github-spec-kit\n" + " source: templates/commands/sample.md\n---\nSample legacy skill\n" + ) + managed.write_text(original) + untouched = scope / "user-skill/SKILL.md" + untouched.parent.mkdir() + untouched.write_text("user-owned skill") + before = snapshot(project) + home_before = snapshot(scope) + + def fail_commit(*args, **kwargs): + if target == "hermes": + assert managed.read_text() != original + else: + assert not managed.exists() + assert (project / ".kimi-code/skills/speckit-sample/SKILL.md").exists() + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, [ + "integration", "switch", target, + *(["--integration-options=--migrate-legacy"] if target == "kimi" else []), + ]) + assert result.exit_code == 1, result.output + assert "sample package commit failure" in result.output + assert snapshot(project) == before + assert snapshot(scope) == home_before + if target == "hermes": + assert not (project / ".hermes").exists() + + +@pytest.mark.parametrize("events_format", ["json-nested", "copilot-json", "toml"]) +def test_round4_failed_switch_restores_native_event_merges(tmp_path, server, monkeypatch, events_format): + from specify_cli.integrations import installer + + extension = tmp_path / "sample-events" + extension.mkdir() + (extension / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": { + "id": "sample-events", "name": "Sample Events", "version": "1.0.0", + "description": "Sample event-only extension", + }, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"commands": []}, + "events": {"session_start": {"command": "speckit.sample.boot"}}, + })) + suffix = "toml" if events_format == "toml" else "json" + body = ( + ' CANONICAL_TO_NATIVE = {"session_start": "SampleStart"}\n' + f' events_config_file = ".sample-agent/events.{suffix}"\n' + f' events_format = {events_format!r}\n' + ) + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + assert run(project, ["extension", "add", "--dev", str(extension)]).exit_code == 0 + config = project / f".sample-agent/events.{suffix}" + assert config.is_file() + before = snapshot(project) + + def fail_commit(*args, **kwargs): + assert not config.exists() + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "switch", "claude"]) + assert result.exit_code == 1, result.output + assert "sample package commit failure" in result.output + assert snapshot(project) == before + + +def test_round4_failed_switch_restores_preset_composition_cache(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + preset = tmp_path / "sample-preset" + (preset / "commands").mkdir(parents=True) + (preset / "commands/sample.md").write_text( + "---\ndescription: Sample wrapper\nstrategy: wrap\n---\n{CORE_TEMPLATE}\nSample wrapper\n" + ) + (preset / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": {"id": "sample-preset", "name": "Sample Preset", "version": "1.0.0", "description": "Sample wrapper"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"templates": [{ + "type": "command", "name": "speckit.plan", "file": "commands/sample.md", + "strategy": "wrap", + }]}, + })) + added = run(project, ["preset", "add", "--dev", str(preset)]) + assert added.exit_code == 0, added.output + cache = project / ".specify/presets/sample-preset/.composed/speckit.plan.md" + cache.write_text("previous cache bytes") + settings = project / ".vscode/settings.json" + settings.parent.mkdir() + settings.write_bytes(INTEGRATION_REGISTRY["copilot"]._vscode_settings_path().read_bytes()) + before = snapshot(project) + + def fail_commit(*args, **kwargs): + assert cache.read_text() != "previous cache bytes" + raise OSError("sample package commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "switch", "copilot", "--integration-options=--commands"]) + assert result.exit_code == 1, result.output + assert "sample package commit failure" in result.output + assert snapshot(project) == before + + +@pytest.mark.parametrize("existing", [False, True]) +def test_round4_successful_switch_retains_settings_ownership_rules(tmp_path, server, existing): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + settings = project / ".vscode/settings.json" + if existing: + settings.parent.mkdir() + settings.write_text('{"user.setting": true}') + result = run(project, ["integration", "switch", "copilot", "--integration-options=--commands"]) + assert result.exit_code == 0, result.output + assert json.loads(settings.read_text())["chat.promptFilesRecommendations"] + manifest = json.loads((project / ".specify/integrations/copilot.manifest.json").read_text()) + assert (".vscode/settings.json" in manifest["files"]) != existing + if existing: + assert json.loads(settings.read_text())["user.setting"] is True + + +def test_round4_failed_settings_merge_preserves_concurrent_user_edit(tmp_path, server, monkeypatch): + from specify_cli.integrations import _lifecycle, installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + settings = project / ".vscode/settings.json" + settings.parent.mkdir() + settings.write_text('{"user.setting": true}') + original_mkdtemp = _lifecycle.tempfile.mkdtemp + backups = [] + + def record_backup(*args, **kwargs): + path = original_mkdtemp(*args, **kwargs) + if kwargs.get("prefix") == "speckit-integration-rollback-": + backups.append(Path(path)) + return path + + def fail_commit(*args, **kwargs): + settings.write_text('{"concurrent.user.edit": true}') + raise OSError("sample package commit failure") + + monkeypatch.setattr(_lifecycle.tempfile, "mkdtemp", record_backup) + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "switch", "copilot", "--integration-options=--commands"]) + assert result.exit_code == 1, result.output + assert json.loads(settings.read_text()) == {"concurrent.user.edit": True} + assert "Preserved concurrent edits" in result.output + assert len(backups) == 1 + shutil.rmtree(backups[0]) + + +@pytest.mark.parametrize("field,value", [ + ("invoke_separator", None), ("invoke_separator", ""), ("invoke_separator", 1), + ("invoke_separator", False), ("dev_no_symlink", "false"), + ("dev_no_symlink", 1), ("dev_no_symlink", None), +]) +def test_round5_invalid_public_adapter_attributes_are_rejected(tmp_path, server, field, value): + publish(server, code=implementation(body=f" {field} = {value!r}\n")) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert field in result.output, result.output + assert snapshot(project) == before + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("no_symlinks", [False, True]) +def test_round5_valid_public_attributes_propagate_to_rendering_and_recovery(tmp_path, server, no_symlinks): + from specify_cli.integrations.installer import recovery_metadata + + publish(server, code=implementation( + body=f' invoke_separator = "_"\n dev_no_symlink = {no_symlinks!r}\n', + )) + project = catalog_project(tmp_path, server) + install(project) + registrar = CommandRegistrar(project) + assert registrar.AGENT_CONFIGS[KEY]["invoke_separator"] == "_" + assert bool(registrar.AGENT_CONFIGS[KEY].get("dev_no_symlink")) == no_symlinks + record = read_records(project)[KEY] + binding = recovery_metadata(project, KEY, record) + assert binding["registrar_config"]["invoke_separator"] == "_" + assert binding["registrar_config"]["dev_no_symlink"] is no_symlinks + assert binding["files"] == record["files"] + + +@pytest.mark.parametrize("damage", ["package", "files", "project"]) +def test_round5_recovery_checks_local_package_identity(tmp_path, server, damage): + from specify_cli.integrations.installer import _recovery_identity + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = Path.home() / ".specify/integration-trust.json" + data = json.loads(path.read_text()) + binding = data["recovery"][_recovery_identity(project, KEY)] + if damage == "package": + binding["package"] = "0" * 64 + elif damage == "files": + binding["files"] = {"__init__.py": "0" * 64} + else: + other = catalog_project(tmp_path / "other", server) + install(other) + data = json.loads(path.read_text()) + data["recovery"][_recovery_identity(project, KEY)] = data["recovery"][_recovery_identity(other, KEY)] + path.write_text(json.dumps(data)) + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + before = snapshot(project) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 1, result.output + assert "package identity" in " ".join(result.output.split()).lower(), result.output + assert snapshot(project) == before + + +def test_round5_legacy_recovery_binding_remains_supported(tmp_path, server): + from specify_cli.integrations.installer import _recovery_identity + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = Path.home() / ".specify/integration-trust.json" + data = json.loads(path.read_text()) + data["recovery"][_recovery_identity(project, KEY)].pop("files", None) + path.write_text(json.dumps(data)) + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 0, result.output + assert not (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() + + +def test_round5_revoked_package_grant_preserves_generated_files_on_forced_cleanup(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = Path.home() / ".specify/integration-trust.json" + data = json.loads(path.read_text()) + data["grants"] = [] + path.write_text(json.dumps(data)) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 0, result.output + assert "No local recovery ownership record" in " ".join(result.output.split()) + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() + assert not (project / f".specify/integrations/packages/{KEY}").exists() + + +@pytest.mark.parametrize("hashes", [None, [], {"__init__.py": None}]) +def test_round5_invalid_local_recovery_hashes_fail_explicitly(tmp_path, server, hashes): + from specify_cli.integrations.installer import _recovery_identity + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = Path.home() / ".specify/integration-trust.json" + data = json.loads(path.read_text()) + data["recovery"][_recovery_identity(project, KEY)]["files"] = hashes + path.write_text(json.dumps(data)) + before = snapshot(project) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 1, result.output + assert "Invalid integration local recovery package hashes" in " ".join(result.output.split()) + assert snapshot(project) == before + + +def test_adapter_only_descriptor(tmp_path): + path = tmp_path / "integration.yml" + path.write_text(yaml.safe_dump(descriptor())) + parsed = IntegrationDescriptor(path) + assert parsed.id == KEY + assert parsed.tools == [] + assert parsed.commands == parsed.scripts == [] + + +@pytest.mark.parametrize("provides", [{}, {"commands": [], "scripts": []}, {"scripts": ["helper.py"]}]) +def test_optional_legacy_provides_preserved(tmp_path, provides): + path = tmp_path / "integration.yml" + path.write_text(yaml.safe_dump({**descriptor(), "provides": provides})) + parsed = IntegrationDescriptor(path) + assert parsed.scripts == provides.get("scripts", []) + + +@pytest.mark.parametrize("requires", [ + {"speckit_version": "invalid"}, + {"speckit_version": ">=0.6", "tools": ["sample-agent"]}, + {"speckit_version": ">=0.6", "tools": [{"name": "sample-agent", "required": "yes"}]}, + {"speckit_version": ">=0.6", "tools": [{"name": "sample-agent", "version": "latest"}]}, +]) +def test_invalid_descriptor_requirements(tmp_path, requires): + path = tmp_path / "integration.yml" + path.write_text(yaml.safe_dump({**descriptor(), "requires": requires})) + with pytest.raises(IntegrationDescriptorError): + IntegrationDescriptor(path) + + +@pytest.mark.parametrize("archive", ["zip", "tar.gz"]) +def test_catalog_install_renders_host_templates_and_fresh_process(tmp_path, server, archive): + publish(server, archive=archive) + project = catalog_project(tmp_path, server) + install(project) + records = read_records(project) + assert records[KEY]["version"] == "1.0.0" + package = project / ".specify/integrations/packages" / KEY + assert set(records[KEY]["files"]) == {"integration.yml", "__init__.py"} + assert not (package / "templates").exists() + skill = project / ".sample-agent/skills/speckit-plan/SKILL.md" + assert skill.is_file() + rendered = skill.read_text(encoding="utf-8") + assert ".specify/scripts/python/setup_plan.py" in rendered + assert "{SCRIPT}" not in rendered and "__AGENT__" not in rendered + assert "speckit-plan" in rendered + manifest = json.loads((project / f".specify/integrations/{KEY}.manifest.json").read_text()) + assert not any("packages/" in name for name in manifest["files"]) + process = subprocess.run( + [str(Path(sys.executable).parent / "specify"), "integration", "list"], + cwd=project, capture_output=True, text=True, encoding="utf-8", check=False, + ) + assert process.returncode == 0, process.stderr + assert "Sample Agent" in process.stdout and KEY in process.stdout + assert KEY in AGENT_CONFIG + assert KEY in CommandRegistrar(project).AGENT_CONFIGS + status = run(project, ["integration", "status", "--json"]) + assert status.exit_code == 0, status.output + assert json.loads(status.output)["default_integration"] == KEY + + +def test_markdown_adapter_renders_host_commands(tmp_path, server): + publish(server, code=implementation(flavor="markdown")) + project = catalog_project(tmp_path, server) + install(project) + command = project / ".sample-agent/commands/speckit.plan.md" + assert command.is_file() + assert "{SCRIPT}" not in command.read_text(encoding="utf-8") + assert not (project / ".sample-agent/skills").exists() + + +def test_catalog_discovery_does_not_import_and_denial_does_not_download(tmp_path, server): + marker = tmp_path / "executed" + publish(server, code=f"from pathlib import Path\nPath({str(marker)!r}).touch()\n" + implementation()) + project = catalog_project(tmp_path, server, install_allowed=False) + for args in (["integration", "list", "--catalog"], ["integration", "info", KEY], ["integration", "search", KEY]): + result = run(project, args) + assert result.exit_code == 0, result.output + denied = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert denied.exit_code == 1 and "discovery-only" in denied.output + assert not marker.exists() + assert not any(".zip" in url for url in server.requests) + + +def test_trust_decline_does_not_download_or_import(tmp_path, server): + marker = tmp_path / "executed" + publish(server, code=f"from pathlib import Path\nPath({str(marker)!r}).touch()\n" + implementation()) + project = catalog_project(tmp_path, server) + denied = run(project, ["integration", "install", KEY], input="n\n") + assert denied.exit_code == 1 and "not trusted" in denied.output + assert not marker.exists() + assert not any(".zip" in url for url in server.requests) + + +@pytest.mark.parametrize(("change", "expected"), [ + ("id", "identity"), ("version", "version mismatch"), ("name", "name mismatch"), + ("requires", "requirements mismatch"), ("checksum", "SHA-256 mismatch"), + ("url", "HTTPS or loopback"), ("class-key", "class key mismatch"), + ("class-name", "name mismatch"), ("class-version", "version mismatch"), + ("import", "Failed to load"), ("multiple-classes", "exactly one"), + ("side-effect", "import side effects"), ("paths", "Unsafe integration path"), + ("registration-extension", "unsafe registration extension"), + ("reserved-root", "reserved directory"), ("alternate-stream", "Unsafe integration path"), + ("builtin", "already registered or built-in"), + ("runtime-contract", "build_exec_args must accept"), + ("runtime-model", "build_exec_args must accept model"), + ("runtime-positional", "host execution signature"), + ("missing-tool", "requires missing tool"), ("host-version", "requires Spec Kit"), +]) +def test_invalid_packages_fail_explicitly_without_install(tmp_path, server, change, expected): + data = descriptor() + code = implementation() + if change == "id": + data["integration"]["id"] = "different-agent" + elif change == "class-key": + code = implementation(key="different-agent") + elif change == "class-name": + code = code.replace('"name": "Sample Agent"', '"name": "Different Name"') + elif change == "class-version": + code += '\n version = "9.0.0"\n' + elif change == "import": + code = "raise RuntimeError('sample import failed')" + elif change == "multiple-classes": + code += "\nclass AnotherIntegration(SampleIntegration):\n pass\n" + elif change == "side-effect": + code += "\nfrom specify_cli.integrations import _register\n_register(SampleIntegration())\n" + elif change == "paths": + code = implementation(folder="../outside") + elif change == "registration-extension": + code = code.replace("'/SKILL.md'", "'/../../outside'") + elif change == "reserved-root": + code = implementation(folder=".GIT") + elif change == "alternate-stream": + code = implementation(folder=".sample-agent:stream") + elif change == "builtin": + # A forged catalog key cannot turn a built-in class into an external adapter. + code = implementation(key="copilot") + expected = "class key mismatch" + elif change == "runtime-contract": + code += "\n def build_exec_args(self, prompt):\n return None\n" + elif change == "runtime-model": + code += "\n def build_exec_args(self, prompt, *, integration_args=None, integration_options=None, project_root=None):\n return None\n" + elif change == "runtime-positional": + code += "\n def build_exec_args(self, prompt, model, output_json, integration_args, integration_options, project_root, /):\n return None\n" + elif change == "missing-tool": + data["requires"]["tools"] = [{"name": "speckit-nonexistent-sample-tool", "required": True}] + elif change == "host-version": + data["requires"]["speckit_version"] = ">=999.0" + info, _ = publish(server, metadata=data, code=code) + if change == "version": + info["version"] = "9.0.0" + elif change == "name": + info["name"] = "Different Name" + elif change == "requires": + info["requires"] = {"speckit_version": ">=0.9.0"} + elif change == "checksum": + info["sha256"] = "0" * 64 + elif change == "url": + info["download_url"] = "http://example.com/sample-agent.zip" + write_catalog(server, info) + project = catalog_project(tmp_path, server) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert expected in " ".join(result.output.split()), result.output + assert not (project / ".specify/integration.json").exists() + assert not (project / ".sample-agent").exists() + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("member", ["../outside", "/absolute", r"..\outside"]) +def test_malicious_archive_paths_rejected(tmp_path, server, member): + publish(server, members={member: b"unsafe"}) + project = catalog_project(tmp_path, server) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1 + assert not (project / ".specify/integration.json").exists() + + +def test_archive_symlink_rejected(tmp_path, server): + info, archive = publish(server) + with zipfile.ZipFile(archive, "a") as package: + entry = zipfile.ZipInfo("linked.py") + entry.create_system = 3 + entry.external_attr = (stat.S_IFLNK | 0o777) << 16 + package.writestr(entry, "../outside") + info["sha256"] = hashlib.sha256(archive.read_bytes()).hexdigest() + write_catalog(server, info) + project = catalog_project(tmp_path, server) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1 and "symlink" in result.output.lower() + + +def test_upgrade_and_uninstall_preserve_edits_and_remove_package(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + skill = project / ".sample-agent/skills/speckit-plan/SKILL.md" + skill.write_text(skill.read_text(encoding="utf-8") + "\nUser customization\n", encoding="utf-8") + before = snapshot(project) + publish(server, version="2.0.0") + blocked = run(project, ["integration", "upgrade", KEY, "--trust-integration"]) + assert blocked.exit_code == 1 and "modified" in blocked.output + assert snapshot(project) == before + upgraded = run(project, ["integration", "upgrade", KEY, "--trust-integration", "--force"]) + assert upgraded.exit_code == 0, upgraded.output + assert read_records(project)[KEY]["version"] == "2.0.0" + skill.write_text("User customization\n") + removed = run(project, ["integration", "uninstall", KEY]) + assert removed.exit_code == 0, removed.output + assert skill.read_text() == "User customization\n" + assert not (project / f".specify/integrations/packages/{KEY}").exists() + assert not (project / ".specify/integrations/packages.json").exists() + assert KEY not in INTEGRATION_REGISTRY and KEY not in AGENT_CONFIG + assert KEY not in CommandRegistrar(project).AGENT_CONFIGS + + +@pytest.mark.parametrize("operation", ["install", "upgrade", "switch"]) +def test_setup_failure_rolls_back_code_metadata_and_generated_files(tmp_path, server, operation): + publish(server) + project = catalog_project(tmp_path, server) + if operation == "upgrade": + install(project) + elif operation == "switch": + result = run(project, ["integration", "install", "claude"]) + assert result.exit_code == 0, result.output + before = snapshot(project) + body = ''' + def setup(self, project_root, manifest, **kwargs): + super().setup(project_root, manifest, **kwargs) + raise RuntimeError("sample setup failed") +''' + publish(server, version="2.0.0", code=implementation(body=body)) + result = run(project, ["integration", operation, KEY, "--trust-integration"]) + assert result.exit_code == 1 and "sample setup failed" in " ".join(result.output.split()), result.output + assert snapshot(project) == before + if operation == "upgrade": + assert read_records(project)[KEY]["version"] == "1.0.0" + assert INTEGRATION_REGISTRY[KEY].config["name"] == "Sample Agent" + else: + assert KEY not in INTEGRATION_REGISTRY + + +def test_missing_or_modified_code_is_loud_and_json_status_is_parseable(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + code = project / f".specify/integrations/packages/{KEY}/__init__.py" + code.write_text(code.read_text() + "\nraise RuntimeError('do not execute')\n") + status = run(project, ["integration", "status", "--json"]) + assert status.exit_code == 1 + report = json.loads(status.output) + assert report["status"] == "error" + assert report["findings"][0]["code"] == "integration-package-invalid" + with pytest.raises(IntegrationInstallError, match="modified"): + load_installed_integrations(project) + code.unlink() + with pytest.raises(IntegrationInstallError, match="root integration.yml"): + load_installed_integrations(project) + + +def test_cross_project_registry_module_and_cache_isolation(tmp_path, server): + publish(server, members={"helper.py": b"VALUE = 'sample'\n"}, code="from .helper import VALUE\n" + implementation()) + project = catalog_project(tmp_path, server) + install(project) + module = type(INTEGRATION_REGISTRY[KEY]).__module__ + assert module + ".helper" in sys.modules + other = tmp_path / "other-project" + (other / ".specify").mkdir(parents=True) + registrar = CommandRegistrar(other) + assert KEY not in registrar.AGENT_CONFIGS + assert KEY not in CommandRegistrar.AGENT_CONFIGS + assert KEY not in AGENT_CONFIG and KEY not in INTEGRATION_REGISTRY + assert not any(name.startswith(module) for name in sys.modules) + load_installed_integrations(project) + assert KEY in CommandRegistrar(project).AGENT_CONFIGS and KEY in AGENT_CONFIG + + +def test_symlinked_storage_never_follows_target(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + outside = tmp_path / "outside" + outside.mkdir() + (project / ".specify/integrations").mkdir() + (project / ".specify/integrations/packages").symlink_to(outside, target_is_directory=True) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1 and "Symlinked" in result.output + assert list(outside.iterdir()) == [] + + +def test_init_external_adapter_from_registered_catalog(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + result = run(project, [ + "init", "--here", "--force", "--non-interactive", "--ignore-agent-tools", + "--integration", KEY, "--trust-integration", "--script", "py", + ]) + assert result.exit_code == 0, result.output + assert (project / ".sample-agent/skills/speckit-specify/SKILL.md").exists() + assert KEY in read_records(project) + + +def test_new_project_init_from_catalog_environment(tmp_path, server, monkeypatch): + publish(server) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", f"{server.url}/catalog.json") + project = tmp_path / "new-project" + result = runner.invoke(app, [ + "init", str(project), "--non-interactive", "--ignore-agent-tools", + "--integration", KEY, "--trust-integration", + ], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").is_file() + assert KEY in read_records(project) + + +@pytest.mark.parametrize("damaged_uninstall", [False, True]) +@pytest.mark.parametrize("failed_commit", [False, True]) +def test_extension_and_preset_contributions_follow_active_external_adapter( + tmp_path, server, monkeypatch, damaged_uninstall, failed_commit, +): + publish(server) + project = catalog_project(tmp_path, server) + assert run(project, ["integration", "install", "claude"]).exit_code == 0 + install(project) + extension = run(project, ["extension", "add", "git"]) + assert extension.exit_code == 0, extension.output + assert not (project / ".sample-agent/skills/speckit-git-commit/SKILL.md").exists() + + preset = tmp_path / "sample-preset" + (preset / "commands").mkdir(parents=True) + (preset / "commands/speckit.specify.md").write_text( + "---\ndescription: Sample preset command\n---\nSample preset guidance\n" + ) + (preset / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": {"id": "sample-preset", "name": "Sample Preset", "version": "1.0.0", "description": "Sample guidance"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"templates": [{"type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md"}]}, + })) + added = run(project, ["preset", "add", "--dev", str(preset)]) + assert added.exit_code == 0, added.output + assert "Sample preset guidance" not in (project / ".sample-agent/skills/speckit-specify/SKILL.md").read_text(encoding="utf-8") + activated = run(project, ["integration", "use", KEY]) + assert activated.exit_code == 0, activated.output + assert (project / ".sample-agent/skills/speckit-git-commit/SKILL.md").is_file() + assert "Sample preset guidance" in (project / ".sample-agent/skills/speckit-specify/SKILL.md").read_text(encoding="utf-8") + executable = Path(sys.executable).parent / ("specify.exe" if os.name == "nt" else "specify") + fresh_removal = subprocess.run( + [str(executable), "preset", "remove", "sample-preset"], + cwd=project, capture_output=True, text=True, encoding="utf-8", timeout=30, check=False, + ) + assert fresh_removal.returncode == 0, fresh_removal.stdout + fresh_removal.stderr + assert "Sample preset guidance" not in (project / ".sample-agent/skills/speckit-specify/SKILL.md").read_text(encoding="utf-8") + restored = run(project, ["preset", "add", "--dev", str(preset)]) + assert restored.exit_code == 0, restored.output + assert "Sample preset guidance" in (project / ".sample-agent/skills/speckit-specify/SKILL.md").read_text(encoding="utf-8") + assert "sample-agent" in json.loads((project / ".specify/init-options.json").read_text())["ai"] + upgraded = run(project, ["integration", "upgrade", KEY, "--trust-integration", "--force"]) + assert upgraded.exit_code == 0, upgraded.output + assert "Sample preset guidance" in (project / ".sample-agent/skills/speckit-specify/SKILL.md").read_text(encoding="utf-8") + arguments = ["integration", "uninstall", KEY] + if damaged_uninstall: + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + arguments.append("--force") + if failed_commit: + from specify_cli.integrations import installer + + def fail_commit(*args, **kwargs): + raise OSError("sample commit failure") + + before = snapshot(project) + monkeypatch.setattr(installer, "write_records", fail_commit) + failed = run(project, arguments) + assert failed.exit_code == 1, failed.output + assert snapshot(project) == before + return + removed = run(project, arguments) + assert removed.exit_code == 0, removed.output + assert not (project / ".sample-agent/skills/speckit-git-commit/SKILL.md").exists() + assert not (project / ".sample-agent/skills/speckit-specify/SKILL.md").exists() + assert (project / ".claude/skills/speckit-git-commit/SKILL.md").is_file() + assert "Sample preset guidance" in (project / ".claude/skills/speckit-specify/SKILL.md").read_text(encoding="utf-8") + assert KEY not in read_records(project) + assert json.loads((project / ".specify/integration.json").read_text())["integration"] == "claude" + + +def test_builtin_generic_can_coexist_with_external_adapter(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + installed = run(project, [ + "integration", "install", "generic", + "--integration-options=--commands-dir .sample-generic/commands", + ]) + assert installed.exit_code == 0, installed.output + blocked = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert blocked.exit_code == 1 and "multi-install" in blocked.output + forced = run(project, ["integration", "install", KEY, "--trust-integration", "--force"]) + assert forced.exit_code == 0, forced.output + assert (project / ".sample-generic/commands/speckit.plan.md").is_file() + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").is_file() + assert run(project, ["integration", "use", KEY]).exit_code == 0 + + +def test_switch_to_and_from_external_adapter_removes_durable_code(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + assert run(project, ["integration", "install", "claude"]).exit_code == 0 + switched = run(project, ["integration", "switch", KEY, "--trust-integration"]) + assert switched.exit_code == 0, switched.output + assert KEY in read_records(project) + assert not (project / ".claude/skills/speckit-plan/SKILL.md").exists() + switched_back = run(project, ["integration", "switch", "claude"]) + assert switched_back.exit_code == 0, switched_back.output + assert not (project / f".specify/integrations/packages/{KEY}").exists() + assert not (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() + assert (project / ".claude/skills/speckit-plan/SKILL.md").is_file() + + +@pytest.mark.parametrize("step_type", ["command", "prompt"]) +def test_public_workflow_dispatch_loads_adapter_and_uses_process_double(tmp_path, server, monkeypatch, step_type): + body = ''' + def build_exec_args(self, prompt, *, model=None, output_json=True, + integration_args=None, integration_options=None, + project_root=None): + return ["sample-agent-process", "-p", prompt] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + unload_installed_integrations() + from specify_cli.workflows import engine + + calls = [] + + def process_double(argv, **kwargs): + calls.append((argv, kwargs)) + return SimpleNamespace(returncode=0, stdout="sample response", stderr="") + + original_which = shutil.which + monkeypatch.setattr( + shutil, "which", + lambda name, *args, **kwargs: "/sample-agent-process" + if name == "sample-agent-process" else original_which(name, *args, **kwargs), + ) + monkeypatch.setattr(subprocess, "run", process_double) + step = {"id": "sample-dispatch", "type": step_type, "integration": KEY} + if step_type == "command": + step["command"] = "speckit.plan" + else: + step["prompt"] = "Sample prompt" + path = project / "sample-workflow.yml" + path.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [step], + })) + executed = run(project, ["workflow", "run", str(path), "--json"]) + assert executed.exit_code == 0, executed.output + report = json.loads(executed.stdout) + assert report["status"] == "completed" + assert calls[0][0][:2] == ["/sample-agent-process", "-p"] + assert calls[0][0][2] == ("/speckit-plan" if step_type == "command" else "Sample prompt") + assert calls[0][1]["cwd"] == str(project) + state = engine.RunState.load(report["run_id"], project) + assert state.step_results["sample-dispatch"]["output"]["dispatched"] is True + + +@pytest.mark.parametrize("failure", ["import", "download", "commit"]) +def test_upgrade_candidate_failure_restores_old_adapter(tmp_path, server, monkeypatch, failure): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + before = snapshot(project) + _info, archive = publish(server, version="2.0.0", code="raise RuntimeError('sample import error')" if failure == "import" else None) + if failure == "download": + archive.unlink() + elif failure == "commit": + from specify_cli.integrations import installer + + def fail_commit(*args, **kwargs): + raise OSError("sample commit error") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "upgrade", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "upgraded successfully" not in result.output + assert snapshot(project) == before + assert read_records(project)[KEY]["version"] == "1.0.0" + assert KEY in INTEGRATION_REGISTRY + + +def test_repeated_install_is_noop_without_downloading_another_package(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + before = snapshot(project) + server.requests.clear() + result = run(project, ["integration", "install", KEY]) + assert result.exit_code == 0 and "already installed" in result.output + assert snapshot(project) == before + assert server.requests == [] + + +def test_uninstall_failure_restores_package_and_files(tmp_path, server): + body = ''' + def teardown(self, project_root, manifest, *, force=False): + super().teardown(project_root, manifest, force=force) + raise RuntimeError("sample teardown failed") +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + before = snapshot(project) + result = run(project, ["integration", "uninstall", KEY]) + assert result.exit_code == 1, result.output + assert snapshot(project) == before + assert KEY in INTEGRATION_REGISTRY + + +def test_force_uninstall_removes_modified_generated_files(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + skill = project / ".sample-agent/skills/speckit-plan/SKILL.md" + skill.write_text("User customization") + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 0, result.output + assert not skill.exists() + + +def test_builtin_collision_rejected_before_import(tmp_path, server): + marker = tmp_path / "executed" + publish(server, code=f"from pathlib import Path\nPath({str(marker)!r}).touch()\n" + implementation()) + project = catalog_project(tmp_path, server) + package = project / ".specify/integrations/packages/copilot" + package.mkdir(parents=True) + (project / ".specify/integrations/packages.json").write_text(json.dumps({ + "schema_version": "1.0", "packages": {"copilot": {"trusted": True, "files": {}}}, + })) + original = INTEGRATION_REGISTRY["copilot"] + result = run(project, ["integration", "list"]) + assert result.exit_code == 1 and "collides with built-in" in " ".join(result.output.split()) + assert INTEGRATION_REGISTRY["copilot"] is original + assert not marker.exists() + + +def test_class_export_from_helper_module_is_supported(tmp_path, server): + publish(server, code="from .adapter import SampleIntegration\n", members={"adapter.py": implementation().encode()}) + project = catalog_project(tmp_path, server) + install(project) + assert type(INTEGRATION_REGISTRY[KEY]).__module__.endswith(".adapter") + + +def test_oversized_archive_entry_inventory_rejected(tmp_path, server): + publish(server, members={f"extras/{index}.txt": b"sample" for index in range(513)}) + project = catalog_project(tmp_path, server) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1 + assert "limit" in result.output.lower() or "too many" in result.output.lower() + assert not (project / ".sample-agent").exists() + + +def test_project_registry_changes_cannot_reuse_stale_metadata_cache(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + registry = project / ".specify/integrations/packages.json" + data = json.loads(registry.read_text()) + data["packages"][KEY]["version"] = "99.0.0" + registry.write_text(json.dumps(data)) + with pytest.raises(IntegrationInstallError, match="version mismatch"): + load_installed_integrations(project) + assert KEY not in INTEGRATION_REGISTRY and KEY not in AGENT_CONFIG + + +@pytest.mark.parametrize("relative", ["__init__.py", "adapter.py"]) +def test_cached_bytecode_cannot_replace_verified_source(tmp_path, server, relative): + import importlib.util + + publish(server, code="from .adapter import SampleIntegration\n", members={"adapter.py": implementation().encode()}) + project = catalog_project(tmp_path, server) + install(project) + package = project / f".specify/integrations/packages/{KEY}" + source = package / relative + marker = tmp_path / "unverified-bytecode-executed" + poisoned_code = compile( + f"from pathlib import Path\nPath({str(marker)!r}).touch()\n" + source.read_text(), + str(source), "exec", + ) + cached = Path(importlib.util.cache_from_source(str(source))) + cached.parent.mkdir(exist_ok=True) + cached.write_bytes( + importlib.util.MAGIC_NUMBER + + struct.pack("= 2: + raise RuntimeError("sample adapter imported after commit") +''' + publish(server, code=prefix + implementation()) + project = catalog_project(tmp_path, server) + install(project) + assert counter.read_text() == "2" + assert KEY in INTEGRATION_REGISTRY + + +def test_relative_namespace_package_is_supported(tmp_path, server): + publish( + server, code="from .helpers.adapter import SampleIntegration\n", + members={"helpers/adapter.py": implementation().encode()}, + ) + project = catalog_project(tmp_path, server) + install(project) + assert type(INTEGRATION_REGISTRY[KEY]).__module__.endswith(".helpers.adapter") + + +def test_abstract_exports_do_not_count_as_concrete_adapters(tmp_path, server): + prefix = '''from abc import ABC, abstractmethod +from specify_cli.integrations.base import IntegrationBase + +class AbstractAdapter(IntegrationBase, ABC): + @abstractmethod + def sample(self): + pass + +''' + publish(server, code=prefix + implementation()) + project = catalog_project(tmp_path, server) + install(project) + assert type(INTEGRATION_REGISTRY[KEY]).__name__ == "SampleIntegration" + + +def test_same_adapter_key_in_different_projects_uses_its_own_paths(tmp_path, server): + publish(server, code=implementation(folder=".sample-first")) + first = catalog_project(tmp_path, server) + install(first) + publish(server, version="2.0.0", code=implementation(folder=".sample-second")) + second = catalog_project(tmp_path / "second", server) + install(second) + outside = tmp_path / "outside" + outside.mkdir() + (first / ".sample-second").symlink_to(outside, target_is_directory=True) + load_installed_integrations(first) + assert AGENT_CONFIG[KEY]["folder"] == ".sample-first" + assert CommandRegistrar(first).AGENT_CONFIGS[KEY]["dir"] == ".sample-first/skills" + load_installed_integrations(second) + assert AGENT_CONFIG[KEY]["folder"] == ".sample-second" + assert CommandRegistrar(second).AGENT_CONFIGS[KEY]["dir"] == ".sample-second/skills" + + +@pytest.mark.parametrize("operation", ["execute", "resume"]) +def test_reused_workflow_engine_reloads_its_own_project(tmp_path, server, monkeypatch, operation): + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + body = ''' + def build_exec_args(self, prompt, **kwargs): + return ["sample-agent-process", prompt] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + first = WorkflowEngine(project) + calls = [] + + def process_double(argv, **kwargs): + calls.append(kwargs["cwd"]) + failed = operation == "resume" and len(calls) == 1 + return SimpleNamespace(returncode=int(failed), stdout="sample response", stderr="sample failure" if failed else "") + + original_which = shutil.which + monkeypatch.setattr( + shutil, "which", + lambda name, *args, **kwargs: "/sample-agent-process" + if name == "sample-agent-process" else original_which(name, *args, **kwargs), + ) + monkeypatch.setattr(subprocess, "run", process_double) + source = project / "sample-workflow.yml" + source.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [{"id": "sample-prompt", "type": "prompt", "integration": KEY, "prompt": "Sample prompt"}], + })) + definition = WorkflowDefinition.from_yaml(source) + if operation == "resume": + state = first.execute(definition) + assert state.status.value == "failed" + other = tmp_path / "other-project" + other.mkdir() + WorkflowEngine(other) + load_installed_integrations(other) + assert KEY not in INTEGRATION_REGISTRY + result = first.resume(state.run_id) if operation == "resume" else first.execute(definition) + assert result.status.value == "completed" + assert calls[-1] == str(project) + + +def test_concurrent_package_registry_changes_are_not_overwritten(tmp_path, server, monkeypatch): + from contextlib import contextmanager + + from specify_cli import shared_infra + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + original_files = snapshot(project) + publish(server, version="2.0.0") + original_lock = shared_infra._exclusive_project_lock + + @contextmanager + def concurrent_update(root, *args, **kwargs): + with original_lock(root, *args, **kwargs): + path = project / ".specify/integrations/packages.json" + records = json.loads(path.read_text()) + records["packages"][KEY]["catalog"] = "updated-source" + path.write_text(json.dumps(records)) + yield + + monkeypatch.setattr(shared_infra, "_exclusive_project_lock", concurrent_update) + result = run(project, ["integration", "upgrade", KEY, "--trust-integration"]) + assert result.exit_code == 1 and "state changed" in result.output + assert read_records(project)[KEY]["catalog"] == "updated-source" + assert read_records(project)[KEY]["version"] == "1.0.0" + current_files = snapshot(project) + registry = ".specify/integrations/packages.json" + assert {key: value for key, value in current_files.items() if key != registry} == { + key: value for key, value in original_files.items() if key != registry + } + + +@pytest.mark.parametrize("arguments", [["integration", "list"], ["check"]]) +def test_package_load_diagnostics_cannot_inject_console_markup(tmp_path, server, arguments): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + registry = project / ".specify/integrations/packages.json" + data = json.loads(registry.read_text()) + data["packages"]["[/sample]"] = data["packages"].pop(KEY) + registry.write_text(json.dumps(data)) + result = run(project, arguments) + assert result.exit_code == 1 + assert "Invalid integration ID" in result.output and "[/sample]" in result.output + + +def test_rollback_never_follows_a_replaced_metadata_symlink(tmp_path, server, monkeypatch): + from specify_cli.integrations import _lifecycle + + outside = tmp_path / "outside" + outside.mkdir() + marker = outside / "preserved.txt" + marker.write_text("Outside data") + body = f''' + def setup(self, project_root, manifest, **kwargs): + import shutil + super().setup(project_root, manifest, **kwargs) + metadata = project_root / ".specify" + shutil.rmtree(metadata) + metadata.symlink_to({str(outside)!r}, target_is_directory=True) + raise RuntimeError("sample setup failed") +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + original_mkdtemp = _lifecycle.tempfile.mkdtemp + backups = [] + + def record_backup(*args, **kwargs): + path = original_mkdtemp(*args, **kwargs) + if kwargs.get("prefix") == "speckit-integration-rollback-": + backups.append(Path(path)) + return path + + monkeypatch.setattr(_lifecycle.tempfile, "mkdtemp", record_backup) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1 + assert marker.read_text() == "Outside data" + assert list(outside.iterdir()) == [marker] + assert "Recovery snapshots retained" in " ".join(result.output.split()), result.output + assert len(backups) == 1 and backups[0].is_dir() + shutil.rmtree(backups[0]) + + +def test_cancelled_init_does_not_persist_a_prepared_adapter(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, [ + "init", "--here", "--ignore-agent-tools", "--integration", KEY, + "--trust-integration", "--script", "py", + ], input="n\n") + assert result.exit_code == 0, result.output + assert snapshot(project) == before + assert read_records(project) == {} + assert KEY not in INTEGRATION_REGISTRY + assert "Project ready" not in result.output + + +@pytest.mark.parametrize("field", ["version", "download_url", "requires"]) +def test_incomplete_package_metadata_is_an_explicit_cli_failure(tmp_path, server, field): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = project / ".specify/integrations/packages.json" + data = json.loads(path.read_text()) + del data["packages"][KEY][field] + path.write_text(json.dumps(data)) + result = run(project, ["integration", "info", KEY]) + assert result.exit_code == 1 + assert "invalid package metadata" in result.output and field in result.output + assert KEY not in INTEGRATION_REGISTRY + + +@pytest.mark.parametrize("arguments", [ + ["integration", "catalog", "list"], + ["workflow", "step", "catalog", "list"], + ["preset", "catalog", "list"], + ["integration", "list", "--catalog"], + ["integration", "info", KEY], +]) +def test_metadata_commands_never_import_an_installed_adapter(tmp_path, server, arguments): + marker = tmp_path / "imported" + code = implementation() + f"\nfrom pathlib import Path\nPath({str(marker)!r}).write_text('imported')\n" + publish(server, code=code) + project = catalog_project(tmp_path, server) + install(project) + marker.unlink() + executable = Path(sys.executable).parent / ("specify.exe" if os.name == "nt" else "specify") + result = subprocess.run( + [str(executable), *arguments], cwd=project, + capture_output=True, text=True, encoding="utf-8", timeout=30, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert not marker.exists() + + +@pytest.mark.parametrize("operation", ["upgrade", "uninstall"]) +@pytest.mark.parametrize("damage", ["modified", "missing", "missing-directory", "incompatible", "import-failure"]) +def test_force_recovery_does_not_require_a_loadable_old_adapter(tmp_path, server, operation, damage): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = project / f".specify/integrations/packages/{KEY}/__init__.py" + if damage == "missing-directory": + shutil.rmtree(path.parent) + elif damage == "missing": + path.unlink() + elif damage == "modified": + path.write_text("raise RuntimeError('modified code must not execute')") + else: + registry = project / ".specify/integrations/packages.json" + records = json.loads(registry.read_text()) + if damage == "import-failure": + path.write_text(implementation() + "\nraise RuntimeError('sample import failure')\n") + records["packages"][KEY]["files"]["__init__.py"] = hashlib.sha256(path.read_bytes()).hexdigest() + else: + descriptor_path = path.parent / "integration.yml" + data = yaml.safe_load(descriptor_path.read_text()) + data["requires"]["speckit_version"] = ">=999" + descriptor_path.write_text(yaml.safe_dump(data)) + records["packages"][KEY]["requires"] = data["requires"] + records["packages"][KEY]["files"]["integration.yml"] = hashlib.sha256(descriptor_path.read_bytes()).hexdigest() + registry.write_text(json.dumps(records)) + result = run(project, ["integration", "catalog", "list"]) + assert result.exit_code == 0, result.output + strict = run(project, ["integration", operation, KEY]) + assert strict.exit_code == 1 + arguments = ["integration", operation, KEY, "--force"] + if operation == "upgrade": + publish(server, version="2.0.0") + arguments.append("--trust-integration") + result = run(project, arguments) + assert result.exit_code == 0, result.output + assert "without loading its failed implementation" in " ".join(result.output.split()) + if operation == "upgrade": + assert read_records(project)[KEY]["version"] == "2.0.0" + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").is_file() + else: + assert KEY not in read_records(project) + assert not path.parent.exists() + + +@pytest.mark.parametrize("relative", [ + ".git.", ".specify ", ".. ", "con", "NUL.txt", "folder/LPT1.log", + " leading", "trailing.", "bad*name", "bad?name", "bad|name", + 'bad"name', "badname", "bad\x1fname", "a" * 256, +]) +def test_review_portable_paths_rejected_before_filesystem_access(tmp_path, relative): + from specify_cli.integrations.installer import safe_project_path + + with pytest.raises(IntegrationInstallError): + safe_project_path(tmp_path, relative) + + +@pytest.mark.parametrize("folder", [".git.", ".. ", "con", ".sample-agent/NUL.txt"]) +def test_review_portable_output_paths_rejected_without_project_changes(tmp_path, server, folder): + publish(server, code=implementation(folder=folder)) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert snapshot(project) == before + assert folder not in {child.name for child in project.iterdir()} + + +def test_review_cloned_project_cannot_transfer_execution_consent(tmp_path, server): + marker = tmp_path / "imported" + publish(server, code=implementation() + f"\nfrom pathlib import Path\nPath({str(marker)!r}).touch()\n") + project = catalog_project(tmp_path, server) + install(project) + marker.unlink() + clone = tmp_path / "clone" + shutil.copytree(project, clone) + registry = clone / ".specify/integrations/packages.json" + data = json.loads(registry.read_text()) + data["packages"][KEY]["trusted"] = True + registry.write_text(json.dumps(data)) + result = run(clone, ["integration", "list"]) + assert result.exit_code == 1, result.output + assert "local trust" in result.output.lower() + assert not marker.exists() + assert KEY not in INTEGRATION_REGISTRY + recovered = run(clone, ["integration", "upgrade", KEY, "--force", "--trust-integration"]) + assert recovered.exit_code == 0, recovered.output + assert marker.exists() + + +def test_review_local_consent_checked_before_cached_registration(tmp_path, server, monkeypatch): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + assert load_installed_integrations(project) == [KEY] + other_home = tmp_path / "other-home" + other_home.mkdir() + monkeypatch.setenv("HOME", str(other_home)) + monkeypatch.setenv("USERPROFILE", str(other_home)) + result = run(project, ["integration", "list"]) + assert result.exit_code == 1, result.output + assert "local trust" in result.output.lower() + assert KEY not in INTEGRATION_REGISTRY + + +def test_review_consent_is_bound_to_verified_package_digest(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + marker = tmp_path / "changed-import" + code = project / f".specify/integrations/packages/{KEY}/__init__.py" + code.write_text(implementation() + f"\nfrom pathlib import Path\nPath({str(marker)!r}).touch()\n") + registry = project / ".specify/integrations/packages.json" + data = json.loads(registry.read_text()) + data["packages"][KEY]["files"]["__init__.py"] = hashlib.sha256(code.read_bytes()).hexdigest() + registry.write_text(json.dumps(data)) + result = run(project, ["integration", "list"]) + assert result.exit_code == 1, result.output + assert "local trust" in result.output.lower() + assert not marker.exists() + + +@pytest.mark.parametrize("command", ["list", "info", "lookup"]) +@pytest.mark.parametrize("contribution", ["extension", "preset"]) +def test_review_artifact_fresh_process_loads_adapter_and_preserves_json_errors( + tmp_path, server, command, contribution, +): + from specify_cli.artifacts import ArtifactCatalog + + marker = tmp_path / "artifact-import" + publish(server, code=implementation() + f"\nfrom pathlib import Path\nPath({str(marker)!r}).touch()\n") + project = catalog_project(tmp_path, server) + install(project) + if contribution == "extension": + added = run(project, ["extension", "add", "--dev", str(Path.cwd() / "extensions/git")]) + source = ".sample-agent/skills/speckit-git-commit/SKILL.md" + else: + preset = tmp_path / "sample-preset" + (preset / "commands").mkdir(parents=True) + (preset / "commands/speckit.specify.md").write_text( + "---\ndescription: Sample preset command\n---\nSample preset guidance\n" + ) + (preset / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": { + "id": "sample-preset", "name": "Sample Preset", + "version": "1.0.0", "description": "Sample guidance", + }, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"templates": [{ + "type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md", + }]}, + })) + added = run(project, ["preset", "add", "--dev", str(preset)]) + source = ".sample-agent/skills/speckit-specify/SKILL.md" + assert added.exit_code == 0, added.output + rows = ArtifactCatalog(project).list_artifacts_with_stack() + row = next(row for row in rows if any(layer["sourcePath"] == source for layer in row["stack"])) + layer = next(layer for layer in row["stack"] if layer["sourcePath"] == source) + arguments = ["artifact", command] + if command != "list": + arguments.append(row["id"] if command == "info" else layer["lookupId"]) + arguments.append("--json") + marker.unlink() + executable = Path(sys.executable).parent / ("specify.exe" if os.name == "nt" else "specify") + result = subprocess.run( + [str(executable), *arguments], cwd=project, + capture_output=True, text=True, encoding="utf-8", timeout=30, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads(result.stdout) + assert marker.exists() + if command in {"list", "info"}: + payload_rows = payload if command == "list" else [payload] + assert any(layer["sourcePath"] == source for row in payload_rows for layer in row["stack"]) + marker.unlink() + package = project / f".specify/integrations/packages/{KEY}/__init__.py" + package.write_text("raise RuntimeError('damaged adapter must not execute')") + failed = subprocess.run( + [str(executable), *arguments], cwd=project, + capture_output=True, text=True, encoding="utf-8", timeout=30, check=False, + ) + assert failed.returncode == 1 + assert failed.stdout == "" + failure = json.loads(failed.stderr) + assert set(failure) == {"error"} + if command == "list": + assert failure["error"] in { + "artifact resolution failed", + f"artifact resolution failed: Integration '{KEY}' installed package has been modified", + } + else: + assert "modified" in failure["error"] + assert not marker.exists() + + +@pytest.mark.parametrize("original", ["absent", "empty", "nonempty"]) +def test_review_failed_init_removes_only_new_scaffolding(tmp_path, server, monkeypatch, original): + publish(server, code=implementation(body=" def setup(self, *args, **kwargs):\n raise RuntimeError('sample setup failure')\n")) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", f"{server.url}/catalog.json") + project = tmp_path / "target" + if original != "absent": + project.mkdir() + if original == "nonempty": + (project / "notes.txt").write_text("preserve") + before = snapshot(project) + result = run(tmp_path, [ + "init", str(project), "--force", "--ignore-agent-tools", + "--integration", KEY, "--trust-integration", "--script", "py", + ]) + assert result.exit_code == 1, result.output + assert snapshot(project) == before + assert project.exists() == (original != "absent") + assert not (project / ".specify").exists() + + +def test_review_cancelled_init_leaves_existing_uninitialized_directory_unchanged(tmp_path, server, monkeypatch): + publish(server) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", f"{server.url}/catalog.json") + project = tmp_path / "target" + project.mkdir() + notes = project / "notes.txt" + notes.write_text("preserve") + result = run(project, [ + "init", "--here", "--ignore-agent-tools", "--integration", KEY, + "--trust-integration", "--script", "py", + ], input="n\n") + assert result.exit_code == 0, result.output + assert list(project.iterdir()) == [notes] + assert notes.read_text() == "preserve" + + +@pytest.mark.parametrize("change", ["revoked", "invalid", "inside-project", "symlink"]) +def test_review_local_trust_store_cannot_be_bypassed(tmp_path, server, monkeypatch, change): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + trust = Path.home() / ".specify/integration-trust.json" + if os.name != "nt": + assert stat.S_IMODE(trust.stat().st_mode) == 0o600 + if change == "revoked": + trust.unlink() + elif change == "invalid": + trust.write_text('{"schema_version":"1.0","grants":[true]}') + elif change == "inside-project": + monkeypatch.setenv("HOME", str(project)) + monkeypatch.setenv("USERPROFILE", str(project)) + else: + target = tmp_path / "copied-trust.json" + shutil.copyfile(trust, target) + trust.unlink() + try: + trust.symlink_to(target) + except OSError as exc: + pytest.skip(f"Symlinks unavailable: {exc}") + result = run(project, ["integration", "list"]) + assert result.exit_code == 1, result.output + assert "trust" in result.output.lower() + assert KEY not in INTEGRATION_REGISTRY + + +def test_review_package_removal_does_not_suppress_permission_errors(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + package = project / f".specify/integrations/packages/{KEY}" + before = snapshot(project) + original = installer.shutil.rmtree + + def deny_package_removal(path, *args, **kwargs): + if Path(path) == package: + raise PermissionError("sample package removal denied") + return original(path, *args, **kwargs) + + monkeypatch.setattr(installer.shutil, "rmtree", deny_package_removal) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 1, result.output + assert "sample package removal denied" in result.output + assert snapshot(project) == before + + +def test_review_failed_init_preserves_new_unowned_scaffolding_content(tmp_path, server, monkeypatch): + project = tmp_path / "target" + notes = project / ".specify/user-notes.txt" + body = f""" def setup(self, *args, **kwargs): + from pathlib import Path + notes = Path({str(notes)!r}) + notes.parent.mkdir(parents=True, exist_ok=True) + notes.write_text("independent progress") + raise RuntimeError("sample setup failure") +""" + publish(server, code=implementation(body=body)) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", f"{server.url}/catalog.json") + result = run(tmp_path, [ + "init", str(project), "--ignore-agent-tools", "--integration", KEY, + "--trust-integration", "--script", "py", + ]) + assert result.exit_code == 1, result.output + assert notes.read_text() == "independent progress" + assert read_records(project) == {} + + +@pytest.mark.parametrize("writer", ["render", "copy", "script"]) +def test_round2_leaf_symlink_never_redirects_transaction_writes(tmp_path, server, writer): + outside = tmp_path / "outside.txt" + outside.write_text("preserve outside data") + if writer == "copy": + source = tmp_path / "source.md" + source.write_text("copied command") + body = f""" def setup(self, project_root, manifest, **kwargs): + from pathlib import Path + self.copy_command_to_directory( + Path({str(source)!r}), project_root / ".sample-agent/skills", "SKILL.md" + ) + return [] +""" + relative = ".sample-agent/skills/SKILL.md" + elif writer == "script": + body = """ def setup(self, project_root, manifest, **kwargs): + return self.install_scripts(project_root, manifest) +""" + relative = f".specify/integrations/{KEY}/scripts/sample.py" + else: + body = "" + relative = ".sample-agent/skills/speckit-plan/SKILL.md" + publish( + server, code=implementation(body=body), + members={"scripts/sample.py": b"print('sample')"} if writer == "script" else None, + ) + project = catalog_project(tmp_path, server) + leaf = project / relative + leaf.parent.mkdir(parents=True) + try: + leaf.symlink_to(outside) + except OSError as exc: + pytest.skip(f"Symlinks unavailable: {exc}") + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "symlink" in result.output.lower(), result.output + assert outside.read_text() == "preserve outside data" + assert leaf.is_symlink() + assert snapshot(project) == before + + +def test_round2_script_helper_installs_and_records_regular_files(tmp_path, server): + body = """ def setup(self, project_root, manifest, **kwargs): + return self.install_scripts(project_root, manifest) +""" + publish( + server, code=implementation(body=body), + members={"scripts/sample.py": b"print('sample')"}, + ) + project = catalog_project(tmp_path, server) + install(project) + relative = f".specify/integrations/{KEY}/scripts/sample.py" + assert (project / relative).read_bytes() == b"print('sample')" + manifest = json.loads((project / f".specify/integrations/{KEY}.manifest.json").read_text()) + assert relative in manifest["files"] + + +def test_round2_forced_removal_unlinks_owned_leaf_without_following_it(tmp_path, server): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + outside = tmp_path / "outside.txt" + outside.write_text("preserve outside data") + leaf = project / ".sample-agent/skills/speckit-plan/SKILL.md" + leaf.unlink() + try: + leaf.symlink_to(outside) + except OSError as exc: + pytest.skip(f"Symlinks unavailable: {exc}") + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 0, result.output + assert not leaf.is_symlink() + assert outside.read_text() == "preserve outside data" + + +def test_round2_snapshot_preserves_standalone_leaf_link_without_traversal(tmp_path, server, monkeypatch): + body = """ def setup(self, project_root, manifest, **kwargs): + self.write_file_and_record( + "managed notes", project_root / "sample-notes.md", project_root, manifest + ) + return [] +""" + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + outside = tmp_path / "outside" + outside.mkdir() + marker = outside / "user-notes.md" + marker.write_text("preserve outside directory") + leaf = project / "sample-notes.md" + leaf.unlink() + try: + leaf.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"Symlinks unavailable: {exc}") + original = shutil.copytree + + def reject_root_link_traversal(source, *args, **kwargs): + assert not Path(source).is_symlink(), "snapshot followed a leaf link" + return original(source, *args, **kwargs) + + monkeypatch.setattr(shutil, "copytree", reject_root_link_traversal) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 0, result.output + assert not leaf.is_symlink() + assert marker.read_text() == "preserve outside directory" + + +@pytest.mark.parametrize("metadata", ["other-root", "unrelated-root", "manifest-path"]) +def test_round2_recovery_rejects_unverified_ownership_before_deleting_files(tmp_path, server, metadata): + publish(server) + project = catalog_project(tmp_path, server) + assert run(project, ["integration", "install", "claude"]).exit_code == 0 + install(project) + marker = project / ".claude/skills/user-notes.md" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("preserve another integration") + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + if metadata == "manifest-path": + path = project / f".specify/integrations/{KEY}.manifest.json" + data = json.loads(path.read_text()) + data["files"][marker.relative_to(project).as_posix()] = hashlib.sha256(marker.read_bytes()).hexdigest() + else: + path = project / ".specify/integrations/packages.json" + data = json.loads(path.read_text()) + data["packages"][KEY]["registrar_config"]["dir"] = ( + ".claude/skills" if metadata == "other-root" else "user-files" + ) + path.write_text(json.dumps(data)) + before = snapshot(project) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 1, result.output + assert "ownership" in result.output.lower() or "overlap" in result.output.lower() + assert snapshot(project) == before + assert marker.read_text() == "preserve another integration" + + +@pytest.mark.parametrize("proof", ["copied-project", "legacy-grant"]) +@pytest.mark.parametrize("operation", ["upgrade", "uninstall"]) +def test_round2_recovery_without_local_ownership_preserves_old_outputs(tmp_path, server, proof, operation): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + if proof == "copied-project": + clone = tmp_path / "copied-project" + shutil.copytree(project, clone) + project = clone + else: + path = Path.home() / ".specify/integration-trust.json" + data = json.loads(path.read_text()) + del data["recovery"] + path.write_text(json.dumps(data)) + marker = project / ".claude/skills/user-notes.md" + marker.parent.mkdir(parents=True) + marker.write_text("preserve unrelated data") + manifest_path = project / f".specify/integrations/{KEY}.manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["files"][marker.relative_to(project).as_posix()] = hashlib.sha256(marker.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + skill = project / ".sample-agent/skills/speckit-plan/SKILL.md" + skill.write_text("preserve previous layout customization") + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + arguments = ["integration", operation, KEY, "--force"] + if operation == "upgrade": + publish(server, version="2.0.0", code=implementation(folder=".sample-new")) + arguments.append("--trust-integration") + result = run(project, arguments) + assert result.exit_code == 0, result.output + assert "No local recovery ownership record" in result.output + assert marker.read_text() == "preserve unrelated data" + assert skill.read_text() == "preserve previous layout customization" + if operation == "upgrade": + assert (project / ".sample-new/skills/speckit-plan/SKILL.md").is_file() + assert read_records(project)[KEY]["version"] == "2.0.0" + else: + assert not (project / f".specify/integrations/packages/{KEY}").exists() + + +@pytest.mark.parametrize("directory", ["primary", "legacy"]) +def test_round2_recovery_rejects_even_locally_recorded_overlap(tmp_path, server, directory): + body = " multi_install_safe = False\n" + if directory == "legacy": + body += ' registrar_config = {**registrar_config, "legacy_dir": ".claude/skills"}\n' + publish( + server, code=implementation( + folder=".claude" if directory == "primary" else ".sample-agent", body=body, + ), + ) + project = catalog_project(tmp_path, server) + install(project) + marker = project / ".claude/skills/user-notes.md" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("preserve another integration") + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + before = snapshot(project) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 1, result.output + assert "ownership overlaps 'claude'" in result.output + assert snapshot(project) == before + assert marker.read_text() == "preserve another integration" + + +def test_round2_failed_durable_upgrade_retains_previous_recovery_ownership(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = Path.home() / ".specify/integration-trust.json" + ownership = json.loads(path.read_text())["recovery"] + before = snapshot(project) + publish(server, version="2.0.0", code=implementation(folder=".sample-new")) + original = installer._import_package + + def fail_durable_update(package, metadata, hashes, root): + if metadata.version == "2.0.0" and project in package.parents: + raise IntegrationInstallError("sample durable import failed") + return original(package, metadata, hashes, root) + + monkeypatch.setattr(installer, "_import_package", fail_durable_update) + result = run(project, ["integration", "upgrade", KEY, "--force", "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "sample durable import failed" in result.output + assert snapshot(project) == before + assert json.loads(path.read_text())["recovery"] == ownership + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + removed = run(project, ["integration", "uninstall", KEY, "--force"]) + assert removed.exit_code == 0, removed.output + + +@pytest.mark.parametrize("operation", ["run", "resume"]) +def test_round2_workflow_reload_oserror_uses_single_json_envelope(tmp_path, server, monkeypatch, operation): + from specify_cli.integrations import installer + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + source = project / "sample-workflow.yml" + source.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [{"id": "sample-shell", "type": "shell", "run": "echo sample"}], + })) + state = RunState( + run_id="sample-run", workflow_id="sample-workflow", project_root=project, + ) + state.status = RunStatus.PAUSED + state.save() + (project / ".specify/workflows/runs/sample-run/workflow.yml").write_bytes(source.read_bytes()) + original = installer.package_hashes + calls = 0 + + def fail_second_load(package): + nonlocal calls + calls += 1 + if calls >= 2: + raise PermissionError("sample package read denied") + return original(package) + + monkeypatch.setattr(installer, "package_hashes", fail_second_load) + result = run(project, [ + "workflow", operation, str(source) if operation == "run" else "sample-run", "--json", + ]) + assert result.exit_code == 1, result.output + payload = json.loads(result.stdout) + assert payload["status"] == "failed" + assert "sample package read denied" in payload["error"] + assert result.stderr == "" + + +def test_round2_concurrent_load_waits_for_its_requested_project(tmp_path, server, monkeypatch): + from concurrent.futures import ThreadPoolExecutor, TimeoutError + + from specify_cli.integrations import installer + + publish(server, code=implementation(folder=".sample-first")) + first = catalog_project(tmp_path, server) + install(first) + publish(server, version="2.0.0", code=implementation(folder=".sample-second")) + second = catalog_project(tmp_path / "second", server) + install(second) + unload_installed_integrations() + entered = threading.Event() + release = threading.Event() + original = installer.package_hashes + + def slow_first_load(package): + if first in package.parents: + entered.set() + assert release.wait(10) + return original(package) + + monkeypatch.setattr(installer, "package_hashes", slow_first_load) + with ThreadPoolExecutor(max_workers=2) as pool: + a = pool.submit(load_installed_integrations, first) + assert entered.wait(10) + b = pool.submit(load_installed_integrations, second) + try: + with pytest.raises(TimeoutError): + b.result(timeout=0.1) + finally: + release.set() + assert a.result(timeout=10) == [KEY] + assert b.result(timeout=10) == [KEY] + assert INTEGRATION_REGISTRY[KEY].config["folder"] == ".sample-second" + + +@pytest.mark.parametrize("kind", ["prompt", "command"]) +def test_round2_workflow_dispatch_keeps_project_adapter_after_registry_switch(tmp_path, server, monkeypatch, kind): + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + body = ''' + def build_exec_args(self, prompt, **kwargs): + return ["sample-agent-process", self.config["folder"], prompt] +''' + publish(server, code=implementation(folder=".sample-first", body=body)) + first = catalog_project(tmp_path, server) + install(first) + publish(server, version="2.0.0", code=implementation(folder=".sample-second", body=body)) + second = catalog_project(tmp_path / "second", server) + install(second) + engine = WorkflowEngine(first) + engine.on_step_start = lambda *args: load_installed_integrations(second) + calls = [] + original_which = shutil.which + monkeypatch.setattr(shutil, "which", lambda name: "/sample-agent-process" if name == "sample-agent-process" else original_which(name)) + + def harmless_process(argv, **kwargs): + calls.append(argv) + return SimpleNamespace(returncode=0, stdout="sample response", stderr="") + + monkeypatch.setattr(subprocess, "run", harmless_process) + step = {"id": "sample-dispatch", "type": kind, "integration": KEY} + step["prompt" if kind == "prompt" else "command"] = "Sample prompt" if kind == "prompt" else "speckit.plan" + source = first / "sample-workflow.yml" + source.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [step], + })) + result = engine.execute(WorkflowDefinition.from_yaml(source)) + assert result.status.value == "completed", result.steps + assert calls and all(argv[1] == ".sample-first" for argv in calls) + + +@pytest.mark.parametrize("kind", ["prompt", "command"]) +@pytest.mark.parametrize("process_fails", [False, True]) +def test_round2_overlapping_dispatch_pins_lazy_imports_and_project_lookup( + tmp_path, server, monkeypatch, kind, process_fails, +): + from concurrent.futures import ThreadPoolExecutor + + from specify_cli.integrations import installer + from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine + + body = ''' + def build_exec_args(self, prompt, **kwargs): + from .helper import marker + from specify_cli.integrations import get_integration + assert get_integration(self.key) is self + return ["sample-agent-process", marker, prompt] +''' + publish( + server, code=implementation(folder=".sample-first", body=body), + members={"helper.py": b"marker = '.sample-first'"}, + ) + first = catalog_project(tmp_path, server) + install(first) + publish( + server, version="2.0.0", code=implementation(folder=".sample-second", body=body), + members={"helper.py": b"marker = '.sample-second'"}, + ) + second = catalog_project(tmp_path / "second", server) + install(second) + engines = [WorkflowEngine(root) for root in (first, second)] + definitions = [] + for root in (first, second): + step = {"id": "sample-dispatch", "type": kind, "integration": KEY} + step["prompt" if kind == "prompt" else "command"] = ( + "Sample prompt" if kind == "prompt" else "speckit.plan" + ) + source = root / "sample-workflow.yml" + source.write_text(yaml.safe_dump({ + "schema_version": "1.0", + "workflow": {"id": "sample-workflow", "name": "Sample Workflow", "version": "1.0.0"}, + "steps": [step], + })) + definitions.append(WorkflowDefinition.from_yaml(source)) + barrier = threading.Barrier(2) + original = installer._VerifiedSourceLoader.get_code + + def overlap_lazy_import(loader, fullname): + if fullname.endswith(".helper"): + barrier.wait(timeout=10) + return original(loader, fullname) + + monkeypatch.setattr(installer._VerifiedSourceLoader, "get_code", overlap_lazy_import) + original_which = shutil.which + monkeypatch.setattr( + shutil, "which", + lambda name: "/sample-agent-process" if name == "sample-agent-process" else original_which(name), + ) + calls = [] + + def harmless_process(argv, **kwargs): + calls.append((argv[1], Path(kwargs["cwd"]))) + return SimpleNamespace( + returncode=1 if process_fails else 0, stdout="sample response", stderr="sample failure", + ) + + monkeypatch.setattr(subprocess, "run", harmless_process) + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [ + pool.submit(engine.execute, definition) + for engine, definition in zip(engines, definitions, strict=True) + ] + results = [future.result(timeout=20) for future in futures] + assert all(result.status.value == ("failed" if process_fails else "completed") for result in results) + assert set(calls) == {(".sample-first", first), (".sample-second", second)} + assert not installer._pinned_names + retained = {installer._namespace(value) for value in INTEGRATION_REGISTRY.values()} + assert set(installer._source_packages) <= retained + unload_installed_integrations() + assert not installer._source_packages + assert not any(name.startswith(installer._MODULE_PREFIX) for name in sys.modules) + + +@pytest.mark.parametrize("legacy", [ + "../outside", "/outside", ".sample/../outside", ".git/hooks", ".SPECIFY/scripts", + "", None, 42, False, [], +]) +def test_round3_invalid_legacy_destination_is_rejected_before_setup(tmp_path, server, legacy): + marker = tmp_path / "setup-ran" + body = f''' registrar_config = {{**registrar_config, "legacy_dir": {legacy!r}}} + def setup(self, project_root, manifest, **kwargs): + from pathlib import Path + Path({str(marker)!r}).touch() + return [] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert not marker.exists() + assert snapshot(project) == before + + +def test_round3_valid_legacy_destination_remains_supported(tmp_path, server): + body = ' registrar_config = {**registrar_config, "legacy_dir": ".sample-previous/skills"}\n' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + assert CommandRegistrar(project).AGENT_CONFIGS[KEY]["legacy_dir"] == ".sample-previous/skills" + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").is_file() + + +@pytest.mark.parametrize("directory", ["primary", "legacy"]) +def test_round3_home_relative_output_cannot_escape_project_contract(tmp_path, server, directory): + body = ( + ' registrar_config = {**registrar_config, "legacy_dir": "~/.sample-previous/skills"}\n' + if directory == "legacy" else "" + ) + publish(server, code=implementation( + folder="~/.sample-agent" if directory == "primary" else ".sample-agent", body=body, + )) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "project-local" in result.output + assert snapshot(project) == before + + +@pytest.mark.parametrize("directory", ["primary", "legacy"]) +@pytest.mark.parametrize("alias", [".CLAUDE", ".CLAUDE/nested", ".KILOCODE"]) +def test_round3_multi_install_overlap_uses_portable_casefolded_paths(tmp_path, server, directory, alias): + body = ( + f' registrar_config = {{**registrar_config, "legacy_dir": {alias!r}}}\n' + if directory == "legacy" else "" + ) + publish(server, code=implementation( + folder=alias if directory == "primary" else ".sample-agent", body=body, + )) + project = catalog_project(tmp_path, server) + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert "overlap" in result.output.lower(), result.output + assert snapshot(project) == before + + +@pytest.mark.parametrize("consumer", ["extension-candidates", "extension-remove", "preset"]) +def test_round3_managers_snapshot_the_requested_project_atomically(tmp_path, server, monkeypatch, consumer): + from specify_cli.extensions import ExtensionManager + from specify_cli.presets import PresetManager + + publish(server, code=implementation(folder=".sample-first")) + first = catalog_project(tmp_path, server) + install(first) + publish(server, version="2.0.0", code=implementation(folder=".sample-second")) + second = catalog_project(tmp_path / "second", server) + install(second) + original = CommandRegistrar.__init__ + + def switch_before_unscoped_snapshot(registrar, project_root=None, **kwargs): + if project_root is None: + load_installed_integrations(second) + original(registrar, project_root, **kwargs) + + monkeypatch.setattr(CommandRegistrar, "__init__", switch_before_unscoped_snapshot) + if consumer == "preset": + registrar = PresetManager(first)._command_registrar() + assert registrar.AGENT_CONFIGS[KEY]["dir"] == ".sample-first/skills" + elif consumer == "extension-candidates": + candidates = ExtensionManager(first)._extension_skill_candidate_dirs() + assert first / ".sample-first/skills" in candidates + assert first / ".sample-second/skills" not in candidates + else: + manager = ExtensionManager(first) + manager.registry.add("sample-extension", { + "version": "1.0.0", "registered_commands": {KEY: ["speckit.sample"]}, + }) + first_leaf = first / ".sample-first/skills/speckit-sample/SKILL.md" + other_leaf = first / ".sample-second/skills/speckit-sample/SKILL.md" + for leaf in (first_leaf, other_leaf): + leaf.parent.mkdir(parents=True, exist_ok=True) + leaf.write_text("preserve the correct project scope") + assert manager.remove("sample-extension") + assert not first_leaf.exists() + assert other_leaf.read_text() == "preserve the correct project scope" + + +@pytest.mark.parametrize("operation", ["add", "remove", "enable", "disable"]) +def test_round3_extension_event_refresh_loads_adapter_in_fresh_process(tmp_path, server, operation): + from specify_cli.events import refresh_integration_events + + body = ''' CANONICAL_TO_NATIVE = {"session_start": "SampleStart"} + events_config_file = ".sample-agent/events.json" + events_format = "json-nested" +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + extension = tmp_path / "sample-events" + extension.mkdir() + (extension / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": { + "id": "sample-events", "name": "Sample Events", "version": "1.0.0", + "description": "Sample event-only extension", + }, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"commands": []}, + "events": {"session_start": {"command": "speckit.sample.boot"}}, + })) + config = project / ".sample-agent/events.json" + if operation != "add": + result = run(project, ["extension", "add", "--dev", str(extension)]) + assert result.exit_code == 0, result.output + refresh_integration_events(project) + if operation == "enable": + result = run(project, ["extension", "disable", "sample-events"]) + assert result.exit_code == 0, result.output + refresh_integration_events(project) + arguments = ( + ["extension", "add", "--dev", str(extension)] + if operation == "add" else ["extension", operation, "sample-events"] + ) + if operation == "remove": + arguments.append("--force") + executable = Path(sys.executable).parent / ("specify.exe" if os.name == "nt" else "specify") + result = subprocess.run( + [str(executable), *arguments], cwd=project, capture_output=True, + text=True, encoding="utf-8", timeout=30, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + data = json.loads(config.read_text()) if config.exists() else {} + hooks = data.get("hooks", {}).get("SampleStart", []) + assert bool(hooks) == (operation in {"add", "enable"}) + + +def test_round3_event_refresh_surfaces_failed_adapter_load(tmp_path, server): + from specify_cli.events import EventRefreshError, refresh_integration_events + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + (project / f".specify/integrations/packages/{KEY}/__init__.py").write_text( + "raise RuntimeError('sample adapter should not execute')" + ) + with pytest.raises(EventRefreshError, match="sample-agent.*modified") as exc: + refresh_integration_events(project) + assert exc.value.failures[0][0] == "installed adapters" + + +@pytest.mark.parametrize("json_output", [False, True]) +def test_round3_resume_disappearing_run_has_specific_missing_run_error(tmp_path, server, monkeypatch, json_output): + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState, WorkflowEngine + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + state = RunState(run_id="sample-run", workflow_id="sample-workflow", project_root=project) + state.status = RunStatus.PAUSED + state.installed_origin_tracked = True + state.save() + + def disappear(engine, run_id, inputs): + raise FileNotFoundError("sample state disappeared") + + monkeypatch.setattr(WorkflowEngine, "resume", disappear) + result = run(project, ["workflow", "resume", "sample-run", *(["--json"] if json_output else [])]) + assert result.exit_code == 1, result.output + if json_output: + assert json.loads(result.stdout)["error"] == "Run not found: sample-run" + assert result.stderr == "" + else: + assert "Run not found: sample-run" in result.stdout + + +def test_round3_resume_adapter_file_disappearance_is_not_a_missing_run(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + state = RunState(run_id="sample-run", workflow_id="sample-workflow", project_root=project) + state.status = RunStatus.PAUSED + state.installed_origin_tracked = True + state.save() + original = installer.package_hashes + calls = 0 + + def disappear_on_resume(package): + nonlocal calls + calls += 1 + if calls == 2: + raise FileNotFoundError("sample adapter source disappeared") + return original(package) + + monkeypatch.setattr(installer, "package_hashes", disappear_on_resume) + result = run(project, ["workflow", "resume", "sample-run", "--json"]) + assert result.exit_code == 1, result.output + assert "sample adapter source disappeared" in json.loads(result.stdout)["error"] + assert "Run not found" not in result.stdout + assert result.stderr == "" + + +def test_failed_operation_preserves_independent_workflow_and_unowned_output_edits(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + from specify_cli.workflows.engine import RunState + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + user_file = project / ".sample-agent/skills/user-notes.md" + user_file.write_text("before") + workflow = RunState(run_id="sample-run", workflow_id="sample-workflow", project_root=project) + workflow.save() + publish(server, version="2.0.0") + + def fail_commit(*args, **kwargs): + def independent_writer(): + user_file.write_text("independent user edit") + workflow.current_step_index = 1 + workflow.save() + thread = threading.Thread(target=independent_writer) + thread.start() + thread.join() + raise OSError("sample commit failure") + + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "upgrade", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert user_file.read_text() == "independent user edit" + assert RunState.load("sample-run", project).current_step_index == 1 + assert read_records(project)[KEY]["version"] == "1.0.0" + + +def test_failed_operation_preserves_concurrent_edits_to_a_managed_file(tmp_path, server, monkeypatch): + from specify_cli.integrations import _lifecycle, installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = project / ".sample-agent/skills/speckit-plan/SKILL.md" + publish(server, version="2.0.0") + original_mkdtemp = _lifecycle.tempfile.mkdtemp + backups = [] + + def record_backup(*args, **kwargs): + directory = original_mkdtemp(*args, **kwargs) + if kwargs.get("prefix") == "speckit-integration-rollback-": + backups.append(Path(directory)) + return directory + + def fail_commit(*args, **kwargs): + path.write_text("concurrent managed-file edit") + raise OSError("sample commit failure") + + monkeypatch.setattr(_lifecycle.tempfile, "mkdtemp", record_backup) + monkeypatch.setattr(installer, "write_records", fail_commit) + result = run(project, ["integration", "upgrade", KEY, "--trust-integration"]) + assert result.exit_code == 1, result.output + assert path.read_text() == "concurrent managed-file edit" + assert "Preserved concurrent edits" in result.output + assert len(backups) == 1 + shutil.rmtree(backups[0]) + + +@pytest.mark.parametrize("script", ["sh", "ps", "py"]) +def test_catalog_init_checks_required_tools_and_scaffolds_host_skills(tmp_path, server, monkeypatch, script): + metadata = descriptor() + metadata["requires"]["tools"] = [{"name": KEY, "required": True}] + publish(server, metadata=metadata) + project = catalog_project(tmp_path, server) + before = snapshot(project) + monkeypatch.setenv("PATH", str(tmp_path / "absent-tools")) + arguments = [ + "init", "--here", "--force", "--ignore-agent-tools", + "--integration", KEY, "--trust-integration", "--script", script, + ] + denied = run(project, arguments) + assert denied.exit_code == 1 and "requires missing tool" in " ".join(denied.output.split()) + assert snapshot(project) == before + tools = tmp_path / "tools" + tools.mkdir() + executable = tools / (KEY + ".exe" if os.name == "nt" else KEY) + executable.write_text("scaffolding-only executable presence double") + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tools)) + result = run(project, arguments) + assert result.exit_code == 0, result.output + skill = project / ".sample-agent/skills/speckit-plan/SKILL.md" + content = skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content and "__SPECKIT_COMMAND_" not in content + assert yaml.safe_load(content.split("---", 2)[1])["name"] == "speckit-plan" + assert (project / f".specify/scripts/{'python' if script == 'py' else 'bash' if script == 'sh' else 'powershell'}").is_dir() + + +@pytest.mark.parametrize("field,value", [ + ("dir", "../outside"), ("dir", ".specify/templates"), + ("format", "unsupported"), ("args", None), ("extension", "/../outside"), + ("invoke_separator", None), ("dev_no_symlink", "false"), +]) +def test_force_recovery_validates_persisted_registration_metadata(tmp_path, server, field, value): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + registry = project / ".specify/integrations/packages.json" + data = json.loads(registry.read_text()) + data["packages"][KEY]["registrar_config"][field] = value + registry.write_text(json.dumps(data)) + (project / f".specify/integrations/packages/{KEY}/__init__.py").write_text( + "raise RuntimeError('modified code must not execute')" + ) + before = snapshot(project) + result = run(project, ["integration", "uninstall", KEY, "--force"]) + assert result.exit_code == 1, result.output + assert snapshot(project) == before + + +def test_adapter_cannot_overwrite_a_concurrent_managed_edit_with_a_second_write(tmp_path, server, monkeypatch): + from specify_cli.integrations import _lifecycle + + body = ''' + def setup(self, project_root, manifest, **kwargs): + from threading import Thread + created = super().setup(project_root, manifest, **kwargs) + path = manifest.project_root / ".sample-agent/skills/speckit-plan/SKILL.md" + thread = Thread(target=lambda: path.write_text("concurrent managed edit")) + thread.start() + thread.join() + manifest.record_file(path.relative_to(manifest.project_root).as_posix(), "second adapter write") + return created +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + original = _lifecycle.tempfile.mkdtemp + backups = [] + + def record_backup(*args, **kwargs): + directory = original(*args, **kwargs) + if kwargs.get("prefix") == "speckit-integration-rollback-": + backups.append(Path(directory)) + return directory + + monkeypatch.setattr(_lifecycle.tempfile, "mkdtemp", record_backup) + try: + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "refusing to overwrite" in " ".join(result.output.split()) + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").read_text() == "concurrent managed edit" + assert KEY not in read_records(project) + finally: + for backup in backups: + if backup.exists(): + shutil.rmtree(backup) + + +@pytest.mark.parametrize("operation", ["install", "switch"]) +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("change", ["concurrent", "deleted"]) +def test_failed_setup_preserves_pending_tracked_changes(tmp_path, server, monkeypatch, operation, existing, change): + from specify_cli.integrations import _lifecycle + + body = f''' def setup(self, project_root, manifest, **kwargs): + from threading import Thread + from specify_cli.integrations._file_changes import changing_file + path = manifest.record_file(".sample-agent/skills/sample-pending.md", "first completed write") + with changing_file(path): + writer = Thread(target=lambda: ( + path.write_text("concurrent edit") if {change!r} == "concurrent" else path.unlink() + )) + writer.start() + writer.join() + raise OSError("interrupted second write") +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + if operation == "switch": + assert run(project, ["integration", "install", "claude"]).exit_code == 0 + target = project / ".sample-agent/skills/sample-pending.md" + target.parent.mkdir(parents=True) + if existing: + target.write_text("original bytes") + backups = [] + original_mkdtemp = _lifecycle.tempfile.mkdtemp + + def record_backup(*args, **kwargs): + directory = original_mkdtemp(*args, **kwargs) + if kwargs.get("prefix") == "speckit-integration-rollback-": + backups.append(Path(directory)) + return directory + + monkeypatch.setattr(_lifecycle.tempfile, "mkdtemp", record_backup) + conflict = change == "concurrent" or existing + try: + result = run(project, ["integration", operation, KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "interrupted second write" in " ".join(result.output.split()) + assert ("Preserved concurrent edits" in result.output) == conflict + if change == "concurrent": + assert target.read_text() == "concurrent edit" + else: + assert not target.exists() + assert len(backups) == 1 and backups[0].exists() == conflict + if existing: + assert any(path.read_bytes() == b"original bytes" for path in backups[0].rglob("*") if path.is_file()) + assert KEY not in read_records(project) + assert KEY not in INTEGRATION_REGISTRY + finally: + for backup in backups: + if backup.exists(): + shutil.rmtree(backup) + + +@pytest.mark.parametrize("operation", ["install", "switch"]) +@pytest.mark.parametrize("preexisting", [None, ".sample-agent", ".sample-agent/skills", ".sample-agent/skills/sample"]) +def test_failed_setup_restores_completed_writes_and_original_directories(tmp_path, server, operation, preexisting): + body = ''' def setup(self, project_root, manifest, **kwargs): + manifest.record_file(".sample-agent/skills/sample/SKILL.md", "completed write") + raise OSError("setup failure after completed write") +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + if operation == "switch": + assert run(project, ["integration", "install", "claude"]).exit_code == 0 + if preexisting: + (project / preexisting).mkdir(parents=True) + original_directories = {path for path in project.rglob("*") if path.is_dir()} + before = snapshot(project) + result = run(project, ["integration", operation, KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "setup failure after completed write" in " ".join(result.output.split()) + assert "Preserved concurrent edits" not in result.output + assert snapshot(project) == before + assert all(path.is_dir() for path in original_directories) + if preexisting: + assert not any((project / preexisting).iterdir()) + else: + assert not (project / ".sample-agent").exists() + + +@pytest.mark.parametrize("record_ownership", [False, True]) +@pytest.mark.parametrize("limit_offset", [-1, 0, 1]) +def test_trust_grant_respects_reader_limit_before_replacement(tmp_path, server, monkeypatch, record_ownership, limit_offset): + from specify_cli.integrations import installer + from specify_cli.integrations.manifest import IntegrationManifest + + publish(server) + first = catalog_project(tmp_path, server) + install(first) + second = catalog_project(tmp_path / "second", server) + manifest = IntegrationManifest(KEY, second, version="1.0.0") + manifest.record_file(".sample-agent/skills/sample.md", "sample output") + manifest.save() + record = read_records(first)[KEY] + trust = installer._trust_store(first) + original = trust.read_bytes() + original_trust_paths = set(trust.parent.iterdir()) + expected = json.loads(original) + identity = installer._trust_identity(second, KEY, record["files"]) + expected["grants"] = sorted(set(expected["grants"]) | {identity}) + if record_ownership: + expected["recovery"][installer._recovery_identity(second, KEY)] = { + "package": identity, + "files": record["files"], + "registrar_config": record["registrar_config"], + "paths": sorted(manifest.files), + } + content = json.dumps(expected, indent=2) + "\n" + monkeypatch.setattr(installer, "_MAX_TRUST_STATE_BYTES", len(content.encode("utf-8")) + limit_offset, raising=False) + if limit_offset < 0: + with pytest.raises(IntegrationInstallError, match="trust registry exceeds size limit"): + installer._grant_trust(second, KEY, record, record_ownership=record_ownership) + assert trust.read_bytes() == original + else: + installer._grant_trust(second, KEY, record, record_ownership=record_ownership) + assert trust.read_text() == content + assert identity in installer._read_trust(trust) + assert load_installed_integrations(first) == [KEY] + assert set(trust.parent.iterdir()) == original_trust_paths + + +def test_install_trust_limit_failure_rolls_back_without_disabling_existing_adapter(tmp_path, server, monkeypatch): + from specify_cli.integrations import installer + from specify_cli.integrations.manifest import IntegrationManifest + + publish(server) + first = catalog_project(tmp_path, server) + install(first) + second = catalog_project(tmp_path / "second", server) + record = read_records(first)[KEY] + trust = installer._trust_store(first) + expected = json.loads(trust.read_text()) + identity = installer._trust_identity(second, KEY, record["files"]) + expected["grants"] = sorted(set(expected["grants"]) | {identity}) + expected["recovery"][installer._recovery_identity(second, KEY)] = { + "package": identity, + "files": record["files"], + "registrar_config": record["registrar_config"], + "paths": sorted(IntegrationManifest.load(KEY, first).files), + } + limit = len((json.dumps(expected, indent=2) + "\n").encode("utf-8")) - 1 + monkeypatch.setattr(installer, "_MAX_TRUST_STATE_BYTES", limit, raising=False) + before = snapshot(second) + result = run(second, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "trust registry exceeds size limit" in " ".join(result.output.split()) + assert snapshot(second) == before + assert KEY not in read_records(second) + assert load_installed_integrations(first) == [KEY] + assert installer._recovery_identity(first, KEY) in installer._read_trust_state(trust)["recovery"] + + +@pytest.mark.parametrize("consumer", ["retained-registrar", "preset-registration"]) +@pytest.mark.parametrize("interleaved", [False, True]) +@pytest.mark.parametrize("lazy_import", [False, True]) +def test_registration_keeps_requested_project_hooks(tmp_path, server, monkeypatch, consumer, interleaved, lazy_import): + from specify_cli.presets import PresetManager + + def package(marker, folder): + hook = ( + " from .helper import MARKER\n" + if lazy_import else f" MARKER = {marker!r}\n" + ) + code = implementation( + flavor="markdown", folder=folder, + body=" def post_process_command_content(self, content):\n" + hook + " return content + '\\n' + MARKER + '\\n'\n", + ) + publish(server, code=code, members={"helper.py": f"MARKER = {marker!r}\n".encode()}) + + package("FIRST PROJECT ADAPTER", ".sample-agent") + first = catalog_project(tmp_path, server) + install(first) + registrar = CommandRegistrar(first) + package("SECOND PROJECT ADAPTER", ".other-agent") + second = catalog_project(tmp_path / "second", server) + install(second) + source = tmp_path / "source" + source.mkdir() + (source / "command.md").write_text("---\ndescription: Sample command\n---\nBody\n") + if consumer == "retained-registrar": + if not interleaved: + load_installed_integrations(first) + registrar.register_commands( + KEY, [{"name": "speckit.sample", "file": "command.md"}], + "sample", source, first, + ) + else: + manager = PresetManager(first) + original = manager._command_registrar + + def snapshot_then_interleave(): + scoped_registrar = original() + if interleaved: + load_installed_integrations(second) + return scoped_registrar + + monkeypatch.setattr(manager, "_command_registrar", snapshot_then_interleave) + manifest = SimpleNamespace(id="sample", templates=[{ + "type": "command", "name": "speckit.sample", "file": "command.md", + }]) + manager._register_commands(manifest, source) + output = (first / ".sample-agent/commands/speckit.sample.md").read_text() + assert "FIRST PROJECT ADAPTER" in output + assert "SECOND PROJECT ADAPTER" not in output + assert not (first / ".other-agent").exists() + + +@pytest.mark.parametrize("consumer", ["extension", "preset"]) +def test_skill_directory_uses_pinned_project_configuration(tmp_path, server, monkeypatch, consumer): + import specify_cli + from specify_cli.extensions import ExtensionManager + from specify_cli.presets import PresetManager + + publish(server) + first = catalog_project(tmp_path, server) + install(first) + publish(server, code=implementation(folder=".other-agent")) + second = catalog_project(tmp_path / "second", server) + install(second) + original = specify_cli.resolve_active_skills_dir + + def interleave_before_directory_resolution(root): + load_installed_integrations(second) + return original(root) + + monkeypatch.setattr(specify_cli, "resolve_active_skills_dir", interleave_before_directory_resolution) + manager = ExtensionManager(first) if consumer == "extension" else PresetManager(first) + assert manager._get_skills_dir() == first / ".sample-agent/skills" + assert not (first / ".other-agent").exists() + + +@pytest.mark.parametrize("consumer", ["retained-registrar", "preset-registration"]) +def test_registration_pins_hooks_and_lazy_imports_during_another_thread_load(tmp_path, server, monkeypatch, consumer): + from specify_cli.integrations import installer + from specify_cli.presets import PresetManager + + body = ''' def post_process_command_content(self, content): + from specify_cli.integrations import get_integration, installer + callback = getattr(installer, "_review_registration_interleave", None) + if callback is not None: + callback() + assert get_integration(self.key) is self, "wrong project registry" + from .helper import MARKER + return content + "\\n" + MARKER + "\\n" +''' + publish(server, code=implementation(flavor="markdown", body=body), members={ + "helper.py": b"MARKER = 'FIRST PROJECT ADAPTER'\n", + }) + first = catalog_project(tmp_path, server) + install(first) + publish(server, code=implementation(flavor="markdown", folder=".other-agent")) + second = catalog_project(tmp_path / "second", server) + install(second) + source = tmp_path / "source" + source.mkdir() + (source / "command.md").write_text("---\ndescription: Sample command\n---\nBody\n") + + def interleave(): + errors = [] + + def load_second(): + try: + load_installed_integrations(second) + except Exception as exc: + errors.append(exc) + + thread = threading.Thread(target=load_second) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive() + assert not errors + + monkeypatch.setattr(installer, "_review_registration_interleave", interleave, raising=False) + if consumer == "retained-registrar": + registrar = CommandRegistrar(first) + registrar.register_commands( + KEY, [{"name": "speckit.sample", "file": "command.md"}], + "sample", source, first, + ) + else: + manager = PresetManager(first) + manifest = SimpleNamespace(id="sample", templates=[{ + "type": "command", "name": "speckit.sample", "file": "command.md", + }]) + manager._register_commands(manifest, source) + output = (first / ".sample-agent/commands/speckit.sample.md").read_text() + assert "FIRST PROJECT ADAPTER" in output + assert not (first / ".other-agent").exists() + + +def test_retained_registrar_rechecks_revoked_local_consent_before_rendering(tmp_path, server): + from specify_cli.integrations import installer + + publish(server, code=implementation(flavor="markdown")) + project = catalog_project(tmp_path, server) + install(project) + registrar = CommandRegistrar(project) + source = tmp_path / "source" + source.mkdir() + (source / "command.md").write_text("---\ndescription: Sample command\n---\nBody\n") + trust = installer._trust_store(project) + data = json.loads(trust.read_text()) + data["grants"] = [] + trust.write_text(json.dumps(data)) + before = snapshot(project) + with pytest.raises(IntegrationInstallError, match="no local trust decision"): + registrar.register_commands( + KEY, [{"name": "speckit.sample", "file": "command.md"}], + "sample", source, project, + ) + assert snapshot(project) == before diff --git a/tests/test_shared_infra_gitignore.py b/tests/test_shared_infra_gitignore.py index 4badeaa8e4..1a639d9cea 100644 --- a/tests/test_shared_infra_gitignore.py +++ b/tests/test_shared_infra_gitignore.py @@ -58,16 +58,23 @@ def test_git_ignores_the_intended_paths(tmp_path: Path) -> None: ext_local = project / ".specify" / "extensions" / "git" / "local-config.yml" ext_local.parent.mkdir(parents=True, exist_ok=True) ext_local.write_text("x\n", encoding="utf-8") + packages = project / ".specify/integrations/packages/sample-agent" + packages.mkdir(parents=True) + (packages / "__init__.py").write_text("# sample adapter\n") + (packages.parent.parent / "packages.json").write_text("{}") for rel in ( ".specify/feature.json", ".specify/extensions/git/local-config.yml", + ".specify/integrations/packages/sample-agent/__init__.py", + ".specify/integrations/packages.json", ): result = subprocess.run( ["git", "check-ignore", rel], cwd=project, capture_output=True, text=True, + check=False, ) assert result.returncode == 0, f"{rel} was not ignored" @@ -77,6 +84,7 @@ def test_git_ignores_the_intended_paths(tmp_path: Path) -> None: cwd=project, capture_output=True, text=True, + check=False, ) assert tracked.returncode == 1