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 +3 -0
- zipsa/cli.py +346 -0
- zipsa/core/__init__.py +1 -0
- zipsa/core/executor.py +591 -0
- zipsa/core/models.py +97 -0
- zipsa/core/skill.py +190 -0
- zipsa/runtimes/__init__.py +33 -0
- zipsa/runtimes/base.py +67 -0
- zipsa/runtimes/claude.py +66 -0
- zipsa-0.1.0.dist-info/METADATA +81 -0
- zipsa-0.1.0.dist-info/RECORD +13 -0
- zipsa-0.1.0.dist-info/WHEEL +4 -0
- zipsa-0.1.0.dist-info/entry_points.txt +2 -0
zipsa/__init__.py
ADDED
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."""
|