diff --git a/.playwright-mcp/console-2026-04-15T19-33-02-997Z.log b/.playwright-mcp/console-2026-04-15T19-33-02-997Z.log new file mode 100644 index 0000000..21847fd --- /dev/null +++ b/.playwright-mcp/console-2026-04-15T19-33-02-997Z.log @@ -0,0 +1,2 @@ +[ 169ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost/api/v1/auth/me:0 +[ 321ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost/login:0 diff --git a/.playwright-mcp/console-2026-04-15T19-33-09-072Z.log b/.playwright-mcp/console-2026-04-15T19-33-09-072Z.log new file mode 100644 index 0000000..8a0f616 --- /dev/null +++ b/.playwright-mcp/console-2026-04-15T19-33-09-072Z.log @@ -0,0 +1,6 @@ +[ 29ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost/api/v1/auth/me:0 +[ 141ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost/login:0 +[ 24316ms] [ERROR] Failed to load resource: the server responded with a status of 400 (Bad Request) @ http://localhost/api/v1/auth/login:0 +[ 41345ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost/register:0 +[ 41345ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost/register:0 +[ 65093ms] [VERBOSE] [DOM] Password field is not contained in a form: (More info: https://goo.gl/9p2vKq) %o @ http://localhost/login:0 diff --git a/.playwright-mcp/page-2026-04-15T19-33-03-189Z.yml b/.playwright-mcp/page-2026-04-15T19-33-03-189Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-04-15T19-33-09-121Z.yml b/.playwright-mcp/page-2026-04-15T19-33-09-121Z.yml new file mode 100644 index 0000000..5752c0e --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-33-09-121Z.yml @@ -0,0 +1,13 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - generic [ref=e7]: OpenTools Dashboard + - generic [ref=e8]: Sign in to continue + - generic [ref=e10]: + - textbox "Email" [ref=e11] + - textbox "Password" [ref=e12] + - button "Sign In" [ref=e13] [cursor=pointer]: + - generic [ref=e14]: Sign In + - paragraph [ref=e16]: + - text: Don't have an account? + - link "Register" [ref=e17] [cursor=pointer]: + - /url: /register \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-33-35-361Z.yml b/.playwright-mcp/page-2026-04-15T19-33-35-361Z.yml new file mode 100644 index 0000000..2851693 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-33-35-361Z.yml @@ -0,0 +1,13 @@ +- generic [ref=e5]: + - generic [ref=e6]: + - generic [ref=e7]: OpenTools Dashboard + - generic [ref=e8]: Sign in to continue + - generic [ref=e10]: + - textbox "Email" [ref=e11]: admin@slabsofficial.com + - textbox "Password" [ref=e12]: admin + - button "Sign In" [ref=e13] [cursor=pointer]: + - generic [ref=e14]: Sign In + - paragraph [ref=e16]: + - text: Don't have an account? + - link "Register" [ref=e17] [cursor=pointer]: + - /url: /register \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-33-52-327Z.yml b/.playwright-mcp/page-2026-04-15T19-33-52-327Z.yml new file mode 100644 index 0000000..3fba8ee --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-33-52-327Z.yml @@ -0,0 +1,12 @@ +- generic [ref=e20]: + - generic [ref=e22]: Create Account + - generic [ref=e24]: + - textbox "Email" [ref=e25] + - textbox "Password" [ref=e26] + - textbox "Confirm Password" [ref=e27] + - button "Register" [ref=e28] [cursor=pointer]: + - generic [ref=e29]: Register + - paragraph [ref=e31]: + - text: Already have an account? + - link "Sign in" [ref=e32] [cursor=pointer]: + - /url: /login \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-34-16-011Z.yml b/.playwright-mcp/page-2026-04-15T19-34-16-011Z.yml new file mode 100644 index 0000000..f6fb3b4 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-34-16-011Z.yml @@ -0,0 +1,13 @@ +- generic [ref=e35]: + - generic [ref=e36]: + - generic [ref=e37]: OpenTools Dashboard + - generic [ref=e38]: Sign in to continue + - generic [ref=e40]: + - textbox "Email" [ref=e41] + - textbox "Password" [ref=e42] + - button "Sign In" [ref=e43] [cursor=pointer]: + - generic [ref=e44]: Sign In + - paragraph [ref=e46]: + - text: Don't have an account? + - link "Register" [ref=e47] [cursor=pointer]: + - /url: /register \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-34-46-734Z.yml b/.playwright-mcp/page-2026-04-15T19-34-46-734Z.yml new file mode 100644 index 0000000..4143f94 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-34-46-734Z.yml @@ -0,0 +1,44 @@ +- generic [ref=e48]: + - generic [ref=e49]: + - generic [ref=e51]: OpenTools + - menubar [ref=e52]: + - menuitem "Engagements" [ref=e53]: + - generic [ref=e55] [cursor=pointer]: + - generic [ref=e56]:  + - generic [ref=e57]: Engagements + - menuitem "Recipes" [ref=e58]: + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]:  + - generic [ref=e62]: Recipes + - menuitem "Scans" [ref=e63]: + - generic [ref=e65] [cursor=pointer]: + - generic [ref=e66]:  + - generic [ref=e67]: Scans + - menuitem "Containers" [ref=e68]: + - generic [ref=e70] [cursor=pointer]: + - generic [ref=e71]:  + - generic [ref=e72]: Containers + - menuitem "Attack Chain" [ref=e73]: + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]:  + - generic [ref=e77]: Attack Chain + - menuitem "IOCs" [ref=e78]: + - generic [ref=e80] [cursor=pointer]: + - generic [ref=e81]:  + - generic [ref=e82]: IOCs + - img [ref=e83] + - text:   + - generic [ref=e85]: + - text: test@test.com + - button "" [ref=e86] [cursor=pointer]: + - generic [ref=e87]:  + - generic [ref=e88]: + - complementary + - main [ref=e89]: + - generic [ref=e90]: + - generic [ref=e91]: + - heading "Engagements" [level=1] [ref=e92] + - button "New Engagement" [ref=e93] [cursor=pointer]: + - generic [ref=e94]:  + - generic [ref=e95]: New Engagement + - paragraph [ref=e96]: No engagements yet. Create one to get started. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-34-53-002Z.yml b/.playwright-mcp/page-2026-04-15T19-34-53-002Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-34-53-002Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-35-08-022Z.yml b/.playwright-mcp/page-2026-04-15T19-35-08-022Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-35-08-022Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-35-29-757Z.yml b/.playwright-mcp/page-2026-04-15T19-35-29-757Z.yml new file mode 100644 index 0000000..c920445 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-35-29-757Z.yml @@ -0,0 +1,45 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic [ref=e43]: + - complementary + - main [ref=e44]: + - generic [ref=e45]: + - generic [ref=e46]: + - heading "Engagements" [level=1] [ref=e47] + - button "New Engagement" [ref=e48] [cursor=pointer]: + - generic [ref=e49]:  + - generic [ref=e50]: New Engagement + - progressbar [ref=e52]: + - img [ref=e53] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T19-35-41-153Z.yml b/.playwright-mcp/page-2026-04-15T19-35-41-153Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T19-35-41-153Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T20-46-13-085Z.yml b/.playwright-mcp/page-2026-04-15T20-46-13-085Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-04-15T20-46-30-597Z.yml b/.playwright-mcp/page-2026-04-15T20-46-30-597Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T20-46-30-597Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T20-46-50-191Z.yml b/.playwright-mcp/page-2026-04-15T20-46-50-191Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T20-46-50-191Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T20-49-48-140Z.yml b/.playwright-mcp/page-2026-04-15T20-49-48-140Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T20-49-48-140Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-15T20-50-06-884Z.yml b/.playwright-mcp/page-2026-04-15T20-50-06-884Z.yml new file mode 100644 index 0000000..28b7068 --- /dev/null +++ b/.playwright-mcp/page-2026-04-15T20-50-06-884Z.yml @@ -0,0 +1,37 @@ +- generic [ref=e3]: + - generic [ref=e4]: + - generic [ref=e6]: OpenTools + - menubar [ref=e7]: + - menuitem "Engagements" [ref=e8]: + - generic [ref=e10] [cursor=pointer]: + - generic [ref=e11]:  + - generic [ref=e12]: Engagements + - menuitem "Recipes" [ref=e13]: + - generic [ref=e15] [cursor=pointer]: + - generic [ref=e16]:  + - generic [ref=e17]: Recipes + - menuitem "Scans" [ref=e18]: + - generic [ref=e20] [cursor=pointer]: + - generic [ref=e21]:  + - generic [ref=e22]: Scans + - menuitem "Containers" [ref=e23]: + - generic [ref=e25] [cursor=pointer]: + - generic [ref=e26]:  + - generic [ref=e27]: Containers + - menuitem "Attack Chain" [ref=e28]: + - generic [ref=e30] [cursor=pointer]: + - generic [ref=e31]:  + - generic [ref=e32]: Attack Chain + - menuitem "IOCs" [ref=e33]: + - generic [ref=e35] [cursor=pointer]: + - generic [ref=e36]:  + - generic [ref=e37]: IOCs + - img [ref=e38] + - text:   + - generic [ref=e40]: + - text: test@test.com + - button "" [ref=e41] [cursor=pointer]: + - generic [ref=e42]:  + - generic: + - complementary + - main \ No newline at end of file diff --git a/chain-global-current.png b/chain-global-current.png new file mode 100644 index 0000000..06ab359 Binary files /dev/null and b/chain-global-current.png differ diff --git a/chain-global-final.png b/chain-global-final.png new file mode 100644 index 0000000..ea8dc01 Binary files /dev/null and b/chain-global-final.png differ diff --git a/chain-global-updated.png b/chain-global-updated.png new file mode 100644 index 0000000..f289d30 Binary files /dev/null and b/chain-global-updated.png differ diff --git a/engagements-current.png b/engagements-current.png new file mode 100644 index 0000000..3339216 Binary files /dev/null and b/engagements-current.png differ diff --git a/packages/cli/src/opentools/plugin_cli.py b/packages/cli/src/opentools/plugin_cli.py index 2649c3f..9f5bab2 100644 --- a/packages/cli/src/opentools/plugin_cli.py +++ b/packages/cli/src/opentools/plugin_cli.py @@ -2,7 +2,15 @@ from __future__ import annotations +import hashlib import json as json_mod +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -34,6 +42,176 @@ def _error(msg: str, hint: str = "") -> None: raise typer.Exit(1) +def _is_local_path(name: str) -> bool: + """Return True if name looks like a filesystem path rather than a registry name.""" + return ( + name.startswith("./") + or name.startswith("../") + or name.startswith("/") + or name.startswith("~") + or (len(name) >= 2 and name[1] == ":" and name[0].isalpha()) + ) + + +def _load_manifest(src: Path): + """Parse opentools-plugin.yaml from src, return (raw_dict, PluginManifest).""" + from ruamel.yaml import YAML + from opentools_plugin_core.models import PluginManifest + + manifest_file = src / "opentools-plugin.yaml" + if not manifest_file.exists(): + _error( + f"No opentools-plugin.yaml in {src}", + hint="Run 'opentools plugin init ' to scaffold a new plugin.", + ) + yaml = YAML() + with manifest_file.open("r", encoding="utf-8") as f: + raw = yaml.load(f) + manifest = PluginManifest(**raw) + return raw, manifest + + +def _compose_path(home: Path, name: str, version: str) -> Path: + return home / "plugins" / name / version / "compose" / "docker-compose.yaml" + + +def _get_active_compose_path(home: Path, name: str): + """Return (compose_path, version) or call _error if plugin/compose not found.""" + from opentools_plugin_core.installer import read_active_version + + plugin_dir = home / "plugins" / name + if not plugin_dir.exists(): + _error(f"Plugin '{name}' is not installed", hint="opentools plugin list") + + active = read_active_version(plugin_dir) + if not active: + _error(f"No active version for plugin '{name}'") + + compose = _compose_path(home, name, active) + if not compose.exists(): + _error( + f"Plugin '{name}' has no compose file (no containers defined).", + hint="This plugin does not manage Docker containers.", + ) + return compose, active + + +def _do_install(src: Path, home: Path, yes: bool) -> None: + """Core install pipeline: stage -> validate -> prompt -> promote -> register.""" + from opentools_plugin_core.index import PluginIndex + from opentools_plugin_core.installer import ( + stage_plugin, + promote_plugin, + cleanup_staging, + ) + from opentools_plugin_core.compose import generate_compose + from opentools_plugin_core.resolver import detect_conflicts + from opentools_plugin_core.models import InstalledPlugin, InstallMode + from opentools_plugin_core.errors import PluginInstallError + + idx = PluginIndex(home / "plugins.db") + _, manifest = _load_manifest(src) + name = manifest.name + version = manifest.version + + # Conflict check against installed plugins + installed_provides: dict[str, dict[str, str]] = {} + for p in idx.list_all(): + p_dir = home / "plugins" / p.name + p_manifest_file = p_dir / p.version / "manifest.yaml" + if p_manifest_file.exists(): + from ruamel.yaml import YAML + yaml = YAML() + with p_manifest_file.open("r") as f: + p_raw = yaml.load(f) or {} + p_provides = p_raw.get("provides", {}) + for cat in ("containers", "skills", "recipes"): + for item in (p_provides.get(cat) or []): + item_name = item.get("name") or item.get("path") if isinstance(item, dict) else str(item) + if item_name: + installed_provides.setdefault(cat, {})[item_name] = p.name + + new_provides: dict[str, list[str]] = {} + for container in manifest.provides.containers: + new_provides.setdefault("containers", []).append(container.name) + for skill in manifest.provides.skills: + new_provides.setdefault("skills", []).append(skill.path) + for recipe in manifest.provides.recipes: + new_provides.setdefault("recipes", []).append(recipe.path) + + conflicts = detect_conflicts(name, new_provides, installed_provides) + if conflicts: + out.print("[red]Conflict detected:[/red]") + for c in conflicts: + out.print(f" {c}") + _error("Install aborted due to conflicts.") + + # Audit summary + out.print(f"\n[bold]Plugin:[/bold] {name} v{version}") + out.print(f" Skills: {len(manifest.provides.skills)}") + out.print(f" Recipes: {len(manifest.provides.recipes)}") + out.print(f" Containers: {len(manifest.provides.containers)}") + if manifest.sandbox.capabilities: + out.print(f" Capabilities: {', '.join(manifest.sandbox.capabilities)}") + if manifest.sandbox.egress: + out.print(f" [yellow]Egress to: {', '.join(manifest.sandbox.egress_domains) or 'any'}[/yellow]") + + if not yes: + confirmed = typer.confirm(f"\nInstall {name} v{version}?") + if not confirmed: + out.print("Cancelled.") + raise typer.Exit(0) + + staged: Optional[Path] = None + try: + staged = stage_plugin(src, home) + + # Generate compose if containers exist + if manifest.provides.containers: + compose_data = generate_compose(manifest) + if compose_data: + from ruamel.yaml import YAML + compose_dir = staged / "compose" + compose_dir.mkdir(exist_ok=True) + yaml2 = YAML() + yaml2.default_flow_style = False + with (compose_dir / "docker-compose.yaml").open("w") as f: + yaml2.dump(compose_data, f) + + # Record integrity hashes for staged files + for fpath in staged.rglob("*"): + if fpath.is_file(): + sha = hashlib.sha256(fpath.read_bytes()).hexdigest() + rel = str(fpath.relative_to(staged)) + idx.record_integrity(name, rel, sha) + + promote_plugin(staged, home, name, version) + staged = None # ownership transferred + + except PluginInstallError as e: + if staged: + cleanup_staging(staged) + _error(e.message, hint=e.hint) + return + except Exception as e: + if staged: + cleanup_staging(staged) + _error(str(e)) + return + + idx.register(InstalledPlugin( + name=name, + version=version, + repo="", + registry="local", + installed_at=datetime.now(timezone.utc).isoformat(), + signature_verified=False, + mode=InstallMode.REGISTRY, + )) + + out.print(f"[green]Installed:[/green] {name} v{version}") + + # --- Core commands: list, search, info, install, uninstall, update --- @plugin_app.command("list") @@ -146,16 +324,60 @@ def plugin_info( @plugin_app.command("install") def plugin_install( - names: list[str] = typer.Argument(..., help="Plugin name(s)"), + names: list[str] = typer.Argument(..., help="Plugin name(s) or local path(s)"), yes: bool = typer.Option(False, "--yes", "-y"), registry_name: Optional[str] = typer.Option(None, "--registry"), pre: bool = typer.Option(False, "--pre"), pull: bool = typer.Option(False, "--pull"), json_output: bool = typer.Option(False, "--json"), ): - """Install plugin(s) from the registry.""" - out.print(f"[bold]Installing:[/bold] {', '.join(names)}") - out.print("[yellow]Full install pipeline not yet wired to registry fetch + git clone.[/yellow]") + """Install plugin(s) from a local path or the registry.""" + from opentools_plugin_core.registry import RegistryClient + from opentools_plugin_core.errors import RegistryError + + home = _opentools_home() + + for name in names: + if _is_local_path(name): + src = Path(name).expanduser().resolve() + if not src.exists(): + _error(f"Path does not exist: {src}") + _do_install(src, home, yes) + else: + client = RegistryClient(cache_dir=home / "registry-cache") + try: + entry = client.lookup(name) + except RegistryError: + entry = None + + if entry is None: + out.print( + "[yellow]Registry-based install requires a populated catalog — " + "use `opentools plugin install ` with a local directory for now.[/yellow]" + ) + raise typer.Exit(1) + + latest_ver = getattr(entry, "latest_version", None) + clone_ref = f"v{latest_ver}" if latest_ver else "main" + + with tempfile.TemporaryDirectory() as tmp_dir: + clone_result = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", clone_ref, entry.repo, tmp_dir], + capture_output=True, + text=True, + ) + if clone_result.returncode != 0: + clone_result = subprocess.run( + ["git", "clone", "--depth", "1", entry.repo, tmp_dir], + capture_output=True, + text=True, + ) + if clone_result.returncode != 0: + _error( + f"Failed to clone {entry.repo}: {clone_result.stderr.strip()}", + hint="Check the repository URL and your network connection.", + ) + _do_install(Path(tmp_dir), home, yes) @plugin_app.command("uninstall") @@ -202,7 +424,83 @@ def plugin_update( json_output: bool = typer.Option(False, "--json"), ): """Update plugin(s) to latest version.""" - out.print("[yellow]Update flow not yet fully wired.[/yellow]") + from opentools_plugin_core.index import PluginIndex + from opentools_plugin_core.installer import read_active_version + + home = _opentools_home() + idx = PluginIndex(home / "plugins.db") + + targets = list(names) if names else [p.name for p in idx.list_all()] + if not targets: + out.print("No plugins installed.") + return + + for name in targets: + plugin = idx.get(name) + if plugin is None: + out.print(f"[yellow]Plugin '{name}' is not installed — skipping.[/yellow]") + continue + + plugin_dir = home / "plugins" / name + + if not plugin.repo: + out.print( + f"[yellow]{name}: no source repo recorded — cannot auto-update. " + "Re-install manually with a local path.[/yellow]" + ) + continue + + out.print(f"Updating [bold]{name}[/bold] (current: v{plugin.version})...") + with tempfile.TemporaryDirectory() as tmp_dir: + clone_result = subprocess.run( + ["git", "clone", "--depth", "1", plugin.repo, tmp_dir], + capture_output=True, + text=True, + ) + if clone_result.returncode != 0: + out.print(f"[red]Failed to clone {plugin.repo}:[/red] {clone_result.stderr.strip()}") + continue + + src = Path(tmp_dir) + try: + from ruamel.yaml import YAML + yaml = YAML() + with (src / "opentools-plugin.yaml").open("r") as f: + raw = yaml.load(f) + new_version = raw.get("version", "") + except Exception: + new_version = "" + + if new_version and new_version == plugin.version: + out.print(f"[green]{name}:[/green] already at latest ({plugin.version})") + continue + + # Stop containers before update + active = read_active_version(plugin_dir) + if active: + compose = _compose_path(home, name, active) + if compose.exists(): + subprocess.run( + ["docker", "compose", "-f", str(compose), "down"], + capture_output=True, + ) + + _do_install(src, home, yes) + + if new_version: + idx.update_version(name, new_version) + + # Restart containers + new_active = read_active_version(plugin_dir) + if new_active: + new_compose = _compose_path(home, name, new_active) + if new_compose.exists(): + subprocess.run( + ["docker", "compose", "-f", str(new_compose), "up", "-d"], + capture_output=True, + ) + + out.print(f"[green]Updated:[/green] {name} {plugin.version} -> {new_version or 'latest'}") # --- Lifecycle commands: up, down, logs, exec, pull, setup, verify --- @@ -213,13 +511,26 @@ def plugin_up( pull_images: bool = typer.Option(False, "--pull"), ): """Start plugin containers.""" - out.print(f"[yellow]Starting containers for {name}...[/yellow]") + home = _opentools_home() + compose, version = _get_active_compose_path(home, name) + cmd = ["docker", "compose", "-f", str(compose), "up", "-d"] + if pull_images: + cmd.append("--pull=always") + out.print(f"Starting containers for [bold]{name}[/bold] v{version}...") + result = subprocess.run(cmd) + if result.returncode != 0: + raise typer.Exit(result.returncode) @plugin_app.command("down") def plugin_down(name: str = typer.Argument(..., help="Plugin name")): """Stop plugin containers.""" - out.print(f"[yellow]Stopping containers for {name}...[/yellow]") + home = _opentools_home() + compose, version = _get_active_compose_path(home, name) + out.print(f"Stopping containers for [bold]{name}[/bold] v{version}...") + result = subprocess.run(["docker", "compose", "-f", str(compose), "down"]) + if result.returncode != 0: + raise typer.Exit(result.returncode) @plugin_app.command("logs") @@ -228,7 +539,13 @@ def plugin_logs( tail: int = typer.Option(50, "--tail"), ): """View plugin container logs.""" - out.print(f"[yellow]Logs for {name} (not yet wired).[/yellow]") + home = _opentools_home() + compose, _ = _get_active_compose_path(home, name) + result = subprocess.run( + ["docker", "compose", "-f", str(compose), "logs", "--tail", str(tail)] + ) + if result.returncode != 0: + raise typer.Exit(result.returncode) @plugin_app.command("exec") @@ -238,7 +555,41 @@ def plugin_exec( command: list[str] = typer.Argument(..., help="Command"), ): """Exec into a plugin container.""" - out.print(f"[yellow]Exec into {container} of {name} (not yet wired).[/yellow]") + from opentools_plugin_core.enforcement import validate_command + from opentools_plugin_core.installer import read_active_version + + home = _opentools_home() + plugin_dir = home / "plugins" / name + if not plugin_dir.exists(): + _error(f"Plugin '{name}' is not installed", hint="opentools plugin list") + + active = read_active_version(plugin_dir) + if not active: + _error(f"No active version for plugin '{name}'") + + allowed_containers: set[str] = set() + manifest_file = plugin_dir / active / "manifest.yaml" + if manifest_file.exists(): + from ruamel.yaml import YAML + yaml = YAML() + with manifest_file.open("r") as f: + raw = yaml.load(f) or {} + for c in (raw.get("provides", {}).get("containers") or []): + if isinstance(c, dict) and c.get("name"): + allowed_containers.add(c["name"]) + + full_cmd = "docker exec " + container + " " + " ".join(command) + violations = validate_command(full_cmd, allowed_containers) + if violations: + for v in violations: + console.print(f"[red]Violation:[/red] {v.message}") + if v.detail: + console.print(f" [dim]{v.detail}[/dim]") + _error("Command blocked by sandbox policy.") + + result = subprocess.run(["docker", "exec", container, *command]) + if result.returncode != 0: + raise typer.Exit(result.returncode) @plugin_app.command("pull") @@ -247,13 +598,80 @@ def plugin_pull( all_plugins: bool = typer.Option(False, "--all"), ): """Pull container images for a plugin.""" - out.print("[yellow]Pull not yet wired.[/yellow]") + from opentools_plugin_core.index import PluginIndex + + home = _opentools_home() + + if all_plugins: + idx = PluginIndex(home / "plugins.db") + names = [p.name for p in idx.list_all()] + elif name: + names = [name] + else: + _error("Specify a plugin name or use --all") + return + + for plugin_name in names: + try: + compose_path, _ = _get_active_compose_path(home, plugin_name) + except SystemExit: + out.print(f"[dim]{plugin_name}: no compose file — skipping.[/dim]") + continue + + out.print(f"Pulling images for [bold]{plugin_name}[/bold]...") + result = subprocess.run(["docker", "compose", "-f", str(compose_path), "pull"]) + if result.returncode != 0: + out.print(f"[red]Pull failed for {plugin_name}[/red]") @plugin_app.command("setup") def plugin_setup(name: str = typer.Argument(..., help="Plugin name")): - """Re-run container setup for a plugin.""" - out.print(f"[yellow]Setup for {name} (not yet wired).[/yellow]") + """Re-run container setup for a plugin (regenerate compose file).""" + from opentools_plugin_core.index import PluginIndex + from opentools_plugin_core.installer import read_active_version + from opentools_plugin_core.compose import generate_compose + from opentools_plugin_core.models import PluginManifest + + home = _opentools_home() + idx = PluginIndex(home / "plugins.db") + plugin = idx.get(name) + if plugin is None: + _error(f"Plugin '{name}' is not installed", hint="opentools plugin list") + + plugin_dir = home / "plugins" / name + active = read_active_version(plugin_dir) + if not active: + _error(f"No active version for plugin '{name}'") + + version_dir = plugin_dir / active + manifest_file = version_dir / "manifest.yaml" + if not manifest_file.exists(): + _error(f"Manifest not found for {name} v{active}") + + from ruamel.yaml import YAML + yaml = YAML() + with manifest_file.open("r") as f: + raw = yaml.load(f) + manifest = PluginManifest(**raw) + + if not manifest.provides.containers: + out.print(f"[yellow]{name} has no containers — nothing to set up.[/yellow]") + return + + compose_data = generate_compose(manifest) + if not compose_data: + out.print(f"[yellow]{name}: compose generation returned nothing.[/yellow]") + return + + compose_dir = version_dir / "compose" + compose_dir.mkdir(exist_ok=True) + compose_file = compose_dir / "docker-compose.yaml" + yaml2 = YAML() + yaml2.default_flow_style = False + with compose_file.open("w") as f: + yaml2.dump(compose_data, f) + + out.print(f"[green]Compose file regenerated for {name} v{active}:[/green] {compose_file}") @plugin_app.command("verify") @@ -357,13 +775,89 @@ def plugin_init(name: str = typer.Argument(..., help="Plugin name")): @plugin_app.command("link") def plugin_link(path: str = typer.Argument(".", help="Path to local plugin")): """Symlink a local plugin for development.""" - out.print(f"[yellow]Link {path} (not yet wired).[/yellow]") + from opentools_plugin_core.index import PluginIndex + from opentools_plugin_core.models import InstalledPlugin, InstallMode + + home = _opentools_home() + src = Path(path).expanduser().resolve() + if not src.exists(): + _error(f"Path does not exist: {src}") + + _, manifest = _load_manifest(src) + name = manifest.name + version = manifest.version + + plugin_dir = home / "plugins" / name + version_dir = plugin_dir / version + plugin_dir.mkdir(parents=True, exist_ok=True) + + if version_dir.exists() or version_dir.is_symlink(): + if version_dir.is_symlink(): + version_dir.unlink() + else: + shutil.rmtree(version_dir) + + # Prefer symlink on non-Windows; fall back to copytree + link_created = False + if sys.platform != "win32": + try: + os.symlink(src, version_dir) + link_created = True + except OSError: + pass + + if not link_created: + shutil.copytree(str(src), str(version_dir)) + + # Write .active pointer atomically + active_file = plugin_dir / ".active" + tmp_active = active_file.with_suffix(".tmp") + tmp_active.write_text(version) + os.replace(str(tmp_active), str(active_file)) + + idx = PluginIndex(home / "plugins.db") + idx.register(InstalledPlugin( + name=name, + version=version, + repo=str(src), + registry="local", + installed_at=datetime.now(timezone.utc).isoformat(), + signature_verified=False, + mode=InstallMode.LINKED, + )) + + link_type = "symlinked" if link_created else "copied (Windows fallback)" + out.print(f"[green]Linked:[/green] {name} v{version} ({link_type})") + out.print(f" Source: {src}") + out.print(f" Active: {version_dir}") @plugin_app.command("unlink") def plugin_unlink(name: str = typer.Argument(..., help="Plugin name")): """Remove a development symlink.""" - out.print(f"[yellow]Unlink {name} (not yet wired).[/yellow]") + from opentools_plugin_core.index import PluginIndex + + home = _opentools_home() + idx = PluginIndex(home / "plugins.db") + plugin = idx.get(name) + + if plugin is None: + _error(f"Plugin '{name}' is not installed", hint="opentools plugin list") + return + + if plugin.mode.value != "linked": + _error( + f"Plugin '{name}' is installed in mode '{plugin.mode.value}', not 'linked'.", + hint="Use 'opentools plugin uninstall' to remove non-linked plugins.", + ) + return + + plugin_dir = home / "plugins" / name + if plugin_dir.exists(): + shutil.rmtree(plugin_dir) + idx.unregister(name) + + out.print(f"[green]Unlinked:[/green] {name} v{plugin.version}") @plugin_app.command("validate") @@ -461,7 +955,117 @@ def plugin_sync( yes: bool = typer.Option(False, "--yes", "-y"), ): """Sync to a lockfile or plugin set.""" - out.print("[yellow]Sync (not yet wired).[/yellow]") + from opentools_plugin_core.index import PluginIndex + + if lockfile and plugin_set: + _error("Specify --lockfile or --set, not both.") + + if not lockfile and not plugin_set: + _error("Specify --lockfile or --set .") + + home = _opentools_home() + idx = PluginIndex(home / "plugins.db") + installed = {p.name: p for p in idx.list_all()} + + if lockfile: + from ruamel.yaml import YAML + from opentools_plugin_core.models import Lockfile + + lf_path = Path(lockfile) + if not lf_path.exists(): + _error(f"Lockfile not found: {lockfile}") + + yaml = YAML() + with lf_path.open("r") as f: + raw = yaml.load(f) + lf = Lockfile(**raw) + + for plugin_name, entry in lf.plugins.items(): + current = installed.get(plugin_name) + if current is None or current.version != entry.version: + out.print(f" Installing {plugin_name} v{entry.version}...") + if entry.repo: + with tempfile.TemporaryDirectory() as tmp_dir: + ref = entry.ref or f"v{entry.version}" + result = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", ref, entry.repo, tmp_dir], + capture_output=True, + text=True, + ) + if result.returncode != 0: + result = subprocess.run( + ["git", "clone", "--depth", "1", entry.repo, tmp_dir], + capture_output=True, + text=True, + ) + if result.returncode != 0: + out.print(f" [red]Failed to clone {entry.repo}[/red]") + continue + _do_install(Path(tmp_dir), home, yes=True) + else: + out.print(f" [yellow]{plugin_name}: no repo URL in lockfile — skipping.[/yellow]") + + for installed_name in list(installed.keys()): + if installed_name not in lf.plugins: + do_remove = yes or typer.confirm( + f"Plugin '{installed_name}' not in lockfile. Uninstall?" + ) + if do_remove: + plugin_dir = home / "plugins" / installed_name + if plugin_dir.exists(): + shutil.rmtree(plugin_dir) + idx.unregister(installed_name) + out.print(f" [yellow]Removed {installed_name}[/yellow]") + + elif plugin_set: + from ruamel.yaml import YAML + from opentools_plugin_core.models import PluginSet + + ps_path = Path(plugin_set) + if not ps_path.exists(): + _error(f"Plugin set file not found: {plugin_set}") + + yaml = YAML() + with ps_path.open("r") as f: + raw = yaml.load(f) + pset = PluginSet(**raw) + + for plugin_name, version_spec in pset.plugins.items(): + current = installed.get(plugin_name) + target_version = version_spec if version_spec not in ("latest", "*") else None + if current is not None and (target_version is None or current.version == target_version): + out.print(f" [dim]{plugin_name}: already up to date ({current.version})[/dim]") + continue + out.print( + f" {plugin_name} ({version_spec}): no registry source — skipping. " + "Use local path install." + ) + + if freeze_path: + from opentools_plugin_core.models import Lockfile, LockfileEntry + from opentools_plugin_core import __version__ + + refreshed = {p.name: p for p in idx.list_all()} + entries = {} + for p in refreshed.values(): + entries[p.name] = LockfileEntry( + version=p.version, registry=p.registry, repo=p.repo, + ref=f"v{p.version}", sha256="", + ) + lf_out = Lockfile( + generated_at=datetime.now(timezone.utc).isoformat(), + opentools_version=__version__, + plugins=entries, + ) + from ruamel.yaml import YAML + import io + yaml3 = YAML() + yaml3.default_flow_style = False + with open(freeze_path, "w") as f: + yaml3.dump(lf_out.model_dump(mode="json"), f) + out.print(f"[green]Lockfile written:[/green] {freeze_path}") + + out.print("[green]Sync complete.[/green]") @plugin_app.command("export") @@ -470,7 +1074,35 @@ def plugin_export( output: Optional[str] = typer.Option(None, "--output", "-o"), ): """Export a plugin to a .otp archive.""" - out.print(f"[yellow]Export {name} (not yet wired).[/yellow]") + from opentools_plugin_core.index import PluginIndex + from opentools_plugin_core.installer import read_active_version + + home = _opentools_home() + idx = PluginIndex(home / "plugins.db") + plugin = idx.get(name) + if plugin is None: + _error(f"Plugin '{name}' is not installed", hint="opentools plugin list") + return + + plugin_dir = home / "plugins" / name + active = read_active_version(plugin_dir) + if not active: + _error(f"No active version for plugin '{name}'") + return + + version_dir = plugin_dir / active + + if output: + archive_path = Path(output) + else: + exports_dir = home / "exports" + exports_dir.mkdir(exist_ok=True) + archive_path = exports_dir / f"{name}-{active}.otp" + + with tarfile.open(str(archive_path), "w:gz") as tar: + tar.add(str(version_dir), arcname=f"{name}-{active}") + + out.print(f"[green]Exported:[/green] {name} v{active} -> {archive_path}") @plugin_app.command("import") @@ -479,7 +1111,41 @@ def plugin_import_cmd( yes: bool = typer.Option(False, "--yes", "-y"), ): """Install a plugin from a .otp archive.""" - out.print(f"[yellow]Import {archive} (not yet wired).[/yellow]") + archive_path = Path(archive) + if not archive_path.exists(): + _error(f"Archive not found: {archive}") + return + + home = _opentools_home() + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = Path(tmp_dir) + try: + with tarfile.open(str(archive_path), "r:gz") as tar: + tar.extractall(str(tmp)) + except tarfile.TarError as e: + _error(f"Failed to extract archive: {e}") + return + + subdirs = [d for d in tmp.iterdir() if d.is_dir()] + if not subdirs: + _error("Archive contains no directories") + return + src = subdirs[0] + + # manifest.yaml is the name used inside .otp archives (from promote_plugin) + manifest_yaml = src / "manifest.yaml" + plugin_yaml = src / "opentools-plugin.yaml" + if manifest_yaml.exists() and not plugin_yaml.exists(): + shutil.copy2(str(manifest_yaml), str(plugin_yaml)) + + if not plugin_yaml.exists(): + _error("No manifest found in archive (expected opentools-plugin.yaml or manifest.yaml)") + return + + _do_install(src, home, yes) + + out.print(f"[green]Imported:[/green] {archive_path.name}") @plugin_app.command("rollback") diff --git a/packages/cli/tests/test_plugin_cli.py b/packages/cli/tests/test_plugin_cli.py index 5b89478..794c9aa 100644 --- a/packages/cli/tests/test_plugin_cli.py +++ b/packages/cli/tests/test_plugin_cli.py @@ -1,12 +1,43 @@ """Tests for opentools plugin CLI commands.""" import json +import tarfile +from pathlib import Path from unittest.mock import patch, MagicMock import pytest from typer.testing import CliRunner runner = CliRunner() +MINIMAL_MANIFEST = """\ +name: test-plugin +version: 1.0.0 +description: A test plugin +author: + name: Tester +license: MIT +min_opentools_version: '0.3.0' +tags: [] +domain: pentest +provides: + skills: [] + recipes: [] + containers: [] +""" + + +def make_plugin_dir(base: Path, name: str = "test-plugin", version: str = "1.0.0") -> Path: + """Create a minimal plugin source directory.""" + d = base / name + d.mkdir(parents=True, exist_ok=True) + (d / "opentools-plugin.yaml").write_text( + f"name: {name}\nversion: {version}\ndescription: T\n" + f"author:\n name: t\nlicense: MIT\n" + f"min_opentools_version: '0.3.0'\ntags: []\ndomain: pentest\n" + f"provides:\n skills: []\n recipes: []\n containers: []\n" + ) + return d + @pytest.fixture def mock_home(tmp_path): @@ -109,3 +140,209 @@ def test_prune_no_plugins(self, mock_home): result = runner.invoke(plugin_app, ["prune"]) assert result.exit_code == 0 assert "0" in result.stdout + + +# --------------------------------------------------------------------------- +# New wired-command tests +# --------------------------------------------------------------------------- + +class TestInstallFromLocalPath: + def test_install_from_local_path(self, mock_home, tmp_path): + """Install a minimal local plugin and verify DB + filesystem.""" + from opentools.plugin_cli import plugin_app + + src = make_plugin_dir(tmp_path) + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + result = runner.invoke(plugin_app, ["install", "--yes", str(src)]) + + assert result.exit_code == 0, result.output + assert "Installed" in result.output + + # Filesystem check + plugin_dir = mock_home / "plugins" / "test-plugin" + assert plugin_dir.exists() + assert (plugin_dir / ".active").read_text().strip() == "1.0.0" + + # DB check + from opentools_plugin_core.index import PluginIndex + idx = PluginIndex(mock_home / "plugins.db") + p = idx.get("test-plugin") + assert p is not None + assert p.version == "1.0.0" + + def test_install_missing_source(self, mock_home): + """Error when path does not exist.""" + from opentools.plugin_cli import plugin_app + + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + result = runner.invoke(plugin_app, ["install", "./no-such-plugin"]) + + assert result.exit_code != 0 + + +class TestUpNoCompose: + def test_up_no_compose(self, mock_home, tmp_path): + """Error message when plugin has no compose file.""" + from opentools.plugin_cli import plugin_app + + # Install first so the plugin exists + src = make_plugin_dir(tmp_path) + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + runner.invoke(plugin_app, ["install", "--yes", str(src)]) + result = runner.invoke(plugin_app, ["up", "test-plugin"]) + + assert result.exit_code != 0 + # Error message should mention no compose / no containers + combined = result.output + (result.stderr if hasattr(result, "stderr") else "") + assert "compose" in combined.lower() or "container" in combined.lower() + + +class TestLinkUnlink: + def test_link_local_plugin(self, mock_home, tmp_path): + """Link a local plugin dir and verify .active and DB entry.""" + from opentools.plugin_cli import plugin_app + + src = make_plugin_dir(tmp_path) + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + result = runner.invoke(plugin_app, ["link", str(src)]) + + assert result.exit_code == 0, result.output + assert "Linked" in result.output + + plugin_dir = mock_home / "plugins" / "test-plugin" + assert (plugin_dir / ".active").read_text().strip() == "1.0.0" + + from opentools_plugin_core.index import PluginIndex + idx = PluginIndex(mock_home / "plugins.db") + p = idx.get("test-plugin") + assert p is not None + assert p.mode.value == "linked" + + def test_unlink_linked_plugin(self, mock_home, tmp_path): + """Link then unlink; verify plugin dir removed from DB.""" + from opentools.plugin_cli import plugin_app + + src = make_plugin_dir(tmp_path) + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + runner.invoke(plugin_app, ["link", str(src)]) + result = runner.invoke(plugin_app, ["unlink", "test-plugin"]) + + assert result.exit_code == 0, result.output + assert "Unlinked" in result.output + + from opentools_plugin_core.index import PluginIndex + idx = PluginIndex(mock_home / "plugins.db") + assert idx.get("test-plugin") is None + + def test_unlink_non_linked_fails(self, mock_home, tmp_path): + """Trying to unlink a registry-mode plugin should error.""" + from opentools.plugin_cli import plugin_app + + src = make_plugin_dir(tmp_path) + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + runner.invoke(plugin_app, ["install", "--yes", str(src)]) + result = runner.invoke(plugin_app, ["unlink", "test-plugin"]) + + assert result.exit_code != 0 + + +class TestExportImport: + def test_export_installed_plugin(self, mock_home, tmp_path): + """Install locally, export, verify archive exists.""" + from opentools.plugin_cli import plugin_app + + src = make_plugin_dir(tmp_path) + archive_path = tmp_path / "test-plugin-1.0.0.otp" + + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + runner.invoke(plugin_app, ["install", "--yes", str(src)]) + result = runner.invoke( + plugin_app, ["export", "test-plugin", "--output", str(archive_path)] + ) + + assert result.exit_code == 0, result.output + assert "Exported" in result.output + assert archive_path.exists() + assert archive_path.stat().st_size > 0 + + # Verify it's a valid gzipped tar + with tarfile.open(str(archive_path), "r:gz") as tar: + names = tar.getnames() + assert any("manifest.yaml" in n for n in names) + + def test_import_archive(self, mock_home, tmp_path): + """Export + import round trip.""" + from opentools.plugin_cli import plugin_app + + src = make_plugin_dir(tmp_path, name="round-trip", version="2.0.0") + archive_path = tmp_path / "round-trip-2.0.0.otp" + + # Use a separate home for import to avoid "already installed" conflicts + import_home = tmp_path / "import_home" + (import_home / "plugins").mkdir(parents=True) + (import_home / "staging").mkdir() + (import_home / "cache").mkdir() + (import_home / "registry-cache").mkdir() + + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + runner.invoke(plugin_app, ["install", "--yes", str(src)]) + runner.invoke( + plugin_app, ["export", "round-trip", "--output", str(archive_path)] + ) + + assert archive_path.exists() + + with patch("opentools.plugin_cli._opentools_home", return_value=import_home): + result = runner.invoke(plugin_app, ["import", "--yes", str(archive_path)]) + + assert result.exit_code == 0, result.output + assert "Imported" in result.output + + from opentools_plugin_core.index import PluginIndex + idx = PluginIndex(import_home / "plugins.db") + p = idx.get("round-trip") + assert p is not None + assert p.version == "2.0.0" + + +class TestSyncWithLockfile: + def test_sync_with_lockfile_already_installed(self, mock_home, tmp_path): + """Sync with lockfile where plugin already at correct version is a no-op.""" + from opentools.plugin_cli import plugin_app + from opentools_plugin_core.index import PluginIndex + from opentools_plugin_core.models import InstalledPlugin, InstallMode + from datetime import datetime, timezone + + # Pre-register a plugin + idx = PluginIndex(mock_home / "plugins.db") + idx.register(InstalledPlugin( + name="already-installed", + version="1.0.0", + repo="", + registry="local", + installed_at=datetime.now(timezone.utc).isoformat(), + signature_verified=False, + mode=InstallMode.REGISTRY, + )) + + # Write a lockfile referencing the same plugin+version with no repo + lockfile_path = tmp_path / "opentools.lock" + lockfile_path.write_text( + "generated_at: '2026-01-01T00:00:00+00:00'\n" + "opentools_version: '0.3.0'\n" + "plugins:\n" + " already-installed:\n" + " version: '1.0.0'\n" + " registry: local\n" + " repo: ''\n" + " ref: v1.0.0\n" + " sha256: ''\n" + ) + + with patch("opentools.plugin_cli._opentools_home", return_value=mock_home): + result = runner.invoke( + plugin_app, ["sync", "--lockfile", str(lockfile_path), "--yes"] + ) + + assert result.exit_code == 0, result.output + assert "Sync complete" in result.output diff --git a/packages/web/frontend/src/components/ChainLegend.vue b/packages/web/frontend/src/components/ChainLegend.vue index f04f703..2cf3e6c 100644 --- a/packages/web/frontend/src/components/ChainLegend.vue +++ b/packages/web/frontend/src/components/ChainLegend.vue @@ -1,31 +1,84 @@ + + diff --git a/packages/web/frontend/src/components/CypherEditor.vue b/packages/web/frontend/src/components/CypherEditor.vue index b53b7cc..f7f24a8 100644 --- a/packages/web/frontend/src/components/CypherEditor.vue +++ b/packages/web/frontend/src/components/CypherEditor.vue @@ -64,10 +64,11 @@ function cypherComplete(context: any) { // Custom Cypher highlighting via simple syntax tag coloring const cypherTheme = EditorView.theme({ '&': { fontSize: '14px', fontFamily: "'Fira Code', 'Cascadia Code', monospace" }, - '.cm-content': { minHeight: '80px' }, + '.cm-content': { minHeight: '80px', caretColor: '#fff' }, '.cm-gutters': { display: 'none' }, '&.cm-focused': { outline: 'none' }, -}) + '.cm-scroller': { overflow: 'auto' }, +}, { dark: true }) onMounted(() => { if (!editorContainer.value) return @@ -130,21 +131,23 @@ watch(() => props.modelValue, (newVal) => { .cypher-editor { display: flex; flex-direction: column; - border: 1px solid var(--border-color, #ddd); - border-radius: 4px; + border: 1px solid var(--p-surface-700, #333); + border-radius: 6px; overflow: hidden; } .editor-container { min-height: 80px; max-height: 200px; overflow: auto; + background: var(--p-surface-900, #1a1a2e); + color: var(--p-surface-0, #e0e0e0); } .editor-actions { display: flex; justify-content: flex-end; padding: 4px 8px; - border-top: 1px solid var(--border-color, #ddd); - background: #f8f8f8; + border-top: 1px solid var(--p-surface-700, #333); + background: var(--p-surface-800, #16213e); } .run-btn { padding: 4px 12px; diff --git a/packages/web/frontend/src/components/ForceGraphCanvas.vue b/packages/web/frontend/src/components/ForceGraphCanvas.vue index 152d71f..819b79e 100644 --- a/packages/web/frontend/src/components/ForceGraphCanvas.vue +++ b/packages/web/frontend/src/components/ForceGraphCanvas.vue @@ -130,8 +130,10 @@ function countConnections(nodeId: string): number { function initGraph() { if (!container.value) return + // Deep-clone to strip Vue reactivity — force-graph mutates nodes (vx, vy, x, y) + const rawData = JSON.parse(JSON.stringify(props.data)) graph = new ForceGraph(container.value) - .graphData(props.data) + .graphData(rawData) .nodeId('id') .linkSource('source') .linkTarget('target') @@ -438,7 +440,8 @@ function updateData(newData: GraphData) { } } - graph.graphData(newData) + // Deep-clone to strip Vue reactivity + graph.graphData(JSON.parse(JSON.stringify(newData))) } watch(() => props.data, (newData) => { diff --git a/packages/web/frontend/src/components/InlineQueryPanel.vue b/packages/web/frontend/src/components/InlineQueryPanel.vue index ed7960f..0bc4b15 100644 --- a/packages/web/frontend/src/components/InlineQueryPanel.vue +++ b/packages/web/frontend/src/components/InlineQueryPanel.vue @@ -104,8 +104,8 @@ async function runQuery() { bottom: 0; left: 0; right: 0; - background: white; - border-top: 2px solid #ddd; + background: var(--p-surface-900, #1a1a2e); + border-top: 2px solid var(--p-surface-700, #333); z-index: 10; max-height: 50%; overflow: auto; @@ -116,11 +116,12 @@ async function runQuery() { padding: 6px; text-align: center; cursor: pointer; + background: var(--p-surface-800, #16213e); + color: var(--p-surface-200); border: none; - background: #f5f5f5; font-size: 13px; } -.toggle-btn:hover { background: #eee; } +.toggle-btn:hover { background: var(--p-surface-700); } .panel-content { padding: 8px; } .panel-actions { display: flex; @@ -137,12 +138,12 @@ async function runQuery() { font-size: 12px; } .run-btn:disabled { opacity: 0.5; } -.error { color: #e74c3c; padding: 4px 0; font-size: 13px; } -.stats { font-size: 12px; color: #666; padding: 2px 0; } +.error { color: var(--p-red-400); padding: 4px 0; font-size: 13px; } +.stats { font-size: 12px; color: var(--p-surface-400); padding: 2px 0; } table { width: 100%; border-collapse: collapse; font-size: 12px; } -th, td { padding: 3px 6px; border-bottom: 1px solid #eee; text-align: left; } -th { background: #f8f8f8; font-weight: 600; } -.more-rows { font-size: 12px; color: #999; padding: 4px; } -.no-results { font-size: 12px; color: #999; padding: 4px; } +th, td { padding: 3px 6px; border-bottom: 1px solid var(--p-surface-700, #333); text-align: left; } +th { background: var(--p-surface-800); color: var(--p-surface-200); font-weight: 600; } +.more-rows { font-size: 12px; color: var(--p-surface-500); padding: 4px; } +.no-results { font-size: 12px; color: var(--p-surface-500); padding: 4px; } .inline-results { max-height: 200px; overflow: auto; } diff --git a/packages/web/frontend/src/components/QueryResultsPane.vue b/packages/web/frontend/src/components/QueryResultsPane.vue index 8709956..e5117b1 100644 --- a/packages/web/frontend/src/components/QueryResultsPane.vue +++ b/packages/web/frontend/src/components/QueryResultsPane.vue @@ -45,13 +45,13 @@ function formatCell(value: any): string { diff --git a/packages/web/frontend/src/views/ChainQueryView.vue b/packages/web/frontend/src/views/ChainQueryView.vue index 50039c9..5e95727 100644 --- a/packages/web/frontend/src/views/ChainQueryView.vue +++ b/packages/web/frontend/src/views/ChainQueryView.vue @@ -1,23 +1,30 @@