Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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`. Bytes already in the file stay as a prefix, including non-ASCII text, CRLF, and a missing final newline. 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.
125 changes: 124 additions & 1 deletion src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
"""opencode integration."""

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

from ..base import MarkdownIntegration
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 +53,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 +126,74 @@ 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 _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, so a
non-UTF-8 prefix is 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 on lines +147 to +151
]
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
updated = _with_specify_ignore_rules(raw)
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
Loading