Coverage for little_loops / skill_expander.py: 100%

54 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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 os 

15import re 

16from pathlib import Path 

17 

18from little_loops.config import BRConfig 

19from little_loops.frontmatter import strip_frontmatter 

20 

21 

22def _find_plugin_root() -> Path: 

23 """Return the plugin root directory. 

24 

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

26 project root derived from this file's location 

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

28 """ 

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

30 if env_root: 

31 return Path(env_root) 

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

33 

34 

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

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

37 

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

39 Returns the path if found, otherwise None. 

40 """ 

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

42 if skill_path.exists(): 

43 return skill_path 

44 

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

46 if command_path.exists(): 

47 return command_path 

48 

49 return None 

50 

51 

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

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

54 

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

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

57 """ 

58 

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

60 var_path = match.group(1) 

61 value = config.resolve_variable(var_path) 

62 if value is None: 

63 return "" # unconfigured → blank; removes placeholder 

64 return value 

65 

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

67 

68 

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

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

71 

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

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

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

75 """ 

76 

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

78 target = match.group(1) 

79 # Skip already-absolute paths and URLs 

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

81 return match.group(0) 

82 candidate = content_dir / target 

83 if candidate.exists(): 

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

85 return match.group(0) 

86 

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

88 

89 

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

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

92 joined = " ".join(args) 

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

94 

95 

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

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

98 

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

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

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

102 joined *args*. 

103 

104 Args: 

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

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

107 config: Project configuration used for placeholder substitution. 

108 

109 Returns: 

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

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

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

113 """ 

114 try: 

115 plugin_root = _find_plugin_root() 

116 content_path = _resolve_content_path(plugin_root, name) 

117 if content_path is None: 

118 return None 

119 

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

121 body = strip_frontmatter(raw) 

122 body = _substitute_config(body, config) 

123 body = _substitute_relative_refs(body, content_path.parent) 

124 body = _substitute_arguments(body, args) 

125 return body 

126 except Exception: # noqa: BLE001 

127 return None