1"""Pre-expand skill/command Markdown content for subprocess prompts.

2

3 Eliminates the ToolSearch → Skill deferred-tool dependency when ll-auto

4 spawns Claude subprocesses: instead of passing `/ll:<name> <args>`, Python

5 reads the skill file, substitutes all ``{{config.xxx}}`` placeholders and

6 the ``$ARGUMENTS`` marker, and returns a self-contained prompt string.

7

8 Falls back to None on any failure so callers can use the original slash

9 command.

10"""

11

12 from __future__ import annotations

13

14 import logging

15 import os

16 import re

17 from pathlib import Path

18

19 from little_loops.config import BRConfig

20 from little_loops.frontmatter import strip_frontmatter

21

22_log = logging.getLogger(__name__)

23

24

25 def _find_plugin_root() -> Path:

26 """Return the plugin root directory.

27

28 Checks ``CLAUDE_PLUGIN_ROOT`` env var first, then falls back to the

29 project root derived from this file's location

30 (``scripts/little_loops/skill_expander.py`` → three parents up).

31 """

32 env_root = os.environ.get("CLAUDE_PLUGIN_ROOT")

33 if env_root:

34 return Path(env_root)

35 return Path(__file__).resolve().parent.parent.parent

36

37

38 def _resolve_content_path(plugin_root: Path, name: str) -> Path | None:

39 """Locate the skill or command Markdown file for *name*.

40

41 Tries ``skills/{name}/SKILL.md`` first, then ``commands/{name}.md``.

42 Returns the path if found, otherwise None.

43 """

44 skill_path = plugin_root / "skills" / name / "SKILL.md"

45 if skill_path.exists():

46 return skill_path

47

48 command_path = plugin_root / "commands" / f"{name}.md"

49 if command_path.exists():

50 return command_path

51

52 return None

53

54

55 def _substitute_config(content: str, config: BRConfig) -> str:

56 """Replace ``{{config.xxx}}`` placeholders using *config*.

57

58 Unresolvable placeholders are left as-is so downstream consumers can

59 still inspect them (and callers can detect failure if needed).

60 """

61

62 def _replacer(match: re.Match[str]) -> str:

63 var_path = match.group(1)

64 value = config.resolve_variable(var_path)

65 if value is None:

66 return "" # unconfigured → blank; removes placeholder

67 return value

68

69 return re.sub(r"\{\{config\.([^}]+)\}\}", _replacer, content)

70

71

72 def _substitute_relative_refs(content: str, content_dir: Path) -> str:

73 """Convert relative Markdown link targets to absolute paths.

74

75 Finds patterns like ``(templates.md)`` or ``(subdir/file.md)`` and

76 replaces the target with the absolute path when the file exists next to

77 the skill/command file. Links to non-existent files are left unchanged.

78 """

79

80 def _replacer(match: re.Match[str]) -> str:

81 target = match.group(1)

82 # Skip already-absolute paths and URLs

83 if target.startswith("/") or "://" in target:

84 return match.group(0)

85 candidate = content_dir / target

86 if candidate.exists():

87 return f"({candidate.resolve()})"

88 return match.group(0)

89

90 return re.sub(r"\(([^)]+\.md)\)", _replacer, content)

91

92

93 def _substitute_arguments(content: str, args: list[str]) -> str:

94 """Replace the ``$ARGUMENTS`` token with the joined *args* string."""

95 joined = " ".join(args)

96 return content.replace("$ARGUMENTS", joined)

97

98

99 def expand_skill(name: str, args: list[str], config: BRConfig) -> str | None:

100 """Pre-expand a skill or command into a self-contained prompt string.

101

102 Reads the Markdown source for *name*, strips frontmatter, substitutes

103 ``{{config.xxx}}`` placeholders, converts relative ``(file.md)``

104 references to absolute paths, and replaces ``$ARGUMENTS`` with the

105 joined *args*.

106

107 Args:

108 name: Skill or command name (e.g. ``"manage-issue"``, ``"ready-issue"``).

109 args: Arguments that would normally follow the slash command.

110 config: Project configuration used for placeholder substitution.

111

112 Returns:

113 Fully-expanded prompt string, or ``None`` on any failure (file not

114 found, substitution error, …). Callers should fall back to the

115 original slash command when ``None`` is returned.

116 """

117 try:

118 plugin_root = _find_plugin_root()

119 content_path = _resolve_content_path(plugin_root, name)

120 if content_path is None:

121 _log.debug(

122 "skill_expander: pre-expansion unavailable for %r — "

123 "CLAUDE_PLUGIN_ROOT not set or skills/commands not found under %s",

124 name,

125 plugin_root,

126 )

127 return None

128

129 raw = content_path.read_text(encoding="utf-8")

130 body = strip_frontmatter(raw)

131 body = _substitute_config(body, config)

132 body = _substitute_relative_refs(body, content_path.parent)

133 body = _substitute_arguments(body, args)

134 return body

135 except Exception: # noqa: BLE001

136 return None