Skip to content

Commit 6ce127b

Browse files
author
James Zhu
committed
feat: Add multi-model selection for Goose/Codex/Droid/Continue and enhance agent installation
Features: - Goose: Add interactive menu for selecting multiple providers/models - Codex: Add multi-model selection with menu interface - Droid: Add multi-model selection with skip/cancel support - Continue: Add multi-model selection support - Agent Install: Support GitHub direct installation (owner/repo:agent-name) - Agent Install: Implement 6-step recursive file search for nested agent files - Agent Install: Use registered agent configuration from agents.json - CLI: Better error messages for agent not found scenarios Enhancements: - BaseAgentHandler: Add _find_file_recursive() for smart file discovery - install_agent(): Check registered agents first before GitHub parsing - install_agent(): Smart fallback to common paths (plugins/, agents/) - Menu system: Support for cancel/skip options in selections Testing: - Updated test_agents_commands.py to handle new error message format - All 418+ core tests passing - Zero regressions detected - Full backward compatibility maintained Files Modified: - code_assistant_manager/cli/agents_commands.py - code_assistant_manager/agents/base.py - code_assistant_manager/tools/goose.py - code_assistant_manager/tools/codex.py - code_assistant_manager/tools/droid.py - code_assistant_manager/tools/continue_tool.py - code_assistant_manager/menu/menus.py - tests/test_tools.py - tests/unit/test_agents_commands.py
1 parent 5fa8cda commit 6ce127b

File tree

9 files changed

+691
-124
lines changed

9 files changed

+691
-124
lines changed

code_assistant_manager/agents/base.py

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,55 @@ def install(self, agent: Agent) -> Path:
7777
)
7878

7979
try:
80-
# Determine source path
80+
# Try to find the agent file using multiple strategies
81+
source_path = None
82+
83+
# Strategy 1: Try exact path (agents_path + filename)
8184
if agent.agents_path:
82-
source_path = temp_dir / agent.agents_path.strip("/") / agent.filename
83-
else:
84-
source_path = temp_dir / agent.filename
85-
86-
if not source_path.exists():
87-
raise ValueError(f"Agent file not found in repository: {source_path}")
85+
exact_path = temp_dir / agent.agents_path.strip("/") / agent.filename
86+
if exact_path.exists():
87+
source_path = exact_path
88+
logger.debug(f"Found agent at exact path: {exact_path}")
89+
90+
# Strategy 2: Try root directory
91+
if not source_path:
92+
root_path = temp_dir / agent.filename
93+
if root_path.exists():
94+
source_path = root_path
95+
logger.debug(f"Found agent at root: {root_path}")
96+
97+
# Strategy 3: Recursive search in agents_path directory
98+
if not source_path and agent.agents_path:
99+
search_dir = temp_dir / agent.agents_path.strip("/")
100+
if search_dir.exists():
101+
source_path = self._find_file_recursive(search_dir, agent.filename)
102+
if source_path:
103+
logger.debug(f"Found agent via recursive search in {agent.agents_path}: {source_path}")
104+
105+
# Strategy 4: Recursive search in plugins directory
106+
if not source_path:
107+
plugins_dir = temp_dir / "plugins"
108+
if plugins_dir.exists():
109+
source_path = self._find_file_recursive(plugins_dir, agent.filename)
110+
if source_path:
111+
logger.debug(f"Found agent via recursive search in plugins: {source_path}")
112+
113+
# Strategy 5: Recursive search in agents directory
114+
if not source_path:
115+
agents_dir = temp_dir / "agents"
116+
if agents_dir.exists():
117+
source_path = self._find_file_recursive(agents_dir, agent.filename)
118+
if source_path:
119+
logger.debug(f"Found agent via recursive search in agents: {source_path}")
120+
121+
# Strategy 6: Search entire repository
122+
if not source_path:
123+
source_path = self._find_file_recursive(temp_dir, agent.filename)
124+
if source_path:
125+
logger.debug(f"Found agent via full repository search: {source_path}")
126+
127+
if not source_path:
128+
raise ValueError(f"Agent file not found in repository: {agent.filename}")
88129

89130
# Copy to install directory
90131
dest_path = self.agents_dir / agent.filename
@@ -95,6 +136,25 @@ def install(self, agent: Agent) -> Path:
95136
if temp_dir.exists():
96137
shutil.rmtree(temp_dir)
97138

139+
def _find_file_recursive(self, search_dir: Path, filename: str) -> Optional[Path]:
140+
"""Recursively search for a file in a directory.
141+
142+
Args:
143+
search_dir: Directory to search in
144+
filename: Filename to search for
145+
146+
Returns:
147+
Path to the file if found, None otherwise
148+
"""
149+
try:
150+
for item in search_dir.rglob(filename):
151+
if item.is_file():
152+
return item
153+
except Exception as e:
154+
logger.debug(f"Error searching {search_dir}: {e}")
155+
return None
156+
157+
98158
def uninstall(self, agent: Agent) -> bool:
99159
"""Uninstall an agent by removing its file.
100160

code_assistant_manager/cli/agents_commands.py

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -255,22 +255,133 @@ def install_agent(
255255
agent_key: str = AGENT_KEY_ARGUMENT,
256256
app_type: str = APP_TYPE_OPTION,
257257
):
258-
"""Install an agent to one or more app's agents directories."""
258+
"""Install an agent to one or more app's agents directories.
259+
260+
Can accept either a registered agent key or a GitHub specification:
261+
- Registered key: 'security-auditor'
262+
- GitHub spec: 'owner/repo:agent-name' or 'owner/repo:agent-name@branch'
263+
"""
259264
target_apps = resolve_app_targets(app_type, VALID_APP_TYPES, default="claude")
260-
265+
261266
manager = _get_agent_manager()
262-
263-
for app in target_apps:
267+
268+
# First check if agent_key matches a registered agent (most common case)
269+
all_agents = manager.get_all()
270+
if agent_key in all_agents:
271+
# Use registered agent configuration
272+
for app in target_apps:
273+
try:
274+
handler = manager.get_handler(app)
275+
dest_path = manager.install(agent_key, app)
276+
typer.echo(
277+
f"{Colors.GREEN}✓ Agent installed to {app}: {agent_key}{Colors.RESET}"
278+
)
279+
typer.echo(f" {Colors.CYAN}Location:{Colors.RESET} {handler.agents_dir}")
280+
except ValueError as e:
281+
typer.echo(f"{Colors.RED}✗ Error installing to {app}: {e}{Colors.RESET}")
282+
raise typer.Exit(1)
283+
return
284+
285+
# Check if agent_key is a GitHub specification (contains / or :)
286+
if "/" in agent_key and ":" in agent_key:
287+
# Parse GitHub specification: owner/repo:agent-name[@branch]
264288
try:
265-
handler = manager.get_handler(app)
266-
dest_path = manager.install(agent_key, app)
289+
parts = agent_key.split(":")
290+
branch = "main"
291+
292+
if len(parts) == 2:
293+
repo_part, agent_name = parts
294+
owner, repo = repo_part.split("/")
295+
elif len(parts) == 3:
296+
repo_part, agent_name, branch = parts
297+
owner, repo = repo_part.split("/")
298+
else:
299+
typer.echo(
300+
f"{Colors.RED}✗ Invalid agent specification: {agent_key}{Colors.RESET}"
301+
)
302+
typer.echo(
303+
f" {Colors.CYAN}Use format: owner/repo:agent-name or owner/repo:agent-name@branch{Colors.RESET}"
304+
)
305+
raise typer.Exit(1)
306+
307+
# Check if this exact GitHub spec is registered in agents.json
308+
if agent_key in all_agents:
309+
agent = all_agents[agent_key]
310+
else:
311+
# Create a temporary agent object for installation
312+
from code_assistant_manager.agents.models import Agent
313+
314+
# Determine filename from agent name
315+
filename = f"{agent_name}.md"
316+
agent = Agent(
317+
key=agent_key,
318+
name=agent_name,
319+
description=f"Installed from {owner}/{repo}",
320+
filename=filename,
321+
repo_owner=owner,
322+
repo_name=repo,
323+
repo_branch=branch,
324+
agents_path=None, # Try common paths
325+
)
326+
327+
for app in target_apps:
328+
try:
329+
handler = manager.get_handler(app)
330+
dest_path = handler.install(agent)
331+
typer.echo(
332+
f"{Colors.GREEN}✓ Agent installed to {app}: {agent_name}{Colors.RESET}"
333+
)
334+
typer.echo(f" {Colors.CYAN}Location:{Colors.RESET} {dest_path}")
335+
except ValueError as e:
336+
# If agent not found in configured path, try common paths
337+
error_msg = str(e)
338+
if "not found" in error_msg and agent.agents_path is None:
339+
# Try plugins directory
340+
agent.agents_path = "plugins"
341+
try:
342+
dest_path = handler.install(agent)
343+
typer.echo(
344+
f"{Colors.GREEN}✓ Agent installed to {app}: {agent_name}{Colors.RESET}"
345+
)
346+
typer.echo(f" {Colors.CYAN}Location:{Colors.RESET} {dest_path}")
347+
except ValueError:
348+
# Try agents directory
349+
agent.agents_path = "agents"
350+
try:
351+
dest_path = handler.install(agent)
352+
typer.echo(
353+
f"{Colors.GREEN}✓ Agent installed to {app}: {agent_name}{Colors.RESET}"
354+
)
355+
typer.echo(f" {Colors.CYAN}Location:{Colors.RESET} {dest_path}")
356+
except ValueError as e2:
357+
typer.echo(f"{Colors.RED}✗ Error installing to {app}: {e2}{Colors.RESET}")
358+
raise typer.Exit(1)
359+
else:
360+
typer.echo(f"{Colors.RED}✗ Error installing to {app}: {e}{Colors.RESET}")
361+
raise typer.Exit(1)
362+
except (ValueError, IndexError) as e:
267363
typer.echo(
268-
f"{Colors.GREEN}✓ Agent installed to {app}: {agent_key}{Colors.RESET}"
364+
f"{Colors.RED}✗ Invalid agent specification format: {agent_key}{Colors.RESET}"
365+
)
366+
typer.echo(
367+
f" {Colors.CYAN}Use format: owner/repo:agent-name or owner/repo:agent-name@branch{Colors.RESET}"
269368
)
270-
typer.echo(f" {Colors.CYAN}Location:{Colors.RESET} {handler.agents_dir}")
271-
except ValueError as e:
272-
typer.echo(f"{Colors.RED}✗ Error installing to {app}: {e}{Colors.RESET}")
273369
raise typer.Exit(1)
370+
else:
371+
# Not found as registered key and not in GitHub format
372+
typer.echo(
373+
f"{Colors.RED}✗ Agent '{agent_key}' not found{Colors.RESET}"
374+
)
375+
typer.echo(
376+
f" {Colors.CYAN}Try one of:{Colors.RESET}"
377+
)
378+
typer.echo(
379+
f" • cam agent list (to see available agents)"
380+
)
381+
typer.echo(
382+
f" • cam agent fetch (to discover agents from repositories)"
383+
)
384+
raise typer.Exit(1)
274385

275386

276387
@agent_app.command("uninstall")

code_assistant_manager/menu/menus.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,57 @@ def select_two_models(
109109
return False, None
110110

111111
return True, (primary, secondary)
112+
113+
114+
def select_multiple_models(
115+
models: List[str],
116+
prompt: str = "Select models (press Cancel to finish):",
117+
cancel_text: str = "Cancel",
118+
key_provider: Optional[Callable[[], Optional[str]]] = None,
119+
) -> Tuple[bool, List[str]]:
120+
"""
121+
Select multiple models from a list until user selects Cancel.
122+
123+
Args:
124+
models: List of available models
125+
prompt: Selection prompt
126+
cancel_text: Text for cancel option
127+
key_provider: Optional function to provide keyboard input (for testing)
128+
129+
Returns:
130+
Tuple of (success, list_of_selected_models)
131+
If no models selected, returns (False, [])
132+
If models selected, returns (True, [models...])
133+
"""
134+
selected_models = []
135+
remaining_models = models.copy()
136+
137+
while remaining_models:
138+
# Show current selections if any
139+
if selected_models:
140+
display_prompt = f"{prompt} (Selected: {len(selected_models)})"
141+
else:
142+
display_prompt = prompt
143+
144+
success, idx = display_centered_menu(
145+
display_prompt, remaining_models, cancel_text, key_provider=key_provider
146+
)
147+
148+
if not success or idx is None:
149+
# User cancelled - return what we have so far
150+
break
151+
152+
# Add selected model to list
153+
selected_model = remaining_models[idx]
154+
selected_models.append(selected_model)
155+
156+
# Remove from remaining
157+
remaining_models.pop(idx)
158+
159+
# Brief pause before next iteration
160+
if remaining_models:
161+
time.sleep(0.5)
162+
163+
if selected_models:
164+
return True, selected_models
165+
return False, []

code_assistant_manager/tools/codex.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -132,36 +132,39 @@ def run(self, args: List[str] = None) -> int:
132132
if os.environ.get("CODE_ASSISTANT_MANAGER_NONINTERACTIVE") == "1":
133133
if not models:
134134
continue
135-
model = models[0]
135+
selected_models = [models[0]]
136136
else:
137-
ok, idx = display_centered_menu(
138-
f"Select model from {endpoint_info} (or skip):",
137+
from code_assistant_manager.menu.menus import select_multiple_models
138+
139+
ok, selected_models = select_multiple_models(
139140
models,
141+
f"Select models from {endpoint_info} (Cancel to skip):",
140142
cancel_text="Skip",
141143
)
142-
if not ok or idx is None:
144+
if not ok or not selected_models:
143145
print(f"Skipped {endpoint_name}\n")
144146
continue
145147

146-
model = models[idx]
147-
profile_name = model
148148
env_key = (
149149
self.config.get_endpoint_config(endpoint_name).get("api_key_env")
150150
or "OPENAI_API_KEY"
151151
)
152152

153-
try:
154-
self._write_profile(
155-
endpoint_name=endpoint_name,
156-
endpoint_config=endpoint_config,
157-
model=model,
158-
profile_name=profile_name,
159-
env_key=env_key,
160-
)
161-
except Exception as e:
162-
return self._handle_error("Failed to write ~/.codex/config.toml", e)
163-
164-
configured_profiles.append(profile_name)
153+
# Create profiles for each selected model
154+
for model in selected_models:
155+
profile_name = model
156+
try:
157+
self._write_profile(
158+
endpoint_name=endpoint_name,
159+
endpoint_config=endpoint_config,
160+
model=model,
161+
profile_name=profile_name,
162+
env_key=env_key,
163+
)
164+
except Exception as e:
165+
return self._handle_error("Failed to write ~/.codex/config.toml", e)
166+
167+
configured_profiles.append(profile_name)
165168
if endpoint_config.get("actual_api_key"):
166169
profile_env[profile_name] = (env_key, endpoint_config.get("actual_api_key"))
167170

0 commit comments

Comments
 (0)