zipsa 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zipsa/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Zipsa - Multi-runtime skill launcher for Claude Code, Codex, and Gemini CLI."""
2
+
3
+ __version__ = "0.1.0"
zipsa/cli.py ADDED
@@ -0,0 +1,346 @@
1
+ """CLI for zipsa launcher."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, Optional
8
+
9
+ import typer
10
+ from pydantic import ValidationError
11
+
12
+ from .core.executor import DockerExecutor
13
+ from .core.skill import Skill
14
+ from .runtimes import list_runtimes
15
+
16
+
17
+ app = typer.Typer(
18
+ name="zipsa",
19
+ help="SKILL runtime launcher - Execute SKILLs with Claude Code, Codex, or Gemini",
20
+ add_completion=False,
21
+ )
22
+
23
+ # ANSI color codes
24
+ GRAY = "\033[90m"
25
+ RESET = "\033[0m"
26
+
27
+ # Global turn counter
28
+ _current_turn = 0
29
+
30
+
31
+ def format_event(event: dict) -> Optional[str]:
32
+ """Format important events for user-friendly display.
33
+
34
+ Returns formatted string or None if event should be skipped.
35
+ """
36
+ global _current_turn
37
+
38
+ event_type = event.get("type")
39
+
40
+ # Skip system and rate_limit events
41
+ if event_type in ("system", "rate_limit_event"):
42
+ return None
43
+
44
+ # Assistant messages
45
+ if event_type == "assistant":
46
+ message = event.get("message", {})
47
+ content = message.get("content", [])
48
+
49
+ if not content:
50
+ return None
51
+
52
+ first_content = content[0]
53
+ content_type = first_content.get("type")
54
+
55
+ # Thinking - indicates new turn
56
+ if content_type == "thinking":
57
+ _current_turn += 1
58
+ thinking = first_content.get("thinking", "")
59
+ return f"\n{GRAY}[Turn {_current_turn}]{RESET}\n{GRAY}Thinking:{RESET} {thinking}"
60
+
61
+ # Tool use (same turn, no turn increment)
62
+ elif content_type == "tool_use":
63
+ tool_name = first_content.get("name", "Unknown")
64
+ tool_input = first_content.get("input", {})
65
+
66
+ # Format input nicely
67
+ if "url" in tool_input:
68
+ detail = f"url={tool_input['url']}"
69
+ elif "query" in tool_input:
70
+ detail = f"query=\"{tool_input['query']}\""
71
+ elif "prompt" in tool_input:
72
+ detail = f"prompt=\"{tool_input['prompt']}\""
73
+ else:
74
+ # Show first key-value pair
75
+ items = list(tool_input.items())
76
+ if items:
77
+ key, val = items[0]
78
+ detail = f"{key}={val}"
79
+ else:
80
+ detail = ""
81
+
82
+ return f"\n{GRAY}Tool:{RESET} {tool_name}\n {detail}"
83
+
84
+ # Final text response (new turn if no thinking)
85
+ elif content_type == "text":
86
+ _current_turn += 1
87
+ text = first_content.get("text", "")
88
+ return f"\n{GRAY}[Turn {_current_turn}]{RESET}\n{GRAY}Answer:{RESET} {text}"
89
+
90
+ # Tool results (user role)
91
+ elif event_type == "user":
92
+ message = event.get("message", {})
93
+ content = message.get("content", [])
94
+
95
+ if not content:
96
+ return None
97
+
98
+ first_content = content[0]
99
+ if first_content.get("type") == "tool_result":
100
+ # Get result from tool_use_result if available
101
+ tool_result = event.get("tool_use_result", {})
102
+
103
+ # tool_use_result can be a string (error messages) or dict (structured data)
104
+ if isinstance(tool_result, str):
105
+ # String result (often error messages)
106
+ return f"{GRAY}Result:{RESET} {tool_result}"
107
+ elif isinstance(tool_result, dict):
108
+ # Structured result - extract meaningful info
109
+ if "matches" in tool_result:
110
+ matches = tool_result["matches"]
111
+ return f"{GRAY}Result:{RESET} Found {', '.join(matches)}"
112
+ elif "result" in tool_result:
113
+ result = tool_result["result"]
114
+ return f"{GRAY}Result:{RESET} {result}"
115
+ elif "code" in tool_result:
116
+ code = tool_result.get("code")
117
+ code_text = tool_result.get("codeText", "")
118
+ return f"{GRAY}Result:{RESET} HTTP {code} {code_text}"
119
+ else:
120
+ # Generic dict result
121
+ return f"{GRAY}Result:{RESET} Success"
122
+ else:
123
+ # Fallback to content field
124
+ content_result = first_content.get("content", "")
125
+ if isinstance(content_result, str):
126
+ return f"{GRAY}Result:{RESET} {content_result}"
127
+ else:
128
+ return f"{GRAY}Result:{RESET} Success"
129
+
130
+ # Final result summary
131
+ elif event_type == "result":
132
+ is_error = event.get("is_error", False)
133
+ duration_ms = event.get("duration_ms", 0)
134
+ num_turns = event.get("num_turns", 0)
135
+ cost = event.get("total_cost_usd", 0)
136
+
137
+ status = "Error" if is_error else "Success"
138
+ duration_s = duration_ms / 1000
139
+
140
+ summary = f"\n{'='*50}\n"
141
+ summary += f"{status}\n"
142
+ summary += f"Duration: {duration_s:.1f}s | Turns: {num_turns} | Cost: ${cost:.4f}\n"
143
+ summary += f"{'='*50}"
144
+
145
+ return summary
146
+
147
+ return None
148
+
149
+
150
+ @app.command()
151
+ def run(
152
+ skill_dir: Annotated[
153
+ str,
154
+ typer.Argument(help="Path to skill directory or manifest.yaml"),
155
+ ],
156
+ user_input: Annotated[
157
+ Optional[str],
158
+ typer.Argument(help="User input/query for the skill"),
159
+ ] = None,
160
+ runtime: Annotated[
161
+ str,
162
+ typer.Option("--runtime", "-r", help="Runtime to use (claude, codex, gemini)"),
163
+ ] = "claude",
164
+ image: Annotated[
165
+ str,
166
+ typer.Option("--image", "-i", help="Docker image to use"),
167
+ ] = "ghcr.io/westbrookai/zipsa-runtime:latest",
168
+ workspace: Annotated[
169
+ Optional[Path],
170
+ typer.Option("--workspace", "-w", help="Workspace directory"),
171
+ ] = None,
172
+ env: Annotated[
173
+ Optional[list[str]],
174
+ typer.Option("--env", "-e", help="Environment variables (KEY=value)"),
175
+ ] = None,
176
+ dry_run: Annotated[
177
+ bool,
178
+ typer.Option("--dry-run", help="Print command without executing"),
179
+ ] = False,
180
+ shell: Annotated[
181
+ bool,
182
+ typer.Option("--shell", help="Start interactive bash shell instead of running skill"),
183
+ ] = False,
184
+ ):
185
+ """Execute a skill with the specified runtime."""
186
+ try:
187
+ # Load skill
188
+ skill = Skill.load(skill_dir)
189
+ typer.echo(f"Loaded skill: {skill.name}")
190
+
191
+ # Validate input
192
+ if not shell and not user_input:
193
+ typer.echo("Error: user_input is required unless --shell is specified", err=True)
194
+ raise typer.Exit(1)
195
+
196
+ # Parse environment variables
197
+ env_dict = {}
198
+ if env:
199
+ for pair in env:
200
+ if "=" not in pair:
201
+ typer.echo(f"Error: Invalid env format '{pair}' (use KEY=value)", err=True)
202
+ raise typer.Exit(1)
203
+ key, value = pair.split("=", 1)
204
+ env_dict[key] = value
205
+
206
+ # Create executor
207
+ executor = DockerExecutor(
208
+ runtime=runtime,
209
+ image=image,
210
+ workspace=workspace or Path.cwd(),
211
+ )
212
+
213
+ # Execute skill or start shell
214
+ output = executor.run(skill, user_input or "", env=env_dict, dry_run=dry_run, shell=shell)
215
+
216
+ if output is None:
217
+ # Dry run mode
218
+ return
219
+
220
+ # Reset turn counter for new execution
221
+ global _current_turn
222
+ _current_turn = 0
223
+
224
+ # Stream output
225
+ for event in output:
226
+ formatted = format_event(event)
227
+ if formatted:
228
+ typer.echo(formatted)
229
+
230
+ except FileNotFoundError as e:
231
+ typer.echo(f"Error: {e}", err=True)
232
+ raise typer.Exit(1)
233
+ except ValidationError as e:
234
+ typer.echo(f"Error: Invalid manifest - {e}", err=True)
235
+ raise typer.Exit(1)
236
+ except Exception as e:
237
+ typer.echo(f"Error: {e}", err=True)
238
+ raise typer.Exit(1)
239
+
240
+
241
+ @app.command()
242
+ def validate(
243
+ skill_dir: Annotated[
244
+ str,
245
+ typer.Argument(help="Path to skill directory or manifest.yaml"),
246
+ ],
247
+ ):
248
+ """Validate a skill manifest."""
249
+ try:
250
+ skill = Skill.load(skill_dir)
251
+ typer.echo(f"✓ Skill '{skill.name}' is valid")
252
+ typer.echo(f" Version: {skill.manifest.metadata.version}")
253
+ typer.echo(f" Purpose: {skill.manifest.spec.purpose}")
254
+ typer.echo(f" MCP Servers: {len(skill.manifest.spec.mcp)}")
255
+ tool_count = len(skill.manifest.spec.tools.builtin) + len(skill.manifest.spec.tools.mcp)
256
+ typer.echo(f" Tools: {tool_count}")
257
+
258
+ except FileNotFoundError as e:
259
+ typer.echo(f"Error: {e}", err=True)
260
+ raise typer.Exit(1)
261
+ except ValidationError as e:
262
+ typer.echo("✗ Validation failed:", err=True)
263
+ for error in e.errors():
264
+ loc = " -> ".join(str(l) for l in error["loc"])
265
+ typer.echo(f" {loc}: {error['msg']}", err=True)
266
+ raise typer.Exit(1)
267
+ except Exception as e:
268
+ typer.echo(f"Error: {e}", err=True)
269
+ raise typer.Exit(1)
270
+
271
+
272
+ @app.command(name="list")
273
+ def list_skills(
274
+ skills_dir: Annotated[
275
+ str,
276
+ typer.Argument(help="Directory containing skills"),
277
+ ] = ".",
278
+ ):
279
+ """List all skills in a directory."""
280
+ try:
281
+ skills_path = Path(skills_dir)
282
+ if not skills_path.exists():
283
+ typer.echo(f"Error: Directory '{skills_dir}' not found", err=True)
284
+ raise typer.Exit(1)
285
+
286
+ if not skills_path.is_dir():
287
+ typer.echo(f"Error: '{skills_dir}' is not a directory", err=True)
288
+ raise typer.Exit(1)
289
+
290
+ # Find all skill directories
291
+ skills = []
292
+ for item in skills_path.iterdir():
293
+ if not item.is_dir():
294
+ continue
295
+
296
+ # Check if manifest.yaml exists
297
+ manifest_path = item / "manifest.yaml"
298
+ if not manifest_path.exists():
299
+ continue
300
+
301
+ try:
302
+ skill = Skill.load(item)
303
+ skills.append({
304
+ "name": skill.name,
305
+ "version": skill.manifest.metadata.version,
306
+ "purpose": skill.manifest.spec.purpose,
307
+ "path": item,
308
+ })
309
+ except Exception:
310
+ # Skip invalid skills
311
+ continue
312
+
313
+ if not skills:
314
+ typer.echo("No skills found")
315
+ return
316
+
317
+ # Print skills table
318
+ typer.echo(f"Found {len(skills)} skill(s):\n")
319
+ for skill in skills:
320
+ typer.echo(f" {skill['name']} (v{skill['version']})")
321
+ typer.echo(f" {skill['purpose']}")
322
+ typer.echo(f" Path: {skill['path']}")
323
+ typer.echo()
324
+
325
+ except Exception as e:
326
+ typer.echo(f"Error: {e}", err=True)
327
+ raise typer.Exit(1)
328
+
329
+
330
+ @app.command()
331
+ def runtimes():
332
+ """List available runtimes."""
333
+ available = list_runtimes()
334
+
335
+ typer.echo("Available runtimes:\n")
336
+ for runtime_name in available:
337
+ typer.echo(f" - {runtime_name}")
338
+
339
+
340
+ def main():
341
+ """Entry point for CLI."""
342
+ app()
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()
zipsa/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core components for skill loading and execution."""