Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

<!-- insert new changelog below this comment -->

## [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
Expand Down
28 changes: 28 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.md`
for Claude Code, `.cursor/agents/<name>.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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
221 changes: 219 additions & 2 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand Down Expand Up @@ -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")

Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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")
Comment on lines +1963 to +1967

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
Comment on lines +1986 to +1988
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],
Expand Down Expand Up @@ -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)
Comment on lines +2793 to +2796

# Register hooks and update installed list in extensions.yml
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)
Expand Down Expand Up @@ -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,
},
)

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ClaudeIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
multi_install_safe = True
agents_dir = ".claude/agents"

CANONICAL_TO_NATIVE = {
"session_start": "SessionStart",
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/cursor_agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class CursorAgentIntegration(SkillsIntegration):
}

multi_install_safe = True
agents_dir = ".cursor/agents"

CANONICAL_TO_NATIVE = {
"session_start": "sessionStart",
Expand Down
Loading