nonoka-cli 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/PKG-INFO +20 -8
  2. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/README.md +17 -6
  3. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/nonoka.yaml.example +6 -0
  4. nonoka_cli-0.2.3/packages/nonoka-opencode-provider/nonoka-opencode-provider-0.2.0.tgz +0 -0
  5. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/pyproject.toml +3 -2
  6. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/cli.py +20 -0
  7. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/commands/config_cmd.py +109 -29
  8. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/config/models.py +2 -0
  9. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/commands/test_config_cmd.py +39 -0
  10. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/commands/test_doctor_cmd.py +10 -7
  11. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/uv.lock +7 -5
  12. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/.gitignore +0 -0
  13. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/LICENSE +0 -0
  14. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/NOTICE +0 -0
  15. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/TODO.md +0 -0
  16. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/install.sh +0 -0
  17. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/nonoka.yaml +0 -0
  18. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/.gitignore +0 -0
  19. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/LICENSE +0 -0
  20. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/README.md +0 -0
  21. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/bun.lock +0 -0
  22. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/package.json +0 -0
  23. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/src/index.ts +0 -0
  24. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/src/nonoka-language-model.ts +0 -0
  25. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/src/protocol.ts +0 -0
  26. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/src/stream.ts +0 -0
  27. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/tests/language-model.test.ts +0 -0
  28. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/tests/protocol.test.ts +0 -0
  29. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/tests/stream.test.ts +0 -0
  30. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/packages/nonoka-opencode-provider/tsconfig.json +0 -0
  31. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/__init__.py +0 -0
  32. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/__main__.py +0 -0
  33. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/bridge/__init__.py +0 -0
  34. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/bridge/events.py +0 -0
  35. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/bridge/handler.py +0 -0
  36. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/bridge/protocol.py +0 -0
  37. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/bridge/server.py +0 -0
  38. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/commands/__init__.py +0 -0
  39. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/commands/doctor_cmd.py +0 -0
  40. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/commands/opencode_cmd.py +0 -0
  41. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/config/__init__.py +0 -0
  42. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/config/loader.py +0 -0
  43. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/config/manager.py +0 -0
  44. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/__init__.py +0 -0
  45. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/agent_factory.py +0 -0
  46. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/context.py +0 -0
  47. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/mcp_service.py +0 -0
  48. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/orchestrator.py +0 -0
  49. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/runner_service.py +0 -0
  50. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/session_service.py +0 -0
  51. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/core/tool_service.py +0 -0
  52. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/mcp/__init__.py +0 -0
  53. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/mcp/manager.py +0 -0
  54. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/mcp/models.py +0 -0
  55. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/sessions/__init__.py +0 -0
  56. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/sessions/manager.py +0 -0
  57. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/sessions/models.py +0 -0
  58. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/skills/__init__.py +0 -0
  59. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/skills/manager.py +0 -0
  60. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/tools/__init__.py +0 -0
  61. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/tools/builtins/__init__.py +0 -0
  62. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/tools/builtins/file_tools.py +0 -0
  63. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/tools/loader.py +0 -0
  64. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/utils/__init__.py +0 -0
  65. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/utils/errors.py +0 -0
  66. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/src/nonoka_cli/utils/logging.py +0 -0
  67. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/conftest.py +0 -0
  68. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/bridge/test_events.py +0 -0
  69. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/bridge/test_handler.py +0 -0
  70. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/bridge/test_protocol.py +0 -0
  71. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/commands/test_opencode_cmd.py +0 -0
  72. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/config/test_loader.py +0 -0
  73. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/config/test_models.py +0 -0
  74. {nonoka_cli-0.2.1 → nonoka_cli-0.2.3}/tests/unit/sessions/test_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonoka-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: OpenCode backend for the Nonoka Agent framework
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -14,8 +14,9 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: >=3.10
17
- Requires-Dist: nonoka>=1.3.1
17
+ Requires-Dist: nonoka>=1.3.2
18
18
  Requires-Dist: pydantic>=2.0
19
+ Requires-Dist: python-dotenv>=1.0
19
20
  Requires-Dist: pyyaml>=6.0
20
21
  Requires-Dist: structlog>=24.0.0
21
22
  Provides-Extra: dev
@@ -48,14 +49,22 @@ The installer will:
48
49
  3. Install `nonoka-cli` and the OpenCode provider.
49
50
  4. Generate `~/.config/nonoka/config.yaml` and `~/.config/opencode/opencode.json`.
50
51
 
51
- After installing, export your model API key and run `opencode`:
52
+ After installing, configure your API key and run `opencode`:
52
53
 
53
54
  ```bash
55
+ # Interactive: it will ask for your key and save it to ~/.config/nonoka/.env
56
+ nonoka-cli config init
57
+
58
+ # Or set it manually
54
59
  export DEEPSEEK_API_KEY=<your-key>
60
+
55
61
  nonoka-cli doctor
56
62
  opencode
57
63
  ```
58
64
 
65
+ `nonoka-cli` automatically loads `~/.config/nonoka/.env` and `./.env` on startup,
66
+ so you don't need to `export` every time if you save the key in `.env`.
67
+
59
68
  > To use `uv` instead of `pip`, or to run non-interactively, pass flags:
60
69
  > `curl -fsSL https://nonoka.dev/install.sh | bash -s -- --uv --yes`.
61
70
 
@@ -73,13 +82,15 @@ npm install -g nonoka-opencode-provider
73
82
 
74
83
  ## Quick start
75
84
 
76
- 1. Create your nonoka config:
85
+ 1. Create your nonoka config (it will ask for your API key and save it to
86
+ `~/.config/nonoka/.env`):
77
87
 
78
88
  ```bash
79
89
  nonoka-cli config init
80
90
  ```
81
91
 
82
- For scripted setups, use the non-interactive mode:
92
+ For scripted setups, use the non-interactive mode (you'll still need to set the
93
+ API key via `.env` or `export`):
83
94
 
84
95
  ```bash
85
96
  nonoka-cli config init --yes --model deepseek-chat
@@ -134,9 +145,10 @@ nonoka-cli doctor --check-llm
134
145
  ### `nonoka-cli config init`
135
146
 
136
147
  Interactive wizard that writes `~/.config/nonoka/config.yaml`. It asks for a
137
- model identifier (e.g. `deepseek-chat`, `openai/gpt-4o`, `ollama/llama3.3`), an
138
- optional API-key environment variable, a system prompt, and whether to
139
- auto-approve all tool calls.
148
+ model identifier (e.g. `deepseek-chat`, `openai/gpt-4o`, `ollama/llama3.3`), a
149
+ masked API key, and whether to save it to `~/.config/nonoka/.env` (recommended),
150
+ directly in `config.yaml`, or skip saving. It also asks for a system prompt and
151
+ whether to auto-approve all tool calls.
140
152
 
141
153
  Non-interactive example:
142
154
 
@@ -22,14 +22,22 @@ The installer will:
22
22
  3. Install `nonoka-cli` and the OpenCode provider.
23
23
  4. Generate `~/.config/nonoka/config.yaml` and `~/.config/opencode/opencode.json`.
24
24
 
25
- After installing, export your model API key and run `opencode`:
25
+ After installing, configure your API key and run `opencode`:
26
26
 
27
27
  ```bash
28
+ # Interactive: it will ask for your key and save it to ~/.config/nonoka/.env
29
+ nonoka-cli config init
30
+
31
+ # Or set it manually
28
32
  export DEEPSEEK_API_KEY=<your-key>
33
+
29
34
  nonoka-cli doctor
30
35
  opencode
31
36
  ```
32
37
 
38
+ `nonoka-cli` automatically loads `~/.config/nonoka/.env` and `./.env` on startup,
39
+ so you don't need to `export` every time if you save the key in `.env`.
40
+
33
41
  > To use `uv` instead of `pip`, or to run non-interactively, pass flags:
34
42
  > `curl -fsSL https://nonoka.dev/install.sh | bash -s -- --uv --yes`.
35
43
 
@@ -47,13 +55,15 @@ npm install -g nonoka-opencode-provider
47
55
 
48
56
  ## Quick start
49
57
 
50
- 1. Create your nonoka config:
58
+ 1. Create your nonoka config (it will ask for your API key and save it to
59
+ `~/.config/nonoka/.env`):
51
60
 
52
61
  ```bash
53
62
  nonoka-cli config init
54
63
  ```
55
64
 
56
- For scripted setups, use the non-interactive mode:
65
+ For scripted setups, use the non-interactive mode (you'll still need to set the
66
+ API key via `.env` or `export`):
57
67
 
58
68
  ```bash
59
69
  nonoka-cli config init --yes --model deepseek-chat
@@ -108,9 +118,10 @@ nonoka-cli doctor --check-llm
108
118
  ### `nonoka-cli config init`
109
119
 
110
120
  Interactive wizard that writes `~/.config/nonoka/config.yaml`. It asks for a
111
- model identifier (e.g. `deepseek-chat`, `openai/gpt-4o`, `ollama/llama3.3`), an
112
- optional API-key environment variable, a system prompt, and whether to
113
- auto-approve all tool calls.
121
+ model identifier (e.g. `deepseek-chat`, `openai/gpt-4o`, `ollama/llama3.3`), a
122
+ masked API key, and whether to save it to `~/.config/nonoka/.env` (recommended),
123
+ directly in `config.yaml`, or skip saving. It also asks for a system prompt and
124
+ whether to auto-approve all tool calls.
114
125
 
115
126
  Non-interactive example:
116
127
 
@@ -3,6 +3,12 @@
3
3
 
4
4
  model: "deepseek-chat"
5
5
 
6
+ # API key / base URL are normally loaded from environment variables or a .env
7
+ # file. nonoka-cli automatically loads ~/.config/nonoka/.env and ./.env.
8
+ # You can also set them here (not recommended for api_key).
9
+ # api_key: "${DEEPSEEK_API_KEY}"
10
+ # base_url: "https://api.deepseek.com"
11
+
6
12
  # Optional: override the default system prompt.
7
13
  # If omitted, nonoka-cli uses a built-in coding-assistant prompt that
8
14
  # explains the available tools and encourages autonomous tool use.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nonoka-cli"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "OpenCode backend for the Nonoka Agent framework"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -20,10 +20,11 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.13",
21
21
  ]
22
22
  dependencies = [
23
- "nonoka>=1.3.1",
23
+ "nonoka>=1.3.2",
24
24
  "structlog>=24.0.0",
25
25
  "pydantic>=2.0",
26
26
  "pyyaml>=6.0",
27
+ "python-dotenv>=1.0",
27
28
  ]
28
29
 
29
30
  [project.scripts]
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
15
15
 
16
16
  import structlog
17
+ from dotenv import load_dotenv
17
18
 
18
19
  from nonoka_cli.bridge.server import main as server_main
19
20
  from nonoka_cli.commands import config_cmd, doctor_cmd, opencode_cmd
@@ -22,6 +23,22 @@ from nonoka_cli.utils.logging import setup_logging
22
23
  logger = structlog.get_logger("nonoka_cli.cli")
23
24
 
24
25
 
26
+ def _load_env_files() -> None:
27
+ """Load .env files so config env-var substitution works transparently.
28
+
29
+ Priority (lowest to highest):
30
+ 1. ~/.config/nonoka/.env
31
+ 2. ./.env
32
+ Existing environment variables always win.
33
+ """
34
+ global_env = Path.home() / ".config" / "nonoka" / ".env"
35
+ local_env = Path.cwd() / ".env"
36
+ for path in (global_env, local_env):
37
+ if path.exists():
38
+ load_dotenv(dotenv_path=path, override=False)
39
+ logger.debug("loaded_env_file", path=str(path))
40
+
41
+
25
42
  def _build_parser() -> argparse.ArgumentParser:
26
43
  """Build the argument parser."""
27
44
  parser = argparse.ArgumentParser(
@@ -74,6 +91,9 @@ def main() -> int:
74
91
  parser = _build_parser()
75
92
  args = parser.parse_args()
76
93
 
94
+ # Load .env files early so configuration and LLM providers see API keys.
95
+ _load_env_files()
96
+
77
97
  if args.server:
78
98
  return server_main(config_path=args.config, model=args.model)
79
99
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import getpass
6
7
  import json
7
8
  import os
8
9
  from pathlib import Path
@@ -19,6 +20,9 @@ from nonoka_cli.utils.errors import ConfigError
19
20
  logger = structlog.get_logger("nonoka_cli.commands.config")
20
21
 
21
22
 
23
+ _GLOBAL_ENV_PATH = Path.home() / ".config" / "nonoka" / ".env"
24
+
25
+
22
26
  def _load_manager(args: argparse.Namespace) -> ConfigManager:
23
27
  """Load or create a ConfigManager from the requested path."""
24
28
  path = getattr(args, "config", None)
@@ -49,6 +53,52 @@ def _confirm(prompt: str, default: bool = False) -> bool:
49
53
  return answer in ("y", "yes")
50
54
 
51
55
 
56
+ def _read_secret(prompt: str) -> str:
57
+ """Read a secret from stdin, masking input when possible."""
58
+ try:
59
+ return getpass.getpass(f"{prompt}: ").strip()
60
+ except (EOFError, KeyboardInterrupt):
61
+ return ""
62
+ except Exception:
63
+ # Fallback for non-TTY environments where getpass may fail.
64
+ value = _read_input(f"{prompt} (input will be visible)")
65
+ if value:
66
+ print("Warning: input was visible because this terminal does not support secure input.")
67
+ return value
68
+
69
+
70
+ def _load_env_file(path: Path) -> dict[str, str]:
71
+ """Parse a simple KEY=VALUE .env file, ignoring comments and blank lines."""
72
+ values: dict[str, str] = {}
73
+ if not path.exists():
74
+ return values
75
+ for line in path.read_text(encoding="utf-8").splitlines():
76
+ line = line.strip()
77
+ if not line or line.startswith("#"):
78
+ continue
79
+ if "=" in line:
80
+ key, value = line.split("=", 1)
81
+ values[key.strip()] = value.strip()
82
+ return values
83
+
84
+
85
+ def _write_env_file(path: Path, env_var: str, value: str) -> None:
86
+ """Write or update a key in a .env file."""
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ values = _load_env_file(path)
89
+ values[env_var] = value
90
+
91
+ lines: list[str] = ["# nonoka API keys and environment overrides", ""]
92
+ for key, val in values.items():
93
+ if " " in val or "#" in val:
94
+ val = f'"{val}"'
95
+ lines.append(f"{key}={val}")
96
+ lines.append("")
97
+ path.write_text("\n".join(lines), encoding="utf-8")
98
+ # Restrict permissions on the .env file.
99
+ path.chmod(0o600)
100
+
101
+
52
102
  def _set_dotted(data: dict[str, Any], key: str, value: Any) -> None:
53
103
  """Set a possibly dotted key in a nested dict, creating parents as needed."""
54
104
  parts = key.split(".")
@@ -107,6 +157,54 @@ def _api_key_env_for_model(model: str) -> str:
107
157
  return "OPENAI_API_KEY"
108
158
 
109
159
 
160
+ def _api_key_source_summary(env_var: str) -> str:
161
+ """Return a short description of where the API key is currently sourced."""
162
+ if os.getenv(env_var):
163
+ return f"from environment (${env_var})"
164
+ if _load_env_file(_GLOBAL_ENV_PATH).get(env_var):
165
+ return f"from {_GLOBAL_ENV_PATH}"
166
+ return ""
167
+
168
+
169
+ def _collect_api_key(model: str) -> tuple[str, str, str]:
170
+ """Collect API key info and optionally persist it.
171
+
172
+ Returns:
173
+ (env_var_name, api_key_for_config, summary_for_user)
174
+ """
175
+ env_var = _api_key_env_for_model(model)
176
+ existing = os.getenv(env_var) or _load_env_file(_GLOBAL_ENV_PATH).get(env_var)
177
+ if existing:
178
+ print(f"Found {env_var} already set.")
179
+ return env_var, "", f"using existing ${env_var}"
180
+
181
+ key_input = _read_secret(f"{env_var} value (press Enter to skip)")
182
+ if not key_input:
183
+ return env_var, "", "no key provided"
184
+
185
+ print("")
186
+ print("Where would you like to save the API key?")
187
+ print(" [d] ~/.config/nonoka/.env (recommended, auto-loaded, file permission 600)")
188
+ print(" [c] directly in config.yaml (not recommended)")
189
+ print(" [s] skip saving, set it manually later")
190
+ choice = _read_input("Choice", "d").lower().strip()
191
+
192
+ if choice in ("c", "config"):
193
+ print("Warning: the API key will be stored in plain text in config.yaml.")
194
+ return env_var, key_input, "stored in config.yaml"
195
+
196
+ if choice in ("s", "skip"):
197
+ print(f"Remember to set the key later: export {env_var}=<your-key>")
198
+ return env_var, "", "not saved"
199
+
200
+ # Default: save to .env
201
+ _write_env_file(_GLOBAL_ENV_PATH, env_var, key_input)
202
+ # Make it available for the rest of this process too.
203
+ os.environ[env_var] = key_input
204
+ print(f"Saved {env_var} to {_GLOBAL_ENV_PATH}")
205
+ return env_var, "", f"saved to {_GLOBAL_ENV_PATH}"
206
+
207
+
110
208
  def cmd_init(args: argparse.Namespace) -> int:
111
209
  """Create an initial nonoka config, interactively or with --yes defaults."""
112
210
  path = Path(args.config) if args.config else ConfigLoader.DEFAULT_PATH
@@ -117,13 +215,12 @@ def cmd_init(args: argparse.Namespace) -> int:
117
215
  model = getattr(args, "model", None) or "deepseek-chat"
118
216
  auto_approve = getattr(args, "auto_approve", False)
119
217
  env_var = _api_key_env_for_model(model)
120
- api_key_display = ""
121
- if os.getenv(env_var):
122
- api_key_display = "${" + env_var + "}"
218
+ key_summary = _api_key_source_summary(env_var) or "not configured"
123
219
 
124
220
  config = CLIConfig(
125
221
  model=model,
126
222
  system_prompt=_DEFAULT_SYSTEM_PROMPT,
223
+ api_key="",
127
224
  cli=CLIBehaviorConfig(auto_approve=auto_approve),
128
225
  hitl=HITLConfigModel(
129
226
  policy="auto" if auto_approve else "interactive",
@@ -136,10 +233,12 @@ def cmd_init(args: argparse.Namespace) -> int:
136
233
  print("")
137
234
  print(f"Configuration saved to {path}")
138
235
  print(f"Model: {model}")
139
- if api_key_display:
140
- print(f"API key: {api_key_display}")
141
- else:
142
- print(f"Remember to export your API key: export {env_var}=<your-key>")
236
+ print(f"API key ({env_var}): {key_summary}")
237
+ if not os.getenv(env_var) and not _load_env_file(_GLOBAL_ENV_PATH).get(env_var):
238
+ print(
239
+ f"Set your API key with: nonoka-cli config init (interactive) "
240
+ f"or export {env_var}=<your-key>"
241
+ )
143
242
  return 0
144
243
 
145
244
  print("")
@@ -149,26 +248,7 @@ def cmd_init(args: argparse.Namespace) -> int:
149
248
  )
150
249
 
151
250
  model = _read_input("Model identifier", "deepseek-chat")
152
- env_var = _read_input(
153
- "Environment variable for the API key (optional, e.g. DEEPSEEK_API_KEY)",
154
- "",
155
- ) or _api_key_env_for_model(model)
156
-
157
- api_key_display = ""
158
- if env_var:
159
- existing = os.getenv(env_var, "")
160
- if existing:
161
- print(f"Found {env_var} in environment.")
162
- api_key_display = "${" + env_var + "}"
163
- else:
164
- key_input = _read_input(f"{env_var} value (press Enter to skip)", "")
165
- if key_input:
166
- prompt = "Save the API key directly in the config file? (Not recommended)"
167
- if _confirm(prompt, default=False):
168
- api_key_display = key_input
169
- else:
170
- api_key_display = "${" + env_var + "}"
171
- print(f"Please export {env_var} in your environment before running nonoka.")
251
+ env_var, api_key_value, key_summary = _collect_api_key(model)
172
252
 
173
253
  system_prompt = _read_input(
174
254
  "Optional system prompt",
@@ -180,6 +260,7 @@ def cmd_init(args: argparse.Namespace) -> int:
180
260
  config = CLIConfig(
181
261
  model=model,
182
262
  system_prompt=system_prompt,
263
+ api_key=api_key_value,
183
264
  cli=CLIBehaviorConfig(auto_approve=auto_approve),
184
265
  hitl=HITLConfigModel(
185
266
  policy="auto" if auto_approve else "interactive",
@@ -192,8 +273,7 @@ def cmd_init(args: argparse.Namespace) -> int:
192
273
 
193
274
  print("")
194
275
  print(f"Configuration saved to {path}")
195
- if env_var and not os.getenv(env_var) and api_key_display.startswith("${"):
196
- print(f"Remember to export your API key: export {env_var}=<your-key>")
276
+ print(f"API key ({env_var}): {key_summary}")
197
277
  return 0
198
278
 
199
279
 
@@ -37,6 +37,8 @@ class CLIConfig(BaseModel):
37
37
  """
38
38
  model: str = "gpt-4o"
39
39
  system_prompt: str = ""
40
+ api_key: str = ""
41
+ base_url: str = ""
40
42
  mcp_servers: dict[str, MCPServerConfigModel] = Field(default_factory=dict)
41
43
  tool_paths: list[Path] = Field(default_factory=list)
42
44
  skills: list[str] = Field(default_factory=list)
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import os
6
7
  from pathlib import Path
7
8
 
9
+ from nonoka_cli.commands import config_cmd
8
10
  from nonoka_cli.commands.config_cmd import _coerce_value, _set_dotted, cmd_init, cmd_set
9
11
  from nonoka_cli.config.loader import ConfigLoader
10
12
  from nonoka_cli.config.models import CLIConfig
@@ -58,3 +60,40 @@ def test_config_init_yes(tmp_path: Path):
58
60
  assert cfg.model == "deepseek-chat"
59
61
  assert cfg.cli.auto_approve is False
60
62
  assert cfg.hitl.policy == "interactive"
63
+
64
+
65
+ def test_write_env_file(tmp_path: Path):
66
+ env_path = tmp_path / ".env"
67
+ config_cmd._write_env_file(env_path, "DEEPSEEK_API_KEY", "sk-secret")
68
+ assert env_path.exists()
69
+ assert env_path.stat().st_mode & 0o777 == 0o600
70
+ values = config_cmd._load_env_file(env_path)
71
+ assert values.get("DEEPSEEK_API_KEY") == "sk-secret"
72
+
73
+
74
+ def test_config_init_saves_api_key_to_env(tmp_path: Path, monkeypatch):
75
+ config_path = tmp_path / "config.yaml"
76
+ env_path = tmp_path / ".env"
77
+ monkeypatch.setattr(config_cmd, "_GLOBAL_ENV_PATH", env_path)
78
+
79
+ inputs = iter(["deepseek-chat", "", "d", ""])
80
+ def fake_read_input(prompt: str, default: str = "") -> str:
81
+ return next(inputs) if default == "" else default
82
+ monkeypatch.setattr(config_cmd, "_read_input", fake_read_input)
83
+ monkeypatch.setattr(config_cmd, "_read_secret", lambda prompt: "sk-test-key")
84
+ monkeypatch.setattr(config_cmd, "_confirm", lambda prompt, default=False: False)
85
+
86
+ args = argparse.Namespace(config=str(config_path), yes=False)
87
+ assert cmd_init(args) == 0
88
+
89
+ assert config_path.exists()
90
+ cfg = ConfigLoader.load(config_path)
91
+ assert cfg.model == "deepseek-chat"
92
+ assert cfg.api_key == ""
93
+
94
+ values = config_cmd._load_env_file(env_path)
95
+ assert values.get("DEEPSEEK_API_KEY") == "sk-test-key"
96
+ assert os.getenv("DEEPSEEK_API_KEY") == "sk-test-key"
97
+
98
+ # Clean up so the key does not leak into other tests.
99
+ monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- import os
7
6
  from pathlib import Path
8
7
  from unittest import mock
9
8
 
@@ -52,8 +51,9 @@ class TestCheckNonokaCliVersion:
52
51
 
53
52
 
54
53
  class TestCheckConfig:
55
- def test_missing_config(self, tmp_path: Path):
56
- os.chdir(tmp_path)
54
+ def test_missing_config(self, tmp_path: Path, monkeypatch):
55
+ monkeypatch.setattr(ConfigLoader, "DEFAULT_PATH", tmp_path / "config.yaml")
56
+ monkeypatch.chdir(tmp_path)
57
57
  result, cfg = check_config(None)
58
58
  assert result.status == "error"
59
59
  assert cfg is None
@@ -75,7 +75,8 @@ class TestCheckApiKey:
75
75
  assert result.status == "ok"
76
76
  assert "DEEPSEEK_API_KEY" in result.message
77
77
 
78
- def test_key_missing(self):
78
+ def test_key_missing(self, monkeypatch):
79
+ monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
79
80
  cfg = CLIConfig(model="deepseek-chat")
80
81
  result = check_api_key(cfg)
81
82
  assert result.status == "error"
@@ -125,8 +126,9 @@ class TestCheckProvider:
125
126
 
126
127
 
127
128
  class TestCheckOpencodeConfig:
128
- def test_missing_config(self, tmp_path: Path):
129
- os.chdir(tmp_path)
129
+ def test_missing_config(self, tmp_path: Path, monkeypatch):
130
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
131
+ monkeypatch.chdir(tmp_path)
130
132
  result = check_opencode_config()
131
133
  assert result.status == "error"
132
134
 
@@ -144,7 +146,8 @@ class TestCheckOpencodeConfig:
144
146
  result = check_opencode_config()
145
147
  assert result.status == "ok"
146
148
 
147
- def test_config_without_nonoka_provider(self, tmp_path: Path):
149
+ def test_config_without_nonoka_provider(self, tmp_path: Path, monkeypatch):
150
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
148
151
  config_path = tmp_path / "opencode.json"
149
152
  config_path.write_text(json.dumps({"model": "gpt-4o"}))
150
153
  result = check_opencode_config()
@@ -1609,7 +1609,7 @@ wheels = [
1609
1609
 
1610
1610
  [[package]]
1611
1611
  name = "nonoka"
1612
- version = "1.3.1"
1612
+ version = "1.3.2"
1613
1613
  source = { registry = "https://pypi.org/simple" }
1614
1614
  dependencies = [
1615
1615
  { name = "anyio" },
@@ -1623,18 +1623,19 @@ dependencies = [
1623
1623
  { name = "structlog" },
1624
1624
  { name = "tomli", marker = "python_full_version < '3.11'" },
1625
1625
  ]
1626
- sdist = { url = "https://files.pythonhosted.org/packages/32/8c/870d3d6d2a86fcc3198531ea9d2bcd86d1210097b7684dae0197e61660fb/nonoka-1.3.1.tar.gz", hash = "sha256:b8a8895360dad949667f8c968795f7bd40a39b05bb5ae74990187ff3b8ffd593", size = 139306, upload-time = "2026-06-30T15:04:01.054Z" }
1626
+ sdist = { url = "https://files.pythonhosted.org/packages/49/36/37a0f7f82c3ee199f7502973c746d647654a90538c540f9cff8c43bae911/nonoka-1.3.2.tar.gz", hash = "sha256:fb00ab1feb7be0791664694b9216ae7f8214435f3b97be12a6ac596f6ff174a2", size = 140261, upload-time = "2026-07-02T15:26:01.99Z" }
1627
1627
  wheels = [
1628
- { url = "https://files.pythonhosted.org/packages/c3/48/21767d38db87bedaaacc12ac77d7446547df97c0960397261a559f6ebed4/nonoka-1.3.1-py3-none-any.whl", hash = "sha256:ecd17d8da7d6f02512ccda438ab4c9e84b90df79c7fc87ec5c3b866f663e8162", size = 102961, upload-time = "2026-06-30T15:03:29.924Z" },
1628
+ { url = "https://files.pythonhosted.org/packages/87/99/1f9e7a0fbdc4c31b8b41e16f6c73f44e0daa5c2d16a1ccc0dc11b94a1aba/nonoka-1.3.2-py3-none-any.whl", hash = "sha256:795c6b1c14aaa37d73a0855f999c3d031013d3a0eafcc69295e4b67afc310575", size = 103557, upload-time = "2026-07-02T15:25:55.658Z" },
1629
1629
  ]
1630
1630
 
1631
1631
  [[package]]
1632
1632
  name = "nonoka-cli"
1633
- version = "0.2.1"
1633
+ version = "0.2.3"
1634
1634
  source = { editable = "." }
1635
1635
  dependencies = [
1636
1636
  { name = "nonoka" },
1637
1637
  { name = "pydantic" },
1638
+ { name = "python-dotenv" },
1638
1639
  { name = "pyyaml" },
1639
1640
  { name = "structlog" },
1640
1641
  ]
@@ -1648,11 +1649,12 @@ dev = [
1648
1649
 
1649
1650
  [package.metadata]
1650
1651
  requires-dist = [
1651
- { name = "nonoka", specifier = ">=1.3.1" },
1652
+ { name = "nonoka", specifier = ">=1.3.2" },
1652
1653
  { name = "pydantic", specifier = ">=2.0" },
1653
1654
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
1654
1655
  { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
1655
1656
  { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" },
1657
+ { name = "python-dotenv", specifier = ">=1.0" },
1656
1658
  { name = "pyyaml", specifier = ">=6.0" },
1657
1659
  { name = "structlog", specifier = ">=24.0.0" },
1658
1660
  ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes