clawctl 0.2.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.
clawctl/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """clawctl — Coordination layer for OpenClaw agent fleets"""
2
+ __version__ = "0.2.0"
clawctl/cli.py ADDED
@@ -0,0 +1,510 @@
1
+ """clawctl — Click-based command interface for OpenClaw agent fleets."""
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import unicodedata
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from clawctl import db
14
+
15
+ # ANSI colors
16
+ R = "\033[0;31m"
17
+ G = "\033[0;32m"
18
+ Y = "\033[1;33m"
19
+ C = "\033[0;36m"
20
+ B = "\033[1m"
21
+ N = "\033[0m"
22
+
23
+ _agent_warned = False
24
+
25
+
26
+ def _warn_agent_fallback():
27
+ """Warn once if CLAW_AGENT wasn't explicitly set."""
28
+ global _agent_warned
29
+ if not db.AGENT_EXPLICIT and not _agent_warned:
30
+ click.echo(
31
+ f"{Y}Note: CLAW_AGENT not set, using $USER ({db.AGENT}){N}", err=True
32
+ )
33
+ _agent_warned = True
34
+
35
+
36
+ def _char_width(ch):
37
+ """Return display width of a character (2 for wide/fullwidth, 1 otherwise)."""
38
+ w = unicodedata.east_asian_width(ch)
39
+ return 2 if w in ("F", "W") else 1
40
+
41
+
42
+ def _str_width(s):
43
+ """Return display width of a string, accounting for multi-byte characters."""
44
+ return sum(_char_width(ch) for ch in s)
45
+
46
+
47
+ def print_columnar(rows, columns):
48
+ """Print rows as aligned columns. columns is a list of (header, key) tuples."""
49
+ if not rows:
50
+ return
51
+ # Convert sqlite3.Row to dicts
52
+ data = [dict(r) for r in rows]
53
+ # Calculate widths
54
+ widths = {}
55
+ for header, key in columns:
56
+ vals = [str(v) if (v := d.get(key)) is not None else "" for d in data]
57
+ widths[key] = max(
58
+ _str_width(header), max((_str_width(v) for v in vals), default=0)
59
+ )
60
+ # Print header
61
+ hdr = " ".join(h.ljust(widths[k]) for h, k in columns)
62
+ click.echo(hdr)
63
+ click.echo(" ".join("-" * widths[k] for _, k in columns))
64
+ # Print rows
65
+ for d in data:
66
+ parts = []
67
+ for _, k in columns:
68
+ val = str(v) if (v := d.get(k)) is not None else ""
69
+ pad = widths[k] - _str_width(val)
70
+ parts.append(val + " " * pad)
71
+ click.echo(" ".join(parts))
72
+
73
+
74
+ @click.group(invoke_without_command=True)
75
+ @click.pass_context
76
+ def cli(ctx):
77
+ """clawctl — Coordination layer for OpenClaw agent fleets"""
78
+ if ctx.invoked_subcommand is None:
79
+ click.echo(ctx.get_help())
80
+ return
81
+ if ctx.invoked_subcommand not in ("init", "help"):
82
+ if not os.path.exists(db.DB_PATH):
83
+ click.echo(f"{Y}No database found. Run: clawctl init{N}", err=True)
84
+ ctx.exit(1)
85
+
86
+
87
+ @cli.command()
88
+ def init():
89
+ """Initialize the database"""
90
+ db.init_db()
91
+ click.echo(f"{G}Initialized{N} {db.DB_PATH}")
92
+
93
+
94
+ @cli.command()
95
+ @click.argument("name")
96
+ @click.option("--role", default="", help="Agent role")
97
+ def register(name, role):
98
+ """Register an agent"""
99
+ with db.get_db() as conn:
100
+ db.register_agent(conn, name, role)
101
+ click.echo(f"{G}Registered{N} {name}{f' as {role}' if role else ''}")
102
+
103
+
104
+ @cli.command()
105
+ def checkin():
106
+ """Heartbeat — register presence"""
107
+ _warn_agent_fallback()
108
+ agent = db.AGENT
109
+ with db.get_db() as conn:
110
+ db.checkin_agent(conn, agent)
111
+ unread = db.get_unread_count(conn, agent)
112
+ if unread > 0:
113
+ click.echo(f"{Y}{unread} unread messages{N} — run: clawctl inbox --unread")
114
+ else:
115
+ click.echo(f"{G}HEARTBEAT_OK{N} ({agent})")
116
+
117
+
118
+ @cli.command()
119
+ @click.argument("subject")
120
+ @click.option("-d", "--desc", default="", help="Description")
121
+ @click.option("-p", "--priority", type=int, default=0, help="Priority (0, 1, 2)")
122
+ @click.option("--for", "assignee", default="", help="Assign to agent")
123
+ @click.option("--parent", type=int, default=None, help="Parent task ID")
124
+ def add(subject, desc, priority, assignee, parent):
125
+ """Create a task"""
126
+ with db.get_db() as conn:
127
+ ok, task_id = db.add_task(
128
+ conn, subject, desc, priority, assignee, db.AGENT, parent
129
+ )
130
+ click.echo(f"{G}#{task_id}{N} {subject}{f' → {assignee}' if assignee else ''}")
131
+
132
+
133
+ @cli.command("list")
134
+ @click.option("--status", default=None, help="Filter by status")
135
+ @click.option("--owner", default=None, help="Filter by owner")
136
+ @click.option("--mine", is_flag=True, help="Show only my tasks")
137
+ @click.option("--all", "include_all", is_flag=True, help="Include done/cancelled")
138
+ def list_cmd(status, owner, mine, include_all):
139
+ """List tasks (excludes done/cancelled by default)"""
140
+ if mine:
141
+ _warn_agent_fallback()
142
+ with db.get_db() as conn:
143
+ rows = db.list_tasks(
144
+ conn, status, owner, db.AGENT if mine else None, include_all
145
+ )
146
+ if not rows:
147
+ click.echo("No tasks found.")
148
+ return
149
+ print_columnar(
150
+ rows,
151
+ [
152
+ ("ID", "id"),
153
+ ("Subject", "subject"),
154
+ ("Status", "icon"),
155
+ ("Owner", "owner"),
156
+ ("Pri", "pri"),
157
+ ],
158
+ )
159
+ scope = "mine" if mine else "all agents"
160
+ if not include_all and not status:
161
+ click.echo(
162
+ f"\n{len(rows)} active ({scope}). Use --all to include done/cancelled."
163
+ )
164
+
165
+
166
+ @cli.command("next")
167
+ def next_cmd():
168
+ """Show the next task to work on (highest priority, actionable)"""
169
+ _warn_agent_fallback()
170
+ agent = db.AGENT
171
+ with db.get_db() as conn:
172
+ row = db.get_next_task(conn, agent)
173
+ if not row:
174
+ click.echo("No actionable tasks.")
175
+ return
176
+ pri = "!!!" if row["priority"] == 2 else "!" if row["priority"] == 1 else ""
177
+ click.echo(
178
+ f"#{row['id']} {row['subject']} [{row['status']}]{f' {pri}' if pri else ''}"
179
+ )
180
+
181
+
182
+ @cli.command()
183
+ @click.argument("id", type=int)
184
+ @click.option("--force", is_flag=True, help="Force claim even if owned by another")
185
+ @click.option("--meta", default=None, help="JSON metadata blob for activity log")
186
+ def claim(id, force, meta):
187
+ """Claim a task"""
188
+ _warn_agent_fallback()
189
+ agent = db.AGENT
190
+ with db.get_db() as conn:
191
+ ok, err = db.claim_task(conn, id, agent, force, meta=meta)
192
+ if ok:
193
+ click.echo(f"{G}Claimed #{id}{N}")
194
+ else:
195
+ click.echo(f"{R}{err}{N}", err=True)
196
+ sys.exit(1)
197
+
198
+
199
+ @cli.command()
200
+ @click.argument("id", type=int)
201
+ @click.option("--meta", default=None, help="JSON metadata blob for activity log")
202
+ def start(id, meta):
203
+ """Begin work on a task"""
204
+ _warn_agent_fallback()
205
+ agent = db.AGENT
206
+ with db.get_db() as conn:
207
+ ok, err = db.start_task(conn, id, agent, meta=meta)
208
+ if ok:
209
+ click.echo(f"{C}▶ Working on #{id}{N}")
210
+ else:
211
+ click.echo(f"{R}{err}{N}", err=True)
212
+ sys.exit(1)
213
+
214
+
215
+ @cli.command()
216
+ @click.argument("id", type=int)
217
+ @click.option("-m", "--message", "note", default="", help="Completion note")
218
+ @click.option("--force", is_flag=True, help="Complete even if not the owner")
219
+ @click.option("--meta", default=None, help="JSON metadata blob for activity log")
220
+ def done(id, note, force, meta):
221
+ """Complete a task"""
222
+ _warn_agent_fallback()
223
+ agent = db.AGENT
224
+ with db.get_db() as conn:
225
+ ok, info = db.complete_task(conn, id, agent, note, meta=meta, force=force)
226
+ if not ok:
227
+ click.echo(f"{R}{info}{N}", err=True)
228
+ sys.exit(1)
229
+ if info == "already done":
230
+ click.echo(f"{Y}#{id} already done{N}")
231
+ else:
232
+ msg = f"{G}✓ Done #{id}{N}"
233
+ if note:
234
+ msg += f" — {note}"
235
+ click.echo(msg)
236
+
237
+
238
+ @cli.command()
239
+ @click.argument("id", type=int)
240
+ @click.option("--meta", default=None, help="JSON metadata blob for activity log")
241
+ def review(id, meta):
242
+ """Mark a task as ready for review"""
243
+ _warn_agent_fallback()
244
+ agent = db.AGENT
245
+ with db.get_db() as conn:
246
+ ok, err = db.review_task(conn, id, agent, meta=meta)
247
+ if ok:
248
+ click.echo(f"{C}⟳ #{id} ready for review{N}")
249
+ else:
250
+ click.echo(f"{R}{err}{N}", err=True)
251
+ sys.exit(1)
252
+
253
+
254
+ @cli.command()
255
+ @click.argument("id", type=int)
256
+ @click.option("--meta", default=None, help="JSON metadata blob for activity log")
257
+ def cancel(id, meta):
258
+ """Cancel a task"""
259
+ _warn_agent_fallback()
260
+ agent = db.AGENT
261
+ with db.get_db() as conn:
262
+ ok, info = db.cancel_task(conn, id, agent, meta=meta)
263
+ if not ok:
264
+ click.echo(f"{R}{info}{N}", err=True)
265
+ sys.exit(1)
266
+ if info and info.startswith("already"):
267
+ click.echo(f"{Y}#{id} {info}{N}")
268
+ else:
269
+ click.echo(f"{Y}✗ Cancelled #{id}{N}")
270
+
271
+
272
+ @cli.command()
273
+ @click.argument("id", type=int)
274
+ @click.option("--by", "blocked_by", type=int, required=True, help="Blocking task ID")
275
+ @click.option("--meta", default=None, help="JSON metadata blob for activity log")
276
+ def block(id, blocked_by, meta):
277
+ """Mark a task as blocked by another task"""
278
+ with db.get_db() as conn:
279
+ ok, err = db.block_task(conn, id, blocked_by, meta=meta)
280
+ if ok:
281
+ click.echo(f"{R}✗ #{id} blocked by #{blocked_by}{N}")
282
+ else:
283
+ click.echo(f"{R}{err}{N}", err=True)
284
+ sys.exit(1)
285
+
286
+
287
+ @cli.command()
288
+ def board():
289
+ """Kanban board view"""
290
+ agent = db.AGENT
291
+ click.echo(f"{B}═══ CLAWCTL ═══{N} agent: {C}{agent}{N}")
292
+ click.echo()
293
+ icons = {
294
+ "pending": "○",
295
+ "claimed": "◉",
296
+ "in_progress": "▶",
297
+ "review": "⟳",
298
+ "blocked": "✗",
299
+ "done": "✓",
300
+ }
301
+ with db.get_db() as conn:
302
+ board_data = db.get_board(conn)
303
+ for status in ("pending", "claimed", "in_progress", "review", "blocked", "done"):
304
+ if status not in board_data:
305
+ continue
306
+ info = board_data[status]
307
+ icon = icons[status]
308
+ click.echo(f"{B}── {icon} {status} ({info['count']}) ──{N}")
309
+ for t in info["tasks"]:
310
+ owner_str = f" [{t['owner']}]" if t["owner"] else ""
311
+ click.echo(f" #{t['id']} {t['subject']}{owner_str}")
312
+ click.echo()
313
+
314
+
315
+ @cli.command()
316
+ @click.argument("to")
317
+ @click.argument("body")
318
+ @click.option("--task", "task_id", type=int, default=None, help="Related task ID")
319
+ @click.option("--type", "msg_type", default="comment", help="Message type")
320
+ def msg(to, body, task_id, msg_type):
321
+ """Send a message to an agent"""
322
+ with db.get_db() as conn:
323
+ db.send_message(conn, db.AGENT, to, body, task_id, msg_type)
324
+ click.echo(f"{G}→ {to}{N}: {body}")
325
+
326
+
327
+ @cli.command()
328
+ @click.argument("body")
329
+ def broadcast(body):
330
+ """Broadcast a message to all agents"""
331
+ with db.get_db() as conn:
332
+ db.broadcast(conn, db.AGENT, body)
333
+ click.echo(f"{Y}Broadcast:{N} {body}")
334
+
335
+
336
+ @cli.command()
337
+ @click.option("--unread", is_flag=True, help="Show only unread messages")
338
+ def inbox(unread):
339
+ """Read messages"""
340
+ agent = db.AGENT
341
+ with db.get_db() as conn:
342
+ rows = db.get_inbox(conn, agent, unread)
343
+ if rows:
344
+ displayed_ids = [row["id"] for row in rows]
345
+ db.mark_messages_read(conn, agent, displayed_ids)
346
+ if not rows:
347
+ click.echo("No messages.")
348
+ return
349
+ print_columnar(
350
+ rows,
351
+ [
352
+ ("ID", "id"),
353
+ ("From", "from_agent"),
354
+ ("Body", "body"),
355
+ ("Type", "msg_type"),
356
+ ("New", "new"),
357
+ ("At", "at"),
358
+ ],
359
+ )
360
+
361
+
362
+ @cli.command()
363
+ def fleet():
364
+ """Show fleet status"""
365
+ click.echo(f"{B}═══ FLEET STATUS ═══{N}")
366
+ with db.get_db() as conn:
367
+ rows = db.get_fleet(conn)
368
+ if not rows:
369
+ click.echo("No agents registered.")
370
+ return
371
+ print_columnar(
372
+ rows,
373
+ [
374
+ ("Name", "name"),
375
+ ("Role", "role"),
376
+ ("Status", "status"),
377
+ ("Working On", "working_on"),
378
+ ("Last Seen", "last_seen"),
379
+ ],
380
+ )
381
+
382
+
383
+ @cli.command()
384
+ @click.option("--last", "limit", type=int, default=20, help="Number of entries")
385
+ @click.option("--agent", "agent_filter", default=None, help="Filter by agent")
386
+ @click.option("--meta", "show_meta", is_flag=True, help="Show metadata column")
387
+ def feed(limit, agent_filter, show_meta):
388
+ """Activity log"""
389
+ with db.get_db() as conn:
390
+ rows = db.get_feed(conn, limit, agent_filter)
391
+ if not rows:
392
+ click.echo("No activity.")
393
+ return
394
+ cols = [
395
+ ("At", "at"),
396
+ ("Agent", "agent"),
397
+ ("Action", "action"),
398
+ ("Detail", "detail"),
399
+ ]
400
+ if show_meta:
401
+ cols.append(("Meta", "meta"))
402
+ print_columnar(rows, cols)
403
+
404
+
405
+ @cli.command()
406
+ def summary():
407
+ """Fleet summary"""
408
+ with db.get_db() as conn:
409
+ data = db.get_summary(conn)
410
+ click.echo(f"{B}═══ SUMMARY ═══{N}")
411
+ click.echo()
412
+ click.echo(f"{C}Fleet:{N}")
413
+ for a in data["agents"]:
414
+ role_str = f" — {a['role']}" if a["role"] else ""
415
+ click.echo(f" {a['name']} ({a['status']}){role_str}")
416
+ click.echo()
417
+ click.echo(f"{C}Open tasks:{N} {data['open']}")
418
+ click.echo(f"{C}In progress:{N} {data['in_progress']}")
419
+ click.echo(f"{C}Blocked:{N} {data['blocked']}")
420
+ click.echo()
421
+ click.echo(f"{C}Last 5 events:{N}")
422
+ for e in data["recent"]:
423
+ detail_str = f" — {e['detail']}" if e["detail"] else ""
424
+ click.echo(f" [{e['at']}] {e['agent']}: {e['action']}{detail_str}")
425
+
426
+
427
+ @cli.command()
428
+ def whoami():
429
+ """Show agent identity"""
430
+ agent = db.AGENT
431
+ click.echo(f"Agent: {C}{agent}{N}")
432
+ click.echo(f"DB: {db.DB_PATH}")
433
+ with db.get_db() as conn:
434
+ role = db.get_agent_info(conn, agent)
435
+ click.echo(f"Role: {role}")
436
+
437
+
438
+ @cli.command()
439
+ @click.option("--port", default=3737, help="Port to run on")
440
+ @click.option("--stop", is_flag=True, help="Stop the running dashboard")
441
+ @click.option(
442
+ "--verbose", is_flag=True, help="Log dashboard output to ~/.openclaw/dashboard.log"
443
+ )
444
+ def dashboard(port, stop, verbose):
445
+ """Start (or stop) the web dashboard"""
446
+ pid_file = Path(db.DB_PATH).parent / ".dashboard.pid"
447
+
448
+ if stop:
449
+ if not pid_file.exists():
450
+ click.echo("No dashboard running.")
451
+ return
452
+ pid = int(pid_file.read_text().strip())
453
+ try:
454
+ os.kill(pid, signal.SIGTERM)
455
+ click.echo(f"{G}Dashboard stopped{N} (pid {pid})")
456
+ except ProcessLookupError:
457
+ click.echo(f"{Y}Dashboard was not running{N} (stale pid {pid})")
458
+ pid_file.unlink(missing_ok=True)
459
+ return
460
+
461
+ if pid_file.exists():
462
+ pid = int(pid_file.read_text().strip())
463
+ try:
464
+ os.kill(pid, 0)
465
+ from dashboard.server import load_or_create_token
466
+
467
+ token = load_or_create_token()
468
+ click.echo(f"{Y}Dashboard already running{N} (pid {pid})")
469
+ click.echo(f"\n {C}http://localhost:{port}/?token={token}{N}\n")
470
+ return
471
+ except ProcessLookupError:
472
+ pid_file.unlink(missing_ok=True)
473
+
474
+ # Ensure token exists before starting (server creates it on import)
475
+ from dashboard.server import load_or_create_token
476
+
477
+ token = load_or_create_token()
478
+
479
+ if verbose:
480
+ log_path = Path(db.DB_PATH).parent / "dashboard.log"
481
+ log_file = open(log_path, "a")
482
+ stdout_target = log_file
483
+ stderr_target = log_file
484
+ else:
485
+ log_file = None
486
+ stdout_target = subprocess.DEVNULL
487
+ stderr_target = subprocess.DEVNULL
488
+
489
+ proc = subprocess.Popen(
490
+ [sys.executable, "-m", "dashboard", "--port", str(port)],
491
+ stdout=stdout_target,
492
+ stderr=stderr_target,
493
+ start_new_session=True,
494
+ )
495
+ if log_file:
496
+ log_file.close()
497
+
498
+ pid_file.write_text(str(proc.pid))
499
+ click.echo(f"{G}Dashboard started{N} on port {port} (pid {proc.pid})")
500
+ click.echo(f"\n {C}http://localhost:{port}/?token={token}{N}\n")
501
+ if verbose:
502
+ click.echo(f"Logging to: {log_path}")
503
+ click.echo(f"Stop with: clawctl dashboard --stop")
504
+
505
+
506
+ @cli.command("help")
507
+ @click.pass_context
508
+ def help_cmd(ctx):
509
+ """Show help"""
510
+ click.echo(ctx.parent.get_help())