Route OpenCode yolo aliases to native auto mode (#7187)

Route --yolo to OpenCode native --auto for the default TUI and run; keep the config permission fallback for no-auto subcommands (including hidden console/generate) and for --mini, which ignores --auto.
This commit is contained in:
Lee Jackson 2026-07-20 05:03:50 +01:00 committed by GitHub
commit 8fab1c5310
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 285 additions and 15 deletions

View file

@ -149,8 +149,8 @@ _PERSIST_OPTION = typer.Option(
),
)
# Per-agent CLI flag for "run tools without prompting". opencode and openclaw have no
# such flag (config only) and are handled in their config writers, so they are absent.
# Per-agent CLI flag for "run tools without prompting". OpenCode (native --auto is
# command-scoped, handled below) and OpenClaw (config-only) are absent from this prefix map.
_YOLO_COMMAND_FLAGS = {
"claude": ["--dangerously-skip-permissions"],
"codex": ["--dangerously-bypass-approvals-and-sandbox"],
@ -166,6 +166,84 @@ def _yolo_command_flags(agent: str, yolo: bool) -> list:
return _YOLO_COMMAND_FLAGS.get(agent, []) if yolo else []
# Subcommands that reject --auto (OpenCode exposes it only on the default TUI and `run`),
# so `opencode serve --auto` is never emitted. Includes console/generate, hidden from
# `opencode --help` but still registered. Unknown first positionals are TUI paths -> --auto.
_OPENCODE_NON_AUTO_SUBCOMMANDS = frozenset(
"completion acp mcp attach debug providers auth agent upgrade uninstall serve web "
"models stats export import github pr session plugin plug db console generate".split()
)
_OPENCODE_GLOBAL_BOOLEAN_OPTIONS = frozenset(
"-h --help -v --version --print-logs --pure --mdns".split()
)
_OPENCODE_GLOBAL_VALUE_OPTIONS = frozenset(
"--log-level --port --hostname --mdns-domain --cors".split()
)
_OPENCODE_NATIVE_AUTO_MIN_VERSION = (1, 17, 12)
def _opencode_supports_native_auto() -> bool:
executable = shutil.which("opencode")
if executable is None:
# No local binary: a --no-launch recipe may run elsewhere, and _run installs the
# current release on launch -- either way assume native --auto is available.
return True
try:
output = subprocess.check_output(
[executable, "--version"],
text = True,
timeout = 10,
stderr = subprocess.DEVNULL,
)
except Exception:
return False
match = re.search(r"(\d+)\.(\d+)\.(\d+)", output)
return bool(match) and tuple(int(part) for part in match.groups()) >= (
_OPENCODE_NATIVE_AUTO_MIN_VERSION
)
def _opencode_subcommand(args: list[str]) -> Optional[str]:
"""Return an explicit OpenCode subcommand after supported global options."""
index = 0
while index < len(args):
arg = args[index]
if arg == "--":
return None
if arg in _OPENCODE_GLOBAL_BOOLEAN_OPTIONS:
index += 1
continue
if arg in _OPENCODE_GLOBAL_VALUE_OPTIONS:
index += 2
continue
if any(arg.startswith(f"{option}=") for option in _OPENCODE_GLOBAL_VALUE_OPTIONS):
index += 1
continue
# A non-global option (e.g. --session) is a TUI flag; stop before its value is
# mistaken for a subcommand.
if arg.startswith("-"):
return None
return arg
return None
def _opencode_native_auto_args(args: list[str], yolo: bool) -> tuple[list[str], bool]:
"""Add OpenCode's native --auto when the selected command supports it."""
routed = list(args)
if not yolo:
return routed, False
if _opencode_subcommand(routed) in _OPENCODE_NON_AUTO_SUBCOMMANDS:
return routed, False
separator = routed.index("--") if "--" in routed else len(routed)
# --mini's runMini TUI forces auto=false and never forwards --auto, so appending it is
# useless; fall back to the config permission block so --yolo still auto-approves.
if any(arg == "--mini" or arg.startswith("--mini=") for arg in routed[:separator]):
return routed, False
if "--auto" not in routed[:separator]:
routed.insert(separator, "--auto")
return routed, True
def _hermes_install_hint() -> str:
return _HERMES_WINDOWS_INSTALL_HINT if os.name == "nt" else _HERMES_POSIX_INSTALL_HINT
@ -1465,10 +1543,10 @@ def write_opencode_config(
compaction["reserved"] = max(1, window // 10)
tools = ("edit", "bash", "webfetch")
if yolo:
# OpenCode has no --yolo flag; auto-approve is the config `permission` block
# (singular). Allow the prompting tools and paths outside the launch directory so
# tool calls don't block on the TUI. This rides inline (OPENCODE_CONFIG_CONTENT) so
# --yolo works even over a project config.
# Fallback for commands without native --auto and for the append-safe bare
# --no-launch command (subcommand unknown yet). Rides inline (OPENCODE_CONFIG_CONTENT)
# so it wins over a project config. TUI and `run` launches use --auto and call here
# with yolo=False, letting OpenCode preserve explicit deny rules.
session_permission = {t: "allow" for t in tools}
session_permission["external_directory"] = {"*": "allow"}
config["permission"] = dict(session_permission)
@ -1807,11 +1885,20 @@ def opencode(
# --no-launch, where the printed command is consumed by drivers that append a
# subcommand such as `run <prompt>`; a leading --model would land before that
# subcommand and break it. Those paths rely on the inline pin instead.
native_auto = False
route_native_auto = yolo and _opencode_supports_native_auto()
if ctx.args:
command = ["opencode", *ctx.args]
opencode_args, native_auto = _opencode_native_auto_args(list(ctx.args), route_native_auto)
command = ["opencode", *opencode_args]
elif launch:
command = ["opencode", "--model", opencode_model]
opencode_args, native_auto = _opencode_native_auto_args(
["--model", opencode_model],
route_native_auto,
)
command = ["opencode", *opencode_args]
else:
# Append-safe base: `opencode --auto run ...` parses as the TUI with a project
# "run", not the run subcommand. Command unknown here, so keep the config fallback.
command = ["opencode"]
# opencode keeps sessions in ~/.local/share/opencode (never relocated), so resume
# already survives exit; reopen the last one by passing `opencode --continue` through.
@ -1820,12 +1907,18 @@ def opencode(
# OPENCODE_CONFIG is an overlay (loaded between the user's global and project
# configs), so this adds the Unsloth provider/model for the session without
# changing the user's default model. Key lives in the config, not the env.
session_permission = write_opencode_config(base, key, entry, config_path, yolo = yolo)
session_permission = write_opencode_config(
base,
key,
entry,
config_path,
yolo = yolo and not native_auto,
)
# A project's own opencode.json outranks OPENCODE_CONFIG, so the session model pin
# would silently lose to a repo config. Carry it in OPENCODE_CONFIG_CONTENT, which
# outranks project config; the API key stays in the private file, never the env.
# Only --yolo carries a permission here (its allow must win over a project config);
# a non-yolo session returns no permission, so the project's own rules are honored.
# Only the config fallback carries a permission. Native --auto omits it (auto-approve
# asks, keep explicit denies); a non-yolo session omits it too, honoring project rules.
# opencode filters every provider (a config-defined custom one included) through
# its enabled_providers allowlist and disabled_providers denylist, and a model pin
# does not bypass that gate -- a filtered provider resolves to ModelNotFoundError.

View file

@ -2190,8 +2190,18 @@ def test_yolo_aliases_are_interchangeable(fake_studio, alias):
assert "--dangerously-bypass-approvals-and-sandbox" in codex.output
assert "--dangerously-skip-permissions" not in codex.output
opencode = CliRunner().invoke(
start.start_app,
["opencode", alias, "--no-launch", "run", "hello"],
)
assert opencode.exit_code == 0, opencode.output
assert _launch_command(opencode.output) == ["opencode", "run", "hello", "--auto"]
assert "permission" not in _opencode_inline_config(opencode.output)
def test_yolo_opencode_writes_permission_block(fake_studio, tmp_path):
def test_yolo_opencode_bare_no_launch_uses_permission_fallback(fake_studio, tmp_path):
# A bare --no-launch recipe stays append-safe (callers add a subcommand later);
# `opencode --auto run ...` would select the TUI, not `run`, so keep the config fallback.
result = CliRunner().invoke(start.start_app, ["opencode", "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
@ -2203,6 +2213,172 @@ def test_yolo_opencode_writes_permission_block(fake_studio, tmp_path):
}
def test_yolo_opencode_run_uses_native_auto(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "run", "hello"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command == ["opencode", "run", "hello", "--auto"]
assert "permission" not in _opencode_inline_config(result.output)
def test_yolo_opencode_tui_resume_uses_native_auto(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "--session", "sid"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command == ["opencode", "--session", "sid", "--auto"]
assert "permission" not in _opencode_inline_config(result.output)
def test_no_yolo_opencode_run_omits_native_auto(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--no-launch", "run", "hello"],
)
assert result.exit_code == 0, result.output
assert _launch_command(result.output) == ["opencode", "run", "hello"]
assert "permission" not in _opencode_inline_config(result.output)
def test_yolo_opencode_bare_launch_uses_native_auto(fake_studio, monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/opencode")
monkeypatch.setattr(start, "_opencode_supports_native_auto", lambda: True)
captured = _capture_launch(monkeypatch, ["opencode", "--yolo"])
assert captured["command"][1:] == [
"--model",
f"{start._OPENCODE_PROVIDER}/{MODEL['id']}",
"--auto",
]
assert "permission" not in json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"])
def test_yolo_opencode_native_auto_clears_prior_config_fallback(fake_studio, tmp_path):
fallback = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch"],
)
assert fallback.exit_code == 0, fallback.output
native = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "run", "hello"],
)
assert native.exit_code == 0, native.output
assert _launch_command(native.output) == ["opencode", "run", "hello", "--auto"]
assert "permission" not in _opencode_inline_config(native.output)
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
assert config["permission"] == {
"edit": "ask",
"bash": "ask",
"webfetch": "ask",
"external_directory": {"*": "ask"},
}
@pytest.mark.parametrize(
("version", "expected"),
[
("1.17.11", False),
("1.17.12", True),
("opencode 1.18.2", True),
("development build", False),
],
)
def test_opencode_native_auto_version_gate(monkeypatch, version, expected):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/opencode")
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: version)
assert start._opencode_supports_native_auto() is expected
def test_opencode_native_auto_assumes_current_without_local_binary(monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: None)
assert start._opencode_supports_native_auto() is True
def test_yolo_opencode_old_version_uses_config_fallback(fake_studio, monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/opencode")
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: "1.17.11")
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "run", "hello"],
)
assert result.exit_code == 0, result.output
assert _launch_command(result.output) == ["opencode", "run", "hello"]
assert _opencode_inline_config(result.output)["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
@pytest.mark.parametrize(
("args", "expected", "native"),
[
([], ["--auto"], True),
(["run", "hello"], ["run", "hello", "--auto"], True),
(
["run", "hello", "--", "--literal"],
["run", "hello", "--auto", "--", "--literal"],
True,
),
(["--print-logs", "run", "hello"], ["--print-logs", "run", "hello", "--auto"], True),
(["--session", "serve"], ["--session", "serve", "--auto"], True),
(["serve"], ["serve"], False),
(["--print-logs", "serve"], ["--print-logs", "serve"], False),
(["run", "--auto", "hello"], ["run", "--auto", "hello"], True),
# Hidden commands that reject --auto fall back like the visible utility ones.
(["generate"], ["generate"], False),
(["console", "login"], ["console", "login"], False),
# --mini ignores --auto (runMini forces auto=false), so use the config fallback.
(["--mini"], ["--mini"], False),
(["--session", "sid", "--mini"], ["--session", "sid", "--mini"], False),
],
)
def test_opencode_native_auto_args(args, expected, native):
assert start._opencode_native_auto_args(args, True) == (expected, native)
assert start._opencode_native_auto_args(args, False) == (args, False)
def test_yolo_opencode_non_agent_subcommand_uses_config_fallback(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "serve"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command == ["opencode", "serve"]
assert _opencode_inline_config(result.output)["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
@pytest.mark.parametrize("passthrough", (["generate"], ["console", "login"], ["--mini"]))
def test_yolo_opencode_no_auto_command_uses_config_fallback(fake_studio, passthrough):
# generate/console are hidden and reject --auto, --mini ignores it: none get --auto,
# all keep the config permission fallback.
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", *passthrough],
)
assert result.exit_code == 0, result.output
assert _launch_command(result.output) == ["opencode", *passthrough]
assert _opencode_inline_config(result.output)["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
def test_no_yolo_opencode_has_no_permission_block(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 0, result.output
@ -2549,15 +2725,16 @@ def test_openclaw_non_yolo_preserves_full_mode(tmp_path):
def test_yolo_command_flags_unmapped_agent_is_empty():
# Config-based agents (and any typo) must yield no flag, not a KeyError.
# Placement-aware/config-based agents (and any typo) must yield no prefix flag.
assert start._yolo_command_flags("opencode", True) == []
assert start._yolo_command_flags("openclaw", True) == []
assert start._yolo_command_flags("claude", True) == ["--dangerously-skip-permissions"]
assert start._yolo_command_flags("claude", False) == []
def test_yolo_config_agents_add_no_command_flag(fake_studio):
# opencode/openclaw auto-approve is config-only; nothing should leak onto argv.
def test_yolo_config_fallbacks_add_no_legacy_command_flag(fake_studio):
# OpenClaw is config-only; OpenCode's append-safe bare recipe uses its config fallback.
# Neither should leak a legacy yolo/dangerous alias onto argv.
for agent in ("opencode", "openclaw"):
result = CliRunner().invoke(start.start_app, [agent, "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output