diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 9257a7fbcb..8128447a02 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -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 `; 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. diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 2405ba0480..5b2806be12 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -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