zima-blue-cli 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.
zima/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Zima Blue CLI - Personal Agent Orchestration Platform"""
2
+
3
+ __version__ = "0.1.0"
zima/cli.py ADDED
@@ -0,0 +1,471 @@
1
+ """ZimaBlue CLI - v2 Simplified"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ # Fix Windows UTF-8 encoding issue
12
+ from zima.utils import get_zima_home, setup_windows_utf8
13
+
14
+ setup_windows_utf8()
15
+
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from zima.commands import agent as agent_cmd
21
+ from zima.commands import env as env_cmd
22
+ from zima.commands import pjob as pjob_cmd
23
+ from zima.commands import pmg as pmg_cmd
24
+ from zima.commands import schedule as schedule_cmd
25
+ from zima.commands import variable as variable_cmd
26
+ from zima.commands import workflow as workflow_cmd
27
+ from zima.config.manager import ConfigManager
28
+ from zima.core import AgentRunner
29
+ from zima.models import AgentConfig
30
+ from zima.models.schedule import ScheduleConfig
31
+
32
+ app = typer.Typer(
33
+ name="zima",
34
+ help="ZimaBlue CLI - Agent Runner",
35
+ add_completion=False,
36
+ )
37
+
38
+ # Register subcommands
39
+ app.add_typer(agent_cmd.app, name="agent")
40
+ app.add_typer(workflow_cmd.app, name="workflow")
41
+ app.add_typer(variable_cmd.app, name="variable")
42
+ app.add_typer(env_cmd.app, name="env")
43
+ app.add_typer(pmg_cmd.app, name="pmg")
44
+ app.add_typer(pjob_cmd.app, name="pjob")
45
+ app.add_typer(schedule_cmd.app, name="schedule")
46
+ console = Console(legacy_windows=False, force_terminal=True)
47
+
48
+
49
+ def get_agents_dir() -> Path:
50
+ """Get agents directory - global in ~/.zima/agents"""
51
+ zima_home = os.environ.get("ZIMA_HOME")
52
+ if zima_home:
53
+ agents_dir = Path(zima_home) / "agents"
54
+ else:
55
+ agents_dir = Path.home() / ".zima" / "agents"
56
+
57
+ agents_dir.mkdir(parents=True, exist_ok=True)
58
+ return agents_dir
59
+
60
+
61
+ @app.callback()
62
+ def main():
63
+ """ZimaBlue CLI - Agent Runner"""
64
+ pass
65
+
66
+
67
+ @app.command()
68
+ def create(
69
+ name: str = typer.Argument(..., help="Agent name"),
70
+ workspace: Optional[Path] = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
71
+ prompt: Optional[Path] = typer.Option(None, "--prompt", "-p", help="Prompt file"),
72
+ ):
73
+ """Create a new agent"""
74
+ agents_dir = get_agents_dir()
75
+ agent_dir = agents_dir / name
76
+
77
+ if agent_dir.exists():
78
+ console.print(f"[red]✗ Agent '{name}' already exists[/red]")
79
+ raise typer.Exit(1)
80
+
81
+ # Create directories
82
+ agent_dir.mkdir(parents=True)
83
+ ws_dir = agent_dir / "workspace"
84
+ ws_dir.mkdir()
85
+ (agent_dir / "logs").mkdir()
86
+
87
+ # Create default agent.yaml
88
+ prompt_file = prompt.name if prompt else "prompt.md"
89
+ agent_yaml = agent_dir / "agent.yaml"
90
+ agent_yaml.write_text(
91
+ f"""metadata:
92
+ name: {name}
93
+ description: Auto-generated agent
94
+
95
+ spec:
96
+ workspace: ./workspace
97
+ prompt:
98
+ file: {prompt_file}
99
+ vars: {{}}
100
+ execution:
101
+ maxTime: 900
102
+ maxStepsPerTurn: 50
103
+ maxRalphIterations: 10
104
+ """,
105
+ encoding="utf-8",
106
+ )
107
+
108
+ # Copy prompt file if provided
109
+ if prompt and prompt.exists():
110
+ import shutil
111
+
112
+ shutil.copy(prompt, agent_dir / prompt_file)
113
+ else:
114
+ # Create default prompt
115
+ (agent_dir / "prompt.md").write_text(f"# {name}\n\nYour task here.\n", encoding="utf-8")
116
+
117
+ console.print(f"[green]✓ Created agent '{name}'[/green]")
118
+ console.print(f" Directory: {agent_dir}")
119
+ console.print(f" Workspace: {ws_dir}")
120
+ console.print(f"\nEdit {agent_dir / 'agent.yaml'} to configure")
121
+
122
+
123
+ @app.command()
124
+ def run(
125
+ name: str = typer.Argument(..., help="Agent name"),
126
+ timeout: Optional[int] = typer.Option(
127
+ None, "--timeout", "-t", help="Max execution time (seconds)"
128
+ ),
129
+ ):
130
+ """Run an agent once"""
131
+ agents_dir = get_agents_dir()
132
+ agent_dir = agents_dir / name
133
+
134
+ if not agent_dir.exists():
135
+ console.print(f"[red]✗ Agent '{name}' not found[/red]")
136
+ raise typer.Exit(1)
137
+
138
+ # Load config
139
+ config = AgentConfig.from_yaml(agent_dir / "agent.yaml")
140
+
141
+ # Override timeout if provided
142
+ if timeout:
143
+ config.max_execution_time = timeout
144
+
145
+ # Run
146
+ runner = AgentRunner(config, agent_dir)
147
+ result = runner.run()
148
+
149
+ # Show result
150
+ console.print("\n[bold]Result:[/bold]")
151
+ console.print(f" Status: {result.status}")
152
+ console.print(f" Time: {result.elapsed_time:.1f}s")
153
+ console.print(f" Summary: {result.summary}")
154
+
155
+ if result.return_code != 0:
156
+ raise typer.Exit(1)
157
+
158
+
159
+ @app.command()
160
+ def list(
161
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
162
+ ):
163
+ """List all agents"""
164
+ agents_dir = get_agents_dir()
165
+
166
+ if not agents_dir.exists():
167
+ console.print("[yellow]No agents found. Create one with: zima create <name>[/yellow]")
168
+ return
169
+
170
+ agent_dirs = [d for d in agents_dir.iterdir() if d.is_dir()]
171
+
172
+ if not agent_dirs:
173
+ console.print("[yellow]No agents found. Create one with: zima create <name>[/yellow]")
174
+ return
175
+
176
+ table = Table(title="Agents")
177
+ table.add_column("Name", style="cyan")
178
+ table.add_column("Description", style="green")
179
+
180
+ for agent_dir in sorted(agent_dirs):
181
+ name = agent_dir.name
182
+ try:
183
+ config = AgentConfig.from_yaml(agent_dir / "agent.yaml")
184
+ desc = (
185
+ config.description[:50] + "..."
186
+ if len(config.description) > 50
187
+ else config.description
188
+ )
189
+ except Exception:
190
+ desc = "-"
191
+
192
+ table.add_row(name, desc)
193
+
194
+ console.print(table)
195
+
196
+
197
+ @app.command()
198
+ def show(
199
+ name: str = typer.Argument(..., help="Agent name"),
200
+ ):
201
+ """Show agent configuration"""
202
+ agents_dir = get_agents_dir()
203
+ agent_dir = agents_dir / name
204
+
205
+ if not agent_dir.exists():
206
+ console.print(f"[red]✗ Agent '{name}' not found[/red]")
207
+ raise typer.Exit(1)
208
+
209
+ config = AgentConfig.from_yaml(agent_dir / "agent.yaml")
210
+
211
+ table = Table(title=f"Agent: {name}")
212
+ table.add_column("Property", style="cyan")
213
+ table.add_column("Value", style="green")
214
+
215
+ table.add_row("Name", config.name)
216
+ table.add_row("Description", config.description)
217
+ table.add_row("Workspace", str(config.workspace))
218
+ table.add_row("Prompt File", config.prompt_file)
219
+ table.add_row("Max Time", f"{config.max_execution_time}s")
220
+ table.add_row("Max Steps/Turn", str(config.max_steps_per_turn))
221
+ table.add_row("Max Ralph Iter", str(config.max_ralph_iterations))
222
+
223
+ console.print(table)
224
+
225
+ if config.prompt_vars:
226
+ console.print("\n[bold]Prompt Variables:[/bold]")
227
+ for k, v in config.prompt_vars.items():
228
+ console.print(f" {k}: {v}")
229
+
230
+
231
+ @app.command()
232
+ def logs(
233
+ name: str = typer.Argument(..., help="Agent name"),
234
+ n: int = typer.Option(20, "--lines", "-n", help="Number of lines"),
235
+ ):
236
+ """Show agent logs"""
237
+ agents_dir = get_agents_dir()
238
+ agent_dir = agents_dir / name
239
+
240
+ if not agent_dir.exists():
241
+ console.print(f"[red]✗ Agent '{name}' not found[/red]")
242
+ raise typer.Exit(1)
243
+
244
+ logs_dir = agent_dir / "logs"
245
+ if not logs_dir.exists():
246
+ console.print("[yellow]No logs found[/yellow]")
247
+ return
248
+
249
+ # Get latest log
250
+ log_files = sorted(logs_dir.glob("*.log"), reverse=True)
251
+ if not log_files:
252
+ console.print("[yellow]No logs found[/yellow]")
253
+ return
254
+
255
+ latest = log_files[0]
256
+ console.print(f"[bold]Latest log ({latest.name}):[/bold]\n")
257
+
258
+ try:
259
+ content = latest.read_text(encoding="utf-8")
260
+ lines = content.split("\n")
261
+ for line in lines[-n:]:
262
+ console.print(line)
263
+ except Exception as e:
264
+ console.print(f"[red]Error reading log: {e}[/red]")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Daemon commands
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ @app.command()
273
+ def daemon_start(
274
+ schedule: str = typer.Option(..., "--schedule", "-s", help="Schedule code"),
275
+ ):
276
+ """Start the global daemon"""
277
+ daemon_dir = get_zima_home() / "daemon"
278
+ pid_file = daemon_dir / "daemon.pid"
279
+
280
+ if pid_file.exists():
281
+ try:
282
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
283
+ # Check if process is alive
284
+ import ctypes
285
+
286
+ kernel32 = ctypes.windll.kernel32
287
+ handle = kernel32.OpenProcess(1, False, pid)
288
+ if handle:
289
+ kernel32.CloseHandle(handle)
290
+ console.print(f"[yellow]⚠[/yellow] Daemon already running (PID {pid})")
291
+ raise typer.Exit(1)
292
+ except Exception:
293
+ pass
294
+ pid_file.unlink(missing_ok=True)
295
+
296
+ manager = ConfigManager()
297
+ if not manager.config_exists("schedule", schedule):
298
+ console.print(f"[red]✗[/red] Schedule '{schedule}' not found")
299
+ raise typer.Exit(1)
300
+
301
+ data = manager.load_config("schedule", schedule)
302
+ cfg = ScheduleConfig.from_dict(data)
303
+ errors = cfg.validate(resolve_refs=True)
304
+ if errors:
305
+ console.print("[red]✗[/red] Schedule validation failed:")
306
+ for e in errors:
307
+ console.print(f" [red]•[/red] {e}")
308
+ raise typer.Exit(1)
309
+
310
+ log_file = daemon_dir / "daemon.log"
311
+ daemon_dir.mkdir(parents=True, exist_ok=True)
312
+
313
+ cmd = [
314
+ sys.executable,
315
+ "-m",
316
+ "zima.daemon_runner",
317
+ "--schedule",
318
+ schedule,
319
+ ]
320
+
321
+ log_fh = open(log_file, "w", encoding="utf-8")
322
+ try:
323
+ if sys.platform == "win32":
324
+ proc = subprocess.Popen(
325
+ cmd,
326
+ stdout=log_fh,
327
+ stderr=subprocess.STDOUT,
328
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW,
329
+ close_fds=True,
330
+ )
331
+ else:
332
+ proc = subprocess.Popen(
333
+ cmd,
334
+ stdout=log_fh,
335
+ stderr=subprocess.STDOUT,
336
+ start_new_session=True,
337
+ close_fds=True,
338
+ )
339
+ except Exception as e:
340
+ log_fh.close()
341
+ console.print(f"[red]✗[/red] Failed to start daemon: {e}")
342
+ raise typer.Exit(1)
343
+ # Detach file handle — daemon process owns it now
344
+ log_fh.close()
345
+
346
+ # Brief check that child didn't exit immediately (e.g. validation failure)
347
+ import time
348
+
349
+ time.sleep(0.5)
350
+ if proc.poll() is not None:
351
+ pid_file.unlink(missing_ok=True)
352
+ console.print(f"[red]✗[/red] Daemon exited immediately (code {proc.returncode})")
353
+ console.print(f" Check log: {log_file}")
354
+ raise typer.Exit(1)
355
+
356
+ pid_file.write_text(str(proc.pid), encoding="utf-8")
357
+ console.print(f"[green]✓[/green] Daemon started (PID {proc.pid})")
358
+ console.print(f" Schedule: {schedule}")
359
+ console.print(f" Log: {log_file}")
360
+
361
+
362
+ @app.command()
363
+ def daemon_stop():
364
+ """Stop the global daemon"""
365
+ daemon_dir = get_zima_home() / "daemon"
366
+ pid_file = daemon_dir / "daemon.pid"
367
+
368
+ if not pid_file.exists():
369
+ console.print("[yellow]⚠[/yellow] Daemon is not running")
370
+ raise typer.Exit(0)
371
+
372
+ try:
373
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
374
+ if sys.platform == "win32":
375
+ # Try graceful shutdown first, then force after 5s
376
+ # /T kills the entire process tree (PJobs spawned with CREATE_NEW_PROCESS_GROUP)
377
+ subprocess.run(["taskkill", "/PID", str(pid), "/T"], check=False)
378
+ import time
379
+
380
+ time.sleep(5)
381
+ # Force kill if still alive
382
+ try:
383
+ import ctypes
384
+
385
+ kernel32 = ctypes.windll.kernel32
386
+ handle = kernel32.OpenProcess(1, False, pid)
387
+ if handle:
388
+ kernel32.CloseHandle(handle)
389
+ subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False)
390
+ except Exception:
391
+ pass
392
+ else:
393
+ import os
394
+ import signal
395
+
396
+ os.kill(pid, signal.SIGTERM)
397
+ pid_file.unlink(missing_ok=True)
398
+ console.print(f"[green]✓[/green] Daemon stopped (PID {pid})")
399
+ except Exception as e:
400
+ console.print(f"[red]✗[/red] Failed to stop daemon: {e}")
401
+ raise typer.Exit(1)
402
+
403
+
404
+ @app.command()
405
+ def daemon_status():
406
+ """Show daemon status"""
407
+ daemon_dir = get_zima_home() / "daemon"
408
+ pid_file = daemon_dir / "daemon.pid"
409
+ state_file = daemon_dir / "state.json"
410
+
411
+ if not pid_file.exists():
412
+ console.print("[yellow]Daemon is not running[/yellow]")
413
+ raise typer.Exit(0)
414
+
415
+ try:
416
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
417
+ except ValueError:
418
+ console.print("[red]Invalid PID file[/red]")
419
+ raise typer.Exit(1)
420
+
421
+ # Check if alive
422
+ alive = False
423
+ if sys.platform == "win32":
424
+ import ctypes
425
+
426
+ kernel32 = ctypes.windll.kernel32
427
+ handle = kernel32.OpenProcess(1, False, pid)
428
+ if handle:
429
+ kernel32.CloseHandle(handle)
430
+ alive = True
431
+ else:
432
+ import os
433
+
434
+ try:
435
+ os.kill(pid, 0)
436
+ alive = True
437
+ except OSError:
438
+ pass
439
+
440
+ if not alive:
441
+ console.print(f"[yellow]Daemon PID {pid} is not alive[/yellow]")
442
+ raise typer.Exit(0)
443
+
444
+ console.print(f"[green]Daemon is running[/green] (PID {pid})")
445
+
446
+ if state_file.exists():
447
+ import json
448
+
449
+ state = json.loads(state_file.read_text(encoding="utf-8"))
450
+ console.print(f" Current cycle: {state.get('currentCycle', 'unknown')}")
451
+ console.print(f" Current stage: {state.get('currentStage', 'unknown')}")
452
+ console.print(f" Active PJobs: {state.get('activePjobs', [])}")
453
+
454
+
455
+ @app.command()
456
+ def daemon_logs(
457
+ tail: int = typer.Option(20, "--tail", "-n", help="Number of lines"),
458
+ ):
459
+ """Show daemon logs"""
460
+ log_file = get_zima_home() / "daemon" / "daemon.log"
461
+ if not log_file.exists():
462
+ console.print("[yellow]No daemon logs found[/yellow]")
463
+ raise typer.Exit(0)
464
+
465
+ lines = log_file.read_text(encoding="utf-8").splitlines()
466
+ for line in lines[-tail:]:
467
+ console.print(line)
468
+
469
+
470
+ if __name__ == "__main__":
471
+ app()
@@ -0,0 +1 @@
1
+ """CLI commands package."""