Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5208679
feat: register extension commands for generic integration
mnriem Sep 29, 2026
c5724ef
fix: preserve generic extension lifecycle across updates
mnriem Sep 29, 2026
42f36bf
chore: merge upstream main for bundled GitHub extension
mnriem Sep 29, 2026
cf484c9
fix: complete generic extension registration lifecycle
mnriem Sep 29, 2026
13e4697
chore: bump bundled GitHub extension after migration docs change
mnriem Sep 29, 2026
6c8bd76
fix: preserve generic disable state through updates and enablement
mnriem Sep 29, 2026
17d2bff
fix: reject linked generic skill directories and roll back write errors
mnriem Sep 29, 2026
f2c5ede
fix: remove generic commands without readable integration settings
mnriem Sep 29, 2026
2546982
fix: roll back generic installs through commit and preserve edited sk…
mnriem Sep 29, 2026
e3b33da
fix: explain unsupported generic integration state schema
mnriem Sep 29, 2026
bd2a512
fix: roll back partial generic extension refresh outputs
mnriem Sep 29, 2026
d305bd9
fix: reject partial generic extension refreshes
mnriem Sep 29, 2026
b645dd5
fix: make generic extension lifecycle rollback complete
mnriem Sep 29, 2026
8b2c44f
fix: restore historical generic artifacts on update rollback
mnriem Sep 29, 2026
e9159ee
Reject inconsistent generic extension state and report update errors
mnriem Sep 29, 2026
71815df
Scope generic extension state handling to generic projects
mnriem Sep 29, 2026
9a8d2ef
Reject hard-linked generic outputs during refresh
mnriem Sep 29, 2026
ca3d27e
Require per-extension ownership for generic symlinks
mnriem Sep 29, 2026
2fd9aaa
Cover owned generic dev links during force reinstall
mnriem Sep 30, 2026
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
4 changes: 3 additions & 1 deletion design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ selected variant. `py` is opt-in; non-interactive init defaults to `sh` on
POSIX or `ps` on Windows. Maintain equivalent stdout behavior across all three.
Bundled extension commands do not yet use this core-template script routing.
`__AGENT__` and command references are resolved during rendering, not by
adding per-agent wrapper scripts.
adding per-agent wrapper scripts. For `generic`, extension registration
resolves the persisted `--commands-dir` rather than the static registry
placeholder; `--skills` emits skills into that same directory.

## Ownership and lifecycle

Expand Down
9 changes: 6 additions & 3 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,12 @@ After initialization, you should see the following commands available in your co
- `/speckit.taskstoissues` - Convert tasks to issues (moving to the bundled `github` extension as
`/speckit.github.taskstoissues`; install it with `specify extension add github`)

The `generic` integration scaffolds core commands or skills, but does not
register extension add-ons; installing `github` there does not make its
replacement command invokable. See the [GitHub extension's installation notes](https://github.com/github/spec-kit/blob/main/extensions/github/README.md#installation).
The `generic` integration also registers extension commands in its configured
`--commands-dir`. Installing `github` makes `/speckit.github.taskstoissues`
available there as a command file, or as `/speckit-github-taskstoissues` when
`--skills` is enabled. The core `/speckit.taskstoissues` (or
`/speckit-taskstoissues` with `--skills`) remains available.
See the [GitHub extension's installation notes](https://github.com/github/spec-kit/blob/main/extensions/github/README.md#installation).

Scripts are installed into a variant subdirectory matching the chosen script type:

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/agentic-sdd.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,4 @@ identified by that remote and checks existing task IDs to avoid duplicates.
It is not required to implement tasks or converge on a feature.

> [!NOTE]
> GitHub issue tracking is moving out of core into the bundled, opt-in [`github` extension](https://github.com/github/spec-kit/blob/main/extensions/github/README.md). `/speckit.taskstoissues` still works and is unchanged, but its replacement, `/speckit.github.taskstoissues`, is available via `specify extension add github` for integrations that register extension add-ons. The `generic` integration does not register them in either commands or skills mode; see the [installation and migration notes](https://github.com/github/spec-kit/blob/main/extensions/github/README.md#installation).
> GitHub issue tracking is moving out of core into the bundled, opt-in [`github` extension](https://github.com/github/spec-kit/blob/main/extensions/github/README.md). `/speckit.taskstoissues` still works and is unchanged. Install its namespaced replacement with `specify extension add github`; the `generic` integration registers it under its configured command directory in commands or skills mode. See the [installation and migration notes](https://github.com/github/spec-kit/blob/main/extensions/github/README.md#installation).
10 changes: 8 additions & 2 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ specify extension add <name>
| `--force` | Overwrite if the extension is already installed |
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |

Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
Installs an extension from the catalog, a URL, or a local directory. Extension commands are registered with the active AI coding agent integration. For `generic`, invocations use the configured `--commands-dir`: flat command files by default, or `speckit-<name>/SKILL.md` with `--skills`. The core `speckit.taskstoissues` command remains available alongside the GitHub extension's namespaced replacement during migration.

If a generic integration refresh cannot produce every extension invocation (for example, because a command or skill is user-modified or its source is missing), it warns and restores that extension's prior registered artifacts. Other extensions can still refresh.

> **Note:** All extension commands require a project already initialized with `specify init`.
Expand Down Expand Up @@ -93,14 +95,18 @@ Updates a specific extension, or all installed extensions if no name is given.

Bundled extensions (such as `agent-context` and `git`) have no download URL; their updates install from the copy shipped with the running spec-kit release. When the catalog advertises a newer version than your spec-kit release ships, the update is reported as requiring a spec-kit upgrade first.

For `generic`, a failed update restores hash-owned invocations from previously configured `--commands-dir` locations as well as the current location, even if another integration is now active.

## Enable / Disable an Extension

```bash
specify extension enable <name>
specify extension disable <name>
```

Disable an extension without removing it. Disabled extensions are not loaded and their commands are not available. Re-enable with `enable`.
Disable an extension without removing it. Disabled extensions are not loaded and their commands are not available. Hook-only extensions can be installed, enabled, and disabled even if generic command-output settings are missing or invalid; extensions with commands still require valid settings. Re-enable with `enable`.

For `generic`, disabling removes hash-owned invocations even after the output directory moves, but preserves unrelated same-named files in the new directory.

## Set Extension Priority

Expand Down
10 changes: 9 additions & 1 deletion docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ Some integrations accept additional options via `--integration-options`:
| Integration | Option | Description |
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `generic` | `--skills` | Render commands as `speckit-<name>/SKILL.md` directories under `--commands-dir` instead of flat `speckit.<name>.md` files. Command references and next-step guidance switch to `/speckit-<name>`. Generic's output directory is a runtime option rather than a static per-agent folder, so this does not enable extension/preset add-on skill registration in either layout. |
| `generic` | `--skills` | Render commands and installed extension invocations as `speckit-<name>/SKILL.md` directories under `--commands-dir` instead of flat `speckit.<name>.md` files. Command references and next-step guidance switch to `/speckit-<name>`. |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. |
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
Expand All @@ -272,6 +272,14 @@ specify integration install generic --integration-options="--commands-dir .myage
specify integration install generic --integration-options="--commands-dir .myagent/skills --skills"
```

Once `generic` is the active integration, `specify extension add` registers
extension commands in its configured `--commands-dir` (as command files or
skills according to `--skills`). `specify extension remove` removes unchanged
extension-owned artifacts while leaving core commands, user files, and edited
extension files intact. The core `speckit.taskstoissues` command remains
available; installing the GitHub extension adds the namespaced replacement
without deprecating or removing the core command.

## Scaffold a New Integration

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ specify preset add [<preset_id>]
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--priority <N>` | Resolution priority (default: 10; lower = higher precedence) |

Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with the currently installed AI coding agent integration.
Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with supported active AI coding agent integrations. The generic integration currently delivers extension invocations but does not register preset command or skill overrides.

> **Note:** All preset commands require a project already initialized with `specify init`.
Expand Down
2 changes: 1 addition & 1 deletion extensions/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"github": {
"name": "GitHub Integration",
"id": "github",
"version": "1.0.0",
"version": "1.0.1",
"description": "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
Expand Down
18 changes: 8 additions & 10 deletions extensions/github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@ From the root of an initialized Spec Kit project:
specify extension add github
```

The `generic` (bring your own agent) integration is an exception: it scaffolds
core commands or skills under `--commands-dir`, but does not currently register
extension add-ons in either layout. Installing `github` in a generic project
installs its sources but does **not** create a
`speckit.github.taskstoissues` command or skill. The core
`speckit.taskstoissues` command remains available. See
The `generic` (bring your own agent) integration registers this command under
its configured `--commands-dir` in both commands and skills layouts. Installing
`github` creates `speckit.github.taskstoissues.md` in commands mode or
`speckit-github-taskstoissues/SKILL.md` in skills mode. The core
`speckit.taskstoissues` command remains available and unchanged. See
[integration-specific options](../../docs/reference/integrations.md#integration-specific-options).

## Removal
Expand Down Expand Up @@ -92,10 +91,9 @@ To migrate, install the extension and use the namespaced command instead:
specify extension add github
```

This migration is not yet available for the `generic` integration: its core
command remains available, but extension add-ons do not register under its
custom `--commands-dir`. Generic registration needs a separate fix before the
core command can be removed for those projects.
The `generic` integration supports this migration in both commands and skills
layouts under its configured `--commands-dir`. The core command remains
available until a separate deprecation and removal decision.

| Before | After |
| ------------------------- | --------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion extensions/github/extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ schema_version: "1.0"
extension:
id: github
name: "GitHub Integration"
version: "1.0.0"
version: "1.0.1"
description: "GitHub platform integration for Spec Kit - create GitHub issues from a feature's task list"
author: spec-kit-core
repository: https://github.com/github/spec-kit
Expand Down
15 changes: 6 additions & 9 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
Returns ``project_path / <agent_folder> / "skills"``, falling back
to ``project_path / ".agents/skills"`` for unknown agents.
"""
if selected_ai == "generic":
from .integrations.generic import registration_directory

return project_path / registration_directory(project_path).relative_to(
project_path.resolve()
)
agent_config = AGENT_CONFIG.get(selected_ai, {})
agent_folder = agent_config.get("folder", "")
if agent_folder:
Expand Down Expand Up @@ -307,15 +313,6 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
if not isinstance(agent, str) or not agent:
return None

# generic's output directory is a runtime --commands-dir CLI option, not
# a static per-agent folder (its config["folder"] is None), so there is
# no directory extension/preset skill registration could safely resolve
# here even when the project was scaffolded with --skills. Registration
# stays disabled for generic in both layouts, matching flat-mode generic
# (which never persists ai_skills=True and so never reaches this point).
if agent == "generic":
return None

ai_skills_enabled = _is_ai_skills_enabled(opts)
if not ai_skills_enabled and agent != "kimi":
return None
Expand Down
44 changes: 43 additions & 1 deletion src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
command files into agent-specific directories in the correct format.
"""

import hashlib
import os
import re
from copy import deepcopy
Expand Down Expand Up @@ -58,8 +59,23 @@ class CommandRegistrar:
AGENT_CONFIGS: dict[str, dict[str, Any]] = {}
_configs_loaded: bool = False

def __init__(self) -> None:
def __init__(self, project_root: Path | None = None) -> None:
self._ensure_configs()
self.AGENT_CONFIGS = dict(self.AGENT_CONFIGS)
if project_root is not None:
from .integrations.generic import registration_directory

from ._init_options import load_init_options

opts = load_init_options(project_root)
if isinstance(opts, dict) and opts.get("ai") == "generic":
self.AGENT_CONFIGS["generic"] = {
"dir": str(registration_directory(project_root)),
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"invoke_separator": ".",
}

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
Expand Down Expand Up @@ -604,6 +620,26 @@ def _is_safe_command_name(name: str) -> bool:
return False
return os.path.normpath(name) == name

@staticmethod
def _generic_owned_output(path: Path, source_id: str, project_root: Path) -> bool:
"""Only reuse a generated file if its installed extension still owns its bytes."""
from .extensions import ExtensionRegistry

metadata = ExtensionRegistry(
project_root / ".specify" / "extensions"
).get(source_id)
hashes = metadata.get("generic_artifact_hashes", {}) if metadata else {}
if not isinstance(hashes, dict) or not path.is_file():
return False
if path.stat().st_nlink > 1:
return False
if path.is_symlink() and not path.resolve().is_relative_to(
(project_root / ".specify/extensions" / source_id).resolve()
):
return False
relative = path.relative_to(project_root.resolve()).as_posix()
return hashes.get(relative) == hashlib.sha256(path.read_bytes()).hexdigest()

@staticmethod
def _same_lexical_path(left: Path, right: Path) -> bool:
"""Compare paths after lexical normalization without resolving symlinks."""
Expand Down Expand Up @@ -872,6 +908,9 @@ def register_commands(

dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
self._ensure_inside(dest_file, commands_dir)
if agent_name == "generic" and (dest_file.exists() or dest_file.is_symlink()):
if not self._generic_owned_output(dest_file, source_id, project_root):
continue
dest_file.parent.mkdir(parents=True, exist_ok=True)
self._write_registered_output(
dest_file,
Expand Down Expand Up @@ -954,6 +993,9 @@ def register_commands(
commands_dir / f"{alias_output_name}{agent_config['extension']}"
)
self._ensure_inside(alias_file, commands_dir)
if agent_name == "generic" and (alias_file.exists() or alias_file.is_symlink()):
if not self._generic_owned_output(alias_file, source_id, project_root):
continue
alias_file.parent.mkdir(parents=True, exist_ok=True)
self._write_registered_output(
alias_file,
Expand Down
18 changes: 15 additions & 3 deletions src/specify_cli/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1829,9 +1829,21 @@ def refresh_integration_events(project_root: Path) -> None:
from ..integrations import get_integration
from ..integrations._helpers import _read_integration_json, _resolve_integration_options
from ..integrations.manifest import IntegrationManifest
from ..integration_state import installed_integration_keys

state = _read_integration_json(project_root)
from ..integration_state import installed_integration_keys, try_read_integration_json
from .._init_options import load_init_options

if load_init_options(project_root).get("ai") == "generic":
state, error = try_read_integration_json(project_root)
if error is not None:
detail = (
f"unsupported schema {error.schema}"
if error.kind == "schema_too_new"
else f"{error.kind}: {error.detail}"
)
raise EventRefreshError([(".specify/integration.json", detail)])
state = state or {}
else:
state = _read_integration_json(project_root)
failures: list[tuple[str, str]] = []
for key in installed_integration_keys(state):
integration = get_integration(key)
Expand Down
Loading
Loading