diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 5aa0a076c1..4fa01ce714 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -97,6 +97,25 @@ echo -e "\nšŸ¤– Installing CodeBuddy CLI..." run_command "npm install -g @tencent-ai/codebuddy-code@latest" echo "āœ… Done" +echo -e "\nšŸ¤– Installing Factory Droid CLI..." +# https://docs.factory.ai/cli/getting-started/quickstart +# Install via the npm distribution (``npm install -g droid``) rather than +# fetching ``https://app.factory.ai/cli``: the upstream shell installer is +# served from a mutable endpoint with no published digest, so downloading +# it and executing it inside the devcontainer would run whatever the +# server returned at build time. npm packages carry integrity checksums +# verified against the registry signature, and this matches the layout of +# every other CLI install block above. +run_command "npm install -g droid@latest" + +if ! command -v droid >/dev/null 2>&1; then + echo -e "\033[0;31m[ERROR] Droid CLI installation did not create 'droid' in PATH.\033[0m" >&2 + exit 1 +fi + +run_command "droid --version > /dev/null" +echo "āœ… Done" + # Installing UV (Python package manager) echo -e "\nšŸ Installing UV - Python Package Manager..." run_command "pipx install uv" diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index e30f773edc..8db3c3f9a8 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -8,7 +8,7 @@ body: value: | Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. - **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed + **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index dd7c55d1ca..bb3d6c054a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -71,6 +71,7 @@ body: - Codex CLI - Cursor - Devin for Terminal + - Factory Droid - Firebender - Forge - Gemini CLI diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 6a037ae1eb..d7304b31c7 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -65,6 +65,7 @@ body: - Codex CLI - Cursor - Devin for Terminal + - Factory Droid - Firebender - Forge - Gemini CLI diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 72044ec684..95f13d37e3 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -15,6 +15,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | | [Cursor](https://cursor.sh/) | `cursor-agent` | | | [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-` | | [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | | [Forge](https://forgecode.dev/) | `forge` | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | diff --git a/integrations/catalog.json b/integrations/catalog.json index 40dc13d84c..6a86596d15 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-15T00:00:00Z", + "updated_at": "2026-07-17T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -48,6 +48,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["ide"] }, + "droid": { + "id": "droid", + "name": "Factory Droid", + "version": "1.0.0", + "description": "Factory Droid CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills", "factory"] + }, "amp": { "id": "amp", "name": "Amp", diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 1d8ccc5ebb..3f71d4fba4 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -58,6 +58,7 @@ def _register_builtins() -> None: from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .devin import DevinIntegration + from .droid import DroidIntegration from .firebender import FirebenderIntegration from .forge import ForgeIntegration from .gemini import GeminiIntegration @@ -95,6 +96,7 @@ def _register_builtins() -> None: _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(DevinIntegration()) + _register(DroidIntegration()) _register(FirebenderIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) diff --git a/src/specify_cli/integrations/droid/__init__.py b/src/specify_cli/integrations/droid/__init__.py new file mode 100644 index 0000000000..496fff008d --- /dev/null +++ b/src/specify_cli/integrations/droid/__init__.py @@ -0,0 +1,136 @@ +"""Factory Droid CLI integration — skills-based agent. + +Droid discovers project skills from +``.factory/skills/speckit-/SKILL.md``. Spec Kit installs into that +native tree so the generated skills are visible to Droid without extra +configuration. + +See: https://docs.factory.ai/cli/configuration/skills +""" + +from __future__ import annotations + +from ..base import SkillsIntegration + + +class DroidIntegration(SkillsIntegration): + """Integration for Factory Droid CLI.""" + + key = "droid" + config = { + "name": "Factory Droid", + "folder": ".factory/", + "commands_subdir": "skills", + "install_url": "https://docs.factory.ai/cli/getting-started/overview", + "requires_cli": True, + } + registrar_config = { + "dir": ".factory/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + multi_install_safe = True + + @staticmethod + def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: + """Insert ``key: value`` before the closing ``---`` if not already present. + + Mirrors the helper used by ``ClaudeIntegration`` / ``VibeIntegration`` + so per-agent frontmatter injection stays consistent across skills-based + integrations. Pre-scans for the key to keep injection idempotent. + """ + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith(f"{key}:"): + return content + + # Inject before the closing --- of frontmatter. Always emit a + # newline after the injected key so the key and the closing --- + # stay on separate lines even when the closing delimiter is the + # last line of the file with no trailing newline. + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + out.append(f"{key}: {value}\n") + injected = True + out.append(line) + return "".join(out) + + def post_process_skill_content(self, content: str) -> str: + """Inject Droid-specific skill frontmatter flags. + + Applies the shared hook-command normalization note (skills agents use + hyphenated ``/speckit-`` invocations, not dotted ``/speckit.``) + and the Droid-specific ``user-invocable`` / ``disable-model-invocation`` + frontmatter flags so skills are both user- and Droid-invocable. + """ + updated = super().post_process_skill_content(content) + updated = self._inject_frontmatter_flag(updated, "user-invocable") + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + return updated + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build CLI arguments for non-interactive ``droid`` execution. + + Uses ``droid exec ""`` for headless dispatch. The + ``--skip-permissions-unsafe`` flag is mandatory for non-interactive + runs because Droid's permission prompts otherwise block workflow + dispatch (matches Cursor's ``--force`` and Grok's ``--always-approve`` + role for spec-kit automation). + + Output format and model selection mirror the documented CLI flags: + ``--output-format json`` (when ``output_json`` is set) and + ``--model ``. Operator-supplied extra args via + ``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` are appended after the + canonical Spec Kit flags so the canonical flags are guaranteed to + be present in argv. Note that with duplicate-flag CLI parsing the + later (operator-supplied) value may take precedence over the + canonical one, so operators can still override ``--model``, + ``--output-format``, or ``--skip-permissions-unsafe``. + """ + if not self.config or not self.config.get("requires_cli"): + return None + args = [ + self._resolve_executable(), + "exec", + prompt, + "--skip-permissions-unsafe", + ] + # Operator-injected extra args are appended after Spec Kit's + # canonical --model / --output-format flags so the canonical + # flags are guaranteed to be present in argv regardless of + # whatever the operator passes via SPECKIT_INTEGRATION_DROID_EXTRA_ARGS. + # This is a deliberate inversion of the cursor-agent / opencode / + # codex ordering (which all apply extra args first, then append + # canonical flags so the canonical values win under duplicate-flag + # parsing). For Droid the canonical flag values are written into + # argv first, then the operator-supplied values follow; with + # duplicate-flag parsing the later (operator) value may therefore + # take precedence. + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + self._apply_extra_args_env_var(args) + return args diff --git a/tests/integrations/test_integration_droid.py b/tests/integrations/test_integration_droid.py new file mode 100644 index 0000000000..aaa63e32b7 --- /dev/null +++ b/tests/integrations/test_integration_droid.py @@ -0,0 +1,255 @@ +"""Tests for DroidIntegration (Factory Droid CLI).""" + +from urllib.parse import urlparse + +import pytest + +from specify_cli.integrations import get_integration +from specify_cli.integrations.droid import DroidIntegration +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestDroidIntegration(SkillsIntegrationTests): + KEY = "droid" + FOLDER = ".factory/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".factory/skills" + + def test_options_include_skills_flag(self): + """Not applicable — Droid only supports the skills layout.""" + pytest.skip("Droid is always skills-based and does not expose a --skills option") + + def test_options_do_not_include_skills_flag(self): + """Droid is always skills-based; no --skills option is exposed.""" + i = get_integration(self.KEY) + assert i is not None + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0, ( + "Droid is always skills-based and should not expose a --skills option" + ) + + def test_requires_cli_is_true(self): + """Droid is a CLI tool; requires_cli must be True.""" + i = get_integration(self.KEY) + assert i is not None + assert i.config["requires_cli"] is True + assert i.config["name"] == "Factory Droid" + + def test_multi_install_safe_is_true(self): + """Droid uses an isolated .factory/ root — safe to install alongside others.""" + i = get_integration(self.KEY) + assert i.multi_install_safe is True + + def test_install_url_points_to_factory(self): + i = get_integration(self.KEY) + url = i.config.get("install_url") + assert url is not None + host = (urlparse(url).hostname or "").lower() + assert host == "factory.ai" or host.endswith(".factory.ai"), ( + f"install_url must point at the Factory domain, got: {url}" + ) + + +class TestDroidInitFlow: + """--integration droid creates expected files.""" + + def test_integration_droid_creates_skills(self, tmp_path): + """--integration droid should create skills under .factory/skills.""" + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke( + app, + [ + "init", + str(target), + "--integration", + "droid", + "--ignore-agent-tools", + "--script", + "sh", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"init --integration droid failed: {result.output}" + assert (target / ".factory" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (target / ".factory" / "skills" / "speckit-specify" / "SKILL.md").exists() + + +class TestDroidBuildExecArgs: + """Droid non-interactive execution argument building.""" + + def test_default_argv_uses_exec_subcommand_and_headless_flag(self): + """Default argv: ``droid exec --skip-permissions-unsafe --output-format json``.""" + i = get_integration("droid") + args = i.build_exec_args("/speckit-specify some-feature") + assert args == [ + "droid", + "exec", + "/speckit-specify some-feature", + "--skip-permissions-unsafe", + "--output-format", + "json", + ] + + def test_text_output_omits_format_flag(self): + i = get_integration("droid") + args = i.build_exec_args("/speckit-plan", output_json=False) + assert args == [ + "droid", + "exec", + "/speckit-plan", + "--skip-permissions-unsafe", + ] + + def test_model_is_appended(self): + i = get_integration("droid") + args = i.build_exec_args( + "/speckit-specify", model="claude-opus-4-7", output_json=False + ) + assert args == [ + "droid", + "exec", + "/speckit-specify", + "--skip-permissions-unsafe", + "--model", + "claude-opus-4-7", + ] + + def test_extra_args_inserted_after_canonical_flags(self, monkeypatch): + """Operator-injected extra args land after Spec Kit's canonical + ``--model`` / ``--output-format`` flags so the canonical flags are + always present in argv regardless of operator override.""" + from specify_cli.integrations import get_integration + + i = get_integration("droid") + monkeypatch.setenv("SPECKIT_INTEGRATION_DROID_EXTRA_ARGS", "--foo bar") + args = i.build_exec_args( + "/speckit-plan", model="claude-sonnet", output_json=True + ) + + assert "--foo" in args + assert "bar" in args + assert args.index("bar") == args.index("--foo") + 1 + # Extra args land AFTER the canonical flags so the canonical flags + # are always present in argv. + assert args.index("--model") < args.index("--foo") + assert args.index("--output-format") < args.index("--foo") + assert args[args.index("--model") + 1] == "claude-sonnet" + assert args[args.index("--output-format") + 1] == "json" + + def test_executable_override(self, monkeypatch): + """``SPECKIT_INTEGRATION_DROID_EXECUTABLE`` overrides argv[0].""" + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DROID_EXECUTABLE", "/custom/droid" + ) + i = get_integration("droid") + args = i.build_exec_args("/speckit-plan", output_json=False) + assert args[0] == "/custom/droid" + # Headless flag must remain regardless of override. + assert "--skip-permissions-unsafe" in args + + def test_returns_none_when_requires_cli_is_false(self, monkeypatch): + """When ``requires_cli`` is False, ``build_exec_args`` returns None.""" + i = get_integration("droid") + monkeypatch.setitem(i.config, "requires_cli", False) + assert i.build_exec_args("/speckit-plan") is None + + +class TestDroidFrontmatter: + """Every generated SKILL.md must carry Droid-specific frontmatter flags.""" + + def test_skills_carry_user_invocable_true(self, tmp_path): + i = get_integration("droid") + m = IntegrationManifest("droid", tmp_path) + i.setup(tmp_path, m, script_type="sh") + + skill_files = [ + f + for f in (tmp_path / ".factory" / "skills").rglob("SKILL.md") + ] + assert skill_files, "expected at least one SKILL.md" + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "user-invocable: true" in content, ( + f"{f} missing user-invocable: true" + ) + + def test_skills_carry_disable_model_invocation_false(self, tmp_path): + i = get_integration("droid") + m = IntegrationManifest("droid", tmp_path) + i.setup(tmp_path, m, script_type="sh") + + skill_files = [ + f + for f in (tmp_path / ".factory" / "skills").rglob("SKILL.md") + ] + assert skill_files, "expected at least one SKILL.md" + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "disable-model-invocation: false" in content, ( + f"{f} missing disable-model-invocation: false" + ) + + def test_inject_frontmatter_flag_adds_key_when_absent(self): + """Fresh content (key absent) gets the flag injected on its own line.""" + content = "---\nname: x\ndescription: y\n---\n\nBody.\n" + result = DroidIntegration._inject_frontmatter_flag(content, "user-invocable") + assert "user-invocable: true" in result + # The injected key must sit on its own line, not glued to the closing ---. + assert "\nuser-invocable: true\n---" in result, ( + "Injected key must be on its own line, not fused to closing ---" + ) + + def test_inject_frontmatter_flag_injects_custom_value(self): + """The value parameter must be honored (used for disable-model-invocation: false).""" + content = "---\nname: x\n---\n\nBody.\n" + result = DroidIntegration._inject_frontmatter_flag( + content, "disable-model-invocation", "false" + ) + assert "disable-model-invocation: false" in result + + def test_inject_frontmatter_flag_no_trailing_newline(self): + """Regression for the frontmatter-fusion P2 bug. + + When the closing ``---`` is the literal last line of the file with + no trailing newline, the injected key must still land on its own + line (not fused onto the closing delimiter). Previously this + produced ``user-invocable: true---``, an unparseable YAML line. + """ + content = "---\nname: x\ndescription: y\n---" + result = DroidIntegration._inject_frontmatter_flag(content, "user-invocable") + assert "user-invocable: true" in result + # The injected key and the closing delimiter must NOT be fused. + assert "user-invocable: true---" not in result, ( + "Injected key fused onto closing ---; no-trailing-newline regression" + ) + # And the injected key must be on its own line. + assert "\nuser-invocable: true\n---" in result + + def test_frontmatter_injection_is_idempotent(self): + """Running the post-processor twice must not duplicate the flag.""" + content = "---\nname: x\n---\n\nBody.\n" + once = DroidIntegration._inject_frontmatter_flag(content, "user-invocable") + twice = DroidIntegration._inject_frontmatter_flag(once, "user-invocable") + assert once == twice, "Frontmatter injection must be idempotent" + # Belt-and-braces: the flag must appear exactly once. + assert once.count("user-invocable: true") == 1 + + +class TestDroidCommandInvocation: + """Skills agents use the hyphenated ``/speckit-`` slash form.""" + + def test_build_command_invocation_uses_hyphenated_skill_name(self): + i = get_integration("droid") + assert i.build_command_invocation("speckit.plan", "feature-x") == ( + "/speckit-plan feature-x" + ) + assert i.build_command_invocation("plan") == "/speckit-plan" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 13d4968927..4f9cff274b 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -28,6 +28,7 @@ "gemini", "tabnine", # Stage 5 — skills, generic & option-driven integrations "codex", "kimi", "agy", "zed", "generic", + "droid", ] diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 4af786685c..a15e1c5b3d 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -20,6 +20,7 @@ "codex", "cursor-agent", "devin", + "droid", "firebender", "forge", "gemini",