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:
parent
17fd6c8ec6
commit
8fab1c5310
2 changed files with 285 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue