local-coding-agent 1.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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,725 @@
1
+ """Subcommand dispatch for the local-coding-agent CLI (part 2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from ..benchmark import run_benchmark, write_artifact
12
+ from ..doctor import diagnose_environment
13
+ from ..mcp_config import integrate_mcp_config
14
+ from ..memory import ModelMemoryManager
15
+ from ..mode_router import build_mode_router, classify_fast, classify_mode
16
+ from ..ollama_adapter import (
17
+ BACKEND_OFFLINE_HINT,
18
+ OllamaError,
19
+ build_client,
20
+ classify_backend_error,
21
+ )
22
+ from ..profiles import get_profile
23
+ from ..skill_config import integrate_skill_config
24
+ from ..smoke import run_smoke_test
25
+ from ..task import TaskEnvelope
26
+
27
+ _SOURCE_EXTS = {
28
+ ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".rb",
29
+ ".php", ".c", ".cpp", ".h", ".hpp", ".cs", ".sh", ".kt", ".swift",
30
+ }
31
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"}
32
+
33
+ _SESSIONS_DIR = Path(".local-run") / "sessions"
34
+
35
+ _CHAT_SYSTEM_PROMPT = "You are a concise local coding assistant."
36
+
37
+
38
+ def _detect_relevant_files(workspace: Path, prompt: str) -> list[str]:
39
+ """Mirror desktop auto-detection: bounded scan for source files to allowlist.
40
+
41
+ Returns relative POSIX paths (as accepted by TaskEnvelope) for source files
42
+ whose basename appears in the prompt; falls back to the first few source
43
+ files if none match. Never recurses into build/vendor/cache dirs.
44
+ """
45
+ source_files: list[Path] = []
46
+ for p in workspace.rglob("*"):
47
+ if not p.is_file():
48
+ continue
49
+ parts = p.relative_to(workspace).parts
50
+ if any(part in _SKIP_DIRS or part.startswith(".") for part in parts):
51
+ continue
52
+ if p.suffix.lower() in _SOURCE_EXTS:
53
+ source_files.append(p)
54
+ source_files.sort()
55
+ # ponytail: bounded scan, 200 files is plenty for an isolated-context allowlist.
56
+ source_files = source_files[:200]
57
+
58
+ matched = [
59
+ str(p.relative_to(workspace).as_posix())
60
+ for p in source_files
61
+ if p.name in prompt or p.stem in prompt
62
+ ]
63
+ # ponytail: cap at max_files=5 so BoundedRepositoryTools never rejects a
64
+ # prompt that names more files than the allowlist permits.
65
+ if matched:
66
+ return matched[:5]
67
+ return [str(p.relative_to(workspace).as_posix()) for p in source_files[:5]]
68
+
69
+
70
+ def _detect_test_checks(workspace: Path) -> list[str]:
71
+ if (workspace / "tests").is_dir():
72
+ return ["pytest tests/"]
73
+ if (workspace / "test").is_dir():
74
+ return ["pytest test/"]
75
+ for p in workspace.rglob("test_*.py"):
76
+ if not p.is_file():
77
+ continue
78
+ if any(part in _SKIP_DIRS or part.startswith(".") for part in p.relative_to(workspace).parts):
79
+ continue
80
+ return [f"pytest {p.relative_to(workspace).as_posix()}"]
81
+ return []
82
+
83
+
84
+ def _detect_files_or_raise(workspace: Path, prompt: str) -> list[str]:
85
+ files = _detect_relevant_files(workspace, prompt)
86
+ if files:
87
+ return files
88
+ raise ValueError(
89
+ "no source files detected in workspace; specify a workspace containing source files"
90
+ )
91
+
92
+
93
+ def _profile_for_args(args: argparse.Namespace):
94
+ overrides: dict[str, object] = {}
95
+ if getattr(args, "model", None):
96
+ overrides["model"] = args.model
97
+ num_ctx = getattr(args, "num_ctx", None)
98
+ if num_ctx is not None:
99
+ overrides["num_ctx"] = num_ctx
100
+ return get_profile(args.profile, **overrides)
101
+
102
+
103
+ def _backend_error_payload(error: Exception) -> dict[str, object]:
104
+ # Normalize to the same vocabulary the controller model boundary uses.
105
+ kind = {
106
+ "offline": "backend_offline",
107
+ "server_error": "backend_error",
108
+ }.get(classify_backend_error(error) or "", "backend_error")
109
+ payload: dict[str, object] = {"status": "failed", "error": {"kind": kind, "message": str(error)}}
110
+ if kind == "backend_offline":
111
+ payload["error"]["hint"] = BACKEND_OFFLINE_HINT
112
+ return payload
113
+
114
+
115
+ def _sanitize_session_id(session_id: str) -> str:
116
+ safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in session_id)[:80]
117
+ return safe or "session"
118
+
119
+
120
+ def _load_session_log(session_id: str):
121
+ from ..session_events import SessionLog
122
+
123
+ log_path = _SESSIONS_DIR / f"{_sanitize_session_id(session_id)}.jsonl"
124
+ if log_path.exists():
125
+ return SessionLog.load_from_jsonl(log_path), True
126
+ _SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
127
+ return SessionLog(session_id=_sanitize_session_id(session_id), storage_dir=_SESSIONS_DIR), False
128
+
129
+
130
+ def _run_chat_repl(args: argparse.Namespace) -> int:
131
+ from datetime import datetime
132
+
133
+ from ..session_events import derive_messages
134
+
135
+ session_id = getattr(args, "session_id", None) or (
136
+ "chat-" + datetime.now().strftime("%Y%m%d-%H%M%S")
137
+ )
138
+ try:
139
+ log, resumed = _load_session_log(session_id)
140
+ except Exception as error:
141
+ print(f"Error: cannot load session {session_id!r}: {error}", file=sys.stderr)
142
+ return 1
143
+ if resumed:
144
+ print(f"[RESUMED session {log.session_id} — {len(log)} events]")
145
+ else:
146
+ log.record_created(metadata={"profile": args.profile, "workspace": str(args.workspace)})
147
+ print(f"[NEW session {log.session_id}] Multi-turn chat REPL. Type 'exit' or Ctrl+C to stop.")
148
+
149
+ try:
150
+ profile = _profile_for_args(args)
151
+ client = build_client(profile)
152
+ except OllamaError as error:
153
+ print(json.dumps(_backend_error_payload(error), ensure_ascii=False, indent=2))
154
+ return 2
155
+
156
+ from ..chat_compaction import fit_history
157
+
158
+ interrupted = False
159
+ sticky_boundary = 0
160
+ while True:
161
+ try:
162
+ line = input("you> ").strip()
163
+ except (EOFError, KeyboardInterrupt):
164
+ print()
165
+ break
166
+ if not line:
167
+ continue
168
+ if line.lower() in {"exit", "quit", "/exit", "/quit"}:
169
+ break
170
+ log.record_user_prompt(line)
171
+ messages = [{"role": "system", "content": _CHAT_SYSTEM_PROMPT}, *derive_messages(log.events)]
172
+ messages, cinfo = fit_history(
173
+ messages, max(1024, profile.num_ctx - 1024), sticky_boundary
174
+ )
175
+ sticky_boundary = cinfo["next_sticky_boundary"]
176
+ try:
177
+ resp = client.chat(messages)
178
+ except KeyboardInterrupt:
179
+ print("\n[interrupted]")
180
+ interrupted = True
181
+ break
182
+ except OllamaError as error:
183
+ print(json.dumps(_backend_error_payload(error), ensure_ascii=False, indent=2))
184
+ return 2
185
+ reply = (resp.get("message") or {}).get("content") or ""
186
+ if cinfo["compacted"]:
187
+ print("[context compacted]")
188
+ log.record_model_turn(content=reply, model=profile_model_name(args))
189
+ print(f"assistant> {reply}")
190
+
191
+ log.record_completed(
192
+ "interrupted" if interrupted else "success",
193
+ summary="repl closed by user",
194
+ )
195
+ print(f"[Session saved: {log.log_path}]")
196
+ return 0
197
+
198
+
199
+ def profile_model_name(args: argparse.Namespace) -> str:
200
+ overrides = {"model": args.model} if getattr(args, "model", None) else {}
201
+ return get_profile(args.profile, **overrides).model
202
+
203
+
204
+ def _handle_chat(args: argparse.Namespace) -> int:
205
+ if getattr(args, "list_sessions", False):
206
+ return _handle_sessions_list(getattr(args, "json", False), getattr(args, "limit", 50) or 50)
207
+
208
+ if getattr(args, "repl", False):
209
+ return _run_chat_repl(args)
210
+
211
+ if not args.prompt or not args.prompt.strip():
212
+ if getattr(args, "json", False):
213
+ print(json.dumps({"status": "failed", "message": "prompt is required"}, ensure_ascii=False, indent=2))
214
+ else:
215
+ print("Error: prompt is required", file=sys.stderr)
216
+ return 1
217
+
218
+ if args.mode == "hybrid":
219
+ try:
220
+ # ponytail: router uses the small default profile, not the main one.
221
+ router = build_mode_router()
222
+ mode = classify_mode(
223
+ args.prompt,
224
+ router=router,
225
+ counter=0,
226
+ recent_prompts=[args.prompt],
227
+ )
228
+ except Exception:
229
+ mode = classify_mode(args.prompt, router=None)
230
+ else:
231
+ mode = args.mode
232
+ # A question never needs a patch: route it to chat even when the user
233
+ # explicitly picked build (mirrors the desktop handler's classifier gate).
234
+ if mode != "plan" and classify_fast(args.prompt) == "chat":
235
+ mode = "chat"
236
+
237
+ try:
238
+ from ..controller import Controller
239
+
240
+ if mode == "plan":
241
+ # Read-only Controller path mirrors desktop: real allowlisted files,
242
+ # mutation tools blocked, output shaped like PlanArtifact.to_dict().
243
+ profile = _profile_for_args(args)
244
+ client = build_client(profile)
245
+ workspace = Path(args.workspace)
246
+ files = _detect_files_or_raise(workspace, args.prompt)
247
+ checks = _detect_test_checks(workspace)
248
+ task = TaskEnvelope(
249
+ id="chat-plan",
250
+ goal=args.prompt,
251
+ files=tuple(files),
252
+ checks=tuple(checks),
253
+ )
254
+ run = Controller(
255
+ client,
256
+ args.workspace,
257
+ max_turns=4,
258
+ blocked_tools={"propose_patch", "run_tests"},
259
+ ).run(task)
260
+ summary = run.get("summary") or ""
261
+ risks = run.get("risks") or []
262
+ files_to_modify = list(files)
263
+ plan = {
264
+ "goal": args.prompt,
265
+ "steps": [summary] if summary else [],
266
+ "risks": [
267
+ str(r.get("message", "")) if isinstance(r, dict) else str(r)
268
+ for r in risks
269
+ ],
270
+ "files_to_modify": [str(f) for f in files_to_modify],
271
+ }
272
+ result = {
273
+ "status": "completed" if run.get("status") == "accepted" else "failed",
274
+ "mode": mode,
275
+ "message": summary or f"Plan generated for '{args.prompt}'.",
276
+ "patch": "",
277
+ "plan": plan,
278
+ "checks": run.get("checks") or [],
279
+ }
280
+ elif mode == "chat":
281
+ client = build_client(_profile_for_args(args))
282
+ resp = client.chat([
283
+ {"role": "system", "content": _CHAT_SYSTEM_PROMPT},
284
+ {"role": "user", "content": args.prompt},
285
+ ])
286
+ reply = (resp.get("message") or {}).get("content") or ""
287
+ result = {
288
+ "status": "completed",
289
+ "mode": mode,
290
+ "message": reply,
291
+ "patch": "",
292
+ "plan": None,
293
+ "checks": [],
294
+ }
295
+ else: # build
296
+ profile = _profile_for_args(args)
297
+ client = build_client(profile)
298
+ workspace = Path(args.workspace)
299
+ files = _detect_files_or_raise(workspace, args.prompt)
300
+ checks = _detect_test_checks(workspace)
301
+ task = TaskEnvelope(
302
+ id="chat-build",
303
+ goal=args.prompt,
304
+ files=tuple(files),
305
+ checks=tuple(checks),
306
+ )
307
+ run = Controller(client, args.workspace, max_turns=4).run(task)
308
+ result = {
309
+ "status": "completed" if run.get("status") == "accepted" else "failed",
310
+ "mode": mode,
311
+ "message": run.get("summary") or "",
312
+ "patch": run.get("patch") or "",
313
+ "plan": None,
314
+ "checks": run.get("checks") or [],
315
+ }
316
+ except OllamaError as error:
317
+ payload = _backend_error_payload(error)
318
+ if getattr(args, "json", False):
319
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
320
+ else:
321
+ print(f"Error: {error}", file=sys.stderr)
322
+ hint = (payload.get("error") or {}).get("hint")
323
+ if hint:
324
+ print(f"Hint: {hint}", file=sys.stderr)
325
+ return 2
326
+ except Exception as error:
327
+ if getattr(args, "json", False):
328
+ print(json.dumps({"status": "failed", "mode": mode, "message": str(error), "patch": "", "plan": None, "checks": []}, ensure_ascii=False, indent=2))
329
+ else:
330
+ print(f"Error: {error}", file=sys.stderr)
331
+ return 1
332
+
333
+ if getattr(args, "json", False):
334
+ print(json.dumps(result, ensure_ascii=False, indent=2))
335
+ else:
336
+ print(f"[{mode.upper()}]")
337
+ print(result.get("message") or "(no response)")
338
+ if result.get("plan"):
339
+ print(json.dumps(result["plan"], ensure_ascii=False, indent=2))
340
+ return 0 if result["status"] == "completed" else 1
341
+
342
+
343
+ def _iter_session_logs():
344
+ from ..session_events import SessionLog
345
+
346
+ if not _SESSIONS_DIR.is_dir():
347
+ return
348
+ for path in sorted(_SESSIONS_DIR.glob("*.jsonl")):
349
+ try:
350
+ yield SessionLog.load_from_jsonl(path)
351
+ except Exception:
352
+ continue # ponytail: skip corrupt/partial session files.
353
+
354
+
355
+ def _index_sessions(engine) -> None:
356
+ for log in _iter_session_logs():
357
+ try:
358
+ record = engine.get_session_record(log.session_id)
359
+ if record is None or record["event_count"] != len(log):
360
+ engine.index_session_log(log)
361
+ except Exception:
362
+ continue
363
+
364
+
365
+ def _handle_sessions_list(as_json: bool, limit: int = 50) -> int:
366
+ from ..session_query import get_default_engine
367
+
368
+ engine = get_default_engine(_SESSIONS_DIR / "index.db")
369
+ _index_sessions(engine)
370
+ sessions = engine.list_sessions(limit=limit)
371
+ if as_json:
372
+ print(json.dumps({"sessions": sessions}, ensure_ascii=False, indent=2))
373
+ return 0
374
+ if not sessions:
375
+ print("No persisted sessions found. Start one with: local-agent chat --repl")
376
+ return 0
377
+ print(f"{'SESSION ID':<32} {'EVENTS':<8} {'UPDATED':<25} STATUS")
378
+ print("-" * 84)
379
+ for s in sessions:
380
+ print(f"{s['session_id']:<32} {s['event_count']:<8} {str(s['updated_at']):<25} {s['status'] or '-'}")
381
+ return 0
382
+
383
+
384
+ def _handle_sessions(args: argparse.Namespace) -> int:
385
+ from ..session_query import get_default_engine
386
+
387
+ action = getattr(args, "session_action", "list") or "list"
388
+ if action == "show":
389
+ if not args.session_id:
390
+ print(json.dumps({"status": "failed", "error": "session id required for 'show'"}, ensure_ascii=False))
391
+ return 2
392
+ engine = get_default_engine(_SESSIONS_DIR / "index.db")
393
+ _index_sessions(engine)
394
+ stored_id = _sanitize_session_id(args.session_id)
395
+ trace = engine.get_session_trace(stored_id)
396
+ record = engine.get_session_record(stored_id)
397
+ if not trace and record is None:
398
+ print(json.dumps({"status": "failed", "error": f"unknown session: {args.session_id!r}"}, ensure_ascii=False))
399
+ return 1
400
+ payload = {"record": record, "events": trace}
401
+ if getattr(args, "json", False):
402
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
403
+ else:
404
+ print(f"Session {stored_id} — {len(trace)} events")
405
+ for ev in trace:
406
+ kind = ev.get("event_type")
407
+ ts = str(ev.get("timestamp", ""))[:19]
408
+ if kind == "user_prompt":
409
+ print(f"[{ts}] you> {ev.get('content', '')}")
410
+ elif kind == "model_turn":
411
+ print(f"[{ts}] assistant> {ev.get('content', '')}")
412
+ elif kind == "session_completed":
413
+ print(f"[{ts}] (completed: {ev.get('status')})")
414
+ elif kind == "session_created":
415
+ print(f"[{ts}] (session started)")
416
+ else:
417
+ print(f"[{ts}] ({kind})")
418
+ return 0
419
+
420
+ return _handle_sessions_list(getattr(args, "json", False), getattr(args, "limit", 50) or 50)
421
+
422
+
423
+ def _handle_skill(args: argparse.Namespace) -> int:
424
+ dry_run = not args.write or args.dry_run
425
+ res = integrate_skill_config(
426
+ client=args.client,
427
+ workspace=args.workspace,
428
+ target_path=args.path,
429
+ dry_run=dry_run,
430
+ print_content=args.print_content,
431
+ )
432
+ if args.print_content:
433
+ content = res.get("content", "")
434
+ try:
435
+ print(content)
436
+ except UnicodeEncodeError:
437
+ if hasattr(sys.stdout, "buffer"):
438
+ sys.stdout.buffer.write(content.encode("utf-8", errors="replace"))
439
+ sys.stdout.buffer.write(b"\n")
440
+ sys.stdout.buffer.flush()
441
+ else:
442
+ print(content.encode("ascii", errors="replace").decode("ascii"))
443
+ return 0
444
+
445
+ if args.json:
446
+ print(json.dumps(res, indent=2, ensure_ascii=False))
447
+ else:
448
+ if "results" in res:
449
+ print(f"--- Multi-Agent Skill Installation ({'Preview' if dry_run else 'Installed'}) ---")
450
+ for sub_res in res["results"]:
451
+ status = "[DRY-RUN]" if dry_run else "[OK]"
452
+ print(f"{status} {sub_res['client'].upper()}: {sub_res['path']}")
453
+ if dry_run:
454
+ print("\nUse --write to write SKILL.md into these directories.")
455
+ else:
456
+ status = "[DRY-RUN]" if dry_run else "[OK]"
457
+ print(f"{status} {res.get('client', 'custom').upper()}: {res.get('path')}")
458
+ if dry_run:
459
+ print("\nUse --write to write SKILL.md into this path.")
460
+ return 0
461
+
462
+
463
+ def _handle_doctor(args: argparse.Namespace) -> int:
464
+ if getattr(args, "fix", False):
465
+ from ..doctor import remediate_environment
466
+
467
+ write = not getattr(args, "dry_run", False)
468
+ fix_rep = remediate_environment(endpoint=args.endpoint, write=write)
469
+ if args.json:
470
+ print(json.dumps(fix_rep.to_dict(), indent=2, ensure_ascii=False))
471
+ else:
472
+ print(fix_rep.render_text())
473
+ return 0 if fix_rep.success else 1
474
+
475
+ report = diagnose_environment(endpoint=args.endpoint)
476
+ if args.json:
477
+ print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
478
+ else:
479
+ print(report.render_text())
480
+ if getattr(args, "strict", False):
481
+ return 0 if report.healthy else 1
482
+ return 0
483
+
484
+
485
+ def _handle_init_mcp(args: argparse.Namespace) -> int:
486
+ dry_run = not args.write or args.dry_run
487
+ res = integrate_mcp_config(
488
+ client=args.client,
489
+ workspace=args.workspace,
490
+ profile=args.profile,
491
+ target_path=args.path,
492
+ dry_run=dry_run,
493
+ )
494
+ if "results" in res:
495
+ print(f"--- Multi-Client MCP Configuration ({'Preview' if dry_run else 'Applied'}) ---")
496
+ for sub_res in res["results"]:
497
+ status = "[DRY-RUN]" if dry_run else "[OK]"
498
+ print(f"{status} {sub_res['client'].upper()}: {sub_res['path']}")
499
+ if dry_run:
500
+ print("\nUse --write to automatically merge these configs into the files.")
501
+ else:
502
+ if dry_run:
503
+ print(f"--- MCP Configuration Preview ({args.client}) ---")
504
+ print(f"Target Path: {res['path']}")
505
+ config = res["config"]
506
+ print(config if isinstance(config, str) else json.dumps(config, indent=2, ensure_ascii=False))
507
+ print("\nUse --write to automatically merge this config into the file.")
508
+ else:
509
+ print(f"[OK] Successfully integrated MCP server into: {res['path']}")
510
+ return 0
511
+
512
+
513
+ def _handle_smoke(args: argparse.Namespace) -> int:
514
+ res = run_smoke_test(
515
+ profile_name=args.profile,
516
+ use_mock=args.mock,
517
+ fallback_to_mock=getattr(args, "allow_mock_fallback", False),
518
+ verbose=True,
519
+ )
520
+ return 0 if res["success"] and (res.get("backend_verified") or res.get("synthetic")) else 1
521
+
522
+
523
+ def _handle_serve_mcp(args: argparse.Namespace) -> int:
524
+ from ..mcp_server import build_server
525
+ from ..service import DelegationService
526
+
527
+ ws_path = str(Path(args.workspace).resolve())
528
+ service = DelegationService({args.workspace_ref: ws_path})
529
+ server = build_server(service, enable_tasks=args.enable_tasks)
530
+ server.run(transport="stdio")
531
+ return 0
532
+
533
+
534
+ def _handle_serve_acp(args: argparse.Namespace) -> int:
535
+ from ..acp_server import AcpServer
536
+
537
+ server = AcpServer(
538
+ default_workspace=args.workspace,
539
+ default_profile=args.profile,
540
+ framing=args.framing,
541
+ )
542
+ server.serve()
543
+ return 0
544
+
545
+
546
+ def _handle_monitor(args: argparse.Namespace) -> int:
547
+ from ..monitor import MonitorServer
548
+ from ..stats import DelegationStats, default_stats_path
549
+
550
+ if args.subcommand in ("ui", "app"):
551
+ print("=" * 72)
552
+ print(" [EXPERIMENTAL PREVIEW] Web Workbench UI is experimental incubation.")
553
+ print(" Full standalone Desktop Harness redesign is currently in progress.")
554
+ print("=" * 72)
555
+
556
+ stats = DelegationStats()
557
+ server = MonitorServer(host=args.host, port=args.port, stats=stats, stats_path=default_stats_path())
558
+ path_name = "workbench" if args.subcommand in ("ui", "app") else "dashboard"
559
+ print(f"Starting server on {server.url}/{path_name} (Press Ctrl+C to stop)...")
560
+ server.start()
561
+ try:
562
+ while True:
563
+ import time
564
+
565
+ time.sleep(1)
566
+ except KeyboardInterrupt:
567
+ server.stop()
568
+ print("\nServer stopped.")
569
+ return 0
570
+
571
+
572
+ def _handle_desktop(args: argparse.Namespace) -> int:
573
+ from ..desktop import launch_desktop_app
574
+
575
+ return launch_desktop_app(
576
+ host=args.host,
577
+ port=args.port,
578
+ workspace=args.workspace,
579
+ default_profile=args.profile,
580
+ browser=getattr(args, "browser", False),
581
+ headless=getattr(args, "headless", False),
582
+ )
583
+
584
+
585
+ def _handle_scan_models(args: argparse.Namespace) -> int:
586
+ from ..model_scanner import get_model_registry
587
+
588
+ registry = get_model_registry()
589
+ if getattr(args, "add_dir", None):
590
+ added = registry.add_custom_directory(args.add_dir)
591
+ res = {"status": "added" if added else "already_present", "directory": args.add_dir}
592
+ print(json.dumps(res, indent=2, ensure_ascii=False))
593
+ return 0
594
+ if getattr(args, "remove_dir", None):
595
+ removed = registry.remove_custom_directory(args.remove_dir)
596
+ res = {"status": "removed" if removed else "not_found", "directory": args.remove_dir}
597
+ print(json.dumps(res, indent=2, ensure_ascii=False))
598
+ return 0
599
+ if getattr(args, "list_dirs", False):
600
+ dirs = registry.list_custom_directories()
601
+ res = {"custom_directories": dirs}
602
+ print(json.dumps(res, indent=2, ensure_ascii=False))
603
+ return 0
604
+
605
+ target_drives = [d.strip() for d in args.drives.split(",") if d.strip()] if getattr(args, "drives", None) else None
606
+ discovered = registry.scan(deep=getattr(args, "deep", False), target_drives=target_drives)
607
+ models_data = [m.to_dict() for m in discovered]
608
+ if getattr(args, "json", False):
609
+ print(json.dumps({"total_models": len(models_data), "models": models_data}, indent=2, ensure_ascii=False))
610
+ else:
611
+ print(f"--- Discovered Local GGUF Models ({len(models_data)}) ---")
612
+ for m in models_data:
613
+ print(f"[{m['backend'].upper()}] {m['name']} ({m['size_gb']} GB) -> {m['path']}")
614
+ return 0
615
+
616
+
617
+ def _handle_benchmark(args: argparse.Namespace) -> int:
618
+ overrides = {}
619
+ if args.endpoint:
620
+ overrides["endpoint"] = args.endpoint
621
+ names = tuple(args.benchmark_models or (
622
+ "qwen3-8b-q6k",
623
+ "qwen3.8-27b-q4",
624
+ "qwen2.5-coder",
625
+ "ornith-9b",
626
+ "qwen3-coder-30b",
627
+ "devstral-small-2-24b",
628
+ ))
629
+ artifact = {
630
+ "schema": "local-coding-agent/benchmark-v1",
631
+ "generated_at": datetime.now(timezone.utc).isoformat(),
632
+ "repeats": args.benchmark_repeats,
633
+ "models": [],
634
+ }
635
+ for name in names:
636
+ benchmark_profile = get_profile(
637
+ name,
638
+ **overrides,
639
+ timeout_seconds=args.benchmark_timeout_seconds,
640
+ )
641
+ benchmark_client = build_client(benchmark_profile)
642
+ try:
643
+ available = benchmark_client.available_models()
644
+ available_names = {
645
+ item.get("name")
646
+ for item in available.get("models", [])
647
+ if isinstance(item, dict) and isinstance(item.get("name"), str)
648
+ }
649
+ if benchmark_profile.model not in available_names:
650
+ artifact["models"].append(
651
+ {
652
+ "profile": name,
653
+ "model": benchmark_profile.model,
654
+ "status": "unavailable",
655
+ "error": f"model is not present in Ollama /api/tags: {benchmark_profile.model}",
656
+ }
657
+ )
658
+ continue
659
+ model_info = next(
660
+ (
661
+ item
662
+ for item in available.get("models", [])
663
+ if isinstance(item, dict) and item.get("name") == benchmark_profile.model
664
+ ),
665
+ {},
666
+ )
667
+ memory = ModelMemoryManager(benchmark_client)
668
+ memory_before = memory.snapshot().as_dict()
669
+ if memory_before["models"]:
670
+ memory.unload_all()
671
+ if getattr(args, "ladder", False):
672
+ from ..capability import CapabilityLadder
673
+ ladder = CapabilityLadder()
674
+ ladder_vec = ladder.evaluate(name, benchmark_client, max_turns=args.max_turns)
675
+ run = {
676
+ "profile": benchmark_profile.__dict__,
677
+ "status": "completed",
678
+ "capability_ladder": ladder_vec.as_dict(),
679
+ }
680
+ else:
681
+ run = run_benchmark(
682
+ name,
683
+ benchmark_client,
684
+ repeats=args.benchmark_repeats,
685
+ max_turns=args.max_turns,
686
+ )
687
+
688
+ run["profile"] = benchmark_profile.__dict__
689
+ run["status"] = "completed"
690
+ run["memory_before"] = memory_before
691
+ run["memory_after"] = memory.snapshot().as_dict()
692
+ run["ollama_model_info"] = {
693
+ key: model_info.get(key)
694
+ for key in ("name", "size", "digest", "details", "capabilities")
695
+ if key in model_info
696
+ }
697
+ artifact["models"].append(run)
698
+ except OllamaError as error:
699
+ artifact["models"].append(
700
+ {
701
+ "profile": name,
702
+ "model": benchmark_profile.model,
703
+ "status": "unavailable",
704
+ "error": {"kind": error.kind, "message": str(error)},
705
+ }
706
+ )
707
+ write_artifact(args.benchmark_output, artifact)
708
+ if getattr(args, "ladder", False) and not getattr(args, "json", False):
709
+ for m in artifact["models"]:
710
+ if m.get("status") == "completed" and "capability_ladder" in m:
711
+ lad = m["capability_ladder"]
712
+ print("\n" + "=" * 64)
713
+ print(f" Capability Ladder — {lad['model']}")
714
+ print(f" Overall Tier {lad['overall_tier']}: {lad['tier_label']}")
715
+ print("=" * 64)
716
+ print(f" Granularity: {lad['granularity_tolerance']} | Gen Speed: {lad['tps_generation']} tok/s")
717
+ print(f" Confidence CI95: {lad['confidence_95_ci']} | Correctness: {lad['correctness_percent']}%")
718
+ print("-" * 64)
719
+ for t_idx, t_data in sorted(lad.get("tested_tiers", {}).items()):
720
+ mark = "[PASS]" if t_data.get("status") == "passed" else "[FAIL]"
721
+ print(f" Tier {t_idx} ({t_data.get('label')}): {mark} {t_data.get('passed_cases')}/{t_data.get('total_cases')} ({t_data.get('score_percent')}%)")
722
+ print("=" * 64 + "\n")
723
+ else:
724
+ print(json.dumps(artifact, ensure_ascii=False, indent=2))
725
+ return 0 if artifact["models"] and all(item["status"] == "completed" for item in artifact["models"]) else 1