Skip to content

Commit c7f46af

Browse files
committed
feat(cli): manage plugin capability scopes via command args
- Add `plugin manage` support to **add/remove** capability scopes with: - `-a/--add-scopes <capability_id>::<scope>` - `-r/--remove-scopes <capability_id>::<scope>` - Enforce mutual exclusivity between `-a` and `-r`. - `--dry-run` prints *only* planned changes (no file diffs, no writes). - `--config <path>` allows targeting a specific `agentup.yml`. - Clear UX messages for added/removed/duplicate/missing scopes & capabilities. Tests: - Add manage-only tests covering add/remove, dry-run, duplicates, invalid format, unknown plugin/capability, multi-adds, and missing config path, using a real minimal `agentup.yml` fixture. Signed-off-by: SequeI <[email protected]>
1 parent 32c72e2 commit c7f46af

File tree

4 files changed

+336
-1
lines changed

4 files changed

+336
-1
lines changed

src/agent/cli/cli_utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,19 @@ def add_command(self, cmd, name=None):
3838

3939
def list_commands(self, ctx):
4040
return self.commands.keys()
41+
42+
43+
class MutuallyExclusiveOption(click.Option):
44+
"""Error when this option is used together with any of `mutually_exclusive`."""
45+
46+
def __init__(self, *args, **kwargs):
47+
self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", []))
48+
super().__init__(*args, **kwargs)
49+
50+
def handle_parse_result(self, ctx, opts, args):
51+
other_used = [name for name in self.mutually_exclusive if opts.get(name)]
52+
if other_used and opts.get(self.name):
53+
raise click.UsageError(
54+
f"Option '{self.name.replace('_', '-')}' is mutually exclusive with: {', '.join(other_used)}"
55+
)
56+
return super().handle_parse_result(ctx, opts, args)

src/agent/cli/commands/plugin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
# Import subcommands from specialized modules
88
from .plugin_init import init
9-
from .plugin_manage import add, reload, remove, sync
9+
from .plugin_manage import add, manage, reload, remove, sync
1010

1111
# Export all commands and functions
1212
__all__ = [
@@ -21,6 +21,7 @@
2121
"config",
2222
"validate",
2323
"get_version",
24+
"manage",
2425
]
2526

2627

@@ -41,3 +42,4 @@ def plugin():
4142
plugin.add_command(info)
4243
plugin.add_command(config)
4344
plugin.add_command(validate)
45+
plugin.add_command(manage)

src/agent/cli/commands/plugin_manage.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import structlog
55
import yaml
66

7+
from agent.cli.cli_utils import MutuallyExclusiveOption
78
from agent.config.intent import load_intent_config, save_intent_config
89

910
# Note: Resolver imports removed - using uv-based workflow instead
@@ -67,6 +68,137 @@ def simple_edit_distance(s1: str, s2: str) -> int:
6768
return suggestions[:max_suggestions]
6869

6970

71+
@click.command()
72+
@click.argument("plugin_name", required=True)
73+
@click.option(
74+
"--add-scope",
75+
"-a",
76+
"--add_scope",
77+
multiple=True,
78+
cls=MutuallyExclusiveOption,
79+
mutually_exclusive=["remove_scope"],
80+
help="Add scopes: -a capability_id::scope (repeatable).",
81+
)
82+
@click.option(
83+
"--remove-scope",
84+
"-r",
85+
"--remove_scope",
86+
multiple=True,
87+
cls=MutuallyExclusiveOption,
88+
mutually_exclusive=["add_scope"],
89+
help="Remove scopes: -r capability_id::scope (repeatable).",
90+
)
91+
@click.option("-n", "--dry-run", is_flag=True, help="Show planned changes only (File diff, no write).")
92+
@click.option("--config", type=click.Path(path_type=Path), default=Path("agentup.yml"), show_default=True)
93+
@click.pass_context
94+
def manage(
95+
ctx: click.Context,
96+
plugin_name: str,
97+
add_scope: tuple[str, ...],
98+
remove_scope: tuple[str, ...],
99+
dry_run: bool,
100+
config: Path | None,
101+
):
102+
"""Add or remove scopes on a plugin capability in agentup.yml.
103+
104+
105+
Examples:
106+
agentup plugin manage brave_search -a search_images::search:images:query
107+
agentup plugin manage brave_search -r search_internet::search:web:query
108+
"""
109+
# Resolve config path
110+
intent_config_path = config if config else (Path.cwd() / "agentup.yml")
111+
112+
# Load config
113+
try:
114+
intent_config = load_intent_config(str(intent_config_path))
115+
except (FileNotFoundError, yaml.YAMLError) as e:
116+
click.secho(f"Failed to load agentup.yml: {e}", fg="red")
117+
ctx.exit(1)
118+
119+
# Validate plugin exists in config
120+
if not intent_config.plugins or plugin_name not in intent_config.plugins:
121+
click.secho(f"Plugin '{plugin_name}' not found in {intent_config_path}", fg="red")
122+
ctx.exit(1)
123+
124+
if not add_scope and not remove_scope:
125+
click.secho("Nothing to do. Use -a/--add-scope or -r/--remove-scope.", fg="yellow")
126+
ctx.exit(0)
127+
128+
# Collect planned changes (only what will be applied)
129+
planned_changes: list[str] = []
130+
changed = False
131+
132+
plugin_override = intent_config.plugins[plugin_name]
133+
134+
# Add scopes
135+
for arg in add_scope:
136+
if "::" not in arg:
137+
click.secho(f"Invalid scope format '{arg}'. Use 'capability_id::scope'.", fg="red")
138+
continue
139+
capability_id, scope_name = arg.split("::", 1)
140+
if capability_id not in plugin_override.capabilities:
141+
click.secho(f"Capability '{capability_id}' not found for plugin '{plugin_name}'.", fg="yellow")
142+
continue
143+
cap_override = plugin_override.capabilities[capability_id]
144+
if cap_override.required_scopes is None:
145+
cap_override.required_scopes = []
146+
if scope_name not in cap_override.required_scopes:
147+
planned_changes.append(f"ADD scope '{scope_name}' → capability '{capability_id}' (plugin '{plugin_name}')")
148+
cap_override.required_scopes.append(scope_name)
149+
changed = True
150+
else:
151+
click.secho(
152+
f"Scope '{scope_name}' already exists for capability '{capability_id}'.",
153+
fg="yellow",
154+
)
155+
156+
# Remove scopes
157+
for arg in remove_scope:
158+
if "::" not in arg:
159+
click.secho(f"Invalid scope format '{arg}'. Use 'capability_id::scope'.", fg="red")
160+
continue
161+
capability_id, scope_name = arg.split("::", 1)
162+
if capability_id not in plugin_override.capabilities:
163+
click.secho(f"Capability '{capability_id}' not found for plugin '{plugin_name}'.", fg="yellow")
164+
continue
165+
cap_override = plugin_override.capabilities[capability_id]
166+
scopes = cap_override.required_scopes or []
167+
if scope_name in scopes:
168+
planned_changes.append(f"DROP scope '{scope_name}' ← capability '{capability_id}' (plugin '{plugin_name}')")
169+
scopes.remove(scope_name)
170+
cap_override.required_scopes = scopes
171+
changed = True
172+
else:
173+
click.secho(
174+
f"Scope '{scope_name}' not found for capability '{capability_id}'.",
175+
fg="yellow",
176+
)
177+
178+
if not changed:
179+
click.secho("No changes made.", fg="yellow")
180+
ctx.exit(0)
181+
182+
# Dry run output: show only the planned changes list
183+
if dry_run:
184+
click.secho("\n--- DRY RUN: planned changes ---\n", fg="cyan")
185+
for line in planned_changes:
186+
click.echo(f"• {line}")
187+
click.secho("\nNo files were written.", fg="cyan")
188+
ctx.exit(0)
189+
190+
# Persist changes
191+
try:
192+
save_intent_config(intent_config, str(intent_config_path))
193+
click.secho("\n✓ agentup.yml has been updated.", fg="green")
194+
# Show a concise summary of what changed (same as dry-run lines)
195+
for line in planned_changes:
196+
click.echo(f" {line}")
197+
except (yaml.YAMLError, PermissionError, OSError) as e:
198+
click.secho(f"Failed to save agentup.yml: {e}", fg="red")
199+
ctx.exit(1)
200+
201+
70202
@click.command()
71203
@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
72204
@click.pass_context
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Manage command tests using a real, minimal agentup.yml on disk.
2+
3+
We write a temp config file (the user-provided minimal YAML) and pass it via --config.
4+
"""
5+
from __future__ import annotations
6+
7+
from pathlib import Path
8+
9+
import pytest
10+
import yaml
11+
from click.testing import CliRunner
12+
13+
from agent.cli.commands.plugin import manage
14+
15+
16+
MINIMAL_YAML = """
17+
apiVersion: v1
18+
name: test
19+
description: AI Agent test Project.
20+
version: 0.0.1
21+
url: http://testing.localhost
22+
provider_organization: AgentUp
23+
provider_url: https://agentup.dev
24+
icon_url: https://raw.githubusercontent.com/RedDotRocket/AgentUp/refs/heads/main/assets/icon.png
25+
documentation_url: https://docs.agentup.dev
26+
27+
plugins:
28+
brave_search:
29+
capabilities:
30+
search_internet:
31+
required_scopes:
32+
- api:read
33+
- api:write
34+
35+
plugin_defaults:
36+
middleware:
37+
rate_limited:
38+
requests_per_minute: 60
39+
burst_size: 72
40+
cached:
41+
backend_type: memory
42+
default_ttl: 300
43+
max_size: 1000
44+
retryable:
45+
max_attempts: 3
46+
initial_delay: 1.0
47+
max_delay: 60.0
48+
"""
49+
50+
51+
@pytest.fixture()
52+
def runner() -> CliRunner:
53+
return CliRunner()
54+
55+
56+
@pytest.fixture()
57+
def cfg(tmp_path: Path) -> Path:
58+
path = tmp_path / "agentup.yml"
59+
path.write_text(MINIMAL_YAML, encoding="utf-8")
60+
return path
61+
62+
63+
# -------------------- Tests --------------------
64+
65+
class TestManageWithFile:
66+
def test_add_scope_dry_run_only_shows_changes(self, runner: CliRunner, cfg: Path):
67+
before = cfg.read_text(encoding="utf-8")
68+
res = runner.invoke(
69+
manage,
70+
[
71+
"brave_search",
72+
"-a",
73+
"search_internet::search:web:query",
74+
"--dry-run",
75+
"--config",
76+
str(cfg),
77+
],
78+
)
79+
assert res.exit_code == 0
80+
assert "DRY RUN" in res.output
81+
assert "search:web:query" in res.output
82+
after = cfg.read_text(encoding="utf-8")
83+
assert after == before # unchanged
84+
85+
def test_add_scope_persists(self, runner: CliRunner, cfg: Path):
86+
res = runner.invoke(
87+
manage,
88+
[
89+
"brave_search",
90+
"-a",
91+
"search_internet::search:web:query",
92+
"--config",
93+
str(cfg),
94+
],
95+
)
96+
assert res.exit_code == 0
97+
data = yaml.safe_load(cfg.read_text(encoding="utf-8"))
98+
scopes = data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"]
99+
assert "search:web:query" in scopes
100+
101+
def test_add_existing_scope_noop(self, runner: CliRunner, cfg: Path):
102+
# add once
103+
_ = runner.invoke(
104+
manage,
105+
["brave_search", "-a", "search_internet::search:web:query", "--config", str(cfg)],
106+
)
107+
# add again
108+
res = runner.invoke(
109+
manage,
110+
["brave_search", "-a", "search_internet::search:web:query", "--config", str(cfg)],
111+
)
112+
assert res.exit_code == 0
113+
assert "already exists" in res.output or "No changes" in res.output
114+
115+
def test_remove_scope_persists(self, runner: CliRunner, cfg: Path):
116+
res = runner.invoke(
117+
manage,
118+
["brave_search", "-r", "search_internet::api:write", "--config", str(cfg)],
119+
)
120+
assert res.exit_code == 0
121+
data = yaml.safe_load(cfg.read_text(encoding="utf-8"))
122+
scopes = data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"]
123+
assert "api:write" not in scopes
124+
125+
def test_remove_missing_scope_warns(self, runner: CliRunner, cfg: Path):
126+
res = runner.invoke(
127+
manage,
128+
["brave_search", "-r", "search_internet::no:such", "--config", str(cfg)],
129+
)
130+
assert res.exit_code == 0
131+
assert "not found" in res.output
132+
133+
def test_invalid_format(self, runner: CliRunner, cfg: Path):
134+
res = runner.invoke(manage, ["brave_search", "-a", "badformat", "--config", str(cfg)])
135+
assert res.exit_code in (0, 1)
136+
assert "Invalid scope format" in res.output
137+
138+
def test_unknown_plugin_errors(self, runner: CliRunner, cfg: Path):
139+
res = runner.invoke(
140+
manage,
141+
["not_a_plugin", "-a", "search_internet::search:web:query", "--config", str(cfg)],
142+
)
143+
assert res.exit_code != 0
144+
assert "Plugin 'not_a_plugin'" in res.output
145+
146+
def test_unknown_capability_warns(self, runner: CliRunner, cfg: Path):
147+
res = runner.invoke(
148+
manage,
149+
["brave_search", "-a", "no_cap::x:y", "--config", str(cfg)],
150+
)
151+
assert res.exit_code == 0
152+
assert "Capability 'no_cap'" in res.output
153+
154+
def test_no_flags_nop(self, runner: CliRunner, cfg: Path):
155+
res = runner.invoke(manage, ["brave_search", "--config", str(cfg)])
156+
assert res.exit_code == 0
157+
assert "Nothing to do" in res.output
158+
159+
def test_multiple_adds_same_cap(self, runner: CliRunner, cfg: Path):
160+
res = runner.invoke(
161+
manage,
162+
[
163+
"brave_search",
164+
"-a",
165+
"search_internet::s:one",
166+
"-a",
167+
"search_internet::s:two",
168+
"--config",
169+
str(cfg),
170+
],
171+
)
172+
assert res.exit_code == 0
173+
data = yaml.safe_load(cfg.read_text(encoding="utf-8"))
174+
scopes = data["plugins"]["brave_search"]["capabilities"]["search_internet"]["required_scopes"]
175+
assert {"s:one", "s:two"}.issubset(scopes)
176+
177+
def test_config_path_not_found(self, runner: CliRunner, tmp_path: Path):
178+
missing = tmp_path / "missing.yml"
179+
res = runner.invoke(
180+
manage,
181+
["brave_search", "-a", "search_internet::s:x", "--config", str(missing)],
182+
)
183+
assert res.exit_code != 0
184+
assert "Plugin 'brave_search' not found" in res.output
185+

0 commit comments

Comments
 (0)