nonoka-cli 0.2.12__tar.gz → 0.2.13__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 (122) hide show
  1. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/PKG-INFO +34 -3
  2. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/README.md +33 -2
  3. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/pyproject.toml +1 -1
  4. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/__init__.py +1 -1
  5. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/cli.py +18 -6
  6. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/config_cmd.py +24 -13
  7. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/opencode_cmd.py +2 -0
  8. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/config/loader.py +6 -2
  9. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_cli_entrypoint.py +15 -1
  10. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_config_cmd.py +7 -1
  11. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_opencode_cmd.py +2 -2
  12. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/config/test_loader.py +4 -0
  13. nonoka_cli-0.2.13/tests/unit/test_install_script.py +200 -0
  14. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/.gitignore +0 -0
  15. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/LICENSE +0 -0
  16. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/NOTICE +0 -0
  17. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/__main__.py +0 -0
  18. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/benchmark/__init__.py +0 -0
  19. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/benchmark/harbor.py +0 -0
  20. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/benchmark/swe_bench.py +0 -0
  21. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/benchmark/swe_environment.py +0 -0
  22. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/benchmark/swe_runner.py +0 -0
  23. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/benchmark/watchdog.py +0 -0
  24. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/bridge/__init__.py +0 -0
  25. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/bridge/events.py +0 -0
  26. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/bridge/handler.py +0 -0
  27. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/bridge/nonoka_tools.py +0 -0
  28. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/bridge/protocol.py +0 -0
  29. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/bridge/server.py +0 -0
  30. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/__init__.py +0 -0
  31. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/benchmark_cmd.py +0 -0
  32. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/doctor_cmd.py +0 -0
  33. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/eval_cmd.py +0 -0
  34. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/logs_cmd.py +0 -0
  35. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/plugin_cmd.py +0 -0
  36. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/run_cmd.py +0 -0
  37. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/commands/sessions_cmd.py +0 -0
  38. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/config/__init__.py +0 -0
  39. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/config/manager.py +0 -0
  40. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/config/models.py +0 -0
  41. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/__init__.py +0 -0
  42. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/agent_factory.py +0 -0
  43. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/context.py +0 -0
  44. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/context_trimmer.py +0 -0
  45. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/git_service.py +0 -0
  46. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/mcp_service.py +0 -0
  47. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/namespaces.py +0 -0
  48. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/operational_signals.py +0 -0
  49. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/orchestrator.py +0 -0
  50. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/plugin_manifest.py +0 -0
  51. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/plugin_manifest_converter.py +0 -0
  52. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/project_agents.py +0 -0
  53. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/prompt_builder.py +0 -0
  54. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/repo_map_service.py +0 -0
  55. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/run_evidence.py +0 -0
  56. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/runner_service.py +0 -0
  57. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/scorecard.py +0 -0
  58. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/semantic_cache.py +0 -0
  59. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/session_service.py +0 -0
  60. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/task_state.py +0 -0
  61. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/tool_output_policy.py +0 -0
  62. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/core/tool_service.py +0 -0
  63. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/mcp/__init__.py +0 -0
  64. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/mcp/manager.py +0 -0
  65. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/mcp/models.py +0 -0
  66. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/safety/__init__.py +0 -0
  67. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/safety/preflight.py +0 -0
  68. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/safety/sandbox.py +0 -0
  69. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/sessions/__init__.py +0 -0
  70. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/sessions/manager.py +0 -0
  71. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/sessions/models.py +0 -0
  72. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/skills/__init__.py +0 -0
  73. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/tools/__init__.py +0 -0
  74. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/tools/builtins/__init__.py +0 -0
  75. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/tools/builtins/agent_tools.py +0 -0
  76. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/tools/builtins/file_tools.py +0 -0
  77. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/tools/loader.py +0 -0
  78. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/utils/__init__.py +0 -0
  79. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/utils/errors.py +0 -0
  80. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/utils/logging.py +0 -0
  81. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/src/nonoka_cli/utils/trace_logger.py +0 -0
  82. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/conftest.py +0 -0
  83. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/fixtures/bridge_external_capabilities.json +0 -0
  84. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/integration/test_server_external_capabilities.py +0 -0
  85. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/integration/test_watchdog_process_group.py +0 -0
  86. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/benchmark/test_harbor.py +0 -0
  87. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/benchmark/test_swe_environment.py +0 -0
  88. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/benchmark/test_watchdog.py +0 -0
  89. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/bridge/test_events.py +0 -0
  90. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/bridge/test_handler.py +0 -0
  91. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/bridge/test_nonoka_tools.py +0 -0
  92. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/bridge/test_package.py +0 -0
  93. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/bridge/test_protocol.py +0 -0
  94. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/bridge/test_server.py +0 -0
  95. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_benchmark_cmd.py +0 -0
  96. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_doctor_cmd.py +0 -0
  97. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_eval_cmd.py +0 -0
  98. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_logs_cmd.py +0 -0
  99. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_plugin_cmd.py +0 -0
  100. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_run_cmd.py +0 -0
  101. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/commands/test_swe_bench_cmd.py +0 -0
  102. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/config/test_models.py +0 -0
  103. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_agent_factory.py +0 -0
  104. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_context_trimmer.py +0 -0
  105. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_git_service.py +0 -0
  106. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_operational_signals.py +0 -0
  107. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_orchestrator.py +0 -0
  108. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_plugin_manifest.py +0 -0
  109. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_plugin_manifest_converter.py +0 -0
  110. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_project_agents.py +0 -0
  111. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_prompt_builder.py +0 -0
  112. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_repo_map_service.py +0 -0
  113. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_safety_policy.py +0 -0
  114. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_scorecard.py +0 -0
  115. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_semantic_cache.py +0 -0
  116. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_task_state.py +0 -0
  117. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/core/test_tool_output_policy.py +0 -0
  118. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/safety/test_preflight.py +0 -0
  119. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/sessions/test_manager.py +0 -0
  120. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/tools/test_file_tools.py +0 -0
  121. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/utils/test_logging.py +0 -0
  122. {nonoka_cli-0.2.12 → nonoka_cli-0.2.13}/tests/unit/utils/test_trace_logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonoka-cli
3
- Version: 0.2.12
3
+ Version: 0.2.13
4
4
  Summary: OpenCode backend for the Nonoka Agent framework
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -51,7 +51,23 @@ The installer will:
51
51
  1. Check Python 3.10+ and Node/npm.
52
52
  2. Install or update OpenCode.
53
53
  3. Install `nonoka-cli` and the OpenCode provider.
54
- 4. Generate `~/.config/nonoka/config.yaml` and `~/.config/opencode/opencode.json`.
54
+ 4. Ask where to keep the installation, configuration, and npm packages.
55
+ 5. Generate the Nonoka and project-level OpenCode configuration.
56
+
57
+ The prompts show what each directory contains and provide these defaults:
58
+
59
+ ```text
60
+ Installation directory (Python environment, launchers, and npm tools; e.g. ~/nonoka)
61
+ [~/.local/share/nonoka]
62
+ Configuration directory (config.yaml and .env; e.g. ~/.config/nonoka)
63
+ [~/.config/nonoka]
64
+ npm prefix (OpenCode/provider global packages)
65
+ [~/.local/share/nonoka/npm]
66
+ ```
67
+
68
+ Press Enter to accept a default, or type a path such as `~/tools/nonoka`.
69
+ The generated `INSTALL_DIR/bin/nonoka` launcher remembers all three paths, so
70
+ using its absolute path does not require any environment exports.
55
71
 
56
72
  After installing, configure your API key and run `nonoka`:
57
73
 
@@ -76,6 +92,21 @@ so you don't need to `export` every time if you save the key in `.env`.
76
92
  > To use `uv` instead of `pip`, or to run non-interactively, pass flags:
77
93
  > `curl -fsSL https://nonoka.dev/install.sh | bash -s -- --uv --yes`.
78
94
 
95
+ For a non-interactive custom location, use flags (environment variables with
96
+ the same names are also supported):
97
+
98
+ ```bash
99
+ bash install.sh --yes --uv --npm-opencode \
100
+ --install-dir ~/tools/nonoka \
101
+ --config-dir ~/.config/nonoka \
102
+ --npm-prefix ~/tools/nonoka/npm
103
+
104
+ ~/tools/nonoka/bin/nonoka doctor
105
+ ```
106
+
107
+ The corresponding variables are `NONOKA_INSTALL_DIR`, `NONOKA_CONFIG_DIR`,
108
+ and `NONOKA_NPM_PREFIX`. CLI flags take precedence over environment variables.
109
+
79
110
  ## Manual installation
80
111
 
81
112
  ```bash
@@ -132,7 +163,7 @@ Example output:
132
163
 
133
164
  ```
134
165
  nonoka-cli doctor
135
- ✓ nonoka-cli 0.2.12
166
+ ✓ nonoka-cli 0.2.13
136
167
  ✓ Python 3.11
137
168
  ✓ opencode 1.18.2
138
169
  ✓ provider nonoka-opencode-provider@0.2.17
@@ -22,7 +22,23 @@ The installer will:
22
22
  1. Check Python 3.10+ and Node/npm.
23
23
  2. Install or update OpenCode.
24
24
  3. Install `nonoka-cli` and the OpenCode provider.
25
- 4. Generate `~/.config/nonoka/config.yaml` and `~/.config/opencode/opencode.json`.
25
+ 4. Ask where to keep the installation, configuration, and npm packages.
26
+ 5. Generate the Nonoka and project-level OpenCode configuration.
27
+
28
+ The prompts show what each directory contains and provide these defaults:
29
+
30
+ ```text
31
+ Installation directory (Python environment, launchers, and npm tools; e.g. ~/nonoka)
32
+ [~/.local/share/nonoka]
33
+ Configuration directory (config.yaml and .env; e.g. ~/.config/nonoka)
34
+ [~/.config/nonoka]
35
+ npm prefix (OpenCode/provider global packages)
36
+ [~/.local/share/nonoka/npm]
37
+ ```
38
+
39
+ Press Enter to accept a default, or type a path such as `~/tools/nonoka`.
40
+ The generated `INSTALL_DIR/bin/nonoka` launcher remembers all three paths, so
41
+ using its absolute path does not require any environment exports.
26
42
 
27
43
  After installing, configure your API key and run `nonoka`:
28
44
 
@@ -47,6 +63,21 @@ so you don't need to `export` every time if you save the key in `.env`.
47
63
  > To use `uv` instead of `pip`, or to run non-interactively, pass flags:
48
64
  > `curl -fsSL https://nonoka.dev/install.sh | bash -s -- --uv --yes`.
49
65
 
66
+ For a non-interactive custom location, use flags (environment variables with
67
+ the same names are also supported):
68
+
69
+ ```bash
70
+ bash install.sh --yes --uv --npm-opencode \
71
+ --install-dir ~/tools/nonoka \
72
+ --config-dir ~/.config/nonoka \
73
+ --npm-prefix ~/tools/nonoka/npm
74
+
75
+ ~/tools/nonoka/bin/nonoka doctor
76
+ ```
77
+
78
+ The corresponding variables are `NONOKA_INSTALL_DIR`, `NONOKA_CONFIG_DIR`,
79
+ and `NONOKA_NPM_PREFIX`. CLI flags take precedence over environment variables.
80
+
50
81
  ## Manual installation
51
82
 
52
83
  ```bash
@@ -103,7 +134,7 @@ Example output:
103
134
 
104
135
  ```
105
136
  nonoka-cli doctor
106
- ✓ nonoka-cli 0.2.12
137
+ ✓ nonoka-cli 0.2.13
107
138
  ✓ Python 3.11
108
139
  ✓ opencode 1.18.2
109
140
  ✓ provider nonoka-opencode-provider@0.2.17
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nonoka-cli"
7
- version = "0.2.12"
7
+ version = "0.2.13"
8
8
  description = "OpenCode backend for the Nonoka Agent framework"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.2.12"
5
+ __version__ = "0.2.13"
@@ -51,17 +51,29 @@ def _version() -> str:
51
51
  return "unknown"
52
52
 
53
53
 
54
- def _load_env_files() -> None:
54
+ def _load_env_files(config_path: Path | str | None = None) -> None:
55
55
  """Load .env files so config env-var substitution works transparently.
56
56
 
57
57
  Priority (lowest to highest):
58
- 1. ~/.config/nonoka/.env
59
- 2. ./.env
58
+ 1. $NONOKA_CONFIG_DIR/.env or ~/.config/nonoka/.env
59
+ 2. the explicit config file's sibling .env
60
+ 3. ./.env
60
61
  Existing environment variables always win.
61
62
  """
62
- global_env = Path.home() / ".config" / "nonoka" / ".env"
63
+ configured_dir = Path(
64
+ os.getenv("NONOKA_CONFIG_DIR", str(Path.home() / ".config" / "nonoka"))
65
+ ).expanduser()
66
+ candidates = [configured_dir / ".env"]
67
+ if config_path is not None:
68
+ candidates.append(Path(config_path).expanduser().resolve().parent / ".env")
63
69
  local_env = Path.cwd() / ".env"
64
- for path in (global_env, local_env):
70
+ candidates.append(local_env)
71
+ seen: set[Path] = set()
72
+ for path in candidates:
73
+ path = path.resolve()
74
+ if path in seen:
75
+ continue
76
+ seen.add(path)
65
77
  if path.exists():
66
78
  load_dotenv(dotenv_path=path, override=False)
67
79
  logger.debug("loaded_env_file", path=str(path))
@@ -140,7 +152,7 @@ def main() -> int:
140
152
 
141
153
  # Load .env files after logging is configured so the debug-only path message
142
154
  # does not leak into normal command output.
143
- _load_env_files()
155
+ _load_env_files(args.config)
144
156
  if args.server:
145
157
  return server_main(config_path=args.config, model=args.model)
146
158
 
@@ -23,6 +23,16 @@ logger = structlog.get_logger("nonoka_cli.commands.config")
23
23
  _GLOBAL_ENV_PATH = Path.home() / ".config" / "nonoka" / ".env"
24
24
 
25
25
 
26
+ def _env_path_for_config(config_path: Path | None) -> Path:
27
+ """Keep credentials beside an explicitly selected configuration file."""
28
+ if config_path is not None:
29
+ return config_path.expanduser().resolve().parent / ".env"
30
+ configured_dir = os.getenv("NONOKA_CONFIG_DIR")
31
+ if configured_dir:
32
+ return Path(configured_dir).expanduser().resolve() / ".env"
33
+ return _GLOBAL_ENV_PATH
34
+
35
+
26
36
  def _load_manager(args: argparse.Namespace) -> ConfigManager:
27
37
  """Load or create a ConfigManager from the requested path."""
28
38
  path = getattr(args, "config", None)
@@ -162,23 +172,23 @@ def _api_key_env_for_model(model: str) -> str:
162
172
  return "OPENAI_API_KEY"
163
173
 
164
174
 
165
- def _api_key_source_summary(env_var: str) -> str:
175
+ def _api_key_source_summary(env_var: str, env_path: Path = _GLOBAL_ENV_PATH) -> str:
166
176
  """Return a short description of where the API key is currently sourced."""
167
177
  if os.getenv(env_var):
168
178
  return f"from environment (${env_var})"
169
- if _load_env_file(_GLOBAL_ENV_PATH).get(env_var):
170
- return f"from {_GLOBAL_ENV_PATH}"
179
+ if _load_env_file(env_path).get(env_var):
180
+ return f"from {env_path}"
171
181
  return ""
172
182
 
173
183
 
174
- def _collect_api_key(model: str) -> tuple[str, str, str]:
184
+ def _collect_api_key(model: str, env_path: Path = _GLOBAL_ENV_PATH) -> tuple[str, str, str]:
175
185
  """Collect API key info and optionally persist it.
176
186
 
177
187
  Returns:
178
188
  (env_var_name, api_key_for_config, summary_for_user)
179
189
  """
180
190
  env_var = _api_key_env_for_model(model)
181
- existing = os.getenv(env_var) or _load_env_file(_GLOBAL_ENV_PATH).get(env_var)
191
+ existing = os.getenv(env_var) or _load_env_file(env_path).get(env_var)
182
192
  if existing:
183
193
  print(f"Found {env_var} already set.")
184
194
  return env_var, "", f"using existing ${env_var}"
@@ -203,16 +213,17 @@ def _collect_api_key(model: str) -> tuple[str, str, str]:
203
213
  return env_var, "", "not saved"
204
214
 
205
215
  # Default: save to .env
206
- _write_env_file(_GLOBAL_ENV_PATH, env_var, key_input)
216
+ _write_env_file(env_path, env_var, key_input)
207
217
  # Make it available for the rest of this process too.
208
218
  os.environ[env_var] = key_input
209
- print(f"Saved {env_var} to {_GLOBAL_ENV_PATH}")
210
- return env_var, "", f"saved to {_GLOBAL_ENV_PATH}"
219
+ print(f"Saved {env_var} to {env_path}")
220
+ return env_var, "", f"saved to {env_path}"
211
221
 
212
222
 
213
223
  def cmd_init(args: argparse.Namespace) -> int:
214
224
  """Create an initial nonoka config, interactively or with --yes defaults."""
215
- path = Path(args.config) if args.config else ConfigLoader.DEFAULT_PATH
225
+ path = Path(args.config).expanduser() if args.config else ConfigLoader.DEFAULT_PATH
226
+ env_path = _env_path_for_config(path if args.config else None)
216
227
 
217
228
  print(f"Creating nonoka configuration at: {path}")
218
229
 
@@ -220,7 +231,7 @@ def cmd_init(args: argparse.Namespace) -> int:
220
231
  model = getattr(args, "model", None) or "deepseek/deepseek-v4-pro"
221
232
  auto_approve = getattr(args, "auto_approve", False)
222
233
  env_var = _api_key_env_for_model(model)
223
- key_summary = _api_key_source_summary(env_var) or "not configured"
234
+ key_summary = _api_key_source_summary(env_var, env_path) or "not configured"
224
235
 
225
236
  config = CLIConfig(
226
237
  model=model,
@@ -240,9 +251,9 @@ def cmd_init(args: argparse.Namespace) -> int:
240
251
  print(f"Configuration saved to {path}")
241
252
  print(f"Model: {model}")
242
253
  print(f"API key ({env_var}): {key_summary}")
243
- if not os.getenv(env_var) and not _load_env_file(_GLOBAL_ENV_PATH).get(env_var):
254
+ if not os.getenv(env_var) and not _load_env_file(env_path).get(env_var):
244
255
  print(
245
- f"Set your API key with: nonoka-cli config init (interactive) "
256
+ f"Set your API key with: nonoka config init (interactive) "
246
257
  f"or export {env_var}=<your-key>"
247
258
  )
248
259
  return 0
@@ -254,7 +265,7 @@ def cmd_init(args: argparse.Namespace) -> int:
254
265
  )
255
266
 
256
267
  model = _read_input("Model identifier", "deepseek/deepseek-v4-pro")
257
- env_var, api_key_value, key_summary = _collect_api_key(model)
268
+ env_var, api_key_value, key_summary = _collect_api_key(model, env_path)
258
269
 
259
270
  system_prompt = _read_input(
260
271
  "Optional system prompt",
@@ -304,6 +304,8 @@ def cmd_init(args: argparse.Namespace) -> int:
304
304
  sys.executable,
305
305
  "-m",
306
306
  "nonoka_cli",
307
+ "--config",
308
+ str(resolved_config_path),
307
309
  "--server",
308
310
  ]
309
311
 
@@ -15,6 +15,10 @@ from nonoka_cli.utils.errors import ConfigError, ConfigNotFoundError
15
15
 
16
16
  logger = structlog.get_logger("nonoka_cli.config")
17
17
 
18
+ _DEFAULT_CONFIG_DIR = Path(
19
+ os.getenv("NONOKA_CONFIG_DIR", str(Path.home() / ".config" / "nonoka"))
20
+ ).expanduser()
21
+
18
22
  _ENV_PATTERN = re.compile(r"\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?::-?(?P<default>[^}]*))?\}")
19
23
 
20
24
 
@@ -99,8 +103,8 @@ class ConfigLoader:
99
103
  main-config entries of the same name.
100
104
  """
101
105
 
102
- DEFAULT_PATH = Path.home() / ".config" / "nonoka" / "config.yaml"
103
- MCP_SERVERS_PATH = Path.home() / ".config" / "nonoka" / "mcp_servers.yaml"
106
+ DEFAULT_PATH = _DEFAULT_CONFIG_DIR / "config.yaml"
107
+ MCP_SERVERS_PATH = _DEFAULT_CONFIG_DIR / "mcp_servers.yaml"
104
108
 
105
109
  @classmethod
106
110
  def fallback_path(cls, search_dir: Path | str | None = None) -> Path:
@@ -33,10 +33,24 @@ def test_version_flag_reports_public_command(capsys: pytest.CaptureFixture[str])
33
33
  def test_logging_is_configured_before_env_files_are_loaded(monkeypatch) -> None:
34
34
  calls: list[str] = []
35
35
  monkeypatch.setattr(cli, "setup_logging", lambda **_kwargs: calls.append("logging"))
36
- monkeypatch.setattr(cli, "_load_env_files", lambda: calls.append("env"))
36
+ monkeypatch.setattr(cli, "_load_env_files", lambda *_args: calls.append("env"))
37
37
  monkeypatch.setattr(cli.run_cmd, "launch_tui", lambda _args: 0)
38
38
 
39
39
  with patch.object(cli.sys, "argv", ["nonoka"]):
40
40
  assert cli.main() == 0
41
41
 
42
42
  assert calls == ["logging", "env"]
43
+
44
+
45
+ def test_explicit_config_loads_sibling_env(tmp_path, monkeypatch) -> None:
46
+ config_path = tmp_path / "custom" / "config.yaml"
47
+ env_path = config_path.parent / ".env"
48
+ env_path.parent.mkdir()
49
+ env_path.write_text("DEEPSEEK_API_KEY=test-only\n")
50
+ loaded: list[str] = []
51
+ monkeypatch.setattr(cli, "load_dotenv", lambda dotenv_path, override: loaded.append(str(dotenv_path)))
52
+ monkeypatch.chdir(tmp_path)
53
+
54
+ cli._load_env_files(config_path)
55
+
56
+ assert str(env_path.resolve()) in loaded
@@ -74,7 +74,6 @@ def test_write_env_file(tmp_path: Path):
74
74
  def test_config_init_saves_api_key_to_env(tmp_path: Path, monkeypatch):
75
75
  config_path = tmp_path / "config.yaml"
76
76
  env_path = tmp_path / ".env"
77
- monkeypatch.setattr(config_cmd, "_GLOBAL_ENV_PATH", env_path)
78
77
 
79
78
  inputs = iter(["deepseek-chat", "", "d", ""])
80
79
  def fake_read_input(prompt: str, default: str = "") -> str:
@@ -97,3 +96,10 @@ def test_config_init_saves_api_key_to_env(tmp_path: Path, monkeypatch):
97
96
 
98
97
  # Clean up so the key does not leak into other tests.
99
98
  monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
99
+
100
+
101
+ def test_config_init_uses_configured_directory_for_default_env(tmp_path: Path, monkeypatch):
102
+ config_dir = tmp_path / "configured"
103
+ monkeypatch.setenv("NONOKA_CONFIG_DIR", str(config_dir))
104
+
105
+ assert config_cmd._env_path_for_config(None) == config_dir / ".env"
@@ -53,7 +53,7 @@ def test_opencode_init_creates_file(tmp_path: Path):
53
53
  assert data["provider"]["nonoka"]["options"]["requireFocusedVerification"] is True
54
54
  assert data["provider"]["nonoka"]["options"]["verificationEnforcement"] == "strict"
55
55
  assert data["provider"]["nonoka"]["options"]["serverCommand"] == [
56
- sys.executable, "-m", "nonoka_cli", "--server",
56
+ sys.executable, "-m", "nonoka_cli", "--config", str(config_path), "--server",
57
57
  ]
58
58
 
59
59
 
@@ -77,7 +77,7 @@ def test_opencode_init_merges_existing(tmp_path: Path):
77
77
  assert data["custom"] is True
78
78
  assert data["provider"]["nonoka"]["options"]["configPath"] == str(config_path)
79
79
  assert data["provider"]["nonoka"]["options"]["serverCommand"] == [
80
- sys.executable, "-m", "nonoka_cli", "--server",
80
+ sys.executable, "-m", "nonoka_cli", "--config", str(config_path), "--server",
81
81
  ]
82
82
 
83
83
 
@@ -8,6 +8,10 @@ from nonoka_cli.config.loader import ConfigLoader, _substitute_env_vars
8
8
  from nonoka_cli.utils.errors import ConfigError, ConfigNotFoundError
9
9
 
10
10
 
11
+ def test_default_config_assets_share_configured_directory():
12
+ assert ConfigLoader.MCP_SERVERS_PATH.parent == ConfigLoader.DEFAULT_PATH.parent
13
+
14
+
11
15
  def test_substitute_env_vars_with_default():
12
16
  value = _substitute_env_vars("${UNSET_VAR:-default_value}")
13
17
  assert value == "default_value"
@@ -0,0 +1,200 @@
1
+ """Offline behavioural tests for install.sh path selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[2]
11
+ INSTALLER = ROOT / "install.sh"
12
+
13
+
14
+ def _command(path: Path, body: str) -> None:
15
+ path.write_text("#!/usr/bin/env bash\nset -e\n" + body, encoding="utf-8")
16
+ path.chmod(0o755)
17
+
18
+
19
+ def _fake_environment(tmp_path: Path) -> tuple[dict[str, str], Path]:
20
+ fake_bin = tmp_path / "bin"
21
+ fake_bin.mkdir()
22
+ log = tmp_path / "calls.log"
23
+ _command(fake_bin / "python3", "printf '3.13\\n'\n")
24
+ _command(
25
+ fake_bin / "uv",
26
+ 'printf "uv:%s\\n" "$*" >> "$NONOKA_TEST_LOG"\n'
27
+ 'if [ "${1:-}" = venv ]; then\n'
28
+ ' mkdir -p "$2/bin"\n'
29
+ ' touch "$2/bin/python"\n'
30
+ ' chmod +x "$2/bin/python"\n'
31
+ ' cp "$(dirname "$0")/nonoka" "$2/bin/nonoka"\n'
32
+ 'fi\n',
33
+ )
34
+ _command(fake_bin / "opencode", "printf '1.18.10\\n'\n")
35
+ _command(fake_bin / "npm", 'printf "npm:%s\\n" "$*" >> "$NONOKA_TEST_LOG"\n')
36
+ _command(fake_bin / "nonoka", 'printf "nonoka:%s\\n" "$*" >> "$NONOKA_TEST_LOG"\n')
37
+ _command(fake_bin / "curl", "exit 0\n")
38
+
39
+ env = os.environ.copy()
40
+ env.update(
41
+ {
42
+ "HOME": str(tmp_path / "home"),
43
+ "PATH": f"{fake_bin}:/usr/bin:/bin",
44
+ "NONOKA_TEST_LOG": str(log),
45
+ }
46
+ )
47
+ for name in (
48
+ "VIRTUAL_ENV",
49
+ "NONOKA_INSTALL_DIR",
50
+ "NONOKA_CONFIG_DIR",
51
+ "NONOKA_NPM_PREFIX",
52
+ ):
53
+ env.pop(name, None)
54
+ return env, log
55
+
56
+
57
+ def _run(tmp_path: Path, *args: str, input_text: str | None = None, env=None):
58
+ return subprocess.run(
59
+ ["bash", str(INSTALLER), *args],
60
+ cwd=tmp_path,
61
+ env=env,
62
+ input=input_text,
63
+ text=True,
64
+ capture_output=True,
65
+ timeout=20,
66
+ check=False,
67
+ )
68
+
69
+
70
+ def test_install_dir_flags_expand_home_and_drive_all_commands(tmp_path: Path) -> None:
71
+ env, log = _fake_environment(tmp_path)
72
+ result = _run(
73
+ tmp_path,
74
+ "--yes",
75
+ "--uv",
76
+ "--no-opencode",
77
+ "--install-dir",
78
+ "~/chosen-install",
79
+ "--config-dir",
80
+ "~/chosen-config",
81
+ "--npm-prefix",
82
+ "~/chosen-npm",
83
+ env=env,
84
+ )
85
+
86
+ assert result.returncode == 0, result.stderr
87
+ home = Path(env["HOME"])
88
+ calls = log.read_text()
89
+ assert f"uv:venv {home}/chosen-install/.venv --python python3" in calls
90
+ assert "uv:pip install --upgrade nonoka-cli" in calls
91
+ assert f"nonoka:config init --yes --config {home}/chosen-config/config.yaml" in calls
92
+ assert f"nonoka:init --config {home}/chosen-config/config.yaml" in calls
93
+ assert f"npm prefix: {home}/chosen-npm" in result.stdout
94
+
95
+
96
+ def test_environment_variables_select_noninteractive_layout(tmp_path: Path) -> None:
97
+ env, log = _fake_environment(tmp_path)
98
+ env.update(
99
+ {
100
+ "NONOKA_INSTALL_DIR": str(tmp_path / "env-install"),
101
+ "NONOKA_CONFIG_DIR": str(tmp_path / "env-config"),
102
+ "NONOKA_NPM_PREFIX": str(tmp_path / "env-npm"),
103
+ }
104
+ )
105
+
106
+ result = _run(tmp_path, "--yes", "--uv", "--no-opencode", env=env)
107
+
108
+ assert result.returncode == 0, result.stderr
109
+ calls = log.read_text()
110
+ assert f"uv:venv {tmp_path}/env-install/.venv --python python3" in calls
111
+ assert f"nonoka:config init --yes --config {tmp_path}/env-config/config.yaml" in calls
112
+ assert f"npm prefix: {tmp_path}/env-npm" in result.stdout
113
+
114
+
115
+ def test_interactive_prompts_explain_each_directory(tmp_path: Path) -> None:
116
+ env, log = _fake_environment(tmp_path)
117
+ selected_install = tmp_path / "interactive-install"
118
+ selected_config = tmp_path / "interactive-config"
119
+ selected_npm = tmp_path / "interactive-npm"
120
+ answers = f"{selected_install}\n{selected_config}\n{selected_npm}\n"
121
+
122
+ result = _run(tmp_path, "--uv", "--no-opencode", input_text=answers, env=env)
123
+
124
+ assert result.returncode == 0, result.stderr
125
+ assert "Installation directory (Python environment, launchers, and npm tools" in result.stderr
126
+ assert "Configuration directory (config.yaml and .env" in result.stderr
127
+ assert "npm prefix (OpenCode/provider global packages)" in result.stderr
128
+ calls = log.read_text()
129
+ assert f"uv:venv {selected_install}/.venv --python python3" in calls
130
+ assert f"nonoka:config init --config {selected_config}/config.yaml" in calls
131
+
132
+
133
+ def test_interactive_defaults_are_displayed_with_tilde(tmp_path: Path) -> None:
134
+ env, _log = _fake_environment(tmp_path)
135
+
136
+ result = _run(
137
+ tmp_path,
138
+ "--uv",
139
+ "--no-opencode",
140
+ input_text="\n\n\n",
141
+ env=env,
142
+ )
143
+
144
+ assert result.returncode == 0, result.stderr
145
+ assert "[~/.local/share/nonoka]" in result.stderr
146
+ assert "[~/.config/nonoka]" in result.stderr
147
+ assert "[~/.local/share/nonoka/npm]" in result.stderr
148
+
149
+
150
+ def test_launcher_preserves_paths_with_spaces_and_needs_no_exports(tmp_path: Path) -> None:
151
+ env, _log = _fake_environment(tmp_path)
152
+ install_dir = tmp_path / "install with spaces"
153
+ config_dir = tmp_path / "config with spaces"
154
+ npm_prefix = tmp_path / "npm with spaces"
155
+
156
+ result = _run(
157
+ tmp_path,
158
+ "--yes",
159
+ "--uv",
160
+ "--no-opencode",
161
+ "--install-dir",
162
+ str(install_dir),
163
+ "--config-dir",
164
+ str(config_dir),
165
+ "--npm-prefix",
166
+ str(npm_prefix),
167
+ env=env,
168
+ )
169
+
170
+ assert result.returncode == 0, result.stderr
171
+ launcher = install_dir / "bin" / "nonoka"
172
+ alias = install_dir / "bin" / "nonoka-cli"
173
+ assert launcher.is_file()
174
+ assert os.access(launcher, os.X_OK)
175
+ assert alias.is_symlink()
176
+ assert os.readlink(alias) == "nonoka"
177
+
178
+ clean_env = env.copy()
179
+ clean_env.pop("VIRTUAL_ENV", None)
180
+ clean_env.pop("NONOKA_CONFIG_DIR", None)
181
+ clean_env.pop("NPM_CONFIG_PREFIX", None)
182
+ invoked = subprocess.run(
183
+ [str(launcher), "doctor"],
184
+ env=clean_env,
185
+ text=True,
186
+ capture_output=True,
187
+ timeout=10,
188
+ check=False,
189
+ )
190
+ assert invoked.returncode == 0, invoked.stderr
191
+ calls = (tmp_path / "calls.log").read_text()
192
+ assert "nonoka:doctor" in calls
193
+
194
+ content = launcher.read_text(encoding="utf-8")
195
+ escaped_venv = str(install_dir / ".venv").replace(" ", "\\ ")
196
+ escaped_config = str(config_dir).replace(" ", "\\ ")
197
+ escaped_npm = str(npm_prefix).replace(" ", "\\ ")
198
+ assert f"export VIRTUAL_ENV={escaped_venv}" in content
199
+ assert f"export NONOKA_CONFIG_DIR={escaped_config}" in content
200
+ assert f"export NPM_CONFIG_PREFIX={escaped_npm}" in content
File without changes
File without changes
File without changes