visualagents 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.
visualagents/cli.py ADDED
@@ -0,0 +1,726 @@
1
+ """`visualagents` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import threading
11
+ import webbrowser
12
+ from importlib import resources
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import __version__
17
+ from . import blueprint as bpmod
18
+ from . import termui
19
+ from .hub import Hub
20
+ from .llm import PROVIDERS, LLMError, detect_provider, load_dotenv_keys
21
+ from .server import Server
22
+
23
+ DEFAULT_PORT = 7420
24
+ DAEMON_PORT = 7400
25
+ _VALUE_OPTS = {"--port", "--host", "--provider", "--model", "--narrate-model", "--blueprint", "--cwd", "--interval"}
26
+
27
+ EPILOG = """examples:
28
+ visualagents demo see it work in 10 seconds, no API key needed
29
+ visualagents run ./my-agent start a project and watch it (asks how, once)
30
+ visualagents hook ./my-agent once: then run it however you like (IDE, scripts...)
31
+ visualagents scan ./my-agent (re)map the agents in a project
32
+ visualagents run ./my-agent -- python main.py "q" run an exact command
33
+ python main.py 2>&1 | visualagents pipe ./my-agent monitor output you already have
34
+ """
35
+
36
+
37
+ def _server_opts(p: argparse.ArgumentParser) -> None:
38
+ p.add_argument("--port", type=int, default=int(os.environ.get("VISUALAGENTS_PORT", DEFAULT_PORT)),
39
+ help=f"dashboard port (default {DEFAULT_PORT}; next free port is used if taken)")
40
+ p.add_argument("--host", default="127.0.0.1", help="interface to bind (default 127.0.0.1)")
41
+ p.add_argument("--no-browser", action="store_true", help="don't open the dashboard in a browser")
42
+ p.add_argument("--blueprint", type=Path, help="use this blueprint.json instead of the project's")
43
+ p.add_argument("--exit", action="store_true", help="exit when the input ends instead of keeping the dashboard open")
44
+
45
+
46
+ def _scan_opts(p: argparse.ArgumentParser, *, standalone: bool = False) -> None:
47
+ g = p.add_mutually_exclusive_group()
48
+ g.add_argument("--static", action="store_true", help="offline analysis only (no LLM)")
49
+ g.add_argument("--llm", action="store_true", help="require an LLM pass (fail if no key is configured)")
50
+ p.add_argument("--provider", default="auto", choices=["auto", *PROVIDERS],
51
+ help="LLM provider (default: $VISUALAGENTS_PROVIDER, else whichever has a key)")
52
+ p.add_argument("--model", help="model that reads the code and draws the map (default: $VISUALAGENTS_MODEL, "
53
+ "else claude-opus-5-5 / gpt-5 / gemini-pro-latest) - see `visualagents models`")
54
+ if standalone:
55
+ p.add_argument("--max-chars", type=int, default=180_000, help="source budget sent to the LLM")
56
+ else:
57
+ p.add_argument("--rescan", action="store_true", help="rebuild the blueprint before starting")
58
+
59
+
60
+ def build_parser() -> argparse.ArgumentParser:
61
+ parser = argparse.ArgumentParser(
62
+ prog="visualagents",
63
+ description="A live map of which agent in a multi-agent AI system is doing what.",
64
+ epilog=EPILOG,
65
+ formatter_class=argparse.RawDescriptionHelpFormatter,
66
+ )
67
+ parser.add_argument("--version", action="version", version=f"visualagents {__version__}")
68
+ sub = parser.add_subparsers(dest="cmd", metavar="command")
69
+
70
+ p = sub.add_parser("scan", help="read a codebase and write .visualagents/blueprint.json")
71
+ p.add_argument("project", nargs="?", default=".", type=Path)
72
+ _scan_opts(p, standalone=True)
73
+ p.add_argument("--json", action="store_true", help="print the blueprint as JSON")
74
+ p.add_argument("-o", "--output", type=Path, help="write the blueprint here instead")
75
+
76
+ p = sub.add_parser("blueprint", help="print a project's blueprint in the terminal")
77
+ p.add_argument("project", nargs="?", default=".", type=Path)
78
+ p.add_argument("--json", action="store_true")
79
+
80
+ p = sub.add_parser("show", help="open the dashboard with just the blueprint")
81
+ p.add_argument("project", nargs="?", default=".", type=Path)
82
+ _server_opts(p)
83
+ _scan_opts(p)
84
+
85
+ p = sub.add_parser("run", help="start a project and map it live: run [folder] [-- command]",
86
+ description="Start a project with live mapping. Without a command, VisualAgents finds how "
87
+ "the project starts (README, start scripts, app files), asks once and remembers.")
88
+ p.add_argument("project", nargs="?", default=None, type=Path, help="project folder (default: current folder)")
89
+ _server_opts(p)
90
+ _scan_opts(p)
91
+ p.add_argument("--cwd", type=Path, help="working directory for the command (default: the project folder)")
92
+ p.add_argument("--smart", action="store_true", help="also use an LLM to narrate what each agent is doing")
93
+ p.add_argument("--narrate-model", help="model for --smart narration (default: $VISUALAGENTS_NARRATE_MODEL, "
94
+ "else the provider's fast default - see `visualagents models`)")
95
+ p.add_argument("--interval", type=float, default=4.0, help="seconds between --smart narrations")
96
+ p.add_argument("--no-instrument", action="store_true", help="don't auto-hook LangChain/LangGraph/OpenAI Agents")
97
+ p.add_argument("--no-pty", action="store_true", help="use plain pipes instead of a pseudo-terminal")
98
+ p.add_argument("--no-record", action="store_true", help="don't save the run under .visualagents/runs/")
99
+ p.add_argument("--pick", action="store_true", help="choose the start command again")
100
+ p.add_argument("--no-follow-logs", action="store_true", help="don't follow *.log files the app writes")
101
+
102
+ p = sub.add_parser("hook", help="hook a folder once - then run its agent however you normally do",
103
+ description="Install a startup hook into the folder's virtualenv(s) so every Python process "
104
+ "from them reports to the background dashboard (`visualagents up`).")
105
+ p.add_argument("project", nargs="?", default=".", type=Path)
106
+ p.add_argument("--python", type=Path, action="append", default=[],
107
+ help="also hook this interpreter (e.g. a conda env's python); repeatable")
108
+ p.add_argument("--no-scan", action="store_true", help="don't build a blueprint now")
109
+ p.add_argument("--no-daemon", action="store_true", help="don't start the background dashboard")
110
+ p.add_argument("--port", type=int, default=DAEMON_PORT)
111
+ p.add_argument("--no-browser", action="store_true")
112
+ _scan_opts(p)
113
+
114
+ p = sub.add_parser("unhook", help="remove the startup hook from a folder's virtualenv(s)")
115
+ p.add_argument("project", nargs="?", default=".", type=Path)
116
+
117
+ p = sub.add_parser("up", help="start the background dashboard for hooked projects")
118
+ p.add_argument("--port", type=int, default=DAEMON_PORT)
119
+ p.add_argument("--host", default="127.0.0.1")
120
+ p.add_argument("--no-browser", action="store_true")
121
+ p.add_argument("--foreground", action="store_true", help="stay in this terminal instead of the background")
122
+
123
+ sub.add_parser("down", help="stop the background dashboard")
124
+ sub.add_parser("status", help="show the background dashboard and hooked projects")
125
+
126
+ p = sub.add_parser("open", help="open the dashboard for a hooked project")
127
+ p.add_argument("project", nargs="?", default=".", type=Path)
128
+
129
+ p = sub.add_parser("pipe", help="map output piped on stdin: python app.py 2>&1 | visualagents pipe")
130
+ p.add_argument("project", nargs="?", default=".", type=Path)
131
+ _server_opts(p)
132
+ _scan_opts(p)
133
+ p.add_argument("--quiet", action="store_true", help="don't echo the piped output")
134
+ p.add_argument("--no-record", action="store_true")
135
+
136
+ p = sub.add_parser("watch", help="follow a log file: watch [project] --log agent.log")
137
+ p.add_argument("project", nargs="?", default=".", type=Path)
138
+ p.add_argument("--log", type=Path, required=True, help="log file to follow")
139
+ p.add_argument("--from-start", action="store_true", help="read the whole file, not only new lines")
140
+ p.add_argument("--echo", action="store_true", help="print followed lines in this terminal too")
141
+ _server_opts(p)
142
+ _scan_opts(p)
143
+ p.add_argument("--no-record", action="store_true")
144
+
145
+ p = sub.add_parser("replay", help="replay a recorded run from .visualagents/runs/")
146
+ p.add_argument("recording", type=Path)
147
+ p.add_argument("--project", type=Path, help="use this project's current blueprint instead of the recorded one")
148
+ p.add_argument("--speed", type=float, default=1.0)
149
+ p.add_argument("--instant", action="store_true", help="jump straight to the end state")
150
+ _server_opts(p)
151
+
152
+ p = sub.add_parser("setup", help="guided start: pick a project, an LLM and a key - then the map pops up "
153
+ "(also what bare `visualagents` does in a terminal)")
154
+ p.add_argument("--port", type=int, default=DEFAULT_PORT)
155
+ p.add_argument("--no-browser", action="store_true")
156
+ p.add_argument("--exit", action="store_true", help=argparse.SUPPRESS)
157
+
158
+ p = sub.add_parser("models", help="show LLM providers, which have keys, and the models you can pick")
159
+ p.add_argument("project", nargs="?", default=".", type=Path, help="also read keys from this project's .env")
160
+ p.add_argument("--provider", choices=list(PROVIDERS), help="list the models this provider's account can use")
161
+
162
+ p = sub.add_parser("demo", help="a simulated 4-agent research crew - no API key needed")
163
+ p.add_argument("--speed", type=float, default=1.0, help="2 = twice as fast")
164
+ _server_opts(p)
165
+ return parser
166
+
167
+
168
+ def _split_run_argv(argv: list[str]) -> tuple[list[str], list[str]]:
169
+ """`run [opts] [folder] [opts] -- cmd...`; without `--`, the command starts at the first token that is
170
+ neither an option nor the project folder."""
171
+ if "--" in argv:
172
+ i = argv.index("--")
173
+ return argv[:i], argv[i + 1:]
174
+ if not argv or argv[0] != "run":
175
+ return argv, []
176
+ ours, rest, i, have_project = ["run"], argv[1:], 0, False
177
+ while i < len(rest):
178
+ tok = rest[i]
179
+ if tok.startswith("-"):
180
+ ours.append(tok)
181
+ if tok in _VALUE_OPTS and i + 1 < len(rest):
182
+ ours.append(rest[i + 1])
183
+ i += 1
184
+ elif not have_project and Path(tok).is_dir():
185
+ ours.append(tok)
186
+ have_project = True
187
+ else:
188
+ break
189
+ i += 1
190
+ return ours, rest[i:]
191
+
192
+
193
+ def main(argv: list[str] | None = None) -> int:
194
+ raw = list(sys.argv[1:] if argv is None else argv)
195
+ ours, command = _split_run_argv(raw)
196
+ parser = build_parser()
197
+ if not ours and sys.stdin.isatty() and sys.stderr.isatty():
198
+ ours = ["setup"] # bare `visualagents` in a terminal: the guided start
199
+ args = parser.parse_args(ours)
200
+ if not args.cmd:
201
+ parser.print_help()
202
+ return 0
203
+ try:
204
+ return {
205
+ "scan": cmd_scan, "blueprint": cmd_blueprint, "show": cmd_show, "run": cmd_run, "pipe": cmd_pipe,
206
+ "watch": cmd_watch, "replay": cmd_replay, "demo": cmd_demo, "models": cmd_models,
207
+ "hook": cmd_hook, "unhook": cmd_unhook, "up": cmd_up, "down": cmd_down, "status": cmd_status,
208
+ "open": cmd_open, "setup": cmd_setup,
209
+ }[args.cmd](args, command)
210
+ except LLMError as exc:
211
+ termui.error(str(exc))
212
+ return 2
213
+ except KeyboardInterrupt:
214
+ print(file=sys.stderr)
215
+ return 130
216
+
217
+
218
+ # -- blueprint helpers --------------------------------------------------------
219
+
220
+ def _scan(project: Path, args: argparse.Namespace, **kw: Any) -> dict[str, Any]:
221
+ from .scanner import scan_project
222
+
223
+ mode = "static" if getattr(args, "static", False) else "llm" if getattr(args, "llm", False) else "auto"
224
+ return scan_project(project, mode=mode, provider=getattr(args, "provider", "auto"),
225
+ model=getattr(args, "model", None), log=termui.info, **kw)
226
+
227
+
228
+ def _project_blueprint(project: Path, args: argparse.Namespace) -> dict[str, Any]:
229
+ if getattr(args, "blueprint", None):
230
+ return bpmod.load(args.blueprint)
231
+ path = bpmod.blueprint_path(project)
232
+ if path.exists() and not getattr(args, "rescan", False):
233
+ return bpmod.load(path)
234
+ if not path.exists():
235
+ termui.info(f"No blueprint yet for {project} - scanning the code first.")
236
+ bp = _scan(project, args)
237
+ try:
238
+ bpmod.save(bp, path)
239
+ termui.info(f"Saved {path}")
240
+ except OSError as exc:
241
+ termui.info(f"Could not save the blueprint ({exc}); continuing.")
242
+ return bp
243
+
244
+
245
+ def _record_dir(project: Path, args: argparse.Namespace) -> Path | None:
246
+ if getattr(args, "no_record", False):
247
+ return None
248
+ d = bpmod.state_dir(project) / "runs"
249
+ try:
250
+ d.mkdir(parents=True, exist_ok=True)
251
+ return d
252
+ except OSError:
253
+ return None
254
+
255
+
256
+ def _serve(hub: Hub, args: argparse.Namespace, extra: str = "") -> Server:
257
+ server = Server(hub, host=args.host, port=args.port).start()
258
+ termui.banner(server.url, hub.blueprint, extra)
259
+ if not args.no_browser and not os.environ.get("VISUALAGENTS_NO_BROWSER"):
260
+ threading.Timer(0.4, lambda: webbrowser.open(server.url)).start()
261
+ return server
262
+
263
+
264
+ def _hold(server: Server, message: str) -> None:
265
+ termui.info(message + f" Dashboard stays live at {server.url} - press Ctrl+C to close.")
266
+ try:
267
+ threading.Event().wait()
268
+ except KeyboardInterrupt:
269
+ pass
270
+
271
+
272
+ # -- commands ----------------------------------------------------------------
273
+
274
+ def cmd_scan(args, _command) -> int:
275
+ project = args.project.resolve()
276
+ if not project.exists():
277
+ termui.error(f"{project} does not exist")
278
+ return 2
279
+ bp = _scan(project, args, max_chars=args.max_chars)
280
+ out = args.output or bpmod.blueprint_path(project if project.is_dir() else project.parent)
281
+ bpmod.save(bp, out)
282
+ if args.json:
283
+ print(json.dumps(bp, indent=2, ensure_ascii=False))
284
+ else:
285
+ termui.print_blueprint(bp)
286
+ termui.info(f"Saved {out}")
287
+ return 0
288
+
289
+
290
+ def cmd_blueprint(args, _command) -> int:
291
+ path = bpmod.blueprint_path(args.project.resolve())
292
+ if not path.exists():
293
+ termui.error(f"no blueprint at {path} - run `visualagents scan {args.project}` first")
294
+ return 1
295
+ bp = bpmod.load(path)
296
+ print(json.dumps(bp, indent=2, ensure_ascii=False)) if args.json else termui.print_blueprint(bp)
297
+ return 0
298
+
299
+
300
+ def cmd_show(args, _command) -> int:
301
+ project = args.project.resolve()
302
+ bp = _project_blueprint(project, args)
303
+ from .launcher import Launcher
304
+
305
+ hub = Hub(bp, record_dir=_record_dir(project, args), meta={"mode": "blueprint", "project": str(project)})
306
+ server = _serve(hub, args, "press ▶ Start in the dashboard (or Ctrl+C) - the agent runs in this terminal")
307
+ Launcher(project, hub, server.url)
308
+ _hold(server, "Showing the map.")
309
+ return 0
310
+
311
+
312
+ def cmd_run(args, command: list[str]) -> int:
313
+ from .runner import install_sigterm_handler, run_command
314
+
315
+ from . import detect
316
+
317
+ project = (args.project or Path.cwd()).resolve()
318
+ if not project.is_dir():
319
+ termui.error(f"{project} is not a folder")
320
+ return 2
321
+ cwd = args.cwd.resolve() if args.cwd else project
322
+ explicit = bool(command)
323
+ if command:
324
+ if command[0].endswith(".py") and not shutil.which(command[0]) and (cwd / command[0]).exists():
325
+ command = [shutil.which("python3") or shutil.which("python") or sys.executable, *command]
326
+ else:
327
+ cand = None if args.pick else detect.load_saved(project)
328
+ if cand is None:
329
+ cand = _choose_start_command(project)
330
+ if cand is None:
331
+ return 2
332
+ saved = detect.save_choice(project, cand)
333
+ termui.info(f"Saved to {saved.relative_to(project)} - next time just run: visualagents run {args.project or '.'}")
334
+ command, notes = detect.resolve(project, cand)
335
+ cwd = (project / cand.cwd).resolve() if not args.cwd else cwd
336
+ for note in notes:
337
+ termui.info(note)
338
+ bp = _project_blueprint(project, args)
339
+ hub = Hub(bp, record_dir=_record_dir(project, args),
340
+ meta={"mode": "run", "project": str(project), "command": " ".join(command)})
341
+ server = _serve(hub, args, f"running: {' '.join(command)}")
342
+ from .launcher import Launcher
343
+
344
+ exact = detect.Candidate(list(command), os.path.relpath(cwd, project)) if explicit else None
345
+ Launcher(project, hub, server.url, instrument=not args.no_instrument, fixed=exact) # "Run again" from the dashboard
346
+
347
+ narrator = None
348
+ if args.smart:
349
+ load_dotenv_keys(project)
350
+ provider = detect_provider(args.provider)
351
+ if provider is None:
352
+ termui.info("--smart needs ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY - continuing without narration.")
353
+ else:
354
+ from .narrator import Narrator
355
+
356
+ from .llm import default_model
357
+
358
+ model = args.narrate_model or default_model(provider, "narrate")
359
+ termui.info(f"Smart narration: {provider}:{model} every {args.interval:g}s while output changes.")
360
+ narrator = Narrator(hub, provider=provider, model=model, interval=args.interval).start()
361
+
362
+ install_sigterm_handler()
363
+ stop_logs = threading.Event()
364
+ if not args.no_follow_logs:
365
+ import time
366
+
367
+ from .runner import follow_new_logs
368
+
369
+ threading.Thread(target=follow_new_logs, args=(project, hub), daemon=True,
370
+ kwargs={"since": time.time() - 1, "stop": stop_logs}).start()
371
+ code = run_command(command, hub, cwd=cwd, server_url=server.url,
372
+ instrument=not args.no_instrument, use_pty=False if args.no_pty else None)
373
+ if narrator:
374
+ narrator.stop.set()
375
+ hub.close()
376
+ if hub.record_path:
377
+ termui.info(f"Recorded to {hub.record_path} (replay: visualagents replay {hub.record_path})")
378
+ if not args.exit:
379
+ status = "finished" if code == 0 else f"exited with code {code}"
380
+ _hold(server, f"Run {status}. Anything it left running in the background keeps reporting here.")
381
+ stop_logs.set()
382
+ server.stop()
383
+ return code
384
+
385
+
386
+ def _choose_start_command(project: Path, force_menu: bool = False):
387
+ from . import detect, tui
388
+
389
+ cands = detect.detect(project)
390
+ if not cands and not force_menu:
391
+ termui.error(f"Couldn't work out how to start {project.name}. Give the command once and it is remembered:\n"
392
+ f" visualagents run {project} -- python main.py")
393
+ return None
394
+ if not tui.interactive() and not force_menu:
395
+ termui.info(f"Starting with: {cands[0].label()} ({cands[0].source}) - use --pick to choose another")
396
+ return cands[0]
397
+ tui.heading("▶", f"How should {project.name} start?")
398
+ shown = cands[:9]
399
+ options = [(c.label(), c.source) for c in shown] + [("Type a command…", "")]
400
+ idx = tui.select("", options)
401
+ if idx < len(shown):
402
+ return shown[idx]
403
+ import shlex
404
+
405
+ text = tui.ask("Command")
406
+ where = tui.ask("Run it in which subfolder", ".")
407
+ return detect.Candidate(shlex.split(text), where, "typed") if text else None
408
+
409
+
410
+ def cmd_pipe(args, _command) -> int:
411
+ from .runner import read_stream
412
+
413
+ project = args.project.resolve()
414
+ bp = _project_blueprint(project, args)
415
+ hub = Hub(bp, record_dir=_record_dir(project, args), meta={"mode": "pipe", "project": str(project)})
416
+ server = _serve(hub, args, "reading stdin")
417
+ read_stream(None, hub, echo=not args.quiet)
418
+ hub.close()
419
+ if not args.exit:
420
+ _hold(server, "Input ended.")
421
+ server.stop()
422
+ return 0
423
+
424
+
425
+ def cmd_watch(args, _command) -> int:
426
+ from .runner import follow_file
427
+
428
+ project = args.project.resolve()
429
+ bp = _project_blueprint(project, args)
430
+ hub = Hub(bp, record_dir=_record_dir(project, args),
431
+ meta={"mode": "watch", "project": str(project), "log": str(args.log)})
432
+ server = _serve(hub, args, f"following {args.log}")
433
+ stop = threading.Event()
434
+ try:
435
+ follow_file(args.log.resolve(), hub, from_start=args.from_start, echo=args.echo, stop=stop)
436
+ except KeyboardInterrupt:
437
+ stop.set()
438
+ hub.close()
439
+ server.stop()
440
+ return 0
441
+
442
+
443
+ def cmd_replay(args, _command) -> int:
444
+ from .runner import read_recording_meta, replay
445
+
446
+ meta = read_recording_meta(args.recording)
447
+ if args.blueprint:
448
+ bp = bpmod.load(args.blueprint)
449
+ elif args.project:
450
+ bp = _project_blueprint(args.project.resolve(), args)
451
+ elif meta.get("blueprint"):
452
+ bp = bpmod.normalize(meta["blueprint"])
453
+ else:
454
+ termui.error("this recording has no blueprint - pass --project or --blueprint")
455
+ return 2
456
+ hub = Hub(bp, meta={"mode": "replay", "recording": str(args.recording), **(meta.get("meta") or {})})
457
+ server = _serve(hub, args, f"replaying {args.recording.name} at {args.speed}x")
458
+ stop = threading.Event()
459
+ try:
460
+ replay(args.recording, hub, speed=args.speed, instant=args.instant, stop=stop)
461
+ if not args.exit:
462
+ _hold(server, "Replay finished.")
463
+ except KeyboardInterrupt:
464
+ stop.set()
465
+ server.stop()
466
+ return 0
467
+
468
+
469
+ def cmd_models(args, _command) -> int:
470
+ from . import llm
471
+
472
+ load_dotenv_keys(args.project.resolve())
473
+ if args.provider:
474
+ models = llm.list_models(args.provider)
475
+ print("\n".join(models) if models else f"(no models returned for {args.provider})")
476
+ return 0
477
+ active = detect_provider("auto")
478
+ print(f"\n{termui.bold('LLM providers')} (the provider + model that read your code and draw the map)\n")
479
+ for p in PROVIDERS:
480
+ mark = termui.good("● key found") if llm.has_credentials(p) else termui.dim("○ no key")
481
+ chosen = termui.accent(" ← used by default") if p == active else ""
482
+ print(f" {termui.bold(f'{p:<10}')} {llm.PROVIDER_LABELS[p]:<22} {mark}{chosen}")
483
+ print(f" scan: {llm.default_model(p)} --smart: {llm.default_model(p, 'narrate')}")
484
+ print(f" {termui.dim('keys: ' + ' / '.join(llm.KEY_VARS[p]))}")
485
+ print(f"""
486
+ {termui.bold('Choose per command')} visualagents scan . --provider google --model gemini-flash-latest
487
+ visualagents run . --smart --provider google --narrate-model gemini-flash-lite-latest -- python app.py
488
+ {termui.bold('Or set defaults')} VISUALAGENTS_PROVIDER, VISUALAGENTS_MODEL, VISUALAGENTS_NARRATE_MODEL
489
+ (in your shell or the project's .env)
490
+ {termui.bold('See what you can use')} visualagents models --provider google
491
+ """)
492
+ return 0
493
+
494
+
495
+ def _daemon_url(info: dict, project_id: str | None = None) -> str:
496
+ return info["url"] + (f"/?project={project_id}" if project_id else "")
497
+
498
+
499
+ def _ensure_daemon(port: int) -> dict:
500
+ """Background dashboard info, starting it if needed."""
501
+ import subprocess
502
+ import time
503
+
504
+ from . import home
505
+
506
+ info = home.read_daemon()
507
+ if info:
508
+ return info
509
+ log = home.home() / "daemon.log"
510
+ log.parent.mkdir(parents=True, exist_ok=True)
511
+ with open(log, "ab") as fh:
512
+ subprocess.Popen([sys.executable, "-m", "visualagents", "up", "--foreground", "--no-browser", "--port", str(port)],
513
+ stdout=fh, stderr=fh, stdin=subprocess.DEVNULL, start_new_session=True)
514
+ for _ in range(50):
515
+ time.sleep(0.1)
516
+ info = home.read_daemon()
517
+ if info:
518
+ return info
519
+ raise SystemExit(termui.error(f"the background dashboard didn't start - see {log}") or 1)
520
+
521
+
522
+ def cmd_up(args, _command) -> int:
523
+ from . import home
524
+ from .projects import Projects
525
+
526
+ info = home.read_daemon()
527
+ if info and not args.foreground:
528
+ termui.info(f"Dashboard already running at {info['url']}")
529
+ elif not args.foreground:
530
+ info = _ensure_daemon(args.port)
531
+ termui.info(f"Dashboard running in the background at {info['url']} (stop: visualagents down)")
532
+ else:
533
+ if info:
534
+ termui.error(f"a dashboard is already running at {info['url']} (pid {info['pid']})")
535
+ return 1
536
+ from .runner import install_sigterm_handler
537
+
538
+ projects = Projects(daemon=True)
539
+ for pid, meta in home.registry().items():
540
+ if Path(meta.get("path", "")).is_dir():
541
+ try:
542
+ projects.open(Path(meta["path"]))
543
+ except Exception as exc: # a broken project must not take the daemon down
544
+ termui.error(f"skipping {meta['path']}: {exc}")
545
+ server = Server(projects, host=args.host, port=args.port).start()
546
+ home.write_daemon(server.url, projects.token, os.getpid())
547
+ termui.info(f"Dashboard for hooked projects at {server.url} (Ctrl+C to stop)")
548
+ install_sigterm_handler()
549
+ try:
550
+ threading.Event().wait()
551
+ except KeyboardInterrupt:
552
+ pass
553
+ finally:
554
+ home.clear_daemon(os.getpid())
555
+ server.stop()
556
+ return 0
557
+ if not args.no_browser and not os.environ.get("VISUALAGENTS_NO_BROWSER"):
558
+ webbrowser.open(info["url"])
559
+ return 0
560
+
561
+
562
+ def cmd_down(args, _command) -> int:
563
+ import signal
564
+ import time
565
+
566
+ from . import home
567
+
568
+ info = home.read_daemon()
569
+ if not info:
570
+ termui.info("No background dashboard is running.")
571
+ return 0
572
+ os.kill(int(info["pid"]), signal.SIGTERM)
573
+ for _ in range(30):
574
+ time.sleep(0.1)
575
+ if not home.read_daemon():
576
+ break
577
+ home.clear_daemon(int(info["pid"]))
578
+ termui.info("Stopped the background dashboard.")
579
+ return 0
580
+
581
+
582
+ def cmd_status(args, _command) -> int:
583
+ from . import home, hooks
584
+
585
+ info = home.read_daemon()
586
+ print(f"\n{termui.bold('Dashboard')} " + (termui.good(f"running at {info['url']}") if info
587
+ else termui.dim("not running (visualagents up)")))
588
+ projects = home.registry()
589
+ print(f"\n{termui.bold('Hooked projects')} ({len(projects)})")
590
+ for pid, meta in projects.items():
591
+ print(f" {termui.accent('●')} {termui.bold(pid)} {meta.get('path')}")
592
+ for site in meta.get("envs", []):
593
+ ok = hooks.hooked_project(Path(site)) is not None
594
+ print(f" {termui.good('hooked') if ok else termui.bad('missing')} {site}")
595
+ if not projects:
596
+ print(termui.dim(" none yet - visualagents hook <folder>"))
597
+ print()
598
+ return 0
599
+
600
+
601
+ def cmd_hook(args, _command) -> int:
602
+ from . import detect, home, hooks
603
+
604
+ project = args.project.resolve()
605
+ if not project.is_dir():
606
+ termui.error(f"{project} is not a folder")
607
+ return 2
608
+ pythons = [detect.venv_bin(v, "python") for v in detect.find_venvs(project)] + [p.expanduser() for p in args.python]
609
+ pythons = [p for p in pythons if p.exists()]
610
+ if not pythons:
611
+ termui.error(f"No virtualenv found in {project}.\n"
612
+ f" Create one: python3 -m venv {project}/.venv && {project}/.venv/bin/pip install -r requirements.txt\n"
613
+ f" or name another: visualagents hook {args.project} --python /path/to/env/bin/python\n"
614
+ f" or skip hooking: visualagents run {args.project}")
615
+ return 2
616
+ projects = home.registry()
617
+ pid = home.project_id(project, projects)
618
+ envs = []
619
+ for py in pythons:
620
+ try:
621
+ target = hooks.install(py, project)
622
+ except Exception as exc:
623
+ termui.error(f"could not hook {py}: {exc}")
624
+ continue
625
+ envs.append(str(target.parent))
626
+ termui.info(f"Hooked {py} -> {target}")
627
+ if not envs:
628
+ return 1
629
+ projects[pid] = {"path": str(project), "envs": sorted(set(envs) | set(projects.get(pid, {}).get("envs", [])))}
630
+ home.save_registry(projects)
631
+ if not args.no_scan and not bpmod.blueprint_path(project).exists():
632
+ try:
633
+ bpmod.save(_scan(project, args), bpmod.blueprint_path(project))
634
+ except LLMError as exc:
635
+ termui.error(f"scan failed ({exc}); the dashboard will use a quick static map")
636
+ url = None
637
+ if not args.no_daemon:
638
+ info = _ensure_daemon(args.port)
639
+ url = _daemon_url(info, pid)
640
+ _notify_daemon(info, project)
641
+ example = detect.load_saved(project) or next(iter(detect.detect(project)), None)
642
+ print(f"""
643
+ {termui.good('✔')} {termui.bold(project.name)} is hooked. Start it however you normally do{' - for example:' if example else '.'}
644
+ {(' ' + example.label()) if example else ''}
645
+ Every Python process from {'its virtualenv' if len(envs) == 1 else 'its virtualenvs'} appears on the dashboard once it calls an LLM.
646
+ Dashboard: {url or 'visualagents up'} Undo: visualagents unhook {args.project}
647
+ """, file=sys.stderr)
648
+ if url and not args.no_browser and not os.environ.get("VISUALAGENTS_NO_BROWSER"):
649
+ webbrowser.open(url)
650
+ return 0
651
+
652
+
653
+ def _notify_daemon(info: dict, project: Path) -> None:
654
+ """Let a running dashboard know about a newly hooked project right away."""
655
+ import json
656
+ import urllib.request
657
+
658
+ req = urllib.request.Request(info["url"] + "/api/ingest", method="POST",
659
+ data=json.dumps([{"type": "hello", "project": str(project)}]).encode(),
660
+ headers={"Content-Type": "application/json", "X-VisualAgents-Token": info["token"]})
661
+ try:
662
+ urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=5).read()
663
+ except Exception:
664
+ pass
665
+
666
+
667
+ def cmd_unhook(args, _command) -> int:
668
+ from . import detect, home, hooks
669
+
670
+ project = args.project.resolve()
671
+ projects = home.registry()
672
+ pid = home.project_id(project, projects)
673
+ sites = set(projects.get(pid, {}).get("envs", []))
674
+ for v in detect.find_venvs(project):
675
+ try:
676
+ sites.add(str(hooks.site_packages(detect.venv_bin(v, "python"))))
677
+ except Exception:
678
+ pass
679
+ removed = [s for s in sites if hooks.uninstall(Path(s))]
680
+ projects.pop(pid, None)
681
+ home.save_registry(projects)
682
+ for s in removed:
683
+ termui.info(f"Removed hook from {s}")
684
+ if not removed:
685
+ termui.info(f"{project.name} had no hooks installed.")
686
+ return 0
687
+
688
+
689
+ def cmd_open(args, _command) -> int:
690
+ from . import home
691
+
692
+ project = args.project.resolve()
693
+ info = _ensure_daemon(DAEMON_PORT)
694
+ url = _daemon_url(info, home.project_id(project))
695
+ termui.info(f"Dashboard: {url}")
696
+ webbrowser.open(url)
697
+ return 0
698
+
699
+
700
+ def cmd_setup(args, _command) -> int:
701
+ from . import wizard
702
+
703
+ return wizard.run(args)
704
+
705
+
706
+ def cmd_demo(args, _command) -> int:
707
+ from .runner import run_command
708
+
709
+ demo_dir = Path(str(resources.files("visualagents") / "demo"))
710
+ bp = bpmod.load(demo_dir / "blueprint.json")
711
+ hub = Hub(bp, meta={"mode": "demo", "project": "demo"})
712
+ command = [sys.executable, "-m", "visualagents.demo.research_crew", "--speed", str(args.speed)]
713
+ server = _serve(hub, args, "demo: a simulated research crew (planner → researcher → writer ⇄ critic)")
714
+ if not args.no_browser:
715
+ import time
716
+
717
+ time.sleep(1.5) # give the browser a moment so the run starts on screen
718
+ code = run_command(command, hub, cwd=demo_dir, server_url=server.url, instrument=False, label="visualagents demo")
719
+ if not args.exit:
720
+ _hold(server, "Demo finished.")
721
+ server.stop()
722
+ return code
723
+
724
+
725
+ if __name__ == "__main__":
726
+ raise SystemExit(main())