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
« 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.
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 os
15import re
16from pathlib import Path
18from little_loops.config import BRConfig
19from little_loops.frontmatter import strip_frontmatter
22def _find_plugin_root() -> Path:
23 """Return the plugin root directory.
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
35def _resolve_content_path(plugin_root: Path, name: str) -> Path | None:
36 """Locate the skill or command Markdown file for *name*.
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
45 command_path = plugin_root / "commands" / f"{name}.md"
46 if command_path.exists():
47 return command_path
49 return None
52def _substitute_config(content: str, config: BRConfig) -> str:
53 """Replace ``{{config.xxx}}`` placeholders using *config*.
55 Unresolvable placeholders are left as-is so downstream consumers can
56 still inspect them (and callers can detect failure if needed).
57 """
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
66 return re.sub(r"\{\{config\.([^}]+)\}\}", _replacer, content)
69def _substitute_relative_refs(content: str, content_dir: Path) -> str:
70 """Convert relative Markdown link targets to absolute paths.
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 """
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)
87 return re.sub(r"\(([^)]+\.md)\)", _replacer, content)
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)
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.
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*.
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.
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
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