diff --git a/src/agent/cli/cli_utils.py b/src/agent/cli/cli_utils.py index 7ffc80a4..6a6042dc 100644 --- a/src/agent/cli/cli_utils.py +++ b/src/agent/cli/cli_utils.py @@ -38,3 +38,19 @@ def add_command(self, cmd, name=None): def list_commands(self, ctx): return self.commands.keys() + + +class MutuallyExclusiveOption(click.Option): + """Error when this option is used together with any of `mutually_exclusive`.""" + + def __init__(self, *args, **kwargs): + self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", [])) + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + other_used = [name for name in self.mutually_exclusive if opts.get(name)] + if other_used and opts.get(self.name): + raise click.UsageError( + f"Option '{self.name.replace('_', '-')}' is mutually exclusive with: {', '.join(n.replace('_', '-') for n in other_used)}" + ) + return super().handle_parse_result(ctx, opts, args) diff --git a/src/agent/cli/commands/plugin.py b/src/agent/cli/commands/plugin.py index e7d76122..385b905d 100644 --- a/src/agent/cli/commands/plugin.py +++ b/src/agent/cli/commands/plugin.py @@ -6,7 +6,7 @@ # Import subcommands from specialized modules from .plugin_init import init -from .plugin_manage import add, reload, remove, sync +from .plugin_manage import add, manage, reload, remove, sync # Export all commands and functions __all__ = [ @@ -21,6 +21,7 @@ "config", "validate", "get_version", + "manage", ] @@ -41,3 +42,4 @@ def plugin(): plugin.add_command(info) plugin.add_command(config) plugin.add_command(validate) +plugin.add_command(manage) diff --git a/src/agent/cli/commands/plugin_manage.py b/src/agent/cli/commands/plugin_manage.py index 684d7453..ef66ad64 100644 --- a/src/agent/cli/commands/plugin_manage.py +++ b/src/agent/cli/commands/plugin_manage.py @@ -4,6 +4,7 @@ import structlog import yaml +from agent.cli.cli_utils import MutuallyExclusiveOption from agent.config.intent import load_intent_config, save_intent_config # Note: Resolver imports removed - using uv-based workflow instead @@ -67,6 +68,173 @@ def simple_edit_distance(s1: str, s2: str) -> int: return suggestions[:max_suggestions] +@click.command() +@click.argument("plugin_name", required=True) +@click.option( + "--add-scope", + "-a", + multiple=True, + cls=MutuallyExclusiveOption, + mutually_exclusive=["remove_scope"], + help="Add scopes: -a capability_id::scope (repeatable).", +) +@click.option( + "--remove-scope", + "-r", + multiple=True, + cls=MutuallyExclusiveOption, + mutually_exclusive=["add_scope"], + help="Remove scopes: -r capability_id::scope (repeatable).", +) +@click.option("-n", "--dry-run", is_flag=True, help="Show planned changes only (File diff, no write).") +@click.option("--config", type=click.Path(path_type=Path), show_default=True) +@click.pass_context +def manage( + ctx: click.Context, + plugin_name: str, + add_scope: tuple[str, ...], + remove_scope: tuple[str, ...], + dry_run: bool, + config: Path | None = None, +): + """Add or remove scopes on a plugin capability in agentup.yml. + + Examples: + agentup plugin manage brave_search -a search_images::search:images:query + agentup plugin manage brave_search -r search_internet::search:web:query + """ + # Resolve config path + intent_config_path = config if config else (Path.cwd() / "agentup.yml") + + # Load config + try: + intent_config = load_intent_config(str(intent_config_path)) + except (FileNotFoundError, yaml.YAMLError) as e: + click.secho(f"Failed to load agentup.yml: {e}", fg="red") + ctx.exit(1) + + # Validate plugin exists in config + if not intent_config.plugins or plugin_name not in intent_config.plugins: + click.secho(f"Plugin '{plugin_name}' not found in {intent_config_path}", fg="red") + ctx.exit(1) + + if not add_scope and not remove_scope: + click.secho("Nothing to do. Use -a/--add-scope or -r/--remove-scope.", fg="yellow") + ctx.exit(0) + + # Collect planned changes (only what will be applied) + planned_changes: list[str] = [] + changed = False + + plugin_override = intent_config.plugins[plugin_name] + + for arg in add_scope: + if "::" not in arg: + click.secho(f"Invalid scope format '{arg}'. Use 'capability_id::scope'.", fg="red") + continue + capability_id, scope_name = arg.split("::", 1) + if capability_id not in plugin_override.capabilities: + click.secho(f"Capability '{capability_id}' not found for plugin '{plugin_name}'.", fg="yellow") + continue + + cap_override = plugin_override.capabilities[capability_id] + + if isinstance(cap_override, dict): + scopes = cap_override.get("required_scopes") + else: + scopes = getattr(cap_override, "required_scopes", None) + + if scopes is None: + scopes = [] + elif isinstance(scopes, str): + scopes = [scopes] + else: + scopes = list(scopes) + + if scope_name not in scopes: + scopes.append(scope_name) + scopes = sorted(set(scopes)) + planned_changes.append(f"ADD scope '{scope_name}' → capability '{capability_id}' (plugin '{plugin_name}')") + if isinstance(cap_override, dict): + cap_override["required_scopes"] = scopes + else: + cap_override.required_scopes = scopes + changed = True + else: + click.secho( + f"Scope '{scope_name}' already exists for capability '{capability_id}'.", + fg="yellow", + ) + + for arg in remove_scope: + if "::" not in arg: + click.secho(f"Invalid scope format '{arg}'. Use 'capability_id::scope'.", fg="red") + continue + capability_id, scope_name = arg.split("::", 1) + if capability_id not in plugin_override.capabilities: + click.secho(f"Capability '{capability_id}' not found for plugin '{plugin_name}'.", fg="yellow") + continue + + cap_override = plugin_override.capabilities[capability_id] + + if isinstance(cap_override, dict): + scopes = cap_override.get("required_scopes") + else: + scopes = getattr(cap_override, "required_scopes", None) + + if scopes is None: + scopes = [] + elif isinstance(scopes, str): + scopes = [scopes] + else: + scopes = list(scopes) + + if scope_name in scopes: + scopes.remove(scope_name) + planned_changes.append(f"DROP scope '{scope_name}' ← capability '{capability_id}' (plugin '{plugin_name}')") + if isinstance(cap_override, dict): + cap_override["required_scopes"] = scopes + else: + cap_override.required_scopes = scopes + changed = True + else: + click.secho( + f"Scope '{scope_name}' not found for capability '{capability_id}'.", + fg="yellow", + ) + + if not changed: + click.secho("No changes made.", fg="yellow") + ctx.exit(0) + + # Dry run output + if dry_run: + click.secho("\n--- DRY RUN: planned changes ---\n", fg="cyan") + for line in planned_changes: + click.echo(f"• {line}") + click.secho("\nNo files were written.", fg="cyan") + ctx.exit(0) + + if getattr(plugin_override, "capabilities", None): + for _cid, _cap in plugin_override.capabilities.items(): + if isinstance(_cap, dict): + rs = _cap.get("required_scopes") + _cap["required_scopes"] = [] if rs is None else ([rs] if isinstance(rs, str) else list(rs)) + else: + rs = getattr(_cap, "required_scopes", None) + _cap.required_scopes = [] if rs is None else [rs] if isinstance(rs, str) else list(rs) + + # Persist changes + try: + save_intent_config(intent_config, str(intent_config_path)) + click.secho("\nāœ“ agentup.yml has been updated.", fg="green") + for line in planned_changes: + click.echo(f" {line}") + except (yaml.YAMLError, PermissionError, OSError) as e: + click.secho(f"Failed to save agentup.yml: {e}", fg="red") + ctx.exit(1) + + @click.command() @click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes") @click.pass_context diff --git a/tests/test_cli/test_plugin_manage.py b/tests/test_cli/test_plugin_manage.py new file mode 100644 index 00000000..b78c4b1a --- /dev/null +++ b/tests/test_cli/test_plugin_manage.py @@ -0,0 +1,250 @@ +"""Manage command tests using a real, minimal agentup.yml on disk. + +We write a temp config file (the user-provided minimal YAML) and pass it via --config. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from agent.cli.commands.plugin import manage + + +MINIMAL_YAML = """ +apiVersion: v1 +name: test +description: AI Agent test Project. +version: 0.0.1 +url: http://testing.localhost +provider_organization: AgentUp +provider_url: https://agentup.dev +icon_url: https://raw.githubusercontent.com/RedDotRocket/AgentUp/refs/heads/main/assets/icon.png +documentation_url: https://docs.agentup.dev + +plugins: + brave_search: + capabilities: + search_internet: + required_scopes: + - api:read + - api:write + +plugin_defaults: + middleware: + rate_limited: + requests_per_minute: 60 + burst_size: 72 + cached: + backend_type: memory + default_ttl: 300 + max_size: 1000 + retryable: + max_attempts: 3 + initial_delay: 1.0 + max_delay: 60.0 +""" + + +@pytest.fixture() +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture() +def cfg(tmp_path: Path) -> Path: + path = tmp_path / "agentup.yml" + path.write_text(MINIMAL_YAML, encoding="utf-8") + return path + + +# -------------------- Tests -------------------- + +class TestManageWithFile: + def test_add_scope_dry_run_only_shows_changes(self, runner: CliRunner, cfg: Path): + before = cfg.read_text(encoding="utf-8") + res = runner.invoke( + manage, + [ + "brave_search", + "-a", + "search_internet::search:web:query", + "--dry-run", + "--config", + str(cfg), + ], + ) + assert res.exit_code == 0 + assert "DRY RUN" in res.output + assert "search:web:query" in res.output + after = cfg.read_text(encoding="utf-8") + assert after == before # unchanged + + def test_add_scope_persists(self, runner: CliRunner, cfg: Path): + res = runner.invoke( + manage, + [ + "brave_search", + "-a", + "search_internet::search:web:query", + "--config", + str(cfg), + ], + ) + assert res.exit_code == 0 + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + scopes = data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"] + assert "search:web:query" in scopes + + def test_add_existing_scope_noop(self, runner: CliRunner, cfg: Path): + # add once + _ = runner.invoke( + manage, + ["brave_search", "-a", "search_internet::search:web:query", "--config", str(cfg)], + ) + # add again + res = runner.invoke( + manage, + ["brave_search", "-a", "search_internet::search:web:query", "--config", str(cfg)], + ) + assert res.exit_code == 0 + assert "already exists" in res.output or "No changes" in res.output + + def test_remove_scope_persists(self, runner: CliRunner, cfg: Path): + res = runner.invoke( + manage, + ["brave_search", "-r", "search_internet::api:write", "--config", str(cfg)], + ) + assert res.exit_code == 0 + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + scopes = data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"] + assert "api:write" not in scopes + + def test_remove_missing_scope_warns(self, runner: CliRunner, cfg: Path): + res = runner.invoke( + manage, + ["brave_search", "-r", "search_internet::no:such", "--config", str(cfg)], + ) + assert res.exit_code == 0 + assert "not found" in res.output + + def test_invalid_format(self, runner: CliRunner, cfg: Path): + res = runner.invoke(manage, ["brave_search", "-a", "badformat", "--config", str(cfg)]) + assert res.exit_code in (0, 1) + assert "Invalid scope format" in res.output + + def test_unknown_plugin_errors(self, runner: CliRunner, cfg: Path): + res = runner.invoke( + manage, + ["not_a_plugin", "-a", "search_internet::search:web:query", "--config", str(cfg)], + ) + assert res.exit_code != 0 + assert "Plugin 'not_a_plugin'" in res.output + + def test_unknown_capability_warns(self, runner: CliRunner, cfg: Path): + res = runner.invoke( + manage, + ["brave_search", "-a", "no_cap::x:y", "--config", str(cfg)], + ) + assert res.exit_code == 0 + assert "Capability 'no_cap'" in res.output + + def test_no_flags_nop(self, runner: CliRunner, cfg: Path): + res = runner.invoke(manage, ["brave_search", "--config", str(cfg)]) + assert res.exit_code == 0 + assert "Nothing to do" in res.output + + def test_multiple_adds_same_cap(self, runner: CliRunner, cfg: Path): + res = runner.invoke( + manage, + [ + "brave_search", + "-a", + "search_internet::s:one", + "-a", + "search_internet::s:two", + "--config", + str(cfg), + ], + ) + assert res.exit_code == 0 + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + scopes = data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"] + assert {"s:one", "s:two"}.issubset(scopes) + + def test_config_path_not_found(self, runner: CliRunner, tmp_path: Path): + missing = tmp_path / "missing.yml" + res = runner.invoke( + manage, + ["brave_search", "-a", "search_internet::s:x", "--config", str(missing)], + ) + assert res.exit_code != 0 + assert "Plugin 'brave_search' not found" in res.output + + def test_add_scope_to_empty_capability_dict(self, runner: CliRunner, cfg: Path): + # Make capability node an empty dict {} + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + data["plugins"]["brave_search"]["capabilities"]["search_internet"] = {} + cfg.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + res = runner.invoke( + manage, + [ + "brave_search", + "-a", + "search_internet::added:scope", + "--config", + str(cfg), + ], + ) + assert res.exit_code == 0 + after = yaml.safe_load(cfg.read_text(encoding="utf-8")) + scopes = after["plugins"]["brave_search"]["capabilities"]["search_internet"].get("required_scopes", []) + assert scopes == ["added:scope"] + + def test_remove_last_scope_results_empty_list(self, runner: CliRunner, cfg: Path): + # Seed a single scope then remove it; we expect an empty dict to persist + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"] = [ + "only:one" + ] + cfg.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + res = runner.invoke( + manage, + ["brave_search", "-r", "search_internet::only:one", "--config", str(cfg)], + ) + assert res.exit_code == 0 + after = yaml.safe_load(cfg.read_text(encoding="utf-8")) + assert after["plugins"]["brave_search"]["capabilities"]["search_internet"] == {} + + + def test_handles_null_required_scopes_then_adds(self, runner: CliRunner, cfg: Path): + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + data["plugins"]["brave_search"]["capabilities"]["search_internet"] = {} + cfg.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + res = runner.invoke( + manage, + ["brave_search", "-a", "search_internet::z:y", "--config", str(cfg)], + ) + assert res.exit_code == 0 + after = yaml.safe_load(cfg.read_text(encoding="utf-8")) + assert after["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"] == [ + "z:y" + ] + + def test_remove_when_required_scopes_missing_warns(self, runner: CliRunner, cfg: Path): + # Make the capability an empty dict and try to remove a scope that isn't there + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + data["plugins"]["brave_search"]["capabilities"]["search_internet"] = {} + cfg.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + res = runner.invoke( + manage, + ["brave_search", "-r", "search_internet::no:match", "--config", str(cfg)], + ) + assert res.exit_code == 0 + assert "not found" in res.output