diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3db2bad0fa..f49a377704 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -156,6 +156,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 e0424f8a2e..b110bbafca 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,13 +78,300 @@ 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 and `specify check` probe required descriptor tools on +PATH. If a CLI adapter declares no required tools, `specify check` probes its +runtime executable rather than assuming its catalog ID is an executable. +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. Display names are rendered as literal text in discovery and lifecycle output; +brackets and Rich-like tags are valid name content, not console formatting. +`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. Every explicitly declared host keyword +parameter must have a default; accepting omitted keywords through `**kwargs` +is also supported. Validation checks both prompt-only and complete host calls. +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. The writer +persists the exact measured UTF-8 bytes, without platform newline translation. +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. +Preset and extension package installation/replacement, configuration rescue +directories, and project extension configuration participate in the journal. +Package replacements keep one bounded directory snapshot rather than duplicate +snapshots for every generated descendant. Replacement observations complete at +the actual copy/config-restore boundary, including failed copies, before command +registration or registry commit. Later observed descendant writes update that +directory's recorded identity without taking duplicate snapshots. +External init aborts its transaction when a requested preset or extension install +raises; built-in init without an external transaction retains its best-effort +optional-install behavior. Late external-init failures therefore +remove new packages and restore pre-existing package contents, not orphan sources +after their registries and agent outputs have been rolled back. +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 includes empty Kimi legacy directories removed during migration or teardown; +non-empty legacy directories are not snapshotted merely for parent cleanup. +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. Manifest recording validates lexical paths before +resolution, including project-local and dangling symlinks, so resolution cannot +hide a link to an unrelated project file. Removing an owned leaf symlink unlinks +the link itself without following its target; declared output directories cannot +be symlinks. Manifest cleanup validates lexical ancestors before reading or +deleting a tracked file and immediately before unlinking it, even without an +active lifecycle journal. Unsafe paths are reported as preserved files rather +than followed through a symlink. +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. +Generated-file manifests distinguish `whole`, `partial`, and `shared` +ownership. The event dispatcher is shared; merged native event settings are +partial. Trusted host event refreshes update only their touched ownership +claims in the user-local record, and active lifecycle transactions defer those +updates until their project writes commit. Editable project manifests cannot +add unrelated cleanup authority or promote shared settings to whole-file +ownership. Damaged fallback cleanup preserves shared and partial files, +including with `--force`; manual event cleanup may be needed. Older local +records without ownership modes conservatively preserve unproven files. +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. +Forced recovery also accepts a recorded package leaf replaced by a regular file +or a symlink: it unlinks that owned leaf without following it, while rejecting +symlinked ancestors. Failed commits restore the original damaged leaf. +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 `.specify/integrations/.manifest.json`. Custom `setup()` code must track files it creates via the manifest (`record_file()` or the base class's -write-and-record helpers). Do not track a pre-existing user file merely because -you merged settings into it: unchanged tracked files are deleted on uninstall. +write-and-record helpers). These APIs accept `ownership="whole"` (the default +for generated files), `"partial"`, or `"shared"` where applicable; recording +an existing file without a new mode retains its current mode. Do not claim +whole-file ownership merely because you merged settings into a user file: +partial and shared files are preserved by manifest-only uninstall. `teardown()` preserves modified tracked files by default; `--force` can remove them. Keep agent-specific settings and events consistent with that lifecycle. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 1e816d49ff..df113a74be 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,82 @@ 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. + +`specify check` probes an external adapter's required descriptor tools rather +than its catalog ID. With no required tools declared, CLI adapters are checked +using their runtime executable; optional tools do not produce missing-tool +errors. These checks do not run the executable or invoke generic version probes. + +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 +270,47 @@ 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. +Shared event dispatchers and partially owned native settings are preserved +during damaged-adapter fallback cleanup, even with `--force`. Recovery uses +the user-local ownership modes, not editable project claims. Successful event +refreshes keep that local record current; older records without modes preserve +unproven files rather than risking user-data deletion. +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 +336,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 e60653dfe0..c9e6cf2a65 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -54,6 +54,14 @@ 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 creating or loading run state use a pre-run +failure envelope: a new run has no run ID, while resume retains the supplied +run ID but has no workflow or step context. After run state exists, adapter +failures from per-step reloads or verified lazy imports retain the actual run, +workflow, and current-step fields, just like execution I/O 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 de09603b4f..544ef1cfef 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/_operation_info.py b/src/specify_cli/artifacts/_operation_info.py index 894fa88a1e..1e1434b980 100644 --- a/src/specify_cli/artifacts/_operation_info.py +++ b/src/specify_cli/artifacts/_operation_info.py @@ -201,10 +201,13 @@ def __init__(self, identifier: str, message: str) -> None: class ArtifactInfoResolutionError(ArtifactInfoError): """Artifact composition could not be resolved.""" - def __init__(self, project_directory: Path, identifier: str) -> None: + def __init__( + self, project_directory: Path, identifier: str, *, + message: str = "artifact resolution failed", + ) -> None: super().__init__( code="artifact_resolution_failed", - message="artifact resolution failed", + message=message, details={ "project_directory": str(project_directory), "identifier": identifier, @@ -521,7 +524,11 @@ def get_artifact_info(request: ArtifactInfoRequest) -> ArtifactInfoResult: if _requests_hook(request.identifier, request.kind): raise ArtifactInfoHookNotFoundError(request.identifier) from exc raise ArtifactInfoNotFoundError(request.identifier) from exc - except (ArtifactResolutionError, OSError, PresetError) as exc: + except ArtifactResolutionError as exc: + raise ArtifactInfoResolutionError( + project_directory, request.identifier, message=exc.message, + ) from exc + except (OSError, PresetError) as exc: raise ArtifactInfoResolutionError( project_directory, request.identifier, 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..cdcc7fd6e2 100644 --- a/src/specify_cli/command_check.py +++ b/src/specify_cli/command_check.py @@ -2,15 +2,58 @@ from __future__ import annotations +import shutil +from pathlib import Path +from typing import Any + import typer +from rich.markup import escape from ._agent_config import AGENT_CONFIG from ._console import StepTracker, console, show_banner +def _check_external_tools( + project_root: Path, key: str, record: dict[str, Any], requires_cli: bool, tracker: StepTracker, +) -> bool: + from .integrations import get_integration + from .integrations.installer import IntegrationInstallError + + integration = get_integration(key) + if integration is None: + raise IntegrationInstallError(f"Installed integration '{key}' is not registered") + tools = [ + tool["name"] for tool in record["requires"].get("tools", []) + if tool.get("required", True) + ] + if not tools and requires_cli: + args = integration.build_exec_args("test", project_root=project_root) + tools = [args[0] if args else integration._resolve_executable()] + if not tools: + tracker.skip(key, "IDE-based, no CLI check") + return False + tools = list(dict.fromkeys(tools)) + missing = [tool for tool in tools if shutil.which(tool) is None] + if missing: + tracker.error(key, "not found: " + escape(", ".join(missing))) + return False + tracker.complete(key, "available: " + escape(", ".join(tools))) + return True + + 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, read_records + + try: + project_root = _resolve_init_dir_override() or Path.cwd() + load_installed_integrations(project_root) + records = read_records(project_root) + 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") @@ -24,9 +67,19 @@ def check() -> None: agent_name = agent_config["name"] requires_cli = agent_config["requires_cli"] - tracker.add(agent_key, agent_name) - - if requires_cli: + tracker.add(agent_key, escape(agent_name)) + + if agent_key in records: + try: + agent_results[agent_key] = _check_external_tools( + project_root, agent_key, records[agent_key], requires_cli, tracker, + ) + except (ValueError, OSError, NotImplementedError) as exc: + console.print( + f"[red]Error:[/red] Cannot check integration '{escape(agent_key)}': {escape(str(exc))}" + ) + raise typer.Exit(1) from exc + elif requires_cli: agent_results[agent_key] = check_tool(agent_key, tracker=tracker) else: tracker.skip(agent_key, "IDE-based, no CLI check") diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index c2630f0ef8..605dd7ed92 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_owns_rollback, 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" ) @@ -871,6 +889,8 @@ def init( catalog_name=pack_info.get("_catalog_name"), ) except PresetError as preset_err: + if lifecycle_owns_rollback(): + raise _print_cli_warning( "install", "preset", @@ -885,6 +905,8 @@ def init( except OSError: pass except Exception as preset_err: + if lifecycle_owns_rollback(): + raise _print_cli_warning( "install", "preset", @@ -919,6 +941,8 @@ def init( tracker.complete(f"extension-{i}", status_msg) any_extension_installed = True except Exception as ext_err: + if lifecycle_owns_rollback(): + raise sanitized_ext = str(ext_err).replace("\n", " ").strip() tracker.error( f"extension-{i}", @@ -966,7 +990,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 +1001,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..0d1aa3a0a1 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,11 +1396,12 @@ 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)), dispatcher_path.read_bytes(), + ownership="shared", ) created.append(dispatcher_path) @@ -1409,6 +1412,7 @@ def install_integration_events( return created config_path = project_root / config_file + manifest.set_ownership(config_file, "partial") if fmt == "ts-plugin": # Opencode TS plugin custom merge @@ -1416,7 +1420,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", ) @@ -1432,8 +1437,7 @@ def install_integration_events( # user's untouched file. if _merge_opencode_plugin_ref(config_path, f"./{plugin_rel}"): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) elif fmt == "copilot-json": @@ -1472,8 +1476,7 @@ def install_integration_events( # S5: only track when the merge wrote (skips on JSONC/malformed). if _merge_copilot_json(config_path, copilot_hooks): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) elif fmt == "toml": @@ -1497,8 +1500,7 @@ def install_integration_events( # S5: only track when the merge wrote (skips on unreadable file). if _merge_toml_fragment(config_path, "\n".join(lines)): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) elif fmt == "toml-vibe": @@ -1546,8 +1548,7 @@ def install_integration_events( # S5: only track when the merge wrote (skips on unreadable file). if _merge_vibe_toml_fragment(config_path, "\n".join(lines)): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) elif fmt == "json-flat": @@ -1575,8 +1576,7 @@ def install_integration_events( # S5: only track when the merge wrote (skips on JSONC/malformed). if _merge_json_fragment(config_path, cursor_hooks, version=1): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) elif fmt == "json-nested": @@ -1612,8 +1612,7 @@ def install_integration_events( # S5: only track when the merge wrote (skips on JSONC/malformed). if _merge_json_fragment(config_path, nested_hooks): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) elif fmt == "json-root-nested": @@ -1642,8 +1641,7 @@ def install_integration_events( ] if _merge_json_root(config_path, root_hooks): rel = str(config_path.relative_to(project_root)) - if rel not in manifest.files: - manifest.record_existing(rel) + manifest.record_existing(rel, ownership="partial") created.append(config_path) return created @@ -1747,7 +1745,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 +1768,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 +1824,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 @@ -1874,8 +1883,15 @@ def refresh_integration_events(project_root: Path) -> None: # install_integration_events handles both the populated case # (writes new config, stripping stale owned entries) and the empty # case (strips prior hooks for --events false / disabled override). - install_integration_events(integration, project_root, manifest, events_map) + written = install_integration_events(integration, project_root, manifest, events_map) manifest.save() + from ..integrations.installer import update_event_recovery_ownership + + touched = {path.relative_to(project_root).as_posix() for path in written} + config = getattr(integration, "events_config_file", None) + if config and config in manifest.files: + touched.add(config) + update_event_recovery_ownership(project_root, key, manifest, touched) except Exception as exc: logger.warning("Failed to refresh events for '%s': %s", key, exc) failures.append((key, str(exc))) @@ -2145,7 +2161,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 +2200,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 +2236,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 +2285,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 +2322,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 +2393,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 +2490,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 +2516,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 +2581,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 +2648,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..d03cb85770 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 @@ -2555,6 +2579,15 @@ def install_from_directory( f"extension. Install from a copy in a different location instead." ) + from ..integrations._file_changes import after_file_change, before_file_change, changing_file + + package_paths = ( + dest_dir, self.extensions_dir / ".backup" / manifest.id, + self._rescue_staging_dir(manifest.id), + ) + for package_path in package_paths: + before_file_change(package_path, removal=True) + # Remove existing installation AFTER all validations pass so that a # validation failure doesn't leave the user with a half-uninstalled # extension (configs stranded in .backup/). @@ -2565,13 +2598,17 @@ def install_from_directory( backup_config_dir = self.extensions_dir / ".backup" / manifest.id # Check is_symlink first: is_dir() follows symlinks so a # symlink-to-directory would pass, but rmtree() raises on them. - if backup_config_dir.is_symlink(): - backup_config_dir.unlink() - elif backup_config_dir.is_dir(): - shutil.rmtree(backup_config_dir) - elif backup_config_dir.exists(): - backup_config_dir.unlink() - did_remove = self.remove(manifest.id) + try: + if backup_config_dir.is_symlink(): + backup_config_dir.unlink() + elif backup_config_dir.is_dir(): + shutil.rmtree(backup_config_dir) + elif backup_config_dir.exists(): + backup_config_dir.unlink() + did_remove = self.remove(manifest.id) + finally: + after_file_change(dest_dir) + after_file_change(backup_config_dir) # Load and validate .extensionignore BEFORE reading/creating the rescue # staging directory (and thus before deleting dest_dir). The loader can @@ -2938,11 +2975,13 @@ def _matches_source_config_baseline(config_name: str) -> bool: # proceeding destructively. shutil.rmtree(rescue_staging_dir, ignore_errors=True) raise + finally: + after_file_change(rescue_staging_dir) - # Install extension (dest_dir computed above during self-install guard) - if dest_dir.exists(): - shutil.rmtree(dest_dir) + after_file_change(rescue_staging_dir) + after_file_change(self.extensions_dir / ".backup" / manifest.id) + # Install extension (dest_dir computed above during self-install guard) def _restore_stranded_config_file( target: Path, content: bytes, preserved_mode: int ) -> None: @@ -2988,23 +3027,25 @@ def _restore_stranded_config_file( tmp_path.unlink() raise + before_file_change(dest_dir, removal=True) try: - shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) - except BaseException: - # copytree failed — dest_dir may be absent or only partially - # created. Write the rescued configs back now so they are not - # permanently lost even though the install did not complete. - if stranded_configs: - dest_dir.mkdir(parents=True, exist_ok=True) - for filename, (content, mode) in stranded_configs.items(): - target = dest_dir / filename - _restore_stranded_config_file(target, content, mode) - raise - - # Restore stranded configs rescued before the rmtree above. - for filename, (content, mode) in stranded_configs.items(): - target = dest_dir / filename - _restore_stranded_config_file(target, content, mode) + if dest_dir.exists(): + shutil.rmtree(dest_dir) + try: + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) + except BaseException: + # Restore rescued configs before completing this package mutation. + if stranded_configs: + dest_dir.mkdir(parents=True, exist_ok=True) + for filename, (content, mode) in stranded_configs.items(): + target = dest_dir / filename + _restore_stranded_config_file(target, content, mode) + raise + for filename, (content, mode) in stranded_configs.items(): + target = dest_dir / filename + _restore_stranded_config_file(target, content, mode) + finally: + after_file_change(dest_dir) # NOTE: the durable staging backup is intentionally NOT cleaned up # here. Command/skill/hook registration and the final registry.add() @@ -3119,7 +3160,8 @@ def rollback_generic_registration() -> None: or cfg_file.name.endswith("-config.local.yml") ) ): - shutil.copy2(cfg_file, dest_dir / cfg_file.name) + with changing_file(dest_dir / cfg_file.name): + shutil.copy2(cfg_file, dest_dir / cfg_file.name) elif backup_config_dir.exists(): backup_config_dir.unlink() @@ -3160,7 +3202,8 @@ def rollback_generic_registration() -> None: except Exception as error: rollback_errors.append(f"hooks: {error}") try: - rollback_generic_registration() + with changing_file(dest_dir, removal=True): + rollback_generic_registration() except Exception as error: rollback_errors.append(f"artifacts: {error}") if rollback_errors: @@ -3176,7 +3219,8 @@ def rollback_generic_registration() -> None: # Retain the backup until registry commit so failed force # reinstalls can still restore the user's configuration. try: - shutil.rmtree(backup_config_dir) + with changing_file(backup_config_dir, removal=True): + shutil.rmtree(backup_config_dir) except OSError as exc: from .. import _print_cli_warning @@ -3196,9 +3240,10 @@ def rollback_generic_registration() -> None: # crash mid-cleanup cannot leave a staging dir that a retry would # wrongly trust as a complete durable backup. try: - rescue_complete_marker.unlink(missing_ok=True) - _fsync_directory(rescue_staging_dir) - shutil.rmtree(rescue_staging_dir) + with changing_file(rescue_staging_dir, removal=True): + rescue_complete_marker.unlink(missing_ok=True) + _fsync_directory(rescue_staging_dir) + shutil.rmtree(rescue_staging_dir) _fsync_directory(rescue_staging_dir.parent) except OSError: pass # Best-effort; install already committed to the registry. @@ -3518,7 +3563,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 +3853,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 +4390,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 @@ -5713,8 +5759,11 @@ def save_project_config(self, config: Dict[str, Any]): Args: config: Configuration dictionary to save """ + from ..integrations._file_changes import write_text + self.config_file.parent.mkdir(parents=True, exist_ok=True) - self.config_file.write_text( + write_text( + self.config_file, yaml.dump( config, default_flow_style=False, sort_keys=False, allow_unicode=True ), 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..c7d7d78b5f --- /dev/null +++ b/src/specify_cli/integrations/_lifecycle.py @@ -0,0 +1,598 @@ +"""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 _matches_directory_before_write(current: Any, expected: Any, parents: tuple[str, ...]) -> bool: + """Allow only new empty directories on the destination's parent chain.""" + if current == expected: + return True + if ( + not parents or current is None or expected is None + or current[0] != "directory" or expected[0] != "directory" + ): + return False + current_entries = dict(current[1]) + expected_entries = dict(expected[1]) + name = parents[0] + if name in current_entries and _matches_directory_before_write( + current_entries[name], expected_entries.get(name, ("directory", ())), parents[1:], + ): + if name in expected_entries: + current_entries[name] = expected_entries[name] + else: + del current_entries[name] + return current_entries == expected_entries + + +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) + for parent in path.parents: + if parent not in self.changes: + continue + if parent in self.pending: + return + saved, identity = self.changes[parent] + if identity is not None and identity[0] == "directory": + if before: + if not _matches_directory_before_write( + _file_identity(parent), identity, path.relative_to(parent).parts[:-1], + ): + raise installer.IntegrationInstallError( + f"Integration output changed during the operation; refusing to overwrite {path}" + ) + self.pending.add(path) + else: + self.changes[parent] = (saved, _file_identity(parent)) + self.pending.discard(path) + return + 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 or any(path in pending.parents for pending 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", ".specify/presets", ".specify/extensions", + ".specify/extensions.yml", ".specify/workflows/speckit", + } + 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: + with installer.event_recovery_transaction(): + 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." + ) + elif recovery_token is not None and recovery_binding is not None: + from .manifest import IntegrationManifest + + path = root / ".specify/integrations" / f"{key}.manifest.json" + if path.exists(): + manifest = IntegrationManifest.load(key, root) + modes = recovery_binding.get("ownership_modes", {}) + preserved = [] + for relative in manifest.files: + mode = modes.get(relative, "shared") + manifest.set_ownership(relative, mode) + if mode != "whole": + preserved.append(relative) + manifest.save() + if preserved: + console.print( + "[yellow]Warning:[/yellow] Preserving shared, partial, or unproven " + f"file ownership: {escape(str(preserved))}. Manual event cleanup may be needed." + ) + 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..3983edc45b 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 {} - 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]") + 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 = _rich_escape(str(cfg.get("name", integration_id))) + console.print(f"\n[bold cyan]{name}[/bold cyan] ({safe_integration_id})") + 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..c6b8e3e728 100644 --- a/src/specify_cli/integrations/command_install.py +++ b/src/specify_cli/integrations/command_install.py @@ -4,6 +4,7 @@ import os import typer +from rich.markup import escape from .._console import console from ..integration_runtime import ( @@ -18,15 +19,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 +172,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 +198,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 '{escape(str(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..540de82d35 100644 --- a/src/specify_cli/integrations/command_list.py +++ b/src/specify_cli/integrations/command_list.py @@ -1,13 +1,15 @@ """The ``specify integration list`` command.""" from __future__ import annotations - import typer +from rich.markup import escape 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 +21,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,19 +59,19 @@ 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( eid, - entry.get("name", eid), + escape(str(entry.get("name", eid))), entry.get("version", ""), cat_name, status, @@ -102,7 +104,7 @@ def integration_list( status = "" cli_req = "yes" if requires_cli else "no (IDE)" safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" - table.add_row(key, name, status, cli_req, safe) + table.add_row(key, escape(str(name)), status, cli_req, safe) console.print(table) 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..39139fb393 100644 --- a/src/specify_cli/integrations/command_switch.py +++ b/src/specify_cli/integrations/command_switch.py @@ -4,6 +4,7 @@ import os import typer +from rich.markup import escape from .._console import console from ..integration_runtime import invoke_prefix_for_integration as _invoke_prefix_for_integration, invoke_separator_for_integration as _invoke_separator_for_integration @@ -14,16 +15,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 +119,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 +291,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 +370,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 '{escape(str(name))}'") diff --git a/src/specify_cli/integrations/command_uninstall.py b/src/specify_cli/integrations/command_uninstall.py index 0bd37ad7d1..c217f0e94a 100644 --- a/src/specify_cli/integrations/command_uninstall.py +++ b/src/specify_cli/integrations/command_uninstall.py @@ -3,15 +3,18 @@ import typer +from rich.markup import escape from .._console import console 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 +119,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 '{escape(str(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..004efa69af 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -5,6 +5,7 @@ from pathlib import PurePath import typer +from rich.markup import escape from .._console import console from ..integration_runtime import ( @@ -22,15 +23,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. @@ -299,6 +303,7 @@ def integration_upgrade( if stale_keys: stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_manifest._ownership_modes = {k: old_manifest.ownership_modes[k] for k in stale_keys} # remove_manifest=False: this throwaway manifest shares ``key`` with the # real one just saved above (new_manifest.save()). Letting uninstall() # delete ``{key}.manifest.json`` would wipe the freshly-written manifest @@ -353,4 +358,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 '{escape(str(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..b621a04bbf --- /dev/null +++ b/src/specify_cli/integrations/installer.py @@ -0,0 +1,1039 @@ +"""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 dataclasses import dataclass +from functools import wraps +from pathlib import Path, PureWindowsPath +from typing import TYPE_CHECKING, 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 + +if TYPE_CHECKING: + from .manifest import IntegrationManifest + + +@dataclass(frozen=True) +class _EventRecoveryUpdate: + root: Path + key: str + paths: tuple[str, ...] + modes: dict[str, str] + touched: frozenset[str] + + +_event_recovery_updates: ContextVar[list[_EventRecoveryUpdate] | None] = ContextVar( + "integration_event_recovery_updates", default=None, +) + +_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 + temporary = None + try: + 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") + os.replace(temporary, path) + after_file_change(path) + finally: + if temporary is not None: + 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]: + from .manifest import OWNERSHIP_MODES + + 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") + modes = binding.get("ownership_modes", {}) + if ( + not isinstance(modes, dict) + or not set(modes) <= set(binding["paths"]) + or any(not isinstance(mode, str) or mode not in OWNERSHIP_MODES for mode in modes.values()) + ): + raise IntegrationInstallError("Invalid integration local recovery ownership modes") + 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 _write_trust_state(path: Path, data: dict[str, Any]) -> None: + content = (json.dumps(data, indent=2) + "\n").encode("utf-8") + if len(content) > _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="wb", 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 _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: + manifest = IntegrationManifest.load(key, root) + data["recovery"][_recovery_identity(root, key)] = { + "package": identity, + "files": dict(record["files"]), + "registrar_config": record["registrar_config"], + "paths": sorted(manifest.files), + "ownership_modes": manifest.ownership_modes, + } + _write_trust_state(path, data) + + +def _recovery_binding( + root: Path, key: str, record: dict[str, Any], trust: dict[str, Any], +) -> dict[str, Any] | None: + 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) + return binding + + +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 + + binding = _recovery_binding(root, key, record, _read_trust_state(_trust_store(root))) + if binding is None: + return None + config = binding["registrar_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: + safe_project_path(root, relative, allow_leaf_symlink=True) + if binding.get("ownership_modes", {}).get(relative) == "whole": + validate_cleanup(relative, leaf=True) + return binding + + +def update_event_recovery_ownership( + root: Path, key: str, manifest: IntegrationManifest, touched: set[str], +) -> None: + """Advance only ownership touched by a verified host event refresh.""" + from . import get_integration + + root = root.resolve() + record = read_records(root).get(key) + integration = get_integration(key) + source = _source_packages.get(_namespace(integration)) if integration is not None else None + if record is None or source is None or source[1] != record["files"]: + return + update = _EventRecoveryUpdate( + root, key, tuple(manifest.files), manifest.ownership_modes, frozenset(touched), + ) + pending = _event_recovery_updates.get() + if pending is not None: + pending.append(update) + else: + _commit_event_recovery_updates([update]) + + +def _commit_event_recovery_updates(updates: list[_EventRecoveryUpdate]) -> None: + if not updates: + return + from ..shared_infra import _exclusive_project_lock + + path = _trust_store(updates[0].root) + with _exclusive_project_lock(path.parent.parent, ".integration-trust.lock", context="integration trust"): + data = _read_trust_state(path) + changed = False + for update in updates: + record = read_records(update.root).get(update.key) + if record is None: + continue + hashes = package_hashes(safe_project_path(update.root, f"{_PACKAGES}/{update.key}")) + if hashes != record["files"] or _trust_identity(update.root, update.key, hashes) not in data["grants"]: + raise IntegrationInstallError(f"Integration '{update.key}' event ownership requires its trusted package") + binding = _recovery_binding(update.root, update.key, record, data) + if binding is None: + continue + if binding["package"] != _trust_identity(update.root, update.key, hashes): + raise IntegrationInstallError(f"Integration '{update.key}' event recovery package identity differs") + if set(update.paths) - set(binding["paths"]) - update.touched: + raise IntegrationInstallError(f"Integration '{update.key}' manifest recovery ownership has been modified") + for relative in update.paths: + safe_project_path(update.root, relative, allow_leaf_symlink=True) + modes = { + relative: mode for relative, mode in binding.get("ownership_modes", {}).items() + if relative in update.paths + } + for relative in update.touched & set(update.paths): + modes[relative] = update.modes[relative] + data["recovery"][_recovery_identity(update.root, update.key)] = { + **binding, "paths": sorted(update.paths), "ownership_modes": modes, + } + changed = True + if changed: + _write_trust_state(path, data) + + +@contextmanager +def event_recovery_transaction(): + """Commit host ownership updates only when project writes succeed.""" + if _event_recovery_updates.get() is not None: + yield + return + updates: list[_EventRecoveryUpdate] = [] + token = _event_recovery_updates.set(updates) + try: + yield + _commit_event_recovery_updates(updates) + finally: + _event_recovery_updates.reset(token) + + +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") + 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}", allow_leaf_symlink=True) + 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, removal=True) + if destination.is_symlink() or destination.is_file(): + destination.unlink() + elif 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}", allow_leaf_symlink=True) + before_file_change(directory, removal=True) + try: + if directory.is_symlink() or directory.is_file(): + directory.unlink() + else: + 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..07142db854 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -146,19 +146,30 @@ 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) - try: - old_skills_dir.rmdir() - except OSError: - pass + _remove_empty_legacy_directory(old_skills_dir) return removed, skipped +def _remove_empty_legacy_directory(path: Path) -> None: + from .._file_changes import changing_file + + try: + if not any(path.iterdir()): + with changing_file(path, removal=True): + path.rmdir() + except OSError: + pass + + def _has_symlinked_component(path: Path, project_root: Path) -> bool: """Return ``True`` when *path* escapes *project_root* or any component is a symlink. @@ -258,7 +269,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,16 +292,16 @@ 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 # Remove the legacy skills directory if it is now empty. - try: - old_skills_dir.rmdir() - except OSError: - pass + _remove_empty_legacy_directory(old_skills_dir) return (migrated_count, removed_count) diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py index bde83f000f..cd1f430c43 100644 --- a/src/specify_cli/integrations/manifest.py +++ b/src/specify_cli/integrations/manifest.py @@ -16,6 +16,15 @@ from pathlib import Path from typing import Any +from ._file_changes import after_file_change, before_file_change + +OWNERSHIP_MODES = frozenset({"whole", "partial", "shared"}) + + +def _validate_ownership_mode(mode: str) -> None: + if not isinstance(mode, str) or mode not in OWNERSHIP_MODES: + raise ValueError(f"Invalid manifest ownership mode: {mode!r}") + def _sha256(path: Path) -> str: """Return the hex SHA-256 digest of *path*.""" @@ -48,12 +57,45 @@ def _validate_rel_path(rel: Path, root: Path) -> Path: return resolved +def _validate_record_path(rel: Path, root: Path) -> Path: + """Reject noncanonical and symlinked lexical paths before resolution.""" + if rel.is_absolute() or ".." in rel.parts: + _validate_rel_path(rel, root) + raise ValueError( + f"Manifest paths must be canonical; '..' segments are not " + f"allowed (got {rel})" + ) + current = root + for part in rel.parts: + current = current / part + if current.is_symlink(): + raise ValueError( + f"Refusing to record symlinked manifest path: {rel} " + f"(symlinked at {current.relative_to(root).as_posix()})" + ) + return _validate_rel_path(rel, root) + + def _manifest_path_label(root: Path, path: Path) -> str: try: return path.relative_to(root).as_posix() except ValueError: return path.as_posix() +def _ensure_safe_manifest_removal(root: Path, path: Path) -> None: + """Check lexical containment and ancestors without following a leaf link.""" + try: + relative = path.relative_to(root) + except ValueError: + raise ValueError(f"Integration manifest removal escapes project root: {path}") from None + if ".." in relative.parts: + raise ValueError(f"Noncanonical integration manifest removal path: {relative}") + current = root + for part in relative.parts[:-1]: + current /= part + if current.is_symlink(): + raise ValueError(f"Refusing deletion through symlinked manifest directory: {current}") + def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None: """Create a manifest directory without following symlinked parents.""" @@ -127,6 +169,7 @@ def __init__( ) self.version = version self._files: dict[str, str] = {} # rel_path → sha256 hex + self._ownership_modes: dict[str, str] = {} self._recovered_files: set[str] = set() self._installed_at: str = "" @@ -139,7 +182,9 @@ def manifest_path(self) -> Path: # -- Recording files -------------------------------------------------- - def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: + def record_file( + self, rel_path: str | Path, content: bytes | str, *, ownership: str = "whole", + ) -> Path: """Write *content* to *rel_path* (relative to project root) and record its hash. Creates parent directories as needed. Returns the absolute path @@ -148,11 +193,15 @@ def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: ``record_existing(recovered=True)``, the recovered marker is cleared because the bytes are now produced, not merely observed. - Raises ``ValueError`` if *rel_path* resolves outside the project root. + Raises ``ValueError`` if *rel_path* is noncanonical, symlinked, or + resolves outside the project root. """ + _validate_ownership_mode(ownership) rel = Path(rel_path) - abs_path = _validate_rel_path(rel, self.project_root) + abs_path = _validate_record_path(rel, self.project_root) + before_file_change(abs_path) abs_path.parent.mkdir(parents=True, exist_ok=True) + _ensure_safe_manifest_destination(self.project_root, abs_path) if isinstance(content, str): content = content.encode("utf-8") @@ -160,12 +209,16 @@ def record_file(self, rel_path: str | Path, content: bytes | str) -> Path: normalized = abs_path.relative_to(self.project_root).as_posix() self._files[normalized] = hashlib.sha256(content).hexdigest() + self._ownership_modes[normalized] = ownership # ``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: + def record_existing( + self, rel_path: str | Path, *, recovered: bool = False, ownership: str | None = None, + ) -> None: """Record the hash of an already-existing regular file at *rel_path*. When ``recovered=True``, the path is also marked in the manifest's @@ -188,41 +241,18 @@ def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> N subclasses such as ``PermissionError``) in addition to ``ValueError``. """ + if ownership is not None: + _validate_ownership_mode(ownership) rel = Path(rel_path) - # Cheap lexical pre-check first so absolute / parent-traversal paths - # don't trigger a filesystem stat outside the project root before - # ``_validate_rel_path`` raises. ``_validate_rel_path`` produces the - # canonical error messages used elsewhere. - if rel.is_absolute() or ".." in rel.parts: - _validate_rel_path(rel, self.project_root) - # _validate_rel_path raised for any actually-escaping path. If we reach - # here the path normalizes inside root (e.g. ``dir/../file.txt``). - # Reject anyway: manifest keys must be canonical so ``check_modified`` - # and ``uninstall`` cannot key the same file under two paths. - raise ValueError( - f"Manifest paths must be canonical; '..' segments are not " - f"allowed (got {rel})" - ) - # Walk each path component before resolution so a symlinked ancestor - # (e.g. ``linked_dir/file.txt`` where ``linked_dir`` is a symlink) - # cannot be silently followed by ``_validate_rel_path().resolve()`` - # down to a target outside the project root. ``_ensure_safe_manifest_directory`` - # uses the same pattern. - _walk = self.project_root - for part in rel.parts: - _walk = _walk / part - if _walk.is_symlink(): - raise ValueError( - f"Refusing to record symlinked manifest path: {rel} " - f"(symlinked at {_walk.relative_to(self.project_root).as_posix()})" - ) - abs_path = _validate_rel_path(rel, self.project_root) + abs_path = _validate_record_path(rel, self.project_root) if not abs_path.is_file(): raise ValueError( f"Manifest path is not a regular file: {rel}" ) normalized = abs_path.relative_to(self.project_root).as_posix() self._files[normalized] = _sha256(abs_path) + if ownership is not None: + self._ownership_modes[normalized] = ownership if recovered: self._recovered_files.add(normalized) else: @@ -231,6 +261,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. @@ -254,6 +286,7 @@ def remove(self, rel_path: str | Path) -> bool: except ValueError: return False self._recovered_files.discard(normalized) + self._ownership_modes.pop(normalized, None) return self._files.pop(normalized, None) is not None # -- Querying --------------------------------------------------------- @@ -263,6 +296,16 @@ def files(self) -> dict[str, str]: """Return a copy of the ``{rel_path: sha256}`` mapping.""" return dict(self._files) + @property + def ownership_modes(self) -> dict[str, str]: + return {path: self._ownership_modes.get(path, "whole") for path in self._files} + + def set_ownership(self, rel_path: str | Path, mode: str) -> None: + _validate_ownership_mode(mode) + relative = Path(rel_path).as_posix() + if relative in self._files: + self._ownership_modes[relative] = mode + @property def recovered_files(self) -> set[str]: """Return a copy of the set of paths recorded with ``recovered=True``. @@ -351,15 +394,16 @@ def uninstall( # Use non-resolved path for deletion so symlinks themselves # are removed, not their targets. path = root / rel - # Validate containment lexically (without following symlinks) - # by collapsing .. segments via Path resolution on the string parts. try: - normed = Path(os.path.normpath(path)) - normed.relative_to(root) + _ensure_safe_manifest_removal(root, path) except (ValueError, OSError): + skipped.append(path) continue if not path.exists() and not path.is_symlink(): continue + if self._ownership_modes.get(rel, "whole") != "whole": + skipped.append(path) + continue # Skip directories — manifest only tracks files if not path.is_file() and not path.is_symlink(): skipped.append(path) @@ -383,26 +427,37 @@ def uninstall( skipped.append(path) continue try: + before_file_change(path, removal=True) + _ensure_safe_manifest_removal(root, path) path.unlink() - except OSError: + except (ValueError, 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: try: + _ensure_safe_manifest_removal(root, parent) parent.rmdir() # only succeeds if empty - except OSError: + except (ValueError, OSError): break parent = parent.parent # Remove the manifest file itself manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json" - if remove_manifest and manifest.exists(): + if remove_manifest: + manifest_present = False try: - manifest.unlink() - except OSError: + _ensure_safe_manifest_removal(root, manifest) + manifest_present = manifest.exists() or manifest.is_symlink() + if manifest_present: + before_file_change(manifest, removal=True) + _ensure_safe_manifest_removal(root, manifest) + manifest.unlink() + after_file_change(manifest) + except (ValueError, OSError): # An undeletable manifest (read-only file, a directory left at # the path, a Windows lock) must not abort the uninstall after # the tracked files were already removed: the caller would lose @@ -414,10 +469,11 @@ def uninstall( # rmdir() raises and breaks immediately. skipped.append(manifest) parent = manifest.parent - while parent != root: + while manifest_present and parent != root: try: + _ensure_safe_manifest_removal(root, parent) parent.rmdir() - except OSError: + except (ValueError, OSError): break parent = parent.parent @@ -433,6 +489,10 @@ def save(self) -> Path: "version": self.version, "installed_at": self._installed_at, "files": self._files, + **( + {"ownership_modes": self._ownership_modes} + if self._ownership_modes else {} + ), **( {"recovered_files": sorted(self._recovered_files)} if self._recovered_files @@ -441,6 +501,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 +511,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 @@ -497,6 +559,14 @@ def load( inst.version = data.get("version", "") inst._installed_at = data.get("installed_at", "") inst._files = files + ownership = data.get("ownership_modes", {}) + if ( + not isinstance(ownership, dict) + or not set(ownership) <= set(files) + or any(not isinstance(mode, str) or mode not in OWNERSHIP_MODES for mode in ownership.values()) + ): + raise ValueError(f"Invalid integration manifest ownership modes at {path}") + inst._ownership_modes = dict(ownership) recovered = data.get("recovered_files", []) if not isinstance(recovered, list) or not all( diff --git a/src/specify_cli/presets/_manager.py b/src/specify_cli/presets/_manager.py index 5793d8512a..963bb5c640 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, @@ -389,19 +393,24 @@ def install_from_directory( self.check_compatibility(manifest, speckit_version) - if self.registry.is_installed(manifest.id): - if not force: - raise PresetError( - f"Preset '{manifest.id}' is already installed. " - f"Use 'specify preset remove {manifest.id}' first." - ) - self.remove(manifest.id) + from ..integrations._file_changes import after_file_change, before_file_change, changing_file dest_dir = self.presets_dir / manifest.id - if dest_dir.exists(): - shutil.rmtree(dest_dir) - - shutil.copytree(source_dir, dest_dir) + installed = self.registry.is_installed(manifest.id) + if installed and not force: + raise PresetError( + f"Preset '{manifest.id}' is already installed. " + f"Use 'specify preset remove {manifest.id}' first." + ) + before_file_change(dest_dir, removal=True) + try: + if installed: + self.remove(manifest.id) + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(source_dir, dest_dir) + finally: + after_file_change(dest_dir) # Pre-register the preset so that composition resolution can see it # in the priority stack when resolving composed command content. @@ -456,7 +465,8 @@ def install_from_directory( ) try: if dest_dir.exists(): - shutil.rmtree(dest_dir) + with changing_file(dest_dir, removal=True): + shutil.rmtree(dest_dir) except OSError: pass # best-effort cleanup; don't mask the original error self.registry.remove(manifest.id) @@ -708,7 +718,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 +845,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..d26741b351 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_execution( + exc: Exception, *, json_output: bool, state: Any, resume: bool = False, +): + """Report execution 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 c5b0604a7b..da27d73a16 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..01f9a79efa 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,25 @@ 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_execution( + 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: + execution_state = engine._execution_state.get() + if execution_state is None: + cli._fail_integration_load(exc, json_output=json_output, run_id=run_id) + cli._fail_workflow_execution( + exc, json_output=json_output, state=execution_state, resume=True, + ) + except OSError as exc: + cli._fail_workflow_execution( + 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..a908def637 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,13 @@ def workflow_run( else None ), ) + except IntegrationInstallError as exc: + execution_state = engine._execution_state.get() + if execution_state is None: + cli._fail_integration_load(exc, json_output=json_output) + cli._fail_workflow_execution(exc, json_output=json_output, state=execution_state) + except OSError as exc: + cli._fail_workflow_execution(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 cb0f3db790..57ed602bd1 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 @@ -955,6 +956,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. @@ -1041,6 +1054,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)) @@ -1064,6 +1079,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 @@ -1131,7 +1147,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/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 36cb30a15b..23f10ccdab 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -218,6 +218,34 @@ def test_teardown_preserves_non_utf8_user_skill(self, tmp_path): assert user_skill.read_bytes() == b"\xff\xfe" +@pytest.mark.parametrize("operation", ["migrate", "teardown"]) +@pytest.mark.parametrize("empty", [False, True]) +def test_legacy_parent_removal_observes_only_empty_directories(tmp_path, operation, empty): + from specify_cli.integrations._file_changes import file_change_observer + + legacy = tmp_path / ".kimi/skills" + legacy.mkdir(parents=True) + if not empty: + (legacy / "user-notes.txt").write_text("user notes") + observations = [] + token = file_change_observer.set( + lambda path, before, removal: observations.append((path, before, removal)) + ) + try: + if operation == "migrate": + assert _migrate_legacy_kimi_skills_dir(legacy, tmp_path / ".kimi-code/skills") == (0, 0) + else: + get_integration("kimi").teardown(tmp_path, IntegrationManifest("kimi", tmp_path)) + finally: + file_change_observer.reset(token) + if empty: + assert not legacy.exists() + assert observations == [(legacy, True, True), (legacy, False, False)] + else: + assert (legacy / "user-notes.txt").read_text() == "user notes" + assert observations == [] + + class TestKimiCommandInvocation: """Kimi dispatch must use the native ``/skill:`` slash command.""" diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index 25188ef9c6..19f6c08ac6 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -33,6 +33,83 @@ def test_record_existing(self, tmp_path): m.record_existing("existing.txt") assert m.files["existing.txt"] == _sha256(f) + @pytest.mark.parametrize("link_kind", ["leaf", "ancestor", "dangling-leaf", "dangling-ancestor"]) + @pytest.mark.parametrize("content", ["replacement", b"replacement"]) + def test_record_file_rejects_lexical_symlinks(self, tmp_path, link_kind, content): + target_dir = tmp_path / "user" + target_dir.mkdir() + target = target_dir / "target.txt" + if not link_kind.startswith("dangling"): + target.write_text("user content") + if link_kind.endswith("leaf"): + link = tmp_path / "link.txt" + relative = "link.txt" + link.symlink_to(target) + else: + link = tmp_path / "linked-dir" + relative = "linked-dir/target.txt" + link.symlink_to(target_dir if link_kind == "ancestor" else tmp_path / "missing-dir", target_is_directory=True) + before = target.read_bytes() if target.exists() else None + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="symlinked"): + m.record_file(relative, content) + assert m.files == {} + assert link.is_symlink() + assert (target.read_bytes() if target.exists() else None) == before + assert not (tmp_path / "missing-dir").exists() + + def test_record_file_rejects_noncanonical_parent_segments(self, tmp_path): + (tmp_path / "nested").mkdir() + target = tmp_path / "safe.txt" + target.write_text("user content") + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="canonical"): + m.record_file("nested/../safe.txt", "replacement") + assert target.read_text() == "user content" + assert m.files == {} + + @pytest.mark.parametrize("content", ["replacement", b"replacement"]) + def test_record_file_can_replace_a_regular_existing_file(self, tmp_path, content): + target = tmp_path / "target.txt" + target.write_text("original") + m = IntegrationManifest("test", tmp_path) + assert m.record_file("target.txt", content) == target + assert target.read_bytes() == b"replacement" + assert m.files == {"target.txt": hashlib.sha256(b"replacement").hexdigest()} + + @pytest.mark.parametrize("ownership", ["whole", "partial", "shared"]) + def test_ownership_round_trip_and_forced_uninstall(self, tmp_path, ownership): + m = IntegrationManifest("test", tmp_path) + path = m.record_file("generated.txt", "content", ownership=ownership) + m.record_existing("generated.txt") + m.save() + loaded = IntegrationManifest.load("test", tmp_path) + assert loaded.ownership_modes == {"generated.txt": ownership} + removed, skipped = loaded.uninstall(force=True) + assert path.exists() == (ownership != "whole") + assert removed == ([path] if ownership == "whole" else []) + assert skipped == ([] if ownership == "whole" else [path]) + + @pytest.mark.parametrize("ownership", ["unknown", False, [], None]) + def test_invalid_record_ownership_fails_before_writing(self, tmp_path, ownership): + m = IntegrationManifest("test", tmp_path) + with pytest.raises(ValueError, match="ownership"): + m.record_file("generated.txt", "content", ownership=ownership) + assert not (tmp_path / "generated.txt").exists() + + @pytest.mark.parametrize("ownership", [ + {"generated.txt": "unknown"}, {"untracked.txt": "whole"}, [], + ]) + def test_invalid_persisted_ownership_is_rejected(self, tmp_path, ownership): + m = IntegrationManifest("test", tmp_path) + m.record_file("generated.txt", "content") + path = m.save() + data = json.loads(path.read_text()) + data["ownership_modes"] = ownership + path.write_text(json.dumps(data)) + with pytest.raises(ValueError, match="ownership"): + IntegrationManifest.load("test", tmp_path) + class TestManifestRecordExistingErrors: """Error-case coverage for ``record_existing`` symlink + non-file guards. @@ -174,6 +251,70 @@ def test_symlink_treated_as_modified(self, tmp_path): class TestManifestUninstall: + @pytest.mark.parametrize("force", [False, True]) + @pytest.mark.parametrize("outside", [False, True]) + def test_uninstall_preserves_files_under_symlinked_ancestors(self, tmp_path, force, outside): + root = tmp_path / "project" + root.mkdir() + m = IntegrationManifest("test", root) + m.record_file("owned/file.txt", "matching content") + m.save() + (root / "owned/file.txt").unlink() + (root / "owned").rmdir() + target = (tmp_path if outside else root) / "user" + target.mkdir() + victim = target / "file.txt" + victim.write_text("matching content") + (root / "owned").symlink_to(target, target_is_directory=True) + removed, skipped = m.uninstall(force=force) + assert victim.read_text() == "matching content" + assert removed == [] + assert skipped == [root / "owned/file.txt"] + assert (root / "owned").is_symlink() + + @pytest.mark.parametrize("force", [False, True]) + def test_uninstall_rechecks_parent_after_observer(self, tmp_path, force): + from specify_cli.integrations._file_changes import file_change_observer + + root = tmp_path / "project" + root.mkdir() + m = IntegrationManifest("test", root) + m.record_file("owned/file.txt", "matching content") + victim_dir = tmp_path / "user" + victim_dir.mkdir() + victim = victim_dir / "file.txt" + victim.write_text("matching content") + + def swap_parent(path, before, removal): + if path == root / "owned/file.txt" and before: + (root / "owned").rename(root / "original") + (root / "owned").symlink_to(victim_dir, target_is_directory=True) + + token = file_change_observer.set(swap_parent) + try: + removed, skipped = m.uninstall(force=force) + finally: + file_change_observer.reset(token) + assert victim.read_text() == "matching content" + assert removed == [] + assert skipped == [root / "owned/file.txt"] + + @pytest.mark.parametrize("force", [False, True]) + def test_uninstall_preserves_manifest_under_symlinked_parent(self, tmp_path, force): + root = tmp_path / "project" + root.mkdir() + m = IntegrationManifest("test", root) + m.save() + integrations = root / ".specify/integrations" + integrations.rename(tmp_path / "user-manifests") + integrations.symlink_to(tmp_path / "user-manifests", target_is_directory=True) + victim = tmp_path / "user-manifests/test.manifest.json" + before = victim.read_bytes() + removed, skipped = m.uninstall(force=force) + assert victim.read_bytes() == before + assert removed == [] + assert skipped == [m.manifest_path] + def test_removes_unmodified(self, tmp_path): m = IntegrationManifest("test", tmp_path) m.record_file("d/f.txt", "content") diff --git a/tests/specify_cli/artifacts/test_operation_info.py b/tests/specify_cli/artifacts/test_operation_info.py index 15f5f9b551..45a5f3b96d 100644 --- a/tests/specify_cli/artifacts/test_operation_info.py +++ b/tests/specify_cli/artifacts/test_operation_info.py @@ -529,6 +529,24 @@ def test_info_converts_resolution_failures( assert exc_info.value.__cause__ is failure +def test_info_preserves_explicit_resolution_diagnostics( + spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch, +): + failure = ArtifactResolutionError("Integration 'sample-agent' installed package has been modified") + monkeypatch.setattr( + artifacts.ArtifactCatalog, "get_artifact_info", Mock(side_effect=failure), + ) + with pytest.raises(ArtifactInfoResolutionError) as exc_info: + get_artifact_info(ArtifactInfoRequest(spec_kit_project, "speckit.plan")) + assert exc_info.value.message == failure.message + assert str(exc_info.value) == failure.message + assert exc_info.value.code == "artifact_resolution_failed" + assert exc_info.value.details == { + "project_directory": str(spec_kit_project), "identifier": "speckit.plan", + } + assert exc_info.value.__cause__ is failure + + def _named_payload() -> dict[str, object]: return { "id": "template:broken", 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..43ff50634b --- /dev/null +++ b/tests/specify_cli/integrations/test_installed_adapters.py @@ -0,0 +1,4639 @@ +"""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("package_kind", ["preset", "extension"]) +def test_registered_package_force_replacement_restores_sources_and_config_on_rollback( + tmp_path, server, package_kind, +): + from specify_cli._assets import get_speckit_version + from specify_cli.extensions import ExtensionManager + from specify_cli.integration_state import try_read_integration_json + from specify_cli.integrations._lifecycle import _transaction + from specify_cli.presets import PresetManager + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + source = tmp_path / "package-source" + source.mkdir() + (source / "package.txt").write_text("original package bytes") + identity = {"id": "sample-replace", "name": "Sample Replace", "version": "1.0.0", "description": "Sample package"} + manifest = { + "schema_version": "1.0", package_kind: identity, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"commands": []} if package_kind == "extension" else {"templates": [ + {"type": "template", "name": "spec-template", "file": "package.txt"}, + ]}, + } + if package_kind == "extension": + manifest["events"] = {"session_start": {"command": "speckit.sample.boot"}} + (source / f"{package_kind}.yml").write_text(yaml.safe_dump(manifest)) + manager = ExtensionManager(project) if package_kind == "extension" else PresetManager(project) + manager.install_from_directory(source, get_speckit_version()) + destination = project / f".specify/{package_kind}s/sample-replace" + (destination / "sample-replace-config.yml").write_text("user: preserved\n") + before = snapshot(project) + original_directories = {path for path in project.rglob("*") if path.is_dir()} + (source / "package.txt").write_text("replacement package bytes") + state, error = try_read_integration_json(project) + assert error is None + with pytest.raises(OSError, match="sample late forced replacement failure"): + with _transaction(project, KEY, state, read_records(project)): + manager.install_from_directory(source, get_speckit_version(), force=True) + assert (destination / "package.txt").read_text() == "replacement package bytes" + raise OSError("sample late forced replacement failure") + assert snapshot(project) == before + assert all(path.is_dir() for path in original_directories) + + +@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]) +@pytest.mark.parametrize("windows_newlines", [False, True]) +def test_trust_grant_respects_reader_limit_before_replacement( + tmp_path, server, monkeypatch, record_ownership, limit_offset, windows_newlines, +): + from specify_cli.integrations import installer + from specify_cli.integrations.manifest import IntegrationManifest + + if windows_newlines: + original_temporary_file = installer.tempfile.NamedTemporaryFile + + def windows_temporary_file(*args, **kwargs): + if "b" not in kwargs.get("mode", "w+b") and "newline" not in kwargs: + kwargs["newline"] = "\r\n" + return original_temporary_file(*args, **kwargs) + + monkeypatch.setattr(installer.tempfile, "NamedTemporaryFile", windows_temporary_file) + 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), + "ownership_modes": manifest.ownership_modes, + } + 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 identity in installer._read_trust(trust) + assert trust.read_bytes() == content.encode("utf-8") + 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), + "ownership_modes": IntegrationManifest.load(KEY, first).ownership_modes, + } + 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 + + +@pytest.mark.parametrize("link_kind", ["leaf", "ancestor", "dangling-leaf"]) +def test_manifest_setup_rejects_project_local_symlink_targets(tmp_path, server, link_kind): + relative = ( + ".sample-agent/skills/linked-dir/target.txt" + if link_kind == "ancestor" else ".sample-agent/skills/link.txt" + ) + body = f''' def setup(self, project_root, manifest, **kwargs): + manifest.record_file({relative!r}, "replacement") + return [] +''' + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + target_dir = project / "user" + target_dir.mkdir() + target = target_dir / "target.txt" + if link_kind != "dangling-leaf": + target.write_text("user content") + link = project / ( + ".sample-agent/skills/linked-dir" if link_kind == "ancestor" + else ".sample-agent/skills/link.txt" + ) + link.parent.mkdir(parents=True) + link.symlink_to(target_dir if link_kind == "ancestor" else target, target_is_directory=link_kind == "ancestor") + before = snapshot(project) + result = run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "symlinked" in result.output.lower() + assert snapshot(project) == before + assert link.is_symlink() + assert KEY not in read_records(project) + + +@pytest.mark.parametrize("source", [ + "descriptor", "runtime", "override", "interactive", "optional-only", "ide-descriptor", +]) +@pytest.mark.parametrize("available", [False, True]) +def test_check_probes_external_adapter_tools_not_catalog_ids(tmp_path, server, monkeypatch, source, available): + metadata = descriptor() + if source in {"descriptor", "ide-descriptor"}: + metadata["requires"]["tools"] = [ + {"name": "acme", "version": ">=1.0"}, + {"name": "optional-helper", "required": False}, + ] + elif source == "optional-only": + metadata["requires"]["tools"] = [{"name": "optional-helper", "required": False}] + body = ''' + def build_exec_args(self, prompt, *, model=None, output_json=True, + integration_args=None, integration_options=None, project_root=None): + return ["acme", "-p", prompt] +''' + if source in {"descriptor", "ide-descriptor"}: + body = ''' + def build_exec_args(self, prompt, *, model=None, output_json=True, + integration_args=None, integration_options=None, project_root=None): + raise AssertionError("explicit tool requirements must not invoke the runtime builder") +''' + if source == "override": + body = "" + monkeypatch.setenv("SPECKIT_INTEGRATION_SAMPLE_AGENT_EXECUTABLE", "acme") + elif source == "interactive": + body = ''' + def build_exec_args(self, prompt, *, model=None, output_json=True, + integration_args=None, integration_options=None, project_root=None): + return None +''' + monkeypatch.setenv("SPECKIT_INTEGRATION_SAMPLE_AGENT_EXECUTABLE", "acme") + code = implementation(flavor="markdown", body=body) + if source != "ide-descriptor": + code = code.replace('"requires_cli": False', '"requires_cli": True') + publish(server, metadata=metadata, code=code) + monkeypatch.setattr(shutil, "which", lambda name, *args, **kwargs: "/sample/acme" if name == "acme" else None) + project = catalog_project(tmp_path, server) + install(project) + probes = [] + + def which(name, *args, **kwargs): + probes.append(name) + return "/sample/acme" if name == "acme" and available else None + + def no_process(*args, **kwargs): + raise AssertionError("tool checks must not execute processes") + + monkeypatch.setattr(shutil, "which", which) + monkeypatch.setattr(subprocess, "run", no_process) + monkeypatch.setattr("specify_cli._utils.CLAUDE_LOCAL_PATH", tmp_path / "absent-claude") + monkeypatch.setattr("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", tmp_path / "absent-npm-claude") + result = run(project, ["check"]) + assert result.exit_code == 0, result.output + assert "acme" in probes + assert KEY not in probes + assert "optional-helper" not in probes + assert "acme" in result.output + assert ("Tip: Install a coding agent" in result.output) == (not available) + + +@pytest.mark.parametrize("missing", ["acme", "required-helper"]) +def test_check_reports_missing_external_auxiliary_requirements(tmp_path, server, monkeypatch, missing): + metadata = descriptor() + metadata["requires"]["tools"] = [ + {"name": "acme"}, + {"name": "required-helper", "required": True}, + {"name": "acme"}, + {"name": "optional-helper", "required": False}, + ] + publish(server, metadata=metadata) + monkeypatch.setattr(shutil, "which", lambda name, *args, **kwargs: "/sample/tool") + project = catalog_project(tmp_path, server) + install(project) + probes = [] + + def which(name, *args, **kwargs): + probes.append(name) + return "/sample/tool" if name in {"acme", "required-helper"} and name != missing else None + + monkeypatch.setattr(shutil, "which", which) + result = run(project, ["check"]) + assert result.exit_code == 0, result.output + assert probes.count("acme") == 1 + assert probes.count("required-helper") == 1 + assert "optional-helper" not in probes + assert KEY not in probes + assert f"not found: {missing}" in result.output + + +def test_check_skips_optional_tools_for_external_ide_adapter(tmp_path, server, monkeypatch): + metadata = descriptor() + metadata["requires"]["tools"] = [{"name": "optional-helper", "required": False}] + publish(server, metadata=metadata) + project = catalog_project(tmp_path, server) + install(project) + probes = [] + + def which(name, *args, **kwargs): + probes.append(name) + return None + + monkeypatch.setattr(shutil, "which", which) + result = run(project, ["check"]) + assert result.exit_code == 0, result.output + assert KEY not in probes + assert "optional-helper" not in probes + assert "Sample Agent" in result.output + assert "IDE-based, no CLI check" in result.output + + +@pytest.mark.parametrize("error_type", ["ValueError", "OSError", "NotImplementedError"]) +def test_check_external_executable_resolution_errors_are_explicit(tmp_path, server, monkeypatch, error_type): + body = f''' + def build_exec_args(self, prompt, *, model=None, output_json=True, + integration_args=None, integration_options=None, project_root=None): + raise {error_type}("sample executable lookup failure [red]") +''' + code = implementation(flavor="markdown", body=body).replace('"requires_cli": False', '"requires_cli": True') + publish(server, code=code) + project = catalog_project(tmp_path, server) + install(project) + monkeypatch.setattr(shutil, "which", lambda *args, **kwargs: None) + result = run(project, ["check"]) + assert result.exit_code == 1, result.output + assert f"Cannot check integration '{KEY}'" in result.output + assert "sample executable lookup failure [red]" in " ".join(result.output.split()) + assert "Specify CLI is ready to use" not in result.output + + +def test_check_rejects_missing_external_requirements_before_tool_probes(tmp_path, server, monkeypatch): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + path = project / ".specify/integrations/packages.json" + data = json.loads(path.read_text()) + data["packages"][KEY].pop("requires") + path.write_text(json.dumps(data)) + + def no_probe(*args, **kwargs): + raise AssertionError("invalid metadata must fail before tool checks") + + monkeypatch.setattr(shutil, "which", no_probe) + result = run(project, ["check"]) + assert result.exit_code == 1, result.output + assert "invalid package metadata: requires" in result.output + + +def test_check_renders_external_adapter_names_as_literal_text(tmp_path, server, monkeypatch): + name = "Sample Agent [red]Preview[/red]" + metadata = descriptor() + metadata["integration"]["name"] = name + metadata["requires"]["tools"] = [{"name": "acme"}] + code = implementation().replace('"name": "Sample Agent"', f'"name": "{name}"') + publish(server, metadata=metadata, code=code) + monkeypatch.setattr(shutil, "which", lambda tool, *args, **kwargs: "/sample/acme" if tool == "acme" else None) + project = catalog_project(tmp_path, server) + install(project) + result = run(project, ["check"]) + assert result.exit_code == 0, result.output + assert name in result.output + + +@pytest.mark.parametrize("operation", ["migrate", "uninstall"]) +@pytest.mark.parametrize("contents", ["empty", "user", "generated"]) +@pytest.mark.parametrize("fail", [False, True]) +def test_kimi_legacy_parent_removal_obeys_lifecycle_rollback(tmp_path, server, monkeypatch, operation, contents, fail): + from specify_cli.integrations import command_install, command_uninstall + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + if operation == "uninstall": + result = run(project, ["integration", "install", "kimi", "--force", "--script", "py"]) + assert result.exit_code == 0, result.output + legacy = project / ".kimi/skills" + legacy.mkdir(parents=True) + if contents == "user": + (legacy / "notes.txt").write_text("preserve user notes") + elif contents == "generated": + skill = legacy / "speckit-legacy/SKILL.md" + skill.parent.mkdir() + skill.write_text( + "---\nname: speckit-legacy\nmetadata:\n" + " author: github-spec-kit\n" + " source: templates/commands/legacy.md\n---\nLegacy skill\n" + ) + original_directories = {path for path in project.rglob("*") if path.is_dir()} + before = snapshot(project) + if fail: + def fail_after_cleanup(*args, **kwargs): + assert legacy.exists() == (contents == "user") + raise OSError("sample failure after legacy cleanup") + + module = command_install if operation == "migrate" else command_uninstall + monkeypatch.setattr(module, "_write_integration_json", fail_after_cleanup) + arguments = ( + ["integration", "install", "kimi", "--force", "--script", "py", "--integration-options=--migrate-legacy"] + if operation == "migrate" else ["integration", "uninstall", "kimi"] + ) + result = run(project, arguments) + if fail: + assert result.exit_code == 1, result.output + assert "sample failure after legacy cleanup" in " ".join(result.output.split()) + assert snapshot(project) == before + assert all(path.is_dir() for path in original_directories) + else: + assert result.exit_code == 0, result.output + assert legacy.exists() == (contents == "user") + if contents == "user": + assert (legacy / "notes.txt").read_text() == "preserve user notes" + elif contents == "generated" and operation == "migrate": + assert (project / ".kimi-code/skills/speckit-legacy/SKILL.md").is_file() + + +@pytest.mark.parametrize("operation", ["run", "resume"]) +@pytest.mark.parametrize("failure", ["dispatch-reload", "lazy-import", "none"]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_workflow_dispatch_adapter_failures_keep_persisted_context( + tmp_path, server, monkeypatch, operation, failure, json_output, +): + from specify_cli.workflows import STEP_REGISTRY + from specify_cli.workflows.base import RunStatus, StepResult, StepStatus + from specify_cli.workflows.engine import RunState + + mutation = ( + ''' from pathlib import Path + Path(__file__).with_name("helper.py").write_text("VALUE = 'changed'\\n") +''' + if failure == "lazy-import" else "" + ) + body = ''' + def build_exec_args(self, prompt, *, model=None, output_json=True, + integration_args=None, integration_options=None, project_root=None): +''' + mutation + ''' from .helper import VALUE + return ["sample-agent-process", "-p", prompt] +''' + publish(server, code=implementation(body=body), members={ + "helper.py": b"VALUE = 'original'\n", + }) + project = catalog_project(tmp_path, server) + install(project) + helper = project / f".specify/integrations/packages/{KEY}/helper.py" + source = project / "sample-dispatch-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-preparation", "type": "gate", "message": "Sample preparation", "options": ["approve", "reject"]}, + {"id": "sample-dispatch", "type": "prompt", "integration": KEY, "prompt": "Sample prompt"}, + ], + })) + if operation == "resume": + started = run(project, ["workflow", "run", str(source), "--json"]) + assert started.exit_code == 0, started.output + initial = json.loads(started.stdout) + assert initial["status"] == "paused" + run_id = initial["run_id"] + + def approve_preparation(_step, config, context): + if failure == "dispatch-reload": + helper.write_text("VALUE = 'changed'\n") + return StepResult(status=StepStatus.COMPLETED) + + monkeypatch.setattr(type(STEP_REGISTRY["gate"]), "execute", approve_preparation) + 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), + ) + processes = [] + + def process_double(argv, **kwargs): + assert failure == "none", "damaged adapters must fail before process execution" + processes.append(argv) + return SimpleNamespace(returncode=0, stdout="sample response", stderr="") + + monkeypatch.setattr(subprocess, "run", process_double) + arguments = ["workflow", operation, str(source) if operation == "run" else run_id] + if json_output: + arguments.append("--json") + result = run(project, arguments) + failed = failure != "none" + assert result.exit_code == (1 if failed else 0), result.output + states = list((project / ".specify/workflows/runs").glob("*/state.json")) + assert len(states) == 1 + persisted = RunState.load(states[0].parent.name, project) + assert persisted.status == (RunStatus.FAILED if failed else RunStatus.COMPLETED) + assert persisted.current_step_id == "sample-dispatch" + assert persisted.current_step_index == 1 + assert persisted.step_results["sample-preparation"]["status"] == "completed" + assert bool(processes) == (not failed) + if json_output: + payload = json.loads(result.stdout) + assert payload["run_id"] == persisted.run_id + assert payload["workflow_id"] == persisted.workflow_id == "sample-workflow" + assert payload["current_step_id"] == persisted.current_step_id + assert payload["current_step_index"] == persisted.current_step_index + assert payload["status"] == persisted.status.value + assert result.stderr == "" + if failed: + assert payload["error"] == persisted.error + else: + assert "error" not in payload + elif failed: + assert ("Workflow failed" if operation == "run" else "Resume failed") in result.output + if failed: + assert "modified" in persisted.error + + +@pytest.mark.parametrize("parameter", ["model", "output_json", "integration_args", "integration_options", "project_root", "all"]) +def test_adapter_rejects_mandatory_host_execution_keywords(tmp_path, server, parameter): + defaults = { + "model": "None", "output_json": "True", "integration_args": "None", + "integration_options": "None", "project_root": "None", + } + arguments = ", ".join( + name if parameter in {name, "all"} else f"{name}={default}" + for name, default in defaults.items() + ) + body = f''' + def build_exec_args(self, prompt, *, {arguments}): + return ["sample-agent-process", prompt] +''' + publish(server, code=implementation(body=body)) + 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 "host execution signature" in " ".join(result.output.split()) + assert snapshot(project) == before + assert KEY not in read_records(project) + + +@pytest.mark.parametrize("arguments", [ + "model=None, output_json=True, integration_args=None, integration_options=None, project_root=None", + "**kwargs", + "model=None, **kwargs", + "**model", +]) +def test_adapter_optional_execution_keywords_support_host_call_subsets(tmp_path, server, arguments): + body = f''' + def build_exec_args(self, prompt, *, {arguments}): + return ["sample-agent-process", prompt] +''' + if arguments.startswith("**"): + body = body.replace("prompt, *, **", "prompt, **") + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + install(project) + integration = INTEGRATION_REGISTRY[KEY] + assert integration.build_exec_args("sample") == ["sample-agent-process", "sample"] + assert integration.build_exec_args("sample", project_root=project) == ["sample-agent-process", "sample"] + assert integration.build_exec_args( + "sample", model=None, output_json=False, project_root=project, + ) == ["sample-agent-process", "sample"] + + +@pytest.mark.parametrize("operation", ["install", "upgrade"]) +@pytest.mark.parametrize("failure", ["serialization", "partial-write", "newline-write"]) +def test_failed_package_registry_serialization_leaves_no_temporary_files( + tmp_path, server, monkeypatch, operation, failure, +): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + if operation == "upgrade": + install(project) + publish(server, version="2.0.0") + before = snapshot(project) + original_directories = {path for path in project.rglob("*") if path.is_dir()} + original_dump = installer.json.dump + + def fail_registry_dump(data, stream, *args, **kwargs): + if not isinstance(data, dict) or "packages" not in data: + return original_dump(data, stream, *args, **kwargs) + if failure == "serialization": + raise TypeError("sample registry serialization failure") + if failure == "partial-write": + stream.write('{"partial":') + raise OSError("sample registry write failure") + original_dump(data, stream, *args, **kwargs) + + def fail_newline(*args, **kwargs): + raise OSError("sample registry newline failure") + + stream.write = fail_newline + + monkeypatch.setattr(installer.json, "dump", fail_registry_dump) + result = run(project, ["integration", operation, KEY, "--trust-integration", "--script", "py"]) + assert result.exit_code == 1, result.output + assert "sample registry" in " ".join(result.output.split()) + assert snapshot(project) == before + assert all(path.is_dir() for path in original_directories) + + +def event_recovery_project( + tmp_path, server, *, events_before=False, other=False, config_relative=".sample-agent/events.json", +): + body = ''' CANONICAL_TO_NATIVE = {"session_start": "SampleStart"} + events_config_file = CONFIG_PATH + events_format = "json-nested" +'''.replace("CONFIG_PATH", repr(config_relative)) + publish(server, code=implementation(body=body)) + project = catalog_project(tmp_path, server) + if other: + result = run(project, ["integration", "install", "claude", "--script", "py"]) + assert result.exit_code == 0, result.output + config = project / config_relative + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(json.dumps({"userSetting": "preserve me"})) + 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 events"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"commands": []}, + "events": {"session_start": {"command": "speckit.sample.boot"}}, + })) + if events_before: + added = run(project, ["extension", "add", "--dev", str(extension)]) + assert added.exit_code == 0, added.output + install(project) + if not events_before: + added = run(project, ["extension", "add", "--dev", str(extension)]) + assert added.exit_code == 0, added.output + return project + + +@pytest.mark.parametrize("events_before", [False, True]) +@pytest.mark.parametrize("other", [False, True]) +@pytest.mark.parametrize("forge_project_modes", [False, True]) +@pytest.mark.parametrize("operation", ["uninstall", "upgrade"]) +def test_damaged_event_adapter_recovery_preserves_shared_and_user_files( + tmp_path, server, events_before, other, forge_project_modes, operation, +): + project = event_recovery_project(tmp_path, server, events_before=events_before, other=other) + config = project / ".sample-agent/events.json" + dispatcher = project / ".specify/events.py" + config_bytes = config.read_bytes() + dispatcher_bytes = dispatcher.read_bytes() + if forge_project_modes: + path = project / f".specify/integrations/{KEY}.manifest.json" + data = json.loads(path.read_text()) + data["ownership_modes"] = { + ".sample-agent/events.json": "whole", ".specify/events.py": "whole", + } + path.write_text(json.dumps(data)) + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + arguments = ["integration", operation, KEY, "--force"] + if operation == "upgrade": + arguments.append("--trust-integration") + removed = run(project, arguments) + assert removed.exit_code == 0, removed.output + assert config.read_bytes() == config_bytes + assert dispatcher.read_bytes() == dispatcher_bytes + assert "shared" in removed.output.lower() + assert (KEY in read_records(project)) == (operation == "upgrade") + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() == (operation == "upgrade") + if other: + assert (project / ".claude/skills/speckit-plan/SKILL.md").exists() + + +def test_event_refresh_updates_local_recovery_paths_and_revokes_removed_claims(tmp_path, server): + from specify_cli.integrations import installer + from specify_cli.integrations.manifest import IntegrationManifest + + project = event_recovery_project(tmp_path, server) + trust = installer._trust_store(project) + binding = json.loads(trust.read_text())["recovery"][installer._recovery_identity(project, KEY)] + manifest = IntegrationManifest.load(KEY, project) + assert set(manifest.files) <= set(binding["paths"]) + assert binding["ownership_modes"][".specify/events.py"] == "shared" + assert binding["ownership_modes"][".sample-agent/events.json"] == "partial" + disabled = run(project, ["extension", "disable", "sample-events"]) + assert disabled.exit_code == 0, disabled.output + binding = json.loads(trust.read_text())["recovery"][installer._recovery_identity(project, KEY)] + assert ".specify/events.py" not in binding["paths"] + assert ".sample-agent/events.json" not in binding["paths"] + + +def test_event_refresh_cannot_launder_unowned_project_manifest_paths(tmp_path, server): + from specify_cli.integrations import installer + + project = event_recovery_project(tmp_path, server) + trust = installer._trust_store(project) + before = trust.read_bytes() + user = project / "unowned-user.txt" + user.write_text("user content") + path = project / f".specify/integrations/{KEY}.manifest.json" + data = json.loads(path.read_text()) + data["files"]["unowned-user.txt"] = hashlib.sha256(user.read_bytes()).hexdigest() + path.write_text(json.dumps(data)) + result = run(project, ["extension", "disable", "sample-events"]) + assert result.exit_code == 0, result.output + assert "event refresh failed" in " ".join(result.output.split()) + assert "ownership" in result.output.lower() + assert trust.read_bytes() == before + assert user.read_text() == "user content" + + +def test_recovery_without_ownership_modes_preserves_unproven_paths(tmp_path, server): + from specify_cli.integrations import installer + + project = event_recovery_project(tmp_path, server, events_before=True) + trust = installer._trust_store(project) + data = json.loads(trust.read_text()) + data["recovery"][installer._recovery_identity(project, KEY)].pop("ownership_modes", None) + trust.write_text(json.dumps(data)) + core = project / ".sample-agent/skills/speckit-plan/SKILL.md" + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + removed = run(project, ["integration", "uninstall", KEY, "--force"]) + assert removed.exit_code == 0, removed.output + assert core.exists() + assert json.loads((project / ".sample-agent/events.json").read_text())["userSetting"] == "preserve me" + assert "unproven" in removed.output.lower() + + +@pytest.mark.parametrize("fail", [False, True]) +def test_host_event_ownership_changes_commit_with_project_transaction(tmp_path, server, fail): + from specify_cli.events import refresh_integration_events + from specify_cli.extensions import ExtensionRegistry + from specify_cli.integration_state import try_read_integration_json + from specify_cli.integrations import installer + from specify_cli.integrations._lifecycle import _transaction + + project = event_recovery_project(tmp_path, server) + trust = installer._trust_store(project) + trust_before = trust.read_bytes() + project_before = snapshot(project) + state, error = try_read_integration_json(project) + assert error is None + + def change_events(): + with _transaction(project, KEY, state, read_records(project)): + registry = ExtensionRegistry(project / ".specify/extensions") + registry.update("sample-events", {"enabled": False}) + refresh_integration_events(project) + assert trust.read_bytes() == trust_before + if fail: + raise OSError("sample host transaction failure") + + if fail: + with pytest.raises(OSError, match="sample host transaction failure"): + change_events() + assert trust.read_bytes() == trust_before + assert snapshot(project) == project_before + else: + change_events() + binding = json.loads(trust.read_text())["recovery"][installer._recovery_identity(project, KEY)] + assert ".specify/events.py" not in binding["paths"] + assert ".sample-agent/events.json" not in binding["paths"] + + +def test_failed_local_ownership_commit_restores_project_event_changes(tmp_path, server, monkeypatch): + from specify_cli.events import refresh_integration_events + from specify_cli.extensions import ExtensionRegistry + from specify_cli.integration_state import try_read_integration_json + from specify_cli.integrations import installer + from specify_cli.integrations._lifecycle import _transaction + + project = event_recovery_project(tmp_path, server) + trust = installer._trust_store(project) + trust_before = trust.read_bytes() + project_before = snapshot(project) + state, error = try_read_integration_json(project) + assert error is None + + def fail_ownership_write(*args, **kwargs): + raise OSError("sample local ownership commit failed") + + monkeypatch.setattr(installer, "_write_trust_state", fail_ownership_write) + with pytest.raises(OSError, match="sample local ownership commit failed"): + with _transaction(project, KEY, state, read_records(project)): + ExtensionRegistry(project / ".specify/extensions").update("sample-events", {"enabled": False}) + refresh_integration_events(project) + assert trust.read_bytes() == trust_before + assert snapshot(project) == project_before + + +def test_healthy_event_adapter_teardown_still_removes_only_managed_entries(tmp_path, server): + project = event_recovery_project(tmp_path, server) + result = run(project, ["integration", "uninstall", KEY]) + assert result.exit_code == 0, result.output + data = json.loads((project / ".sample-agent/events.json").read_text()) + assert data == {"userSetting": "preserve me"} + assert not (project / ".specify/events.py").exists() + + +def test_preserved_event_settings_may_share_another_integration_root(tmp_path, server): + project = event_recovery_project( + tmp_path, server, other=True, config_relative=".claude/settings.json", + ) + settings = project / ".claude/settings.json" + original = settings.read_bytes() + (project / f".specify/integrations/packages/{KEY}/__init__.py").unlink() + removed = run(project, ["integration", "uninstall", KEY, "--force"]) + assert removed.exit_code == 0, removed.output + assert settings.read_bytes() == original + assert (project / ".specify/events.py").exists() + assert (project / ".claude/skills/speckit-plan/SKILL.md").exists() + + +@pytest.mark.parametrize("modes", [None, [], {"unowned.txt": "whole"}, {".specify/events.py": "invalid"}]) +def test_invalid_local_recovery_ownership_modes_fail_explicitly(tmp_path, server, modes): + from specify_cli.integrations import installer + + project = event_recovery_project(tmp_path, server, events_before=True) + trust = installer._trust_store(project) + data = json.loads(trust.read_text()) + data["recovery"][installer._recovery_identity(project, KEY)]["ownership_modes"] = modes + trust.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 "ownership modes" in result.output + assert snapshot(project) == before + + +@pytest.mark.parametrize("name", ["Sample Agent", "[/red]foo", "[red]Preview[/red]", "[link=https://example.com]Link[/link]"]) +@pytest.mark.parametrize("operation", ["install", "switch", "upgrade", "uninstall", "list", "list-catalog", "info-fallback"]) +def test_external_names_render_literally_without_breaking_committed_operations(tmp_path, server, monkeypatch, name, operation): + monkeypatch.setattr("specify_cli._console.console.width", 240) + metadata = descriptor() + metadata["integration"]["name"] = name + code = implementation().replace('"name": "Sample Agent"', f'"name": {name!r}') + publish(server, metadata=metadata, code=code) + project = catalog_project(tmp_path, server) + if operation not in {"install", "switch"}: + run(project, ["integration", "install", KEY, "--trust-integration", "--script", "py"]) + assert KEY in read_records(project) + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() + arguments = { + "install": ["integration", "install", KEY, "--trust-integration", "--script", "py"], + "switch": ["integration", "switch", KEY, "--trust-integration", "--script", "py"], + "upgrade": ["integration", "upgrade", KEY, "--trust-integration"], + "uninstall": ["integration", "uninstall", KEY], + "list": ["integration", "list"], + "list-catalog": ["integration", "list", "--catalog"], + "info-fallback": ["integration", "info", KEY], + }[operation] + if operation == "info-fallback": + (server.root / "catalog.json").write_text(json.dumps({ + "schema_version": "1.0", "integrations": {}, + })) + result = run(project, arguments) + assert result.exit_code == 0, result.output + assert name in " ".join(result.output.split()) + assert (KEY in read_records(project)) == (operation != "uninstall") + assert (project / ".sample-agent/skills/speckit-plan/SKILL.md").exists() == (operation != "uninstall") + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("operation", ["uninstall", "upgrade"]) +def test_builtin_manifest_cleanup_does_not_follow_symlinked_stale_parent(tmp_path, server, force, operation): + from specify_cli.integrations.manifest import IntegrationManifest + + project = catalog_project(tmp_path, server) + installed = run(project, ["integration", "install", "claude", "--script", "py"]) + assert installed.exit_code == 0, installed.output + assert read_records(project) == {} + manifest = IntegrationManifest.load("claude", project) + path = manifest.record_file(".claude/skills/speckit-old/SKILL.md", "old generated content") + manifest.save() + path.unlink() + path.parent.rmdir() + user = tmp_path / "user" + user.mkdir() + victim = user / "SKILL.md" + victim.write_text("old generated content") + path.parent.symlink_to(user, target_is_directory=True) + result = run(project, ["integration", operation, "claude", *(["--force"] if force else [])]) + assert result.exit_code == 0, result.output + assert victim.read_text() == "old generated content" + assert path.parent.is_symlink() + if operation == "upgrade": + assert (project / ".claude/skills/speckit-plan/SKILL.md").exists() + assert read_records(project) == {} + + +@pytest.mark.parametrize("package_kind", ["preset", "extension", "both"]) +@pytest.mark.parametrize("original", ["absent", "existing", "existing-package"]) +@pytest.mark.parametrize("failure", ["registry", "ownership", "none"]) +@pytest.mark.parametrize("windows_encoding", [False, True]) +def test_external_init_package_directories_follow_late_commit_rollback( + tmp_path, server, monkeypatch, package_kind, original, failure, windows_encoding, +): + from specify_cli.integrations import installer + + publish(server) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", f"{server.url}/catalog.json") + preset = tmp_path / "sample-preset" + (preset / "commands").mkdir(parents=True) + (preset / "commands/speckit.specify.md").write_text( + "---\ndescription: Sample prepend\n---\nSample preset before command\n" + ) + (preset / "preset.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "preset": {"id": "sample-init", "name": "Sample Init", "version": "1.0.0", "description": "Sample init"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"templates": [{ + "type": "command", "name": "speckit.specify", "file": "commands/speckit.specify.md", "strategy": "prepend", + }]}, + })) + extension = tmp_path / "sample-extension" + (extension / "commands").mkdir(parents=True) + (extension / "commands/boot.md").write_text( + "---\ndescription: Sample init extension\n---\nSample boot\n" + ) + (extension / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": {"id": "sample-init", "name": "Sample Init", "version": "1.0.0", "description": "Sample init"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"commands": [{"name": "speckit.sample-init.boot", "file": "commands/boot.md"}]}, + })) + project = tmp_path / "target" + if windows_encoding: + original_read_text = Path.read_text + target_skill = project / ".sample-agent/skills/speckit-specify/SKILL.md" + + def read_with_windows_default(path, *args, **kwargs): + if path == target_skill and not args and kwargs.get("encoding") is None: + kwargs["encoding"] = "cp1252" + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", read_with_windows_default) + if original != "absent": + project.mkdir() + (project / "notes.txt").write_text("unowned notes") + if original == "existing-package": + for kind in (["preset", "extension"] if package_kind == "both" else [package_kind]): + directory = project / f".specify/{kind}s/sample-init" + directory.mkdir(parents=True) + (directory / "original.txt").write_text("original unregistered package") + before = snapshot(project) + original_directories = {path for path in project.rglob("*") if path.is_dir()} + + def verify_installed_packages(): + if package_kind in {"preset", "both"}: + assert (project / ".specify/presets/sample-init/preset.yml").is_file() + assert "Sample preset before command" in ( + project / ".sample-agent/skills/speckit-specify/SKILL.md" + ).read_text(encoding="utf-8") + if package_kind in {"extension", "both"}: + assert (project / ".specify/extensions/sample-init/extension.yml").is_file() + assert (project / ".sample-agent/skills/speckit-sample-init-boot/SKILL.md").is_file() + + if failure == "registry": + def fail_records(*args, **kwargs): + verify_installed_packages() + raise OSError("sample late package registry failure") + + monkeypatch.setattr(installer, "write_records", fail_records) + elif failure == "ownership": + original_grant = installer._grant_trust + + def fail_ownership(*args, **kwargs): + if kwargs.get("record_ownership"): + verify_installed_packages() + raise OSError("sample late package ownership failure") + return original_grant(*args, **kwargs) + + monkeypatch.setattr(installer, "_grant_trust", fail_ownership) + arguments = [ + "init", str(project), "--force", "--ignore-agent-tools", + "--integration", KEY, "--trust-integration", "--script", "py", + ] + if package_kind in {"preset", "both"}: + arguments.extend(["--preset", str(preset)]) + if package_kind in {"extension", "both"}: + arguments.extend(["--extension", str(extension)]) + result = run(tmp_path, arguments) + if failure == "none": + assert result.exit_code == 0, result.output + verify_installed_packages() + assert KEY in read_records(project) + else: + assert result.exit_code == 1, result.output + assert "sample late package" in " ".join(result.output.split()) + assert snapshot(project) == before + assert project.exists() == (original != "absent"), [str(path.relative_to(project)) for path in project.rglob("*")] + assert all(path.is_dir() for path in original_directories) + assert "Recovery snapshots retained" not in result.output + assert KEY not in read_records(project) + + +@pytest.mark.parametrize("package_kind", ["preset", "extension"]) +@pytest.mark.parametrize("failure", ["registration", "registry", "copy"]) +@pytest.mark.parametrize("route,original", [ + ("init", "absent"), ("init", "unregistered"), + ("manager", "new"), ("manager", "registered"), +]) +def test_package_post_copy_failure_restores_original_state( + tmp_path, server, monkeypatch, package_kind, failure, route, original, +): + from specify_cli._assets import get_speckit_version + from specify_cli.extensions import ExtensionManager, ExtensionRegistry + from specify_cli.integration_state import try_read_integration_json + from specify_cli.integrations._lifecycle import _transaction + from specify_cli.presets import PresetManager, PresetRegistry + + publish(server) + project = tmp_path / "target" if route == "init" else catalog_project(tmp_path, server) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", f"{server.url}/catalog.json") + if route == "manager": + install(project) + source = tmp_path / "package-source" + (source / "commands").mkdir(parents=True) + (source / "commands/sample.md").write_text( + "---\ndescription: Sample package\n---\nSample before core\n", encoding="utf-8", + ) + (source / "package.txt").write_text("original bytes") + data = { + "schema_version": "1.0", + package_kind: {"id": "sample-failure", "name": "Sample Failure", "version": "1.0.0", "description": "Sample package"}, + "requires": {"speckit_version": ">=0.1"}, + "provides": ( + {"commands": [{"name": "speckit.sample-failure.boot", "file": "commands/sample.md"}]} + if package_kind == "extension" else + {"templates": [{"type": "command", "name": "speckit.specify", "file": "commands/sample.md", "strategy": "prepend"}]} + ), + } + (source / f"{package_kind}.yml").write_text(yaml.safe_dump(data)) + manager_type = PresetManager if package_kind == "preset" else ExtensionManager + registry_type = PresetRegistry if package_kind == "preset" else ExtensionRegistry + destination = project / f".specify/{package_kind}s/sample-failure" + if original == "registered": + manager_type(project).install_from_directory(source, get_speckit_version()) + (destination / "sample-failure-config.yml").write_text("user: original\n") + elif original == "unregistered": + destination.mkdir(parents=True) + (destination / "original.txt").write_text("original unregistered source") + (source / "package.txt").write_text("replacement bytes") + before = snapshot(project) + directories = {path for path in project.rglob("*") if path.is_dir()} + if failure == "copy": + original_copytree = shutil.copytree + + def fail_partial_copy(src, dst, *args, **kwargs): + if Path(src) == source and Path(dst) == destination: + destination.mkdir(parents=True) + shutil.copy2(source / "package.txt", destination / "package.txt") + raise OSError("sample post-copy package failure") + return original_copytree(src, dst, *args, **kwargs) + + monkeypatch.setattr(shutil, "copytree", fail_partial_copy) + else: + method = ( + "_register_commands" if package_kind == "preset" else "_register_extension_skills" + ) if failure == "registration" else "add" + owner = manager_type if failure == "registration" else registry_type + original_method = getattr(owner, method) + + def fail_after_copy(self, *args, **kwargs): + assert (destination / "package.txt").read_text() == "replacement bytes" + if failure == "registration": + original_method(self, *args, **kwargs) + raise OSError("sample post-copy package failure") + + monkeypatch.setattr(owner, method, fail_after_copy) + if route == "init": + result = run(tmp_path, [ + "init", str(project), "--force", "--ignore-agent-tools", + "--integration", KEY, "--trust-integration", "--script", "py", + f"--{package_kind}", str(source), + ]) + assert result.exit_code == 1, result.output + assert "sample post-copy package failure" in " ".join(result.output.split()) + assert project.exists() == (original != "absent") + assert "Recovery snapshots retained" not in result.output + else: + state, error = try_read_integration_json(project) + assert error is None + with pytest.raises(OSError, match="sample post-copy package failure"): + with _transaction(project, KEY, state, read_records(project)): + manager_type(project).install_from_directory(source, get_speckit_version(), force=True) + assert snapshot(project) == before + assert all(path.is_dir() for path in directories) + + +@pytest.mark.parametrize("operation", ["upgrade", "uninstall"]) +@pytest.mark.parametrize("leaf", ["file", "symlink", "dangling-symlink"]) +@pytest.mark.parametrize("fail_commit", [False, True]) +def test_forced_adapter_recovery_handles_owned_package_leaves_without_following( + tmp_path, server, monkeypatch, operation, leaf, fail_commit, +): + from specify_cli.integrations import installer + + publish(server) + project = catalog_project(tmp_path, server) + install(project) + package = project / f".specify/integrations/packages/{KEY}" + shutil.rmtree(package) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "user.txt").write_text("outside user content") + target = outside if leaf == "symlink" else tmp_path / "missing-target" + if leaf == "file": + package.write_text("damaged package leaf") + else: + package.symlink_to(target, target_is_directory=True) + link_target = os.readlink(package) if leaf != "file" else None + outside_before = snapshot(outside) + before = snapshot(project) + if fail_commit: + def fail_records(root, records): + if operation == "upgrade": + assert package.is_dir() and not package.is_symlink() + else: + assert not package.exists() and not package.is_symlink() + raise OSError("sample package leaf commit failure") + + monkeypatch.setattr(installer, "write_records", fail_records) + args = ["integration", operation, KEY, "--force"] + if operation == "upgrade": + args.append("--trust-integration") + result = run(project, args) + assert result.exit_code == (1 if fail_commit else 0), result.output + assert snapshot(outside) == outside_before + assert not (tmp_path / "missing-target").exists() + if fail_commit: + assert "sample package leaf commit failure" in " ".join(result.output.split()) + assert snapshot(project) == before + assert package.is_symlink() == (leaf != "file") + if leaf != "file": + assert os.readlink(package) == link_target + elif operation == "upgrade": + assert load_installed_integrations(project) == [KEY] + assert package.is_dir() and not package.is_symlink() + else: + assert not package.exists() and not package.is_symlink() + assert KEY not in read_records(project) + + +@pytest.mark.parametrize("package_kind", ["preset", "extension"]) +def test_builtin_init_keeps_optional_install_failures_best_effort(tmp_path, monkeypatch, package_kind): + from specify_cli.extensions import ExtensionManager + from specify_cli.presets import PresetManager + + source = tmp_path / "optional-source" + source.mkdir() + (source / f"{package_kind}.yml").write_text("schema_version: '1.0'\n") + manager_type = PresetManager if package_kind == "preset" else ExtensionManager + attempted = [] + + def fail_optional_install(*args, **kwargs): + attempted.append(True) + raise OSError("sample optional builtin install failure") + + monkeypatch.setattr(manager_type, "install_from_directory", fail_optional_install) + project = tmp_path / "builtin-target" + result = run(tmp_path, [ + "init", str(project), "--ignore-agent-tools", "--integration", "copilot", + "--script", "py", f"--{package_kind}", str(source), + ]) + assert result.exit_code == 0, result.output + assert attempted == [True] + if package_kind == "preset": + assert "sample optional builtin install failure" in " ".join(result.output.split()) + assert (project / ".github/skills/speckit-plan/SKILL.md").exists() + + +@pytest.mark.parametrize("operation", ["upgrade", "uninstall"]) +def test_forced_package_recovery_still_rejects_symlinked_ancestors(tmp_path, server, operation): + publish(server) + project = catalog_project(tmp_path, server) + install(project) + packages = project / ".specify/integrations/packages" + outside = tmp_path / "outside-packages" + packages.rename(outside) + packages.symlink_to(outside, target_is_directory=True) + before = snapshot(outside) + args = ["integration", operation, KEY, "--force"] + if operation == "upgrade": + args.append("--trust-integration") + result = run(project, args) + assert result.exit_code == 1, result.output + assert "symlinked" in result.output.lower() + assert packages.is_symlink() + assert snapshot(outside) == before diff --git a/tests/specify_cli/integrations/test_lifecycle.py b/tests/specify_cli/integrations/test_lifecycle.py index f5e3c1968c..76a45f5afe 100644 --- a/tests/specify_cli/integrations/test_lifecycle.py +++ b/tests/specify_cli/integrations/test_lifecycle.py @@ -57,3 +57,26 @@ def test_install_modify_uninstall_preserves_modified(self, tmp_path): assert plan_file.read_text(encoding="utf-8") == "# user customization\n" finally: os.chdir(old_cwd) + + +@pytest.mark.parametrize("change", ["none", "changed-file", "new-file", "deleted-directory", "nonempty-sibling"]) +def test_directory_identity_ignores_only_new_empty_destination_parents(tmp_path, change): + from specify_cli.integrations._lifecycle import _file_identity, _matches_directory_before_write + + package = tmp_path / "package" + package.mkdir() + (package / "original.txt").write_text("original") + (package / "original-empty").mkdir() + expected = _file_identity(package) + (package / ".cache/nested").mkdir(parents=True) + if change == "changed-file": + (package / "original.txt").write_text("concurrent edit") + elif change == "new-file": + (package / "user.txt").write_text("concurrent user file") + elif change == "deleted-directory": + (package / "original-empty").rmdir() + elif change == "nonempty-sibling": + (package / ".cache/user.txt").write_text("concurrent user cache file") + assert _matches_directory_before_write( + _file_identity(package), expected, (".cache", "nested"), + ) == (change == "none") 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