cli2api 0.2.0__tar.gz → 0.2.1__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 (50) hide show
  1. cli2api-0.2.1/.env.example +32 -0
  2. {cli2api-0.2.0 → cli2api-0.2.1}/PKG-INFO +1 -1
  3. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/dependencies.py +21 -1
  4. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/config/settings.py +28 -15
  5. cli2api-0.2.1/cli2api/utils/cli_detector.py +337 -0
  6. {cli2api-0.2.0 → cli2api-0.2.1}/pyproject.toml +1 -1
  7. cli2api-0.2.1/tests/test_cli_detector.py +423 -0
  8. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_config.py +21 -14
  9. cli2api-0.2.0/.env.example +0 -22
  10. {cli2api-0.2.0 → cli2api-0.2.1}/.dockerignore +0 -0
  11. {cli2api-0.2.0 → cli2api-0.2.1}/.github/workflows/publish.yml +0 -0
  12. {cli2api-0.2.0 → cli2api-0.2.1}/.gitignore +0 -0
  13. {cli2api-0.2.0 → cli2api-0.2.1}/Dockerfile +0 -0
  14. {cli2api-0.2.0 → cli2api-0.2.1}/README.md +0 -0
  15. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/__init__.py +0 -0
  16. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/__main__.py +0 -0
  17. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/__init__.py +0 -0
  18. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/router.py +0 -0
  19. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/utils.py +0 -0
  20. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/__init__.py +0 -0
  21. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/chat.py +0 -0
  22. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/models.py +0 -0
  23. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/responses.py +0 -0
  24. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/config/__init__.py +0 -0
  25. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/constants.py +0 -0
  26. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/main.py +0 -0
  27. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/providers/__init__.py +0 -0
  28. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/providers/claude.py +0 -0
  29. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/schemas/__init__.py +0 -0
  30. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/schemas/internal.py +0 -0
  31. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/schemas/openai.py +0 -0
  32. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/services/__init__.py +0 -0
  33. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/services/completion.py +0 -0
  34. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/streaming/__init__.py +0 -0
  35. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/streaming/sse.py +0 -0
  36. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/streaming/tool_parser.py +0 -0
  37. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/tools/__init__.py +0 -0
  38. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/tools/handler.py +0 -0
  39. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/utils/__init__.py +0 -0
  40. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/utils/logging.py +0 -0
  41. {cli2api-0.2.0 → cli2api-0.2.1}/cli2api.sh +0 -0
  42. {cli2api-0.2.0 → cli2api-0.2.1}/docker-compose.yaml +0 -0
  43. {cli2api-0.2.0 → cli2api-0.2.1}/tests/__init__.py +0 -0
  44. {cli2api-0.2.0 → cli2api-0.2.1}/tests/conftest.py +0 -0
  45. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_api.py +0 -0
  46. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_integration.py +0 -0
  47. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_providers.py +0 -0
  48. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_schemas.py +0 -0
  49. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_streaming.py +0 -0
  50. {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_streaming_tool_parser.py +0 -0
@@ -0,0 +1,32 @@
1
+ # CLI2API Configuration
2
+ # Copy this file to .env and customize as needed
3
+
4
+ # Server settings
5
+ CLI2API_HOST=0.0.0.0
6
+ CLI2API_PORT=8000
7
+ CLI2API_DEBUG=false
8
+
9
+ # CLI executable path (auto-detected if not set)
10
+ # Auto-detection checks (in order):
11
+ # 1. Cached path (~/.cache/cli2api/claude_cli_path)
12
+ # 2. System PATH (shutil.which)
13
+ # 3. Running Claude processes (ps aux / wmic)
14
+ # 4. VSCode extensions (~/.vscode/extensions/anthropic.claude-code-*)
15
+ # 5. NPM global packages
16
+ # 6. Common paths (/opt/homebrew/bin, /usr/local/bin, ~/.local/bin)
17
+ #
18
+ # Explicitly set if auto-detection fails or you want to override:
19
+ # CLI2API_CLAUDE_CLI_PATH=/opt/homebrew/bin/claude
20
+ # CLI2API_CLAUDE_CLI_PATH=/Users/username/.vscode/extensions/anthropic.claude-code-2.1.31-darwin-arm64/resources/native-binary/claude
21
+
22
+ # Timeout (in seconds)
23
+ CLI2API_DEFAULT_TIMEOUT=300
24
+
25
+ # Default model
26
+ CLI2API_DEFAULT_MODEL=sonnet
27
+
28
+ # Custom models (comma-separated)
29
+ # CLI2API_CLAUDE_MODELS=sonnet,opus,haiku
30
+
31
+ # Logging
32
+ CLI2API_LOG_LEVEL=INFO
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli2api
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: OpenAI-compatible API over Claude Code CLI
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: fastapi>=0.115.0
@@ -29,7 +29,27 @@ def get_provider() -> ClaudeCodeProvider:
29
29
  """
30
30
  settings = get_settings()
31
31
  if not settings.claude_cli_path:
32
- raise RuntimeError("Claude CLI not found. Set CLI2API_CLAUDE_CLI_PATH.")
32
+ error_msg = """
33
+ Claude CLI not found. Please:
34
+
35
+ 1. Install Claude Code: https://docs.anthropic.com/en/docs/claude-code
36
+ 2. Or set environment variable: export CLI2API_CLAUDE_CLI_PATH=/path/to/claude
37
+ 3. Or ensure Claude is in your PATH
38
+
39
+ Auto-detection checked:
40
+ - Cached path (~/.cache/cli2api/claude_cli_path)
41
+ - System PATH (shutil.which)
42
+ - Running processes (ps aux / wmic)
43
+ - VSCode extensions (platform-specific)
44
+ - NPM global packages
45
+ - Common paths (platform-specific)
46
+
47
+ For debugging, run: CLI2API_LOG_LEVEL=DEBUG cli2api
48
+
49
+ Note: Detected path is cached in ~/.cache/cli2api/claude_cli_path
50
+ To force re-detection, delete the cache file.
51
+ """
52
+ raise RuntimeError(error_msg.strip())
33
53
 
34
54
  return ClaudeCodeProvider(
35
55
  executable_path=Path(settings.claude_cli_path),
@@ -1,6 +1,5 @@
1
1
  """Application settings."""
2
2
 
3
- import shutil
4
3
  from pathlib import Path
5
4
  from typing import Optional
6
5
 
@@ -68,19 +67,33 @@ class Settings(BaseSettings):
68
67
  @classmethod
69
68
  def detect_claude_cli(cls, v: Optional[str]) -> Optional[str]:
70
69
  """Auto-detect claude CLI path if not provided."""
70
+ from cli2api.utils.cli_detector import (
71
+ cache_path,
72
+ detect_claude_cli,
73
+ verify_claude_executable,
74
+ )
75
+ from cli2api.utils.logging import get_logger
76
+
77
+ logger = get_logger(__name__)
78
+
71
79
  if v:
72
- return v
73
- # Try which first
74
- path = shutil.which("claude")
75
- if path:
76
- return path
77
- # Fallback to common paths
78
- common_paths = [
79
- "/opt/homebrew/bin/claude",
80
- "/usr/local/bin/claude",
81
- Path.home() / ".local/bin/claude",
82
- ]
83
- for p in common_paths:
84
- if Path(p).exists():
85
- return str(p)
80
+ # User explicitly set path - verify it
81
+ path = Path(v)
82
+ if verify_claude_executable(path):
83
+ logger.info(f"Using explicitly set Claude CLI path: {v}")
84
+ # Cache explicitly set path
85
+ cache_path(path)
86
+ return v
87
+ logger.warning(f"Explicitly set Claude CLI path is invalid: {v}")
88
+
89
+ # Auto-detect (includes cache check)
90
+ detected_path = detect_claude_cli()
91
+
92
+ if detected_path:
93
+ logger.info(f"Auto-detected Claude CLI: {detected_path}")
94
+ # Cache detected path for next startup
95
+ cache_path(detected_path)
96
+ return str(detected_path)
97
+
98
+ logger.error("Claude CLI not found")
86
99
  return None
@@ -0,0 +1,337 @@
1
+ """Claude CLI auto-detection utilities with caching and cross-platform support."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from cli2api.utils.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ # Cache file location
15
+ CACHE_FILE = Path.home() / ".cache" / "cli2api" / "claude_cli_path"
16
+
17
+
18
+ def verify_claude_executable(path: Path) -> bool:
19
+ """Verify that the path is a valid Claude CLI executable.
20
+
21
+ Args:
22
+ path: Path to potential Claude CLI executable.
23
+
24
+ Returns:
25
+ True if the path is a valid, executable Claude CLI.
26
+ """
27
+ if not path.exists():
28
+ logger.debug(f"Path does not exist: {path}")
29
+ return False
30
+
31
+ if not path.is_file():
32
+ logger.debug(f"Path is not a file: {path}")
33
+ return False
34
+
35
+ if not os.access(path, os.X_OK):
36
+ logger.debug(f"Path is not executable: {path}")
37
+ return False
38
+
39
+ # Quick version check to confirm it's Claude
40
+ try:
41
+ result = subprocess.run(
42
+ [str(path), "--version"],
43
+ capture_output=True,
44
+ timeout=2,
45
+ )
46
+ if result.returncode == 0:
47
+ logger.debug(f"Valid Claude CLI found: {path}")
48
+ return True
49
+ else:
50
+ logger.debug(f"Claude CLI returned non-zero exit code: {path}")
51
+ return False
52
+ except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError) as e:
53
+ logger.debug(f"Failed to verify Claude CLI at {path}: {e}")
54
+ return False
55
+
56
+
57
+ def get_cached_path() -> Optional[Path]:
58
+ """Get cached Claude CLI path if still valid.
59
+
60
+ Returns:
61
+ Cached path if valid, None otherwise.
62
+ """
63
+ if not CACHE_FILE.exists():
64
+ logger.debug("No cache file found")
65
+ return None
66
+
67
+ try:
68
+ cached_path = Path(CACHE_FILE.read_text().strip())
69
+ if verify_claude_executable(cached_path):
70
+ logger.debug(f"Using cached Claude CLI path: {cached_path}")
71
+ return cached_path
72
+ else:
73
+ logger.debug("Cached path is invalid, removing cache")
74
+ CACHE_FILE.unlink(missing_ok=True)
75
+ except Exception as e:
76
+ logger.debug(f"Failed to read cache: {e}")
77
+
78
+ return None
79
+
80
+
81
+ def cache_path(path: Path) -> None:
82
+ """Cache the detected Claude CLI path.
83
+
84
+ Args:
85
+ path: Path to cache.
86
+ """
87
+ try:
88
+ CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
89
+ CACHE_FILE.write_text(str(path))
90
+ logger.debug(f"Cached Claude CLI path: {path}")
91
+ except Exception as e:
92
+ logger.debug(f"Failed to cache path: {e}")
93
+
94
+
95
+ def get_vscode_extensions_dir() -> Path:
96
+ """Get VSCode extensions directory for current OS.
97
+
98
+ Returns:
99
+ Path to VSCode extensions directory.
100
+ """
101
+ system = platform.system()
102
+ if system == "Windows":
103
+ return Path.home() / "AppData" / "Roaming" / "Code" / "extensions"
104
+ else: # macOS and Linux
105
+ return Path.home() / ".vscode" / "extensions"
106
+
107
+
108
+ def detect_from_path() -> Optional[Path]:
109
+ """Detect Claude CLI from system PATH.
110
+
111
+ Returns:
112
+ Path to Claude CLI if found in PATH, None otherwise.
113
+ """
114
+ logger.debug("Checking system PATH for Claude CLI")
115
+ path_str = shutil.which("claude")
116
+ if path_str:
117
+ path = Path(path_str)
118
+ if verify_claude_executable(path):
119
+ logger.info(f"Found Claude CLI in system PATH: {path}")
120
+ return path
121
+ logger.debug("Claude CLI not found in system PATH")
122
+ return None
123
+
124
+
125
+ def detect_from_processes() -> Optional[Path]:
126
+ """Detect Claude CLI from running processes (cross-platform).
127
+
128
+ Returns:
129
+ Path to Claude CLI if found in running processes, None otherwise.
130
+ """
131
+ system = platform.system()
132
+ logger.debug(f"Checking running processes for Claude CLI on {system}")
133
+
134
+ try:
135
+ if system == "Windows":
136
+ # Windows: use wmic to get process executable paths
137
+ result = subprocess.run(
138
+ ["wmic", "process", "where", "name like '%claude%'", "get", "ExecutablePath"],
139
+ capture_output=True,
140
+ text=True,
141
+ timeout=2,
142
+ )
143
+ logger.debug(f"Windows process detection output (first 200 chars): {result.stdout[:200]}")
144
+
145
+ for line in result.stdout.splitlines():
146
+ line = line.strip()
147
+ if line and "claude" in line.lower() and Path(line).exists():
148
+ path = Path(line)
149
+ if verify_claude_executable(path):
150
+ logger.info(f"Found Claude in running processes: {path}")
151
+ return path
152
+ else:
153
+ # macOS/Linux: use ps aux
154
+ result = subprocess.run(
155
+ ["ps", "aux"],
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=2,
159
+ )
160
+ logger.debug(f"Unix process detection found {len(result.stdout.splitlines())} processes")
161
+
162
+ for line in result.stdout.splitlines():
163
+ if "claude" in line.lower() and "native-binary" in line:
164
+ parts = line.split()
165
+ for part in parts:
166
+ if "claude" in part and Path(part).exists():
167
+ path = Path(part)
168
+ if verify_claude_executable(path):
169
+ logger.info(f"Found Claude in running processes: {path}")
170
+ return path
171
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
172
+ logger.debug(f"Process detection failed: {e}")
173
+
174
+ logger.debug("No Claude CLI found in running processes")
175
+ return None
176
+
177
+
178
+ def detect_from_vscode() -> Optional[Path]:
179
+ """Detect Claude CLI from VSCode extensions (cross-platform, version-agnostic).
180
+
181
+ Returns:
182
+ Path to Claude CLI if found in VSCode extensions, None otherwise.
183
+ """
184
+ vscode_dir = get_vscode_extensions_dir()
185
+ logger.debug(f"Checking VSCode extensions directory: {vscode_dir}")
186
+
187
+ if not vscode_dir.exists():
188
+ logger.debug(f"VSCode extensions directory not found: {vscode_dir}")
189
+ return None
190
+
191
+ # Pattern: anthropic.claude-code-*-{platform}
192
+ pattern = "anthropic.claude-code-*"
193
+ matches = sorted(vscode_dir.glob(pattern), reverse=True) # Latest version first
194
+ logger.debug(f"Found {len(matches)} VSCode extension directories matching pattern")
195
+
196
+ for ext_dir in matches:
197
+ # Windows uses .exe
198
+ binary_name = "claude.exe" if platform.system() == "Windows" else "claude"
199
+ claude_path = ext_dir / "resources" / "native-binary" / binary_name
200
+
201
+ if verify_claude_executable(claude_path):
202
+ logger.info(f"Found Claude in VSCode extension: {ext_dir.name}")
203
+ return claude_path
204
+ else:
205
+ logger.debug(f"Invalid Claude binary in {ext_dir.name}")
206
+
207
+ logger.debug("No valid Claude CLI found in VSCode extensions")
208
+ return None
209
+
210
+
211
+ def detect_from_npm() -> Optional[Path]:
212
+ """Detect Claude CLI from NPM global packages.
213
+
214
+ Returns:
215
+ Path to Claude CLI if found in NPM globals, None otherwise.
216
+ """
217
+ logger.debug("Checking NPM global packages for Claude CLI")
218
+
219
+ try:
220
+ # Get npm global directory
221
+ result = subprocess.run(
222
+ ["npm", "root", "-g"],
223
+ capture_output=True,
224
+ text=True,
225
+ timeout=2,
226
+ )
227
+
228
+ if result.returncode == 0:
229
+ npm_root = Path(result.stdout.strip())
230
+ logger.debug(f"NPM global root: {npm_root}")
231
+
232
+ # Common npm package names for Claude
233
+ package_names = [
234
+ "@anthropic-ai/claude-code",
235
+ "claude-code",
236
+ "@anthropic/claude",
237
+ ]
238
+
239
+ for package_name in package_names:
240
+ package_dir = npm_root / package_name
241
+ if package_dir.exists():
242
+ # Try common binary locations
243
+ binary_paths = [
244
+ package_dir / "bin" / "claude",
245
+ package_dir / "bin" / "claude.exe",
246
+ package_dir / "claude",
247
+ package_dir / "claude.exe",
248
+ ]
249
+
250
+ for binary_path in binary_paths:
251
+ if verify_claude_executable(binary_path):
252
+ logger.info(f"Found Claude in NPM package: {package_name}")
253
+ return binary_path
254
+
255
+ except (subprocess.TimeoutExpired, FileNotFoundError) as e:
256
+ logger.debug(f"NPM detection failed: {e}")
257
+
258
+ logger.debug("No Claude CLI found in NPM global packages")
259
+ return None
260
+
261
+
262
+ def detect_from_common_paths() -> Optional[Path]:
263
+ """Detect Claude CLI from common installation paths (cross-platform).
264
+
265
+ Returns:
266
+ Path to Claude CLI if found in common paths, None otherwise.
267
+ """
268
+ logger.debug("Checking common installation paths for Claude CLI")
269
+
270
+ system = platform.system()
271
+
272
+ if system == "Windows":
273
+ common_paths = [
274
+ Path("C:/Program Files/Claude/claude.exe"),
275
+ Path("C:/Program Files (x86)/Claude/claude.exe"),
276
+ Path.home() / "AppData" / "Local" / "Programs" / "Claude" / "claude.exe",
277
+ ]
278
+ else: # macOS and Linux
279
+ common_paths = [
280
+ Path("/opt/homebrew/bin/claude"),
281
+ Path("/usr/local/bin/claude"),
282
+ Path.home() / ".local/bin/claude",
283
+ Path.home() / "bin/claude",
284
+ ]
285
+
286
+ logger.debug(f"Checking {len(common_paths)} common paths")
287
+
288
+ for path in common_paths:
289
+ if verify_claude_executable(path):
290
+ logger.info(f"Found Claude CLI in common path: {path}")
291
+ return path
292
+ else:
293
+ logger.debug(f"Not found or invalid: {path}")
294
+
295
+ logger.debug("No Claude CLI found in common paths")
296
+ return None
297
+
298
+
299
+ def detect_claude_cli() -> Optional[Path]:
300
+ """Main orchestrator for detecting Claude CLI.
301
+
302
+ Tries detection methods in priority order:
303
+ 1. Cache
304
+ 2. System PATH
305
+ 3. Running processes
306
+ 4. VSCode extensions
307
+ 5. NPM global
308
+ 6. Common paths
309
+
310
+ Returns:
311
+ Path to Claude CLI if found, None otherwise.
312
+ """
313
+ logger.debug("Starting Claude CLI auto-detection")
314
+
315
+ # Try cache first (fastest)
316
+ path = get_cached_path()
317
+ if path:
318
+ return path
319
+
320
+ # Try each detection method in order
321
+ detection_methods = [
322
+ ("System PATH", detect_from_path),
323
+ ("Running processes", detect_from_processes),
324
+ ("VSCode extensions", detect_from_vscode),
325
+ ("NPM global", detect_from_npm),
326
+ ("Common paths", detect_from_common_paths),
327
+ ]
328
+
329
+ for method_name, method_func in detection_methods:
330
+ logger.debug(f"Trying detection method: {method_name}")
331
+ path = method_func()
332
+ if path:
333
+ logger.info(f"Claude CLI detected via {method_name}: {path}")
334
+ return path
335
+
336
+ logger.error("Claude CLI not found by any detection method")
337
+ return None
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cli2api"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "OpenAI-compatible API over Claude Code CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,423 @@
1
+ """Tests for Claude CLI auto-detection."""
2
+
3
+ import platform
4
+ import subprocess
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, Mock, patch
7
+
8
+ import pytest
9
+
10
+ from cli2api.utils.cli_detector import (
11
+ CACHE_FILE,
12
+ cache_path,
13
+ detect_claude_cli,
14
+ detect_from_common_paths,
15
+ detect_from_npm,
16
+ detect_from_path,
17
+ detect_from_processes,
18
+ detect_from_vscode,
19
+ get_cached_path,
20
+ get_vscode_extensions_dir,
21
+ verify_claude_executable,
22
+ )
23
+
24
+
25
+ class TestVerifyClaudeExecutable:
26
+ """Tests for verify_claude_executable function."""
27
+
28
+ def test_returns_false_for_nonexistent_path(self):
29
+ """Test that verify returns False for non-existent path."""
30
+ path = Path("/nonexistent/path/to/claude")
31
+ assert verify_claude_executable(path) is False
32
+
33
+ def test_returns_false_for_directory(self, tmp_path):
34
+ """Test that verify returns False for directory."""
35
+ dir_path = tmp_path / "claude"
36
+ dir_path.mkdir()
37
+ assert verify_claude_executable(dir_path) is False
38
+
39
+ def test_returns_false_for_non_executable(self, tmp_path):
40
+ """Test that verify returns False for non-executable file."""
41
+ file_path = tmp_path / "claude"
42
+ file_path.write_text("#!/bin/bash\necho 'test'")
43
+ file_path.chmod(0o644) # Not executable
44
+ assert verify_claude_executable(file_path) is False
45
+
46
+ @patch("subprocess.run")
47
+ def test_returns_true_for_valid_executable(self, mock_run, tmp_path):
48
+ """Test that verify returns True for valid Claude executable."""
49
+ file_path = tmp_path / "claude"
50
+ file_path.write_text("#!/bin/bash\necho 'claude version 1.0'")
51
+ file_path.chmod(0o755) # Make executable
52
+
53
+ # Mock successful --version check
54
+ mock_run.return_value = Mock(returncode=0)
55
+
56
+ assert verify_claude_executable(file_path) is True
57
+ mock_run.assert_called_once()
58
+
59
+ @patch("subprocess.run")
60
+ def test_returns_false_when_version_check_fails(self, mock_run, tmp_path):
61
+ """Test that verify returns False when --version fails."""
62
+ file_path = tmp_path / "claude"
63
+ file_path.write_text("#!/bin/bash\nexit 1")
64
+ file_path.chmod(0o755)
65
+
66
+ # Mock failed --version check
67
+ mock_run.return_value = Mock(returncode=1)
68
+
69
+ assert verify_claude_executable(file_path) is False
70
+
71
+ @patch("subprocess.run")
72
+ def test_handles_timeout_gracefully(self, mock_run, tmp_path):
73
+ """Test that verify handles timeout gracefully."""
74
+ file_path = tmp_path / "claude"
75
+ file_path.write_text("#!/bin/bash\nsleep 10")
76
+ file_path.chmod(0o755)
77
+
78
+ # Mock timeout
79
+ mock_run.side_effect = subprocess.TimeoutExpired("claude", 2)
80
+
81
+ assert verify_claude_executable(file_path) is False
82
+
83
+
84
+ class TestCaching:
85
+ """Tests for path caching functionality."""
86
+
87
+ def test_get_cached_path_returns_none_when_no_cache(self, tmp_path, monkeypatch):
88
+ """Test that get_cached_path returns None when cache doesn't exist."""
89
+ # Use temporary cache location
90
+ cache_file = tmp_path / "cache" / "claude_cli_path"
91
+ monkeypatch.setattr("cli2api.utils.cli_detector.CACHE_FILE", cache_file)
92
+
93
+ assert get_cached_path() is None
94
+
95
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
96
+ def test_get_cached_path_returns_valid_path(self, mock_verify, tmp_path, monkeypatch):
97
+ """Test that get_cached_path returns valid cached path."""
98
+ cache_file = tmp_path / "cache" / "claude_cli_path"
99
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
100
+
101
+ # Write a path to cache
102
+ test_path = Path("/usr/local/bin/claude")
103
+ cache_file.write_text(str(test_path))
104
+
105
+ # Mock verification as successful
106
+ mock_verify.return_value = True
107
+
108
+ monkeypatch.setattr("cli2api.utils.cli_detector.CACHE_FILE", cache_file)
109
+
110
+ result = get_cached_path()
111
+ assert result == test_path
112
+ mock_verify.assert_called_once_with(test_path)
113
+
114
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
115
+ def test_get_cached_path_invalidates_bad_path(self, mock_verify, tmp_path, monkeypatch):
116
+ """Test that get_cached_path removes invalid cache."""
117
+ cache_file = tmp_path / "cache" / "claude_cli_path"
118
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
119
+ cache_file.write_text("/invalid/path/claude")
120
+
121
+ # Mock verification as failed
122
+ mock_verify.return_value = False
123
+
124
+ monkeypatch.setattr("cli2api.utils.cli_detector.CACHE_FILE", cache_file)
125
+
126
+ result = get_cached_path()
127
+ assert result is None
128
+ assert not cache_file.exists()
129
+
130
+ def test_cache_path_creates_directory(self, tmp_path, monkeypatch):
131
+ """Test that cache_path creates cache directory."""
132
+ cache_file = tmp_path / "new_cache_dir" / "claude_cli_path"
133
+ monkeypatch.setattr("cli2api.utils.cli_detector.CACHE_FILE", cache_file)
134
+
135
+ test_path = Path("/usr/local/bin/claude")
136
+ cache_path(test_path)
137
+
138
+ assert cache_file.exists()
139
+ assert cache_file.read_text() == str(test_path)
140
+
141
+ def test_cache_path_handles_errors_gracefully(self, tmp_path, monkeypatch):
142
+ """Test that cache_path handles write errors gracefully."""
143
+ # Create a read-only directory
144
+ cache_dir = tmp_path / "readonly"
145
+ cache_dir.mkdir()
146
+ cache_file = cache_dir / "claude_cli_path"
147
+
148
+ # Make directory read-only
149
+ cache_dir.chmod(0o444)
150
+
151
+ monkeypatch.setattr("cli2api.utils.cli_detector.CACHE_FILE", cache_file)
152
+
153
+ test_path = Path("/usr/local/bin/claude")
154
+ # Should not raise exception
155
+ cache_path(test_path)
156
+
157
+ # Cleanup: restore permissions
158
+ cache_dir.chmod(0o755)
159
+
160
+
161
+ class TestGetVSCodeExtensionsDir:
162
+ """Tests for get_vscode_extensions_dir function."""
163
+
164
+ @patch("platform.system")
165
+ def test_returns_windows_path(self, mock_system):
166
+ """Test that Windows path is returned on Windows."""
167
+ mock_system.return_value = "Windows"
168
+ result = get_vscode_extensions_dir()
169
+ assert "AppData" in str(result)
170
+ assert "Code" in str(result)
171
+ assert "extensions" in str(result)
172
+
173
+ @patch("platform.system")
174
+ def test_returns_unix_path(self, mock_system):
175
+ """Test that Unix path is returned on macOS/Linux."""
176
+ for system in ["Darwin", "Linux"]:
177
+ mock_system.return_value = system
178
+ result = get_vscode_extensions_dir()
179
+ assert ".vscode" in str(result)
180
+ assert "extensions" in str(result)
181
+
182
+
183
+ class TestDetectFromPath:
184
+ """Tests for detect_from_path function."""
185
+
186
+ @patch("shutil.which")
187
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
188
+ def test_finds_claude_in_path(self, mock_verify, mock_which):
189
+ """Test that detect_from_path finds Claude in PATH."""
190
+ mock_which.return_value = "/usr/local/bin/claude"
191
+ mock_verify.return_value = True
192
+
193
+ result = detect_from_path()
194
+ assert result == Path("/usr/local/bin/claude")
195
+
196
+ @patch("shutil.which")
197
+ def test_returns_none_when_not_in_path(self, mock_which):
198
+ """Test that detect_from_path returns None when not in PATH."""
199
+ mock_which.return_value = None
200
+
201
+ result = detect_from_path()
202
+ assert result is None
203
+
204
+
205
+ class TestDetectFromProcesses:
206
+ """Tests for detect_from_processes function."""
207
+
208
+ @patch("platform.system")
209
+ @patch("subprocess.run")
210
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
211
+ def test_detects_from_ps_aux_on_unix(self, mock_verify, mock_run, mock_system):
212
+ """Test detection from ps aux on Unix systems."""
213
+ mock_system.return_value = "Darwin"
214
+ mock_verify.return_value = True
215
+
216
+ # Mock ps aux output with Claude process
217
+ ps_output = """
218
+ USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
219
+ user 12345 1.0 2.0 1234567 123456 ?? S 1:00PM 0:01.23 /path/to/vscode/native-binary/claude --args
220
+ """
221
+ mock_run.return_value = Mock(stdout=ps_output, returncode=0)
222
+
223
+ # Need to mock Path.exists() for the extracted path
224
+ with patch("pathlib.Path.exists", return_value=True):
225
+ result = detect_from_processes()
226
+ assert result == Path("/path/to/vscode/native-binary/claude")
227
+
228
+ @patch("platform.system")
229
+ @patch("subprocess.run")
230
+ def test_returns_none_when_no_processes_found(self, mock_run, mock_system):
231
+ """Test that returns None when no Claude processes found."""
232
+ mock_system.return_value = "Linux"
233
+ mock_run.return_value = Mock(stdout="no claude processes here", returncode=0)
234
+
235
+ result = detect_from_processes()
236
+ assert result is None
237
+
238
+ @patch("platform.system")
239
+ @patch("subprocess.run")
240
+ def test_handles_subprocess_timeout(self, mock_run, mock_system):
241
+ """Test that handles subprocess timeout gracefully."""
242
+ mock_system.return_value = "Darwin"
243
+ mock_run.side_effect = subprocess.TimeoutExpired("ps", 2)
244
+
245
+ result = detect_from_processes()
246
+ assert result is None
247
+
248
+
249
+ class TestDetectFromVSCode:
250
+ """Tests for detect_from_vscode function."""
251
+
252
+ @patch("cli2api.utils.cli_detector.get_vscode_extensions_dir")
253
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
254
+ @patch("platform.system")
255
+ def test_finds_latest_version(self, mock_system, mock_verify, mock_vscode_dir, tmp_path):
256
+ """Test that detect_from_vscode finds latest version."""
257
+ mock_system.return_value = "Darwin"
258
+ mock_vscode_dir.return_value = tmp_path
259
+
260
+ # Create multiple version directories
261
+ ext1 = tmp_path / "anthropic.claude-code-2.1.27-darwin-arm64"
262
+ ext2 = tmp_path / "anthropic.claude-code-2.1.31-darwin-arm64"
263
+ ext3 = tmp_path / "anthropic.claude-code-2.1.29-darwin-arm64"
264
+
265
+ for ext_dir in [ext1, ext2, ext3]:
266
+ binary_path = ext_dir / "resources" / "native-binary"
267
+ binary_path.mkdir(parents=True)
268
+ (binary_path / "claude").touch()
269
+
270
+ # Verify should succeed for the latest version
271
+ def verify_side_effect(path):
272
+ return "2.1.31" in str(path)
273
+
274
+ mock_verify.side_effect = verify_side_effect
275
+
276
+ result = detect_from_vscode()
277
+ assert result is not None
278
+ assert "2.1.31" in str(result)
279
+
280
+ @patch("cli2api.utils.cli_detector.get_vscode_extensions_dir")
281
+ def test_returns_none_when_vscode_dir_not_found(self, mock_vscode_dir, tmp_path):
282
+ """Test that returns None when VSCode directory doesn't exist."""
283
+ mock_vscode_dir.return_value = tmp_path / "nonexistent"
284
+
285
+ result = detect_from_vscode()
286
+ assert result is None
287
+
288
+ @patch("cli2api.utils.cli_detector.get_vscode_extensions_dir")
289
+ @patch("platform.system")
290
+ def test_handles_windows_exe(self, mock_system, mock_vscode_dir, tmp_path):
291
+ """Test that handles .exe extension on Windows."""
292
+ mock_system.return_value = "Windows"
293
+ mock_vscode_dir.return_value = tmp_path
294
+
295
+ ext_dir = tmp_path / "anthropic.claude-code-2.1.31-win32-x64"
296
+ binary_path = ext_dir / "resources" / "native-binary"
297
+ binary_path.mkdir(parents=True)
298
+
299
+ # Windows should look for claude.exe
300
+ exe_file = binary_path / "claude.exe"
301
+ exe_file.touch()
302
+
303
+ with patch("cli2api.utils.cli_detector.verify_claude_executable", return_value=True):
304
+ result = detect_from_vscode()
305
+ assert result == exe_file
306
+
307
+
308
+ class TestDetectFromNPM:
309
+ """Tests for detect_from_npm function."""
310
+
311
+ @patch("subprocess.run")
312
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
313
+ def test_finds_claude_in_npm_global(self, mock_verify, mock_run, tmp_path):
314
+ """Test that detect_from_npm finds Claude in npm global packages."""
315
+ npm_root = tmp_path / "npm_global"
316
+ npm_root.mkdir()
317
+
318
+ # Create package structure
319
+ package_dir = npm_root / "@anthropic-ai" / "claude-code"
320
+ bin_dir = package_dir / "bin"
321
+ bin_dir.mkdir(parents=True)
322
+ claude_bin = bin_dir / "claude"
323
+ claude_bin.touch()
324
+
325
+ # Mock npm root command
326
+ mock_run.return_value = Mock(stdout=str(npm_root), returncode=0)
327
+ mock_verify.return_value = True
328
+
329
+ result = detect_from_npm()
330
+ assert result == claude_bin
331
+
332
+ @patch("subprocess.run")
333
+ def test_returns_none_when_npm_fails(self, mock_run):
334
+ """Test that returns None when npm command fails."""
335
+ mock_run.side_effect = FileNotFoundError()
336
+
337
+ result = detect_from_npm()
338
+ assert result is None
339
+
340
+
341
+ class TestDetectFromCommonPaths:
342
+ """Tests for detect_from_common_paths function."""
343
+
344
+ @patch("platform.system")
345
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
346
+ def test_checks_unix_paths(self, mock_verify, mock_system):
347
+ """Test that checks Unix common paths."""
348
+ mock_system.return_value = "Darwin"
349
+
350
+ def verify_side_effect(path):
351
+ return str(path) == "/opt/homebrew/bin/claude"
352
+
353
+ mock_verify.side_effect = verify_side_effect
354
+
355
+ result = detect_from_common_paths()
356
+ assert result == Path("/opt/homebrew/bin/claude")
357
+
358
+ @patch("platform.system")
359
+ @patch("cli2api.utils.cli_detector.verify_claude_executable")
360
+ def test_checks_windows_paths(self, mock_verify, mock_system):
361
+ """Test that checks Windows common paths."""
362
+ mock_system.return_value = "Windows"
363
+ mock_verify.return_value = False
364
+
365
+ result = detect_from_common_paths()
366
+ assert result is None
367
+
368
+
369
+ class TestDetectClaudeCLI:
370
+ """Tests for main detect_claude_cli orchestrator."""
371
+
372
+ @patch("cli2api.utils.cli_detector.get_cached_path")
373
+ def test_uses_cache_first(self, mock_cache):
374
+ """Test that detect_claude_cli checks cache first."""
375
+ mock_cache.return_value = Path("/cached/claude")
376
+
377
+ result = detect_claude_cli()
378
+ assert result == Path("/cached/claude")
379
+
380
+ @patch("cli2api.utils.cli_detector.get_cached_path")
381
+ @patch("cli2api.utils.cli_detector.detect_from_path")
382
+ @patch("cli2api.utils.cli_detector.detect_from_processes")
383
+ @patch("cli2api.utils.cli_detector.detect_from_vscode")
384
+ def test_tries_methods_in_order(self, mock_vscode, mock_processes, mock_path, mock_cache):
385
+ """Test that detect_claude_cli tries methods in correct order."""
386
+ # Cache returns None
387
+ mock_cache.return_value = None
388
+ # PATH returns None
389
+ mock_path.return_value = None
390
+ # Processes returns None
391
+ mock_processes.return_value = None
392
+ # VSCode succeeds
393
+ mock_vscode.return_value = Path("/vscode/claude")
394
+
395
+ result = detect_claude_cli()
396
+ assert result == Path("/vscode/claude")
397
+
398
+ # Verify order of calls
399
+ mock_cache.assert_called_once()
400
+ mock_path.assert_called_once()
401
+ mock_processes.assert_called_once()
402
+ mock_vscode.assert_called_once()
403
+
404
+ @patch("cli2api.utils.cli_detector.get_cached_path")
405
+ @patch("cli2api.utils.cli_detector.detect_from_path")
406
+ @patch("cli2api.utils.cli_detector.detect_from_processes")
407
+ @patch("cli2api.utils.cli_detector.detect_from_vscode")
408
+ @patch("cli2api.utils.cli_detector.detect_from_npm")
409
+ @patch("cli2api.utils.cli_detector.detect_from_common_paths")
410
+ def test_returns_none_when_all_fail(
411
+ self, mock_common, mock_npm, mock_vscode, mock_processes, mock_path, mock_cache
412
+ ):
413
+ """Test that returns None when all detection methods fail."""
414
+ # All methods return None
415
+ mock_cache.return_value = None
416
+ mock_path.return_value = None
417
+ mock_processes.return_value = None
418
+ mock_vscode.return_value = None
419
+ mock_npm.return_value = None
420
+ mock_common.return_value = None
421
+
422
+ result = detect_claude_cli()
423
+ assert result is None
@@ -1,6 +1,7 @@
1
1
  """Tests for configuration and settings."""
2
2
 
3
3
  import os
4
+ from pathlib import Path
4
5
  from unittest.mock import patch
5
6
 
6
7
  import pytest
@@ -54,15 +55,18 @@ class TestSettings:
54
55
  "CLI2API_CLAUDE_CLI_PATH": "/custom/path/to/claude",
55
56
  }
56
57
 
57
- with patch.dict(os.environ, env_vars, clear=True):
58
- settings = Settings(_env_file=None)
58
+ # Mock verify_claude_executable to accept the custom path
59
+ with patch("cli2api.utils.cli_detector.verify_claude_executable", return_value=True):
60
+ with patch.dict(os.environ, env_vars, clear=True):
61
+ settings = Settings(_env_file=None)
59
62
 
60
- assert settings.claude_cli_path == "/custom/path/to/claude"
63
+ assert settings.claude_cli_path == "/custom/path/to/claude"
61
64
 
62
65
  def test_cli_path_auto_detect(self):
63
66
  """Test CLI path auto-detection."""
64
- with patch("shutil.which") as mock_which:
65
- mock_which.return_value = "/detected/path/to/claude"
67
+ # Mock detect_claude_cli to return a detected path
68
+ with patch("cli2api.utils.cli_detector.detect_claude_cli") as mock_detect:
69
+ mock_detect.return_value = Path("/detected/path/to/claude")
66
70
 
67
71
  with patch.dict(os.environ, {}, clear=True):
68
72
  settings = Settings(_env_file=None)
@@ -71,12 +75,12 @@ class TestSettings:
71
75
 
72
76
  def test_cli_path_not_found(self):
73
77
  """Test when CLI is not found."""
74
- with patch("cli2api.config.settings.shutil.which", return_value=None):
75
- with patch("pathlib.Path.exists", return_value=False):
76
- with patch.dict(os.environ, {}, clear=True):
77
- settings = Settings(_env_file=None)
78
+ # Mock detect_claude_cli to return None (not found)
79
+ with patch("cli2api.utils.cli_detector.detect_claude_cli", return_value=None):
80
+ with patch.dict(os.environ, {}, clear=True):
81
+ settings = Settings(_env_file=None)
78
82
 
79
- assert settings.claude_cli_path is None
83
+ assert settings.claude_cli_path is None
80
84
 
81
85
  def test_explicit_path_overrides_auto_detect(self):
82
86
  """Test that explicit path overrides auto-detection."""
@@ -84,11 +88,14 @@ class TestSettings:
84
88
  "CLI2API_CLAUDE_CLI_PATH": "/explicit/claude",
85
89
  }
86
90
 
87
- with patch("shutil.which", return_value="/auto/detected"):
88
- with patch.dict(os.environ, env_vars, clear=True):
89
- settings = Settings(_env_file=None)
91
+ # Mock verify to accept explicit path
92
+ with patch("cli2api.utils.cli_detector.verify_claude_executable", return_value=True):
93
+ # Mock detect_claude_cli (should not be called when explicit path is valid)
94
+ with patch("cli2api.utils.cli_detector.detect_claude_cli", return_value=Path("/auto/detected")):
95
+ with patch.dict(os.environ, env_vars, clear=True):
96
+ settings = Settings(_env_file=None)
90
97
 
91
- assert settings.claude_cli_path == "/explicit/claude"
98
+ assert settings.claude_cli_path == "/explicit/claude"
92
99
 
93
100
  def test_get_claude_models_default(self):
94
101
  """Test default Claude models."""
@@ -1,22 +0,0 @@
1
- # CLI2API Configuration
2
- # Copy this file to .env and customize as needed
3
-
4
- # Server settings
5
- CLI2API_HOST=0.0.0.0
6
- CLI2API_PORT=8000
7
- CLI2API_DEBUG=false
8
-
9
- # CLI executable path (auto-detected if not set)
10
- # CLI2API_CLAUDE_CLI_PATH=/opt/homebrew/bin/claude
11
-
12
- # Timeout (in seconds)
13
- CLI2API_DEFAULT_TIMEOUT=300
14
-
15
- # Default model
16
- CLI2API_DEFAULT_MODEL=sonnet
17
-
18
- # Custom models (comma-separated)
19
- # CLI2API_CLAUDE_MODELS=sonnet,opus,haiku
20
-
21
- # Logging
22
- CLI2API_LOG_LEVEL=INFO
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes