feat: enhancement workflow preset system with multi-target CLI
- Add YAML-based enhancement workflow presets shipped inside the package (default, minimal, security-focus, architecture-comprehensive, api-documentation) - Add `skill-seekers workflows` subcommand: list, show, copy, add, remove, validate - copy/add/remove all accept multiple names/files in one invocation with partial-failure behaviour - `add --name` override restricted to single-file operations - Add 5 MCP tools: list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow - Fix: create command _add_common_args() now correctly forwards each --enhance-workflow as a separate flag instead of passing the whole list as a single argument - Update README: reposition as "data layer for AI systems" with AI Skills front and centre - Update CHANGELOG, QUICK_REFERENCE, CLAUDE.md with workflow preset details - 1,880+ tests passing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
311
src/skill_seekers/cli/workflows_command.py
Normal file
311
src/skill_seekers/cli/workflows_command.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Workflows CLI Command
|
||||
|
||||
Manage enhancement workflow presets:
|
||||
list List all workflows (bundled + user)
|
||||
show Print YAML content of a workflow
|
||||
copy Copy a bundled workflow to user dir for editing
|
||||
add Install a custom YAML into user dir
|
||||
remove Delete a user workflow (bundled ones cannot be removed)
|
||||
validate Parse and validate a workflow YAML
|
||||
|
||||
Usage:
|
||||
skill-seekers workflows list
|
||||
skill-seekers workflows show security-focus
|
||||
skill-seekers workflows copy security-focus
|
||||
skill-seekers workflows add ./my-workflow.yaml
|
||||
skill-seekers workflows remove my-workflow
|
||||
skill-seekers workflows validate security-focus
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from skill_seekers.cli.enhancement_workflow import (
|
||||
WorkflowEngine,
|
||||
list_bundled_workflows,
|
||||
)
|
||||
|
||||
USER_WORKFLOWS_DIR = Path.home() / ".config" / "skill-seekers" / "workflows"
|
||||
|
||||
|
||||
def _ensure_user_dir() -> Path:
|
||||
USER_WORKFLOWS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return USER_WORKFLOWS_DIR
|
||||
|
||||
|
||||
def _bundled_yaml_text(name: str) -> str | None:
|
||||
"""Return raw YAML text of a bundled workflow, or None if not found."""
|
||||
from importlib.resources import files as importlib_files
|
||||
|
||||
for suffix in (".yaml", ".yml"):
|
||||
try:
|
||||
pkg_ref = importlib_files("skill_seekers.workflows").joinpath(name + suffix)
|
||||
return pkg_ref.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, TypeError, ModuleNotFoundError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _workflow_yaml_text(name_or_path: str) -> str | None:
|
||||
"""Resolve a workflow by name or path and return its raw YAML text."""
|
||||
# Try as a file path first
|
||||
p = Path(name_or_path)
|
||||
if p.suffix in (".yaml", ".yml") and p.exists():
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
# Try as a name with .yaml extension
|
||||
for suffix in (".yaml", ".yml"):
|
||||
candidate = Path(name_or_path + suffix)
|
||||
if candidate.exists():
|
||||
return candidate.read_text(encoding="utf-8")
|
||||
|
||||
# User dir
|
||||
user_file = USER_WORKFLOWS_DIR / (name_or_path + ".yaml")
|
||||
if user_file.exists():
|
||||
return user_file.read_text(encoding="utf-8")
|
||||
user_file_yml = USER_WORKFLOWS_DIR / (name_or_path + ".yml")
|
||||
if user_file_yml.exists():
|
||||
return user_file_yml.read_text(encoding="utf-8")
|
||||
|
||||
# Bundled
|
||||
return _bundled_yaml_text(name_or_path)
|
||||
|
||||
|
||||
def _list_user_workflow_names() -> list[str]:
|
||||
"""Return names of user workflows (without extension) from USER_WORKFLOWS_DIR."""
|
||||
if not USER_WORKFLOWS_DIR.exists():
|
||||
return []
|
||||
return sorted(
|
||||
p.stem for p in USER_WORKFLOWS_DIR.iterdir() if p.suffix in (".yaml", ".yml")
|
||||
)
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
"""List all available workflows."""
|
||||
bundled = list_bundled_workflows()
|
||||
user = _list_user_workflow_names()
|
||||
|
||||
if not bundled and not user:
|
||||
print("No workflows found.")
|
||||
return 0
|
||||
|
||||
if bundled:
|
||||
print("Bundled workflows (read-only):")
|
||||
for name in bundled:
|
||||
# Load description from YAML
|
||||
text = _bundled_yaml_text(name)
|
||||
desc = ""
|
||||
if text:
|
||||
try:
|
||||
data = yaml.safe_load(text)
|
||||
desc = data.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
print(f" {name:<32} {desc}")
|
||||
|
||||
if user:
|
||||
print("\nUser workflows (~/.config/skill-seekers/workflows/):")
|
||||
for name in user:
|
||||
user_file = USER_WORKFLOWS_DIR / (name + ".yaml")
|
||||
if not user_file.exists():
|
||||
user_file = USER_WORKFLOWS_DIR / (name + ".yml")
|
||||
desc = ""
|
||||
try:
|
||||
data = yaml.safe_load(user_file.read_text(encoding="utf-8"))
|
||||
desc = data.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
print(f" {name:<32} {desc}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(name: str) -> int:
|
||||
"""Print YAML content of a workflow."""
|
||||
text = _workflow_yaml_text(name)
|
||||
if text is None:
|
||||
print(f"Error: Workflow '{name}' not found.", file=sys.stderr)
|
||||
print("Use 'skill-seekers workflows list' to see available workflows.", file=sys.stderr)
|
||||
return 1
|
||||
print(text, end="")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_copy(names: list[str]) -> int:
|
||||
"""Copy one or more bundled workflows to user dir."""
|
||||
rc = 0
|
||||
for name in names:
|
||||
text = _bundled_yaml_text(name)
|
||||
if text is None:
|
||||
print(f"Error: Bundled workflow '{name}' not found.", file=sys.stderr)
|
||||
bundled = list_bundled_workflows()
|
||||
if bundled:
|
||||
print(f"Available bundled workflows: {', '.join(bundled)}", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
|
||||
dest = _ensure_user_dir() / (name + ".yaml")
|
||||
if dest.exists():
|
||||
print(f"Warning: '{dest}' already exists. Overwriting.")
|
||||
|
||||
dest.write_text(text, encoding="utf-8")
|
||||
print(f"Copied '{name}' to: {dest}")
|
||||
print(f"Edit it with your favourite editor, then reference it as '--enhance-workflow {name}'")
|
||||
|
||||
return rc
|
||||
|
||||
|
||||
def cmd_add(file_paths: list[str], override_name: str | None = None) -> int:
|
||||
"""Install one or more custom YAML workflows into user dir."""
|
||||
if override_name and len(file_paths) > 1:
|
||||
print("Error: --name cannot be used when adding multiple files.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
rc = 0
|
||||
for file_path in file_paths:
|
||||
src = Path(file_path)
|
||||
if not src.exists():
|
||||
print(f"Error: File '{file_path}' does not exist.", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
if src.suffix not in (".yaml", ".yml"):
|
||||
print(f"Error: '{file_path}' must have a .yaml or .yml extension.", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
|
||||
# Validate before installing
|
||||
try:
|
||||
text = src.read_text(encoding="utf-8")
|
||||
data = yaml.safe_load(text)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("YAML root must be a mapping")
|
||||
if "stages" not in data:
|
||||
raise ValueError("Workflow must contain a 'stages' key")
|
||||
except Exception as exc:
|
||||
print(f"Error: Invalid workflow YAML '{file_path}' – {exc}", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
|
||||
dest_name = override_name if override_name else src.stem
|
||||
dest = _ensure_user_dir() / (dest_name + ".yaml")
|
||||
|
||||
if dest.exists():
|
||||
print(f"Warning: '{dest}' already exists. Overwriting.")
|
||||
|
||||
shutil.copy2(src, dest)
|
||||
print(f"Installed workflow '{dest_name}' to: {dest}")
|
||||
|
||||
return rc
|
||||
|
||||
|
||||
def cmd_remove(names: list[str]) -> int:
|
||||
"""Delete one or more user workflows."""
|
||||
rc = 0
|
||||
bundled = list_bundled_workflows()
|
||||
for name in names:
|
||||
if name in bundled:
|
||||
print(
|
||||
f"Error: '{name}' is a bundled workflow and cannot be removed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Use 'skill-seekers workflows copy' to create an editable copy.", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
|
||||
removed = False
|
||||
for suffix in (".yaml", ".yml"):
|
||||
candidate = USER_WORKFLOWS_DIR / (name + suffix)
|
||||
if candidate.exists():
|
||||
candidate.unlink()
|
||||
print(f"Removed workflow: {candidate}")
|
||||
removed = True
|
||||
break
|
||||
|
||||
if not removed:
|
||||
print(f"Error: User workflow '{name}' not found.", file=sys.stderr)
|
||||
rc = 1
|
||||
|
||||
return rc
|
||||
|
||||
|
||||
def cmd_validate(name_or_path: str) -> int:
|
||||
"""Parse and validate a workflow."""
|
||||
try:
|
||||
engine = WorkflowEngine(name_or_path)
|
||||
wf = engine.workflow
|
||||
print(f"✅ Workflow '{wf.name}' is valid.")
|
||||
print(f" Description : {wf.description}")
|
||||
print(f" Version : {wf.version}")
|
||||
print(f" Stages : {len(wf.stages)}")
|
||||
for stage in wf.stages:
|
||||
status = "enabled" if stage.enabled else "disabled"
|
||||
print(f" - {stage.name} ({stage.type}, {status})")
|
||||
return 0
|
||||
except FileNotFoundError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
print(f"Error: Invalid workflow – {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main(argv=None) -> None:
|
||||
"""Entry point for skill-seekers-workflows."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="skill-seekers-workflows",
|
||||
description="Manage enhancement workflow presets",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="action", metavar="ACTION")
|
||||
|
||||
subparsers.add_parser("list", help="List all workflows (bundled + user)")
|
||||
|
||||
show_p = subparsers.add_parser("show", help="Print YAML content of a workflow")
|
||||
show_p.add_argument("workflow_name")
|
||||
|
||||
copy_p = subparsers.add_parser("copy", help="Copy bundled workflow(s) to user dir")
|
||||
copy_p.add_argument("workflow_names", nargs="+")
|
||||
|
||||
add_p = subparsers.add_parser("add", help="Install custom YAML file(s) into user dir")
|
||||
add_p.add_argument("files", nargs="+")
|
||||
add_p.add_argument("--name")
|
||||
|
||||
remove_p = subparsers.add_parser("remove", help="Delete user workflow(s)")
|
||||
remove_p.add_argument("workflow_names", nargs="+")
|
||||
|
||||
validate_p = subparsers.add_parser("validate", help="Validate a workflow by name or file")
|
||||
validate_p.add_argument("workflow_name")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.action is None:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
rc = 0
|
||||
if args.action == "list":
|
||||
rc = cmd_list()
|
||||
elif args.action == "show":
|
||||
rc = cmd_show(args.workflow_name)
|
||||
elif args.action == "copy":
|
||||
rc = cmd_copy(args.workflow_names)
|
||||
elif args.action == "add":
|
||||
rc = cmd_add(args.files, getattr(args, "name", None))
|
||||
elif args.action == "remove":
|
||||
rc = cmd_remove(args.workflow_names)
|
||||
elif args.action == "validate":
|
||||
rc = cmd_validate(args.workflow_name)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
sys.exit(rc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user