Coverage for little_loops / skill_expander.py: 25%
57 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-26 17:38 -0500
1"""Pre-expand skill/command Markdown content for subprocess prompts.
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.
8Falls back to None on any failure so callers can use the original slash
9command.
10"""
12from __future__ import annotations
14import logging
15import os
16import re
17from pathlib import Path
19from little_loops.config import BRConfig
20from little_loops.frontmatter import strip_frontmatter
22_log = logging.getLogger(__name__)
25def _find_plugin_root() -> Path:
26 """Return the plugin root directory.
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
38def _resolve_content_path(plugin_root: Path, name: str) -> Path | None:
39 """Locate the skill or command Markdown file for *name*.
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
48 command_path = plugin_root / "commands" / f"{name}.md"
49 if command_path.exists():
50 return command_path
52 return None
55def _substitute_config(content: str, config: BRConfig) -> str:
56 """Replace ``{{config.xxx}}`` placeholders using *config*.
58 Unresolvable placeholders are left as-is so downstream consumers can
59 still inspect them (and callers can detect failure if needed).
60 """
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
69 return re.sub(r"\{\{config\.([^}]+)\}\}", _replacer, content)
72def _substitute_relative_refs(content: str, content_dir: Path) -> str:
73 """Convert relative Markdown link targets to absolute paths.
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 """
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)
90 return re.sub(r"\(([^)]+\.md)\)", _replacer, content)
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)
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.
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*.
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.
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
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