From 616c8ce1da098c9eeca93ea962e91dbd6a03911d Mon Sep 17 00:00:00 2001 From: CoderDeltaLAN Date: Tue, 9 Jun 2026 05:11:15 +0100 Subject: [PATCH] feat: add instruction discovery --- src/agent_rules_kit/discovery.py | 88 ++++++++++++++++++++++++++++++++ tests/test_discovery.py | 72 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/agent_rules_kit/discovery.py create mode 100644 tests/test_discovery.py diff --git a/src/agent_rules_kit/discovery.py b/src/agent_rules_kit/discovery.py new file mode 100644 index 0000000..5b8a527 --- /dev/null +++ b/src/agent_rules_kit/discovery.py @@ -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", +] diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..bf3e8ac --- /dev/null +++ b/tests/test_discovery.py @@ -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()