diff --git a/dikte/cli.py b/dikte/cli.py index 3109348..584ab8a 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -227,7 +227,8 @@ def cmd_ask(opts): conf["assistant_provider"] = opts.provider if opts.model: key = {"claude": "assistant_model", "codex": "assistant_codex_model", - "openrouter": "assistant_openrouter_model"}[assistant.provider(conf)] + "openrouter": "assistant_openrouter_model", + "opencode": "assistant_opencode_model"}[assistant.provider(conf)] conf[key] = opts.model if opts.dir: conf["assistant_dir"] = opts.dir @@ -519,7 +520,7 @@ def cmd_history_clear(opts): # --- settings --------------------------------------------------------------- -SECRET_KEYS = ("openai_api_key", "openrouter_api_key") +SECRET_KEYS = ("openai_api_key", "openrouter_api_key", "opencode_api_key") def _mask(key, value): @@ -709,6 +710,14 @@ def cmd_test_key(opts): results[name] = {"ok": True, "message": message} except api.ApiError as exc: results[name] = {"ok": False, "message": str(exc)} + if opts.which in ("opencode", "all"): + try: + count = len(api.openai_models(conf.opencode_key(), + conf["opencode_base_url"], "OpenCode Go")) + results["opencode"] = {"ok": True, + "message": f"connection works, {count} models visible"} + except api.ApiError as exc: + results["opencode"] = {"ok": False, "message": str(exc)} everything_ok = all(item["ok"] for item in results.values()) lines = [f"{'✓' if item['ok'] else '✗'} {name}: {item['message']}" for name, item in results.items()] @@ -875,7 +884,9 @@ def cmd_doctor(opts): "key": bool(target.api_key)}, "cleanup": {"enabled": conf["cleanup_enabled"], "provider": cleaner, "model": cleanup.model(conf), - "key": bool(conf.openrouter_key())}, + "key": (bool(conf.openrouter_key()) if cleaner == "openrouter" + else bool(conf.opencode_key()) + if cleaner == "opencode" else None)}, "agent": {"provider": assistant.provider(conf), "directory": assistant.working_dir(conf)}, "running": ipc.send("status") is not None, @@ -888,6 +899,8 @@ def cmd_doctor(opts): # Cleanup on a CLI needs no key, so what is checked is the program. (f"{'✓' if conf.openrouter_key() else '✗'} OpenRouter key, cleaning up on " f"{conf['cleanup_model']}") if cleaner == "openrouter" else + (f"{'✓' if conf.opencode_key() else '✗'} OpenCode Go key, cleaning up on " + f"{conf['cleanup_opencode_model']}") if cleaner == "opencode" else (f"{'✓' if programs[cleanup.executable(cleaner)] else '✗'} " f"{cleanup.executable(cleaner)}, cleaning up on {cleanup.model(conf)}"), f"{'✓' if checks['running'] else '·'} application " @@ -977,7 +990,7 @@ def build_parser(): ask = leaf(subs, "ask", "put a command to the agent") ask.add_argument("text", nargs="*", help="the command; read from stdin, or " "recorded when there is none") - ask.add_argument("--provider", choices=("claude", "codex", "openrouter"), + ask.add_argument("--provider", choices=("claude", "codex", "openrouter", "opencode"), help="just for this run") ask.add_argument("--model", help="just for this run") ask.add_argument("--dir", help="working directory, just for this run") @@ -1100,7 +1113,7 @@ def build_parser(): models.set_defaults(func=cmd_models) test = leaf(subs, "test-key", "check the API keys") test.add_argument("which", nargs="?", default="all", - choices=("all", *cfg.TRANSCRIBERS)) + choices=("all", *cfg.TRANSCRIBERS, "opencode")) test.set_defaults(func=cmd_test_key) leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) diff --git a/tests/test_cli.py b/tests/test_cli.py index 585bad5..7ef369f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -423,6 +423,16 @@ class Providers(DikteTest): self.assertIn("groq", out) self.assertIn("Groq", out) + def test_opencode_is_a_choice_and_reports_under_its_own_name(self): + parser = cli.build_parser() + self.assertEqual( + parser.parse_args(["test-key", "opencode"]).which, "opencode") + self.write_config({"opencode_api_key": "opencode-test"}) + with fake_urlopen({"data": [{"id": "deepseek-v4-flash"}]}): + code, out, _ = self.run_cmd(cli.cmd_test_key, which="opencode") + self.assertEqual(code, 0) + self.assertIn("opencode: connection works, 1 models visible", out) + class Updates(DikteTest): """`dikte update` looks, says what it found, and installs nothing.""" @@ -503,6 +513,15 @@ class Doctor(DikteTest): self.assertIn("OpenRouter key, cleaning up on some/model", self.run_doctor(as_json=False, cleanup_model="some/model")) + def test_cleanup_on_opencode_is_a_question_about_its_own_key(self): + reply = self.run_doctor(cleanup_provider="opencode", + cleanup_opencode_model="glm-5.3") + self.assertEqual(reply["cleanup"]["provider"], "opencode") + self.assertEqual(reply["cleanup"]["model"], "glm-5.3") + self.assertIn("OpenCode Go key, cleaning up on glm-5.3", + self.run_doctor(as_json=False, cleanup_provider="opencode", + cleanup_opencode_model="glm-5.3")) + def test_it_asks_after_the_programs_this_desktop_actually_uses(self): """A missing ydotool on a Mac is a red mark with nothing behind it.""" with mock.patch.object(cli.paste, "desktop", return_value=paste.MACOS): diff --git a/tests/test_config.py b/tests/test_config.py index 9d8e372..24f4da6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -134,6 +134,8 @@ class Keys(DikteTest): def test_every_provider_falls_back_to_the_variable_of_its_own_name(self): with mock.patch.dict(os.environ, {"GROQ_API_KEY": "gsk-env"}): self.assertEqual(cfg.Config().groq_key(), "gsk-env") + with mock.patch.dict(os.environ, {"OPENCODE_API_KEY": "opencode-env"}): + self.assertEqual(cfg.Config().opencode_key(), "opencode-env") class TranscribeTarget(DikteTest): @@ -449,6 +451,13 @@ class Defaults(unittest.TestCase): def test_the_keys_ship_empty(self): self.assertEqual(cfg.DEFAULTS["openai_api_key"], "") self.assertEqual(cfg.DEFAULTS["openrouter_api_key"], "") + self.assertEqual(cfg.DEFAULTS["opencode_api_key"], "") + + def test_opencode_ships_on_its_own_endpoint(self): + self.assertEqual(cfg.DEFAULTS["opencode_base_url"], + "https://opencode.ai/zen/go/v1") + self.assertEqual(cfg.DEFAULTS["cleanup_opencode_model"], "deepseek-v4-flash") + self.assertEqual(cfg.DEFAULTS["assistant_opencode_model"], "deepseek-v4-flash") def test_every_language_specific_prompt_has_both_languages(self): for name in ("CLEANUP_PROMPT", "FILE_CLEANUP_PROMPT", "MEETING_PROMPT",