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.
- cli2api-0.2.1/.env.example +32 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/PKG-INFO +1 -1
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/dependencies.py +21 -1
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/config/settings.py +28 -15
- cli2api-0.2.1/cli2api/utils/cli_detector.py +337 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/pyproject.toml +1 -1
- cli2api-0.2.1/tests/test_cli_detector.py +423 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_config.py +21 -14
- cli2api-0.2.0/.env.example +0 -22
- {cli2api-0.2.0 → cli2api-0.2.1}/.dockerignore +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/.github/workflows/publish.yml +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/.gitignore +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/Dockerfile +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/README.md +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/__main__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/router.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/utils.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/chat.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/models.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/api/v1/responses.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/config/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/constants.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/main.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/providers/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/providers/claude.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/schemas/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/schemas/internal.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/schemas/openai.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/services/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/services/completion.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/streaming/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/streaming/sse.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/streaming/tool_parser.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/tools/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/tools/handler.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/utils/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api/utils/logging.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/cli2api.sh +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/docker-compose.yaml +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/__init__.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/conftest.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_api.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_integration.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_providers.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_schemas.py +0 -0
- {cli2api-0.2.0 → cli2api-0.2.1}/tests/test_streaming.py +0 -0
- {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
|
|
@@ -29,7 +29,27 @@ def get_provider() -> ClaudeCodeProvider:
|
|
|
29
29
|
"""
|
|
30
30
|
settings = get_settings()
|
|
31
31
|
if not settings.claude_cli_path:
|
|
32
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
@@ -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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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."""
|
cli2api-0.2.0/.env.example
DELETED
|
@@ -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
|
|
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
|
|
File without changes
|