nonoka-cli 0.2.12__tar.gz → 0.2.14__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.14}/PKG-INFO +38 -6
  2. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/README.md +37 -5
  3. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/pyproject.toml +1 -1
  4. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/__init__.py +1 -1
  5. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/cli.py +18 -6
  6. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/config_cmd.py +24 -13
  7. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/opencode_cmd.py +2 -0
  8. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/config/loader.py +6 -2
  9. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_cli_entrypoint.py +15 -1
  10. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_config_cmd.py +7 -1
  11. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_opencode_cmd.py +2 -2
  12. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/config/test_loader.py +4 -0
  13. nonoka_cli-0.2.14/tests/unit/test_install_script.py +254 -0
  14. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/.gitignore +0 -0
  15. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/LICENSE +0 -0
  16. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/NOTICE +0 -0
  17. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/__main__.py +0 -0
  18. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/benchmark/__init__.py +0 -0
  19. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/benchmark/harbor.py +0 -0
  20. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/benchmark/swe_bench.py +0 -0
  21. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/benchmark/swe_environment.py +0 -0
  22. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/benchmark/swe_runner.py +0 -0
  23. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/benchmark/watchdog.py +0 -0
  24. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/bridge/__init__.py +0 -0
  25. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/bridge/events.py +0 -0
  26. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/bridge/handler.py +0 -0
  27. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/bridge/nonoka_tools.py +0 -0
  28. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/bridge/protocol.py +0 -0
  29. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/bridge/server.py +0 -0
  30. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/__init__.py +0 -0
  31. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/benchmark_cmd.py +0 -0
  32. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/doctor_cmd.py +0 -0
  33. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/eval_cmd.py +0 -0
  34. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/logs_cmd.py +0 -0
  35. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/plugin_cmd.py +0 -0
  36. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/run_cmd.py +0 -0
  37. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/commands/sessions_cmd.py +0 -0
  38. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/config/__init__.py +0 -0
  39. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/config/manager.py +0 -0
  40. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/config/models.py +0 -0
  41. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/__init__.py +0 -0
  42. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/agent_factory.py +0 -0
  43. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/context.py +0 -0
  44. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/context_trimmer.py +0 -0
  45. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/git_service.py +0 -0
  46. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/mcp_service.py +0 -0
  47. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/namespaces.py +0 -0
  48. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/operational_signals.py +0 -0
  49. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/orchestrator.py +0 -0
  50. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/plugin_manifest.py +0 -0
  51. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/plugin_manifest_converter.py +0 -0
  52. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/project_agents.py +0 -0
  53. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/prompt_builder.py +0 -0
  54. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/repo_map_service.py +0 -0
  55. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/run_evidence.py +0 -0
  56. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/runner_service.py +0 -0
  57. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/scorecard.py +0 -0
  58. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/semantic_cache.py +0 -0
  59. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/session_service.py +0 -0
  60. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/task_state.py +0 -0
  61. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/tool_output_policy.py +0 -0
  62. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/core/tool_service.py +0 -0
  63. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/mcp/__init__.py +0 -0
  64. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/mcp/manager.py +0 -0
  65. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/mcp/models.py +0 -0
  66. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/safety/__init__.py +0 -0
  67. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/safety/preflight.py +0 -0
  68. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/safety/sandbox.py +0 -0
  69. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/sessions/__init__.py +0 -0
  70. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/sessions/manager.py +0 -0
  71. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/sessions/models.py +0 -0
  72. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/skills/__init__.py +0 -0
  73. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/tools/__init__.py +0 -0
  74. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/tools/builtins/__init__.py +0 -0
  75. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/tools/builtins/agent_tools.py +0 -0
  76. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/tools/builtins/file_tools.py +0 -0
  77. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/tools/loader.py +0 -0
  78. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/utils/__init__.py +0 -0
  79. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/utils/errors.py +0 -0
  80. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/utils/logging.py +0 -0
  81. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/src/nonoka_cli/utils/trace_logger.py +0 -0
  82. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/conftest.py +0 -0
  83. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/fixtures/bridge_external_capabilities.json +0 -0
  84. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/integration/test_server_external_capabilities.py +0 -0
  85. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/integration/test_watchdog_process_group.py +0 -0
  86. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/benchmark/test_harbor.py +0 -0
  87. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/benchmark/test_swe_environment.py +0 -0
  88. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/benchmark/test_watchdog.py +0 -0
  89. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/bridge/test_events.py +0 -0
  90. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/bridge/test_handler.py +0 -0
  91. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/bridge/test_nonoka_tools.py +0 -0
  92. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/bridge/test_package.py +0 -0
  93. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/bridge/test_protocol.py +0 -0
  94. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/bridge/test_server.py +0 -0
  95. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_benchmark_cmd.py +0 -0
  96. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_doctor_cmd.py +0 -0
  97. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_eval_cmd.py +0 -0
  98. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_logs_cmd.py +0 -0
  99. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_plugin_cmd.py +0 -0
  100. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_run_cmd.py +0 -0
  101. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/commands/test_swe_bench_cmd.py +0 -0
  102. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/config/test_models.py +0 -0
  103. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_agent_factory.py +0 -0
  104. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_context_trimmer.py +0 -0
  105. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_git_service.py +0 -0
  106. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_operational_signals.py +0 -0
  107. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_orchestrator.py +0 -0
  108. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_plugin_manifest.py +0 -0
  109. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_plugin_manifest_converter.py +0 -0
  110. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_project_agents.py +0 -0
  111. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_prompt_builder.py +0 -0
  112. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_repo_map_service.py +0 -0
  113. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_safety_policy.py +0 -0
  114. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_scorecard.py +0 -0
  115. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_semantic_cache.py +0 -0
  116. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_task_state.py +0 -0
  117. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/core/test_tool_output_policy.py +0 -0
  118. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/safety/test_preflight.py +0 -0
  119. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/sessions/test_manager.py +0 -0
  120. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/tools/test_file_tools.py +0 -0
  121. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/tests/unit/utils/test_logging.py +0 -0
  122. {nonoka_cli-0.2.12 → nonoka_cli-0.2.14}/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.14
4
4
  Summary: OpenCode backend for the Nonoka Agent framework
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -43,7 +43,7 @@ tool execution and human-in-the-loop (HITL) approval using its native tools.
43
43
  The easiest way to get nonoka + OpenCode is the one-line installer:
44
44
 
45
45
  ```bash
46
- curl -fsSL https://nonoka.dev/install.sh | bash
46
+ curl -fsSL https://raw.githubusercontent.com/fyerfyer/Nonoka-cli/main/install.sh | bash
47
47
  ```
48
48
 
49
49
  The installer will:
@@ -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
 
@@ -73,8 +89,24 @@ The legacy `nonoka-cli` executable remains available for compatibility.
73
89
  `nonoka-cli` automatically loads `~/.config/nonoka/.env` and `./.env` on startup,
74
90
  so you don't need to `export` every time if you save the key in `.env`.
75
91
 
76
- > To use `uv` instead of `pip`, or to run non-interactively, pass flags:
77
- > `curl -fsSL https://nonoka.dev/install.sh | bash -s -- --uv --yes`.
92
+ > The installer uses `uv` by default when it is available and falls back to
93
+ > the isolated environment's pip. Pass `--pip` to explicitly select pip, or
94
+ > `--yes` to run non-interactively.
95
+
96
+ For a non-interactive custom location, use flags (environment variables with
97
+ the same names are also supported):
98
+
99
+ ```bash
100
+ bash install.sh --yes --uv --npm-opencode \
101
+ --install-dir ~/tools/nonoka \
102
+ --config-dir ~/.config/nonoka \
103
+ --npm-prefix ~/tools/nonoka/npm
104
+
105
+ ~/tools/nonoka/bin/nonoka doctor
106
+ ```
107
+
108
+ The corresponding variables are `NONOKA_INSTALL_DIR`, `NONOKA_CONFIG_DIR`,
109
+ and `NONOKA_NPM_PREFIX`. CLI flags take precedence over environment variables.
78
110
 
79
111
  ## Manual installation
80
112
 
@@ -132,7 +164,7 @@ Example output:
132
164
 
133
165
  ```
134
166
  nonoka-cli doctor
135
- ✓ nonoka-cli 0.2.12
167
+ ✓ nonoka-cli 0.2.14
136
168
  ✓ Python 3.11
137
169
  ✓ opencode 1.18.2
138
170
  ✓ provider nonoka-opencode-provider@0.2.17
@@ -14,7 +14,7 @@ tool execution and human-in-the-loop (HITL) approval using its native tools.
14
14
  The easiest way to get nonoka + OpenCode is the one-line installer:
15
15
 
16
16
  ```bash
17
- curl -fsSL https://nonoka.dev/install.sh | bash
17
+ curl -fsSL https://raw.githubusercontent.com/fyerfyer/Nonoka-cli/main/install.sh | bash
18
18
  ```
19
19
 
20
20
  The installer will:
@@ -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
 
@@ -44,8 +60,24 @@ The legacy `nonoka-cli` executable remains available for compatibility.
44
60
  `nonoka-cli` automatically loads `~/.config/nonoka/.env` and `./.env` on startup,
45
61
  so you don't need to `export` every time if you save the key in `.env`.
46
62
 
47
- > To use `uv` instead of `pip`, or to run non-interactively, pass flags:
48
- > `curl -fsSL https://nonoka.dev/install.sh | bash -s -- --uv --yes`.
63
+ > The installer uses `uv` by default when it is available and falls back to
64
+ > the isolated environment's pip. Pass `--pip` to explicitly select pip, or
65
+ > `--yes` to run non-interactively.
66
+
67
+ For a non-interactive custom location, use flags (environment variables with
68
+ the same names are also supported):
69
+
70
+ ```bash
71
+ bash install.sh --yes --uv --npm-opencode \
72
+ --install-dir ~/tools/nonoka \
73
+ --config-dir ~/.config/nonoka \
74
+ --npm-prefix ~/tools/nonoka/npm
75
+
76
+ ~/tools/nonoka/bin/nonoka doctor
77
+ ```
78
+
79
+ The corresponding variables are `NONOKA_INSTALL_DIR`, `NONOKA_CONFIG_DIR`,
80
+ and `NONOKA_NPM_PREFIX`. CLI flags take precedence over environment variables.
49
81
 
50
82
  ## Manual installation
51
83
 
@@ -103,7 +135,7 @@ Example output:
103
135
 
104
136
  ```
105
137
  nonoka-cli doctor
106
- ✓ nonoka-cli 0.2.12
138
+ ✓ nonoka-cli 0.2.14
107
139
  ✓ Python 3.11
108
140
  ✓ opencode 1.18.2
109
141
  ✓ 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.14"
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.14"
@@ -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,254 @@
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(
24
+ fake_bin / "python3",
25
+ 'if [ "${1:-}" = "-c" ]; then\n'
26
+ ' printf "3.13\\n"\n'
27
+ 'elif [ "${1:-}" = "-m" ] && [ "${2:-}" = "venv" ]; then\n'
28
+ ' mkdir -p "$3/bin"\n'
29
+ ' cp "$0" "$3/bin/python"\n'
30
+ ' cp "$(dirname "$0")/nonoka" "$3/bin/nonoka"\n'
31
+ 'elif [ "${1:-}" = "-m" ] && [ "${2:-}" = "pip" ]; then\n'
32
+ ' printf "python-pip:%s\\n" "${*:3}" >> "$NONOKA_TEST_LOG"\n'
33
+ 'fi\n',
34
+ )
35
+ _command(
36
+ fake_bin / "uv",
37
+ 'printf "uv:%s\\n" "$*" >> "$NONOKA_TEST_LOG"\n'
38
+ 'if [ "${1:-}" = venv ]; then\n'
39
+ ' mkdir -p "$2/bin"\n'
40
+ ' touch "$2/bin/python"\n'
41
+ ' chmod +x "$2/bin/python"\n'
42
+ ' cp "$(dirname "$0")/nonoka" "$2/bin/nonoka"\n'
43
+ 'fi\n',
44
+ )
45
+ _command(fake_bin / "opencode", "printf '1.18.10\\n'\n")
46
+ _command(fake_bin / "npm", 'printf "npm:%s\\n" "$*" >> "$NONOKA_TEST_LOG"\n')
47
+ _command(fake_bin / "nonoka", 'printf "nonoka:%s\\n" "$*" >> "$NONOKA_TEST_LOG"\n')
48
+ _command(fake_bin / "curl", "exit 0\n")
49
+
50
+ env = os.environ.copy()
51
+ env.update(
52
+ {
53
+ "HOME": str(tmp_path / "home"),
54
+ "PATH": f"{fake_bin}:/usr/bin:/bin",
55
+ "NONOKA_TEST_LOG": str(log),
56
+ }
57
+ )
58
+ for name in (
59
+ "VIRTUAL_ENV",
60
+ "NONOKA_INSTALL_DIR",
61
+ "NONOKA_CONFIG_DIR",
62
+ "NONOKA_NPM_PREFIX",
63
+ ):
64
+ env.pop(name, None)
65
+ return env, log
66
+
67
+
68
+ def _run(tmp_path: Path, *args: str, input_text: str | None = None, env=None):
69
+ return subprocess.run(
70
+ ["bash", str(INSTALLER), *args],
71
+ cwd=tmp_path,
72
+ env=env,
73
+ input=input_text,
74
+ text=True,
75
+ capture_output=True,
76
+ timeout=20,
77
+ check=False,
78
+ )
79
+
80
+
81
+ def test_install_dir_flags_expand_home_and_drive_all_commands(tmp_path: Path) -> None:
82
+ env, log = _fake_environment(tmp_path)
83
+ result = _run(
84
+ tmp_path,
85
+ "--yes",
86
+ "--uv",
87
+ "--no-opencode",
88
+ "--install-dir",
89
+ "~/chosen-install",
90
+ "--config-dir",
91
+ "~/chosen-config",
92
+ "--npm-prefix",
93
+ "~/chosen-npm",
94
+ env=env,
95
+ )
96
+
97
+ assert result.returncode == 0, result.stderr
98
+ home = Path(env["HOME"])
99
+ calls = log.read_text()
100
+ assert f"uv:venv {home}/chosen-install/.venv --python python3" in calls
101
+ assert f"uv:pip install --python {home}/chosen-install/.venv/bin/python --upgrade nonoka-cli" in calls
102
+ assert f"nonoka:config init --yes --config {home}/chosen-config/config.yaml" in calls
103
+ assert f"nonoka:init --config {home}/chosen-config/config.yaml" in calls
104
+ assert f"npm prefix: {home}/chosen-npm" in result.stdout
105
+
106
+
107
+ def test_environment_variables_select_noninteractive_layout(tmp_path: Path) -> None:
108
+ env, log = _fake_environment(tmp_path)
109
+ env.update(
110
+ {
111
+ "NONOKA_INSTALL_DIR": str(tmp_path / "env-install"),
112
+ "NONOKA_CONFIG_DIR": str(tmp_path / "env-config"),
113
+ "NONOKA_NPM_PREFIX": str(tmp_path / "env-npm"),
114
+ }
115
+ )
116
+
117
+ result = _run(tmp_path, "--yes", "--uv", "--no-opencode", env=env)
118
+
119
+ assert result.returncode == 0, result.stderr
120
+ calls = log.read_text()
121
+ assert f"uv:venv {tmp_path}/env-install/.venv --python python3" in calls
122
+ assert f"nonoka:config init --yes --config {tmp_path}/env-config/config.yaml" in calls
123
+ assert f"npm prefix: {tmp_path}/env-npm" in result.stdout
124
+
125
+
126
+ def test_uv_is_the_default_and_targets_the_created_environment(tmp_path: Path) -> None:
127
+ env, log = _fake_environment(tmp_path)
128
+ install_dir = tmp_path / "default-uv"
129
+
130
+ result = _run(
131
+ tmp_path,
132
+ "--yes",
133
+ "--no-opencode",
134
+ "--install-dir",
135
+ str(install_dir),
136
+ env=env,
137
+ )
138
+
139
+ assert result.returncode == 0, result.stderr
140
+ calls = log.read_text()
141
+ assert f"uv:venv {install_dir}/.venv --python python3" in calls
142
+ assert (
143
+ f"uv:pip install --python {install_dir}/.venv/bin/python --upgrade nonoka-cli"
144
+ in calls
145
+ )
146
+ assert "python-pip:" not in calls
147
+
148
+
149
+ def test_pip_mode_is_pinned_to_the_created_environment(tmp_path: Path) -> None:
150
+ env, log = _fake_environment(tmp_path)
151
+ install_dir = tmp_path / "explicit-pip"
152
+
153
+ result = _run(
154
+ tmp_path,
155
+ "--yes",
156
+ "--pip",
157
+ "--no-opencode",
158
+ "--install-dir",
159
+ str(install_dir),
160
+ env=env,
161
+ )
162
+
163
+ assert result.returncode == 0, result.stderr
164
+ calls = log.read_text()
165
+ assert "python-pip:install --upgrade nonoka-cli" in calls
166
+ assert f"uv:venv {install_dir}/.venv --python python3" not in calls
167
+
168
+
169
+ def test_interactive_prompts_explain_each_directory(tmp_path: Path) -> None:
170
+ env, log = _fake_environment(tmp_path)
171
+ selected_install = tmp_path / "interactive-install"
172
+ selected_config = tmp_path / "interactive-config"
173
+ selected_npm = tmp_path / "interactive-npm"
174
+ answers = f"{selected_install}\n{selected_config}\n{selected_npm}\n"
175
+
176
+ result = _run(tmp_path, "--uv", "--no-opencode", input_text=answers, env=env)
177
+
178
+ assert result.returncode == 0, result.stderr
179
+ assert "Installation directory (Python environment, launchers, and npm tools" in result.stderr
180
+ assert "Configuration directory (config.yaml and .env" in result.stderr
181
+ assert "npm prefix (OpenCode/provider global packages)" in result.stderr
182
+ calls = log.read_text()
183
+ assert f"uv:venv {selected_install}/.venv --python python3" in calls
184
+ assert f"nonoka:config init --config {selected_config}/config.yaml" in calls
185
+
186
+
187
+ def test_interactive_defaults_are_displayed_with_tilde(tmp_path: Path) -> None:
188
+ env, _log = _fake_environment(tmp_path)
189
+
190
+ result = _run(
191
+ tmp_path,
192
+ "--uv",
193
+ "--no-opencode",
194
+ input_text="\n\n\n",
195
+ env=env,
196
+ )
197
+
198
+ assert result.returncode == 0, result.stderr
199
+ assert "[~/.local/share/nonoka]" in result.stderr
200
+ assert "[~/.config/nonoka]" in result.stderr
201
+ assert "[~/.local/share/nonoka/npm]" in result.stderr
202
+
203
+
204
+ def test_launcher_preserves_paths_with_spaces_and_needs_no_exports(tmp_path: Path) -> None:
205
+ env, _log = _fake_environment(tmp_path)
206
+ install_dir = tmp_path / "install with spaces"
207
+ config_dir = tmp_path / "config with spaces"
208
+ npm_prefix = tmp_path / "npm with spaces"
209
+
210
+ result = _run(
211
+ tmp_path,
212
+ "--yes",
213
+ "--uv",
214
+ "--no-opencode",
215
+ "--install-dir",
216
+ str(install_dir),
217
+ "--config-dir",
218
+ str(config_dir),
219
+ "--npm-prefix",
220
+ str(npm_prefix),
221
+ env=env,
222
+ )
223
+
224
+ assert result.returncode == 0, result.stderr
225
+ launcher = install_dir / "bin" / "nonoka"
226
+ alias = install_dir / "bin" / "nonoka-cli"
227
+ assert launcher.is_file()
228
+ assert os.access(launcher, os.X_OK)
229
+ assert alias.is_symlink()
230
+ assert os.readlink(alias) == "nonoka"
231
+
232
+ clean_env = env.copy()
233
+ clean_env.pop("VIRTUAL_ENV", None)
234
+ clean_env.pop("NONOKA_CONFIG_DIR", None)
235
+ clean_env.pop("NPM_CONFIG_PREFIX", None)
236
+ invoked = subprocess.run(
237
+ [str(launcher), "doctor"],
238
+ env=clean_env,
239
+ text=True,
240
+ capture_output=True,
241
+ timeout=10,
242
+ check=False,
243
+ )
244
+ assert invoked.returncode == 0, invoked.stderr
245
+ calls = (tmp_path / "calls.log").read_text()
246
+ assert "nonoka:doctor" in calls
247
+
248
+ content = launcher.read_text(encoding="utf-8")
249
+ escaped_venv = str(install_dir / ".venv").replace(" ", "\\ ")
250
+ escaped_config = str(config_dir).replace(" ", "\\ ")
251
+ escaped_npm = str(npm_prefix).replace(" ", "\\ ")
252
+ assert f"export VIRTUAL_ENV={escaped_venv}" in content
253
+ assert f"export NONOKA_CONFIG_DIR={escaped_config}" in content
254
+ assert f"export NPM_CONFIG_PREFIX={escaped_npm}" in content
File without changes
File without changes
File without changes