fix: address PR review — legacy teardown, generic agent, ~/.specify paths, 3-segment commands_dir, full file tracking

- Legacy --ai teardown: detect empty tracked files and fall back to
  AGENT_CONFIG-based directory removal during agent switch
- --agent generic: falls through to legacy flow (no embedded pack)
- User/catalog dirs: use ~/.specify/ instead of platformdirs for
  consistency with extensions/presets
- DefaultBootstrap: join all path segments after first for COMMANDS_SUBDIR
  (fixes 3+-segment commands_dir like .tabnine/agent/commands)
- agent_add --from: validate manifest.id matches provided agent_id
- finalize_setup: track all files from setup(), not just agent-root files
- setup() docstring: reference --agent not --ai
- AGENTS.md: document generic agent fallback behavior
This commit is contained in:
Manfred Riem
2026-03-23 10:28:15 -05:00
parent ab8c58ff23
commit b94e541234
3 changed files with 48 additions and 41 deletions

View File

@@ -440,7 +440,7 @@ specify init my-project --agent claude # Pack-based flow (with file tra
specify init --here --agent gemini --ai-skills # With skills
```
`--agent` and `--ai` are mutually exclusive. When `--agent` is used, `init-options.json` gains `"agent_pack": true`.
`--agent` and `--ai` are mutually exclusive. When `--agent` is used, `init-options.json` gains `"agent_pack": true`. The `generic` agent (which requires `--ai-commands-dir`) falls through to the legacy flow since it has no embedded pack.
### `specify agent` subcommands

View File

@@ -1715,7 +1715,7 @@ def _handle_agent_skills_migration(console: Console, agent_key: str) -> None:
def init(
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
agent: str = typer.Option(None, "--agent", help="AI agent to use (enables file tracking for clean teardown when switching agents). Accepts the same agent IDs as --ai."),
agent: str = typer.Option(None, "--agent", help="AI agent to use (enables file tracking for clean teardown when switching agents). Accepts the same agent IDs as --ai. Use --ai generic for custom agent directories."),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
@@ -1784,7 +1784,10 @@ def init(
console.print("[red]Error:[/red] --agent and --ai cannot both be specified. Use one or the other.")
raise typer.Exit(1)
ai_assistant = agent
use_agent_pack = True
# "generic" uses --ai-commands-dir and has no embedded pack,
# so it falls through to the legacy flow.
if agent != "generic":
use_agent_pack = True
# Detect when option values are likely misinterpreted flags (parameter ordering issue)
if ai_assistant and ai_assistant.startswith("--"):
@@ -2678,15 +2681,28 @@ def agent_switch(
old_tracked_agent, old_tracked_ext = get_tracked_files(project_path, current_agent)
all_files = {**old_tracked_agent, **old_tracked_ext}
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
current_bootstrap.teardown(
project_path,
force=True, # already confirmed above
files=all_files if all_files else None,
)
console.print(f" [green]✓[/green] {current_agent} removed")
if all_files:
console.print(f" [dim]Tearing down {current_agent}...[/dim]")
current_bootstrap.teardown(
project_path,
force=True, # already confirmed above
files=all_files,
)
console.print(f" [green]✓[/green] {current_agent} removed")
else:
# No install manifest (legacy --ai project) — fall back
# to removing the agent directory via AGENT_CONFIG.
agent_config = AGENT_CONFIG.get(current_agent, {})
agent_folder = agent_config.get("folder")
if agent_folder:
agent_dir = project_path / agent_folder.rstrip("/")
if agent_dir.is_dir():
shutil.rmtree(agent_dir)
console.print(f" [green]✓[/green] {current_agent} directory removed (legacy)")
else:
console.print(f" [yellow]Warning:[/yellow] No tracked files or AGENT_CONFIG entry for '{current_agent}' — skipping teardown")
except AgentPackError:
# If pack-based teardown fails, try legacy cleanup via AGENT_CONFIG
# If pack-based resolution/load fails, try legacy cleanup via AGENT_CONFIG
agent_config = AGENT_CONFIG.get(current_agent, {})
agent_folder = agent_config.get("folder")
if agent_folder:
@@ -2925,6 +2941,13 @@ def agent_add(
console.print(f"[red]Validation failed:[/red] {exc}")
raise typer.Exit(1)
if manifest.id != agent_id:
console.print(
f"[red]Error:[/red] Manifest ID '{manifest.id}' does not match "
f"the specified agent ID '{agent_id}'."
)
raise typer.Exit(1)
dest = _catalog_agents_dir() / manifest.id
dest.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, dest, dirs_exist_ok=True)

View File

@@ -25,7 +25,6 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from platformdirs import user_data_path
logger = logging.getLogger(__name__)
@@ -212,8 +211,10 @@ class AgentBootstrap:
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install agent files into *project_path*.
This is invoked by ``specify init --ai <agent>`` and
``specify agent switch <agent>``.
This is invoked by ``specify init --agent <agent>`` and
``specify agent switch <agent>``. The legacy ``--ai`` flag
uses the old non-pack bootstrap flow and does not call this
method.
Implementations **must** return every file they create so that the
CLI can record both agent-installed files and extension-installed
@@ -340,37 +341,20 @@ class AgentBootstrap:
This must be called **after** the full init pipeline has finished
writing files (commands, context files, extensions) into the
project. It combines the files reported by :meth:`setup` with
any extra files (e.g. from extension registration), scans the
agent's directory tree for anything additional, and writes the
install manifest.
project. It records every file reported by :meth:`setup` plus
any extra files (e.g. from extension registration) and scans
the agent's directory tree for anything additional.
``setup()`` may return *all* files created by the shared
scaffolding (including shared project files in ``.specify/``).
Only files under the agent's own directory tree are recorded as
``agent_files`` — shared project infrastructure is not tracked
per-agent and will not be removed during teardown.
All files returned by ``setup()`` are tracked — including shared
project infrastructure — so that teardown/switch can precisely
remove everything the agent installed.
Args:
agent_files: Files reported by :meth:`setup`.
extension_files: Files created by extension registration.
"""
all_extension = list(extension_files or [])
# Filter agent_files: only keep files under the agent's directory
# tree. setup() returns *all* scaffolded files (including shared
# project infrastructure in .specify/) but only agent-owned files
# should be tracked per-agent — shared files are not removed
# during teardown/switch.
agent_root = self.agent_dir(project_path)
agent_root_resolved = agent_root.resolve()
all_agent: List[Path] = []
for p in (agent_files or []):
try:
p.resolve().relative_to(agent_root_resolved)
all_agent.append(p)
except ValueError:
pass # Path is outside the agent root — skip it
all_agent: List[Path] = list(agent_files or [])
# Scan the agent's directory tree for files created by later
# init pipeline steps (skills, presets, extensions) that
@@ -413,7 +397,7 @@ class DefaultBootstrap(AgentBootstrap):
super().__init__(manifest)
parts = manifest.commands_dir.split("/") if manifest.commands_dir else []
self.AGENT_DIR = parts[0] if parts else ""
self.COMMANDS_SUBDIR = parts[1] if len(parts) > 1 else ""
self.COMMANDS_SUBDIR = "/".join(parts[1:]) if len(parts) > 1 else ""
def setup(self, project_path: Path, script_type: str, options: Dict[str, Any]) -> List[Path]:
"""Install agent files into the project using the standard scaffold."""
@@ -673,7 +657,7 @@ def _embedded_agents_dir() -> Path:
def _user_agents_dir() -> Path:
"""Return the user-level agent overrides directory."""
return user_data_path("specify", "github") / "agents"
return Path.home() / ".specify" / "agents"
def _project_agents_dir(project_path: Path) -> Path:
@@ -683,7 +667,7 @@ def _project_agents_dir(project_path: Path) -> Path:
def _catalog_agents_dir() -> Path:
"""Return the catalog-installed agent cache directory."""
return user_data_path("specify", "github") / "agent-cache"
return Path.home() / ".specify" / "agent-cache"
@dataclass