From 05bec3fd9c76d5229e04f254d1d30d2cedfedd8d Mon Sep 17 00:00:00 2001 From: fortishield <161459699+FortiShield@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:43:45 +0600 Subject: [PATCH 1/5] feat: add ui, framework, monologue tags to TAGS.md --- TAGS.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 TAGS.md diff --git a/TAGS.md b/TAGS.md new file mode 100644 index 0000000..8e94029 --- /dev/null +++ b/TAGS.md @@ -0,0 +1,89 @@ +# Recommended tags + +Use short, lowercase tags. Prefer existing tags from this list. Recommended: up to 5 tags. + +## Core capabilities + +- `memory` +- `storage` +- `files` +- `tools` +- `workflow` +- `automation` +- `jobs` +- `scheduling` + +## AI / LLM + +- `llm` +- `prompting` +- `embeddings` +- `rag` +- `agents` +- `monologue` + +## Search / Retrieval + +- `search` +- `web-search` +- `browser` +- `scraping` +- `documents` + +## Web / APIs + +- `api` +- `http` +- `web` +- `webhooks` +- `oauth` +- `ui` + +## Data / Databases + +- `database` +- `sql` +- `sqlite` +- `postgres` +- `redis` +- `vector-db` + +## Integrations + +- `integration` +- `github` +- `google` +- `notion` +- `jira` + +## Communication + +- `email` +- `slack` +- `discord` +- `telegram` + +## Security + +- `security` +- `auth` +- `secrets` + +## Dev / Ops + +- `development` +- `cli` +- `monitoring` +- `docker` +- `framework` + +## Media + +- `image` +- `audio` +- `video` + +## Others + +- `example` +- `template` From 9686c06b1750092f6d32514f73a3d3b6f185eac9 Mon Sep 17 00:00:00 2001 From: fortishield <161459699+FortiShield@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:46:29 +0600 Subject: [PATCH 2/5] Add validation script for plugin pull requests --- scripts/validate_pr.py | 221 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 scripts/validate_pr.py diff --git a/scripts/validate_pr.py b/scripts/validate_pr.py new file mode 100644 index 0000000..74fea89 --- /dev/null +++ b/scripts/validate_pr.py @@ -0,0 +1,221 @@ +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import NoReturn, cast + +import yaml +from PIL import Image + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PLUGINS_DIR = REPO_ROOT / "plugins" + +ALLOWED_YAML_KEYS = {"title", "description", "github", "tags"} +REQUIRED_YAML_KEYS = {"title", "description", "github"} +ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"} +MAX_IMAGE_BYTES = 20 * 1024 +MAX_TAGS = 5 +THUMBNAIL_BASENAME = "thumbnail" + + +class ValidationError(Exception): + pass + + +def _run(cmd: list[str]) -> str: + out = subprocess.check_output(cmd, cwd=REPO_ROOT) + return out.decode("utf-8", errors="replace") + + +def _get_changed_files(base_sha: str, head_sha: str) -> list[tuple[str, str]]: + # Use name-status so we can detect deleted/renamed files. + raw = _run(["git", "diff", "--name-status", f"{base_sha}..{head_sha}"]) + changes: list[tuple[str, str]] = [] + for line in raw.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0] + # For renames/copies, the format is: R100\told\tnew + # For others: A|M|D\tpath + path = parts[-1] + changes.append((status, path)) + return changes + + +def _fail(msg: str) -> NoReturn: + raise ValidationError(msg) + + +def _validate_yaml(plugin_yaml: Path) -> None: + loaded = None + try: + loaded = yaml.safe_load(plugin_yaml.read_text(encoding="utf-8")) + except Exception as e: + _fail(f"Invalid YAML in {plugin_yaml.relative_to(REPO_ROOT)}: {e}") + + data = loaded + + if not isinstance(data, dict): + _fail(f"{plugin_yaml.relative_to(REPO_ROOT)} must contain a YAML mapping/object") + + keys = set(data.keys()) + extra = keys - ALLOWED_YAML_KEYS + missing = REQUIRED_YAML_KEYS - keys + + if extra: + _fail( + f"{plugin_yaml.relative_to(REPO_ROOT)} contains unsupported fields: {sorted(extra)}. " + f"Allowed fields are: {sorted(ALLOWED_YAML_KEYS)}" + ) + if missing: + _fail( + f"{plugin_yaml.relative_to(REPO_ROOT)} is missing required fields: {sorted(missing)}" + ) + + for k in REQUIRED_YAML_KEYS: + v = data.get(k) + if not isinstance(v, str) or not v.strip(): + _fail(f"{plugin_yaml.relative_to(REPO_ROOT)} field '{k}' must be a non-empty string") + + github = data.get("github") + if isinstance(github, str) and not re.match(r"^https?://", github.strip()): + _fail( + f"{plugin_yaml.relative_to(REPO_ROOT)} field 'github' must be a valid http(s) URL" + ) + + if "tags" in data: + tags = data.get("tags") + if tags is None: + _fail(f"{plugin_yaml.relative_to(REPO_ROOT)} field 'tags' must be a list of strings") + if not isinstance(tags, list) or not all(isinstance(t, str) and t.strip() for t in tags): + _fail(f"{plugin_yaml.relative_to(REPO_ROOT)} field 'tags' must be a list of strings") + tags_list = cast(list[str], tags) + if len(tags_list) > MAX_TAGS: + _fail( + f"{plugin_yaml.relative_to(REPO_ROOT)} field 'tags' must contain at most {MAX_TAGS} entries" + ) + + +def _validate_thumbnail(image_path: Path) -> None: + if image_path.suffix.lower() not in ALLOWED_IMAGE_EXTS: + _fail( + f"Thumbnail must be one of {sorted(ALLOWED_IMAGE_EXTS)}: {image_path.relative_to(REPO_ROOT)}" + ) + + size = image_path.stat().st_size + if size > MAX_IMAGE_BYTES: + _fail( + f"Thumbnail is too large ({size} bytes). Max is {MAX_IMAGE_BYTES} bytes: {image_path.relative_to(REPO_ROOT)}" + ) + + try: + with Image.open(image_path) as im: + w, h = im.size + except Exception as e: + _fail(f"Thumbnail image could not be opened: {image_path.relative_to(REPO_ROOT)}: {e}") + + if w != h: + _fail( + f"Thumbnail must be square (width == height). Got {w}x{h}: {image_path.relative_to(REPO_ROOT)}" + ) + + +def main() -> int: + base_sha = os.environ.get("BASE_SHA") + head_sha = os.environ.get("HEAD_SHA") + if not base_sha or not head_sha: + _fail("BASE_SHA and HEAD_SHA environment variables are required") + + base_sha = cast(str, base_sha) + head_sha = cast(str, head_sha) + + changes = _get_changed_files(base_sha, head_sha) + if not changes: + _fail("No changed files detected") + + changed_paths: list[Path] = [] + for _, p in changes: + # Normalize to posix-like path fragments. + changed_paths.append(Path(p)) + + # Only allow modifications within exactly one plugin folder. + plugin_roots: set[Path] = set() + for p in changed_paths: + parts = p.parts + if len(parts) < 3 or parts[0] != "plugins": + _fail( + "PRs must only change files under plugins//. " + f"Found change outside plugins/: {p.as_posix()}" + ) + plugin_roots.add(Path(parts[0]) / parts[1]) + + if len(plugin_roots) != 1: + _fail( + f"PR must submit exactly one plugin folder. Found: {sorted(pr.as_posix() for pr in plugin_roots)}" + ) + + plugin_root_rel = next(iter(plugin_roots)) + plugin_name = plugin_root_rel.parts[1] + + if plugin_name.startswith("_"): + _fail( + f"Plugin folder '{plugin_name}' starts with '_' which is reserved and not visible in Agent Zero" + ) + + plugin_root = REPO_ROOT / plugin_root_rel + if not plugin_root.exists() or not plugin_root.is_dir(): + _fail(f"Plugin folder does not exist: {plugin_root_rel.as_posix()}") + + plugin_yaml = plugin_root / "plugin.yaml" + if not plugin_yaml.exists(): + _fail(f"Missing required file: {plugin_yaml.relative_to(REPO_ROOT)}") + + thumbnails: list[Path] = [] + for child in plugin_root.iterdir(): + if child.is_dir(): + _fail( + f"No subdirectories are allowed inside a plugin folder: {child.relative_to(REPO_ROOT)}" + ) + if child.name == "plugin.yaml": + continue + if child.suffix.lower() in ALLOWED_IMAGE_EXTS: + if child.stem.lower() != THUMBNAIL_BASENAME: + _fail( + f"Thumbnail must be named '{THUMBNAIL_BASENAME}' (e.g. thumbnail.png). " + f"Found: {child.relative_to(REPO_ROOT)}" + ) + thumbnails.append(child) + continue + _fail( + f"Unsupported file in plugin folder: {child.relative_to(REPO_ROOT)}. " + "Only plugin.yaml and an optional thumbnail image are allowed." + ) + + if len(thumbnails) > 1: + _fail( + "At most one thumbnail image is allowed. Found: " + + ", ".join(t.relative_to(REPO_ROOT).as_posix() for t in thumbnails) + ) + + _validate_yaml(plugin_yaml) + + if thumbnails: + _validate_thumbnail(thumbnails[0]) + + # Ensure the PR didn't delete required files. + deleted = [p for status, p in changes if status.startswith("D")] + if deleted: + _fail(f"PR must not delete files. Deleted: {deleted}") + + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ValidationError as e: + print(f"Validation failed: {e}") + raise SystemExit(1) From ff97c00bf0a55ccd6ebf8d3ac75a9197782fe694 Mon Sep 17 00:00:00 2001 From: fortishield <161459699+FortiShield@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:47:29 +0600 Subject: [PATCH 3/5] Implement comment validation failure handling script Add script to handle validation failure comments on PRs. --- scripts/comment_validation_failure.js | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 scripts/comment_validation_failure.js diff --git a/scripts/comment_validation_failure.js b/scripts/comment_validation_failure.js new file mode 100644 index 0000000..d66e6ab --- /dev/null +++ b/scripts/comment_validation_failure.js @@ -0,0 +1,37 @@ +const fs = require('fs'); + +const marker = ''; +const raw = fs.readFileSync('validation.log', 'utf8'); +const max = 60000; +const text = raw.length > max ? raw.slice(0, max) + '\n... (truncated)\n' : raw; +const body = `${marker}\n## Plugin submission validation failed\n\n\`\`\`\n${text}\n\`\`\`\n\nPush an update to this PR to re-run validation.`; + +const { owner, repo } = context.repo; +const issue_number = context.payload.pull_request.number; + +const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, +}); + +const existing = comments.find( + (c) => (c.user?.type === 'Bot') && (c.body || '').includes(marker) +); + +if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); +} else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); +} From 1bb19ffce1f2112721f6dcd45615544ca119dfd3 Mon Sep 17 00:00:00 2001 From: fortishield <161459699+FortiShield@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:48:58 +0600 Subject: [PATCH 4/5] Create plugin.yaml --- plugins/_example1/plugin.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 plugins/_example1/plugin.yaml diff --git a/plugins/_example1/plugin.yaml b/plugins/_example1/plugin.yaml new file mode 100644 index 0000000..1b364dc --- /dev/null +++ b/plugins/_example1/plugin.yaml @@ -0,0 +1,6 @@ +title: Example Plugin +description: Example plugin template to demonstrate the plugin system +github: https://github.com/ctxos/ctx-plugin-example +tags: + - example + - template From 4b7147bef84856352cfc040f6d060f3076040577 Mon Sep 17 00:00:00 2001 From: fortishield <161459699+FortiShield@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:50:09 +0600 Subject: [PATCH 5/5] Create validate-plugin-pr.yml --- .github/workflows/validate-plugin-pr.yml | 51 ++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/validate-plugin-pr.yml diff --git a/.github/workflows/validate-plugin-pr.yml b/.github/workflows/validate-plugin-pr.yml new file mode 100644 index 0000000..9fd8f2e --- /dev/null +++ b/.github/workflows/validate-plugin-pr.yml @@ -0,0 +1,51 @@ +name: Validate Plugin PR + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +jobs: + validate: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pyyaml pillow + + - name: Validate PR contents + id: validate + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + continue-on-error: true + run: | + set -o pipefail + python scripts/validate_pr.py 2>&1 | tee validation.log + echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Comment on PR (validation failure) + if: ${{ steps.validate.outputs.exit_code != '0' }} + uses: actions/github-script@v7 + with: + script-file: scripts/comment_validation_failure.js + + - name: Fail job if validation failed + if: ${{ steps.validate.outputs.exit_code != '0' }} + run: exit 1