Coverage for little_loops / skill_expander.py: 25%

57 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -0500

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

2 

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

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

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

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

7 

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

9command. 

10""" 

11 

12from __future__ import annotations 

13 

14import logging 

15import os 

16import re 

17from pathlib import Path 

18 

19from little_loops.config import BRConfig 

20from little_loops.frontmatter import strip_frontmatter 

21 

22_log = logging.getLogger(__name__) 

23 

24 

25def _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 

38def _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 

55def _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 

72def _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 

93def _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 

99def 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