Coverage for little_loops / cli / action.py: 96%
122 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"""ll-action: Thin CLI wrapper for invoking ll skills as one-shot commands."""
3from __future__ import annotations
5import argparse
6import json
7import subprocess
8import time
9from datetime import UTC, datetime
10from pathlib import Path
12from little_loops.host_runner import resolve_host
14__all__ = ["main_action"]
17def _now_iso() -> str:
18 return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
21def _emit(event: dict) -> None:
22 print(json.dumps(event), flush=True)
25def _find_plugin_root() -> Path:
26 from little_loops.skill_expander import _find_plugin_root as _fpr
28 return _fpr()
31def _read_skill_description(skill_md: Path) -> str:
32 """Extract description from SKILL.md YAML frontmatter."""
33 try:
34 content = skill_md.read_text()
35 except OSError:
36 return ""
37 if not content.startswith("---"):
38 return ""
39 end = content.find("---", 3)
40 if end == -1:
41 return ""
42 frontmatter = content[3:end]
43 for line in frontmatter.splitlines():
44 if line.startswith("description:"):
45 return line[len("description:") :].strip().strip('"').strip("'")
46 return ""
49def _load_skills() -> list[dict[str, str]]:
50 """Return skill list with name and description from skills/*/SKILL.md files."""
51 plugin_root = _find_plugin_root()
52 skills_dir = plugin_root / "skills"
53 skills = []
54 for skill_md in sorted(skills_dir.glob("*/SKILL.md")):
55 name = skill_md.parent.name
56 description = _read_skill_description(skill_md)
57 skills.append({"name": name, "description": description})
58 return skills
61def cmd_invoke(args: argparse.Namespace) -> int:
62 from little_loops.subprocess_utils import run_claude_command
64 skill = args.skill
65 skill_args: list[str] = args.args or []
66 timeout: int = args.timeout
67 output_mode: str = args.output
69 command = f"/ll:{skill}"
70 if skill_args:
71 command += " " + " ".join(skill_args)
73 start_ms = int(time.time() * 1000)
75 if output_mode == "stream-json":
76 _emit({"event": "action_start", "ts": _now_iso(), "skill": skill, "args": skill_args})
78 exit_code = 0
80 def _stream_cb(line: str, is_stderr: bool) -> None:
81 if not is_stderr:
82 _emit({"event": "action_output", "ts": _now_iso(), "line": line})
84 try:
85 result = run_claude_command(
86 command=command,
87 timeout=timeout,
88 stream_callback=_stream_cb,
89 )
90 exit_code = result.returncode
91 except subprocess.TimeoutExpired:
92 exit_code = 124
94 duration_ms = int(time.time() * 1000) - start_ms
95 _emit(
96 {
97 "event": "action_complete",
98 "ts": _now_iso(),
99 "exit_code": exit_code,
100 "duration_ms": duration_ms,
101 }
102 )
103 return exit_code
105 else: # --output json
106 from little_loops.cli.output import print_json
108 output_lines: list[str] = []
109 stderr_lines: list[str] = []
111 def _stream_cb_json(line: str, is_stderr: bool) -> None:
112 if is_stderr:
113 stderr_lines.append(line)
114 else:
115 output_lines.append(line)
117 exit_code = 0
118 try:
119 result = run_claude_command(
120 command=command,
121 timeout=timeout,
122 stream_callback=_stream_cb_json,
123 )
124 exit_code = result.returncode
125 except subprocess.TimeoutExpired:
126 exit_code = 124
128 duration_ms = int(time.time() * 1000) - start_ms
129 print_json(
130 {
131 "exit_code": exit_code,
132 "duration_ms": duration_ms,
133 "output": "\n".join(output_lines),
134 "error": "\n".join(stderr_lines) if stderr_lines else None,
135 }
136 )
137 return exit_code
140def cmd_capabilities(args: argparse.Namespace) -> int:
141 from little_loops.cli.output import print_json
143 runner = resolve_host()
144 report = runner.describe_capabilities()
146 available = runner.detect()
147 version = ""
148 if available:
149 try:
150 invocation = runner.build_version_check()
151 version_result = subprocess.run(
152 [invocation.binary, *invocation.args],
153 capture_output=True,
154 text=True,
155 timeout=10,
156 )
157 version = version_result.stdout.strip()
158 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
159 available = False
161 print_json(
162 {
163 "host": report.host,
164 "binary": report.binary,
165 "version": version,
166 "capabilities": [
167 {"name": c.name, "status": c.status, "note": c.note} for c in report.capabilities
168 ],
169 "hooks": [{"name": h.name, "status": h.status, "note": h.note} for h in report.hooks],
170 }
171 )
172 return 0
175def cmd_list(args: argparse.Namespace) -> int:
176 from little_loops.cli.output import print_json
178 skills = _load_skills()
179 print_json(skills)
180 return 0
183def main_action() -> int:
184 """Entry point for ll-action CLI."""
185 parser = argparse.ArgumentParser(
186 prog="ll-action",
187 description="Invoke ll skills as one-shot commands with JSON-structured output",
188 formatter_class=argparse.RawDescriptionHelpFormatter,
189 epilog="""
190Examples:
191 ll-action invoke refine-issue --args P2-ENH-1229
192 ll-action invoke confidence-check --args FEAT-042 --timeout 120
193 ll-action invoke refine-issue --args P2-ENH-1229 --output json
194 ll-action capabilities
195 ll-action list
196""",
197 )
199 subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
200 subparsers.required = True
202 # invoke subcommand
203 invoke_parser = subparsers.add_parser(
204 "invoke",
205 help="Invoke a skill and stream output as NDJSON events",
206 description="Invoke a skill and stream output as NDJSON events (default) or collect and print as JSON",
207 )
208 invoke_parser.add_argument("skill", help="Skill name (e.g. refine-issue, confidence-check)")
209 invoke_parser.add_argument(
210 "--args",
211 nargs="+",
212 metavar="ARG",
213 help="Arguments to pass to the skill",
214 )
215 invoke_parser.add_argument(
216 "--timeout",
217 type=int,
218 default=300,
219 metavar="SECONDS",
220 help="Timeout in seconds (default: 300)",
221 )
222 invoke_parser.add_argument(
223 "--output",
224 choices=["stream-json", "json"],
225 default="stream-json",
226 dest="output",
227 help="Output format: stream-json (default, streaming NDJSON) or json (collect then print)",
228 )
230 # capabilities subcommand
231 cap_parser = subparsers.add_parser(
232 "capabilities",
233 help="Emit full CapabilityReport as JSON (host, binary, version, capabilities, hooks)",
234 description="Call describe_capabilities() and serialize the full CapabilityReport to JSON",
235 )
236 cap_parser.add_argument(
237 "--output",
238 choices=["json"],
239 default="json",
240 dest="output",
241 help="Output format (json only)",
242 )
244 # list subcommand
245 list_parser = subparsers.add_parser(
246 "list",
247 help="List all available skills with descriptions",
248 description="List all available skills with names and descriptions from plugin manifest",
249 )
250 list_parser.add_argument(
251 "--output",
252 choices=["json"],
253 default="json",
254 dest="output",
255 help="Output format (json only)",
256 )
258 parsed = parser.parse_args()
260 if parsed.command == "invoke":
261 return cmd_invoke(parsed)
262 elif parsed.command == "capabilities":
263 return cmd_capabilities(parsed)
264 elif parsed.command == "list":
265 return cmd_list(parsed)
266 else:
267 parser.print_help()
268 return 1