Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
58afa3e
Initial plan
Copilot Apr 10, 2026
9ef46b4
Add workflow engine with step registry, expression engine, catalog sy…
Copilot Apr 10, 2026
51d09c0
Add comprehensive tests for workflow engine (94 tests)
Copilot Apr 10, 2026
273dd70
Address review feedback: do-while condition preservation and URL sche…
Copilot Apr 10, 2026
c1ad7ce
Address review feedback, add CLI dispatch, interactive gates, and docs
mnriem Apr 13, 2026
eb7a764
Fix ruff lint errors: unused imports, f-string placeholders, undefine…
mnriem Apr 13, 2026
b682f90
Address second review: registry-backed validation, shell failures, lo…
mnriem Apr 13, 2026
2dace14
Potential fix for pull request finding 'Empty except'
mnriem Apr 13, 2026
88f9a36
Address third review: fan-out IDs, catalog guards, shell coercion, docs
mnriem Apr 13, 2026
4ea9483
Validate final URL after redirects in catalog fetch
mnriem Apr 13, 2026
ea14a73
Address fourth review: filter arg eval, tags normalization, install r…
mnriem Apr 13, 2026
3942369
Add explanatory comment to empty except ValueError block
mnriem Apr 13, 2026
1054708
Address fifth review: expression parsing, fan-out output, URL install…
mnriem Apr 13, 2026
97dcf01
Add comments to empty except ValueError blocks in URL install
mnriem Apr 13, 2026
e681ffe
Address sixth review: operator precedence, fan_in cleanup, registry r…
mnriem Apr 13, 2026
704f62c
Address seventh review: string literal before pipe, type annotations,…
mnriem Apr 13, 2026
65092d4
Address eighth review: fan-out namespaced IDs, early return, catalog …
mnriem Apr 13, 2026
3932af0
Address ninth review: populate catalog, fix indentation, priority, RE…
mnriem Apr 13, 2026
38b7b17
Address tenth review: max_iterations validation, catalog config guard…
mnriem Apr 14, 2026
e80dc90
Address eleventh review: command step fails without CLI, ID mismatch …
mnriem Apr 14, 2026
b3a0e33
Address twelfth review: type annotations, version examples, streaming…
mnriem Apr 14, 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
637 changes: 637 additions & 0 deletions src/specify_cli/__init__.py

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,126 @@ def options(cls) -> list[IntegrationOption]:
"""Return options this integration accepts. Default: none."""
return []

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive execution.

Returns a list of command-line tokens that will execute *prompt*
non-interactively using this integration's CLI tool, or ``None``
if the integration does not support CLI dispatch.

Subclasses for CLI-based integrations should override this.
"""
return None

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native slash-command invocation for a Spec Kit command.

The CLI tools discover and execute commands from installed files
on disk. This method builds the invocation string the CLI
expects — e.g. ``"/speckit.specify my-feature"`` for markdown
agents or ``"/speckit-specify my-feature"`` for skills agents.

*command_name* may be a full dotted name like
``"speckit.specify"`` or a bare stem like ``"specify"``.
"""
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]

invocation = f"/speckit.{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation

def dispatch_command(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
model: str | None = None,
timeout: int = 600,
stream: bool = True,
) -> dict[str, Any]:
"""Dispatch a Spec Kit command through this integration's CLI.

By default this builds a slash-command invocation with
``build_command_invocation()`` and passes that prompt to
``build_exec_args()`` to construct the CLI command line.
Integrations with custom dispatch behavior can override
``build_command_invocation()``, ``build_exec_args()``, or
``dispatch_command()`` directly.

When *stream* is ``True`` (the default), stdout and stderr are
piped directly to the terminal so the user sees live output.
When ``False``, output is captured and returned in the dict.

Returns a dict with ``exit_code``, ``stdout``, and ``stderr``.
Raises ``NotImplementedError`` if the integration does not
support CLI dispatch.
"""
import subprocess
import sys

prompt = self.build_command_invocation(command_name, args)
# When streaming to the terminal, request text output so the
# user sees readable output instead of raw JSONL events.
exec_args = self.build_exec_args(
prompt, model=model, output_json=not stream
)

if exec_args is None:
msg = (
f"Integration {self.key!r} does not support CLI dispatch. "
f"Override build_exec_args() to enable it."
)
raise NotImplementedError(msg)

cwd = str(project_root) if project_root else None

if stream:
# No timeout when streaming — the user sees live output and
# can Ctrl+C at any time. The timeout parameter is only
# applied in the captured (non-streaming) branch below.
try:
result = subprocess.run(
exec_args,
stdout=sys.stdout,
stderr=sys.stderr,
text=True,
cwd=cwd,
)
except KeyboardInterrupt:
return {
"exit_code": 130,
"stdout": "",
"stderr": "Interrupted by user",
}
return {
"exit_code": result.returncode,
"stdout": "",
"stderr": "",
}

result = subprocess.run(
exec_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)
return {
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}

# -- Primitives — building blocks for setup() -------------------------

def shared_commands_dir(self) -> Path | None:
Expand Down Expand Up @@ -463,6 +583,22 @@ class MarkdownIntegration(IntegrationBase):
integration-specific scripts (``update-context.sh`` / ``.ps1``).
"""

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args

def setup(
self,
project_root: Path,
Expand Down Expand Up @@ -524,6 +660,22 @@ class TomlIntegration(IntegrationBase):
TOML format (``description`` key + ``prompt`` multiline string).
"""

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["-m", model])
if output_json:
args.extend(["--output-format", "json"])
return args

def command_filename(self, template_name: str) -> str:
"""TOML commands use ``.toml`` extension."""
return f"speckit.{template_name}.toml"
Expand Down Expand Up @@ -704,6 +856,22 @@ class SkillsIntegration(IntegrationBase):
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
"""

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args

def skills_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the skills output directory.

Expand All @@ -724,6 +892,17 @@ def skills_dest(self, project_root: Path) -> Path:
subdir = self.config.get("commands_subdir", "skills")
return project_root / folder / subdir

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]

invocation = f"/speckit-{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation

def setup(
self,
project_root: Path,
Expand Down
15 changes: 15 additions & 0 deletions src/specify_cli/integrations/codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ class CodexIntegration(SkillsIntegration):
}
context_file = "AGENTS.md"

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
args: list[str] = ["codex", "exec", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.append("--json")
return args

@classmethod
def options(cls) -> list[IntegrationOption]:
return [
Expand Down
99 changes: 98 additions & 1 deletion src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@


class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot in VS Code."""
"""Integration for GitHub Copilot (VS Code IDE + CLI)."""

key = "copilot"
config = {
Expand All @@ -37,6 +37,103 @@ class CopilotIntegration(IntegrationBase):
}
context_file = ".github/copilot-instructions.md"

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# GitHub Copilot CLI uses ``copilot -p "prompt"`` for
# non-interactive mode. --allow-all-tools lets the agent
# execute file edits and shell commands without manual approval.
args = ["copilot", "-p", prompt, "--allow-all-tools"]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Copilot agents are not slash-commands — just return the args as prompt."""
return args or ""

def dispatch_command(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
model: str | None = None,
timeout: int = 600,
stream: bool = True,
) -> dict[str, Any]:
"""Dispatch via ``--agent speckit.<stem>`` instead of slash-commands.

Copilot ``.agent.md`` files are agents, not skills. The CLI
selects them with ``--agent <name>`` and the prompt is just
the user's arguments.
"""
import subprocess
import sys

stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
agent_name = f"speckit.{stem}"

prompt = args or ""
# NOTE: --allow-all-tools is required for non-interactive execution
# so the agent can perform file edits and shell commands. The
# workflow engine's gate steps serve as the human approval mechanism.
# Making tool approval configurable (e.g. via workflow config or
# env var) is tracked as a future enhancement.
cli_args = [
"copilot", "-p", prompt,
"--agent", agent_name,
"--allow-all-tools",
]
if model:
cli_args.extend(["--model", model])
if not stream:
cli_args.extend(["--output-format", "json"])

cwd = str(project_root) if project_root else None

if stream:
try:
result = subprocess.run(
cli_args,
stdout=sys.stdout,
stderr=sys.stderr,
text=True,
cwd=cwd,
)
except KeyboardInterrupt:
return {
"exit_code": 130,
"stdout": "",
"stderr": "Interrupted by user",
}
return {
"exit_code": result.returncode,
"stdout": "",
"stderr": "",
}

result = subprocess.run(
cli_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)
return {
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}

def command_filename(self, template_name: str) -> str:
"""Copilot commands use ``.agent.md`` extension."""
return f"speckit.{template_name}.agent.md"
Expand Down
Loading
Loading