split-stack 0.2.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.
split_stack/cli.py ADDED
@@ -0,0 +1,690 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ from split_stack.advice import stack_recommendation
10
+ from split_stack.benchmark import format_markdown_table, routed_model_mix, run_benchmark
11
+ from split_stack.compare import CompareRunError, format_compare_text, run_compare
12
+ from split_stack.discovery import discover_models
13
+ from split_stack.local_models import assign_tiers_from_local, list_local_models
14
+ from split_stack.model_registry import (
15
+ config_search_paths,
16
+ list_deployment_profiles,
17
+ load_registry,
18
+ )
19
+ from split_stack.ollama_generate import ask_prompt_json, route_prompt_json
20
+ from split_stack.requirements import UsageProfile, list_usage_profiles, usage_requirements
21
+ from split_stack.presets import assign_recommended_tiers, list_recommended_stacks, recommended_models
22
+ from split_stack.setup_wizard import format_setup_summary, plan_setup, run_setup
23
+ from split_stack.tiering import assign_tiers, describe_tiers
24
+
25
+
26
+ def _print_requirements(profile: UsageProfile, *, check: bool) -> None:
27
+ report = usage_requirements(profile, check=check)
28
+ status = "ready" if report.ready else "missing required items"
29
+ print(f"{report.title} ({report.profile.value}) - {status}")
30
+ print(report.summary)
31
+ print("")
32
+ for item in report.prerequisites:
33
+ required_label = "required" if item.required else "optional"
34
+ if item.satisfied is True:
35
+ state = "ok"
36
+ elif item.satisfied is False:
37
+ state = "missing"
38
+ else:
39
+ state = "not checked"
40
+ print(f" [{state}] {item.description} ({required_label})")
41
+ if item.install_command:
42
+ print(f" install: {item.install_command}")
43
+ if item.verify_hint:
44
+ print(f" verify: {item.verify_hint}")
45
+ print("")
46
+
47
+
48
+ def _cmd_requirements(profile_name: str | None, check: bool) -> int:
49
+ if profile_name:
50
+ try:
51
+ profile = UsageProfile(profile_name)
52
+ except ValueError:
53
+ valid = ", ".join(item.value for item in list_usage_profiles())
54
+ print(f"Unknown profile '{profile_name}'. Valid profiles: {valid}")
55
+ return 1
56
+ _print_requirements(profile, check=check)
57
+ return 0
58
+
59
+ for profile in list_usage_profiles():
60
+ _print_requirements(profile, check=check)
61
+ return 0
62
+
63
+
64
+ def _add_quant_arg(parser: argparse.ArgumentParser) -> None:
65
+ parser.add_argument(
66
+ "--quant",
67
+ choices=["default", "qat", "qat_mobile", "bf16"],
68
+ help=(
69
+ "Assume Gemma 4 pull quantization for VRAM sizing "
70
+ "(qat=Unsloth UD-Q4_K_XL runtime GB; not per-prompt routing)"
71
+ ),
72
+ )
73
+
74
+
75
+ def _add_profile_arg(parser: argparse.ArgumentParser) -> None:
76
+ parser.add_argument(
77
+ "--profile",
78
+ help=(
79
+ "Deployment profile override: workstation_8gb, workstation_12gb, "
80
+ "workstation_16gb, workstation_24gb, workstation_32gb, datacenter "
81
+ "(aliases: 8gb, 12gb, 16gb, 24gb, 32gb)"
82
+ ),
83
+ )
84
+
85
+
86
+ def _add_hint_arg(parser: argparse.ArgumentParser) -> None:
87
+ parser.add_argument(
88
+ "--hint",
89
+ choices=["lookup", "explain", "design", "code", "reason"],
90
+ help="Agent step hint: lookup, explain, design, code, reason",
91
+ )
92
+
93
+
94
+ def _cmd_stacks(args: argparse.Namespace) -> int:
95
+ if args.profile:
96
+ try:
97
+ models = recommended_models(args.profile, quant=args.quant)
98
+ tiers = assign_tiers(models)
99
+ except ValueError as exc:
100
+ print(f"Error: {exc}")
101
+ return 1
102
+ if args.json:
103
+ payload = {
104
+ "profile": args.profile,
105
+ "quant": args.quant or "default",
106
+ "models": models,
107
+ "tiers": describe_tiers(tiers),
108
+ }
109
+ return _emit_json(payload)
110
+ print(f"Recommended stack for {args.profile}:")
111
+ if args.quant:
112
+ print(f" quant: {args.quant}")
113
+ print(" models:", ",".join(models))
114
+ print(" tiers:")
115
+ for key, value in describe_tiers(tiers).items():
116
+ print(f" {key}: {value or '-'}")
117
+ return 0
118
+
119
+ if args.json:
120
+ payload = {
121
+ "stacks": [
122
+ {
123
+ "profile": item.profile,
124
+ "models": list(item.models),
125
+ "description": item.description,
126
+ }
127
+ for item in list_recommended_stacks()
128
+ ]
129
+ }
130
+ return _emit_json(payload)
131
+ print("Recommended specialist stacks (workstation 8–32 GB):")
132
+ for item in list_recommended_stacks():
133
+ print(f" {item.profile}")
134
+ print(f" {item.description}")
135
+ print(f" models: {','.join(item.models)}")
136
+ return 0
137
+
138
+
139
+ def _cmd_profiles(args: argparse.Namespace) -> int:
140
+ if args.json:
141
+ payload = {
142
+ "profiles": [
143
+ {
144
+ "name": item.name,
145
+ "assumed_vram_gb": item.assumed_vram_gb,
146
+ "apply_vram_filter": item.apply_vram_filter,
147
+ "description": item.description,
148
+ }
149
+ for item in list_deployment_profiles()
150
+ ]
151
+ }
152
+ return _emit_json(payload)
153
+ print("Deployment profiles:")
154
+ for item in list_deployment_profiles():
155
+ vram = item.assumed_vram_gb if item.assumed_vram_gb is not None else "n/a"
156
+ filter_label = "on" if item.apply_vram_filter else "off"
157
+ print(f" {item.name}\tassumed_vram_gb={vram}\tvram_filter={filter_label}")
158
+ print(f" {item.description}")
159
+ return 0
160
+
161
+
162
+ def _cmd_doctor(args: argparse.Namespace) -> int:
163
+ advice = stack_recommendation(cursor_override_enabled=False)
164
+ print(f"Cursor model: {advice.cursor_model}")
165
+ print(f"Prose path: {advice.prose_path}")
166
+ print(f"Local path: {advice.local_path}")
167
+
168
+ try:
169
+ tiers, models, warning = assign_tiers_from_local(
170
+ only_vram_ok=True,
171
+ profile=args.profile,
172
+ config_path=args.config,
173
+ quant_mode=args.quant,
174
+ )
175
+ registry = load_registry(args.config, profile=args.profile)
176
+ vram_label = registry.assumed_vram_gb if registry.assumed_vram_gb is not None else "n/a"
177
+ quant_label = args.quant or "default"
178
+ print(f"\nLocal model table (profile={registry.profile}, assumed_vram_gb={vram_label}, quant={quant_label}):")
179
+ print(" model\tweight\tvram_gb\tfamily\tvram_ok\tsource")
180
+ for item in models:
181
+ vram = item.vram_gb if item.vram_gb is not None else "-"
182
+ family = item.family or "-"
183
+ print(
184
+ f" {item.name}\t{item.weight}\t{vram}\t{family}\t{item.vram_ok}\t{item.source}"
185
+ )
186
+ print("\nDetected model tiers:")
187
+ print(f" SIMPLE: {tiers.simple}")
188
+ print(f" MEDIUM: {tiers.medium}")
189
+ print(f" COMPLEX: {tiers.complex}")
190
+ print(f" REASONING: {tiers.reasoning}")
191
+ if tiers.code:
192
+ print(f" CODE: {tiers.code}")
193
+ if warning:
194
+ print(f"\nWarning: {warning}")
195
+ config_paths = [str(path) for path in config_search_paths() if path.is_file()]
196
+ if config_paths:
197
+ print(f"\nUsing config: {config_paths[0]}")
198
+ else:
199
+ print("\nUsing built-in model table. Copy config/models.example.json to split-stack.models.json")
200
+ except Exception as exc:
201
+ print(f"\nOllama discovery skipped: {exc}")
202
+ return 0
203
+
204
+
205
+ def _cmd_models(args: argparse.Namespace) -> int:
206
+ try:
207
+ models, warning = list_local_models(
208
+ base_url=args.base_url,
209
+ config_path=args.config,
210
+ profile=args.profile,
211
+ only_vram_ok=not args.all,
212
+ include_disk=args.include_disk,
213
+ quant_mode=args.quant,
214
+ )
215
+ except ValueError as exc:
216
+ print(f"Error: {exc}")
217
+ return 1
218
+ except Exception as exc:
219
+ print(f"Error: {exc}")
220
+ return 1
221
+ registry = load_registry(args.config, profile=args.profile)
222
+ vram_label = registry.assumed_vram_gb if registry.assumed_vram_gb is not None else None
223
+ if args.json:
224
+ payload = {
225
+ "profile": registry.profile,
226
+ "assumed_vram_gb": vram_label,
227
+ "quant": args.quant or "default",
228
+ "apply_vram_filter": registry.apply_vram_filter,
229
+ "models": [
230
+ {
231
+ "name": item.name,
232
+ "weight": item.weight,
233
+ "vram_gb": item.vram_gb,
234
+ "family": item.family,
235
+ "vram_ok": item.vram_ok,
236
+ "source": item.source,
237
+ "quant_mode": item.quant_mode,
238
+ }
239
+ for item in models
240
+ ],
241
+ "warning": warning,
242
+ }
243
+ return _emit_json(payload)
244
+ vram_text = registry.assumed_vram_gb if registry.assumed_vram_gb is not None else "n/a"
245
+ print(
246
+ f"profile={registry.profile} assumed_vram_gb={vram_text} "
247
+ f"vram_filter={'on' if registry.apply_vram_filter else 'off'}"
248
+ )
249
+ print("model\tweight\tvram_gb\tfamily\tvram_ok\tsource")
250
+ for item in models:
251
+ vram = item.vram_gb if item.vram_gb is not None else "-"
252
+ family = item.family or "-"
253
+ print(f"{item.name}\t{item.weight}\t{vram}\t{family}\t{item.vram_ok}\t{item.source}")
254
+ if warning:
255
+ print(f"\nWarning: {warning}")
256
+ searched = config_search_paths(args.config)
257
+ active = next((path for path in searched if path.is_file()), None)
258
+ if active:
259
+ print(f"\nConfig: {active}")
260
+ else:
261
+ print("\nConfig: built-in table (copy config/models.example.json → split-stack.models.json)")
262
+ return 0
263
+
264
+
265
+ def _cmd_tips(args: argparse.Namespace) -> int:
266
+ from split_stack.startup_tips import model_recommendation_report
267
+
268
+ lines = model_recommendation_report(
269
+ profile=args.profile,
270
+ include_api=args.api,
271
+ base_url=args.base_url,
272
+ )
273
+ if args.json:
274
+ return _emit_json({"ready": True, "lines": lines, "profile": args.profile})
275
+ for line in lines:
276
+ print(line)
277
+ return 0
278
+
279
+
280
+ def _parse_model_names(raw: str | None) -> list[str] | None:
281
+ if not raw:
282
+ return None
283
+ names = [item.strip() for item in raw.split(",") if item.strip()]
284
+ return names or None
285
+
286
+
287
+ def _emit_json(payload: dict) -> int:
288
+ print(json.dumps(payload))
289
+ return 0 if payload.get("ready", True) else 1
290
+
291
+
292
+ def _cmd_setup(args: argparse.Namespace) -> int:
293
+ config_path = Path(args.config) if args.config else None
294
+ result = run_setup(
295
+ args.profile,
296
+ base_url=args.base_url,
297
+ config_path=config_path,
298
+ assume_yes=args.yes,
299
+ dry_run=args.dry_run,
300
+ interactive=not args.yes and not args.dry_run and not args.json,
301
+ )
302
+ if args.json:
303
+ payload = {
304
+ "ready": result.ready,
305
+ "profile": result.profile,
306
+ "config_path": str(result.config_path),
307
+ "pulled": list(result.pulled),
308
+ "already_present": list(result.already_present),
309
+ "skipped": list(result.skipped),
310
+ "tiers": result.tiers,
311
+ "cancelled": result.cancelled,
312
+ "dry_run": result.dry_run,
313
+ "error": result.error,
314
+ }
315
+ return _emit_json(payload)
316
+ print(format_setup_summary(result))
317
+ if result.cancelled:
318
+ print("\nNo changes made. Re-run with --yes to skip confirmation.")
319
+ return 1
320
+ if not result.ready:
321
+ return 1
322
+ print("\nNext: stack doctor | stack ask --prompt \"what is caching?\" --hint lookup")
323
+ return 0
324
+
325
+
326
+ def _cmd_explain(args: argparse.Namespace) -> int:
327
+ from split_stack.routing import explain_route
328
+ from split_stack.session import configure, describe_session
329
+ from split_stack.tiering import assign_tiers
330
+ from split_stack.validation import validate_tier_map
331
+
332
+ models = _parse_model_names(args.models)
333
+ if models:
334
+ tiers = assign_tiers(models)
335
+ session_info: dict[str, object] = {"source": "explicit_models", "models": models}
336
+ warnings = validate_tier_map(tiers, models, profile=args.profile)
337
+ else:
338
+ try:
339
+ session = configure(profile=args.profile, quant=args.quant)
340
+ except ValueError as exc:
341
+ if args.json:
342
+ return _emit_json({"ready": False, "error": str(exc)})
343
+ print(f"Error: {exc}")
344
+ return 1
345
+ tiers = session.tiers
346
+ session_info = describe_session()
347
+ warnings = list(session.warnings)
348
+
349
+ decision = explain_route(args.prompt, tiers, hint=args.hint)
350
+ payload = {
351
+ "ready": True,
352
+ "decision": decision.to_dict(),
353
+ "session": session_info,
354
+ "warnings": warnings,
355
+ }
356
+ if args.json:
357
+ return _emit_json(payload)
358
+ print(f"model={decision.model} tier={decision.tier.value}")
359
+ for line in decision.reasons:
360
+ print(f" - {line}")
361
+ if warnings:
362
+ print("Warnings:")
363
+ for line in warnings:
364
+ print(f" ! {line}")
365
+ return 0
366
+
367
+
368
+ def _cmd_route(args: argparse.Namespace) -> int:
369
+ if getattr(args, "explain", False):
370
+ return _cmd_explain(args)
371
+
372
+ result = route_prompt_json(
373
+ args.prompt,
374
+ base_url=args.base_url,
375
+ model_names=_parse_model_names(args.models),
376
+ hint=args.hint,
377
+ )
378
+ payload = asdict(result)
379
+ if args.json:
380
+ return _emit_json(payload)
381
+ if not result.ready:
382
+ print(f"Error: {result.error}")
383
+ return 1
384
+ print(f"Routed to {result.model} ({result.tier})")
385
+ return 0
386
+
387
+
388
+ def _cmd_ask(args: argparse.Namespace) -> int:
389
+ result = ask_prompt_json(
390
+ args.prompt,
391
+ base_url=args.base_url,
392
+ model_names=_parse_model_names(args.models),
393
+ timeout_seconds=args.timeout,
394
+ hint=args.hint,
395
+ )
396
+ payload = asdict(result)
397
+ if args.json:
398
+ return _emit_json(payload)
399
+ if not result.ready:
400
+ print(f"Error: {result.error}")
401
+ return 1
402
+ print(f"Routed to {result.model} ({result.tier})")
403
+ print(result.text)
404
+ return 0
405
+
406
+
407
+ def _cmd_compare(args: argparse.Namespace) -> int:
408
+ try:
409
+ report = run_compare(
410
+ model_names=_parse_model_names(args.models),
411
+ base_url=args.base_url,
412
+ dry_run=not args.live,
413
+ timeout_seconds=args.timeout,
414
+ )
415
+ except CompareRunError as exc:
416
+ print(f"Error: {exc}", file=sys.stderr)
417
+ return 1
418
+ except RuntimeError as exc:
419
+ print(f"Error: {exc}", file=sys.stderr)
420
+ return 1
421
+ if args.json:
422
+ payload = {
423
+ "models": list(report.models),
424
+ "rows": [
425
+ {
426
+ "step": row.step,
427
+ "routed_tier": row.routed_tier,
428
+ "routed_model": row.routed_model,
429
+ "baseline_model": row.baseline_model,
430
+ "routed_latency_ms": row.routed_latency_ms,
431
+ "baseline_latency_ms": row.baseline_latency_ms,
432
+ }
433
+ for row in report.rows
434
+ ],
435
+ "summary": {
436
+ "baseline_model": report.summary.baseline_model,
437
+ "routed_models_used": report.summary.routed_models_used,
438
+ "baseline_models_used": report.summary.baseline_models_used,
439
+ "steps_avoided_largest": report.summary.steps_avoided_largest,
440
+ "total_steps": report.summary.total_steps,
441
+ "routed_total_latency_ms": report.summary.routed_total_latency_ms,
442
+ "baseline_total_latency_ms": report.summary.baseline_total_latency_ms,
443
+ },
444
+ }
445
+ return _emit_json(payload)
446
+ print(format_compare_text(report))
447
+ if not args.live:
448
+ print("\nDry run only (routing). Re-run with --live to measure Ollama latency per step.")
449
+ return 0
450
+
451
+
452
+ def _cmd_benchmark(args: argparse.Namespace) -> int:
453
+ report = run_benchmark(model_names=_parse_model_names(args.models))
454
+ if args.markdown:
455
+ print(format_markdown_table(report))
456
+ print("")
457
+ print("tier_counts:", report.tier_counts)
458
+ print("model_mix:", routed_model_mix(report))
459
+ print("always_biggest_would_use:", report.models[-1] if report.models else "")
460
+ return 0
461
+ if args.json:
462
+ payload = {
463
+ "models": list(report.models),
464
+ "tier_counts": report.tier_counts,
465
+ "model_mix": routed_model_mix(report),
466
+ "rows": [
467
+ {
468
+ "id": row.id,
469
+ "tier": row.tier,
470
+ "model": row.model,
471
+ "note": row.note,
472
+ "prompt": row.prompt,
473
+ }
474
+ for row in report.rows
475
+ ],
476
+ }
477
+ return _emit_json(payload)
478
+ for row in report.rows:
479
+ print(f"{row.id}\t{row.tier}\t{row.model}\t{row.note}")
480
+ return 0
481
+
482
+
483
+ def _add_ollama_args(parser: argparse.ArgumentParser) -> None:
484
+ parser.add_argument("--prompt", required=True, help="Prompt text to route or ask")
485
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
486
+ parser.add_argument(
487
+ "--base-url",
488
+ default="http://127.0.0.1:11434",
489
+ help="Ollama base URL for model discovery and generation",
490
+ )
491
+ parser.add_argument(
492
+ "--models",
493
+ help="Comma-separated model names (skip Ollama discovery when set)",
494
+ )
495
+
496
+
497
+ def main(argv: list[str] | None = None) -> int:
498
+ parser = argparse.ArgumentParser(prog="stack", description="split-stack helper CLI")
499
+ subparsers = parser.add_subparsers(dest="command")
500
+
501
+ doctor_parser = subparsers.add_parser("doctor", help="Show stack advice and optional Ollama tiers")
502
+ _add_profile_arg(doctor_parser)
503
+ doctor_parser.add_argument(
504
+ "--config",
505
+ help="Path to split-stack.models.json (or set SPLIT_STACK_MODELS_CONFIG)",
506
+ )
507
+ _add_quant_arg(doctor_parser)
508
+ doctor_parser.set_defaults(handler=_cmd_doctor)
509
+
510
+ requirements_parser = subparsers.add_parser(
511
+ "requirements",
512
+ help="Show prerequisites for each usage profile",
513
+ )
514
+ requirements_parser.add_argument(
515
+ "profile",
516
+ nargs="?",
517
+ help="Profile id: core, ollama_discovery, local_assistant, cli_doctor",
518
+ )
519
+ requirements_parser.add_argument(
520
+ "--check",
521
+ action="store_true",
522
+ help="Probe this machine (Python, requests, Ollama)",
523
+ )
524
+ requirements_parser.set_defaults(handler=lambda args: _cmd_requirements(args.profile, args.check))
525
+
526
+ route_parser = subparsers.add_parser("route", help="Route a prompt to a model tier")
527
+ _add_ollama_args(route_parser)
528
+ _add_hint_arg(route_parser)
529
+ _add_profile_arg(route_parser)
530
+ _add_quant_arg(route_parser)
531
+ route_parser.add_argument(
532
+ "--explain",
533
+ action="store_true",
534
+ help="Print routing decision trace (uses configure/profile when --models omitted)",
535
+ )
536
+ route_parser.set_defaults(handler=_cmd_route)
537
+
538
+ explain_parser = subparsers.add_parser(
539
+ "explain",
540
+ help="Show why a prompt maps to a tier and model (JSON-friendly)",
541
+ )
542
+ explain_parser.add_argument("--prompt", required=True, help="Prompt text to route")
543
+ explain_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
544
+ _add_hint_arg(explain_parser)
545
+ _add_profile_arg(explain_parser)
546
+ _add_quant_arg(explain_parser)
547
+ explain_parser.add_argument(
548
+ "--models",
549
+ help="Comma-separated model list (power user — bypasses preset discovery)",
550
+ )
551
+ explain_parser.set_defaults(handler=_cmd_explain)
552
+
553
+ ask_parser = subparsers.add_parser("ask", help="Route a prompt and generate via Ollama")
554
+ _add_ollama_args(ask_parser)
555
+ _add_hint_arg(ask_parser)
556
+ ask_parser.add_argument("--timeout", type=int, default=60, help="Ollama request timeout in seconds")
557
+ ask_parser.set_defaults(handler=_cmd_ask)
558
+
559
+ compare_parser = subparsers.add_parser(
560
+ "compare",
561
+ help="Compare split-stack routing vs always-largest on 5-step agent loop",
562
+ )
563
+ compare_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
564
+ compare_parser.add_argument(
565
+ "--live",
566
+ action="store_true",
567
+ help="Call Ollama per step for routed and baseline latency (slow)",
568
+ )
569
+ compare_parser.add_argument(
570
+ "--base-url",
571
+ default="http://127.0.0.1:11434",
572
+ help="Ollama base URL (used with --live)",
573
+ )
574
+ compare_parser.add_argument("--timeout", type=int, default=90, help="Ollama timeout in seconds")
575
+ compare_parser.add_argument(
576
+ "--models",
577
+ default="gemma4:e4b,qwen3:8b,qwen3:14b",
578
+ help="Comma-separated model stack (default: Gemma simple + Qwen mid/complex)",
579
+ )
580
+ compare_parser.set_defaults(handler=_cmd_compare)
581
+
582
+ benchmark_parser = subparsers.add_parser(
583
+ "benchmark",
584
+ help="Run fixed 10-prompt routing benchmark (no inference)",
585
+ )
586
+ benchmark_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
587
+ benchmark_parser.add_argument("--markdown", action="store_true", help="Print markdown table")
588
+ benchmark_parser.add_argument(
589
+ "--models",
590
+ help="Comma-separated model names (default qwen3:4b,qwen3:8b,qwen3:14b,qwen3:30b-a3b)",
591
+ )
592
+ benchmark_parser.set_defaults(handler=_cmd_benchmark)
593
+
594
+ models_parser = subparsers.add_parser(
595
+ "models",
596
+ help="List local Ollama models with registry weights and VRAM hints",
597
+ )
598
+ models_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
599
+ models_parser.add_argument(
600
+ "--config",
601
+ help="Path to split-stack.models.json (or set SPLIT_STACK_MODELS_CONFIG)",
602
+ )
603
+ models_parser.add_argument(
604
+ "--base-url",
605
+ default="http://127.0.0.1:11434",
606
+ help="Ollama base URL",
607
+ )
608
+ models_parser.add_argument(
609
+ "--include-disk",
610
+ action="store_true",
611
+ help="Also scan Ollama manifest folders on disk (OLLAMA_MODELS, ~/.ollama/models, ~/dev/Tools/.ollama/models)",
612
+ )
613
+ models_parser.add_argument(
614
+ "--all",
615
+ action="store_true",
616
+ help="Include models above assumed_vram_gb (ignored when profile=datacenter)",
617
+ )
618
+ _add_profile_arg(models_parser)
619
+ _add_quant_arg(models_parser)
620
+ models_parser.set_defaults(handler=_cmd_models)
621
+
622
+ tips_parser = subparsers.add_parser(
623
+ "tips",
624
+ help="Show installed vs recommended local models (community picks)",
625
+ )
626
+ tips_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
627
+ tips_parser.add_argument(
628
+ "--api",
629
+ action="store_true",
630
+ help="Include models from running Ollama /api/tags",
631
+ )
632
+ tips_parser.add_argument(
633
+ "--base-url",
634
+ default="http://127.0.0.1:11434",
635
+ help="Ollama base URL when --api is set",
636
+ )
637
+ _add_profile_arg(tips_parser)
638
+ tips_parser.set_defaults(handler=_cmd_tips)
639
+
640
+ profiles_parser = subparsers.add_parser(
641
+ "profiles",
642
+ help="List workstation VRAM presets and the datacenter profile",
643
+ )
644
+ profiles_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
645
+ profiles_parser.set_defaults(handler=_cmd_profiles)
646
+
647
+ stacks_parser = subparsers.add_parser(
648
+ "stacks",
649
+ help="List recommended specialist model stacks for workstation profiles",
650
+ )
651
+ stacks_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
652
+ _add_profile_arg(stacks_parser)
653
+ _add_quant_arg(stacks_parser)
654
+ stacks_parser.set_defaults(handler=_cmd_stacks)
655
+
656
+ setup_parser = subparsers.add_parser(
657
+ "setup",
658
+ help="Pick a VRAM preset, consent to Ollama pulls, write split-stack.models.json",
659
+ )
660
+ setup_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
661
+ _add_profile_arg(setup_parser)
662
+ setup_parser.add_argument(
663
+ "--yes",
664
+ action="store_true",
665
+ help="Skip confirmation prompts (non-interactive installs)",
666
+ )
667
+ setup_parser.add_argument(
668
+ "--dry-run",
669
+ action="store_true",
670
+ help="Show planned pulls and tier map without downloading",
671
+ )
672
+ setup_parser.add_argument(
673
+ "--config",
674
+ help="Write config to this path (default ./split-stack.models.json)",
675
+ )
676
+ setup_parser.add_argument(
677
+ "--base-url",
678
+ default="http://127.0.0.1:11434",
679
+ help="Ollama base URL",
680
+ )
681
+ setup_parser.set_defaults(handler=_cmd_setup)
682
+
683
+ args = parser.parse_args(argv)
684
+ if not args.command:
685
+ return _cmd_doctor(argparse.Namespace(profile=None, config=None, quant=None))
686
+ return args.handler(args)
687
+
688
+
689
+ if __name__ == "__main__":
690
+ raise SystemExit(main())