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/__init__.py +20 -0
- visualagents/__main__.py +3 -0
- visualagents/_autoinstrument/__init__.py +0 -0
- visualagents/_autoinstrument/sitecustomize.py +40 -0
- visualagents/_autoinstrument/visualagents_runtime.py +864 -0
- visualagents/_hook/visualagents_boot.py +42 -0
- visualagents/blueprint.py +276 -0
- visualagents/cli.py +726 -0
- visualagents/collector.py +146 -0
- visualagents/demo/__init__.py +0 -0
- visualagents/demo/blueprint.json +68 -0
- visualagents/demo/research_crew.py +146 -0
- visualagents/detect.py +337 -0
- visualagents/home.py +139 -0
- visualagents/hooks.py +63 -0
- visualagents/hub.py +143 -0
- visualagents/launcher.py +87 -0
- visualagents/llm.py +314 -0
- visualagents/narrator.py +58 -0
- visualagents/patterns.py +81 -0
- visualagents/projects.py +140 -0
- visualagents/runner.py +355 -0
- visualagents/scanner.py +91 -0
- visualagents/sdk.py +18 -0
- visualagents/server.py +209 -0
- visualagents/static_scan.py +677 -0
- visualagents/termui.py +94 -0
- visualagents/tracker.py +501 -0
- visualagents/tui.py +240 -0
- visualagents/web/app.js +1753 -0
- visualagents/web/index.html +117 -0
- visualagents/web/styles.css +551 -0
- visualagents/wizard.py +331 -0
- visualagents-0.1.0.dist-info/METADATA +438 -0
- visualagents-0.1.0.dist-info/RECORD +38 -0
- visualagents-0.1.0.dist-info/WHEEL +4 -0
- visualagents-0.1.0.dist-info/entry_points.txt +3 -0
- visualagents-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|