Skip to content

Commit 6c3cdd8

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 6c3cdd8

File tree

4 files changed

+437
-1
lines changed

4 files changed

+437
-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(n.replace('_', '-') for n in 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: 168 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,173 @@ 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+
multiple=True,
77+
cls=MutuallyExclusiveOption,
78+
mutually_exclusive=["remove_scope"],
79+
help="Add scopes: -a capability_id::scope (repeatable).",
80+
)
81+
@click.option(
82+
"--remove-scope",
83+
"-r",
84+
multiple=True,
85+
cls=MutuallyExclusiveOption,
86+
mutually_exclusive=["add_scope"],
87+
help="Remove scopes: -r capability_id::scope (repeatable).",
88+
)
89+
@click.option("-n", "--dry-run", is_flag=True, help="Show planned changes only (File diff, no write).")
90+
@click.option("--config", type=click.Path(path_type=Path), show_default=True)
91+
@click.pass_context
92+
def manage(
93+
ctx: click.Context,
94+
plugin_name: str,
95+
add_scope: tuple[str, ...],
96+
remove_scope: tuple[str, ...],
97+
dry_run: bool,
98+
config: Path | None = None,
99+
):
100+
"""Add or remove scopes on a plugin capability in agentup.yml.
101+
102+
Examples:
103+
agentup plugin manage brave_search -a search_images::search:images:query
104+
agentup plugin manage brave_search -r search_internet::search:web:query
105+
"""
106+
# Resolve config path
107+
intent_config_path = config if config else (Path.cwd() / "agentup.yml")
108+
109+
# Load config
110+
try:
111+
intent_config = load_intent_config(str(intent_config_path))
112+
except (FileNotFoundError, yaml.YAMLError) as e:
113+
click.secho(f"Failed to load agentup.yml: {e}", fg="red")
114+
ctx.exit(1)
115+
116+
# Validate plugin exists in config
117+
if not intent_config.plugins or plugin_name not in intent_config.plugins:
118+
click.secho(f"Plugin '{plugin_name}' not found in {intent_config_path}", fg="red")
119+
ctx.exit(1)
120+
121+
if not add_scope and not remove_scope:
122+
click.secho("Nothing to do. Use -a/--add-scope or -r/--remove-scope.", fg="yellow")
123+
ctx.exit(0)
124+
125+
# Collect planned changes (only what will be applied)
126+
planned_changes: list[str] = []
127+
changed = False
128+
129+
plugin_override = intent_config.plugins[plugin_name]
130+
131+
for arg in add_scope:
132+
if "::" not in arg:
133+
click.secho(f"Invalid scope format '{arg}'. Use 'capability_id::scope'.", fg="red")
134+
continue
135+
capability_id, scope_name = arg.split("::", 1)
136+
if capability_id not in plugin_override.capabilities:
137+
click.secho(f"Capability '{capability_id}' not found for plugin '{plugin_name}'.", fg="yellow")
138+
continue
139+
140+
cap_override = plugin_override.capabilities[capability_id]
141+
142+
if isinstance(cap_override, dict):
143+
scopes = cap_override.get("required_scopes")
144+
else:
145+
scopes = getattr(cap_override, "required_scopes", None)
146+
147+
if scopes is None:
148+
scopes = []
149+
elif isinstance(scopes, str):
150+
scopes = [scopes]
151+
else:
152+
scopes = list(scopes)
153+
154+
if scope_name not in scopes:
155+
scopes.append(scope_name)
156+
scopes = sorted(set(scopes))
157+
planned_changes.append(f"ADD scope '{scope_name}' → capability '{capability_id}' (plugin '{plugin_name}')")
158+
if isinstance(cap_override, dict):
159+
cap_override["required_scopes"] = scopes
160+
else:
161+
cap_override.required_scopes = scopes
162+
changed = True
163+
else:
164+
click.secho(
165+
f"Scope '{scope_name}' already exists for capability '{capability_id}'.",
166+
fg="yellow",
167+
)
168+
169+
for arg in remove_scope:
170+
if "::" not in arg:
171+
click.secho(f"Invalid scope format '{arg}'. Use 'capability_id::scope'.", fg="red")
172+
continue
173+
capability_id, scope_name = arg.split("::", 1)
174+
if capability_id not in plugin_override.capabilities:
175+
click.secho(f"Capability '{capability_id}' not found for plugin '{plugin_name}'.", fg="yellow")
176+
continue
177+
178+
cap_override = plugin_override.capabilities[capability_id]
179+
180+
if isinstance(cap_override, dict):
181+
scopes = cap_override.get("required_scopes")
182+
else:
183+
scopes = getattr(cap_override, "required_scopes", None)
184+
185+
if scopes is None:
186+
scopes = []
187+
elif isinstance(scopes, str):
188+
scopes = [scopes]
189+
else:
190+
scopes = list(scopes)
191+
192+
if scope_name in scopes:
193+
scopes.remove(scope_name)
194+
planned_changes.append(f"DROP scope '{scope_name}' ← capability '{capability_id}' (plugin '{plugin_name}')")
195+
if isinstance(cap_override, dict):
196+
cap_override["required_scopes"] = scopes
197+
else:
198+
cap_override.required_scopes = scopes
199+
changed = True
200+
else:
201+
click.secho(
202+
f"Scope '{scope_name}' not found for capability '{capability_id}'.",
203+
fg="yellow",
204+
)
205+
206+
if not changed:
207+
click.secho("No changes made.", fg="yellow")
208+
ctx.exit(0)
209+
210+
# Dry run output
211+
if dry_run:
212+
click.secho("\n--- DRY RUN: planned changes ---\n", fg="cyan")
213+
for line in planned_changes:
214+
click.echo(f"• {line}")
215+
click.secho("\nNo files were written.", fg="cyan")
216+
ctx.exit(0)
217+
218+
if getattr(plugin_override, "capabilities", None):
219+
for _cid, _cap in plugin_override.capabilities.items():
220+
if isinstance(_cap, dict):
221+
rs = _cap.get("required_scopes")
222+
_cap["required_scopes"] = [] if rs is None else ([rs] if isinstance(rs, str) else list(rs))
223+
else:
224+
rs = getattr(_cap, "required_scopes", None)
225+
_cap.required_scopes = [] if rs is None else [rs] if isinstance(rs, str) else list(rs)
226+
227+
# Persist changes
228+
try:
229+
save_intent_config(intent_config, str(intent_config_path))
230+
click.secho("\n✓ agentup.yml has been updated.", fg="green")
231+
for line in planned_changes:
232+
click.echo(f" {line}")
233+
except (yaml.YAMLError, PermissionError, OSError) as e:
234+
click.secho(f"Failed to save agentup.yml: {e}", fg="red")
235+
ctx.exit(1)
236+
237+
70238
@click.command()
71239
@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
72240
@click.pass_context

0 commit comments

Comments
 (0)