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 +2 -0
- clawctl/cli.py +510 -0
- clawctl/db.py +473 -0
- clawctl/schema.sql +68 -0
- clawctl-0.2.0.dist-info/METADATA +273 -0
- clawctl-0.2.0.dist-info/RECORD +14 -0
- clawctl-0.2.0.dist-info/WHEEL +5 -0
- clawctl-0.2.0.dist-info/entry_points.txt +2 -0
- clawctl-0.2.0.dist-info/licenses/LICENSE +21 -0
- clawctl-0.2.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +0 -0
- dashboard/__main__.py +3 -0
- dashboard/index.html +1464 -0
- dashboard/server.py +166 -0
clawctl/__init__.py
ADDED
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())
|