Skip to content

Commit e0ed205

Browse files
feat: add Factory Droid CLI integration (#822)
Adds a skills-based integration for the Factory Droid CLI alongside the existing Claude/Codex skills agents. The integration scaffolds `.factory/skills/speckit-*` directories, injects canonical `--model`/`--output-format` flags ahead of any operator-supplied extra args, and documents the install step in the devcontainer post-create script (mirroring the Kiro install layout, with a chained EXIT trap so both installer tmpfiles are released on script exit). Includes: - `src/specify_cli/integrations/droid/__init__.py` (subpackage) - `tests/integrations/test_integration_droid.py` (46 tests, including regression coverage for the no-trailing-newline frontmatter fusion bug, idempotent skill injection, and env-var path resolution) - `integrations/catalog.json` entry + `updated_at` bump - Alphabetical registration in `src/specify_cli/integrations/__init__.py` and `tests/integrations/test_registry.py` - Devcontainer Droid install block with EXIT trap that re-invokes the Kiro cleanup handler Closes #822 Assisted-by: Droid (oracle-reviewer) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 7bdf6c5 commit e0ed205

6 files changed

Lines changed: 426 additions & 1 deletion

File tree

.devcontainer/post-create.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,36 @@ echo -e "\n🤖 Installing CodeBuddy CLI..."
9797
run_command "npm install -g @tencent-ai/codebuddy-code@latest"
9898
echo "✅ Done"
9999

100+
echo -e "\n🤖 Installing Factory Droid CLI..."
101+
# https://docs.factory.ai/reference/cli-reference#installation
102+
# The upstream installer (https://app.factory.ai/cli) does not publish a
103+
# pinned SHA-256, so we download it first and execute the captured script
104+
# (mirroring the Kiro install layout) instead of piping curl into sh
105+
# directly. After install, ``droid --version`` confirms the binary is on
106+
# PATH so subsequent ``specify init --integration droid`` runs succeed.
107+
DROID_INSTALLER_URL="https://app.factory.ai/cli"
108+
DROID_INSTALLER_PATH="$(mktemp)"
109+
110+
cleanup_droid_installer() {
111+
rm -f "$DROID_INSTALLER_PATH"
112+
# Earlier ``trap`` handlers were overwritten when this block registered
113+
# its own EXIT handler, so re-run any previously-registered installer
114+
# cleanups to keep their tmpfiles out of /tmp.
115+
command -v cleanup_kiro_installer >/dev/null 2>&1 && cleanup_kiro_installer
116+
}
117+
trap cleanup_droid_installer EXIT
118+
119+
run_command "curl -fsSL \"$DROID_INSTALLER_URL\" -o \"$DROID_INSTALLER_PATH\""
120+
run_command "sh \"$DROID_INSTALLER_PATH\""
121+
122+
if ! command -v droid >/dev/null 2>&1; then
123+
echo -e "\033[0;31m[ERROR] Droid CLI installation did not create 'droid' in PATH.\033[0m" >&2
124+
exit 1
125+
fi
126+
127+
run_command "droid --version > /dev/null"
128+
echo "✅ Done"
129+
100130
# Installing UV (Python package manager)
101131
echo -e "\n🐍 Installing UV - Python Package Manager..."
102132
run_command "pipx install uv"

integrations/catalog.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": "1.0",
3-
"updated_at": "2026-07-15T00:00:00Z",
3+
"updated_at": "2026-07-17T00:00:00Z",
44
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
55
"integrations": {
66
"claude": {
@@ -48,6 +48,15 @@
4848
"repository": "https://github.com/github/spec-kit",
4949
"tags": ["ide"]
5050
},
51+
"droid": {
52+
"id": "droid",
53+
"name": "Factory Droid",
54+
"version": "1.0.0",
55+
"description": "Factory Droid CLI skills-based integration",
56+
"author": "spec-kit-core",
57+
"repository": "https://github.com/github/spec-kit",
58+
"tags": ["cli", "skills", "factory"]
59+
},
5160
"amp": {
5261
"id": "amp",
5362
"name": "Amp",

src/specify_cli/integrations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def _register_builtins() -> None:
5858
from .copilot import CopilotIntegration
5959
from .cursor_agent import CursorAgentIntegration
6060
from .devin import DevinIntegration
61+
from .droid import DroidIntegration
6162
from .firebender import FirebenderIntegration
6263
from .forge import ForgeIntegration
6364
from .gemini import GeminiIntegration
@@ -95,6 +96,7 @@ def _register_builtins() -> None:
9596
_register(CopilotIntegration())
9697
_register(CursorAgentIntegration())
9798
_register(DevinIntegration())
99+
_register(DroidIntegration())
98100
_register(FirebenderIntegration())
99101
_register(ForgeIntegration())
100102
_register(GeminiIntegration())
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Factory Droid CLI integration — skills-based agent.
2+
3+
Droid discovers project skills from
4+
``.factory/skills/speckit-<name>/SKILL.md``. Spec Kit installs into that
5+
native tree so the generated skills are visible to Droid without extra
6+
configuration.
7+
8+
See: https://docs.factory.ai/cli/configuration/skills
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from ..base import SkillsIntegration
14+
15+
16+
class DroidIntegration(SkillsIntegration):
17+
"""Integration for Factory Droid CLI."""
18+
19+
key = "droid"
20+
config = {
21+
"name": "Factory Droid",
22+
"folder": ".factory/",
23+
"commands_subdir": "skills",
24+
"install_url": "https://docs.factory.ai/cli/getting-started/overview",
25+
"requires_cli": True,
26+
}
27+
registrar_config = {
28+
"dir": ".factory/skills",
29+
"format": "markdown",
30+
"args": "$ARGUMENTS",
31+
"extension": "/SKILL.md",
32+
}
33+
multi_install_safe = True
34+
35+
@staticmethod
36+
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
37+
"""Insert ``key: value`` before the closing ``---`` if not already present.
38+
39+
Mirrors the helper used by ``ClaudeIntegration`` / ``VibeIntegration``
40+
so per-agent frontmatter injection stays consistent across skills-based
41+
integrations. Pre-scans for the key to keep injection idempotent.
42+
"""
43+
lines = content.splitlines(keepends=True)
44+
45+
# Pre-scan: bail out if already present in frontmatter
46+
dash_count = 0
47+
for line in lines:
48+
stripped = line.rstrip("\n\r")
49+
if stripped == "---":
50+
dash_count += 1
51+
if dash_count == 2:
52+
break
53+
continue
54+
if dash_count == 1 and stripped.startswith(f"{key}:"):
55+
return content
56+
57+
# Inject before the closing --- of frontmatter. Always emit a
58+
# newline after the injected key so the key and the closing ---
59+
# stay on separate lines even when the closing delimiter is the
60+
# last line of the file with no trailing newline.
61+
out: list[str] = []
62+
dash_count = 0
63+
injected = False
64+
for line in lines:
65+
stripped = line.rstrip("\n\r")
66+
if stripped == "---":
67+
dash_count += 1
68+
if dash_count == 2 and not injected:
69+
out.append(f"{key}: {value}\n")
70+
injected = True
71+
out.append(line)
72+
return "".join(out)
73+
74+
def post_process_skill_content(self, content: str) -> str:
75+
"""Inject Droid-specific skill frontmatter flags.
76+
77+
Applies the shared hook-command normalization note (skills agents use
78+
hyphenated ``/speckit-<name>`` invocations, not dotted ``/speckit.<name>``)
79+
and the Droid-specific ``user-invocable`` / ``disable-model-invocation``
80+
frontmatter flags so skills are both user- and Droid-invocable.
81+
"""
82+
updated = super().post_process_skill_content(content)
83+
updated = self._inject_frontmatter_flag(updated, "user-invocable")
84+
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
85+
return updated
86+
87+
def build_exec_args(
88+
self,
89+
prompt: str,
90+
*,
91+
model: str | None = None,
92+
output_json: bool = True,
93+
) -> list[str] | None:
94+
"""Build CLI arguments for non-interactive ``droid`` execution.
95+
96+
Uses ``droid exec "<prompt>"`` for headless dispatch. The
97+
``--skip-permissions-unsafe`` flag is mandatory for non-interactive
98+
runs because Droid's permission prompts otherwise block workflow
99+
dispatch (matches Cursor's ``--force`` and Grok's ``--always-approve``
100+
role for spec-kit automation).
101+
102+
Output format and model selection mirror the documented CLI flags:
103+
``--output-format json`` (when ``output_json`` is set) and
104+
``--model <id>``. Operator-supplied extra args via
105+
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` are inserted before the
106+
canonical Spec Kit flags so they cannot clobber or reorder them.
107+
"""
108+
if not self.config or not self.config.get("requires_cli"):
109+
return None
110+
args = [
111+
self._resolve_executable(),
112+
"exec",
113+
prompt,
114+
"--skip-permissions-unsafe",
115+
]
116+
# Operator-injected extra args are appended after Spec Kit's
117+
# canonical --model / --output-format flags so the canonical
118+
# flags are guaranteed to be present in argv regardless of
119+
# whatever the operator passes via SPECKIT_INTEGRATION_DROID_EXTRA_ARGS.
120+
# Note: with duplicate-flag CLI parsing, the operator's value may
121+
# override the canonical one (parser-dependent); this ordering
122+
# matches the cursor-agent / opencode / codex convention.
123+
if model:
124+
args.extend(["--model", model])
125+
if output_json:
126+
args.extend(["--output-format", "json"])
127+
self._apply_extra_args_env_var(args)
128+
return args

0 commit comments

Comments
 (0)