Skip to content
Merged
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
88 changes: 88 additions & 0 deletions src/agent_rules_kit/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Instruction file discovery for supported agent rule files."""

from __future__ import annotations

from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path


class InstructionFileKind(StrEnum):
"""Supported instruction file family."""

AGENTS = "agents"
CLAUDE = "claude"
GEMINI = "gemini"
CURSOR_RULE = "cursor-rule"
COPILOT = "copilot"
GITHUB_INSTRUCTION = "github-instruction"


@dataclass(frozen=True, slots=True)
class InstructionFile:
"""A discovered instruction file."""

path: str
kind: InstructionFileKind


def discover_instruction_files(root: Path | str) -> tuple[InstructionFile, ...]:
"""Discover supported instruction files below a repository root.

Discovery is intentionally limited to known instruction file locations. It
does not execute repository commands, call the network, or inspect file
contents.
"""
root_path = Path(root)

if not root_path.exists():
raise ValueError(f"repository root does not exist: {root_path}")
if not root_path.is_dir():
raise ValueError(f"repository root is not a directory: {root_path}")

discovered: list[InstructionFile] = []

for relative_path, kind in _exact_instruction_paths():
candidate = root_path / relative_path
if candidate.is_file():
discovered.append(InstructionFile(path=relative_path, kind=kind))

cursor_rules_dir = root_path / ".cursor" / "rules"
if cursor_rules_dir.is_dir():
for candidate in sorted(cursor_rules_dir.glob("*.mdc")):
if candidate.is_file():
discovered.append(
InstructionFile(
path=candidate.relative_to(root_path).as_posix(),
kind=InstructionFileKind.CURSOR_RULE,
)
)

github_instructions_dir = root_path / ".github" / "instructions"
if github_instructions_dir.is_dir():
for candidate in sorted(github_instructions_dir.glob("*.md")):
if candidate.is_file():
discovered.append(
InstructionFile(
path=candidate.relative_to(root_path).as_posix(),
kind=InstructionFileKind.GITHUB_INSTRUCTION,
)
)

return tuple(discovered)


def _exact_instruction_paths() -> tuple[tuple[str, InstructionFileKind], ...]:
return (
("AGENTS.md", InstructionFileKind.AGENTS),
("CLAUDE.md", InstructionFileKind.CLAUDE),
("GEMINI.md", InstructionFileKind.GEMINI),
(".github/copilot-instructions.md", InstructionFileKind.COPILOT),
)


__all__ = [
"InstructionFile",
"InstructionFileKind",
"discover_instruction_files",
]
72 changes: 72 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from __future__ import annotations

import tempfile
import unittest
from pathlib import Path

from agent_rules_kit.discovery import (
InstructionFile,
InstructionFileKind,
discover_instruction_files,
)

FIXTURE_ROOT = Path(__file__).parent / "fixtures" / "repositories"


class InstructionDiscoveryTests(unittest.TestCase):
def test_empty_repository_has_no_instruction_files(self) -> None:
self.assertEqual(
discover_instruction_files(FIXTURE_ROOT / "empty-repo"),
(),
)

def test_single_agent_repository_discovers_agents_file(self) -> None:
self.assertEqual(
discover_instruction_files(FIXTURE_ROOT / "single-agent"),
(
InstructionFile(
path="AGENTS.md",
kind=InstructionFileKind.AGENTS,
),
),
)

def test_multi_agent_repository_discovers_supported_instruction_files(self) -> None:
self.assertEqual(
discover_instruction_files(FIXTURE_ROOT / "multi-agent-overlap"),
(
InstructionFile(path="AGENTS.md", kind=InstructionFileKind.AGENTS),
InstructionFile(path="CLAUDE.md", kind=InstructionFileKind.CLAUDE),
InstructionFile(path="GEMINI.md", kind=InstructionFileKind.GEMINI),
InstructionFile(
path=".github/copilot-instructions.md",
kind=InstructionFileKind.COPILOT,
),
InstructionFile(
path=".cursor/rules/agent-rules.mdc",
kind=InstructionFileKind.CURSOR_RULE,
),
InstructionFile(
path=".github/instructions/agents.instructions.md",
kind=InstructionFileKind.GITHUB_INSTRUCTION,
),
),
)

def test_discovery_accepts_string_root(self) -> None:
discovered = discover_instruction_files(str(FIXTURE_ROOT / "single-agent"))

self.assertEqual(discovered[0].path, "AGENTS.md")

def test_discovery_rejects_missing_root(self) -> None:
with self.assertRaises(ValueError):
discover_instruction_files(FIXTURE_ROOT / "missing-repo")

def test_discovery_rejects_file_root(self) -> None:
with tempfile.NamedTemporaryFile() as temporary_file:
with self.assertRaises(ValueError):
discover_instruction_files(temporary_file.name)


if __name__ == "__main__":
unittest.main()
Loading