Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
Expand Down
11 changes: 10 additions & 1 deletion integrations/catalog.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-29T00:00:00Z",
"updated_at": "2026-05-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
Expand All @@ -12,6 +12,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "anthropic"]
},
"cline": {
"id": "cline",
"name": "Cline",
"version": "1.0.0",
"description": "Cline IDE integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
Comment thread
pedropalb marked this conversation as resolved.
"copilot": {
"id": "copilot",
"name": "GitHub Copilot",
Expand Down
10 changes: 9 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3118,9 +3118,17 @@ def extension_add(
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")

is_cline = load_init_options(project_root).get("ai") == "cline"

if is_cline:
from specify_cli.integrations.cline import format_cline_command_name

console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
cmd_name = cmd['name']
if is_cline:
cmd_name = format_cline_command_name(cmd_name)
console.print(f" • {cmd_name} - {cmd.get('description', '')}")

# Report agent skills registration
reg_meta = manager.registry.get(manifest.id)
Expand Down
85 changes: 69 additions & 16 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,33 @@ def _ensure_configs(cls) -> None:
except ImportError:
pass # Circular import during module init; retry on next access

@staticmethod
def _hyphenate_frontmatter_refs(val: Any) -> Any:
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
if isinstance(val, dict):
return {
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
for k, v in val.items()
}
elif isinstance(val, list):
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
elif isinstance(val, str):
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
val,
)
return val

@staticmethod
def _hyphenate_body_refs(body: str) -> str:
"""Hyphenate dotted speckit references in command body text."""
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
body,
)

@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Expand Down Expand Up @@ -401,6 +428,9 @@ def _compute_output_name(
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
format_name = agent_config.get("format_name")
if format_name:
return format_name(cmd_name)
Comment thread
pedropalb marked this conversation as resolved.
return cmd_name
Comment thread
pedropalb marked this conversation as resolved.

short_name = cmd_name
Expand Down Expand Up @@ -430,6 +460,13 @@ def _ensure_inside(candidate: Path, base: Path) -> None:
if not normalized.is_relative_to(base_normalized):
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")

@staticmethod
def _is_safe_command_name(name: str) -> bool:
"""Reject names that could escape the commands directory via path traversal."""
if os.path.sep in name or "/" in name or "\\" in name:
return False
return os.path.normpath(name) == name

def register_commands(
self,
agent_name: str,
Expand Down Expand Up @@ -475,9 +512,11 @@ def register_commands(
commands_dir.mkdir(parents=True, exist_ok=True)

registered = []
is_cline_ext = agent_name == "cline" and source_id != "core"

for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]

source_file = source_dir / cmd_file
Expand Down Expand Up @@ -509,6 +548,10 @@ def register_commands(
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name

if is_cline_ext:
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
body = self._hyphenate_body_refs(body)

body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
Expand Down Expand Up @@ -578,7 +621,7 @@ def register_commands(

registered.append(cmd_name)

for alias in cmd_info.get("aliases", []):
for alias in aliases:
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
Expand Down Expand Up @@ -902,22 +945,32 @@ def unregister_commands(
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)

names_to_clean = [output_name]
if output_name != cmd_name and self._is_safe_command_name(cmd_name):
names_to_clean.append(cmd_name)

for target_dir in dirs_to_clean:
cmd_file = (
target_dir / f"{output_name}{agent_config['extension']}"
)
if cmd_file.exists() or cmd_file.is_symlink():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
for name in names_to_clean:
cmd_file = (
target_dir / f"{name}{agent_config['extension']}"
)
Comment thread
pedropalb marked this conversation as resolved.
try:
self._ensure_inside(cmd_file, target_dir)
except ValueError:
continue
if cmd_file.exists() or cmd_file.is_symlink():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass

if agent_name == "copilot":
prompt_file = (
Expand Down
3 changes: 2 additions & 1 deletion src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ def init(
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
cline_skill_mode = selected_ai == "cline"
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode

if codex_skill_mode and not ai_skills:
Expand All @@ -709,7 +710,7 @@ def _display_cmd(name: str) -> str:
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"

Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,7 @@ def _render_hook_invocation(self, command: Any) -> str:
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
cline_mode = selected_ai == "cline"

skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
Expand All @@ -2423,6 +2424,10 @@ def _render_hook_invocation(self, command: Any) -> str:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
if cline_mode:
from .integrations.cline import format_cline_command_name

return f"/{format_cline_command_name(command_id)}"

return f"/{command_id}"

Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def _register_builtins() -> None:
from .auggie import AuggieIntegration
from .bob import BobIntegration
from .claude import ClaudeIntegration
from .cline import ClineIntegration
from .codebuddy import CodebuddyIntegration
from .codex import CodexIntegration
from .copilot import CopilotIntegration
Expand Down Expand Up @@ -85,6 +86,7 @@ def _register_builtins() -> None:
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(ClineIntegration())
_register(CodebuddyIntegration())
_register(CodexIntegration())
_register(CopilotIntegration())
Expand Down
Loading
Loading