mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
* fix(integrations): reject empty --commands-dir in generic raw_options GenericIntegration._resolve_commands_dir has a parity gap: the parsed-options branch guards emptiness (`if commands_dir:`), but the raw_options fallback returned the value verbatim with no check. So `--integration-options= "--commands-dir="` (or `--commands-dir ""`) resolves to `""`, which makes setup() compute `dest = project_root / "" == project_root` and write every speckit command file (specify.md, plan.md, ...) directly into the PROJECT ROOT — silently bypassing the documented "--commands-dir is required" contract and polluting the repo root. Apply the same non-empty guard to the raw_options branch so an empty value falls through to the existing "required" ValueError on every input form. Non-empty values resolve exactly as before. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(integrations): reject a BLANK --commands-dir, not just an empty one Self-review follow-up: bare truthiness only closes the empty-string subset. A whitespace-only value passed both branches (verified: raw "--commands-dir ' '" returned ' ', parsed {"commands_dir": " "} returned ' '), so command files still landed in a directory literally named " " instead of failing with the documented "required" error. Require a non-BLANK value and normalize the padding, in the parsed branch as well as raw_options so the two cannot drift apart -- a padded but real value (" .myagent/cmds ") now resolves to ".myagent/cmds" rather than being rejected, matching how other padded config references are normalized. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(integrations): use strip() only to test blankness, return the value verbatim Address review feedback: normalizing with strip() changed EXISTING valid values, contrary to this PR's "no behaviour change for valid usage" claim -- a quoted `--commands-dir ' commands '` previously targeted the literal ` commands ` directory and would have started writing to `commands` instead. The blankness test still uses strip(), but the accepted value is now returned unchanged, so the fix stays limited to empty/blank input. Test updated accordingly: a padded non-blank value must round-trip verbatim (quoted in raw_options, since shlex.split() consumes unquoted padding before this code sees it). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
"""Generic integration — bring your own agent.
|
|
|
|
Requires ``--commands-dir`` to specify the output directory for command
|
|
files. No longer special-cased in the core CLI — just another
|
|
integration with its own required option.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ..base import IntegrationOption, MarkdownIntegration
|
|
from ..manifest import IntegrationManifest
|
|
|
|
|
|
class GenericIntegration(MarkdownIntegration):
|
|
"""Integration for user-specified (generic) agents."""
|
|
|
|
key = "generic"
|
|
config = {
|
|
"name": "Generic (bring your own agent)",
|
|
"folder": None, # Set dynamically from --commands-dir
|
|
"commands_subdir": "commands",
|
|
"install_url": None,
|
|
"requires_cli": False,
|
|
}
|
|
registrar_config = {
|
|
"dir": "", # Set dynamically from --commands-dir
|
|
"format": "markdown",
|
|
"args": "$ARGUMENTS",
|
|
"extension": ".md",
|
|
}
|
|
|
|
@classmethod
|
|
def options(cls) -> list[IntegrationOption]:
|
|
return [
|
|
IntegrationOption(
|
|
"--commands-dir",
|
|
required=True,
|
|
help="Directory for command files (e.g. .myagent/commands/)",
|
|
),
|
|
]
|
|
|
|
@staticmethod
|
|
def _resolve_commands_dir(
|
|
parsed_options: dict[str, Any] | None,
|
|
opts: dict[str, Any],
|
|
) -> str:
|
|
"""Extract ``--commands-dir`` from parsed options or raw_options.
|
|
|
|
Returns the directory string or raises ``ValueError``.
|
|
"""
|
|
parsed_options = parsed_options or {}
|
|
|
|
# Accept a value only when it is non-BLANK. An empty value resolves to
|
|
# the project root (``project_root / ""``) and a whitespace-only one to
|
|
# a directory literally named " ", so either would silently scatter
|
|
# command files instead of failing with the documented "required"
|
|
# error. ``strip()`` is used ONLY to decide blankness -- the value
|
|
# itself is returned verbatim, so a deliberate (if unusual) padded
|
|
# directory name still targets exactly what the user asked for. Both
|
|
# branches below apply the same rule so they cannot drift apart.
|
|
commands_dir = parsed_options.get("commands_dir")
|
|
if commands_dir and (not isinstance(commands_dir, str) or commands_dir.strip()):
|
|
return commands_dir
|
|
|
|
# Fall back to raw_options (--integration-options="--commands-dir ...")
|
|
raw = opts.get("raw_options")
|
|
if raw:
|
|
import shlex
|
|
tokens = shlex.split(raw)
|
|
for i, token in enumerate(tokens):
|
|
if token == "--commands-dir" and i + 1 < len(tokens):
|
|
candidate = tokens[i + 1]
|
|
if candidate.strip():
|
|
return candidate
|
|
if token.startswith("--commands-dir="):
|
|
candidate = token.split("=", 1)[1]
|
|
if candidate.strip():
|
|
return candidate
|
|
|
|
raise ValueError(
|
|
"--commands-dir is required for the generic integration"
|
|
)
|
|
|
|
def commands_dest(self, project_root: Path) -> Path:
|
|
"""Not supported for GenericIntegration — use setup() directly.
|
|
|
|
GenericIntegration is stateless; the output directory comes from
|
|
``parsed_options`` or ``raw_options`` at call time, not from
|
|
instance state.
|
|
"""
|
|
raise ValueError(
|
|
"GenericIntegration.commands_dest() cannot be called directly; "
|
|
"the output directory is resolved from parsed_options in setup()"
|
|
)
|
|
|
|
def setup(
|
|
self,
|
|
project_root: Path,
|
|
manifest: IntegrationManifest,
|
|
parsed_options: dict[str, Any] | None = None,
|
|
**opts: Any,
|
|
) -> list[Path]:
|
|
"""Install commands to the user-provided commands directory."""
|
|
commands_dir = self._resolve_commands_dir(parsed_options, opts)
|
|
|
|
templates = self.list_command_templates()
|
|
if not templates:
|
|
return []
|
|
|
|
project_root_resolved = project_root.resolve()
|
|
if manifest.project_root != project_root_resolved:
|
|
raise ValueError(
|
|
f"manifest.project_root ({manifest.project_root}) does not match "
|
|
f"project_root ({project_root_resolved})"
|
|
)
|
|
|
|
dest = (project_root / commands_dir).resolve()
|
|
try:
|
|
dest.relative_to(project_root_resolved)
|
|
except ValueError as exc:
|
|
raise ValueError(
|
|
f"Integration destination {dest} escapes "
|
|
f"project root {project_root_resolved}"
|
|
) from exc
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
script_type = opts.get("script_type", "sh")
|
|
arg_placeholder = "$ARGUMENTS"
|
|
created: list[Path] = []
|
|
|
|
for src_file in templates:
|
|
raw = src_file.read_text(encoding="utf-8")
|
|
processed = self.process_template(
|
|
raw, self.key, script_type, arg_placeholder,
|
|
project_root=project_root,
|
|
)
|
|
dst_name = self.command_filename(src_file.stem)
|
|
dst_file = self.write_file_and_record(
|
|
processed, dest / dst_name, project_root, manifest
|
|
)
|
|
created.append(dst_file)
|
|
|
|
|
|
return created
|