Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ddf6ddd
fix: opt-in OpenCode discovery of hidden Spec Kit files
mvanhorn Oct 20, 2026
b395863
docs: add a language to the OpenCode .ignore code fence
mvanhorn Oct 20, 2026
dca252d
fix(opencode): validate .ignore before setup and write it atomically
mvanhorn Oct 21, 2026
1e4de0c
Merge github/spec-kit main into fix/5028-opencode-hidden-discovery
mvanhorn Oct 21, 2026
ed12123
fix(opencode): check .ignore parent writability on replace
mvanhorn Oct 21, 2026
76d791d
Merge branch 'main' into fix/5028-opencode-hidden-discovery
mvanhorn Oct 21, 2026
246dd00
fix(opencode): reject overridden .ignore exceptions and non-UTF-8 files
mvanhorn Oct 22, 2026
f82d057
fix(opencode): validate planned files and parent dirs in .ignore
mvanhorn Oct 22, 2026
e7d405b
opencode: match ripgrep line splitting and gitignore precedence
mvanhorn Oct 24, 2026
5aaaeab
opencode: check .ignore before shared writes and only for installed s…
mvanhorn Oct 29, 2026
2004c8f
Merge remote-tracking branch 'upstream/main' into fix/5028-opencode-h…
mvanhorn Oct 30, 2026
7882944
Merge remote-tracking branch 'upstream/main' into fix/5028-opencode-h…
mvanhorn Nov 11, 2026
d4255f3
fix: keep extension local-config.yml hidden from OpenCode glob
mvanhorn Nov 11, 2026
2c44f73
fix: harden opencode .ignore preflight and spell opencode consistently
mvanhorn Nov 11, 2026
b8de1ba
fix: protect local configs at every depth in opencode .ignore
mvanhorn Nov 11, 2026
0823fa0
test: check opencode private-config rules against real ripgrep
mvanhorn Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/integrations/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,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`, followed by `.specify/**/local-config.yml` and `.specify/**/*-config.local.yml` unless those exact lines already come after the last `!` line. ripgrep ranks `.ignore` above `.gitignore`, so these lines keep per-machine extension overrides, which can hold credentials, out of ripgrep searches that pass no matching glob, at any depth, including the copies extension removal keeps under `.specify/extensions/.backup/`. They do not hide those files from opencode's glob tool: ripgrep lets a `--glob` pattern that matches a file override every ignore file, so with ripgrep 15.2.0, `rg --files --glob '.specify/**/*'` and `rg --files --glob '**/*.yml'` list `local-config.yml` and `*-config.local.yml` files under `.specify` once the exceptions are in place, while `rg --files` with no glob omits them. Before the exceptions, `**/*` already listed them. Keep credentials out of files under `.specify` (use `SPECKIT_{EXTENSION}_*` environment variables instead) if opencode should not see their paths. A later exclusion of a `local-config.yml` or `*-config.local.yml` file, or of `.specify/extensions/.backup/`, is allowed. 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 `.specify/.ignore` or `.specify/.rgignore` that excludes one of those paths (for example a `memory/` line) is rejected the same way, because ripgrep applies it below the root exceptions; remove that line and rerun. Bundled scripts are checked only for the `--script` variant being installed, plus any script already under `.specify/scripts`. `specify integration install`, `switch` and `upgrade` run this check before they write shared templates or scripts, so a rejected `.ignore` leaves the project unchanged, including with `--force`. 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.
303 changes: 302 additions & 1 deletion src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
"""opencode integration."""

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

from ..base import MarkdownIntegration
import pathspec

from ...file_helper import FileHelper, SymlinkDeniedError, UnsupportedPathError
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/**")
Comment thread
mvanhorn marked this conversation as resolved.
Resolved
# ripgrep ranks ``.ignore`` above every ``.gitignore``, so the exceptions
# would also surface per-machine extension overrides that the managed
# ``.specify/.gitignore`` keeps out of version control and that can hold
# credentials, including the copies extension removal keeps under
# ``.specify/extensions/.backup/``. These rules must follow the last
# negation line. They only cover ripgrep runs without a matching
# ``--glob``: a positive glob that matches a file (``.specify/**/*``,
# ``**/*.yml``) overrides every ignore file, so opencode's glob tool still
# lists these paths once ``.specify`` is walked (ripgrep 15.2.0).
_SPECIFY_PRIVATE_RULES = (
".specify/**/local-config.yml",
".specify/**/*-config.local.yml",
)
Comment thread
mvanhorn marked this conversation as resolved.
Resolved
# Extension removal backups are user state, not Spec Kit files to surface.
_UNPROBED_SPECIFY_DIRS = (".specify/extensions/.backup/",)


class OpencodeIntegration(MarkdownIntegration):
Expand Down Expand Up @@ -42,6 +73,63 @@ 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, "
"excluding extension local-config.yml and "
"*-config.local.yml files from searches without a "
"matching glob (a glob that matches them still lists them). "
"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, then the private-config exclusion, 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
script_type = opts.get("script_type")
if configure:
_preflight_root_ignore(manifest.project_root, script_type)
created = super().setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
Comment thread
Copilot marked this conversation as resolved.
Resolved
if configure:
created.append(_configure_root_ignore(manifest, script_type))
return created

def preflight_setup(
self,
project_root: Path,
parsed_options: dict[str, Any] | None = None,
*,
script_type: str | None = None,
) -> None:
"""Refuse an unusable ``.ignore`` before shared files are written."""
if (parsed_options or {}).get("configure_ignore_file") is True:
_preflight_root_ignore(project_root, script_type)

def build_exec_args(
self,
prompt: str,
Expand Down Expand Up @@ -74,3 +162,216 @@ 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 _split_ignore_lines(raw: bytes) -> list[bytes]:
lines = raw.split(b"\n")
last = len(lines) - 1
return [
line[:-1] if index < last and line.endswith(b"\r") else line
for index, line in enumerate(lines)
]


def _ignore_spec(raw: bytes) -> pathspec.GitIgnoreSpec:
"""Compile *raw* ignore lines, skipping lines pathspec cannot parse."""
parsed = []
for raw_line in _split_ignore_lines(raw):
text = raw_line.decode("utf-8", errors="replace")
try:
pathspec.GitIgnoreSpec.from_lines([text])
except (ValueError, re.error):
continue
parsed.append(text)
return pathspec.GitIgnoreSpec.from_lines(parsed)


def _spec_hides(spec: pathspec.GitIgnoreSpec, probe: str) -> bool:
"""Whether *spec* excludes *probe* or one of its parent directories."""

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

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 ancestor_excluded(built + "/"):
return True
return not probe.endswith("/") and spec.match_file(probe)


def _reject_hidden_specify_paths(
path: Path, updated: bytes, script_type: str | None = None
) -> None:
"""Fail when *updated* still hides Spec Kit paths, evaluated in file order.

Bundled scripts are probed only for the variants *script_type* installs
(every variant when it is unknown); files already under ``.specify/``
are always probed. ``.ignore`` and ``.rgignore`` under ``.specify/`` are
checked too, because ripgrep applies them below the root exceptions.
"""
from ..._assets import _locate_core_pack, _repo_root
from ...shared_infra import script_variant_dirs

spec = _ignore_spec(updated)

pack, repo = _locate_core_pack(), _repo_root()
probes = {".specify/", ".specify/.gitignore", ".specify/memory/constitution.md"}
variants = None if script_type is None else set(script_variant_dirs(script_type))
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("*"))
Comment thread
mvanhorn marked this conversation as resolved.
Resolved
probes.update(
f".specify/{kind}/{item.relative_to(base).as_posix()}"
for item in walk
if kind == "templates" or variants is None
or item.relative_to(base).parts[0] in variants
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())
private = pathspec.GitIgnoreSpec.from_lines(_SPECIFY_PRIVATE_RULES)
visible = sorted(
probe for probe in probes
if not private.match_file(probe)
and not probe.startswith(_UNPROBED_SPECIFY_DIRS)
)
hidden = [probe for probe in visible if _spec_hides(spec, probe)]
if hidden:
raise ValueError(
f"A later exclusion in {path} hides {hidden[0]}. "
"Move !.specify/ and !.specify/** below it, then rerun."
)
if not specify.is_dir() or specify.is_symlink():
return
for name in (".ignore", ".rgignore"):
nested = specify / name
if not nested.is_file() or nested.is_symlink():
continue
nested_spec = _ignore_spec(FileHelper(specify).read_bytes(name))
nested_hidden = [
probe for probe in visible
if probe != ".specify/"
and _spec_hides(nested_spec, probe.removeprefix(".specify/"))
]
if nested_hidden:
raise ValueError(
f"An exclusion in {nested} hides {nested_hidden[0]}. "
"Remove it from that file, then rerun."
)


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

Private rules are appended when they are not exact lines after the
last negation line, so a later ``!`` rule cannot re-include them. 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 + _SPECIFY_PRIVATE_RULES
)
return body + newline

lines = _split_ignore_lines(raw)
exceptions = [rule.encode("ascii") for rule in _SPECIFY_IGNORE_RULES]
missing = [rule for rule in exceptions if rule not in lines]
last = max(
(
index for index, line in enumerate(lines)
if line in exceptions or line.startswith(b"!")
),
default=-1,
)
after = set() if missing else set(lines[last + 1:])
missing += [
rule.encode("ascii")
for rule in _SPECIFY_PRIVATE_RULES
if rule.encode("ascii") not in after
]
if not missing:
return raw

newline = _ignore_newline(raw)
addition = b"" if raw.endswith(b"\n") else newline
addition += newline.join(missing) + newline
return raw + addition


def _read_root_ignore(path: Path) -> bytes:
"""Read root ``.ignore`` without following a link swapped in at the leaf."""
try:
return FileHelper(path.parent).read_bytes(path.name)
except FileNotFoundError:
return b""
except SymlinkDeniedError as exc:
raise ValueError(f"Refusing to configure symlinked .ignore: {path}") from exc
except UnsupportedPathError as exc:
raise ValueError(
f"Refusing to configure .ignore because it is not a regular file: {path}"
) from exc
except OSError as exc:
if exc.errno == errno.ELOOP:
raise ValueError(f"Refusing to configure symlinked .ignore: {path}") from exc
raise OSError(f"Cannot read .ignore at {path}") from exc


def _preflight_root_ignore(
project_root: Path, script_type: str | None = None
) -> tuple[Path, bytes, bytes]:
path = project_root / ".ignore"
raw = _read_root_ignore(path)
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.
Resolved
_reject_hidden_specify_paths(path, updated, script_type)
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, script_type: str | None = None
) -> Path:
"""Recheck root ``.ignore``, write it atomically, and record partial ownership."""
path, raw, updated = _preflight_root_ignore(manifest.project_root, script_type)
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
if _read_root_ignore(path) != raw:
raise ValueError(f"{path} changed while it was being configured; rerun.")
_write_shared_bytes(manifest.project_root, path, updated, mode=mode)
Comment thread
Copilot marked this conversation as resolved.
Resolved
manifest.record_existing(".ignore", ownership="partial")
return path
Loading