Skip to content
1 change: 1 addition & 0 deletions docs/integrations/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ semantics, examples, and migration caveats live in the relevant agent guide:
| GitHub Copilot | [Skills and commands layouts](copilot.md#commands-layout) |
| IBM Bob | [Legacy commands and migration](bob.md#legacy-commands-and-migration) |
| Kimi Code | [Legacy migration](kimi.md#legacy-migration) |
| opencode | [Hidden Spec Kit files](opencode.md#hidden-spec-kit-files) |

Other agent-specific details are listed in the
[integration directory](index.md).
Expand Down
22 changes: 22 additions & 0 deletions docs/integrations/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,25 @@ Workflow steps use `opencode run`. Slash-prefixed prompts become
`--command <name>` with the remaining text as the message; other prompts are
passed as message text. The adapter forwards a configured model with `-m` and
requests `--format json` for structured output.

## Hidden Spec Kit Files

OpenCode 1.18.31 and 1.19.3 on darwin arm64 discover files with a glob tool that calls ripgrep without `--hidden`. In a temporary Spec Kit tree, `opencode debug rg files --glob '.specify/**/*'` (the same `Ripgrep.glob` call the glob tool makes, with no model involved) returned no paths. `**/*.md` on that same command returned only a visible `README.md`, and missed `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, and `.specify/scripts/bash/check-prerequisites.sh`. After the project root `.ignore` contained:

```text
!.specify/
!.specify/**
```

those patterns returned the constitution, both templates, and the script. A catch-all `**/*` already listed `.specify` files before the exceptions. `!.specify/**` alone still returned no `.specify/**/*` matches; the directory rule is what re-includes the hidden directory on this ripgrep path. Cloud-model fallback prompts and OpenCode 2.x were outside this check.

Opt in at install or upgrade:

```bash
specify init my-project --integration opencode --integration-options="--configure-ignore-file"
specify integration upgrade opencode --integration-options="--configure-ignore-file"
```

The flag appends whichever of those two rules is not already an exact line, placing them at the end of the root `.ignore`. A later line that excludes `.specify`, a parent directory such as `.specify/memory`, or a file under it (including a template or script init has not created yet, such as `.specify/templates/plan-template.md`) is rejected; move `!.specify/` and `!.specify/**` below it and rerun. A child exception does not uncover a file while its parent directory stays excluded. Bytes already in the file stay as a prefix, including UTF-8 non-ASCII text, CRLF, and a missing final newline. Non-UTF-8 content is rejected before command files are written; convert the file to UTF-8 and rerun, because ripgrep stops at the first invalid byte. Default installs leave `.ignore` untouched. The file is recorded with partial ownership, so `specify integration uninstall` and `specify integration uninstall --force` both leave it in place, exceptions included. Upgrades still compare its hash: adding a user rule blocks a plain `specify integration upgrade opencode` until that change is resolved or `--force` is used, and `--force` also overwrites edited command files. Deleting the two lines is not a lasting opt-out while the stored flag remains, because the next upgrade restores them. Clear the stored flag with `specify integration upgrade opencode --integration-options=""` before removing the lines.

`.ignore` is a shared ignore file. OpenCode's glob tool sees the rules because it searches with ripgrep, and other tools that read `.ignore` (including fd) can start showing `.specify` as well. `specify integration upgrade opencode` replays the stored flag. Passing `--integration-options` replaces the whole stored set (repeat `--configure-ignore-file` alongside any other flags, such as `--events false`); `--integration-options=""` clears it.
184 changes: 183 additions & 1 deletion src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
"""opencode integration."""

import os
import stat
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

from ..base import MarkdownIntegration
import pathspec

from ..base import IntegrationOption, MarkdownIntegration
from ..manifest import IntegrationManifest

# OpenCode 1.18.31 and 1.19.3 glob through ripgrep without ``--hidden``.
# Root ``.ignore`` rules ``!.specify/`` and ``!.specify/**`` make
# ``.specify/**/*`` return constitution, template, and script paths on
# that path (``opencode debug rg files --glob``, the same ``Ripgrep.glob``
# call the glob tool makes). ``!.specify/**`` alone does not. OpenCode 2.x
# was not part of that check.
_SPECIFY_IGNORE_RULES = ("!.specify/", "!.specify/**")


class OpencodeIntegration(MarkdownIntegration):
Expand Down Expand Up @@ -42,6 +55,47 @@ class OpencodeIntegration(MarkdownIntegration):
events_config_file = "opencode.json"
events_format = "ts-plugin"

@classmethod
def options(cls) -> list[IntegrationOption]:
opts = super().options()
opts.append(
IntegrationOption(
"--configure-ignore-file",
is_flag=True,
default=False,
help=(
"Append !.specify/ and !.specify/** to the project root "
".ignore so OpenCode can glob hidden Spec Kit files. "
"Uninstall leaves .ignore in place."
),
)
)
return opts

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Validate an explicit ``.ignore`` opt-in, then render commands.

Command files stay on the Markdown layout from ``super().setup()``.
``--configure-ignore-file`` appends missing ``!.specify/`` and
``!.specify/**`` rules and records the root file as partial
ownership. Without the flag the root ``.ignore`` is left alone.
"""
configure = (parsed_options or {}).get("configure_ignore_file") is True
if configure:
_preflight_root_ignore(manifest)
created = super().setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
Comment thread
Copilot marked this conversation as resolved.
if configure:
created.append(_configure_root_ignore(manifest))
return created

def build_exec_args(
self,
prompt: str,
Expand Down Expand Up @@ -74,3 +128,131 @@ def build_exec_args(
if message:
args.append(message)
return args


def _ignore_newline(raw: bytes) -> bytes:
return b"\r\n" if b"\r\n" in raw else b"\n"


def _reject_hidden_specify_paths(path: Path, updated: bytes) -> None:
"""Fail when *updated* still hides Spec Kit paths, evaluated in file order."""
from ..._assets import _locate_core_pack, _repo_root

patterns = []
for line in updated.decode("utf-8").splitlines():
try:
patterns.append(pathspec.GitIgnoreSpec.from_lines([line]).patterns[0])
except (ValueError, IndexError):
continue

def ignored(candidate: str) -> bool:
state = False
for pattern in patterns:
hit = pattern.include is not None and pattern.match_file(candidate) is not None
state = bool(pattern.include) if hit else state
return state

def hides(probe: str) -> bool:
parts = [part for part in probe.split("/") if part]
built, ancestors = "", parts if probe.endswith("/") else parts[:-1]
for part in ancestors:
built = f"{built}/{part}" if built else part
if ignored(built + "/"):
return True
return not probe.endswith("/") and ignored(probe)

pack, repo = _locate_core_pack(), _repo_root()
probes = {".specify/", ".specify/.gitignore", ".specify/memory/constitution.md"}
for kind in ("templates", "scripts"):
base = (pack if pack and (pack / kind).is_dir() else repo) / kind
walk = () if not base.is_dir() else (base.iterdir() if kind == "templates" else base.rglob("*"))
probes.update(
f".specify/{kind}/{item.relative_to(base).as_posix()}"
for item in walk
if item.is_file() and "__pycache__" not in item.parts
and not item.name.startswith(".") and item.name != "vscode-settings.json"
)
specify = path.parent / ".specify"
if specify.is_dir() and not specify.is_symlink():
probes.update(f.relative_to(path.parent).as_posix() for f in specify.rglob("*") if f.is_file())
hidden = sorted(probe for probe in probes if hides(probe))
if hidden:
raise ValueError(
f"A later exclusion in {path} hides {hidden[0]}. "
"Move !.specify/ and !.specify/** below it, then rerun."
)


def _with_specify_ignore_rules(raw: bytes) -> bytes:
"""Append missing Spec Kit rules, keeping *raw* as a byte prefix.

New rules use the file's newline (``\\n`` when *raw* is empty) and a
trailing newline. Exact ASCII lines are matched without decoding.
Non-UTF-8 files are rejected before this runs and are not preserved.
"""
if not raw:
newline = b"\n"
body = newline.join(rule.encode("ascii") for rule in _SPECIFY_IGNORE_RULES)
return body + newline

present = set(raw.splitlines())
missing = [
rule
for rule in _SPECIFY_IGNORE_RULES
if rule.encode("ascii") not in present
Comment thread
Copilot marked this conversation as resolved.
]
if not missing:
return raw

newline = _ignore_newline(raw)
addition = b"" if raw.endswith(b"\n") else newline
addition += newline.join(rule.encode("ascii") for rule in missing) + newline
return raw + addition


def _preflight_root_ignore(manifest: IntegrationManifest) -> tuple[Path, bytes, bytes]:
path = manifest.project_root / ".ignore"
if path.is_symlink():
raise ValueError(f"Refusing to configure symlinked .ignore: {path}")
if path.exists() and not path.is_file():
raise ValueError(
f"Refusing to configure .ignore because it is not a regular file: {path}"
)
try:
raw = path.read_bytes() if path.is_file() else b""
except OSError as exc:
raise OSError(f"Cannot read .ignore at {path}") from exc
try:
raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError(
f"{path} is not UTF-8 ({exc.reason} at byte {exc.start}). "
"Convert it to UTF-8 and rerun; ripgrep stops at the first invalid byte."
) from exc
updated = _with_specify_ignore_rules(raw)
Comment thread
Copilot marked this conversation as resolved.
_reject_hidden_specify_paths(path, updated)
if updated != raw and path.exists() and not os.access(path, os.W_OK):
raise PermissionError(f"Cannot update .ignore at {path}")
if updated != raw and not os.access(path.parent, os.W_OK):
if path.exists():
raise PermissionError(f"Cannot update .ignore at {path}")
raise PermissionError(f"Cannot create .ignore at {path}")
return path, raw, updated


def _configure_root_ignore(manifest: IntegrationManifest) -> Path:
"""Recheck root ``.ignore``, write it atomically, and record partial ownership."""
path, raw, updated = _preflight_root_ignore(manifest)
if updated == raw and path.is_file():
from .._file_changes import before_file_change

before_file_change(path)
manifest.record_existing(".ignore", ownership="partial")
return path

from ...shared_infra import _write_shared_bytes

mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
_write_shared_bytes(manifest.project_root, path, updated, mode=mode)
manifest.record_existing(".ignore", ownership="partial")
return path
Loading