|  | 
| 4 | 4 | import structlog | 
| 5 | 5 | import yaml | 
| 6 | 6 | 
 | 
|  | 7 | +from agent.cli.cli_utils import MutuallyExclusiveOption | 
| 7 | 8 | from agent.config.intent import load_intent_config, save_intent_config | 
| 8 | 9 | 
 | 
| 9 | 10 | # Note: Resolver imports removed - using uv-based workflow instead | 
| @@ -67,6 +68,173 @@ def simple_edit_distance(s1: str, s2: str) -> int: | 
| 67 | 68 |     return suggestions[:max_suggestions] | 
| 68 | 69 | 
 | 
| 69 | 70 | 
 | 
|  | 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 | + | 
| 70 | 238 | @click.command() | 
| 71 | 239 | @click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes") | 
| 72 | 240 | @click.pass_context | 
|  | 
0 commit comments