jarvix-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,30 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ build/
7
+ dist/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Tooling caches
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .mypy_cache/
19
+ .coverage
20
+ htmlcov/
21
+
22
+ # Environment
23
+ .env
24
+ .env.*
25
+ !.env.example
26
+
27
+ # Editors / OS
28
+ .idea/
29
+ .vscode/
30
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loctvl842
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.5
2
+ Name: jarvix-cli
3
+ Version: 0.1.0
4
+ Summary: Jarvix command line interface
5
+ Project-URL: Homepage, https://github.com/loctvl842/jarvix
6
+ Project-URL: Repository, https://github.com/loctvl842/jarvix
7
+ Project-URL: Issues, https://github.com/loctvl842/jarvix/issues
8
+ Author-email: loctvl842 <loclepnvx@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,cli,multiplexer,tmux
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: click>=8.1
22
+ Requires-Dist: jarvix-core>=0.1.0
23
+ Requires-Dist: jarvix-hub>=0.1.0
24
+ Requires-Dist: jarvix-mux>=0.1.0
File without changes
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build.targets.wheel]
6
+ packages = ["src/jarvix"]
7
+
8
+ [project]
9
+ name = "jarvix-cli"
10
+ version = "0.1.0"
11
+ description = "Jarvix command line interface"
12
+ authors = [{ name = "loctvl842", email = "loclepnvx@gmail.com" }]
13
+ readme = "README.md"
14
+ requires-python = ">=3.12"
15
+ license = "MIT"
16
+ license-files = ["LICENSE"]
17
+ keywords = ["ai", "agents", "tmux", "multiplexer", "cli"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: MacOS",
23
+ "Operating System :: POSIX :: Linux",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ ]
28
+ dependencies = [
29
+ "jarvix-core>=0.1.0",
30
+ "jarvix-hub>=0.1.0",
31
+ "jarvix-mux>=0.1.0",
32
+ "click>=8.1",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/loctvl842/jarvix"
37
+ Repository = "https://github.com/loctvl842/jarvix"
38
+ Issues = "https://github.com/loctvl842/jarvix/issues"
39
+
40
+ [project.scripts]
41
+ jarvix = "jarvix.cli:main"
42
+
43
+ [tool.uv.sources]
44
+ jarvix-core = { workspace = true }
45
+ jarvix-hub = { workspace = true }
46
+ jarvix-mux = { workspace = true }
@@ -0,0 +1,86 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import time
5
+ from datetime import datetime
6
+
7
+ import click
8
+ from jarvix.hub.client import send
9
+ from jarvix.hub.paths import socket_path
10
+
11
+ from .mesh import mesh, mesh_option
12
+
13
+
14
+ @click.group()
15
+ def main() -> None:
16
+ """Jarvix CLI."""
17
+
18
+
19
+ main.add_command(mesh)
20
+
21
+
22
+ @main.command(hidden=True, context_settings={"ignore_unknown_options": True})
23
+ @click.argument("event")
24
+ def hook(event: str) -> None:
25
+ """Forward a harness hook event (stdin JSON) to the hub. Never fails the harness."""
26
+ try:
27
+ node = os.environ.get("JARVIX_NODE")
28
+ sock = os.environ.get("JARVIX_SOCKET")
29
+ if not node or not sock:
30
+ return
31
+ try:
32
+ payload = json.loads(sys.stdin.read() or "{}")
33
+ except ValueError:
34
+ payload = {}
35
+ reply = send(
36
+ sock, {"op": "event", "node": node, "event": {"name": event, "ts": time.time(), "payload": payload}}
37
+ )
38
+ if reply and reply.get("output"):
39
+ click.echo(reply["output"])
40
+ except Exception: # noqa: BLE001 - a broken hook must never break the harness
41
+ pass
42
+
43
+
44
+ def _age(since: float) -> str:
45
+ secs = max(0, int(time.time() - since))
46
+ if secs < 60:
47
+ return f"{secs}s"
48
+ if secs < 3600:
49
+ return f"{secs // 60}m"
50
+ return f"{secs // 3600}h{secs % 3600 // 60:02d}m"
51
+
52
+
53
+ @main.command()
54
+ @mesh_option
55
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
56
+ def status(mesh: str, as_json: bool) -> None:
57
+ """Show the state of every node."""
58
+ reply = send(socket_path(mesh), {"op": "nodes"})
59
+ if reply is None or not reply.get("ok"):
60
+ raise click.ClickException(f"hub for mesh {mesh!r} is not running")
61
+ nodes = reply["nodes"]
62
+ if as_json:
63
+ click.echo(json.dumps(nodes, indent=2))
64
+ return
65
+ width = max([len(n["name"]) for n in nodes] + [4])
66
+ click.echo(f"{'NODE':<{width}} {'STATE':<8} {'SINCE':<6} {'ATTN':<4} SUMMARY")
67
+ for n in nodes:
68
+ attn = "!" if n["attention"] else "-"
69
+ click.echo(f"{n['name']:<{width}} {n['state']:<8} {_age(n['state_since']):<6} {attn:<4} {n['summary']}")
70
+
71
+
72
+ @main.command()
73
+ @mesh_option
74
+ @click.argument("node")
75
+ @click.option("-n", default=10, show_default=True, help="Number of recent events.")
76
+ def logs(mesh: str, node: str, n: int) -> None:
77
+ """Show recent events for a node."""
78
+ reply = send(socket_path(mesh), {"op": "status", "node": node, "n": n})
79
+ if reply is None:
80
+ raise click.ClickException(f"hub for mesh {mesh!r} is not running")
81
+ if not reply.get("ok"):
82
+ raise click.ClickException(reply["error"])
83
+ for e in reply["events"]:
84
+ stamp = datetime.fromtimestamp(e["ts"]).strftime("%H:%M:%S")
85
+ detail = e["payload"].get("tool_name") or e["payload"].get("prompt") or e["payload"].get("message") or ""
86
+ click.echo(f"{stamp} {e['kind']:<18} {detail[:80]}")
@@ -0,0 +1,4 @@
1
+ from jarvix.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,67 @@
1
+ import json
2
+ import shlex
3
+ import sys
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+
7
+ from jarvix.hub.hooks import hook_settings
8
+ from jarvix.hub.paths import runtime_dir
9
+ from jarvix.hub.server import NodeSpec
10
+ from jarvix.mux import LayoutStrategy, Multiplexer, PaneRef, SessionRef
11
+
12
+ NODES_WINDOW = "nodes"
13
+
14
+
15
+ def default_jarvix_bin() -> str:
16
+ """Command the hooks run. Uses this interpreter so it works without `jarvix` on PATH."""
17
+ return shlex.join([sys.executable, "-m", "jarvix.cli"])
18
+
19
+
20
+ def session_name(mesh: str) -> str:
21
+ return f"jarvix-{mesh}"
22
+
23
+
24
+ def tmux_socket(mesh: str) -> str:
25
+ """Private tmux server per mesh, so the user's own tmux is untouched."""
26
+ return f"jarvix-{mesh}"
27
+
28
+
29
+ def write_settings(mesh: str, jarvix_bin: str) -> Path:
30
+ path = runtime_dir(mesh) / "claude-settings.json"
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ path.write_text(json.dumps(hook_settings(jarvix_bin), indent=2))
33
+ return path
34
+
35
+
36
+ def node_argv(spec: NodeSpec, settings: Path, *, model: str, claude_bin: str = "claude") -> list[str]:
37
+ if spec.harness != "claude":
38
+ raise ValueError(f"unsupported harness {spec.harness!r} for node {spec.name!r}")
39
+ # --model is required: a model pinned in the user's global settings can break turns (M0 gotcha).
40
+ return [claude_bin, "--model", model, "--settings", str(settings)]
41
+
42
+
43
+ def launch_nodes(
44
+ mux: Multiplexer,
45
+ mesh: str,
46
+ specs: Sequence[NodeSpec],
47
+ sock_path: Path,
48
+ settings: Path,
49
+ *,
50
+ model: str,
51
+ claude_bin: str = "claude",
52
+ ) -> dict[str, PaneRef]:
53
+ """Spawn one pane per node in a fresh session and arrange them in a grid. The hub must already listen."""
54
+ session: SessionRef = mux.spawn_session(session_name(mesh))
55
+ panes: dict[str, PaneRef] = {}
56
+ for spec in specs:
57
+ env = {"JARVIX_NODE": spec.name, "JARVIX_SOCKET": str(sock_path), "JARVIX_MESH": mesh}
58
+ panes[spec.name] = mux.spawn_pane(
59
+ session,
60
+ node_argv(spec, settings, model=model, claude_bin=claude_bin),
61
+ env=env,
62
+ cwd=Path(spec.cwd),
63
+ tag=spec.name,
64
+ window=NODES_WINDOW,
65
+ )
66
+ mux.layout(session, LayoutStrategy.GRID, window=NODES_WINDOW)
67
+ return panes
@@ -0,0 +1,205 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+
11
+ import click
12
+ from jarvix.hub import (
13
+ Hub,
14
+ HubAlreadyRunning,
15
+ NodeSpec,
16
+ Store,
17
+ db_path,
18
+ encode_pane,
19
+ log_path,
20
+ pid_path,
21
+ send,
22
+ socket_path,
23
+ )
24
+ from jarvix.mux import SessionRef, TmuxMultiplexer
25
+ from jarvix.mux.tmux import MuxError
26
+
27
+ from .launch import (
28
+ default_jarvix_bin,
29
+ launch_nodes,
30
+ session_name,
31
+ tmux_socket,
32
+ write_settings,
33
+ )
34
+
35
+ mesh_option = click.option("--mesh", envvar="JARVIX_MESH", default="default", show_default=True, help="Mesh name.")
36
+
37
+ STARTUP_TIMEOUT = 20.0
38
+ STOP_TIMEOUT = 5.0
39
+
40
+
41
+ @click.group()
42
+ def mesh() -> None:
43
+ """Run a mesh of harness nodes in tmux panes."""
44
+
45
+
46
+ def _hub_pid(mesh: str) -> int | None:
47
+ """Pid of the background hub, or None if there is no live one."""
48
+ try:
49
+ pid = int(pid_path(mesh).read_text())
50
+ os.kill(pid, 0)
51
+ except (OSError, ValueError):
52
+ return None
53
+ return pid
54
+
55
+
56
+ def _tail(path: Path, n: int = 15) -> str:
57
+ try:
58
+ return "\n".join(path.read_text().splitlines()[-n:])
59
+ except OSError:
60
+ return ""
61
+
62
+
63
+ def _print_hints(mesh: str, count: int) -> None:
64
+ click.echo(f"mesh {mesh!r} up with {count} node(s)")
65
+ click.echo(f" jarvix mesh attach --mesh {mesh} # see the nodes")
66
+ click.echo(f" jarvix status --mesh {mesh} # state of every node")
67
+ click.echo(f" jarvix mesh logs --mesh {mesh} -f # hub log")
68
+ click.echo(f" jarvix mesh down --mesh {mesh} # stop the hub and kill the panes")
69
+
70
+
71
+ def _serve(mesh: str, specs: list[NodeSpec], model: str, claude_bin: str, clean_config: bool) -> None:
72
+ """Run the hub in this process and spawn the nodes once it listens. Returns when stopped."""
73
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
74
+ sock = socket_path(mesh)
75
+ sock.parent.mkdir(parents=True, exist_ok=True)
76
+ store = Store(db_path(mesh))
77
+ mux = TmuxMultiplexer(socket=tmux_socket(mesh), config_file="/dev/null" if clean_config else None)
78
+ server = Hub(sock, store, specs, mux=mux)
79
+ settings = write_settings(mesh, default_jarvix_bin())
80
+
81
+ def spawn() -> None:
82
+ # Hooks are fire-and-forget, so the hub must be listening before the first SessionStart.
83
+ try:
84
+ panes = launch_nodes(mux, mesh, specs, sock, settings, model=model, claude_bin=claude_bin)
85
+ except MuxError as e:
86
+ raise click.ClickException(f"{e}\nIs the mesh already up? Try: jarvix mesh down --mesh {mesh}") from e
87
+ for name, pane in panes.items():
88
+ store.set_pane(name, encode_pane(pane))
89
+ logging.info("spawned %d node(s): %s", len(panes), ", ".join(panes))
90
+
91
+ async def run() -> None:
92
+ loop = asyncio.get_running_loop()
93
+ task = asyncio.current_task()
94
+ assert task is not None
95
+ for sig in (signal.SIGINT, signal.SIGTERM):
96
+ loop.add_signal_handler(sig, task.cancel)
97
+ with contextlib.suppress(asyncio.CancelledError):
98
+ await server.serve_forever(on_ready=spawn)
99
+
100
+ try:
101
+ pid_path(mesh).write_text(str(os.getpid()))
102
+ asyncio.run(run())
103
+ except HubAlreadyRunning as e:
104
+ raise click.ClickException(str(e)) from e
105
+ finally:
106
+ with contextlib.suppress(FileNotFoundError):
107
+ pid_path(mesh).unlink()
108
+ store.close()
109
+ logging.info("hub stopped")
110
+
111
+
112
+ def _wait_until_up(mesh: str, names: list[str], child: subprocess.Popen) -> None:
113
+ """Block until the hub answers and every node has a pane, or fail with the hub log."""
114
+ deadline = time.monotonic() + STARTUP_TIMEOUT
115
+ while time.monotonic() < deadline:
116
+ if child.poll() is not None:
117
+ raise click.ClickException(f"hub exited during startup:\n{_tail(log_path(mesh))}")
118
+ reply = send(socket_path(mesh), {"op": "nodes"}, timeout=0.5)
119
+ if reply and reply["ok"]:
120
+ panes = {n["name"]: n["pane_ref"] for n in reply["nodes"]}
121
+ if all(panes.get(name) for name in names):
122
+ return
123
+ time.sleep(0.1)
124
+ raise click.ClickException(f"hub did not come up in {STARTUP_TIMEOUT:.0f}s:\n{_tail(log_path(mesh))}")
125
+
126
+
127
+ @mesh.command("up")
128
+ @mesh_option
129
+ @click.option("--node", "nodes", multiple=True, required=True, help="Node name. Repeatable.")
130
+ @click.option("--cwd", default=".", show_default=True, help="Working directory of every node.")
131
+ @click.option("--model", default="sonnet", show_default=True, help="Model passed to claude.")
132
+ @click.option("--claude-bin", default="claude", show_default=True, help="The claude executable.")
133
+ @click.option("--clean-config", is_flag=True, help="Ignore ~/.tmux.conf for the mesh's tmux server.")
134
+ @click.option("--foreground", is_flag=True, help="Run the hub in this terminal instead of the background.")
135
+ def mesh_up(
136
+ mesh: str, nodes: tuple[str, ...], cwd: str, model: str, claude_bin: str, clean_config: bool, foreground: bool
137
+ ) -> None:
138
+ """Spawn one Claude Code pane per node and run the hub (in the background by default)."""
139
+ # TODO: temporary entry point. The real one is `jarvix up <graph>`: compile() the graph into a launch plan
140
+ # (per-node harness, cwd, prompt, model, plus edges and Jarvis) and feed it to launch_nodes, instead of --node flags.
141
+ if send(socket_path(mesh), {"op": "ping"}, timeout=0.5):
142
+ raise click.ClickException(f"mesh {mesh!r} is already up. Stop it first: jarvix mesh down --mesh {mesh}")
143
+ specs = [NodeSpec(name=n, cwd=os.path.abspath(cwd)) for n in nodes]
144
+ if foreground:
145
+ if sys.stdout.isatty(): # the background child inherits a log file, where this banner would be noise
146
+ click.echo(f"hub for mesh {mesh!r} running in this terminal. Ctrl-C stops it; nodes keep running.")
147
+ _serve(mesh, specs, model, claude_bin, clean_config)
148
+ return
149
+
150
+ log = log_path(mesh)
151
+ log.parent.mkdir(parents=True, exist_ok=True)
152
+ argv = [sys.executable, "-m", "jarvix.cli", "mesh", "up", "--foreground", "--mesh", mesh, "--cwd", cwd]
153
+ argv += ["--model", model, "--claude-bin", claude_bin]
154
+ argv += ["--clean-config"] if clean_config else []
155
+ for n in nodes:
156
+ argv += ["--node", n]
157
+ with log.open("ab") as out:
158
+ # New session: the hub must outlive this terminal.
159
+ child = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=out, start_new_session=True)
160
+ _wait_until_up(mesh, list(nodes), child)
161
+ _print_hints(mesh, len(nodes))
162
+
163
+
164
+ @mesh.command("down")
165
+ @mesh_option
166
+ def mesh_down(mesh: str) -> None:
167
+ """Stop the hub and kill the mesh's tmux session and all its panes."""
168
+ stopped = False
169
+ pid = _hub_pid(mesh)
170
+ if pid is not None:
171
+ os.kill(pid, signal.SIGTERM)
172
+ deadline = time.monotonic() + STOP_TIMEOUT
173
+ while _hub_pid(mesh) is not None and time.monotonic() < deadline:
174
+ time.sleep(0.1)
175
+ stopped = True
176
+ try:
177
+ TmuxMultiplexer(socket=tmux_socket(mesh)).kill_session(SessionRef(session_name(mesh)))
178
+ stopped = True
179
+ except MuxError:
180
+ pass
181
+ if not stopped:
182
+ raise click.ClickException(f"no running mesh {mesh!r}")
183
+ click.echo(f"mesh {mesh!r} down")
184
+
185
+
186
+ @mesh.command("attach")
187
+ @mesh_option
188
+ def mesh_attach(mesh: str) -> None:
189
+ """Attach this terminal to the mesh's tmux session (detach with ctrl-b d)."""
190
+ sock, session = tmux_socket(mesh), session_name(mesh)
191
+ os.execvp("tmux", ["tmux", "-L", sock, "attach", "-t", f"={session}"])
192
+
193
+
194
+ @mesh.command("logs")
195
+ @mesh_option
196
+ @click.option("-n", default=50, show_default=True, help="Number of lines.")
197
+ @click.option("-f", "follow", is_flag=True, help="Follow new lines.")
198
+ def mesh_logs(mesh: str, n: int, follow: bool) -> None:
199
+ """Show the hub's log (state transitions, startup errors)."""
200
+ path = log_path(mesh)
201
+ if not path.exists():
202
+ raise click.ClickException(f"no hub log for mesh {mesh!r}")
203
+ if follow:
204
+ os.execvp("tail", ["tail", "-n", str(n), "-f", str(path)])
205
+ click.echo(_tail(path, n))