[Feature]: `specify preset update` single update convenience wrapper around the existing remove and add operations
- Lenguaje dominante
- Python
- Estrellas
- 137k
- Forks
- 12.3k
- Merge medio
- 2 d 12 h
- PR fusionados (30 d)
- 159
Descripción
## Summary
There is currently no `specify preset update ` command. The only way to move an installed preset to a new version is `preset remove ` followed by `preset add `. That workaround has real correctness problems beyond performance, because both `remove()` and `install_from_directory()` independently trigger full-stack reconciliation (composed commands/skills, and — when `constitution-sync` is enabled — `memory/constitution.md`) whenever the touched preset provides a constitution template. A version bump therefore reconciles the constitution twice: once against the stack with the preset temporarily absent, once against the reinstalled stack — with a real window in between where on-disk state reflects neither the old nor the new version.
This was raised as a valid feature-parity gap in #4415, with a request to open a tracking issue including acceptance criteria for: single/all updates, catalog-only eligibility, preserving priority/enabled state, rollback on failure, and command/skill/constitution reconciliation.
## Current behavior (traced in source)
- `PresetManager.install_from_directory` (`presets/__init__.py`) refuses to install over an already-installed preset id unless `force=True`, which itself calls `self.remove()` first — i.e. there is no in-place update path, only teardown+rebuild.
- `PresetResolver.collect_all_layers` / `resolve_content` recompute composed content from the *entire* installed-preset priority stack on every reconciliation call — never a diff against a single preset's previous version.
- `_reconcile_constitution` / `_materialize_constitution_template` unconditionally overwrite `.specify/memory/constitution.md` (when `constitution-sync` is enabled and the file is still detected as machine-generated) with no check for whether the resolved content actually changed.
- `PresetRegistry` stores `priority` and `enabled` per installed preset, but neither `remove()` nor `install_from_directory()` reads the *previous* entry back when reinstalling — so a manual remove+add workaround also risks losing an explicit `--priority` (if omitted, it silently falls back to the default of 10) and always resets `enabled` to `True` regardless of whether the preset was previously disabled.
- Neither `remove` nor `add` ever holds both the old and new manifest at the same time, so today there's no way to report what an update actually changed.
## Proposed command
```
specify preset update [--from URL | --dev PATH] [--priority N] [--all]
```
## ~~Acceptance criteria~~
### ~~Single-preset update~~
- [ ] ~~`specify preset update ` updates one named, already-installed preset to a new version from a supplied source (`--from URL` / `--dev PATH`) or, when eligible (see catalog-eligibility below), by re-resolving from its original catalog source.~~
- [ ] ~~Running `preset update` against a preset id that is **not** installed fails with a clear error directing the user to `preset add` instead — it must not silently fall through to an install path.~~
- [ ] ~~Old vs. new `preset.yml` manifests are diffed (added / removed / changed template names) before any files are touched, and this diff drives which command/skill files are registered vs. unregistered — not a blind re-register of everything the new manifest declares.~~
### ~~Bulk update (`--all`)~~
- [ ] ~~`specify preset update --all` checks every installed preset for a newer compatible version and updates only those that have one; presets already at the latest version are reported as skipped, not reinstalled with identical content.~~
- [ ] ~~A failure updating one preset in a bulk run is isolated to that preset (full rollback per the rollback criteria below) and does not abort or skip processing of the remaining presets.~~
- [ ] ~~Bulk update produces a per-preset outcome summary (updated / already-latest / failed-with-reason / skipped-and-why).~~
- [ ] ~~Behavior for **disabled** presets under `--all` is explicitly decided and documented (skip entirely vs. update files but leave disabled) rather than left as an implementation accident.~~
### ~~Catalog-only eligibility~~
- [ ] ~~When no `--from`/`--dev` is supplied, automatic re-resolution of "the newer version" is only attempted when the preset's original install source is a catalog entry (or otherwise durably re-resolvable); presets installed via `--dev` or a one-off `--from URL` fail with a clear "source not re-resolvable, supply --from/--dev explicitly" error rather than failing silently or guessing.~~
- [ ] ~~Update re-applies the same catalog `install-allowed` / discovery-only check that `preset add` already enforces (`_install_allowed`) — a discovery-only catalog preset cannot be updated any more than it can be freshly installed, if that's still the intended catalog semantics.~~
- [ ] ~~This dovetails with #3533 (recording install source/ref for extensions, presets, and workflows installed via `--from`, to support `update`) — provenance persisted for that issue should be reused here rather than re-invented per artifact type.~~
### ~~Preserving priority / enabled state~~
- [ ] ~~Omitting `--priority` on `preset update` preserves the preset's currently registered priority (read via the existing `PresetRegistry.get(pack_id)["priority"]`) rather than resetting to the default of 10.~~
- [ ] ~~Passing `--priority N` explicitly still allows a deliberate reprioritize as part of the same update call.~~
- [ ] ~~A preset's `enabled`/`disabled` state (set via `preset enable`/`preset disable`) is preserved across an update — updating a disabled preset must not silently re-enable it.~~
### ~~Rollback on failure~~
- [ ] ~~Compatibility/manifest validation failures on the new version occur before any live preset directory or registry entry is modified — a validation failure is a true no-op.~~
- [ ] ~~New content is staged (e.g. `presets/.staging/`) and validated before being swapped in; if staging or pre-swap validation fails, the previously installed version remains fully installed and functional.~~
- [ ] ~~The swap from old to new content is atomic at the directory level (rename-based, not a recursive file-by-file overwrite), minimizing any window where the preset directory is partial or missing.~~
- [ ] ~~A documented recovery path exists for the rare case of a crash during the swap step itself (e.g. a `.bak` directory retained, or a `preset repair` command).~~
- [ ] ~~A reconciliation failure *after* a successful swap does not roll back the already-successful content/registry update (the new version is genuinely installed) — it surfaces a clear, actionable warning instead of silently succeeding or incorrectly reverting a good install.~~
### ~~Command / skill / constitution reconciliation~~
- [ ] ~~Command and skill registration reconciliation runs exactly once per update, over the union of added/removed/changed names from the manifest diff — not once via an implicit "remove" pass and again via an implicit "add" pass.~~
- [ ] ~~`memory/constitution.md` reconciliation (when `constitution-sync` is enabled) runs at most once per update, against the final post-update stack — never against an intermediate stack with the preset transiently absent.~~
- [ ] ~~Reconciliation compares the newly resolved constitution content's hash against the current on-disk content and skips the write entirely when they match, so a version bump that doesn't change the resolved constitution produces zero file changes.~~
- [ ] ~~The existing "don't clobber a hand-edited constitution" guard (`_constitution_is_generated`, based on the provenance sidecar hash) is reused unchanged by `update()`, not reimplemented.~~
- [ ] ~~`preset update` prints a real diff summary (e.g. `+2 commands, -1 command, constitution unchanged, priority kept at 10`), since it's the first place both the old and new manifest are available together.~~
### ~~Compatibility with existing workflows~~
- [ ] ~~`preset add` and `preset remove` continue to behave exactly as they do today; `preset update` is additive, not a replacement.~~
- [ ] ~~A new preset version that renames the preset id or otherwise restructures its layout in a way a diff can't reconcile causes `update` to fail clearly and point the user to the remove+add workaround, rather than attempting a partial/corrupt diff-based update.~~
## Implementation
I plan to work on this together with @markuswondrak.
## Related
- #4415 — originating discussion
- #3533 — install source/ref provenance for `--from` installs, needed for catalog-eligible auto-resolution here
- #3950, #3873, #3995 — constitution-sync design and reconciliation-on-force work this builds on
Guía de contribución
Línea de trabajo
Start with PresetManager.install_from_directory and remove() in presets/__init__.py, then trace PresetResolver.collect_all_layers/resolve_content, PresetRegistry.get(), and the constitution reconciliation helpers. Define the update command around the stated single and bulk-update criteria, preserving registry state and validating before changes. Done includes isolated rollback behavior, one final reconciliation, and a useful outcome or diff summary.
Escrito por el modelo de indexación a partir del texto del issue.
Evaluación
- Stack tecnológico
- python
- Área
- cli, tooling
- Tipo de issue
- Nueva funcionalidad
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Estado de actividad
- Activo
- Claridad
- Bastante claro
- Aptitud para principiantes
- 35/100