diff --git a/CHANGELOG.md b/CHANGELOG.md index b657824be4..62b23f79ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ +## [Unreleased] + +### Added + +- feat(extensions): `provides.agents` places subagent definitions in the active integration's agent directory (`.claude/agents`, `.cursor/agents`), and `provides.files` ships verbatim project files (for example Workflow scripts) to a declared destination, `{integration_folder}/` resolving per integration. Both are hash-tracked: a locally edited file is never overwritten or removed. + +## [1.0.6] - 2026-09-10 + +### Changed + +- fix(events): cap stdin in the generated dispatcher, not just the CLI command (#4337) +- docs(core): SPECIFY_FEATURE sets the feature label, not the feature directory (#3786) +- chore: shorten stale timeline to 60 days stale, 30 days to close (#4503) +- [extension] Update Spec Kit Schedule extension to v0.7.4 (#4498) +- [preset] Update Inventory Alignment preset to v0.1.1 (#4494) +- Update Spec Inventory extension to v0.1.1 (#4496) +- docs: document contribution evidence gate and label taxonomy (#4478) +- feat(workflows): add per-step integration configuration (#4425) +- fix: preserve extension authors in generated skills (#4459) +- Add ProductShape PRODUCT workflows extension to community catalog (#4485) +- Update AgentPay x402 extension to v1.1.0 (#4482) +- Update Figma Starter extension to v1.1.0 (#4490) +- Fix #4345 (3/4): CI guard requiring version bumps on bundled extension changes (#4395) +- fix(templates): report an unreadable extensions.yml instead of skipping hooks silently (#4456) +- Add concise code review skill (#4471) +- docs(templates): clarify /constitution's Sync Impact Report is temporary, review-only material (#4431) (#4432) +- fix(bundler): re-read the step registry when rolling back a failed step refresh (#4139) +- Fix August newsletter review findings (#4444) +- docs: resolve assess clarifications by editing artifacts in place (#4402) +- chore: release 1.0.5, begin 1.0.6.dev0 development (#4479) + ## [1.0.5] - 2026-09-08 ### Changed diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 0473e72008..7b5d655910 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -168,6 +168,34 @@ catalogs: description: "Our approved extensions" ``` +## Subagent Definitions and Project Files + +An extension can ship two artifact kinds that live in the coding agent's own +directories rather than in a command: + +```yaml +provides: + agents: + - name: explorer + file: agents/explorer.md # Markdown with frontmatter (name, description, model, tools) + description: "Read-only codebase exploration on a small model" + files: + - name: critic-panel + file: workflows/critic-panel.js + dest: "{integration_folder}/workflows/critic-panel.js" # or a plain project-relative path + description: "A Workflow script the /speckit-…-review command runs" +``` + +- **`agents`** land in the active integration's subagent directory: `.claude/agents/.md` + for Claude Code, `.cursor/agents/.md` for Cursor. An integration with no file-based + subagent lane (Codex today) skips them with a one-line note; nothing else changes. +- **`files`** are copied verbatim to `dest`. `{integration_folder}/` at the start of `dest` + resolves to the active integration's folder (`.claude/`, `.cursor/`, `.agents/`, …). A + destination may not be absolute, climb out of the project, or land under `.specify/`. +- Both are recorded in the registry with a content hash. On reinstall or removal a file a + person edited since is **left alone** and reported; only unchanged copies are replaced or + deleted. + ## Extension Configuration Most extensions include configuration files in their install directory: diff --git a/pyproject.toml b/pyproject.toml index 06e8f5df56..962bc01165 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "1.0.6.dev0" +version = "1.0.6" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index cb78f0ea88..be83c7d8c0 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -65,6 +65,39 @@ # commands, these are not namespaced (they aren't invoked via a command # name), so they follow the same plain slug pattern as extension.id. VALID_EXTENSION_ARTIFACT_NAME_PATTERN = re.compile(r"^[a-z0-9-]+$") +INTEGRATION_FOLDER_TOKEN = "{integration_folder}" + + +def project_dest_violation(value: Any) -> Optional[str]: + """Return why ``value`` is unsafe as a ``provides.files`` destination. + + A destination is a project-relative file path (``.claude/workflows/x.js``), + optionally starting with ``{integration_folder}/`` which resolves to the + active integration's own folder at install time. It may never be absolute, + climb out of the project, end in a separator, or land under ``.specify/`` + (that tree belongs to Spec Kit's own machinery and the extension's own + installed copy already lives there). + """ + if not isinstance(value, str) or not value or value.strip() != value: + return "must be a non-empty string without surrounding whitespace" + probe = value + if probe.startswith(INTEGRATION_FOLDER_TOKEN + "/"): + probe = "x/" + probe[len(INTEGRATION_FOLDER_TOKEN) + 1:] + elif INTEGRATION_FOLDER_TOKEN in probe: + return f"{INTEGRATION_FOLDER_TOKEN} is only allowed as the leading path segment" + reason = relative_extension_path_violation(probe) + if reason: + return reason + if probe == ".specify" or probe.startswith(".specify/"): + return "must not land under .specify/" + return None + + +def _note(message: str) -> None: + """A one-line, user-facing note (rich markup allowed).""" + from rich.console import Console + + Console().print(message) VALID_SCRIPT_RUNTIMES = frozenset({"bash", "powershell", "python"}) @@ -377,6 +410,8 @@ def _validate(self): commands = provides.get("commands", []) templates = provides.get("templates", []) scripts = provides.get("scripts", []) + agents = provides.get("agents", []) + files = provides.get("files", []) hooks = self.data.get("hooks") events = self.data.get("events") @@ -386,6 +421,10 @@ def _validate(self): raise ValidationError("Invalid provides.templates: expected a list") if "scripts" in provides and not isinstance(scripts, list): raise ValidationError("Invalid provides.scripts: expected a list") + if "agents" in provides and not isinstance(agents, list): + raise ValidationError("Invalid provides.agents: expected a list") + if "files" in provides and not isinstance(files, list): + raise ValidationError("Invalid provides.files: expected a list") if "hooks" in self.data and not isinstance(hooks, dict): raise ValidationError("Invalid hooks: expected a mapping") if "events" in self.data: @@ -397,15 +436,25 @@ def _validate(self): has_events = bool(events) has_templates = bool(templates) has_scripts = bool(scripts) + has_agents = bool(agents) + has_files = bool(files) - if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts: + if not (has_commands or has_hooks or has_events or has_templates or has_scripts or has_agents or has_files): raise ValidationError( "Extension must provide at least one command, hook, or event " - "(or a declared template/script)" + "(or a declared template/script/agent/file)" ) self._validate_provided_artifacts(templates, section="templates", singular="template") self._validate_provided_artifacts(scripts, section="scripts", singular="script") + self._validate_provided_artifacts(agents, section="agents", singular="agent") + self._validate_provided_artifacts(files, section="files", singular="file") + for entry in files: + reason = project_dest_violation(entry.get("dest")) + if reason: + raise ValidationError( + f"Invalid file 'dest' for '{entry.get('name')}': {reason}" + ) # Validate hook values (if present). # Each event is a single mapping or a list of mappings. @@ -720,6 +769,16 @@ def scripts(self) -> List[Dict[str, Any]]: """Get list of declared scripts (provides.scripts).""" return self.data.get("provides", {}).get("scripts", []) + @property + def agents(self) -> List[Dict[str, Any]]: + """Get list of declared subagent definitions (provides.agents).""" + return self.data.get("provides", {}).get("agents", []) + + @property + def files(self) -> List[Dict[str, Any]]: + """Get list of declared project files (provides.files).""" + return self.data.get("provides", {}).get("files", []) + @property def hooks(self) -> Dict[str, Any]: """Get hook definitions.""" @@ -1877,6 +1936,153 @@ def add_candidate(candidate: Path) -> None: return candidates + def _active_integration(self): + """The integration the project was initialised with, or ``None``.""" + from .. import load_init_options + from ..integrations import get_integration + + opts = load_init_options(self.project_root) + if not isinstance(opts, dict): + return None + selected_ai = opts.get("ai") + if not isinstance(selected_ai, str) or not selected_ai: + return None + try: + return get_integration(selected_ai) + except Exception: + return None + + @staticmethod + def _file_sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + def _ensure_inside_project(self, dest: Path) -> None: + normalized = Path(os.path.normpath(dest)) + root = Path(os.path.normpath(self.project_root)) + if not normalized.is_relative_to(root): + raise ExtensionError(f"Destination {dest} escapes the project root") + + def _place_owned_file( + self, source: Path, dest: Path, owned: Dict[str, str] + ) -> bool: + """Write ``source`` to ``dest`` unless a file we do not own is there. + + Ownership is a recorded content hash: a destination that exists with a + hash other than the one this extension last wrote is a person's file and + is left alone (and reported). Returns True when the file was written. + """ + self._ensure_inside_project(dest) + rel = dest.relative_to(self.project_root).as_posix() + if dest.exists(): + current = self._file_sha256(dest) + previously = owned.get(rel) + if previously is not None and previously != current: + _note(f"[yellow]⚠[/yellow] {rel} was edited locally; left alone (delete it to take the extension's copy)") + return False + if previously is None and current != self._file_sha256(source): + _note(f"[yellow]⚠[/yellow] {rel} exists and is not this extension's; left alone") + return False + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest) + owned[rel] = self._file_sha256(dest) + return True + + def _register_extension_agents( + self, manifest: ExtensionManifest, extension_dir: Path + ) -> Dict[str, str]: + """Copy ``provides.agents`` into the active integration's agents directory. + + Returns ``{project-relative path: sha256}`` for the registry. An + integration without an ``agents_dir`` gets nothing and a one-line note. + """ + agents = manifest.agents + if not agents: + return {} + integration = self._active_integration() + agents_dir = getattr(integration, "agents_dir", None) if integration else None + if not agents_dir: + key = getattr(integration, "key", None) or "the active integration" + _note(f"[dim]{key} has no subagent directory; {len(agents)} agent definition(s) from '{manifest.id}' not placed[/dim]") + return {} + owned: Dict[str, str] = {} + previous = self.registry.get(manifest.id) if self.registry.is_installed(manifest.id) else None + if previous and isinstance(previous.get("registered_agents"), dict): + owned.update({k: v for k, v in previous["registered_agents"].items() if isinstance(k, str) and isinstance(v, str)}) + for entry in agents: + source = extension_dir / entry["file"] + if not source.is_file(): + _note(f"[yellow]⚠[/yellow] agent '{entry['name']}': {entry['file']} missing in extension") + continue + dest = self.project_root / agents_dir / f"{entry['name']}{source.suffix or '.md'}" + self._place_owned_file(source, dest, owned) + return owned + + def _install_extension_files( + self, manifest: ExtensionManifest, extension_dir: Path + ) -> Dict[str, str]: + """Copy ``provides.files`` verbatim to their project destinations. + + ``{integration_folder}/`` at the start of ``dest`` resolves to the active + integration's folder (``.claude/``, ``.cursor/``, …); an entry that needs + it is skipped, with a note, when no integration is active. + """ + files = manifest.files + if not files: + return {} + integration = self._active_integration() + folder = None + if integration is not None and isinstance(getattr(integration, "config", None), dict): + folder = str(integration.config.get("folder") or "").strip("/") + owned: Dict[str, str] = {} + previous = self.registry.get(manifest.id) if self.registry.is_installed(manifest.id) else None + if previous and isinstance(previous.get("registered_files"), dict): + owned.update({k: v for k, v in previous["registered_files"].items() if isinstance(k, str) and isinstance(v, str)}) + for entry in files: + source = extension_dir / entry["file"] + if not source.is_file(): + _note(f"[yellow]⚠[/yellow] file '{entry['name']}': {entry['file']} missing in extension") + continue + dest_rel = str(entry["dest"]) + if dest_rel.startswith(INTEGRATION_FOLDER_TOKEN + "/"): + if not folder: + _note(f"[dim]no active integration folder; file '{entry['name']}' not placed[/dim]") + continue + dest_rel = f"{folder}/{dest_rel[len(INTEGRATION_FOLDER_TOKEN) + 1:]}" + self._place_owned_file(source, self.project_root / dest_rel, owned) + return owned + + def _remove_extension_owned_files( + self, registered_agents: Dict[str, str], registered_files: Dict[str, str] + ) -> None: + """Delete the agent and project files this extension placed, unless edited since.""" + for group in (registered_agents, registered_files): + if not isinstance(group, dict): + continue + for rel, recorded in group.items(): + if not isinstance(rel, str) or not isinstance(recorded, str): + continue + dest = self.project_root / rel + try: + self._ensure_inside_project(dest) + except ExtensionError: + continue + if not dest.is_file(): + continue + if self._file_sha256(dest) != recorded: + _note(f"[yellow]⚠[/yellow] {rel} was edited locally; left in place") + continue + dest.unlink() + parent = dest.parent + try: + if parent != self.project_root and not any(parent.iterdir()): + parent.rmdir() + except OSError: + pass + def _unregister_extension_skills( self, skill_names: List[str], @@ -2584,6 +2790,11 @@ def _restore_stranded_config_file( manifest, dest_dir, link_outputs=link_commands ) + # Subagent definitions and verbatim project files (provides.agents / + # provides.files) land in the active integration's own directories. + registered_agents = self._register_extension_agents(manifest, dest_dir) + registered_files = self._install_extension_files(manifest, dest_dir) + # Register hooks and update installed list in extensions.yml hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) @@ -2623,6 +2834,8 @@ def _restore_stranded_config_file( "priority": priority, "registered_commands": registered_commands, "registered_skills": registered_skills, + "registered_agents": registered_agents, + "registered_files": registered_files, }, ) @@ -2927,6 +3140,10 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: # Unregister agent skills self._unregister_extension_skills(registered_skills, extension_id) + self._remove_extension_owned_files( + (metadata.get("registered_agents") if metadata else None) or {}, + (metadata.get("registered_files") if metadata else None) or {}, + ) if keep_config: # Preserve config files, only remove non-config files diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 529803922e..5b65db1a84 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -143,6 +143,11 @@ class IntegrationBase(ABC): integration that sets this flag. """ + agents_dir: str | None = None + """Project-relative directory this harness reads subagent definitions from + (``.claude/agents`` for Claude Code, ``.cursor/agents`` for Cursor). ``None`` + means the harness has no file-based subagent lane and an extension's + ``provides.agents`` is skipped for it, with a note.""" legacy_flat_command_dir: str | None = None """Previous flat command directory retired after skill replacements exist.""" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 2ce7fb6dcc..a333a5bf9a 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -53,6 +53,7 @@ class ClaudeIntegration(SkillsIntegration): "extension": "/SKILL.md", } multi_install_safe = True + agents_dir = ".claude/agents" CANONICAL_TO_NATIVE = { "session_start": "SessionStart", diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index eb28286a9a..4af589c9ac 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -40,6 +40,7 @@ class CursorAgentIntegration(SkillsIntegration): } multi_install_safe = True + agents_dir = ".cursor/agents" CANONICAL_TO_NATIVE = { "session_start": "sessionStart", diff --git a/tests/test_extension_agents_files.py b/tests/test_extension_agents_files.py new file mode 100644 index 0000000000..fb0bb1c4dd --- /dev/null +++ b/tests/test_extension_agents_files.py @@ -0,0 +1,154 @@ +"""Tests for ``provides.agents`` and ``provides.files`` in extension manifests. + +An extension may ship subagent definitions and verbatim project files. On install they +land in the active integration's own directories (``.claude/agents/`` for Claude Code, +``.cursor/agents/`` for Cursor; ``{integration_folder}/…`` for files); on removal they +are deleted unless a person edited them since. An integration with no subagent lane +gets a note, not an error. +""" +import json +from pathlib import Path + +import pytest +import yaml + +from specify_cli.extensions import ( + ExtensionManager, + ExtensionManifest, + ValidationError, + project_dest_violation, +) + + +def _init_options(project_root: Path, ai: str) -> None: + (project_root / ".specify").mkdir(parents=True, exist_ok=True) + (project_root / ".specify" / "init-options.json").write_text( + json.dumps({"ai": ai, "ai_skills": True, "script": "sh"}), encoding="utf-8" + ) + + +def _extension(tmp: Path, ext_id: str = "tiered", *, with_agent=True, with_file=True) -> Path: + ext = tmp / ext_id + (ext / "agents").mkdir(parents=True) + (ext / "workflows").mkdir() + provides = {"commands": [{"name": f"speckit.{ext_id}.run", "file": "commands/run.md", "description": "run"}]} + if with_agent: + (ext / "agents" / "explorer.md").write_text( + "---\nname: explorer\ndescription: Read-only exploration\nmodel: haiku\ntools: Read, Grep\n---\n\nMap the code.\n", + encoding="utf-8", + ) + provides["agents"] = [{"name": "explorer", "file": "agents/explorer.md", "description": "haiku explorer"}] + if with_file: + (ext / "workflows" / "panel.js").write_text("export const meta = { name: 'panel' }\nreturn 1\n", encoding="utf-8") + provides["files"] = [ + {"name": "panel", "file": "workflows/panel.js", "dest": "{integration_folder}/workflows/panel.js", "description": "a Workflow script"}, + ] + (ext / "commands").mkdir() + (ext / "commands" / "run.md").write_text("---\ndescription: run\n---\n\nRun.\n", encoding="utf-8") + (ext / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": {"id": ext_id, "name": "Tiered", "version": "1.0.0", "description": "agents and files"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": provides, + } + ), + encoding="utf-8", + ) + return ext + + +def _manager(project_root: Path) -> ExtensionManager: + (project_root / ".specify" / "extensions").mkdir(parents=True, exist_ok=True) + return ExtensionManager(project_root) + + +def test_manifest_accepts_agents_and_files_and_rejects_bad_destinations(tmp_path): + ext = _extension(tmp_path) + manifest = ExtensionManifest(ext / "extension.yml") + assert [a["name"] for a in manifest.agents] == ["explorer"] + assert [f["dest"] for f in manifest.files] == ["{integration_folder}/workflows/panel.js"] + + assert project_dest_violation(".claude/workflows/x.js") is None + assert project_dest_violation("{integration_folder}/workflows/x.js") is None + assert project_dest_violation("/abs/x.js") + assert project_dest_violation("../x.js") + assert project_dest_violation(".specify/extensions/x/y.js") + assert project_dest_violation("a/{integration_folder}/x.js") + + data = yaml.safe_load((ext / "extension.yml").read_text()) + data["provides"]["files"][0]["dest"] = "../escape.js" + (ext / "extension.yml").write_text(yaml.safe_dump(data)) + with pytest.raises(ValidationError, match="dest"): + ExtensionManifest(ext / "extension.yml") + + +def test_install_places_agent_and_file_for_claude_and_remove_cleans_them(tmp_path): + project = tmp_path / "project" + project.mkdir() + _init_options(project, "claude") + ext = _extension(tmp_path) + manager = _manager(project) + manager.install_from_directory(ext, "1.0.0", register_commands=False) + + agent = project / ".claude" / "agents" / "explorer.md" + workflow = project / ".claude" / "workflows" / "panel.js" + assert agent.read_text(encoding="utf-8").startswith("---\nname: explorer") + assert workflow.read_text(encoding="utf-8").startswith("export const meta") + + meta = manager.registry.get("tiered") + assert set(meta["registered_agents"]) == {".claude/agents/explorer.md"} + assert set(meta["registered_files"]) == {".claude/workflows/panel.js"} + + assert manager.remove("tiered") is True + assert not agent.exists() + assert not workflow.exists() + + +def test_a_locally_edited_file_is_never_overwritten_or_deleted(tmp_path): + project = tmp_path / "project" + project.mkdir() + _init_options(project, "claude") + ext = _extension(tmp_path) + manager = _manager(project) + manager.install_from_directory(ext, "1.0.0", register_commands=False) + agent = project / ".claude" / "agents" / "explorer.md" + agent.write_text(agent.read_text(encoding="utf-8") + "\nA local tweak.\n", encoding="utf-8") + + # reinstall (--force) keeps the person's edit + manager.install_from_directory(ext, "1.0.0", register_commands=False, force=True) + assert "A local tweak." in agent.read_text(encoding="utf-8") + + # removal leaves it in place too + manager.remove("tiered") + assert agent.exists() + assert not (project / ".claude" / "workflows" / "panel.js").exists(), "the untouched file is gone" + + +def test_cursor_gets_its_own_agents_dir_and_codex_gets_files_only(tmp_path): + project = tmp_path / "cursor" + project.mkdir() + _init_options(project, "cursor-agent") + manager = _manager(project) + manager.install_from_directory(_extension(tmp_path / "a"), "1.0.0", register_commands=False) + assert (project / ".cursor" / "agents" / "explorer.md").is_file() + assert (project / ".cursor" / "workflows" / "panel.js").is_file() + + project2 = tmp_path / "codex" + project2.mkdir() + _init_options(project2, "codex") + manager2 = _manager(project2) + manager2.install_from_directory(_extension(tmp_path / "b"), "1.0.0", register_commands=False) + assert manager2.registry.get("tiered")["registered_agents"] == {} + assert (project2 / ".agents" / "workflows" / "panel.js").is_file() + + +def test_an_extension_of_only_agents_and_files_is_valid(tmp_path): + ext = _extension(tmp_path) + data = yaml.safe_load((ext / "extension.yml").read_text()) + del data["provides"]["commands"] + (ext / "extension.yml").write_text(yaml.safe_dump(data)) + manifest = ExtensionManifest(ext / "extension.yml") + assert manifest.commands == [] + assert manifest.agents and manifest.files