Skip to content

feat: add --dev flag to extension update command#2006

Open
Testimonial wants to merge 1 commit intogithub:mainfrom
Testimonial:feat/extension-update-dev
Open

feat: add --dev flag to extension update command#2006
Testimonial wants to merge 1 commit intogithub:mainfrom
Testimonial:feat/extension-update-dev

Conversation

@Testimonial
Copy link
Copy Markdown
Contributor

Summary

  • Adds --dev flag to specify extension update for local directory updates
  • Eliminates the common dev workflow pain: rm -rf && git clone && remove && add --dev
  • Now just: git pull && specify extension update --dev /path

Problem

Dev-installed extensions (specify extension add --dev /path) cannot be updated without a full remove + add cycle. The update command only checks the catalog, so locally-installed extensions get "Not found in catalog (skipping)".

Common workaround:

rm -rf /tmp/cognitive-squad
git clone https://github.com/org/cognitive-squad.git /tmp/cognitive-squad
specify extension remove cognitive-squad
specify extension add --dev /tmp/cognitive-squad

This destroys any local config files and resets registry metadata (installed_at, priority).

Solution

# Pull latest changes in local repo
cd /tmp/cognitive-squad && git pull

# Update the installed extension from local directory
specify extension update --dev /tmp/cognitive-squad

When --dev is specified:

  1. Reads extension.yml from source directory to identify the extension
  2. If not installed: installs fresh (same as add --dev)
  3. If installed: backs up config files, removes old, installs from source, restores config + registry metadata
  4. Preserves: installed_at, priority, enabled state, *-config.yml, *-config.local.yml, local-config.yml
  5. Sets source: "local" in registry

No changes to the catalog update code path when --dev is not used.

Test plan

  • specify extension update --dev /path/to/ext updates an installed dev extension
  • specify extension update --dev /path/to/new-ext installs if not yet installed
  • specify extension update --dev (no path) prints usage error
  • specify extension update --dev /nonexistent prints directory not found error
  • Config files preserved across dev update
  • Registry metadata (installed_at, priority) preserved
  • specify extension update ext-name (no --dev) still uses catalog (unchanged)

🤖 Generated with Claude Code

Adds `--dev` support to the update command, enabling local directory
updates without remove + add cycle:

  specify extension update --dev /tmp/cognitive-squad

Behavior:
- Reads extension.yml from the source directory to get the extension ID
- If extension is not installed: installs fresh (same as add --dev)
- If extension is installed: backs up config files, removes old version,
  installs from local directory, restores config files and registry
  metadata (installed_at, priority, enabled state)
- Sets source to "local" in registry

This eliminates the common dev workflow pain point:
  rm -rf /tmp/ext && git clone ... && specify extension remove ext &&
  specify extension add --dev /tmp/ext

Now just:
  cd /tmp/ext && git pull && specify extension update --dev /tmp/ext

Config files (*-config.yml, *-config.local.yml, local-config.yml) are
preserved across updates. The catalog code path is unchanged when --dev
is not specified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a --dev mode to specify extension update to support updating dev-installed extensions directly from a local source directory, aiming to preserve config and key registry metadata during the update workflow.

Changes:

  • Introduces --dev flag to specify extension update.
  • Implements a dev update code path that reads extension.yml from a provided directory, removes the installed extension, reinstalls from the directory, and restores config/registry metadata.
  • Keeps existing catalog-based update behavior when --dev is not used.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +4082 to +4086
# Check if installed
installed = manager.list_installed()
installed_ids = {ext["id"] for ext in installed}

if extension_id not in installed_ids:
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Installed detection uses manager.list_installed() to build installed_ids. list_installed() is based on registry.list() which filters out corrupted/non-dict entries, so an extension can still be “installed” per registry.is_installed() yet be missing from installed_ids—leading --dev to attempt a fresh install and fail with “already installed”. Consider checking installation via manager.registry.is_installed(extension_id) (or manager.registry.keys()) instead of relying on list_installed().

Suggested change
# Check if installed
installed = manager.list_installed()
installed_ids = {ext["id"] for ext in installed}
if extension_id not in installed_ids:
# Check if installed using registry to handle corrupted entries gracefully
try:
is_installed = manager.registry.is_installed(extension_id)
except AttributeError:
# Fallback for environments without registry.is_installed
installed = manager.list_installed()
installed_ids = {ext["id"] for ext in installed}
is_installed = extension_id in installed_ids
if not is_installed:

Copilot uses AI. Check for mistakes.
Comment on lines +4130 to +4142
# Restore preserved metadata (installed_at, priority, enabled state)
if backup_registry_entry and isinstance(backup_registry_entry, dict):
current_metadata = manager.registry.get(extension_id)
if current_metadata and isinstance(current_metadata, dict):
new_metadata = dict(current_metadata)
if "installed_at" in backup_registry_entry:
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
if "priority" in backup_registry_entry:
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
if not backup_registry_entry.get("enabled", True):
new_metadata["enabled"] = False
new_metadata["source"] = "local"
manager.registry.restore(extension_id, new_metadata)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the extension was disabled before the update, this code restores enabled=False in the registry, but it doesn’t re-disable hooks in .specify/extensions.yml. The catalog update path explicitly disables those hooks to keep behavior consistent. Consider mirroring that logic here so disabled extensions don’t end up executing hooks after a dev update.

Copilot uses AI. Check for mistakes.
Comment on lines +4144 to +4153
console.print(f"\n[green]✓[/green] Updated {extension_id} to v{new_version} from {source_path}")
except Exception as e:
console.print(f"\n[red]✗[/red] Update failed: {e}")
raise typer.Exit(1)
finally:
# Clean up backup
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-dev-update"
if backup_base.exists():
shutil.rmtree(backup_base)

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failure handling here can cause irreversible loss: after remove() succeeds, if install_from_directory() or config restore fails, the old extension is gone and the config backup is deleted in finally. Consider implementing rollback (similar to the existing catalog update path) or, at minimum, only deleting the backup directory after a successful update and leaving it in place on failure. Also consider guarding shutil.rmtree in the cleanup path so a cleanup error doesn’t mask the original update failure.

Copilot uses AI. Check for mistakes.
Comment on lines +4054 to +4059
# ── Dev mode: update from local directory ──────────────────────────
if dev:
if not extension:
console.print("[red]Error:[/red] --dev requires extension path argument")
console.print("Usage: specify extension update --dev /path/to/extension")
raise typer.Exit(1)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are CLI integration tests for specify extension update already (see TestExtensionUpdateCLI), but this new --dev code path is untested. Adding tests for --dev update (installed and not-installed cases, config preservation, and disabled extension hook behavior) would help prevent regressions.

Copilot uses AI. Check for mistakes.
source_path = Path(extension).expanduser().resolve()
if not source_path.exists():
console.print(f"[red]Error:[/red] Directory not found: {source_path}")
raise typer.Exit(1)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--dev accepts a local directory, but the code only checks exists(). If a user passes a file path, the error message becomes “No extension.yml found” rather than a clear “must be a directory”. Consider validating source_path.is_dir() and exiting with an explicit message when it isn’t a directory.

Suggested change
raise typer.Exit(1)
raise typer.Exit(1)
elif not source_path.is_dir():
console.print(f"[red]Error:[/red] --dev path must be a directory: {source_path}")
raise typer.Exit(1)

Copilot uses AI. Check for mistakes.
Comment on lines +4072 to +4074
import yaml
with open(manifest_path) as f:
manifest_data = yaml.safe_load(f) or {}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading/parsing extension.yml here is outside the surrounding try/except and can raise (YAML syntax error, permission error, etc.), resulting in an uncaught exception/traceback. Consider using the existing ExtensionManifest loader/validator (from specify_cli.extensions) or wrapping the file read + yaml.safe_load in error handling and returning a clean Typer error message.

Suggested change
import yaml
with open(manifest_path) as f:
manifest_data = yaml.safe_load(f) or {}
try:
with open(manifest_path) as f:
manifest_data = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError) as e:
console.print(f"[red]Error:[/red] Failed to read or parse {manifest_path}: {e}")
raise typer.Exit(1)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@mnriem mnriem left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address Copilot feedback. If not applicable, please explain why

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants