|
| 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 appended after the |
| 106 | + canonical Spec Kit flags so the canonical flags are guaranteed to |
| 107 | + be present in argv. Note that with duplicate-flag CLI parsing the |
| 108 | + later (operator-supplied) value may take precedence over the |
| 109 | + canonical one, so operators can still override ``--model``, |
| 110 | + ``--output-format``, or ``--skip-permissions-unsafe``. |
| 111 | + """ |
| 112 | + if not self.config or not self.config.get("requires_cli"): |
| 113 | + return None |
| 114 | + args = [ |
| 115 | + self._resolve_executable(), |
| 116 | + "exec", |
| 117 | + prompt, |
| 118 | + "--skip-permissions-unsafe", |
| 119 | + ] |
| 120 | + # Operator-injected extra args are appended after Spec Kit's |
| 121 | + # canonical --model / --output-format flags so the canonical |
| 122 | + # flags are guaranteed to be present in argv regardless of |
| 123 | + # whatever the operator passes via SPECKIT_INTEGRATION_DROID_EXTRA_ARGS. |
| 124 | + # This is a deliberate inversion of the cursor-agent / opencode / |
| 125 | + # codex ordering (which all apply extra args first, then append |
| 126 | + # canonical flags so the canonical values win under duplicate-flag |
| 127 | + # parsing). For Droid the canonical flag values are written into |
| 128 | + # argv first, then the operator-supplied values follow; with |
| 129 | + # duplicate-flag parsing the later (operator) value may therefore |
| 130 | + # take precedence. |
| 131 | + if model: |
| 132 | + args.extend(["--model", model]) |
| 133 | + if output_json: |
| 134 | + args.extend(["--output-format", "json"]) |
| 135 | + self._apply_extra_args_env_var(args) |
| 136 | + return args |
0 commit comments