Files
github-spec-kit/src/specify_cli/integrations/generic/__init__.py
Ali jawwad 6ef96373e2 fix(integrations): reject empty --commands-dir in generic raw_options (#3714)
* 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>
2026-07-28 17:25:04 -05:00

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