nimcode 0.3.3__tar.gz → 0.4.0__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 (45) hide show
  1. {nimcode-0.3.3 → nimcode-0.4.0}/PKG-INFO +1 -1
  2. {nimcode-0.3.3 → nimcode-0.4.0}/README.md +34 -0
  3. {nimcode-0.3.3 → nimcode-0.4.0}/setup.py +7 -1
  4. nimcode-0.4.0/src/nimcode/__version__.py +1 -0
  5. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/agent.py +121 -32
  6. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/config.py +15 -1
  7. nimcode-0.4.0/src/nimcode/model_registry.py +54 -0
  8. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/nim_client.py +30 -14
  9. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/permissions.py +12 -2
  10. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/repl.py +22 -9
  11. nimcode-0.4.0/src/nimcode/repo_map.py +97 -0
  12. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/tools.py +10 -3
  13. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/updater.py +7 -2
  14. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/PKG-INFO +1 -1
  15. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/SOURCES.txt +7 -1
  16. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_config.py +10 -0
  17. nimcode-0.4.0/tests/test_model_registry.py +38 -0
  18. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_permissions.py +4 -2
  19. nimcode-0.4.0/tests/test_repo_map.py +93 -0
  20. nimcode-0.4.0/tests/test_version.py +24 -0
  21. {nimcode-0.3.3 → nimcode-0.4.0}/setup.cfg +0 -0
  22. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/__init__.py +0 -0
  23. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/cli.py +0 -0
  24. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/lenient_parser.py +0 -0
  25. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/mcp_client.py +0 -0
  26. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/memory.py +0 -0
  27. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/plugin_manager.py +0 -0
  28. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/rag.py +0 -0
  29. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode/watcher.py +0 -0
  30. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/dependency_links.txt +0 -0
  31. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/entry_points.txt +0 -0
  32. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/requires.txt +0 -0
  33. {nimcode-0.3.3 → nimcode-0.4.0}/src/nimcode.egg-info/top_level.txt +0 -0
  34. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_agent.py +0 -0
  35. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_cli.py +0 -0
  36. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_lenient_parser.py +0 -0
  37. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_mcp_client.py +0 -0
  38. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_memory.py +0 -0
  39. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_nim_client.py +0 -0
  40. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_plugin_manager.py +0 -0
  41. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_repl.py +0 -0
  42. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_repl_extra.py +0 -0
  43. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_repl_trust.py +0 -0
  44. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_tools.py +0 -0
  45. {nimcode-0.3.3 → nimcode-0.4.0}/tests/test_updater.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nimcode
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: A standalone, robust coding agent for NVIDIA NIM models.
5
5
  Author: Autonomous Agent
6
6
  Requires-Dist: httpx>=0.27.0
@@ -121,6 +121,40 @@ NimCode creates a `.nimcode` directory inside your projects. This directory acts
121
121
  - `.nimcode/skills/`: Any custom guidelines, framework rules, or memories you teach the agent.
122
122
  - `.nimcode/history/`: File backups for `/undo` capabilities.
123
123
 
124
+ ### Advanced Configuration
125
+
126
+ NimCode's behavior can be fully customized through `~/.nimcode/settings.json` (global) or `.nimcode/settings.json` (per-project). Setting any timeout to `0` disables it (infinite).
127
+
128
+ ```json
129
+ {
130
+ "timeout_command": 1200,
131
+ "timeout_llm": 120,
132
+ "timeout_format": 10,
133
+ "timeout_browser": 15000,
134
+ "timeout_updater": 3,
135
+ "max_turns": 200,
136
+ "max_tokens": 120000,
137
+ "max_retries": 15,
138
+ "retry_base_delay": 2.0,
139
+ "retry_max_delay": 60.0,
140
+ "allow_bash_non_interactive": false
141
+ }
142
+ ```
143
+
144
+ | Setting | Default | Description |
145
+ |---------|---------|-------------|
146
+ | `timeout_command` | 1200 | Max seconds for bash commands (0 = infinite) |
147
+ | `timeout_llm` | 120 | Max seconds for LLM API calls (0 = infinite) |
148
+ | `timeout_format` | 10 | Max seconds for formatters like black/prettier |
149
+ | `timeout_browser` | 15000 | Max ms for browser actions |
150
+ | `timeout_updater` | 3 | Max seconds for update checks |
151
+ | `max_turns` | 200 | Max agent turns per session (0 = unlimited) |
152
+ | `max_tokens` | 120000 | Token budget before auto-compact |
153
+ | `max_retries` | 15 | Max API retry attempts on transient errors |
154
+ | `retry_base_delay` | 2.0 | Base delay for exponential backoff (seconds) |
155
+ | `retry_max_delay` | 60.0 | Max delay cap for backoff (seconds) |
156
+ | `allow_bash_non_interactive` | false | Allow Bash commands in non-interactive/CI mode |
157
+
124
158
  ## 🛡️ Requirements
125
159
 
126
160
  - Python 3.8+
@@ -1,8 +1,14 @@
1
1
  from setuptools import setup, find_packages
2
+ import os
3
+
4
+ # Single source of truth for version
5
+ version = {}
6
+ with open(os.path.join("src", "nimcode", "__version__.py")) as f:
7
+ exec(f.read(), version)
2
8
 
3
9
  setup(
4
10
  name="nimcode",
5
- version="0.3.3",
11
+ version=version["__version__"],
6
12
  packages=find_packages(where="src"),
7
13
  package_dir={"": "src"},
8
14
  install_requires=[
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -11,8 +11,13 @@ from .memory import MemoryManager
11
11
 
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
- SYSTEM_PROMPT = """You are nimcode, an autonomous AI coding assistant.
15
- You have access to a set of tools to read, write, and execute code.
14
+ import platform
15
+
16
+ SYSTEM_PROMPT_TEMPLATE = """You are nimcode, an autonomous AI coding assistant. You are a senior-level developer who writes production-ready code.
17
+
18
+ OPERATING SYSTEM: {os_name}
19
+ SHELL: {shell_info}
20
+ WORKING DIRECTORY: {cwd}
16
21
 
17
22
  CRITICAL INSTRUCTION FOR TOOL CALLING:
18
23
  You must output exactly ONE tool call per turn, formatted exactly as a fenced XML block.
@@ -20,49 +25,120 @@ DO NOT use Markdown code blocks for the tool call. DO NOT output multiple tool c
20
25
 
21
26
  Format:
22
27
  <tool_call>
23
- {"tool": "ToolName", "args": {"arg1": "val1"}}
28
+ {{"tool": "ToolName", "args": {{"arg1": "val1"}}}}
24
29
  </tool_call>
25
30
 
26
31
  Available Tools:
27
- - Bash: {"tool": "Bash", "args": {"command": "string"}}
28
- - Read: {"tool": "Read", "args": {"file_path": "string"}}
29
- - Write: {"tool": "Write", "args": {"file_path": "string", "content": "string"}}
30
- - Edit: {"tool": "Edit", "args": {"file_path": "string", "old_string": "string", "new_string": "string"}}
31
- - Glob: {"tool": "Glob", "args": {"pattern": "string"}}
32
- - Grep: {"tool": "Grep", "args": {"query": "string", "directory": "string"}}
33
-
34
- Before calling a tool, you may optionally use a <think> block to reason about your plan. This helps you execute complex tasks correctly.
35
- For example:
32
+ - Bash: {{"tool": "Bash", "args": {{"command": "string"}}}}
33
+ - Read: {{"tool": "Read", "args": {{"file_path": "string"}}}}
34
+ - Write: {{"tool": "Write", "args": {{"file_path": "string", "content": "string"}}}}
35
+ - Edit: {{"tool": "Edit", "args": {{"file_path": "string", "old_string": "string", "new_string": "string"}}}}
36
+ - Glob: {{"tool": "Glob", "args": {{"pattern": "string"}}}}
37
+ - Grep: {{"tool": "Grep", "args": {{"query": "string", "directory": "string"}}}}
38
+
39
+ Before calling a tool, you may optionally use a <think> block to reason about your plan.
36
40
  <think>
37
41
  I need to check the file contents first to see where the function is defined.
38
42
  </think>
39
43
  <tool_call>
40
- {"tool": "Read", "args": {"file_path": "main.py"}}
44
+ {{"tool": "Read", "args": {{"file_path": "main.py"}}}}
41
45
  </tool_call>
42
46
 
43
- Workspace Guidelines:
44
- - When creating plans, store them inside the `.nimcode/plans/` directory (e.g., `.nimcode/plans/dino_game_plan.md`). Create this directory if it doesn't exist.
45
- - When creating or learning new skills/memories, store them as markdown files inside the `.nimcode/skills/` directory.
47
+ WORKSPACE STRUCTURE:
48
+ - The `.nimcode/` directory is YOUR workspace for plans, skills, and logs. It is NOT where user project code lives.
49
+ - `.nimcode/plans/` Store step-by-step markdown plans here.
50
+ - `.nimcode/skills/` — Store custom guidelines, framework rules, or memories as markdown files.
51
+ - `.nimcode/history/` — File backups for /undo capabilities.
52
+ - User's project code lives in the WORKING DIRECTORY root and its subdirectories.
53
+ - NEVER write user's source code files inside `.nimcode/`. That directory is only for your internal data.
54
+ - When the user asks you to create a file like `main.py`, `app.js`, etc., write it to the WORKING DIRECTORY, not `.nimcode/`.
55
+
56
+ {os_specific_instructions}
57
+
58
+ PLANNING MODE INSTRUCTIONS:
59
+ When in /plan mode or asked to create a plan:
60
+ 1. FIRST: Read the project structure using Glob and Read tools. Understand what already exists.
61
+ 2. SECOND: Analyze the codebase — read key files, understand the architecture, dependencies, and patterns.
62
+ 3. THIRD: Write a DETAILED, ACTIONABLE plan that references actual files and code in the project.
63
+ 4. Your plan must include:
64
+ - Specific files to create/modify (with full paths)
65
+ - Actual code snippets or pseudocode for each change
66
+ - Dependencies to install
67
+ - Testing strategy
68
+ - Step-by-step execution order
69
+ 5. NEVER write generic project management templates (stakeholders, timelines, resources).
70
+ 6. NEVER use placeholders like [Insert Date] or [Insert Dependencies].
71
+ 7. A good plan is one that another developer could follow to implement the feature without asking questions.
72
+
73
+ CODE EXECUTION INSTRUCTIONS:
74
+ When asked to "execute", "implement", or "build" something:
75
+ 1. Write REAL, WORKING code — not echo statements or placeholder scripts.
76
+ 2. Create actual source files with production-quality code.
77
+ 3. Install dependencies, run tests, and verify your work.
78
+ 4. If a plan exists in `.nimcode/plans/`, follow it step by step.
46
79
 
47
80
  When you have completely fulfilled the user's request and have no more tools to run, output the word TASK_COMPLETE.
48
81
  """
49
82
 
83
+ OS_INSTRUCTIONS_WINDOWS = """WINDOWS-SPECIFIC RULES:
84
+ - Use backslash (\\) for file paths in Bash commands, or use forward slash (/) which also works on Windows.
85
+ - Use PowerShell or cmd syntax: `mkdir .nimcode\\plans` (NOT `mkdir -p .nimcode/plans`).
86
+ - Use `type` instead of `cat`, `dir` instead of `ls`, `copy` instead of `cp`.
87
+ - Use `python` instead of `python3`.
88
+ - For creating nested directories, use: `mkdir .nimcode\\plans 2>nul` or `New-Item -ItemType Directory -Force -Path .nimcode\\plans`
89
+ - DO NOT use Unix-only commands like chmod, grep (use findstr), sed, awk.
90
+ """
91
+
92
+ OS_INSTRUCTIONS_UNIX = """UNIX-SPECIFIC RULES:
93
+ - Use forward slash (/) for file paths.
94
+ - Use standard Unix commands: mkdir -p, cat, ls, cp, chmod.
95
+ - Use `python3` if `python` is not available.
96
+ """
97
+
98
+
99
+ def _build_system_prompt(cwd: str) -> str:
100
+ """Builds the system prompt with OS-aware instructions."""
101
+ os_name = platform.system()
102
+ if os_name == "Windows":
103
+ shell_info = "PowerShell / cmd.exe"
104
+ os_specific = OS_INSTRUCTIONS_WINDOWS
105
+ else:
106
+ shell_info = "bash / zsh"
107
+ os_specific = OS_INSTRUCTIONS_UNIX
108
+
109
+ return SYSTEM_PROMPT_TEMPLATE.format(
110
+ os_name=os_name,
111
+ shell_info=shell_info,
112
+ cwd=cwd,
113
+ os_specific_instructions=os_specific,
114
+ )
115
+
116
+
50
117
  class Agent:
51
- def __init__(self, api_key: str, model: str = None, max_turns: int = 30, permission_mode: PermissionMode = PermissionMode.DEFAULT, max_tokens: int = 4000):
118
+ def __init__(self, api_key: str, model: str = None, max_turns: int = None, permission_mode: PermissionMode = PermissionMode.DEFAULT, max_tokens: int = None):
52
119
  # Load global settings
53
120
  self.settings = load_settings()
54
121
  self.model = model or self.settings.get("model", "meta/llama-3.1-70b-instruct")
55
122
  api_base_url = self.settings.get("api_base_url", "https://integrate.api.nvidia.com/v1")
56
- self.client = NimClient(api_key=api_key, base_url=api_base_url, model=self.model)
123
+ self.client = NimClient(
124
+ api_key=api_key,
125
+ base_url=api_base_url,
126
+ model=self.model,
127
+ timeout=self.settings.get("timeout_llm", 120.0),
128
+ max_retries=self.settings.get("max_retries", 15),
129
+ retry_base_delay=self.settings.get("retry_base_delay", 2.0),
130
+ retry_max_delay=self.settings.get("retry_max_delay", 60.0),
131
+ )
57
132
 
58
133
  # Initialize MCP Manager
59
134
  self.mcp = MCPManager(self.settings)
60
135
 
61
- # Base system prompt
62
- final_prompt = SYSTEM_PROMPT + self.mcp.get_system_prompt_additions()
136
+ # Build OS-aware system prompt
137
+ cwd = os.getcwd()
138
+ final_prompt = _build_system_prompt(cwd) + self.mcp.get_system_prompt_additions()
63
139
 
64
140
  # Load skills if present
65
- skills_dir = os.path.join(os.getcwd(), ".nimcode", "skills")
141
+ skills_dir = os.path.join(cwd, ".nimcode", "skills")
66
142
  if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
67
143
  loaded_skills = []
68
144
  for filename in os.listdir(skills_dir):
@@ -86,9 +162,16 @@ class Agent:
86
162
  self.messages: List[Dict[str, Any]] = [
87
163
  {"role": "system", "content": final_prompt}
88
164
  ]
89
- self.max_turns = max_turns
165
+
166
+ # Use config values with parameter overrides
167
+ cfg_max_turns = self.settings.get("max_turns", 200)
168
+ self.max_turns = max_turns if max_turns is not None else (0 if cfg_max_turns == 0 else cfg_max_turns)
169
+
170
+ cfg_max_tokens = self.settings.get("max_tokens", 120000)
171
+ effective_max_tokens = max_tokens if max_tokens is not None else cfg_max_tokens
172
+ self.memory = MemoryManager(max_tokens=effective_max_tokens)
173
+
90
174
  self.permission_engine = PermissionEngine(mode=permission_mode)
91
- self.memory = MemoryManager(max_tokens=max_tokens)
92
175
 
93
176
  def save_history(self):
94
177
  """Saves current conversation to NIMCODE.md"""
@@ -185,7 +268,8 @@ class Agent:
185
268
  import json
186
269
  total_context_chars = sum(len(str(m.get("content", ""))) for m in self.messages)
187
270
  total_est_tokens = total_context_chars // 4
188
- max_context = 128000 # Assume standard Llama-3.1 128k context for now
271
+ from .model_registry import get_context_window
272
+ max_context = get_context_window(self.model)
189
273
  if total_est_tokens > max_context * 0.8:
190
274
  c.print("[bold yellow]⚠️ Context window is over 80% full. Consider running /compact or /clear.[/bold yellow]")
191
275
 
@@ -199,7 +283,9 @@ class Agent:
199
283
  while turn < max_turns:
200
284
  turn += 1
201
285
  try:
202
- response_text = await self.client.chat_one_shot(self.messages)
286
+ # Build a combined prompt from messages for one-shot call
287
+ combined = "\n".join(f"{m['role'].upper()}: {m.get('content','')}" for m in self.messages)
288
+ response_text = await self.client.chat_one_shot(combined)
203
289
  except Exception as e:
204
290
  return f"Subagent error: {e}"
205
291
 
@@ -228,13 +314,12 @@ class Agent:
228
314
  if not old_msgs:
229
315
  return self.messages
230
316
 
231
- summary_prompt = "Please summarize the following conversation concisely. Focus on the final state, any completed goals, and any context needed for the next steps:\n\n"
317
+ summary_prompt = "You are a summarization AI. Please summarize the following conversation concisely. Focus on the final state, any completed goals, and any context needed for the next steps:\n\n"
232
318
  for m in old_msgs:
233
319
  summary_prompt += f"{m['role'].upper()}: {str(m['content'])[:500]}...\n"
234
320
 
235
- messages = [{"role": "system", "content": "You are a summarization AI."}, {"role": "user", "content": summary_prompt}]
236
321
  try:
237
- summary = await self.client.chat_one_shot(messages)
322
+ summary = await self.client.chat_one_shot(summary_prompt)
238
323
  system_msg["content"] += f"\n\n[PREVIOUS MEMORY SUMMARY]\n{summary}"
239
324
  except Exception as e:
240
325
  logger.error(f"Distillation failed: {e}")
@@ -251,7 +336,7 @@ class Agent:
251
336
 
252
337
  cwd = os.getcwd()
253
338
  turn = 0
254
- while turn < self.max_turns:
339
+ while self.max_turns == 0 or turn < self.max_turns:
255
340
  turn += 1
256
341
  logger.info(f"--- Turn {turn} ---")
257
342
 
@@ -338,8 +423,10 @@ class Agent:
338
423
  if file_path.endswith(".py"):
339
424
  import subprocess
340
425
  try:
341
- subprocess.run(["black", "-q", file_path], capture_output=True, timeout=10)
342
- lint = subprocess.run(["flake8", "--select=E9,F821,F822,F823", file_path], capture_output=True, text=True, timeout=10)
426
+ t_fmt = self.settings.get("timeout_format", 10)
427
+ t_fmt = None if t_fmt == 0 else t_fmt
428
+ subprocess.run(["black", "-q", file_path], capture_output=True, timeout=t_fmt)
429
+ lint = subprocess.run(["flake8", "--select=E9,F821,F822,F823", file_path], capture_output=True, text=True, timeout=t_fmt)
343
430
  if lint.returncode != 0:
344
431
  import sys
345
432
  if sys.stdin.isatty():
@@ -357,7 +444,9 @@ class Agent:
357
444
  elif file_path.endswith((".js", ".ts", ".jsx", ".tsx")):
358
445
  import subprocess
359
446
  try:
360
- subprocess.run(["npx", "--yes", "prettier", "--write", file_path], capture_output=True, timeout=10)
447
+ t_fmt = self.settings.get("timeout_format", 10)
448
+ t_fmt = None if t_fmt == 0 else t_fmt
449
+ subprocess.run(["npx", "--yes", "prettier", "--write", file_path], capture_output=True, timeout=t_fmt)
361
450
  except Exception:
362
451
  pass
363
452
  except KeyboardInterrupt:
@@ -10,7 +10,21 @@ def load_settings() -> Dict[str, Any]:
10
10
  settings = {
11
11
  "model": "meta/llama-3.1-70b-instruct",
12
12
  "api_base_url": "https://integrate.api.nvidia.com/v1",
13
- "mcp_servers": {}
13
+ "mcp_servers": {},
14
+ # Timeouts (0 = disabled/infinite)
15
+ "timeout_command": 1200, # Timeout for bash commands (seconds)
16
+ "timeout_llm": 120, # Timeout for LLM API calls (seconds)
17
+ "timeout_format": 10, # Timeout for formatting/linting tools (seconds)
18
+ "timeout_browser": 15000, # Timeout for browser actions (milliseconds)
19
+ "timeout_updater": 3, # Timeout for update checks (seconds)
20
+ # Agent behavior
21
+ "max_turns": 200, # Max agent turns per session (0 = unlimited)
22
+ "max_tokens": 120000, # Token budget before auto-compact kicks in
23
+ "max_retries": 15, # Max LLM API retry attempts on transient errors
24
+ "retry_base_delay": 2.0, # Base delay for exponential backoff (seconds)
25
+ "retry_max_delay": 60.0, # Max delay cap for exponential backoff (seconds)
26
+ # Security
27
+ "allow_bash_non_interactive": False, # Allow Bash in non-interactive (CI) mode
14
28
  }
15
29
 
16
30
  # Global settings
@@ -0,0 +1,54 @@
1
+ """
2
+ Model registry with context window sizes and metadata.
3
+ Used for auto-compact thresholds and dynamic model listing.
4
+ """
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Known NVIDIA NIM models and their context window sizes (in tokens)
10
+ # This acts as a fallback + enrichment layer when API is unavailable
11
+ MODEL_CONTEXT_WINDOWS = {
12
+ # Llama 3.1 family
13
+ "meta/llama-3.1-8b-instruct": 128000,
14
+ "meta/llama-3.1-70b-instruct": 128000,
15
+ "meta/llama-3.1-405b-instruct": 128000,
16
+ # Llama 3.2 family
17
+ "meta/llama-3.2-1b-instruct": 128000,
18
+ "meta/llama-3.2-3b-instruct": 128000,
19
+ "meta/llama-3.2-11b-vision-instruct": 128000,
20
+ "meta/llama-3.2-90b-vision-instruct": 128000,
21
+ # Llama 3.3 family
22
+ "meta/llama-3.3-70b-instruct": 128000,
23
+ # Nemotron
24
+ "nvidia/nemotron-4-340b-instruct": 4096,
25
+ "nvidia/llama-3.1-nemotron-70b-instruct": 128000,
26
+ # Mistral
27
+ "mistralai/mixtral-8x22b-instruct-v0.1": 65536,
28
+ "mistralai/mistral-large-2-instruct": 128000,
29
+ # DeepSeek
30
+ "deepseek-ai/deepseek-r1": 128000,
31
+ # Qwen
32
+ "qwen/qwen2.5-72b-instruct": 128000,
33
+ "qwen/qwq-32b": 128000,
34
+ }
35
+
36
+ # Default context window for unknown models
37
+ DEFAULT_CONTEXT_WINDOW = 8192
38
+
39
+ # Hardcoded fallback model list (used when API is unreachable)
40
+ FALLBACK_MODELS = [
41
+ "meta/llama-3.1-70b-instruct",
42
+ "meta/llama-3.1-8b-instruct",
43
+ "meta/llama-3.1-405b-instruct",
44
+ "meta/llama-3.3-70b-instruct",
45
+ "nvidia/llama-3.1-nemotron-70b-instruct",
46
+ "deepseek-ai/deepseek-r1",
47
+ "mistralai/mixtral-8x22b-instruct-v0.1",
48
+ "qwen/qwen2.5-72b-instruct",
49
+ ]
50
+
51
+
52
+ def get_context_window(model_id: str) -> int:
53
+ """Returns the context window size for a given model ID."""
54
+ return MODEL_CONTEXT_WINDOWS.get(model_id, DEFAULT_CONTEXT_WINDOW)
@@ -8,10 +8,14 @@ from typing import List, Dict, Any, Optional, AsyncGenerator
8
8
  logger = logging.getLogger(__name__)
9
9
 
10
10
  class NimClient:
11
- def __init__(self, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "meta/llama-3.1-70b-instruct"):
11
+ def __init__(self, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "meta/llama-3.1-70b-instruct", timeout: float = 120.0, max_retries: int = 15, retry_base_delay: float = 2.0, retry_max_delay: float = 60.0):
12
12
  self.api_key = api_key
13
13
  self.base_url = base_url.rstrip("/")
14
14
  self.model = model
15
+ self.timeout = None if timeout == 0 else timeout
16
+ self.max_retries = max_retries
17
+ self.retry_base_delay = retry_base_delay
18
+ self.retry_max_delay = retry_max_delay
15
19
  self.headers = {
16
20
  "Authorization": f"Bearer {self.api_key}",
17
21
  "Content-Type": "application/json",
@@ -19,13 +23,25 @@ class NimClient:
19
23
  }
20
24
 
21
25
  async def get_available_models(self) -> List[str]:
22
- return [
23
- "meta/llama-3.1-70b-instruct",
24
- "meta/llama-3.1-8b-instruct",
25
- "meta/llama-3.1-405b-instruct",
26
- "nvidia/nemotron-4-340b-instruct",
27
- "mistralai/mixtral-8x22b-instruct-v0.1"
28
- ]
26
+ """Fetches available models from the NIM API. Falls back to known models on error."""
27
+ try:
28
+ async with httpx.AsyncClient() as client:
29
+ response = await client.get(
30
+ f"{self.base_url}/models",
31
+ headers=self.headers,
32
+ timeout=10.0
33
+ )
34
+ if response.status_code == 200:
35
+ data = response.json()
36
+ models = [m["id"] for m in data.get("data", []) if m.get("id")]
37
+ if models:
38
+ return sorted(models)
39
+ except Exception as e:
40
+ logger.debug(f"Failed to fetch models from API: {e}")
41
+
42
+ # Fallback to known model list
43
+ from .model_registry import FALLBACK_MODELS
44
+ return FALLBACK_MODELS
29
45
 
30
46
  async def chat_one_shot(self, prompt: str) -> str:
31
47
  payload = {
@@ -39,7 +55,7 @@ class NimClient:
39
55
  f"{self.base_url}/chat/completions",
40
56
  headers=self.headers,
41
57
  json=payload,
42
- timeout=60.0
58
+ timeout=self.timeout
43
59
  )
44
60
  response.raise_for_status()
45
61
  data = response.json()
@@ -70,7 +86,7 @@ class NimClient:
70
86
  f"{self.base_url}/chat/completions",
71
87
  headers=self.headers,
72
88
  json=payload,
73
- timeout=60.0
89
+ timeout=self.timeout
74
90
  )
75
91
  response.raise_for_status()
76
92
  data = response.json()
@@ -90,15 +106,15 @@ class NimClient:
90
106
  "stream": stream
91
107
  }
92
108
 
93
- max_retries = 15
94
- base_delay = 2.0
95
- max_delay = 60.0
109
+ max_retries = self.max_retries
110
+ base_delay = self.retry_base_delay
111
+ max_delay = self.retry_max_delay
96
112
 
97
113
  for attempt in range(max_retries):
98
114
  chunk_yielded = False
99
115
  try:
100
116
  async with httpx.AsyncClient() as client:
101
- async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=120.0) as response:
117
+ async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=self.timeout) as response:
102
118
  if response.status_code in [408, 429, 500, 502, 503, 504, 529]:
103
119
  raise httpx.HTTPStatusError(f"Temporary server error {response.status_code}", request=response.request, response=response)
104
120
 
@@ -39,8 +39,18 @@ class PermissionEngine:
39
39
  args = tool_call.get("args", {})
40
40
 
41
41
  if not sys.stdin.isatty():
42
- console.print(f"[yellow]Running non-interactive. Approving {tool_name} for tests.[/yellow]")
43
- return True
42
+ # Non-interactive mode: only auto-approve safe tools + writes.
43
+ # Bash requires explicit opt-in via config.
44
+ from .config import load_settings
45
+ settings = load_settings()
46
+ safe_non_interactive = {"Read", "Glob", "Grep", "Write", "Edit", "ASTReplace"}
47
+ if tool_name in safe_non_interactive:
48
+ return True
49
+ if tool_name == "Bash" and settings.get("allow_bash_non_interactive", False):
50
+ console.print(f"[yellow]Non-interactive: Bash auto-approved via config.[/yellow]")
51
+ return True
52
+ console.print(f"[red]Non-interactive: {tool_name} DENIED (enable 'allow_bash_non_interactive' in settings to allow).[/red]")
53
+ return False
44
54
 
45
55
  while True:
46
56
  content = ""
@@ -531,17 +531,30 @@ class NimcodeREPL:
531
531
  self.messages.append({"role": "system", "content": "You are the Application Guardian. Your sole priority is preventing regressions. Before making any code changes, ensure you write a test first. If a user asks for a breaking change, warn them aggressively."})
532
532
  continue
533
533
  elif user_input.strip() == "/thinkback":
534
- console.print("[bold blue]⏪ Entering Agent Replay Mode (Thinkback)[/bold blue]")
534
+ console.print("[bold blue]⏪ Agent Replay (Thinkback)[/bold blue]")
535
535
  from rich.table import Table
536
- table = Table(title="Recent Agent Actions")
536
+ table = Table(title="Session History")
537
537
  table.add_column("Turn", style="cyan")
538
- table.add_column("Tokens", style="yellow")
539
- table.add_column("Tools Called", style="green")
540
- table.add_row("1", "450", "Read, Glob")
541
- table.add_row("2", "1200", "Bash, Write")
542
- table.add_row("3", "300", "StartTerminal")
543
- console.print(table)
544
- console.print("[dim]Note: Full replay logs are stored in .nimcode/logs/[/dim]")
538
+ table.add_column("Role", style="yellow")
539
+ table.add_column("Tokens (est.)", style="green")
540
+ table.add_column("Preview", style="dim", max_width=60)
541
+ turn_num = 0
542
+ for msg in self.messages[1:]: # Skip system prompt
543
+ role = msg.get("role", "?")
544
+ content = str(msg.get("content", ""))
545
+ est_tokens = len(content) // 4 + 1
546
+ preview = content[:80].replace("\n", " ")
547
+ if len(content) > 80:
548
+ preview += "..."
549
+ if role == "user":
550
+ turn_num += 1
551
+ table.add_row(str(turn_num), role, str(est_tokens), preview)
552
+ if turn_num == 0:
553
+ console.print("[dim]No conversation history yet.[/dim]")
554
+ else:
555
+ console.print(table)
556
+ total_tokens = sum(len(str(m.get("content", ""))) // 4 + 1 for m in self.messages)
557
+ console.print(f"[dim]Total messages: {len(self.messages)} | Est. total tokens: {total_tokens}[/dim]")
545
558
  continue
546
559
  elif user_input.strip().startswith("/fix"):
547
560
  parts = user_input.strip().split(" ", 1)
@@ -0,0 +1,97 @@
1
+ """
2
+ Lightweight repository mapper.
3
+ Generates a tree-style overview of the project structure.
4
+ Used by watcher.py for Live Sync updates.
5
+ """
6
+ import os
7
+ import logging
8
+ from typing import List, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Directories to always skip
13
+ IGNORE_DIRS = {
14
+ ".git", ".nimcode", "__pycache__", "node_modules", ".venv", "venv",
15
+ ".tox", ".mypy_cache", ".pytest_cache", "dist", "build", ".egg-info",
16
+ ".idea", ".vscode", "env", ".env",
17
+ }
18
+
19
+ # File extensions to include in the map
20
+ CODE_EXTENSIONS = {
21
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".rb",
22
+ ".c", ".cpp", ".h", ".cs", ".php", ".swift", ".kt", ".scala",
23
+ ".html", ".css", ".scss", ".vue", ".svelte",
24
+ ".json", ".yaml", ".yml", ".toml", ".md", ".txt",
25
+ ".sql", ".sh", ".bat", ".ps1", ".dockerfile",
26
+ }
27
+
28
+ MAX_FILES = 500 # Safety limit
29
+
30
+
31
+ class RepoMapper:
32
+ def __init__(self, root_dir: str, max_depth: int = 6):
33
+ self.root_dir = os.path.abspath(root_dir)
34
+ self.max_depth = max_depth
35
+ self._file_count = 0
36
+
37
+ def generate_map(self) -> str:
38
+ """Generates a tree-style string of the repository structure."""
39
+ self._file_count = 0
40
+ lines = [f"📁 {os.path.basename(self.root_dir)}/"]
41
+ self._walk(self.root_dir, lines, prefix="", depth=0)
42
+
43
+ if self._file_count >= MAX_FILES:
44
+ lines.append(f"\n... (truncated at {MAX_FILES} files)")
45
+
46
+ return "\n".join(lines)
47
+
48
+ def _walk(self, directory: str, lines: List[str], prefix: str, depth: int) -> None:
49
+ if depth >= self.max_depth or self._file_count >= MAX_FILES:
50
+ return
51
+
52
+ try:
53
+ entries = sorted(os.listdir(directory))
54
+ except PermissionError:
55
+ return
56
+
57
+ # Separate dirs and files
58
+ dirs = []
59
+ files = []
60
+ for entry in entries:
61
+ full_path = os.path.join(directory, entry)
62
+ if os.path.isdir(full_path):
63
+ if entry not in IGNORE_DIRS and not entry.startswith("."):
64
+ dirs.append(entry)
65
+ else:
66
+ ext = os.path.splitext(entry)[1].lower()
67
+ if ext in CODE_EXTENSIONS or entry in {"Makefile", "Dockerfile", "Procfile", ".gitignore"}:
68
+ files.append(entry)
69
+
70
+ all_entries = dirs + files
71
+ for i, entry in enumerate(all_entries):
72
+ is_last = (i == len(all_entries) - 1)
73
+ connector = "└── " if is_last else "├── "
74
+
75
+ full_path = os.path.join(directory, entry)
76
+
77
+ if entry in dirs:
78
+ lines.append(f"{prefix}{connector}📁 {entry}/")
79
+ extension = " " if is_last else "│ "
80
+ self._walk(full_path, lines, prefix + extension, depth + 1)
81
+ else:
82
+ self._file_count += 1
83
+ if self._file_count > MAX_FILES:
84
+ return
85
+ size = os.path.getsize(full_path)
86
+ size_str = _format_size(size)
87
+ lines.append(f"{prefix}{connector}{entry} ({size_str})")
88
+
89
+
90
+ def _format_size(size_bytes: int) -> str:
91
+ """Formats file size in human-readable format."""
92
+ if size_bytes < 1024:
93
+ return f"{size_bytes}B"
94
+ elif size_bytes < 1024 * 1024:
95
+ return f"{size_bytes / 1024:.1f}KB"
96
+ else:
97
+ return f"{size_bytes / (1024 * 1024):.1f}MB"
@@ -239,6 +239,8 @@ class ToolRegistry:
239
239
  return f"Started task '{task_id}' in the background. Check logs at {log_file}. Use /tasks to manage."
240
240
 
241
241
  try:
242
+ t_cmd = settings.get("timeout_command", 1200)
243
+ t_cmd = None if t_cmd == 0 else t_cmd
242
244
  result = subprocess.run(
243
245
  command,
244
246
  shell=True,
@@ -246,7 +248,7 @@ class ToolRegistry:
246
248
  stdout=subprocess.PIPE,
247
249
  stderr=subprocess.PIPE,
248
250
  text=True,
249
- timeout=1200
251
+ timeout=t_cmd
250
252
  )
251
253
  out = result.stdout + "\n" + result.stderr
252
254
  out = out.strip()
@@ -257,7 +259,7 @@ class ToolRegistry:
257
259
 
258
260
  return out if out else "Command executed successfully with no output."
259
261
  except subprocess.TimeoutExpired:
260
- return "Error: Command timed out after 1200 seconds."
262
+ return f"Error: Command timed out after {t_cmd} seconds."
261
263
  except Exception as e:
262
264
  return f"Error running bash: {e}"
263
265
 
@@ -565,11 +567,16 @@ class ToolRegistry:
565
567
  try:
566
568
  from playwright.sync_api import sync_playwright
567
569
  import base64
570
+ from .config import load_settings
571
+
572
+ settings = load_settings()
573
+ t_browser = settings.get("timeout_browser", 15000)
574
+ t_browser = 0 if t_browser == 0 else t_browser
568
575
 
569
576
  with sync_playwright() as p:
570
577
  browser = p.chromium.launch(headless=True)
571
578
  page = browser.new_page()
572
- page.goto(url, wait_until="networkidle", timeout=15000)
579
+ page.goto(url, wait_until="networkidle", timeout=t_browser)
573
580
 
574
581
  screenshot_bytes = page.screenshot(type="jpeg", quality=50)
575
582
  b64_img = base64.b64encode(screenshot_bytes).decode('utf-8')
@@ -3,7 +3,7 @@ import logging
3
3
 
4
4
  logger = logging.getLogger(__name__)
5
5
 
6
- CURRENT_VERSION = "3.0.0"
6
+ from .__version__ import __version__ as CURRENT_VERSION
7
7
 
8
8
  class AutoUpdater:
9
9
  @staticmethod
@@ -13,7 +13,12 @@ class AutoUpdater:
13
13
  Returns the latest version string if > CURRENT_VERSION, else None.
14
14
  """
15
15
  try:
16
- async with httpx.AsyncClient(timeout=3.0) as client:
16
+ from .config import load_settings
17
+ settings = load_settings()
18
+ t_upd = settings.get("timeout_updater", 3.0)
19
+ t_upd = None if t_upd == 0 else t_upd
20
+
21
+ async with httpx.AsyncClient(timeout=t_upd) as client:
17
22
  response = await client.get("https://pypi.org/pypi/nimcode/json")
18
23
  if response.status_code == 200:
19
24
  data = response.json()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nimcode
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: A standalone, robust coding agent for NVIDIA NIM models.
5
5
  Author: Autonomous Agent
6
6
  Requires-Dist: httpx>=0.27.0
@@ -1,17 +1,20 @@
1
1
  README.md
2
2
  setup.py
3
3
  src/nimcode/__init__.py
4
+ src/nimcode/__version__.py
4
5
  src/nimcode/agent.py
5
6
  src/nimcode/cli.py
6
7
  src/nimcode/config.py
7
8
  src/nimcode/lenient_parser.py
8
9
  src/nimcode/mcp_client.py
9
10
  src/nimcode/memory.py
11
+ src/nimcode/model_registry.py
10
12
  src/nimcode/nim_client.py
11
13
  src/nimcode/permissions.py
12
14
  src/nimcode/plugin_manager.py
13
15
  src/nimcode/rag.py
14
16
  src/nimcode/repl.py
17
+ src/nimcode/repo_map.py
15
18
  src/nimcode/tools.py
16
19
  src/nimcode/updater.py
17
20
  src/nimcode/watcher.py
@@ -27,11 +30,14 @@ tests/test_config.py
27
30
  tests/test_lenient_parser.py
28
31
  tests/test_mcp_client.py
29
32
  tests/test_memory.py
33
+ tests/test_model_registry.py
30
34
  tests/test_nim_client.py
31
35
  tests/test_permissions.py
32
36
  tests/test_plugin_manager.py
33
37
  tests/test_repl.py
34
38
  tests/test_repl_extra.py
35
39
  tests/test_repl_trust.py
40
+ tests/test_repo_map.py
36
41
  tests/test_tools.py
37
- tests/test_updater.py
42
+ tests/test_updater.py
43
+ tests/test_version.py
@@ -9,6 +9,16 @@ def test_load_settings_default():
9
9
  settings = load_settings()
10
10
  assert settings["model"] == "meta/llama-3.1-70b-instruct"
11
11
  assert settings["mcp_servers"] == {}
12
+ # New configurable settings
13
+ assert settings["max_turns"] == 200
14
+ assert settings["max_tokens"] == 120000
15
+ assert settings["max_retries"] == 15
16
+ assert settings["retry_base_delay"] == 2.0
17
+ assert settings["retry_max_delay"] == 60.0
18
+ assert settings["allow_bash_non_interactive"] == False
19
+ # Timeouts
20
+ assert settings["timeout_command"] == 1200
21
+ assert settings["timeout_llm"] == 120
12
22
 
13
23
  def test_load_settings_global_only():
14
24
  mock_global = '{"model": "global_model", "mcp_servers": {"global": {}}}'
@@ -0,0 +1,38 @@
1
+ """Tests for the model_registry module."""
2
+ import pytest
3
+ from nimcode.model_registry import (
4
+ MODEL_CONTEXT_WINDOWS,
5
+ FALLBACK_MODELS,
6
+ DEFAULT_CONTEXT_WINDOW,
7
+ get_context_window,
8
+ )
9
+
10
+
11
+ def test_get_context_window_known_model():
12
+ """Known models should return their specific context window size."""
13
+ assert get_context_window("meta/llama-3.1-70b-instruct") == 128000
14
+ assert get_context_window("nvidia/nemotron-4-340b-instruct") == 4096
15
+ assert get_context_window("mistralai/mixtral-8x22b-instruct-v0.1") == 65536
16
+
17
+
18
+ def test_get_context_window_unknown_model():
19
+ """Unknown models should return the default context window."""
20
+ assert get_context_window("some/unknown-model") == DEFAULT_CONTEXT_WINDOW
21
+ assert get_context_window("") == DEFAULT_CONTEXT_WINDOW
22
+
23
+
24
+ def test_fallback_models_not_empty():
25
+ """Fallback model list should have entries."""
26
+ assert len(FALLBACK_MODELS) > 0
27
+
28
+
29
+ def test_fallback_models_all_in_context_windows():
30
+ """All fallback models should have a known context window entry."""
31
+ for model in FALLBACK_MODELS:
32
+ assert model in MODEL_CONTEXT_WINDOWS, f"Fallback model {model} missing from context windows"
33
+
34
+
35
+ def test_context_windows_positive():
36
+ """All context window values should be positive integers."""
37
+ for model, ctx in MODEL_CONTEXT_WINDOWS.items():
38
+ assert isinstance(ctx, int) and ctx > 0, f"Invalid context window for {model}: {ctx}"
@@ -8,8 +8,10 @@ def test_permission_bypass():
8
8
 
9
9
  def test_permission_readonly_blocked():
10
10
  engine = PermissionEngine(mode=PermissionMode.AUTO)
11
- # Bash should still be checked (not returned True immediately) and if not interactive, returns True for now (which is a flaw but let's test current behavior)
12
- assert engine.check_permission({"tool": "Bash", "args": {}}) == True
11
+ # In non-interactive mode (tests), Bash is now DENIED by default.
12
+ # Safe tools (Read, Glob, Grep) and Write/Edit are still auto-approved.
13
+ assert engine.check_permission({"tool": "Read", "args": {}}) == True
14
+ assert engine.check_permission({"tool": "Bash", "args": {}}) == False
13
15
 
14
16
  def test_permission_safe_tools():
15
17
  engine = PermissionEngine(mode=PermissionMode.DEFAULT)
@@ -0,0 +1,93 @@
1
+ """Tests for repo_map module."""
2
+ import os
3
+ import pytest
4
+ import tempfile
5
+ from nimcode.repo_map import RepoMapper, _format_size
6
+
7
+
8
+ def test_format_size_bytes():
9
+ assert _format_size(500) == "500B"
10
+ assert _format_size(0) == "0B"
11
+
12
+
13
+ def test_format_size_kb():
14
+ result = _format_size(2048)
15
+ assert "KB" in result
16
+
17
+
18
+ def test_format_size_mb():
19
+ result = _format_size(2 * 1024 * 1024)
20
+ assert "MB" in result
21
+
22
+
23
+ def test_generate_map_basic(tmp_path):
24
+ """Test basic tree generation with files and directories."""
25
+ # Create a simple project structure
26
+ (tmp_path / "main.py").write_text("print('hello')")
27
+ (tmp_path / "utils.py").write_text("def helper(): pass")
28
+ sub = tmp_path / "src"
29
+ sub.mkdir()
30
+ (sub / "app.py").write_text("class App: pass")
31
+
32
+ mapper = RepoMapper(str(tmp_path))
33
+ result = mapper.generate_map()
34
+
35
+ assert "main.py" in result
36
+ assert "utils.py" in result
37
+ assert "src" in result
38
+ assert "app.py" in result
39
+
40
+
41
+ def test_generate_map_ignores_pycache(tmp_path):
42
+ """Test that __pycache__ directories are ignored."""
43
+ (tmp_path / "main.py").write_text("x = 1")
44
+ cache = tmp_path / "__pycache__"
45
+ cache.mkdir()
46
+ (cache / "main.cpython-311.pyc").write_bytes(b"bytecode")
47
+
48
+ mapper = RepoMapper(str(tmp_path))
49
+ result = mapper.generate_map()
50
+
51
+ assert "main.py" in result
52
+ assert "__pycache__" not in result
53
+
54
+
55
+ def test_generate_map_ignores_node_modules(tmp_path):
56
+ """Test that node_modules is ignored."""
57
+ (tmp_path / "index.js").write_text("console.log('hi')")
58
+ nm = tmp_path / "node_modules"
59
+ nm.mkdir()
60
+ (nm / "package.json").write_text("{}")
61
+
62
+ mapper = RepoMapper(str(tmp_path))
63
+ result = mapper.generate_map()
64
+
65
+ assert "index.js" in result
66
+ assert "node_modules" not in result
67
+
68
+
69
+ def test_generate_map_max_depth(tmp_path):
70
+ """Test that max_depth is respected."""
71
+ # Create a deeply nested structure
72
+ current = tmp_path
73
+ for i in range(10):
74
+ current = current / f"level{i}"
75
+ current.mkdir()
76
+ (current / "file.py").write_text(f"level {i}")
77
+
78
+ mapper = RepoMapper(str(tmp_path), max_depth=3)
79
+ result = mapper.generate_map()
80
+
81
+ assert "level0" in result
82
+ assert "level1" in result
83
+ # Deep levels should NOT appear
84
+ assert "level8" not in result
85
+
86
+
87
+ def test_generate_map_empty_dir(tmp_path):
88
+ """Empty directory should still generate valid output."""
89
+ mapper = RepoMapper(str(tmp_path))
90
+ result = mapper.generate_map()
91
+
92
+ # Should at least have the root directory name
93
+ assert tmp_path.name in result
@@ -0,0 +1,24 @@
1
+ """Tests for __version__ single source of truth."""
2
+ import pytest
3
+
4
+
5
+ def test_version_format():
6
+ """Version should be a valid semver string."""
7
+ from nimcode.__version__ import __version__
8
+ parts = __version__.split(".")
9
+ assert len(parts) == 3, f"Version should be x.y.z, got {__version__}"
10
+ for part in parts:
11
+ assert part.isdigit(), f"Version part '{part}' is not a digit"
12
+
13
+
14
+ def test_version_is_0_4_0():
15
+ """Current version should be 0.4.0."""
16
+ from nimcode.__version__ import __version__
17
+ assert __version__ == "0.4.0"
18
+
19
+
20
+ def test_updater_uses_version():
21
+ """updater.py should use the same version as __version__.py."""
22
+ from nimcode.__version__ import __version__
23
+ from nimcode.updater import CURRENT_VERSION
24
+ assert CURRENT_VERSION == __version__
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes