Files
mlx-knife/mlxk2/cli.py
T
The BROKE Cluster Team 9261bc0c4e 2.0.0-beta.3: Feature Complete - Clone Implementation & Issue Resolution
- Clone Feature (Issue #29): Complete workspace-based workflow with ADR-007
  - Pull Preflight (Issue #30): Prevents cache pollution from gated/private repos
  - Lenient MLX Detection (Issue #31): Framework detection beyond mlx-community
  - Multi-shard Health (Issue #27): Strict completeness validation
  - Full JSON API 0.1.4: Complete schema for all 10 commands
  - Test Suite: 254/254 passed, comprehensive validation

  See CHANGELOG.md fnd TESTING.md or technical implementation details.
2025-09-18 14:09:32 +02:00

360 lines
17 KiB
Python

#!/usr/bin/env python3
"""MLX-Knife 2.0 CLI - JSON-first architecture."""
import argparse
import json
import os
import sys
from typing import Dict, Any
from . import __version__
from .operations.list import list_models
from .operations.health import health_check_operation
from .operations.pull import pull_operation
from .operations.rm import rm_operation
from .operations.push import push_operation
from .operations.show import show_model_operation
from .operations.run import run_model_enhanced
from .spec import JSON_API_SPEC_VERSION
from .output.human import (
render_list,
render_health,
render_show,
render_pull,
render_clone,
render_rm,
)
def format_json_output(data: Dict[str, Any]) -> str:
"""Format output as JSON."""
return json.dumps(data, indent=2)
def handle_error(error_type: str, message: str) -> Dict[str, Any]:
"""Format error as JSON response."""
return {
"status": "error",
"command": None,
"data": None,
"error": {
"type": error_type,
"message": message
}
}
class MLXKArgumentParser(argparse.ArgumentParser):
"""ArgumentParser that prints JSON errors when --json is present.
This ensures invocations like `mlxk2 push --json --private` (missing args)
emit a JSON error instead of argparse usage text.
"""
def error(self, message): # type: ignore[override]
want_json = "--json" in sys.argv
if want_json:
err = handle_error("CommandError", message)
print(format_json_output(err))
self.exit(2)
super().error(message)
def main():
"""Main CLI entry point."""
parser = MLXKArgumentParser(
prog="mlxk2",
description="MLX-Knife 2.0 - JSON-first model management"
)
# Add version argument (supports --json)
parser.add_argument("--version", action="store_true", help="Show version information and exit")
parser.add_argument("--json", action="store_true", help="Output in JSON format (with --version or per command)")
subparsers = parser.add_subparsers(dest="command", help="Available commands", parser_class=MLXKArgumentParser)
# List command
list_parser = subparsers.add_parser("list", help="List all cached models")
list_parser.add_argument("pattern", nargs="?", help="Filter models by pattern (optional)")
# Human-output modifiers (JSON output remains unchanged)
list_parser.add_argument("--all", action="store_true", dest="show_all", help="Show all details (human output)")
list_parser.add_argument("--health", action="store_true", dest="show_health", help="Include health column (human output)")
list_parser.add_argument("--verbose", action="store_true", help="Verbose details (human output)")
list_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Health command
health_parser = subparsers.add_parser("health", help="Check model health")
health_parser.add_argument("model", nargs="?", help="Model pattern to check (optional)")
health_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Show command
show_parser = subparsers.add_parser("show", help="Show detailed model information")
show_parser.add_argument("model", help="Model name to show")
show_parser.add_argument("--files", action="store_true", help="Include file listing")
show_parser.add_argument("--config", action="store_true", help="Include config.json content")
show_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Pull command
pull_parser = subparsers.add_parser("pull", help="Download a model")
pull_parser.add_argument("model", help="Model name to download")
pull_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Clone command (alpha) - only show if alpha features enabled
if os.getenv("MLXK2_ENABLE_ALPHA_FEATURES"):
clone_parser = subparsers.add_parser("clone", help="ALPHA: Clone a model to a local workspace")
clone_parser.add_argument("model", help="Model name to clone (org/repo[@revision])")
clone_parser.add_argument("target_dir", help="Target directory for workspace")
clone_parser.add_argument("--branch", help="Specific branch/revision to clone")
clone_parser.add_argument("--no-health-check", action="store_true", help="Skip health validation before copy")
clone_parser.add_argument("--quiet", action="store_true", help="Suppress progress output")
clone_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Remove command
rm_parser = subparsers.add_parser("rm", help="Delete a model")
rm_parser.add_argument("model", help="Model name to delete")
rm_parser.add_argument("-f", "--force", action="store_true", help="Delete without confirmation")
rm_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Run command
run_parser = subparsers.add_parser("run", help="Run model with prompt")
run_parser.add_argument("model", help="Model name to run")
run_parser.add_argument("prompt", nargs="?", help="Input prompt (optional - triggers interactive mode if omitted)")
run_parser.add_argument("--max-tokens", type=int, help="Maximum tokens to generate")
run_parser.add_argument("--temperature", type=float, default=0.7, help="Sampling temperature (default: 0.7)")
run_parser.add_argument("--top-p", type=float, default=0.9, help="Top-p sampling parameter (default: 0.9)")
run_parser.add_argument("--repetition-penalty", type=float, default=1.1, help="Repetition penalty (default: 1.1)")
run_parser.add_argument("--no-stream", action="store_true", help="Disable streaming output")
run_parser.add_argument("--no-chat-template", action="store_true", help="Disable chat template")
run_parser.add_argument("--verbose", action="store_true", help="Show detailed output")
run_parser.add_argument("--json", action="store_true", help="Output in JSON format")
# Future features (beta.2)
run_parser.add_argument("--system", help="System prompt (future feature)")
run_parser.add_argument("--hide-reasoning", action="store_true", help="Hide reasoning output (future feature)")
# Serve command (primary, ollama-compatible)
serve_parser = subparsers.add_parser("serve", help="Start OpenAI-compatible API server")
serve_parser.add_argument("--model", help="Specific model to pre-load (optional)")
serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind server to (default: 8000)")
serve_parser.add_argument("--host", default="127.0.0.1", help="Host address to bind to (default: 127.0.0.1)")
serve_parser.add_argument("--max-tokens", type=int, help="Default maximum tokens for generation")
serve_parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
serve_parser.add_argument("--log-level", default="info", help="Logging level (default: info)")
serve_parser.add_argument("--verbose", action="store_true", help="Show detailed output")
serve_parser.add_argument("--json", action="store_true", help="Output startup info in JSON format")
# Server command (alias for backward compatibility with 1.x)
_ = subparsers.add_parser(
"server",
help="Start OpenAI-compatible API server (alias for serve)",
parents=[serve_parser],
add_help=False,
)
# Push command (alpha) - only show if alpha features enabled
if os.getenv("MLXK2_ENABLE_ALPHA_FEATURES"):
push_parser = subparsers.add_parser("push", help="ALPHA: Upload a local folder to Hugging Face")
push_parser.add_argument("local_dir", help="Local folder to upload")
push_parser.add_argument("repo_id", help="Target repo as org/model")
push_parser.add_argument("--create", action="store_true", help="Create repository/branch if missing")
# Alpha.1 safety: require --private to avoid accidental public uploads
push_parser.add_argument(
"--private",
action="store_true",
required=True,
help="REQUIRED (alpha.1): Proceed only when targeting a private repo",
)
push_parser.add_argument("--branch", default="main", help="Target branch (default: main)")
push_parser.add_argument("--commit", dest="commit_message", default="mlx-knife push", help="Commit message")
push_parser.add_argument("--verbose", action="store_true", help="Verbose details (human output)")
push_parser.add_argument("--check-only", action="store_true", help="Analyze workspace content; do not upload")
push_parser.add_argument("--dry-run", action="store_true", help="Compute changes against remote; do not upload")
push_parser.add_argument("--json", action="store_true", help="Output in JSON format")
args = parser.parse_args()
try:
# Handle top-level version first
if args.version:
if args.json:
result = {
"status": "success",
"command": "version",
"data": {
"cli_version": __version__,
"json_api_spec_version": JSON_API_SPEC_VERSION,
},
"error": None,
}
print(format_json_output(result))
else:
print(f"mlxk2 {__version__}")
sys.exit(0)
# Initialize result for all paths
result = None
# Execute command and render per mode
if args.command == "list":
result = list_models(pattern=args.pattern)
if args.json:
print(format_json_output(result))
else:
show_health = getattr(args, "show_health", False)
show_all = getattr(args, "show_all", False)
verbose = getattr(args, "verbose", False)
print(render_list(result, show_health=show_health, show_all=show_all, verbose=verbose))
elif args.command == "health":
result = health_check_operation(args.model)
if args.json:
print(format_json_output(result))
else:
print(render_health(result))
elif args.command == "show":
result = show_model_operation(args.model, args.files, args.config)
if args.json:
print(format_json_output(result))
else:
print(render_show(result))
elif args.command == "pull":
result = pull_operation(args.model)
if args.json:
print(format_json_output(result))
else:
print(render_pull(result))
elif args.command == "clone":
# Check if alpha features are enabled (should not reach here if not, but double-check)
if not os.getenv("MLXK2_ENABLE_ALPHA_FEATURES"):
result = handle_error("CommandError", "Clone command requires MLXK2_ENABLE_ALPHA_FEATURES=1")
print(format_json_output(result))
sys.exit(1)
# Handle branch parameter by modifying model spec
model_spec = args.model
if getattr(args, "branch", None):
# If --branch is provided, append it to model spec
model_spec = f"{args.model}@{args.branch}"
from .operations.clone import clone_operation
result = clone_operation(
model_spec=model_spec,
target_dir=args.target_dir,
health_check=not getattr(args, "no_health_check", False)
)
if args.json:
print(format_json_output(result))
else:
print(render_clone(result, quiet=getattr(args, "quiet", False)))
elif args.command == "rm":
result = rm_operation(args.model, args.force)
if args.json:
print(format_json_output(result))
else:
print(render_rm(result))
elif args.command == "run":
# Handle run command with proper parameter mapping
result_text = run_model_enhanced(
model_spec=args.model,
prompt=args.prompt, # Can be None for interactive mode
stream=not args.no_stream,
max_tokens=getattr(args, "max_tokens", None),
temperature=args.temperature,
top_p=getattr(args, "top_p", 0.9),
repetition_penalty=getattr(args, "repetition_penalty", 1.1),
use_chat_template=not getattr(args, "no_chat_template", False),
json_output=args.json,
verbose=getattr(args, "verbose", False),
system_prompt=getattr(args, "system", None),
hide_reasoning=getattr(args, "hide_reasoning", False)
)
# For JSON output, wrap result in standard format (only for single-shot mode)
if args.json and result_text is not None and args.prompt is not None:
result = {
"status": "success",
"command": "run",
"data": {
"model": args.model,
"prompt": args.prompt,
"response": result_text
},
"error": None
}
print(format_json_output(result))
else:
# For non-JSON or interactive mode, set success result
result = {"status": "success"}
elif args.command in ["serve", "server"]: # Handle both serve and server aliases
# Handle serve command
if args.json:
# JSON startup info
server_info = {
"status": "starting",
"command": "serve",
"data": {
"host": args.host,
"port": args.port,
"model": getattr(args, "model", None),
"max_tokens": getattr(args, "max_tokens", None),
},
"error": None
}
print(format_json_output(server_info))
# Start server (this will run indefinitely)
# Lazy import to avoid hard dependency on FastAPI/uvicorn at import time
from .operations.serve import start_server
start_server(
model=getattr(args, "model", None),
port=args.port,
host=args.host,
max_tokens=getattr(args, "max_tokens", None),
reload=getattr(args, "reload", False),
log_level=getattr(args, "log_level", "info"),
verbose=getattr(args, "verbose", False),
supervise=True
)
# Should never reach here (server runs indefinitely)
result = {"status": "success"}
elif args.command == "push":
# Check if alpha features are enabled (should not reach here if not, but double-check)
if not os.getenv("MLXK2_ENABLE_ALPHA_FEATURES"):
result = handle_error("CommandError", "Push command requires MLXK2_ENABLE_ALPHA_FEATURES=1")
print(format_json_output(result))
sys.exit(1)
result = push_operation(
local_dir=args.local_dir,
repo_id=args.repo_id,
create=getattr(args, "create", False),
private=getattr(args, "private", False),
branch=getattr(args, "branch", None),
commit_message=getattr(args, "commit_message", None),
check_only=getattr(args, "check_only", False),
dry_run=getattr(args, "dry_run", False),
# Quiet mode: when emitting JSON without --verbose, suppress hub progress/log noise
quiet=(getattr(args, "json", False) and not getattr(args, "verbose", False)),
)
if args.json:
print(format_json_output(result))
else:
from .output.human import render_push
print(render_push(result, verbose=getattr(args, "verbose", False)))
elif args.command is None:
result = handle_error("CommandError", "No command specified")
print(format_json_output(result))
else:
result = handle_error("CommandError", f"Unknown command: {args.command}")
print(format_json_output(result))
# Exit with appropriate code
sys.exit(0 if result.get("status") == "success" else 1)
except Exception as e:
error_result = handle_error("InternalError", str(e))
print(format_json_output(error_result))
sys.exit(1)
if __name__ == "__main__":
main()