mdify-cli 2.11.8__py3-none-any.whl → 2.11.10__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.
mdify/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """mdify - Convert documents to Markdown via Docling container."""
2
2
 
3
- __version__ = "2.11.8"
3
+ __version__ = "2.11.10"
mdify/cli.py CHANGED
@@ -116,11 +116,17 @@ def parse_memory_string(mem_str: str) -> float:
116
116
  raise ValueError(f"Invalid memory format: {mem_str}")
117
117
 
118
118
 
119
- def validate_memory_availability(required_gb: float) -> tuple[bool, str]:
119
+ def validate_memory_availability(
120
+ required_gb: float,
121
+ profile_name: str = "default",
122
+ suggest_profile: Optional[str] = None,
123
+ ) -> tuple[bool, str]:
120
124
  """Check if system has sufficient available memory.
121
125
 
122
126
  Args:
123
127
  required_gb: Required memory in GB
128
+ profile_name: Name of current profile being used
129
+ suggest_profile: Name of smaller profile to suggest (auto-detected if None)
124
130
 
125
131
  Returns:
126
132
  Tuple of (is_sufficient, error_message)
@@ -132,13 +138,39 @@ def validate_memory_availability(required_gb: float) -> tuple[bool, str]:
132
138
  return True, ""
133
139
 
134
140
  if available_gb < required_gb:
141
+ # Determine which smaller profile to suggest
142
+ if suggest_profile is None:
143
+ if profile_name == "heavy":
144
+ suggest_profile = "default"
145
+ elif profile_name == "default":
146
+ suggest_profile = "minimal"
147
+ else:
148
+ suggest_profile = None # Already on minimal
149
+
135
150
  error = (
136
151
  f"Insufficient memory available for container startup.\n"
152
+ f" Current profile: {profile_name}\n"
137
153
  f" Required: {required_gb:.1f} GB\n"
138
154
  f" Available: {available_gb:.1f} GB\n"
139
155
  f" Short by: {required_gb - available_gb:.1f} GB\n\n"
140
- f"Please close other applications or use a smaller profile (--profile minimal)"
141
156
  )
157
+
158
+ if suggest_profile:
159
+ suggested = RESOURCE_PROFILES[suggest_profile]
160
+ error += (
161
+ f"Suggested solutions:\n"
162
+ f" 1. Close other applications to free up memory\n"
163
+ f" 2. Use a smaller profile: --profile {suggest_profile} "
164
+ f"({suggested['cpus']} CPUs, {suggested['memory']} memory)\n"
165
+ f" 3. Skip memory check: --skip-memory-check (not recommended)"
166
+ )
167
+ else:
168
+ error += (
169
+ f"Suggested solutions:\n"
170
+ f" 1. Close other applications to free up memory\n"
171
+ f" 2. Skip memory check: --skip-memory-check (not recommended)"
172
+ )
173
+
142
174
  return False, error
143
175
 
144
176
  return True, ""
@@ -882,6 +914,97 @@ Examples:
882
914
  help="Skip memory availability validation (not recommended)",
883
915
  )
884
916
 
917
+ # SSH/Remote server options
918
+ ssh_group = parser.add_argument_group("Remote SSH Server", "Execute conversion on remote server via SSH")
919
+
920
+ ssh_group.add_argument(
921
+ "--remote-host",
922
+ type=str,
923
+ default=None,
924
+ help="SSH host or alias (e.g., tsrv, 192.168.1.200, or SSH config alias)",
925
+ )
926
+
927
+ ssh_group.add_argument(
928
+ "--remote-port",
929
+ type=int,
930
+ default=None,
931
+ help="SSH port (default: 22 or from SSH config)",
932
+ )
933
+
934
+ ssh_group.add_argument(
935
+ "--remote-user",
936
+ type=str,
937
+ default=None,
938
+ help="SSH username (default: from SSH config or system user)",
939
+ )
940
+
941
+ ssh_group.add_argument(
942
+ "--remote-key",
943
+ type=str,
944
+ default=None,
945
+ help="SSH private key path (default: ~/.ssh/id_rsa or from SSH config)",
946
+ )
947
+
948
+ ssh_group.add_argument(
949
+ "--remote-key-passphrase",
950
+ type=str,
951
+ default=None,
952
+ help="SSH key passphrase (not recommended; use SSH agent)",
953
+ )
954
+
955
+ ssh_group.add_argument(
956
+ "--remote-timeout",
957
+ type=int,
958
+ default=30,
959
+ help="SSH connection timeout in seconds (default: 30)",
960
+ )
961
+
962
+ ssh_group.add_argument(
963
+ "--remote-work-dir",
964
+ type=str,
965
+ default="/tmp/mdify-remote",
966
+ help="Work directory on remote server (default: /tmp/mdify-remote)",
967
+ )
968
+
969
+ ssh_group.add_argument(
970
+ "--remote-runtime",
971
+ type=str,
972
+ choices=("docker", "podman"),
973
+ default=None,
974
+ help="Container runtime on remote (docker or podman; auto-detect if not specified)",
975
+ )
976
+
977
+ ssh_group.add_argument(
978
+ "--remote-config",
979
+ type=str,
980
+ default=None,
981
+ help="Path to mdify remote config file (YAML format, default: ~/.mdify/remote.conf)",
982
+ )
983
+
984
+ ssh_group.add_argument(
985
+ "--remote-skip-ssh-config",
986
+ action="store_true",
987
+ help="Skip loading SSH config (use CLI arguments only)",
988
+ )
989
+
990
+ ssh_group.add_argument(
991
+ "--remote-skip-validation",
992
+ action="store_true",
993
+ help="Skip remote resource validation (not recommended)",
994
+ )
995
+
996
+ ssh_group.add_argument(
997
+ "--remote-validate-only",
998
+ action="store_true",
999
+ help="Validate remote connection and resources, then exit",
1000
+ )
1001
+
1002
+ ssh_group.add_argument(
1003
+ "--remote-debug",
1004
+ action="store_true",
1005
+ help="Enable debug logging for remote SSH operations",
1006
+ )
1007
+
885
1008
  # Utility options
886
1009
  parser.add_argument(
887
1010
  "--check-update",
@@ -898,6 +1021,442 @@ Examples:
898
1021
  return parser.parse_args()
899
1022
 
900
1023
 
1024
+ # =============================================================================
1025
+ # Remote SSH execution support
1026
+ # =============================================================================
1027
+
1028
+
1029
+ def main_async_remote(args) -> int:
1030
+ """Execute conversion on remote server via SSH.
1031
+
1032
+ This function handles:
1033
+ 1. Loading and merging SSH configuration
1034
+ 2. Establishing remote connection
1035
+ 3. Uploading input files
1036
+ 4. Executing remote conversion
1037
+ 5. Downloading output files
1038
+ 6. Cleanup on success or failure
1039
+
1040
+ Args:
1041
+ args: Parsed command-line arguments with remote_* options
1042
+
1043
+ Returns:
1044
+ Exit code (0 for success, non-zero for errors)
1045
+ """
1046
+ import asyncio
1047
+ from pathlib import Path
1048
+ from mdify.ssh import SSHConfig, AsyncSSHClient
1049
+ from mdify.ssh.models import SSHConnectionError, SSHAuthError, ConfigError, ValidationError
1050
+
1051
+ async def async_main() -> int:
1052
+ """Async implementation of remote conversion."""
1053
+
1054
+ # Resolve timeout value: CLI > env > default 1200
1055
+ timeout = args.timeout or int(os.environ.get("MDIFY_TIMEOUT", 1200))
1056
+
1057
+ # Build SSH config from CLI arguments and SSH config files
1058
+ try:
1059
+ # Build config with proper precedence (lowest to highest):
1060
+ # SSH config -> mdify remote.conf -> CLI args
1061
+ ssh_config = None
1062
+
1063
+ if not args.remote_skip_ssh_config:
1064
+ # Load from SSH config if host looks like an alias
1065
+ if not args.remote_host.replace('.', '').replace('-', '').isdigit():
1066
+ try:
1067
+ ssh_config = SSHConfig.from_ssh_config(args.remote_host)
1068
+ except Exception as e:
1069
+ if not args.quiet:
1070
+ print(f"Warning: Could not load SSH config for {args.remote_host}: {e}", file=sys.stderr)
1071
+
1072
+ # Load from mdify remote.conf if it exists
1073
+ mdify_remote_conf = args.remote_config or (Path.home() / ".mdify" / "remote.conf")
1074
+ if mdify_remote_conf and Path(mdify_remote_conf).exists():
1075
+ try:
1076
+ ssh_from_mdify = SSHConfig.from_remote_conf(str(mdify_remote_conf))
1077
+ if ssh_config:
1078
+ ssh_config = ssh_config.merge(ssh_from_mdify)
1079
+ else:
1080
+ ssh_config = ssh_from_mdify
1081
+ except Exception as e:
1082
+ if not args.quiet:
1083
+ print(f"Warning: Could not load mdify remote config: {e}", file=sys.stderr)
1084
+
1085
+ # Start with minimal defaults if no config loaded
1086
+ if ssh_config is None:
1087
+ ssh_config = SSHConfig(host=args.remote_host, port=22, username=None)
1088
+
1089
+ # Apply CLI arguments with highest precedence
1090
+ cli_config = SSHConfig(
1091
+ host=args.remote_host,
1092
+ port=args.remote_port,
1093
+ username=args.remote_user,
1094
+ key_file=args.remote_key,
1095
+ key_passphrase=args.remote_key_passphrase,
1096
+ timeout=args.remote_timeout,
1097
+ work_dir=args.remote_work_dir,
1098
+ container_runtime=args.remote_runtime,
1099
+ )
1100
+ ssh_config = ssh_config.merge(cli_config)
1101
+
1102
+ # Create SSH client
1103
+ ssh_client = AsyncSSHClient(ssh_config)
1104
+
1105
+ # Connect to remote server
1106
+ if not args.quiet:
1107
+ print(f"Connecting to {ssh_config.host}:{ssh_config.port}...", file=sys.stderr)
1108
+
1109
+ await ssh_client.connect()
1110
+
1111
+ if not args.quiet:
1112
+ print(f"✓ Connected to {ssh_config.host}", file=sys.stderr)
1113
+
1114
+ # Validate remote resources if not skipped
1115
+ if not args.remote_skip_validation:
1116
+ if not args.quiet:
1117
+ print("Validating remote resources...", file=sys.stderr)
1118
+
1119
+ validation_result = await ssh_client.validate_remote_resources()
1120
+
1121
+ if not validation_result.get("can_connect"):
1122
+ await ssh_client.disconnect()
1123
+ print("Error: Cannot connect to remote server", file=sys.stderr)
1124
+ return 1
1125
+
1126
+ if not validation_result.get("work_dir_writable"):
1127
+ await ssh_client.disconnect()
1128
+ print(f"Error: Work directory not writable: {ssh_config.work_dir}", file=sys.stderr)
1129
+ return 1
1130
+
1131
+ if not validation_result.get("container_runtime_available"):
1132
+ await ssh_client.disconnect()
1133
+ runtime_str = ssh_config.container_runtime or "docker/podman"
1134
+ print(f"Error: Container runtime not available: {runtime_str}", file=sys.stderr)
1135
+ return 1
1136
+
1137
+ if not validation_result.get("disk_space_min_5gb"):
1138
+ print(f"Warning: Less than 5GB available on remote", file=sys.stderr)
1139
+ if not args.yes and sys.stdin.isatty():
1140
+ if not confirm_proceed("Continue anyway?"):
1141
+ await ssh_client.disconnect()
1142
+ return 130
1143
+
1144
+ if not validation_result.get("memory_min_2gb"):
1145
+ print(f"Warning: Less than 2GB available memory on remote", file=sys.stderr)
1146
+ if not args.yes and sys.stdin.isatty():
1147
+ if not confirm_proceed("Continue anyway?"):
1148
+ await ssh_client.disconnect()
1149
+ return 130
1150
+
1151
+ if not args.quiet:
1152
+ print("✓ All remote resources validated", file=sys.stderr)
1153
+
1154
+ # If --remote-validate-only, exit here
1155
+ if args.remote_validate_only:
1156
+ await ssh_client.disconnect()
1157
+ print("Remote validation successful", file=sys.stderr)
1158
+ return 0
1159
+
1160
+ # Phase 2.4.2: File upload, remote conversion, and download
1161
+
1162
+ # Build file list
1163
+ input_path = Path(args.input)
1164
+ if not input_path.exists():
1165
+ await ssh_client.disconnect()
1166
+ print(f"Error: Input file or directory not found: {args.input}", file=sys.stderr)
1167
+ return 1
1168
+
1169
+ files_to_convert = get_files_to_convert(input_path.resolve(), args.glob, args.recursive)
1170
+
1171
+ if not files_to_convert:
1172
+ await ssh_client.disconnect()
1173
+ print(f"Error: No supported files found in {args.input}", file=sys.stderr)
1174
+ print(f" Supported formats: {', '.join(sorted(SUPPORTED_EXTENSIONS))}", file=sys.stderr)
1175
+ return 1
1176
+
1177
+ if not args.quiet:
1178
+ print(f"\nFound {len(files_to_convert)} file(s) to convert", file=sys.stderr)
1179
+
1180
+ # Import remote container and transfer manager
1181
+ from mdify.ssh.transfer import FileTransferManager
1182
+ from mdify.ssh.remote_container import RemoteContainer
1183
+
1184
+ # Determine container runtime and image
1185
+ runtime = ssh_config.container_runtime
1186
+ if not runtime:
1187
+ runtime = await ssh_client.check_container_runtime()
1188
+ if not runtime:
1189
+ await ssh_client.disconnect()
1190
+ print("Error: No container runtime found on remote (docker/podman)", file=sys.stderr)
1191
+ return 1
1192
+
1193
+ if args.gpu:
1194
+ image = GPU_IMAGE
1195
+ elif args.image:
1196
+ image = args.image
1197
+ else:
1198
+ image = DEFAULT_IMAGE
1199
+
1200
+ # Create remote container
1201
+ remote_container = RemoteContainer(
1202
+ ssh_client=ssh_client,
1203
+ image=image,
1204
+ port=args.port,
1205
+ runtime=runtime,
1206
+ name=f"mdify-remote-{int(time.time())}",
1207
+ timeout=timeout,
1208
+ )
1209
+
1210
+ # Create file transfer manager
1211
+ transfer_manager = FileTransferManager(ssh_client)
1212
+
1213
+ # Create remote work directory
1214
+ work_dir = ssh_config.work_dir or "/tmp/mdify-remote"
1215
+ stdout, stderr, code = await ssh_client.run_command(f"mkdir -p {work_dir}")
1216
+ if code != 0:
1217
+ await ssh_client.disconnect()
1218
+ print(f"Error: Failed to create remote work directory: {work_dir}", file=sys.stderr)
1219
+ return 1
1220
+
1221
+ # Start remote container
1222
+ if not args.quiet:
1223
+ print(f"\nStarting remote container ({image})...", file=sys.stderr)
1224
+
1225
+ try:
1226
+ await remote_container.start()
1227
+ if not args.quiet:
1228
+ print(f"✓ Container started: {remote_container.state.container_name}", file=sys.stderr)
1229
+ except Exception as e:
1230
+ await ssh_client.disconnect()
1231
+ print(f"Error: Failed to start remote container: {e}", file=sys.stderr)
1232
+ return 1
1233
+
1234
+ # Process files
1235
+ successful = 0
1236
+ failed = 0
1237
+
1238
+ try:
1239
+ for idx, input_file in enumerate(files_to_convert, 1):
1240
+ if not args.quiet:
1241
+ print(f"\n[{idx}/{len(files_to_convert)}] Processing: {input_file.name}", file=sys.stderr)
1242
+
1243
+ try:
1244
+ # Upload file
1245
+ remote_file_path = f"{work_dir}/{input_file.name}"
1246
+
1247
+ if not args.quiet:
1248
+ print(f" Uploading to {remote_file_path}...", file=sys.stderr)
1249
+
1250
+ await transfer_manager.upload_file(
1251
+ local_path=str(input_file),
1252
+ remote_path=remote_file_path,
1253
+ overwrite=True,
1254
+ )
1255
+
1256
+ if not args.quiet:
1257
+ print(f" ✓ Upload complete", file=sys.stderr)
1258
+
1259
+ # Convert via remote container
1260
+ if not args.quiet:
1261
+ print(f" Converting via remote container...", file=sys.stderr)
1262
+
1263
+ # Determine output path
1264
+ output_dir = Path(args.out_dir)
1265
+
1266
+ # Preserve directory structure if not flat
1267
+ if not args.flat and input_path.is_dir():
1268
+ try:
1269
+ rel_path = input_file.relative_to(input_path)
1270
+ output_subdir = output_dir / rel_path.parent
1271
+ except ValueError:
1272
+ output_subdir = output_dir
1273
+ else:
1274
+ output_subdir = output_dir
1275
+
1276
+ output_subdir.mkdir(parents=True, exist_ok=True)
1277
+ output_file = output_subdir / f"{input_file.stem}.md"
1278
+
1279
+ # Check if output exists and skip if not overwrite
1280
+ if output_file.exists() and not args.overwrite:
1281
+ if not args.quiet:
1282
+ print(f" ⊘ Skipped: {output_file} already exists (use --overwrite to replace)", file=sys.stderr)
1283
+ continue
1284
+
1285
+ # Convert using remote container's HTTP API
1286
+ # The docling-serve API expects:
1287
+ # - Endpoint: /v1/convert/file
1288
+ # - Method: POST with multipart/form-data
1289
+ # - File field: "files" (note the plural)
1290
+ # - Additional fields: to_formats=md, do_ocr=true
1291
+ remote_output_path = f"{work_dir}/{input_file.stem}.md"
1292
+
1293
+ # Build conversion command on remote - use -F for multipart form data
1294
+ convert_cmd = (
1295
+ f"curl -X POST "
1296
+ f"-F 'files=@{remote_file_path}' "
1297
+ f"-F 'to_formats=md' "
1298
+ f"-F 'do_ocr=true' "
1299
+ )
1300
+ if args.mask:
1301
+ convert_cmd += f"-F 'mask=true' "
1302
+ convert_cmd += f"http://localhost:{args.port}/v1/convert/file"
1303
+
1304
+ stdout, stderr, code = await ssh_client.run_command(convert_cmd, timeout=timeout)
1305
+
1306
+ if code != 0:
1307
+ print(f" ✗ Conversion failed (curl error code {code}): {stderr}", file=sys.stderr)
1308
+ failed += 1
1309
+ continue
1310
+
1311
+ # Parse JSON response to extract markdown content
1312
+ try:
1313
+ response_data = json.loads(stdout)
1314
+
1315
+ # Extract content from response structure
1316
+ # Actual format: {"document": {"md_content": "..."}, "status": "success"}
1317
+ if "document" in response_data:
1318
+ document = response_data["document"]
1319
+ if "md_content" in document and document["md_content"]:
1320
+ markdown_content = document["md_content"]
1321
+ elif "text_content" in document and document["text_content"]:
1322
+ markdown_content = document["text_content"]
1323
+ else:
1324
+ # Fallback - use whole document
1325
+ markdown_content = json.dumps(document, indent=2)
1326
+ else:
1327
+ # Legacy format fallback
1328
+ if "results" in response_data and response_data["results"]:
1329
+ result = response_data["results"][0]
1330
+ if "content" in result:
1331
+ content = result["content"]
1332
+ if isinstance(content, dict) and "markdown" in content:
1333
+ markdown_content = content["markdown"]
1334
+ elif isinstance(content, str):
1335
+ markdown_content = content
1336
+ else:
1337
+ markdown_content = str(content)
1338
+ else:
1339
+ markdown_content = str(result)
1340
+ else:
1341
+ # Ultimate fallback
1342
+ markdown_content = stdout
1343
+
1344
+ # Write markdown content to remote file
1345
+ write_cmd = f"cat > {remote_output_path} << 'MDIFY_EOF'\n{markdown_content}\nMDIFY_EOF"
1346
+ _, _, write_code = await ssh_client.run_command(write_cmd, timeout=30)
1347
+
1348
+ if write_code != 0:
1349
+ print(f" ✗ Failed to write markdown output", file=sys.stderr)
1350
+ failed += 1
1351
+ continue
1352
+
1353
+ except (json.JSONDecodeError, KeyError, IndexError) as e:
1354
+ print(f" ✗ Failed to parse conversion response: {e}", file=sys.stderr)
1355
+ if DEBUG:
1356
+ print(f" Response: {stdout[:500]}", file=sys.stderr)
1357
+ failed += 1
1358
+ continue
1359
+
1360
+ if not args.quiet:
1361
+ print(f" ✓ Conversion complete", file=sys.stderr)
1362
+
1363
+ # Download result
1364
+ if not args.quiet:
1365
+ print(f" Downloading result to {output_file}...", file=sys.stderr)
1366
+
1367
+ await transfer_manager.download_file(
1368
+ remote_path=remote_output_path,
1369
+ local_path=str(output_file),
1370
+ overwrite=True,
1371
+ )
1372
+
1373
+ if not args.quiet:
1374
+ print(f" ✓ Download complete: {output_file}", file=sys.stderr)
1375
+
1376
+ successful += 1
1377
+
1378
+ # Cleanup remote files
1379
+ await ssh_client.run_command(f"rm -f {remote_file_path} {remote_output_path}")
1380
+
1381
+ except Exception as e:
1382
+ print(f" ✗ Failed: {e}", file=sys.stderr)
1383
+ if DEBUG:
1384
+ import traceback
1385
+ traceback.print_exc(file=sys.stderr)
1386
+ failed += 1
1387
+ continue
1388
+
1389
+ finally:
1390
+ # Stop and remove container
1391
+ if not args.quiet:
1392
+ print(f"\nStopping remote container...", file=sys.stderr)
1393
+
1394
+ try:
1395
+ await remote_container.stop(force=False)
1396
+ if not args.quiet:
1397
+ print(f"✓ Container stopped", file=sys.stderr)
1398
+ except Exception as e:
1399
+ if not args.quiet:
1400
+ print(f"Warning: Failed to stop container: {e}", file=sys.stderr)
1401
+
1402
+ # Cleanup remote work directory
1403
+ try:
1404
+ await ssh_client.run_command(f"rm -rf {work_dir}")
1405
+ if not args.quiet:
1406
+ print(f"✓ Cleaned up remote directory", file=sys.stderr)
1407
+ except Exception as e:
1408
+ if not args.quiet:
1409
+ print(f"Warning: Failed to cleanup remote directory: {e}", file=sys.stderr)
1410
+
1411
+ # Disconnect
1412
+ await ssh_client.disconnect()
1413
+
1414
+ # Print summary
1415
+ print(f"\n{'='*60}", file=sys.stderr)
1416
+ print(f"Remote conversion complete:", file=sys.stderr)
1417
+ print(f" Successful: {successful}", file=sys.stderr)
1418
+ print(f" Failed: {failed}", file=sys.stderr)
1419
+ print(f" Total: {len(files_to_convert)}", file=sys.stderr)
1420
+ print(f"{'='*60}", file=sys.stderr)
1421
+
1422
+ return 0 if failed == 0 else 1
1423
+
1424
+ except SSHAuthError as e:
1425
+ print(f"Error: SSH authentication failed: {e}", file=sys.stderr)
1426
+ print(" Check your SSH key, passphrase, or username", file=sys.stderr)
1427
+ return 1
1428
+ except SSHConnectionError as e:
1429
+ print(f"Error: SSH connection failed: {e}", file=sys.stderr)
1430
+ if hasattr(e, 'host') and hasattr(e, 'port'):
1431
+ print(f" Host: {e.host}:{e.port}", file=sys.stderr)
1432
+ return 1
1433
+ except ConfigError as e:
1434
+ print(f"Error: Configuration error: {e}", file=sys.stderr)
1435
+ return 1
1436
+ except ValidationError as e:
1437
+ print(f"Error: Validation error: {e}", file=sys.stderr)
1438
+ return 1
1439
+ except Exception as e:
1440
+ print(f"Error: Unexpected error during remote execution: {e}", file=sys.stderr)
1441
+ if DEBUG:
1442
+ import traceback
1443
+ traceback.print_exc(file=sys.stderr)
1444
+ return 1
1445
+
1446
+ # Run async main
1447
+ try:
1448
+ return asyncio.run(async_main())
1449
+ except KeyboardInterrupt:
1450
+ print("\n⚠ Interrupted by user", file=sys.stderr)
1451
+ return 130
1452
+ except Exception as e:
1453
+ print(f"Error: Failed to run remote execution: {e}", file=sys.stderr)
1454
+ if DEBUG:
1455
+ import traceback
1456
+ traceback.print_exc(file=sys.stderr)
1457
+ return 1
1458
+
1459
+
901
1460
  # =============================================================================
902
1461
  # Main entry point
903
1462
  # =============================================================================
@@ -916,6 +1475,21 @@ def main() -> int:
916
1475
  # Check for updates (daily, silent on errors)
917
1476
  check_for_update(force=False)
918
1477
 
1478
+ # Detect remote mode (SSH-based execution)
1479
+ is_remote_mode = hasattr(args, 'remote_host') and args.remote_host is not None
1480
+
1481
+ if is_remote_mode:
1482
+ # Remote mode: will use SSH to execute on remote server
1483
+ # Import here to avoid import errors if asyncssh not installed in local environment
1484
+ try:
1485
+ import asyncio
1486
+ from mdify.ssh import AsyncSSHClient, SSHConfig
1487
+ return main_async_remote(args)
1488
+ except ImportError:
1489
+ print("Error: Remote mode requires asyncssh and additional dependencies", file=sys.stderr)
1490
+ print("Install with: pip install mdify-cli[remote]", file=sys.stderr)
1491
+ return 1
1492
+
919
1493
  # Resolve timeout value: CLI > env > default 1200
920
1494
  timeout = args.timeout or int(os.environ.get("MDIFY_TIMEOUT", 1200))
921
1495
 
@@ -1082,7 +1656,7 @@ def main() -> int:
1082
1656
 
1083
1657
  try:
1084
1658
  if not args.quiet:
1085
- print(f"Starting docling-serve container...\\n")
1659
+ print(f"Starting docling-serve container...\n")
1086
1660
 
1087
1661
  # Apply resource profile
1088
1662
  profile = RESOURCE_PROFILES[args.profile]
@@ -1092,7 +1666,9 @@ def main() -> int:
1092
1666
  # Validate memory availability unless skipped
1093
1667
  if not args.skip_memory_check:
1094
1668
  required_gb = parse_memory_string(memory)
1095
- is_sufficient, error_msg = validate_memory_availability(required_gb)
1669
+ is_sufficient, error_msg = validate_memory_availability(
1670
+ required_gb, profile_name=args.profile
1671
+ )
1096
1672
  if not is_sufficient:
1097
1673
  print(f"Error: {error_msg}", file=sys.stderr)
1098
1674
  return 1
mdify/container.py CHANGED
@@ -221,10 +221,6 @@ class DoclingContainer:
221
221
  True if container is healthy, False otherwise
222
222
  """
223
223
  try:
224
- # First check if container is still running
225
- if not self.is_running():
226
- return False
227
- # Then check health endpoint
228
224
  return check_health(self.base_url)
229
225
  except Exception:
230
226
  return False
mdify/ssh/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """SSH remote server support for mdify."""
2
+
3
+ from mdify.ssh.models import SSHConfig, TransferSession, RemoteContainerState
4
+ from mdify.ssh.client import AsyncSSHClient
5
+
6
+ __all__ = [
7
+ "SSHConfig",
8
+ "TransferSession",
9
+ "RemoteContainerState",
10
+ "AsyncSSHClient",
11
+ ]