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:
yusyus
2026-02-18 21:22:16 +03:00
parent a9b51ab3fe
commit 265214ac27
25 changed files with 2381 additions and 201 deletions

View File

@@ -376,7 +376,8 @@ class CreateCommand:
# Enhancement Workflow arguments (NEW - Phase 2)
if getattr(self.args, "enhance_workflow", None):
argv.extend(["--enhance-workflow", self.args.enhance_workflow])
for wf in self.args.enhance_workflow:
argv.extend(["--enhance-workflow", wf])
if getattr(self.args, "enhance_stage", None):
for stage in self.args.enhance_stage:
argv.extend(["--enhance-stage", stage])

View File

@@ -27,6 +27,7 @@ import logging
import os
from dataclasses import dataclass, field
from datetime import datetime
from importlib.resources import files as importlib_files
from pathlib import Path
from typing import Any, Literal
@@ -99,25 +100,63 @@ class WorkflowEngine:
self.history: list[dict[str, Any]] = []
self.enhancer = None # Lazy load UnifiedEnhancer
def _load_workflow(self, workflow_path: str | Path) -> EnhancementWorkflow:
"""Load workflow from YAML file."""
workflow_path = Path(workflow_path)
def _load_workflow(self, workflow_ref: str | Path) -> EnhancementWorkflow:
"""Load workflow from YAML file using 3-level search order.
# Resolve path (support both absolute and relative)
if not workflow_path.is_absolute():
# Try relative to CWD first
if not workflow_path.exists():
# Try in config directory
config_dir = Path.home() / ".config" / "skill-seekers" / "workflows"
workflow_path = config_dir / workflow_path
Search order:
1. Raw file path (absolute or relative) — existing behaviour
2. ~/.config/skill-seekers/workflows/{name}.yaml — user overrides/custom
3. skill_seekers/workflows/{name}.yaml via importlib.resources — bundled defaults
"""
workflow_ref = Path(workflow_ref)
if not workflow_path.exists():
raise FileNotFoundError(f"Workflow not found: {workflow_path}")
# Add .yaml extension for bare names
name_str = str(workflow_ref)
if not name_str.endswith((".yaml", ".yml")):
yaml_ref = Path(name_str + ".yaml")
else:
yaml_ref = workflow_ref
logger.info(f"📋 Loading workflow: {workflow_path}")
resolved_path: Path | None = None
yaml_text: str | None = None
with open(workflow_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
# Level 1: absolute path or relative-to-CWD
if yaml_ref.is_absolute():
if yaml_ref.exists():
resolved_path = yaml_ref
else:
cwd_path = Path.cwd() / yaml_ref
if cwd_path.exists():
resolved_path = cwd_path
elif yaml_ref.exists():
resolved_path = yaml_ref
# Level 2: user config directory
if resolved_path is None:
user_dir = Path.home() / ".config" / "skill-seekers" / "workflows"
user_path = user_dir / yaml_ref.name
if user_path.exists():
resolved_path = user_path
# Level 3: bundled package workflows via importlib.resources
if resolved_path is None:
bare_name = yaml_ref.name # e.g. "security-focus.yaml"
try:
pkg_ref = importlib_files("skill_seekers.workflows").joinpath(bare_name)
yaml_text = pkg_ref.read_text(encoding="utf-8")
logger.info(f"📋 Loading bundled workflow: {bare_name}")
except (FileNotFoundError, TypeError, ModuleNotFoundError):
raise FileNotFoundError(
f"Workflow '{yaml_ref.stem}' not found. "
"Use 'skill-seekers workflows list' to see available workflows."
)
if resolved_path is not None:
logger.info(f"📋 Loading workflow: {resolved_path}")
with open(resolved_path, encoding="utf-8") as f:
data = yaml.safe_load(f)
else:
data = yaml.safe_load(yaml_text)
# Handle inheritance (extends)
if "extends" in data and data["extends"]:
@@ -430,103 +469,27 @@ class WorkflowEngine:
logger.info(f"💾 Saved workflow history: {output_path}")
def create_default_workflows():
"""Create default workflow templates in user config directory."""
config_dir = Path.home() / ".config" / "skill-seekers" / "workflows"
config_dir.mkdir(parents=True, exist_ok=True)
# Default workflow
default_workflow = {
"name": "Default Enhancement",
"description": "Standard AI enhancement with all features",
"version": "1.0",
"applies_to": ["codebase_analysis", "doc_scraping", "github_analysis"],
"stages": [
{
"name": "base_analysis",
"type": "builtin",
"target": "patterns",
"enabled": True,
},
{
"name": "test_examples",
"type": "builtin",
"target": "examples",
"enabled": True,
},
],
"post_process": {
"add_metadata": {"enhanced": True, "workflow": "default"}
},
}
# Security-focused workflow
security_workflow = {
"name": "Security-Focused Analysis",
"description": "Emphasize security patterns and vulnerabilities",
"version": "1.0",
"applies_to": ["codebase_analysis"],
"variables": {"focus_area": "security"},
"stages": [
{
"name": "base_patterns",
"type": "builtin",
"target": "patterns",
},
{
"name": "security_analysis",
"type": "custom",
"target": "security",
"uses_history": True,
"prompt": """Based on the patterns detected: {previous_results}
Perform deep security analysis:
1. **Authentication/Authorization**:
- Auth bypass risks?
- Token handling secure?
- Session management issues?
2. **Input Validation**:
- User input sanitized?
- SQL injection risks?
- XSS vulnerabilities?
3. **Data Exposure**:
- Sensitive data in logs?
- Secrets in config?
- PII handling?
4. **Cryptography**:
- Weak algorithms?
- Hardcoded keys?
- Insecure RNG?
Output as JSON with 'findings' array.""",
},
],
"post_process": {
"add_metadata": {"security_reviewed": True},
},
}
# Save workflows
workflows = {
"default.yaml": default_workflow,
"security-focus.yaml": security_workflow,
}
for filename, workflow_data in workflows.items():
workflow_file = config_dir / filename
if not workflow_file.exists():
with open(workflow_file, "w", encoding="utf-8") as f:
yaml.dump(workflow_data, f, default_flow_style=False, sort_keys=False)
logger.info(f"✅ Created workflow: {workflow_file}")
return config_dir
def list_bundled_workflows() -> list[str]:
"""Return names of all bundled default workflows (without .yaml extension)."""
try:
pkg = importlib_files("skill_seekers.workflows")
names = []
for item in pkg.iterdir():
name = str(item.name)
if name.endswith((".yaml", ".yml")):
names.append(name.removesuffix(".yaml").removesuffix(".yml"))
return sorted(names)
except Exception:
return []
if __name__ == "__main__":
# Create default workflows
create_default_workflows()
print("✅ Default workflows created!")
def list_user_workflows() -> list[str]:
"""Return names of all user-defined workflows (without .yaml extension)."""
user_dir = Path.home() / ".config" / "skill-seekers" / "workflows"
if not user_dir.exists():
return []
names = []
for p in user_dir.iterdir():
if p.suffix in (".yaml", ".yml"):
names.append(p.stem)
return sorted(names)

View File

@@ -62,6 +62,7 @@ COMMAND_MODULES = {
"update": "skill_seekers.cli.incremental_updater",
"multilang": "skill_seekers.cli.multilang_support",
"quality": "skill_seekers.cli.quality_metrics",
"workflows": "skill_seekers.cli.workflows_command",
}

View File

@@ -27,6 +27,7 @@ from .stream_parser import StreamParser
from .update_parser import UpdateParser
from .multilang_parser import MultilangParser
from .quality_parser import QualityParser
from .workflows_parser import WorkflowsParser
# Registry of all parsers (in order of usage frequency)
PARSERS = [
@@ -50,6 +51,7 @@ PARSERS = [
UpdateParser(),
MultilangParser(),
QualityParser(),
WorkflowsParser(),
]

View File

@@ -0,0 +1,85 @@
"""Workflows subcommand parser."""
from .base import SubcommandParser
class WorkflowsParser(SubcommandParser):
"""Parser for the workflows subcommand."""
@property
def name(self) -> str:
return "workflows"
@property
def help(self) -> str:
return "Manage enhancement workflow presets"
@property
def description(self) -> str:
return (
"List, inspect, copy, add, remove, and validate enhancement workflow "
"presets. Bundled presets ship with the package; user presets live in "
"~/.config/skill-seekers/workflows/."
)
def add_arguments(self, parser) -> None:
subparsers = parser.add_subparsers(dest="workflows_action", metavar="ACTION")
# list
subparsers.add_parser(
"list",
help="List all available workflows (bundled + user)",
)
# show
show_p = subparsers.add_parser(
"show",
help="Print YAML content of a workflow",
)
show_p.add_argument("workflow_name", help="Workflow name (e.g. security-focus)")
# copy
copy_p = subparsers.add_parser(
"copy",
help="Copy bundled workflow(s) to user dir for editing",
)
copy_p.add_argument(
"workflow_names",
nargs="+",
help="Bundled workflow name(s) to copy",
)
# add
add_p = subparsers.add_parser(
"add",
help="Install a custom YAML file into the user workflow directory",
)
add_p.add_argument(
"files",
nargs="+",
help="Path(s) to YAML workflow file(s) to install",
)
add_p.add_argument(
"--name",
help="Override the workflow filename (stem); only valid when adding a single file",
)
# remove
remove_p = subparsers.add_parser(
"remove",
help="Delete workflow(s) from the user directory (bundled workflows cannot be removed)",
)
remove_p.add_argument(
"workflow_names",
nargs="+",
help="User workflow name(s) to remove",
)
# validate
validate_p = subparsers.add_parser(
"validate",
help="Parse and validate a workflow by name or file path",
)
validate_p.add_argument(
"workflow_name", help="Workflow name or path to YAML file"
)

View 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()