Conversation
…n, resolution, and CLI commands - Create src/specify_cli/agent_pack.py with AgentBootstrap base class, AgentManifest schema/validation, pack resolution (user > project > catalog > embedded) - Generate all 25 official agent packs under src/specify_cli/core_pack/agents/ with speckit-agent.yml manifests and bootstrap.py modules - Add 'specify agent' CLI subcommands: list, info, validate, export, switch, search, add, remove - Update pyproject.toml to bundle agent packs in the wheel - Add comprehensive tests (39 tests): manifest validation, bootstrap API, resolution order, discovery, consistency with AGENT_CONFIG Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b
…est assertions - Copilot: only remove .github/agents/ (preserves workflows, templates) - Tabnine: only remove .tabnine/agent/ (preserves other config) - Amp/Codex: only remove respective subdirs (commands/skills) to avoid deleting each other's files in shared .agents/ dir - Tests: use flexible assertions instead of hardcoded >= 25 counts Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b
mnriem
left a comment
There was a problem hiding this comment.
@copilot The setup should store a list of installed files with their hash in the .specify directory so we can track what was installed by the agent. At teardown time that file needs to be used to determine if ANY of the installed files was updated and if so the remove should require a --force. Note you will NOT delete any directories but rather just the individual files.
Setup records installed files and their SHA-256 hashes in .specify/agent-manifest-<agent_id>.json. Teardown uses the manifest to remove only individual files (never directories). If any tracked file was modified since installation, teardown requires --force. - Add record_installed_files(), check_modified_files(), remove_tracked_files() and AgentFileModifiedError to agent_pack.py - Update all 25 bootstrap modules to use file-tracked setup/teardown - Add --force flag to 'specify agent switch' - Add 11 new tests for file tracking (record, check, remove, force, directory preservation, deleted-file handling, manifest structure) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a
…writes files Address code review: setup() now only creates directories, while finalize_setup() (on base class) scans the agent's commands_dir for all files and records them. This ensures files are tracked after the full init pipeline has written them, not before. - Add AgentBootstrap.finalize_setup() that scans commands_dir - Remove premature record_installed_files() from all 25 setup() methods - agent_switch calls finalize_setup() after setup() completes - Update test helper to match new pattern Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a
Implemented in b5a5e3f (file tracking) and a63c248 (finalize_setup fix):
|
mnriem
left a comment
There was a problem hiding this comment.
@copilot the agent setup should report the files it installed as the cli needs to record both what was installed by the agent for the core commands / templates as well as what installed by any extensions. It should the record that. And then BEFORE agent teardown is called determine if any file was changed and ask for confirmation. And then feed those lists into the teardown of the agent and/or the installed extensions.
…wn, categorised manifest - setup() returns List[Path] of installed files so CLI can record them - finalize_setup() accepts agent_files + extension_files for combined tracking - Install manifest categorises files: agent_files and extension_files - get_tracked_files() returns (agent_files, extension_files) split - remove_tracked_files() accepts explicit files dict for CLI-driven teardown - agent_switch checks for modifications BEFORE teardown and prompts user - _reregister_extension_commands() returns List[Path] of created files - teardown() accepts files parameter to receive explicit file lists - All 25 bootstraps updated with new signatures - 5 new tests: categorised manifest, get_tracked_files, explicit file teardown, extension file modification detection Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56
Implemented in e190116:
|
…1 unused imports, F841 unused variable) Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/e19bd25e-f084-4f38-85b6-8105cbb50494
Fixed in 55bcbd3 — 7 ruff errors resolved:
|
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bb495c08-5d15-410f-9ba9-89d3fc413904
Fixed in 795f1e7 — added explanatory comments to all 5 empty
|
mnriem
left a comment
There was a problem hiding this comment.
@copilot Address the following:
- Add agent ID validation in
resolve_agent_pack()— reject IDs containing/,.., or characters outside[a-z0-9-]to prevent path traversal - Add rollback logic to
agent_switch: if the new agent'ssetup()fails after the old agent was torn down, restore the old agent's files or set a clear error state ininit-options.json - Fix
cursor-agent/speckit-agent.yml: it setsrequires_cli: falsebutAGENT_CONFIGhasrequires_cli: True— align the manifest with the config - Guard
agent_dir()against emptycommands_dir— currentlysplit("/")[0]returns""whencommands_diris empty, makingagent_dir()returnproject_pathitself - Replace the 25 near-identical
bootstrap.pyfiles with a singleDefaultBootstrapclass in agent_pack.py that readsAGENT_DIRandCOMMANDS_SUBDIRfrom the manifest'scommands_dirfield - Log or warn (at debug level) when
_reregister_extension_commands()catches an exception instead of silently swallowing it with bareexcept Exception: continue - Fix the
--installedflag onspecify agent list— it should filter to only agents that have files present in the current project, not just add project-level resolution while still showing all embedded agents - Add a confirmation prompt (or
--forceflag) tospecify agent removebefore deleting user-level overrides, since those affect all projects globally - Update AGENTS.md to document the new
--agentflag onspecify initand thespecify agentsubcommands - Add a note about the trust boundary for dynamically loaded
bootstrap.pymodules — pack authors can execute arbitrary code via the resolution stack
…efaultBootstrap, logging, CLI fixes, docs Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3
…de review Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3
All 10 items addressed in 00117c5 and ab8c58f:
471 tests pass, |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new “agent pack” system for specify-cli, moving agent definitions toward self-contained packs (manifest + optional bootstrap) with resolution (user/project/catalog/embedded), installed-file tracking for safe teardown, and a new specify agent CLI surface—while keeping the legacy --ai flow intact.
Changes:
- Added
src/specify_cli/agent_pack.pyimplementing manifest parsing/validation, pack resolution, bootstrap loading (withDefaultBootstrapfallback), and SHA-256 file tracking for teardown. - Extended
specify initwith--agent(pack-based flow + tracking) and addedspecify agentsubcommands (list/info/validate/export/switch/search/add/remove). - Embedded agent pack manifests under
src/specify_cli/core_pack/agents/<id>/and updated wheel packaging; added extensive test coverage intests/test_agent_pack.py.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/agent_pack.py |
New agent-pack core: manifest schema, resolution stack, bootstrap loading, file tracking + teardown helpers |
src/specify_cli/__init__.py |
Adds --agent init flow + new specify agent command group (including switch/rollback) |
tests/test_agent_pack.py |
New comprehensive tests for manifest validation, resolution, lifecycle, tracking, and init parity |
pyproject.toml |
Includes embedded agent packs in the wheel via force-include |
AGENTS.md |
Documents the new agent pack system, commands, and resolution order |
src/specify_cli/core_pack/agents/agy/speckit-agent.yml |
Adds embedded manifest for agy |
src/specify_cli/core_pack/agents/amp/speckit-agent.yml |
Adds embedded manifest for amp |
src/specify_cli/core_pack/agents/auggie/speckit-agent.yml |
Adds embedded manifest for auggie |
src/specify_cli/core_pack/agents/bob/speckit-agent.yml |
Adds embedded manifest for bob |
src/specify_cli/core_pack/agents/claude/speckit-agent.yml |
Adds embedded manifest for claude |
src/specify_cli/core_pack/agents/codebuddy/speckit-agent.yml |
Adds embedded manifest for codebuddy |
src/specify_cli/core_pack/agents/codex/speckit-agent.yml |
Adds embedded manifest for codex |
src/specify_cli/core_pack/agents/copilot/speckit-agent.yml |
Adds embedded manifest for copilot |
src/specify_cli/core_pack/agents/cursor-agent/speckit-agent.yml |
Adds embedded manifest for cursor-agent |
src/specify_cli/core_pack/agents/gemini/speckit-agent.yml |
Adds embedded manifest for gemini |
src/specify_cli/core_pack/agents/iflow/speckit-agent.yml |
Adds embedded manifest for iflow |
src/specify_cli/core_pack/agents/junie/speckit-agent.yml |
Adds embedded manifest for junie |
src/specify_cli/core_pack/agents/kilocode/speckit-agent.yml |
Adds embedded manifest for kilocode |
src/specify_cli/core_pack/agents/kimi/speckit-agent.yml |
Adds embedded manifest for kimi |
src/specify_cli/core_pack/agents/kiro-cli/speckit-agent.yml |
Adds embedded manifest for kiro-cli |
src/specify_cli/core_pack/agents/opencode/speckit-agent.yml |
Adds embedded manifest for opencode |
src/specify_cli/core_pack/agents/pi/speckit-agent.yml |
Adds embedded manifest for pi |
src/specify_cli/core_pack/agents/qodercli/speckit-agent.yml |
Adds embedded manifest for qodercli |
src/specify_cli/core_pack/agents/qwen/speckit-agent.yml |
Adds embedded manifest for qwen |
src/specify_cli/core_pack/agents/roo/speckit-agent.yml |
Adds embedded manifest for roo |
src/specify_cli/core_pack/agents/shai/speckit-agent.yml |
Adds embedded manifest for shai |
src/specify_cli/core_pack/agents/tabnine/speckit-agent.yml |
Adds embedded manifest for tabnine |
src/specify_cli/core_pack/agents/trae/speckit-agent.yml |
Adds embedded manifest for trae |
src/specify_cli/core_pack/agents/vibe/speckit-agent.yml |
Adds embedded manifest for vibe |
src/specify_cli/core_pack/agents/windsurf/speckit-agent.yml |
Adds embedded manifest for windsurf |
Comments suppressed due to low confidence (4)
src/specify_cli/init.py:2724
- Rollback path restores the previous agent by rerunning setup()+finalize_setup(), but it doesn’t re-register extension commands (unlike the success path which calls _reregister_extension_commands). Since teardown likely removed tracked extension-generated command files, a rollback can leave the project missing extension commands even though extensions are still installed. Consider calling _reregister_extension_commands(project_path, current_agent) during rollback and passing those created files into finalize_setup().
rollback_resolved = resolve_agent_pack(current_agent, project_path=project_path)
rollback_bs = load_bootstrap(rollback_resolved.path, rollback_resolved.manifest)
rollback_files = rollback_bs.setup(project_path, script_type, options)
rollback_bs.finalize_setup(
project_path,
agent_files=rollback_files,
extension_files=list(
(project_path / p).resolve()
for p in old_tracked_ext
if (project_path / p).is_file()
),
)
console.print(f" [green]✓[/green] {current_agent} restored")
src/specify_cli/agent_pack.py:474
- record_installed_files() stores tracked paths using str(Path.relative_to(...)), which will emit OS-specific separators (e.g. backslashes on Windows). Since this JSON manifest is a project artifact and other code/tests treat paths as POSIX-like, consider normalizing to .as_posix() when writing keys (and likewise when reporting modified paths) for cross-platform stability.
entries: Dict[str, str] = {}
for file_path in files:
abs_path = project_path / file_path if not file_path.is_absolute() else file_path
if abs_path.is_file():
rel = str(abs_path.relative_to(project_path))
entries[rel] = _sha256(abs_path)
return entries
src/specify_cli/init.py:2746
- After a successful switch, init-options.json is updated with the new "ai" but the "agent_pack" flag is not set/updated. Since
specify init --agentpersists "agent_pack": true and other tooling may rely on it, consider setting options["agent_pack"] = True here as well.
# Update init options
options["ai"] = agent_id
options.pop("agent_switch_error", None) # clear any previous error
init_options_file.write_text(json.dumps(options, indent=2), encoding="utf-8")
src/specify_cli/agent_pack.py:759
- resolve_agent_pack() loads whatever speckit-agent.yml it finds under /<agent_id>/ but never verifies that manifest.id matches the requested agent_id. A mismatched manifest can lead to confusing behavior (scaffolding/teardown keyed off the manifest id) and makes it easier for a malicious override to masquerade under a different directory name. Validate manifest.id == agent_id (and raise PackResolutionError if not).
for source, pack_dir in candidates:
manifest_file = pack_dir / MANIFEST_FILENAME
if manifest_file.is_file():
manifest = AgentManifest.from_yaml(manifest_file)
if source == "embedded":
embedded_manifest = manifest
overrides = None
if source != "embedded" and embedded_manifest is None:
# Try loading embedded to record what it overrides
emb_file = _embedded_agents_dir() / agent_id / MANIFEST_FILENAME
if emb_file.is_file():
try:
emb = AgentManifest.from_yaml(emb_file)
overrides = f"embedded v{emb.version}"
except AgentPackError:
pass # Embedded manifest unreadable — skip override info
return ResolvedPack(
manifest=manifest,
source=source,
path=pack_dir,
overrides=overrides,
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…aths, 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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
src/specify_cli/agent_pack.py:408
DefaultBootstrap.setup()returns_scaffold_project(...), which yields all newly created files across the whole project (not just agent-owned files). When those paths are passed intofinalize_setup(), shared scaffolding outputs (e.g..specify/scripts, templates) can become tracked and later removed during teardown/switch. Consider returning only files under the agent root (e.g. by collecting files underagent_dir()after scaffolding) so tracking/teardown stays scoped to the agent’s directory.
if self.AGENT_DIR and self.COMMANDS_SUBDIR:
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) |
There was a problem hiding this comment.
In the legacy fallback path (no install manifest), agent_switch deletes the entire agent directory with shutil.rmtree(...) without any modification check or confirmation (unless the user happened to have tracked files). This can silently delete user-edited legacy agent commands/prompts. Consider prompting before deleting the legacy agent directory when --force is not set (or at least require --force to proceed).
| raise typer.Exit(1) | ||
|
|
||
| # Update init options | ||
| options["ai"] = agent_id |
There was a problem hiding this comment.
After a successful switch, init-options.json is updated with the new ai value but the agent_pack flag is not updated. If a project started in legacy --ai mode and then switches to a pack-based agent, agent_pack will remain false and the project metadata won’t reflect the actual management mode. Update options["agent_pack"] accordingly during switch (and clear/adjust any legacy-only keys if needed).
| options["ai"] = agent_id | |
| options["ai"] = agent_id | |
| options["agent_pack"] = True |
| try: | ||
| from .agents import CommandRegistrar | ||
| registrar = CommandRegistrar() | ||
| except ImportError: | ||
| return created_files |
There was a problem hiding this comment.
_reregister_extension_commands() passes agent_id directly into CommandRegistrar.register_commands(), but CommandRegistrar.AGENT_CONFIGS keys don’t match all AGENT_CONFIG/pack IDs (e.g. cursor-agent vs cursor). For those agents, register_commands() raises ValueError("Unsupported agent"), which is caught and only logged at debug, so extension commands won’t be re-registered after a switch. Consider adding missing agent IDs to CommandRegistrar.AGENT_CONFIGS (aliasing where appropriate) or mapping pack IDs to registrar IDs before calling register_commands() so switch preserves extension commands.
| for source, pack_dir in candidates: | ||
| manifest_file = pack_dir / MANIFEST_FILENAME | ||
| if manifest_file.is_file(): | ||
| manifest = AgentManifest.from_yaml(manifest_file) |
There was a problem hiding this comment.
resolve_agent_pack() trusts the manifest's internal agent.id without verifying it matches the requested agent_id (directory name). A malicious override pack could set agent.id to a different value (including path separators) and that value is later used for bootstrap module naming and for writing .specify/agent-manifest-<id>.json, which can lead to incorrect resolution and potential path traversal when creating the manifest path. After loading the manifest, validate manifest.id with _validate_agent_id() and require manifest.id == agent_id (otherwise raise a hard error).
| manifest = AgentManifest.from_yaml(manifest_file) | |
| manifest = AgentManifest.from_yaml(manifest_file) | |
| # Validate that the manifest's declared ID is safe and matches the requested agent_id | |
| _validate_agent_id(manifest.id) | |
| if manifest.id != agent_id: | |
| raise PackResolutionError( | |
| f"Agent pack manifest ID '{manifest.id}' does not match requested ID '{agent_id}'." | |
| ) |
| all_extension = list(extension_files or []) | ||
| all_agent: List[Path] = list(agent_files or []) | ||
|
|
There was a problem hiding this comment.
finalize_setup() currently records every path returned by setup() as agent_files. With DefaultBootstrap.setup() returning all files created by the scaffold (including shared .specify/ infrastructure), this means an agent switch/teardown can delete core project files that should be preserved. Filter agent_files down to files under the agent’s own directory tree (and keep extension-created files separate), or change the setup return contract so the default bootstrap only reports agent-directory files.
This issue also appears on line 404 of the same file.
…ck flag, registrar alias, manifest ID validation - Legacy rmtree: prompt user before deleting agent directory in legacy fallback path (both no-manifest and AgentPackError cases), respects --force - Set options['agent_pack'] = True during agent_switch so projects originally created with --ai reflect pack-based management after switch - Add cursor-agent alias in CommandRegistrar.AGENT_CONFIGS so extension re-registration works when switching to/from cursor-agent - Validate manifest.id matches agent_id in resolve_agent_pack() to prevent malicious override packs from injecting different IDs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 31 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (4)
src/specify_cli/agent_pack.py:415
DefaultBootstrap.setup()returnsself._scaffold_project(...), which runs the full bundled scaffold and will overwrite existing project files viashutil.copy2(seescaffold_from_core_pack), without any confirmation duringspecify agent switch. For switching agents, this risks clobbering user edits in shared files (specs/plans/templates/etc.) even though the command claims to preserve them. A safer pattern is to scaffold into a temp dir and copy only the agent-specific subtree (or only create/update the commands/skills dir) when switching.
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."""
if self.AGENT_DIR and self.COMMANDS_SUBDIR:
commands_dir = project_path / self.AGENT_DIR / self.COMMANDS_SUBDIR
commands_dir.mkdir(parents=True, exist_ok=True)
return self._scaffold_project(project_path, script_type)
src/specify_cli/agent_pack.py:465
_hash_file_list()derivesrelusingabs_path.relative_to(project_path)without verifying that the resulting path stays withinproject_pathafter normalization. If a caller passes a relative path containing..segments, you can end up recording entries like../../somewherein the manifest, which later makesremove_tracked_files()operate outside the project root. Consider resolving (abs_path.resolve()) and rejecting anything not underproject_path.resolve(), and normalizing stored paths to POSIX with no..components.
def _hash_file_list(
project_path: Path,
files: List[Path],
) -> Dict[str, str]:
"""Build a {relative_path: sha256} dict from a list of file paths."""
entries: Dict[str, str] = {}
for file_path in files:
abs_path = project_path / file_path if not file_path.is_absolute() else file_path
if abs_path.is_file():
rel = str(abs_path.relative_to(project_path))
entries[rel] = _sha256(abs_path)
return entries
src/specify_cli/agent_pack.py:580
check_modified_files()trusts manifest keys as relative paths and doesproject_path / rel_path. If the manifest is corrupted or user-edited to include absolute paths or..segments, this can read/hash files outside the project. It would be safer to validate eachrel_path(must be a relative, normalized path underproject_path) before hashing, and skip/flag invalid entries.
modified: List[str] = []
for rel_path, original_hash in entries.items():
abs_path = project_path / rel_path
if abs_path.is_file():
if _sha256(abs_path) != original_hash:
modified.append(rel_path)
src/specify_cli/agent_pack.py:648
remove_tracked_files()deletesproject_path / rel_pathfor each manifest entry without validating thatrel_pathis a safe project-relative path. If.specify/agent-manifest-<id>.jsonis tampered with (or_hash_file_listrecords..segments), this can delete files outside the project directory. Add a safety check (e.g.,resolved = (project_path / rel_path).resolve()and ensure it is withinproject_path.resolve(), and ignore/raise on invalid paths) before unlinking.
removed: List[str] = []
for rel_path in entries:
abs_path = project_path / rel_path
if abs_path.is_file():
abs_path.unlink()
removed.append(rel_path)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| All files returned by ``setup()`` are tracked — including shared | ||
| project infrastructure — so that teardown/switch can precisely | ||
| remove everything the agent installed. This is intentional: | ||
| ``remove_tracked_files()`` only deletes files whose SHA-256 | ||
| hash still matches the original, so user-modified files are | ||
| always preserved (unless ``--force`` is used). | ||
|
|
||
| Args: | ||
| agent_files: Files reported by :meth:`setup`. | ||
| extension_files: Files created by extension registration. | ||
| """ | ||
| all_extension = list(extension_files or []) | ||
| # Track ALL files returned by setup(), not just those under the | ||
| # agent's directory tree. This is safe because teardown only | ||
| # removes files that are unmodified (hash check) and prompts | ||
| # for confirmation on modified files. | ||
| 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 | ||
| # setup() did not report. We scan the agent root directory | ||
| # (e.g. .claude/) so we catch both commands and skills | ||
| # directories (skills-migrated agents replace the commands | ||
| # directory with a sibling skills directory during init). | ||
| if self.manifest.commands_dir: | ||
| agent_root = self.agent_dir(project_path) | ||
| if agent_root.is_dir(): | ||
| agent_set = {p.resolve() for p in all_agent} | ||
| for p in agent_root.rglob("*"): | ||
| if p.is_file() and p.resolve() not in agent_set: | ||
| all_agent.append(p) | ||
| agent_set.add(p.resolve()) |
There was a problem hiding this comment.
finalize_setup() currently tracks all files returned by setup() (including shared project infrastructure) and then teardown() delegates to remove_tracked_files(), which will delete every unmodified tracked file. Since DefaultBootstrap.setup() returns the full scaffold’s new-file set, this can cause agent switches/uninstalls to remove .specify/*, templates, etc., contradicting the teardown docstring (“Must preserve shared infrastructure”) and the specify agent switch help text. Consider filtering agent_files to only paths under agent_dir(project_path) (and keeping extension command files separate) so switching agents doesn’t affect global project state like .specify/extensions/.registry.
This issue also appears in the following locations of the same file:
- line 409
- line 454
- line 575
- line 643
| All files returned by ``setup()`` are tracked — including shared | |
| project infrastructure — so that teardown/switch can precisely | |
| remove everything the agent installed. This is intentional: | |
| ``remove_tracked_files()`` only deletes files whose SHA-256 | |
| hash still matches the original, so user-modified files are | |
| always preserved (unless ``--force`` is used). | |
| Args: | |
| agent_files: Files reported by :meth:`setup`. | |
| extension_files: Files created by extension registration. | |
| """ | |
| all_extension = list(extension_files or []) | |
| # Track ALL files returned by setup(), not just those under the | |
| # agent's directory tree. This is safe because teardown only | |
| # removes files that are unmodified (hash check) and prompts | |
| # for confirmation on modified files. | |
| 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 | |
| # setup() did not report. We scan the agent root directory | |
| # (e.g. .claude/) so we catch both commands and skills | |
| # directories (skills-migrated agents replace the commands | |
| # directory with a sibling skills directory during init). | |
| if self.manifest.commands_dir: | |
| agent_root = self.agent_dir(project_path) | |
| if agent_root.is_dir(): | |
| agent_set = {p.resolve() for p in all_agent} | |
| for p in agent_root.rglob("*"): | |
| if p.is_file() and p.resolve() not in agent_set: | |
| all_agent.append(p) | |
| agent_set.add(p.resolve()) | |
| Only files under the agent's directory tree are tracked as | |
| ``agent_files`` so that teardown/switch does not remove shared | |
| project infrastructure (e.g. ``.specify/*``). Extension-related | |
| files are tracked separately via ``extension_files``. | |
| Args: | |
| agent_files: Files reported by :meth:`setup`. | |
| extension_files: Files created by extension registration. | |
| """ | |
| all_extension = list(extension_files or []) | |
| agent_root = self.agent_dir(project_path) | |
| def _is_under_agent_root(path: Path) -> bool: | |
| """Return True if ``path`` is within the agent root directory.""" | |
| try: | |
| path.resolve().relative_to(agent_root.resolve()) | |
| return True | |
| except ValueError: | |
| return False | |
| # Only track files owned by this agent (under agent_root) as agent files. | |
| all_agent: List[Path] = [] | |
| for p in agent_files or []: | |
| try: | |
| candidate = Path(p) | |
| except TypeError: | |
| # Skip entries that cannot be interpreted as Paths. | |
| continue | |
| if _is_under_agent_root(candidate): | |
| all_agent.append(candidate) | |
| # Scan the agent's directory tree for files created by later | |
| # init pipeline steps (skills, presets, extensions) that | |
| # setup() did not report. We scan the agent root directory | |
| # (e.g. .claude/) so we catch both commands and skills | |
| # directories (skills-migrated agents replace the commands | |
| # directory with a sibling skills directory during init). | |
| if self.manifest.commands_dir and agent_root.is_dir(): | |
| agent_set = {p.resolve() for p in all_agent} | |
| for p in agent_root.rglob("*"): | |
| if p.is_file(): | |
| resolved = p.resolve() | |
| if resolved not in agent_set: | |
| all_agent.append(p) | |
| agent_set.add(resolved) |
| m = AgentManifest.from_yaml(mf) | ||
| overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None | ||
| seen[m.id] = ResolvedPack(manifest=m, source="catalog", path=child, overrides=overrides) | ||
| except AgentPackError: | ||
| continue | ||
|
|
||
| # Project-level | ||
| if project_path is not None: | ||
| proj_dir = _project_agents_dir(project_path) | ||
| if proj_dir.is_dir(): | ||
| for child in sorted(proj_dir.iterdir()): | ||
| mf = child / MANIFEST_FILENAME | ||
| if child.is_dir() and mf.is_file(): | ||
| try: | ||
| m = AgentManifest.from_yaml(mf) | ||
| overrides = f"embedded v{seen[m.id].manifest.version}" if m.id in seen else None | ||
| seen[m.id] = ResolvedPack(manifest=m, source="project", path=child, overrides=overrides) |
There was a problem hiding this comment.
list_all_agents() computes overrides as embedded v{seen[m.id].manifest.version} whenever an ID was already seen. After the catalog loop runs, seen[m.id] may no longer be the embedded manifest (it could be a catalog override), so project/user overrides can display an incorrect “overrides embedded v...” message for agents that are not embedded or when overriding a non-embedded source. Consider tracking the embedded version separately (like resolve_agent_pack does) or including the actual overridden source/version.
| 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. 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"), |
There was a problem hiding this comment.
Now that --agent is supported and --ai-skills is validated as compatible with --ai or --agent, the --ai-skills option help string later in this signature still says it “requires --ai”. Please update that help text so specify init --help reflects the actual behavior.
Adding a new agent today requires changes to 10+ files across 5 languages. This PR introduces the foundational agent pack infrastructure that replaces the monolithic
AGENT_CONFIG+ case/switch architecture with self-contained, declarative agent packs — while preserving full backward compatibility during migration.Agent pack module (
src/specify_cli/agent_pack.py)AgentManifest— parsed/validatedspeckit-agent.ymlwith schema version enforcementAgentBootstrap— abstract base withsetup() → List[Path]/teardown(force, files)/finalize_setup(agent_files, extension_files)lifecycle contractDefaultBootstrap— generic bootstrap that derivesAGENT_DIRandCOMMANDS_SUBDIRfrom the manifest'scommands_dirfield, eliminating the need for per-agentbootstrap.pyfiles when the agent follows the standard setup/teardown pattern~/.specify/agents/) → project (.specify/agents/) → catalog cache → embedded in wheelload_bootstrap()dynamically imports pack's bootstrap module and discovers theAgentBootstrapsubclass; falls back toDefaultBootstrapwhen nobootstrap.pyexists_validate_agent_id()— rejects IDs containing/,.., or characters outside[a-z0-9-]to prevent path traversal through the resolution stackvalidate_pack(),export_pack(),list_embedded_agents(),list_all_agents()setup()owns scaffolding and returns actual installed filesEach bootstrap's
setup()calls_scaffold_project(), a base-class helper that invokesscaffold_from_core_pack, snapshots the project directory before and after, and returns every new file created. This meanssetup()is the single entry point for agent installation — it runs the shared scaffolding and reports what it created._scaffold_project(project_path, script_type)— callsscaffold_from_core_pack, diffs before/after snapshots, returnsList[Path]of all new filescollect_installed_files(project_path)— scans the agent's directory tree and returns all files foundagent_dir()— guarded against emptycommands_dir; raisesAgentPackErrorwhen the manifest'scommands_diris emptyfinalize_setup(agent_files, extension_files)— filtersagent_filesto only track files under the agent's own directory tree (shared.specify/infrastructure is not tracked per-agent), scans for additional files from later pipeline steps (skills, extensions), and writes.specify/agent-manifest<agent_id>.jsonInstalled-file tracking with SHA-256 hashes
finalize_setup(agent_files, extension_files)accepts categorised file lists from the CLI and records them in.specify/agent-manifest<agent_id>.jsonwith separateagent_filesandextension_filessectionsget_tracked_files()returns an(agent_files, extension_files)tuple so the CLI can retrieve and split the tracked setscheck_modified_files()compares current file hashes against the recorded manifest to detect user modifications across both categoriesremove_tracked_files(force, files)deletes only individual tracked files — directories are never deleted. Accepts an explicitfilesdict so the CLI can feed tracked file lists directly into teardown. RaisesAgentFileModifiedErrorif any tracked file was modified since installation andforceisFalse_reregister_extension_commands()returnsList[Path]of files created by extension registration, which are passed tofinalize_setup()asextension_files. Logs caught exceptions at debug level instead of silently swallowing them.CLI-level modification check before teardown
specify agent switchchecks for modified files before calling teardown and prompts the user for confirmation. The CLI retrieves the tracked file lists viaget_tracked_files(), checks for modifications, lists any changed files, and only proceeds with teardown after the user confirms (or if--forceis passed). The file lists are then fed directly intoteardown().Rollback on failed agent switch
If the new agent's
setup()fails after the old agent was torn down,agent_switchattempts to restore the old agent by re-running itssetup()andfinalize_setup(). If rollback also fails, it records anagent_switch_errorkey ininit-options.jsonwith a specific repair command so the user knows how to recover.--agentflag onspecify initspecify init --agent claudeopts into the pack-based init flow. The--agentflag:setup()— the bootstrap owns the scaffolding process, always using bundled assetsfinalize_setup(agent_files=setup_files)to record every installed file in.specify/agent-manifest<id>.json"agent_pack": trueininit-options.json--agentand--aiare mutually exclusive.--agentaccepts the same agent IDs as--aibut additionally enables file tracking for clean teardown when switching agents.25 embedded agent packs (
src/specify_cli/core_pack/agents/<id>/)Each pack contains
speckit-agent.yml. Per-agentbootstrap.pyfiles have been replaced by theDefaultBootstrapclass inagent_pack.py, which derives its directory layout from the manifest'scommands_dirfield. Manifests carry command registration metadata (commands_dir,format,arg_placeholder,file_extension) sufficient forCommandRegistrarto render extension commands without importing a bootstrap module.Bundled in the wheel via
pyproject.tomlforce-include.specify agentCLI subcommandslist,info,validate,export,switch,search,add,remove— following the sametyper.Typer()+app.add_typer()pattern asspecify extensionandspecify preset.list --installedfilters to only agents that have an install manifest (.specify/agent-manifest-<id>.json) in the current project, rather than showing all embedded agents.switchchecks for modified files and prompts before teardown, tears down the old agent (file-by-file using the tracked file lists), sets up the new one (with rollback on failure), re-registers extension commands, then callsfinalize_setup()with both agent and extension files to record the new install manifest. Updatesinit-options.jsonaccordingly.removenow accepts--forceand prompts for confirmation before deleting user-level overrides, since those affect all projects globally.Trust boundary
Agent packs can include a
bootstrap.pymodule that is dynamically imported and executed. Pack authors can run arbitrary code through this mechanism. The 4-level resolution stack means that placing a pack in any of the resolution directories causes its code to run when the agent is loaded. Only install packs from trusted sources. This is documented in theload_bootstrap()docstring and in AGENTS.md.Backward compatibility
AGENT_CONFIGand all existing code paths are untouched. The pack system is purely additive —specify init --ai <agent>continues to resolve throughAGENT_CONFIG. Pack-based resolution is available viaspecify init --agent <agent>andspecify agentcommands and will become the default after validation.Documentation
AGENTS.md updated with a new "Agent Pack System" section covering:
--agentflag onspecify initspecify agentsubcommands with descriptionsbootstrap.pymodulesTests
471 tests in
test_agent_pack.pycovering:../,/), uppercase, special characters; accepts valid[a-z0-9-]IDsAGENT_CONFIGandCommandRegistrar.AGENT_CONFIGSsetup()returns actual files (TestSetupReturnsFiles, 100 tests) — every agent'ssetup()returns non-empty list of existing absolute paths, including files under the agent's directory treeTestInitFlowParity, 300 tests) — usestyper.testing.CliRunnerto invoke the realspecify initcommand for both--aiand--agentflows across all 25 agents, comparing file sets, file contents, directory structure, tracking manifests, commands_dir alignment, and CommandRegistrar metadata byte-for-byte✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.