cli-memory-os 0.1.0__py3-none-any.whl
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.
- cli/__init__.py +1 -0
- cli/commands/__init__.py +1 -0
- cli/commands/benchmark.py +151 -0
- cli/commands/config_cmd.py +57 -0
- cli/commands/doctor.py +61 -0
- cli/commands/export.py +106 -0
- cli/commands/import_cmd.py +143 -0
- cli/commands/init.py +205 -0
- cli/commands/logs.py +38 -0
- cli/commands/monitor.py +63 -0
- cli/commands/plugins.py +37 -0
- cli/commands/start.py +24 -0
- cli/commands/status.py +92 -0
- cli/commands/stop.py +19 -0
- cli/commands/version.py +25 -0
- cli/commands/workspace.py +154 -0
- cli/main.py +496 -0
- cli/parser.py +188 -0
- cli_memory_os-0.1.0.dist-info/METADATA +231 -0
- cli_memory_os-0.1.0.dist-info/RECORD +50 -0
- cli_memory_os-0.1.0.dist-info/WHEEL +5 -0
- cli_memory_os-0.1.0.dist-info/entry_points.txt +2 -0
- cli_memory_os-0.1.0.dist-info/licenses/LICENSE +21 -0
- cli_memory_os-0.1.0.dist-info/top_level.txt +6 -0
- connectors/__init__.py +1 -0
- connectors/base.py +39 -0
- connectors/github.py +273 -0
- connectors/gmail.py +116 -0
- connectors/notion.py +156 -0
- connectors/registry.py +60 -0
- core/__init__.py +1 -0
- core/chunker.py +136 -0
- core/context_builder.py +407 -0
- core/embedder.py +42 -0
- core/llm.py +301 -0
- core/vector_store.py +629 -0
- infrastructure/__init__.py +1 -0
- infrastructure/compose.py +136 -0
- infrastructure/config.py +280 -0
- infrastructure/docker.py +61 -0
- infrastructure/health.py +338 -0
- infrastructure/observability.py +160 -0
- infrastructure/workspace.py +152 -0
- models/__init__.py +1 -0
- models/memory.py +28 -0
- storage/__init__.py +1 -0
- storage/db.py +528 -0
- storage/graph.py +324 -0
- storage/schema.sql +57 -0
- storage/tech_detector.py +117 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infrastructure: Docker Compose orchestration.
|
|
3
|
+
|
|
4
|
+
Wraps docker compose commands. Environment variables (ports, paths,
|
|
5
|
+
passwords) are exported by config.py before these functions are called,
|
|
6
|
+
so docker-compose.yml can reference them via ${VAR} syntax.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("infrastructure.compose")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_compose_file() -> str:
|
|
19
|
+
"""Return path to the project's docker-compose.yml."""
|
|
20
|
+
return os.path.join(os.path.dirname(os.path.dirname(__file__)), "docker-compose.yml")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _run_compose(args: list[str], project_dir: str | None = None) -> subprocess.CompletedProcess:
|
|
24
|
+
"""Run a docker compose command with the project compose file."""
|
|
25
|
+
from infrastructure.workspace import get_neo4j_path, get_qdrant_path
|
|
26
|
+
|
|
27
|
+
compose_file = _get_compose_file()
|
|
28
|
+
cmd = ["docker", "compose", "-f", compose_file] + args
|
|
29
|
+
|
|
30
|
+
env = os.environ.copy()
|
|
31
|
+
env["MEMORY_OS_NEO4J_PATH"] = str(get_neo4j_path())
|
|
32
|
+
env["MEMORY_OS_QDRANT_PATH"] = str(get_qdrant_path())
|
|
33
|
+
|
|
34
|
+
return subprocess.run(
|
|
35
|
+
cmd,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=120,
|
|
39
|
+
env=env,
|
|
40
|
+
cwd=project_dir,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compose_up(project_dir: str | None = None) -> bool:
|
|
46
|
+
"""Start services with docker compose up -d."""
|
|
47
|
+
result = _run_compose(["up", "-d"], project_dir)
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
logger.error(f"docker compose up failed: {result.stderr}")
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compose_down(project_dir: str | None = None) -> bool:
|
|
55
|
+
"""Stop and remove containers with docker compose down."""
|
|
56
|
+
result = _run_compose(["down"], project_dir)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
logger.error(f"docker compose down failed: {result.stderr}")
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compose_stop(project_dir: str | None = None) -> bool:
|
|
64
|
+
"""Stop containers without removing them (preserves data)."""
|
|
65
|
+
result = _run_compose(["stop"], project_dir)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
logger.error(f"docker compose stop failed: {result.stderr}")
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compose_status(project_dir: str | None = None) -> list[dict]:
|
|
73
|
+
"""Get container status via docker compose ps --format json."""
|
|
74
|
+
result = _run_compose(["ps", "--format", "json"], project_dir)
|
|
75
|
+
if result.returncode != 0:
|
|
76
|
+
return []
|
|
77
|
+
try:
|
|
78
|
+
# docker compose ps --format json may return one JSON object per line
|
|
79
|
+
containers = []
|
|
80
|
+
for line in result.stdout.strip().splitlines():
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if line:
|
|
83
|
+
containers.append(json.loads(line))
|
|
84
|
+
return containers
|
|
85
|
+
except (json.JSONDecodeError, ValueError):
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def wait_for_services(timeout: int = 60) -> bool:
|
|
90
|
+
"""Poll Neo4j and Qdrant health endpoints until ready or timeout.
|
|
91
|
+
|
|
92
|
+
Returns True if all services are healthy within the timeout.
|
|
93
|
+
"""
|
|
94
|
+
import urllib.request
|
|
95
|
+
import urllib.error
|
|
96
|
+
|
|
97
|
+
neo4j_port = os.getenv("NEO4J_HTTP_PORT", "7474")
|
|
98
|
+
qdrant_port = os.getenv("QDRANT_PORT", "6333")
|
|
99
|
+
|
|
100
|
+
neo4j_url = f"http://localhost:{neo4j_port}"
|
|
101
|
+
qdrant_url = f"http://localhost:{qdrant_port}/healthz"
|
|
102
|
+
|
|
103
|
+
start = time.time()
|
|
104
|
+
neo4j_ready = False
|
|
105
|
+
qdrant_ready = False
|
|
106
|
+
|
|
107
|
+
while time.time() - start < timeout:
|
|
108
|
+
# Check Qdrant
|
|
109
|
+
if not qdrant_ready:
|
|
110
|
+
try:
|
|
111
|
+
req = urllib.request.urlopen(qdrant_url, timeout=3)
|
|
112
|
+
if req.status == 200:
|
|
113
|
+
qdrant_ready = True
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Check Neo4j
|
|
118
|
+
if not neo4j_ready:
|
|
119
|
+
try:
|
|
120
|
+
req = urllib.request.urlopen(neo4j_url, timeout=3)
|
|
121
|
+
if req.status == 200:
|
|
122
|
+
neo4j_ready = True
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
if neo4j_ready and qdrant_ready:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
time.sleep(2)
|
|
130
|
+
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"Service health timeout after {timeout}s. "
|
|
133
|
+
f"Neo4j={'ready' if neo4j_ready else 'not ready'}, "
|
|
134
|
+
f"Qdrant={'ready' if qdrant_ready else 'not ready'}"
|
|
135
|
+
)
|
|
136
|
+
return False
|
infrastructure/config.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infrastructure: Configuration management.
|
|
3
|
+
|
|
4
|
+
Implements config priority chain:
|
|
5
|
+
CLI Flags -> config.toml -> .env -> Defaults
|
|
6
|
+
|
|
7
|
+
Config is loaded once at startup and exported to os.environ so existing
|
|
8
|
+
modules (llm.py, graph.py, vector_store.py) work without changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
|
|
16
|
+
# Default configuration values
|
|
17
|
+
DEFAULTS = {
|
|
18
|
+
"workspace": "~/.memory-os",
|
|
19
|
+
"active_profile": "default",
|
|
20
|
+
"composio": {
|
|
21
|
+
"api_key": "",
|
|
22
|
+
"user_id": "",
|
|
23
|
+
},
|
|
24
|
+
"groq": {
|
|
25
|
+
"api_key": "",
|
|
26
|
+
"model": "llama-3.3-70b-versatile",
|
|
27
|
+
},
|
|
28
|
+
"neo4j": {
|
|
29
|
+
"uri": "bolt://localhost:7687",
|
|
30
|
+
"user": "neo4j",
|
|
31
|
+
"password": "",
|
|
32
|
+
"port_http": 7474,
|
|
33
|
+
"port_bolt": 7687,
|
|
34
|
+
},
|
|
35
|
+
"qdrant": {
|
|
36
|
+
"url": "http://localhost:6333",
|
|
37
|
+
"port": 6333,
|
|
38
|
+
},
|
|
39
|
+
"embeddings": {
|
|
40
|
+
"model": "all-MiniLM-L6-v2",
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_config = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_config_path() -> Path:
|
|
48
|
+
"""Return the path to the config.toml file."""
|
|
49
|
+
workspace_root = Path(os.path.expanduser(
|
|
50
|
+
os.getenv("MEMORY_OS_WORKSPACE", "~/.memory-os")
|
|
51
|
+
))
|
|
52
|
+
return workspace_root / "config.toml"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
56
|
+
"""Recursively merge override into base, returning a new dict."""
|
|
57
|
+
merged = dict(base)
|
|
58
|
+
for key, value in override.items():
|
|
59
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
60
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
61
|
+
else:
|
|
62
|
+
merged[key] = value
|
|
63
|
+
return merged
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(cli_overrides: dict | None = None) -> dict:
|
|
67
|
+
"""Load configuration using the priority chain.
|
|
68
|
+
|
|
69
|
+
Priority (highest to lowest):
|
|
70
|
+
1. cli_overrides dict
|
|
71
|
+
2. ~/.memory-os/config.toml
|
|
72
|
+
3. .env file (loaded into os.environ)
|
|
73
|
+
4. DEFAULTS
|
|
74
|
+
|
|
75
|
+
After merging, key values are exported to os.environ so that
|
|
76
|
+
existing modules pick them up transparently.
|
|
77
|
+
"""
|
|
78
|
+
global _config
|
|
79
|
+
|
|
80
|
+
# Start with defaults
|
|
81
|
+
config = dict(DEFAULTS)
|
|
82
|
+
for k, v in DEFAULTS.items():
|
|
83
|
+
if isinstance(v, dict):
|
|
84
|
+
config[k] = dict(v)
|
|
85
|
+
|
|
86
|
+
# Layer 3: Load .env (lowest priority, loaded into os.environ)
|
|
87
|
+
load_dotenv(override=False)
|
|
88
|
+
|
|
89
|
+
# Layer 2: Load config.toml if it exists
|
|
90
|
+
config_path = _get_config_path()
|
|
91
|
+
if config_path.exists():
|
|
92
|
+
with open(config_path, "rb") as f:
|
|
93
|
+
toml_config = tomllib.load(f)
|
|
94
|
+
config = _deep_merge(config, toml_config)
|
|
95
|
+
|
|
96
|
+
# Layer 1: CLI overrides (highest priority)
|
|
97
|
+
if cli_overrides:
|
|
98
|
+
config = _deep_merge(config, cli_overrides)
|
|
99
|
+
|
|
100
|
+
# Export key values to os.environ for backwards compatibility
|
|
101
|
+
_export_to_environ(config)
|
|
102
|
+
|
|
103
|
+
_config = config
|
|
104
|
+
return config
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _export_to_environ(config: dict):
|
|
108
|
+
"""Export configuration values to os.environ.
|
|
109
|
+
|
|
110
|
+
This ensures existing modules that read os.getenv() continue
|
|
111
|
+
to work without modification.
|
|
112
|
+
"""
|
|
113
|
+
# Groq
|
|
114
|
+
groq = config.get("groq", {})
|
|
115
|
+
if groq.get("api_key"):
|
|
116
|
+
os.environ.setdefault("GROQ_API_KEY", groq["api_key"])
|
|
117
|
+
if groq.get("model"):
|
|
118
|
+
os.environ.setdefault("GROQ_MODEL", groq["model"])
|
|
119
|
+
|
|
120
|
+
# Composio
|
|
121
|
+
composio = config.get("composio", {})
|
|
122
|
+
if composio.get("api_key"):
|
|
123
|
+
os.environ.setdefault("COMPOSIO_API_KEY", composio["api_key"])
|
|
124
|
+
|
|
125
|
+
# Neo4j
|
|
126
|
+
neo4j = config.get("neo4j", {})
|
|
127
|
+
if neo4j.get("uri"):
|
|
128
|
+
os.environ.setdefault("NEO4J_URI", neo4j["uri"])
|
|
129
|
+
if neo4j.get("user"):
|
|
130
|
+
os.environ.setdefault("NEO4J_USER", neo4j["user"])
|
|
131
|
+
if neo4j.get("password"):
|
|
132
|
+
os.environ.setdefault("NEO4J_PASSWORD", neo4j["password"])
|
|
133
|
+
# Port env vars for docker-compose
|
|
134
|
+
os.environ.setdefault("NEO4J_HTTP_PORT", str(neo4j.get("port_http", 7474)))
|
|
135
|
+
os.environ.setdefault("NEO4J_BOLT_PORT", str(neo4j.get("port_bolt", 7687)))
|
|
136
|
+
|
|
137
|
+
# Qdrant
|
|
138
|
+
qdrant = config.get("qdrant", {})
|
|
139
|
+
if qdrant.get("url"):
|
|
140
|
+
os.environ.setdefault("QDRANT_URL", qdrant["url"])
|
|
141
|
+
os.environ.setdefault("QDRANT_PORT", str(qdrant.get("port", 6333)))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_config() -> dict:
|
|
145
|
+
"""Return the currently loaded config, loading it if necessary."""
|
|
146
|
+
global _config
|
|
147
|
+
if _config is None:
|
|
148
|
+
load_config()
|
|
149
|
+
return _config
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get(section: str, key: str, default=None):
|
|
153
|
+
"""Read a specific config value. Example: get('groq', 'model')"""
|
|
154
|
+
config = get_config()
|
|
155
|
+
section_dict = config.get(section, {})
|
|
156
|
+
if isinstance(section_dict, dict):
|
|
157
|
+
return section_dict.get(key, default)
|
|
158
|
+
return default
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def save_config(config_dict: dict):
|
|
162
|
+
"""Write configuration to ~/.memory-os/config.toml."""
|
|
163
|
+
import tomllib # read-only; we write manually for now
|
|
164
|
+
config_path = _get_config_path()
|
|
165
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
|
|
167
|
+
lines = []
|
|
168
|
+
for key, value in config_dict.items():
|
|
169
|
+
if isinstance(value, dict):
|
|
170
|
+
lines.append(f"\n[{key}]")
|
|
171
|
+
for k, v in value.items():
|
|
172
|
+
lines.append(f'{k} = {_toml_value(v)}')
|
|
173
|
+
else:
|
|
174
|
+
lines.append(f'{key} = {_toml_value(value)}')
|
|
175
|
+
|
|
176
|
+
config_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def set_value(section: str, key: str, value):
|
|
180
|
+
"""Update a single config key and save."""
|
|
181
|
+
config = get_config()
|
|
182
|
+
if section not in config:
|
|
183
|
+
config[section] = {}
|
|
184
|
+
config[section][key] = value
|
|
185
|
+
save_config(config)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_dotted(key: str, default=None):
|
|
189
|
+
"""Fetch value from config dictionary using dotted path, e.g., 'groq.model' or 'workspace'."""
|
|
190
|
+
config = get_config()
|
|
191
|
+
parts = key.split(".")
|
|
192
|
+
|
|
193
|
+
current = config
|
|
194
|
+
for part in parts:
|
|
195
|
+
if isinstance(current, dict) and part in current:
|
|
196
|
+
current = current[part]
|
|
197
|
+
else:
|
|
198
|
+
return default
|
|
199
|
+
return current
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def set_dotted(key: str, value) -> tuple[bool, str]:
|
|
203
|
+
"""Set value in config dictionary using dotted path, e.g., 'groq.model' or 'workspace'.
|
|
204
|
+
Performs type and schema validations. Returns (success, error_message).
|
|
205
|
+
"""
|
|
206
|
+
config = get_config()
|
|
207
|
+
parts = key.split(".")
|
|
208
|
+
|
|
209
|
+
if len(parts) == 1:
|
|
210
|
+
t_key = parts[0]
|
|
211
|
+
if t_key not in DEFAULTS:
|
|
212
|
+
return False, f"Invalid configuration key '{t_key}'"
|
|
213
|
+
if t_key == "workspace" and not value:
|
|
214
|
+
return False, "Workspace path cannot be empty"
|
|
215
|
+
config[t_key] = value
|
|
216
|
+
elif len(parts) == 2:
|
|
217
|
+
section, prop = parts[0], parts[1]
|
|
218
|
+
if section not in DEFAULTS or prop not in DEFAULTS[section]:
|
|
219
|
+
return False, f"Invalid configuration key '{key}'"
|
|
220
|
+
|
|
221
|
+
default_val = DEFAULTS[section][prop]
|
|
222
|
+
if isinstance(default_val, int):
|
|
223
|
+
try:
|
|
224
|
+
value = int(value)
|
|
225
|
+
if value <= 0:
|
|
226
|
+
raise ValueError()
|
|
227
|
+
except ValueError:
|
|
228
|
+
return False, f"Configuration key '{key}' must be a positive integer"
|
|
229
|
+
elif isinstance(default_val, bool):
|
|
230
|
+
if str(value).lower() in ("true", "1", "yes", "y"):
|
|
231
|
+
value = True
|
|
232
|
+
elif str(value).lower() in ("false", "0", "no", "n"):
|
|
233
|
+
value = False
|
|
234
|
+
else:
|
|
235
|
+
return False, f"Configuration key '{key}' must be a boolean value"
|
|
236
|
+
|
|
237
|
+
if section not in config:
|
|
238
|
+
config[section] = {}
|
|
239
|
+
config[section][prop] = value
|
|
240
|
+
else:
|
|
241
|
+
return False, f"Configuration key depth '{key}' not supported"
|
|
242
|
+
|
|
243
|
+
save_config(config)
|
|
244
|
+
return True, ""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def generate_default_config(answers: dict) -> dict:
|
|
249
|
+
"""Create initial config from prompted values during init.
|
|
250
|
+
|
|
251
|
+
``answers`` should contain keys like 'groq_api_key',
|
|
252
|
+
'composio_api_key', 'neo4j_password', 'composio_user_id'.
|
|
253
|
+
"""
|
|
254
|
+
import copy
|
|
255
|
+
config = copy.deepcopy(DEFAULTS)
|
|
256
|
+
|
|
257
|
+
if answers.get("groq_api_key"):
|
|
258
|
+
config["groq"]["api_key"] = answers["groq_api_key"]
|
|
259
|
+
if answers.get("composio_api_key"):
|
|
260
|
+
config["composio"]["api_key"] = answers["composio_api_key"]
|
|
261
|
+
if answers.get("composio_user_id"):
|
|
262
|
+
config["composio"]["user_id"] = answers["composio_user_id"]
|
|
263
|
+
if answers.get("neo4j_password"):
|
|
264
|
+
config["neo4j"]["password"] = answers["neo4j_password"]
|
|
265
|
+
|
|
266
|
+
return config
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _toml_value(value) -> str:
|
|
270
|
+
"""Format a Python value as a TOML literal."""
|
|
271
|
+
if isinstance(value, str):
|
|
272
|
+
return f'"{value}"'
|
|
273
|
+
elif isinstance(value, bool):
|
|
274
|
+
return "true" if value else "false"
|
|
275
|
+
elif isinstance(value, int):
|
|
276
|
+
return str(value)
|
|
277
|
+
elif isinstance(value, float):
|
|
278
|
+
return str(value)
|
|
279
|
+
else:
|
|
280
|
+
return f'"{value}"'
|
infrastructure/docker.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infrastructure: Docker availability checks.
|
|
3
|
+
|
|
4
|
+
Verifies that Docker and Docker Compose are installed and running.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("infrastructure.docker")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_docker_installed() -> tuple[bool, str]:
|
|
14
|
+
"""Check if Docker is installed. Returns (available, version_string)."""
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["docker", "--version"],
|
|
18
|
+
capture_output=True, text=True, timeout=10
|
|
19
|
+
)
|
|
20
|
+
if result.returncode == 0:
|
|
21
|
+
version = result.stdout.strip()
|
|
22
|
+
return True, version
|
|
23
|
+
return False, ""
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
return False, ""
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.error(f"Docker check failed: {e}")
|
|
28
|
+
return False, ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_docker_compose_installed() -> tuple[bool, str]:
|
|
32
|
+
"""Check if Docker Compose is installed. Returns (available, version_string)."""
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["docker", "compose", "version"],
|
|
36
|
+
capture_output=True, text=True, timeout=10
|
|
37
|
+
)
|
|
38
|
+
if result.returncode == 0:
|
|
39
|
+
version = result.stdout.strip()
|
|
40
|
+
return True, version
|
|
41
|
+
return False, ""
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return False, ""
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(f"Docker Compose check failed: {e}")
|
|
46
|
+
return False, ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_docker_running() -> bool:
|
|
50
|
+
"""Verify that the Docker daemon is active."""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["docker", "info"],
|
|
54
|
+
capture_output=True, text=True, timeout=10
|
|
55
|
+
)
|
|
56
|
+
return result.returncode == 0
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
return False
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Docker daemon check failed: {e}")
|
|
61
|
+
return False
|