feat(workflows): align workflow CLI with extension command surface#3419
feat(workflows): align workflow CLI with extension command surface#3419marcelsafin wants to merge 5 commits into
Conversation
Adds the missing workflow commands and flags so the workflow CLI matches the extension/preset pattern: add --dev and --from, search --author, update, enable and disable. Disabled workflows are blocked from running and marked in list output. Fixes github#2342 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR aligns the specify workflow CLI with the established extension/preset command surface, adding missing flags and lifecycle commands so workflows can be installed from dev paths/URLs, searched by author, updated to newer catalog versions, and toggled enabled/disabled without removal.
Changes:
- Added
workflow add --dev <path>andworkflow add <id> --from <url>(with ID mismatch enforcement for--frominstalls). - Added
workflow update [id]to update catalog-installed workflows (with confirmation + backup/restore on failure). - Added
workflow enable/disable <id>and enforced disabled workflows inworkflow runandworkflow listUI.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/specify_cli/workflows/_commands.py |
Implements the aligned CLI surface (add --dev/--from, update, enable/disable) and enforces disabled workflows at run/list time. |
src/specify_cli/workflows/catalog.py |
Extends catalog search to support exact (case-insensitive) --author filtering. |
tests/test_workflows.py |
Adds a dedicated test suite covering the new CLI behaviors and edge cases described in #2342. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not is_file_source: | ||
| from .catalog import WorkflowRegistry | ||
|
|
||
| installed_meta = WorkflowRegistry(project_root).get(source) | ||
| if installed_meta is not None and installed_meta.get("enabled", True) is False: |
There was a problem hiding this comment.
Done — the run guard now checks isinstance(installed_meta, dict), so a corrupted entry no longer crashes (and doesn't block the run, matching the pre-existing behavior of having no check).
| metadata = installed.get(wf_id) or {} | ||
| if metadata.get("source") != "catalog": | ||
| console.print(f"⚠ {safe_id}: Installed from a local path or URL — re-add to update (skipping)") | ||
| continue |
There was a problem hiding this comment.
Done — workflow update now skips non-dict registry entries with a "Registry entry is corrupted (skipping)" warning. Test added.
| registry.add(workflow_id, { | ||
| "name": definition.name or info.get("name", workflow_id), | ||
| "version": definition.version or info.get("version", "0.0.0"), | ||
| "description": definition.description or info.get("description", ""), | ||
| "source": "catalog", | ||
| "catalog_name": info.get("_catalog_name", ""), | ||
| "url": workflow_url, | ||
| }) |
There was a problem hiding this comment.
Good catch — fixed in the shared _install_workflow_from_catalog helper: a prior enabled: False is preserved across updates/reinstalls. Regression test added.
| metadata = registry.get(workflow_id) | ||
| if metadata is None: | ||
| console.print(f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is not installed") | ||
| raise typer.Exit(1) | ||
| if metadata.get("enabled", True): | ||
| console.print(f"[yellow]Workflow '{_escape_markup(workflow_id)}' is already enabled[/yellow]") | ||
| raise typer.Exit(0) |
There was a problem hiding this comment.
Done — workflow enable exits cleanly with a corrupted-entry error for non-dict values. Test covers both enable and disable.
| metadata = registry.get(workflow_id) | ||
| if metadata is None: | ||
| console.print(f"[red]Error:[/red] Workflow '{_escape_markup(workflow_id)}' is not installed") | ||
| raise typer.Exit(1) | ||
| if not metadata.get("enabled", True): | ||
| console.print(f"[yellow]Workflow '{_escape_markup(workflow_id)}' is already disabled[/yellow]") | ||
| raise typer.Exit(0) |
There was a problem hiding this comment.
Done — same guard added to workflow disable.
…gistry entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| for wf_id, wf_data in installed.items(): | ||
| console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") | ||
| marker = "" if wf_data.get("enabled", True) else " [red]\\[disabled][/red]" | ||
| console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}{marker}") |
There was a problem hiding this comment.
Fixed in a2ef4a0. workflow list now skips non-dict entries with a warning and keeps listing valid ones. Covered by test_list_skips_corrupted_registry_entry.
| except Exception as exc: | ||
| if workflow_dir.exists(): | ||
| import shutil | ||
| shutil.rmtree(workflow_dir, ignore_errors=True) | ||
| console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") | ||
| console.print(f"[red]Error:[/red] Failed to install workflow '{workflow_id}' from catalog: {exc}") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
Fixed in a2ef4a0. Added except typer.Exit: raise before the generic handler, matching the --from download path, so the non-HTTPS redirect error is no longer duplicated.
…se typer.Exit in catalog install workflow list now skips non-dict registry entries with a warning instead of crashing, matching update/enable/disable. The broad except in _install_workflow_from_catalog no longer swallows typer.Exit, so precise errors like the non-HTTPS redirect message are not duplicated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| # Try as URL (http/https) — either the positional source is a URL, or an | ||
| # explicit --from URL names where to fetch it (mirrors `extension add --from`). | ||
| download_url = from_url or ( | ||
| source if source.startswith(("http://", "https://")) else None | ||
| ) |
There was a problem hiding this comment.
Fixed in 4fcfd1a. workflow add <source> --from <url> now runs _validate_workflow_id_or_exit(source) up front, so a URL/path/uppercase source fails without touching the network. Regression covered by test_add_from_rejects_invalid_source_id_without_fetch.
| console.print( | ||
| f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " | ||
| f"does not match the requested workflow ID ({expected_id!r})." | ||
| ) |
There was a problem hiding this comment.
Fixed in 4fcfd1a. Both repr() values now go through rich.markup.escape so a bracketed typo can't be parsed as markup.
| console.print( | ||
| f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " | ||
| f"does not match catalog key ({source!r}). " | ||
| f"does not match catalog key ({workflow_id!r}). " | ||
| f"The catalog entry may be misconfigured." | ||
| ) |
There was a problem hiding this comment.
Fixed in 4fcfd1a. Same treatment for the catalog-mismatch message: both repr() values wrapped in _escape_markup.
… --from source early The two id-mismatch error paths interpolated repr() into Rich markup, so a stray bracket in a user typo could be parsed as markup. Route both through rich.markup.escape. `workflow add <source> --from <url>` also validated the source only after downloading. Validate it up front so a URL/path/typo fails without a network fetch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| if not isinstance(wf_data, dict): | ||
| console.print(f" [yellow]Warning:[/yellow] Skipping corrupted registry entry '{wf_id}'.\n") | ||
| continue | ||
| marker = "" if wf_data.get("enabled", True) else " [red]\\[disabled][/red]" | ||
| console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}{marker}") | ||
| desc = wf_data.get("description", "") | ||
| if desc: | ||
| console.print(f" {desc}") |
There was a problem hiding this comment.
Fixed in 3225b4e. workflow list now routes id, name, version and description through _escape_markup before printing. Regression covered by test_list_escapes_rich_markup_in_registry_fields.
| if not info: | ||
| console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") | ||
| console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found in catalog") | ||
| raise typer.Exit(1) | ||
|
|
||
| if not info.get("_install_allowed", True): | ||
| console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") | ||
| console.print(f"[yellow]Warning:[/yellow] Workflow '{workflow_id}' is from a discovery-only catalog") | ||
| console.print("Direct installation is not enabled for this catalog source.") | ||
| raise typer.Exit(1) | ||
|
|
||
| workflow_url = info.get("url") | ||
| if not workflow_url: | ||
| console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") | ||
| console.print(f"[red]Error:[/red] Workflow '{workflow_id}' does not have an install URL in the catalog") | ||
| raise typer.Exit(1) |
There was a problem hiding this comment.
Fixed in 3225b4e. _install_workflow_from_catalog now computes safe_wf_id = _escape_markup(workflow_id) once and uses it for every error path (not-found, discovery-only, missing URL, bad scheme, redirect, generic failure).
| for update in updates_available: | ||
| # Installed workflows are a single workflow.yml — back it up so a | ||
| # failed download/validation doesn't destroy the working copy. | ||
| wf_dir = _safe_workflow_id_dir(workflows_dir, update["id"]) | ||
| wf_file = wf_dir / "workflow.yml" | ||
| backup = wf_file.read_bytes() if wf_file.is_file() else None | ||
| try: | ||
| _install_workflow_from_catalog(project_root, registry, workflows_dir, update["id"]) | ||
| except typer.Exit: | ||
| if backup is not None: | ||
| wf_dir.mkdir(parents=True, exist_ok=True) | ||
| wf_file.write_bytes(backup) | ||
| failed.append(update["id"]) |
There was a problem hiding this comment.
Fixed in 3225b4e. _safe_workflow_id_dir and the backup read are now inside the per-workflow try/except typer.Exit, so an unsafe id in a corrupted registry fails that one entry and the loop continues. Regression covered by test_update_reports_unsafe_registry_id_per_workflow.
… errors, isolate update failures workflow list now escapes id/name/version/description before printing, matching how extensions render user-editable fields. The catalog install helper computes safe_wf_id once and uses it for every early error path plus the final failure message. workflow update wraps _safe_workflow_id_dir and the backup read inside the try/except typer.Exit block so an unsafe id in a corrupted registry fails that one workflow and the rest continue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| @@ -684,7 +742,13 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: | |||
| console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") | |||
Fixes #2342
Root cause
The workflow CLI grew after the extension CLI and never picked up the full command surface.
workflow addonly accepted catalog IDs, URLs and plain file paths, and there was no way to update an installed workflow, filter search by author, or disable a workflow without removing it.Change
Mirrors the extension/preset commands, same flag names and behavior:
workflow add --dev <path>installs from a local directory or YAML file for development.workflow add <id> --from <url>installs from an explicit URL and fails if the downloaded workflow ID does not match, so nothing is registered under a wrong name.workflow search --author <name>filters catalog results by author (case-insensitive exact match, same as extensions).workflow update [id]updates catalog-installed workflows when a newer version exists, with a confirm prompt. Local and URL installs are skipped with a hint to re-add. The previousworkflow.ymlis backed up and restored if the download fails.workflow enable/disable <id>toggles anenabledflag in the registry.workflow runrefuses disabled workflows andworkflow listmarks them[disabled].Left out
set-priority(no priority concept for workflows) andsearch --verified(catalog has no verification data), per the issue discussion.The catalog install block in
workflow_addmoved verbatim into a module-level_install_workflow_from_cataloghelper soupdatereuses the exact same download/validate/register path instead of duplicating it.Testing
17 new tests in
TestWorkflowCliAlignmentcover: dev install from directory and file, missing path and missing workflow.yml errors, URL install, ID mismatch rejection, author filtering, update with no workflows, unknown ID, non-catalog skip, newer-version update, up-to-date short-circuit, backup restore on failed download, disable blocking run, enable restoring, list marker, unknown-ID errors and idempotent enable/disable warnings.Full suite: 3851 passed, 107 skipped.
ruff checkclean.