Skip to content

Commit dacb528

Browse files
committed
Add CLI with run, speculate, best-of-n, reflexion, and status commands
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent c24c774 commit dacb528

8 files changed

Lines changed: 595 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ description = "Unified branching for speculative execution: filesystem, process,
99
requires-python = ">=3.10"
1010
license = "Apache-2.0"
1111

12+
[project.scripts]
13+
branching = "cli:main"
14+
1215
[tool.setuptools.packages.find]
1316
where = ["src"]
1417

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ packages = find:
1111

1212
[options.packages.find]
1313
where = src
14+
15+
[options.entry_points]
16+
console_scripts =
17+
branching = cli:main

src/cli/__init__.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""CLI for BranchContext — run commands in COW branches from the terminal."""
3+
4+
import argparse
5+
import json
6+
import sys
7+
import uuid
8+
from pathlib import Path
9+
from typing import Optional
10+
11+
from branching.fs._mount import parse_mounts
12+
13+
14+
def _find_branchfs_mounts():
15+
"""Find all branchfs mounts (handles both 'fuse.branchfs' and bare 'fuse' types)."""
16+
results = []
17+
for m in parse_mounts():
18+
if m.fstype == "fuse.branchfs":
19+
results.append(m)
20+
elif m.fstype == "fuse" and m.source == "branchfs":
21+
results.append(m)
22+
return results
23+
24+
25+
def _find_workspace_from_cwd() -> Optional[Path]:
26+
"""Find a branchfs mount that contains the current working directory.
27+
28+
Checks all branchfs mounts, returns the longest matching mountpoint
29+
(most specific ancestor of cwd).
30+
"""
31+
cwd = Path.cwd().resolve()
32+
mounts = _find_branchfs_mounts()
33+
best: Optional[Path] = None
34+
for m in mounts:
35+
mp = m.mountpoint.resolve()
36+
try:
37+
cwd.relative_to(mp)
38+
except ValueError:
39+
continue
40+
if best is None or len(mp.parts) > len(best.parts):
41+
best = mp
42+
return best
43+
44+
45+
def _resolve_workspace(args: argparse.Namespace) -> Path:
46+
"""Resolve workspace path from --workspace arg or cwd auto-detection."""
47+
if hasattr(args, "workspace") and args.workspace:
48+
p = Path(args.workspace).resolve()
49+
if not p.is_dir():
50+
_print_error(f"workspace path does not exist: {p}", args)
51+
sys.exit(1)
52+
return p
53+
detected = _find_workspace_from_cwd()
54+
if detected is None:
55+
_print_error(
56+
"no branchfs mount found for current directory; use -w/--workspace",
57+
args,
58+
)
59+
sys.exit(1)
60+
return detected
61+
62+
63+
def _generate_branch_name(prefix: str = "run") -> str:
64+
"""Generate a branch name like 'run-a1b2c3d4'."""
65+
return f"{prefix}-{uuid.uuid4().hex[:8]}"
66+
67+
68+
def _print_result(data: dict, args: argparse.Namespace) -> None:
69+
"""Print result as JSON (if --json) or human-readable text."""
70+
if getattr(args, "json", False):
71+
print(json.dumps(data))
72+
else:
73+
for key, value in data.items():
74+
print(f"{key}: {value}")
75+
76+
77+
def _print_error(message: str, args: argparse.Namespace) -> None:
78+
"""Print error as JSON (if --json) or plain text to stderr."""
79+
if getattr(args, "json", False):
80+
print(json.dumps({"error": message}), file=sys.stderr)
81+
else:
82+
print(f"error: {message}", file=sys.stderr)
83+
84+
85+
def build_parser() -> argparse.ArgumentParser:
86+
parser = argparse.ArgumentParser(
87+
prog="branching",
88+
description="CLI for BranchContext — COW branching for AI agent workflows.",
89+
)
90+
sub = parser.add_subparsers(dest="command")
91+
92+
# --- run ---
93+
p_run = sub.add_parser(
94+
"run",
95+
help="Run a command in a new branch.",
96+
description=(
97+
"Run CMD in a new branch. Commits on exit 0, aborts on non-zero."
98+
),
99+
)
100+
p_run.add_argument("-w", "--workspace", help="Workspace path (auto-detected from cwd)")
101+
p_run.add_argument("-b", "--branch", help="Branch name (auto-generated if omitted)")
102+
p_run.add_argument(
103+
"--on-error",
104+
choices=["abort", "none"],
105+
default="abort",
106+
help="Action on non-zero exit (default: abort)",
107+
)
108+
p_run.add_argument(
109+
"--ask",
110+
action="store_true",
111+
help="Prompt to commit/abort instead of auto-deciding",
112+
)
113+
p_run.add_argument("--json", action="store_true", help="JSON output")
114+
p_run.add_argument("cmd", nargs=argparse.REMAINDER, metavar="CMD",
115+
help="Command to run (after --)")
116+
117+
# --- speculate ---
118+
p_spec = sub.add_parser(
119+
"speculate",
120+
help="Race N commands in parallel branches.",
121+
description="Race N commands in parallel; first success wins.",
122+
)
123+
p_spec.add_argument("-w", "--workspace", help="Workspace path (auto-detected from cwd)")
124+
p_spec.add_argument(
125+
"-c", "--candidate",
126+
action="append",
127+
required=True,
128+
dest="candidates",
129+
metavar="CMD",
130+
help="Command string to run as candidate (use multiple -c flags)",
131+
)
132+
p_spec.add_argument("--timeout", type=float, default=None, help="Timeout in seconds")
133+
p_spec.add_argument("--json", action="store_true", help="JSON output")
134+
135+
# --- best-of-n ---
136+
p_bon = sub.add_parser(
137+
"best-of-n",
138+
help="Run CMD N times in parallel, commit the highest-scoring success.",
139+
description="Run CMD N times in parallel branches, commit the highest-scoring success.",
140+
)
141+
p_bon.add_argument("-w", "--workspace", help="Workspace path (auto-detected from cwd)")
142+
p_bon.add_argument("-n", type=int, default=3, help="Number of parallel attempts (default: 3)")
143+
p_bon.add_argument("--timeout", type=float, default=None, help="Timeout in seconds")
144+
p_bon.add_argument("--json", action="store_true", help="JSON output")
145+
p_bon.add_argument("cmd", nargs=argparse.REMAINDER, metavar="CMD",
146+
help="Command to run (after --)")
147+
148+
# --- reflexion ---
149+
p_refl = sub.add_parser(
150+
"reflexion",
151+
help="Sequential retry with optional critique feedback loop.",
152+
description="Sequential retry with optional critique feedback loop.",
153+
)
154+
p_refl.add_argument("-w", "--workspace", help="Workspace path (auto-detected from cwd)")
155+
p_refl.add_argument("--retries", type=int, default=3, help="Max retry attempts (default: 3)")
156+
p_refl.add_argument("--critique", type=str, default=None,
157+
help="Shell command to generate feedback after failure")
158+
p_refl.add_argument("--json", action="store_true", help="JSON output")
159+
p_refl.add_argument("cmd", nargs=argparse.REMAINDER, metavar="CMD",
160+
help="Command to run (after --)")
161+
162+
# --- status ---
163+
p_status = sub.add_parser(
164+
"status",
165+
help="Show workspace info and branch list.",
166+
)
167+
p_status.add_argument("-w", "--workspace", help="Workspace path (auto-detected from cwd)")
168+
p_status.add_argument("--json", action="store_true", help="JSON output")
169+
170+
return parser
171+
172+
173+
def main(argv: list[str] | None = None) -> None:
174+
parser = build_parser()
175+
args = parser.parse_args(argv)
176+
177+
if args.command is None:
178+
parser.print_help()
179+
sys.exit(0)
180+
181+
# Strip leading '--' from remainder args for commands that take CMD...
182+
if args.command in ("run", "best-of-n", "reflexion"):
183+
cmd = args.cmd
184+
if cmd and cmd[0] == "--":
185+
cmd = cmd[1:]
186+
args.cmd = cmd
187+
188+
try:
189+
if args.command == "run":
190+
from .run import cmd_run
191+
sys.exit(cmd_run(args))
192+
elif args.command == "speculate":
193+
from .speculate import cmd_speculate
194+
sys.exit(cmd_speculate(args))
195+
elif args.command == "best-of-n":
196+
from .best_of_n import cmd_best_of_n
197+
sys.exit(cmd_best_of_n(args))
198+
elif args.command == "reflexion":
199+
from .reflexion import cmd_reflexion
200+
sys.exit(cmd_reflexion(args))
201+
elif args.command == "status":
202+
from .status import cmd_status
203+
sys.exit(cmd_status(args))
204+
else:
205+
parser.print_help()
206+
sys.exit(1)
207+
except KeyboardInterrupt:
208+
print(file=sys.stderr)
209+
sys.exit(130)
210+
except Exception as e:
211+
_print_error(str(e), args)
212+
sys.exit(1)

src/cli/best_of_n.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Implementation of 'branching best-of-n' command."""
3+
4+
import json
5+
import os
6+
import subprocess
7+
from pathlib import Path
8+
9+
from branching import BestOfN, Workspace
10+
11+
from . import _print_error, _resolve_workspace
12+
13+
14+
def _make_task(cmd: list[str]):
15+
"""Wrap a command into a BestOfN task callable.
16+
17+
Returns a callable(path, index) -> (success, score).
18+
The child process can write a score float to fd 3.
19+
"""
20+
21+
def task(workdir: Path, index: int) -> tuple[bool, float]:
22+
# Create a pipe for the child to report its score.
23+
# Python 3.4+ creates pipe fds with CLOEXEC, so they are
24+
# automatically closed on exec — only fd 3 (dup2 clears
25+
# CLOEXEC) survives into the child.
26+
r_fd, w_fd = os.pipe()
27+
28+
env = {**os.environ, "BRANCHING_ATTEMPT": str(index)}
29+
30+
def _preexec():
31+
os.dup2(w_fd, 3)
32+
33+
proc = subprocess.Popen(
34+
cmd,
35+
cwd=workdir,
36+
env=env,
37+
close_fds=False,
38+
preexec_fn=_preexec,
39+
)
40+
# Close write end in the parent so read will EOF when child exits
41+
os.close(w_fd)
42+
43+
proc.wait()
44+
success = proc.returncode == 0
45+
46+
# Read score from the pipe
47+
with os.fdopen(r_fd, "r") as f:
48+
raw = f.read().strip()
49+
50+
if raw:
51+
try:
52+
score = float(raw)
53+
except ValueError:
54+
score = 1.0 if success else 0.0
55+
else:
56+
score = 1.0 if success else 0.0
57+
58+
return (success, score)
59+
60+
return task
61+
62+
63+
def cmd_best_of_n(args) -> int:
64+
if not args.cmd:
65+
_print_error("no command specified; use -- CMD...", args)
66+
return 1
67+
68+
ws_path = _resolve_workspace(args)
69+
ws = Workspace(ws_path)
70+
71+
task = _make_task(args.cmd)
72+
best = BestOfN(task, n=args.n, timeout=args.timeout)
73+
outcome = best(ws)
74+
75+
results_summary = []
76+
for r in outcome.all_results:
77+
entry = {
78+
"index": r.branch_index,
79+
"success": r.success,
80+
"score": r.score,
81+
}
82+
if r.exception is not None:
83+
entry["error"] = str(r.exception)
84+
results_summary.append(entry)
85+
86+
data = {
87+
"command": "best-of-n",
88+
"n": args.n,
89+
"committed": outcome.committed,
90+
"winner": {
91+
"index": outcome.winner.branch_index,
92+
"score": outcome.winner.score,
93+
} if outcome.winner else None,
94+
"results": results_summary,
95+
}
96+
97+
if getattr(args, "json", False):
98+
print(json.dumps(data))
99+
else:
100+
if outcome.committed:
101+
print(
102+
f"winner: attempt {outcome.winner.branch_index}"
103+
f" (score: {outcome.winner.score:.2f})"
104+
)
105+
else:
106+
print("no attempt succeeded")
107+
for r in outcome.all_results:
108+
status = "ok" if r.success else "fail"
109+
print(f" [{r.branch_index}] {status:<4} ({r.score:.2f})")
110+
111+
return 0 if outcome.committed else 1

0 commit comments

Comments
 (0)