github-sync-agent 0.2.0__py3-none-any.whl → 0.3.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.
github_sync/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.0"
github_sync/api.py CHANGED
@@ -3,14 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import logging
8
+ from pathlib import Path
7
9
 
8
10
  from fastapi import Depends, FastAPI, HTTPException, Security
11
+ from fastapi.responses import HTMLResponse, StreamingResponse
9
12
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
10
- from pydantic import BaseModel, Field
13
+ from pydantic import BaseModel
11
14
 
12
15
  from . import __version__
13
- from . import agent
16
+ from . import tools
14
17
 
15
18
  LOG = logging.getLogger(__name__)
16
19
  _security = HTTPBearer(auto_error=False)
@@ -47,6 +50,10 @@ class StatusResponse(BaseModel):
47
50
  projects: list[StatusRow]
48
51
 
49
52
 
53
+ class ChatRequest(BaseModel):
54
+ query: str
55
+
56
+
50
57
  def create_app(cfg: dict) -> FastAPI:
51
58
  api_key: str = cfg.get("api_key") or ""
52
59
 
@@ -64,17 +71,39 @@ def create_app(cfg: dict) -> FastAPI:
64
71
  if credentials is None or credentials.credentials != api_key:
65
72
  raise HTTPException(status_code=401, detail="Invalid or missing API key")
66
73
 
74
+ _chat_html = (Path(__file__).parent / "static" / "chat.html").read_text()
75
+
76
+ @app.get("/", include_in_schema=False)
77
+ async def index() -> HTMLResponse:
78
+ # Inject API key and config so the browser UI can authenticate
79
+ html = _chat_html.replace(
80
+ "const API_KEY = null;",
81
+ f"const API_KEY = {json.dumps(api_key or '')};",
82
+ ).replace(
83
+ "const LLM_MODEL = null;",
84
+ f"const LLM_MODEL = {json.dumps(str(cfg.get('llm_model', 'gemma3:4b')))};",
85
+ ).replace(
86
+ "const GITHUB_USER = null;",
87
+ f"const GITHUB_USER = {json.dumps(str(cfg.get('github_user', '')))};",
88
+ )
89
+ return HTMLResponse(html)
90
+
67
91
  @app.get("/health")
68
92
  async def health() -> dict[str, str]:
69
93
  return {"status": "ok", "version": __version__}
70
94
 
95
+ @app.get("/tools", dependencies=[Depends(require_auth)])
96
+ async def list_tools() -> list[dict]:
97
+ return tools.list_tools()
98
+
71
99
  @app.get("/status", response_model=StatusResponse, dependencies=[Depends(require_auth)])
72
100
  async def status() -> StatusResponse:
73
- rows = await asyncio.to_thread(agent.get_status, cfg)
101
+ result = await asyncio.to_thread(tools.run_tool, "get_status", cfg, {})
102
+ data = result["data"]
74
103
  return StatusResponse(
75
- github_user=cfg["github_user"],
76
- projects_root=cfg["projects_root"],
77
- projects=[StatusRow(**row) for row in rows],
104
+ github_user=data["github_user"],
105
+ projects_root=data["projects_root"],
106
+ projects=[StatusRow(**row) for row in data["projects"]],
78
107
  )
79
108
 
80
109
  @app.post("/sync", response_model=SyncResponse, dependencies=[Depends(require_auth)])
@@ -85,27 +114,56 @@ def create_app(cfg: dict) -> FastAPI:
85
114
  async def sync_one(project: str, body: SyncRequest = SyncRequest()) -> SyncResponse:
86
115
  return await _run_sync(cfg, project=project, dry_run=body.dry_run)
87
116
 
117
+ @app.post("/chat", dependencies=[Depends(require_auth)])
118
+ async def chat(body: ChatRequest):
119
+ async def generate():
120
+ try:
121
+ from .llm import ask_stream
122
+ async for event in ask_stream(body.query, cfg):
123
+ yield f"data: {json.dumps(event)}\n\n"
124
+ except ImportError:
125
+ yield f"data: {json.dumps({'type': 'error', 'message': 'LLM deps not installed. Run: pip install httpx'})}\n\n"
126
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
127
+ except Exception as exc:
128
+ LOG.exception("Chat error")
129
+ yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
130
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
131
+
132
+ return StreamingResponse(
133
+ generate(),
134
+ media_type="text/event-stream",
135
+ headers={
136
+ "Cache-Control": "no-cache",
137
+ "X-Accel-Buffering": "no",
138
+ "Connection": "keep-alive",
139
+ },
140
+ )
141
+
88
142
  return app
89
143
 
90
144
 
91
145
  async def _run_sync(cfg: dict, project: str | None, dry_run: bool) -> SyncResponse:
92
- try:
93
- projects = await asyncio.to_thread(agent.discover_projects, cfg, project)
94
- except ValueError as exc:
95
- raise HTTPException(status_code=404, detail=str(exc)) from exc
96
-
97
- results: list[SyncResult] = []
98
- for path in projects:
99
- ok = await asyncio.to_thread(agent.sync_project, path, cfg, dry_run)
100
- results.append(SyncResult(project=path.name, success=ok))
101
- level = logging.INFO if ok else logging.ERROR
102
- LOG.log(level, "%s %s", path.name, "synced" if ok else "FAILED")
103
-
104
- synced = sum(1 for r in results if r.success)
105
- failed = len(results) - synced
146
+ tool_name = "sync_project" if project else "sync_all"
147
+ args: dict = {"dry_run": dry_run}
148
+ if project:
149
+ args["project"] = project
150
+
151
+ result = await asyncio.to_thread(tools.run_tool, tool_name, cfg, args)
152
+ if not result["ok"] and project and "not found" in result["message"].lower():
153
+ raise HTTPException(status_code=404, detail=result["message"])
154
+
155
+ data = result["data"]
156
+ results = [SyncResult(**r) for r in data.get("results", [])]
157
+ if project and not results:
158
+ results = [SyncResult(project=project, success=result["ok"])]
159
+
160
+ for r in results:
161
+ level = logging.INFO if r.success else logging.ERROR
162
+ LOG.log(level, "%s %s", r.project, "synced" if r.success else "FAILED")
163
+
106
164
  return SyncResponse(
107
- success=failed == 0,
108
- synced=synced,
109
- failed=failed,
165
+ success=result["ok"],
166
+ synced=data.get("synced", 1 if result["ok"] else 0),
167
+ failed=data.get("failed", 0 if result["ok"] else 1),
110
168
  results=results,
111
169
  )
github_sync/cli.py CHANGED
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  import click
7
7
 
8
8
  from . import __version__
9
- from . import agent, config, setup
9
+ from . import agent, config, setup, tools
10
10
 
11
11
  LOG_FORMAT = "%(asctime)s %(levelname)-8s %(message)s"
12
12
 
@@ -120,7 +120,7 @@ def status(ctx: click.Context) -> None:
120
120
  f"{root_short:<30} "
121
121
  f"{'✓' if row['git'] else '✗':<6} "
122
122
  f"{'✓' if row['remote'] else '✗':<8} "
123
- f"{'dirty' if row['changes'] else 'clean':<10} "
123
+ f"{'unsynced' if row['changes'] else 'synced':<10} "
124
124
  f"{'✓' if row['on_github'] else '✗'}"
125
125
  )
126
126
  click.echo()
@@ -257,6 +257,69 @@ def init() -> None:
257
257
  click.echo()
258
258
 
259
259
 
260
+ # ---------------------------------------------------------------------------
261
+ # tools
262
+ # ---------------------------------------------------------------------------
263
+
264
+ @cli.group()
265
+ def tool() -> None:
266
+ """Inspect and run shared tools (used by MCP / LLM)."""
267
+
268
+
269
+ @tool.command("list")
270
+ def tool_list() -> None:
271
+ """List available tools and their schemas."""
272
+ for t in tools.list_tools():
273
+ click.echo(f"\n{click.style(t['name'], bold=True)}")
274
+ click.echo(f" {t['description']}")
275
+ props = t["input_schema"].get("properties") or {}
276
+ if props:
277
+ click.echo(" Parameters:")
278
+ for name, spec in props.items():
279
+ req = "required" if name in t["input_schema"].get("required", []) else "optional"
280
+ click.echo(f" - {name} ({spec.get('type', 'any')}, {req}): {spec.get('description', '')}")
281
+ else:
282
+ click.echo(" Parameters: none")
283
+ click.echo()
284
+
285
+
286
+ @tool.command("run")
287
+ @click.argument("name")
288
+ @click.option("--arg", "args", multiple=True, help="Tool argument as key=value")
289
+ @click.option("--config", "config_path", type=click.Path(path_type=Path), default=None)
290
+ def tool_run(name: str, args: tuple[str, ...], config_path: Path | None) -> None:
291
+ """Run a tool by name (e.g. github-sync tool run get_status)."""
292
+ try:
293
+ cfg = config.load(config_path)
294
+ except (FileNotFoundError, ValueError) as exc:
295
+ click.echo(f"Error: {exc}", err=True)
296
+ sys.exit(1)
297
+
298
+ _setup_logging(cfg)
299
+
300
+ arguments: dict = {}
301
+ for item in args:
302
+ if "=" not in item:
303
+ click.echo(f"Error: invalid --arg '{item}' (use key=value)", err=True)
304
+ sys.exit(1)
305
+ key, _, value = item.partition("=")
306
+ if value.lower() in ("true", "false"):
307
+ arguments[key] = value.lower() == "true"
308
+ else:
309
+ arguments[key] = value
310
+
311
+ try:
312
+ result = tools.run_tool(name, cfg, arguments)
313
+ except KeyError as exc:
314
+ click.echo(f"Error: {exc}", err=True)
315
+ sys.exit(1)
316
+
317
+ import json
318
+ click.echo(json.dumps(result, indent=2))
319
+ if not result.get("ok"):
320
+ sys.exit(1)
321
+
322
+
260
323
  # ---------------------------------------------------------------------------
261
324
  # serve
262
325
  # ---------------------------------------------------------------------------
@@ -285,12 +348,351 @@ def serve(ctx: click.Context, host: str | None, port: int | None) -> None:
285
348
 
286
349
  auth_note = "API key required" if cfg.get("api_key") else "no auth (localhost only recommended)"
287
350
  click.echo(f"Starting GitHub Sync API on http://{bind_host}:{bind_port} ({auth_note})")
288
- click.echo("Endpoints: GET /health GET /status POST /sync POST /sync/{project}")
351
+ click.echo("Endpoints: GET /health GET /tools GET /status POST /sync POST /sync/{project}")
289
352
  click.echo()
290
353
 
291
354
  uvicorn.run(app, host=bind_host, port=bind_port, log_level=cfg.get("log_level", "info").lower())
292
355
 
293
356
 
357
+ # ---------------------------------------------------------------------------
358
+ # ask (LLM / Ollama)
359
+ # ---------------------------------------------------------------------------
360
+
361
+ @cli.command()
362
+ @click.argument("query", nargs=-1, required=True)
363
+ @click.option("--model", default=None, help="Ollama model (default: llm_model from config)")
364
+ @click.option("--ollama-url", default=None, help="Ollama base URL (default: ollama_url from config)")
365
+ @click.pass_context
366
+ def ask(ctx: click.Context, query: tuple[str, ...], model: str | None, ollama_url: str | None) -> None:
367
+ """Ask a question in plain English — the LLM calls tools as needed.
368
+
369
+ Examples:\n
370
+ github-sync ask what is the status of my projects\n
371
+ github-sync ask sync the Agents project\n
372
+ github-sync ask are there any projects with uncommitted changes
373
+ """
374
+ try:
375
+ from . import llm
376
+ except ImportError:
377
+ click.echo("Error: LLM dependencies not installed.", err=True)
378
+ click.echo("Run: pip install 'github-sync-agent[llm]'", err=True)
379
+ sys.exit(1)
380
+
381
+ cfg = ctx.obj["cfg"]
382
+ _setup_logging(cfg)
383
+
384
+ if model:
385
+ cfg = {**cfg, "llm_model": model}
386
+ if ollama_url:
387
+ cfg = {**cfg, "ollama_url": ollama_url}
388
+
389
+ question = " ".join(query)
390
+ ollama = cfg.get("ollama_url", "http://localhost:11434")
391
+ llm_model = cfg.get("llm_model", "gemma3:4b")
392
+
393
+ click.echo(f"Asking {llm_model} via {ollama} ...\n")
394
+
395
+ try:
396
+ import httpx
397
+ answer = llm.ask(question, cfg)
398
+ except httpx.HTTPError as exc:
399
+ click.echo(click.style(f"Connection error: {exc}", fg="red"), err=True)
400
+ click.echo("Is Ollama running? Try: ollama serve", err=True)
401
+ sys.exit(1)
402
+ except Exception as exc:
403
+ import traceback
404
+ click.echo(click.style(f"Error: {type(exc).__name__}: {exc}", fg="red"), err=True)
405
+ click.echo(traceback.format_exc(), err=True)
406
+ sys.exit(1)
407
+
408
+ click.echo(answer)
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # doctor / start / stop
413
+ # ---------------------------------------------------------------------------
414
+
415
+ def _check(label: str, ok: bool, detail: str = "", fix: str = "") -> bool:
416
+ icon = click.style("✓", fg="green") if ok else click.style("✗", fg="red")
417
+ line = f" {icon} {label}"
418
+ if detail:
419
+ line += f" ({detail})"
420
+ click.echo(line)
421
+ if not ok and fix:
422
+ click.echo(f" → {fix}")
423
+ return ok
424
+
425
+
426
+ def _cmd_ok(*cmd: str) -> tuple[bool, str]:
427
+ import subprocess
428
+ r = subprocess.run(list(cmd), capture_output=True, text=True)
429
+ return r.returncode == 0, (r.stdout + r.stderr).strip()
430
+
431
+
432
+ def _port_pid(port: int) -> int | None:
433
+ """Return PID listening on port, or None."""
434
+ import subprocess
435
+ r = subprocess.run(["lsof", "-ti", f":{port}"], capture_output=True, text=True)
436
+ out = r.stdout.strip()
437
+ return int(out.split()[0]) if out else None
438
+
439
+
440
+ def _ollama_url(cfg: dict | None) -> str:
441
+ return (cfg or {}).get("ollama_url", "http://localhost:11434")
442
+
443
+
444
+ def _ollama_running(url: str) -> bool:
445
+ try:
446
+ import urllib.request
447
+ urllib.request.urlopen(url, timeout=2)
448
+ return True
449
+ except Exception:
450
+ return False
451
+
452
+
453
+ @cli.command()
454
+ @click.pass_context
455
+ def doctor(ctx: click.Context) -> None:
456
+ """Check all prerequisites and service status."""
457
+ click.echo()
458
+ click.echo(click.style("=== GitHub Sync Agent — Doctor ===", bold=True))
459
+ click.echo()
460
+
461
+ all_ok = True
462
+
463
+ # ── System tools ──────────────────────────────────────────────
464
+ click.echo(click.style("System tools", bold=True))
465
+ ok, ver = _cmd_ok("git", "--version")
466
+ all_ok &= _check("git", ok, ver.split("\n")[0], _install_hint("git"))
467
+
468
+ ok, ver = _cmd_ok("gh", "--version")
469
+ all_ok &= _check("gh (GitHub CLI)", ok, ver.split("\n")[0], _install_hint("gh"))
470
+
471
+ ok, ver = _cmd_ok("python3", "--version")
472
+ all_ok &= _check("python3", ok, ver)
473
+
474
+ click.echo()
475
+
476
+ # ── GitHub auth ───────────────────────────────────────────────
477
+ click.echo(click.style("GitHub", bold=True))
478
+ from . import setup as _setup
479
+ auth_ok = _setup.gh_authenticated()
480
+ gh_user = _setup.get_github_username() if auth_ok else ""
481
+ all_ok &= _check("gh authenticated", auth_ok, gh_user, "Run: gh auth login")
482
+
483
+ ssh_ok, ssh_msg, _ = _setup.ensure_github_ssh()
484
+ ssh_user = _setup.ssh_auth_username(ssh_msg) if ssh_ok else ""
485
+ all_ok &= _check("SSH to github.com", ssh_ok, ssh_user or ssh_msg[:60],
486
+ "Run: github-sync init")
487
+ click.echo()
488
+
489
+ # ── Config ────────────────────────────────────────────────────
490
+ click.echo(click.style("Configuration", bold=True))
491
+ cfg = None
492
+ try:
493
+ from . import config as _config
494
+ cfg = _config.load()
495
+ all_ok &= _check("config file", True, str(_config.DEFAULT_CONFIG
496
+ if _config.DEFAULT_CONFIG.exists() else _config.FALLBACK_CONFIG))
497
+ all_ok &= _check("projects_root exists",
498
+ all(Path(r).exists() for r in cfg["projects_root"]),
499
+ ", ".join(cfg["projects_root"]))
500
+ except (FileNotFoundError, ValueError) as exc:
501
+ all_ok &= _check("config file", False, str(exc), "Run: github-sync init")
502
+ click.echo()
503
+
504
+ # ── Ollama / LLM ──────────────────────────────────────────────
505
+ click.echo(click.style("Ollama / LLM (optional — needed for ask + web chat)", bold=True))
506
+ ok, _ = _cmd_ok("ollama", "--version")
507
+ _check("ollama installed", ok, fix="See https://ollama.com/download")
508
+
509
+ url = _ollama_url(cfg)
510
+ running = _ollama_running(url)
511
+ _check("ollama running", running, url, "Run: ollama serve")
512
+
513
+ if running:
514
+ import json as _json, urllib.request as _req
515
+ try:
516
+ data = _json.loads(_req.urlopen(f"{url}/api/tags", timeout=3).read())
517
+ models = [m["name"] for m in data.get("models", [])]
518
+ model_name = cfg.get("llm_model", "gemma3:4b") if cfg else "gemma3:4b"
519
+ has_model = any(model_name in m for m in models)
520
+ _check(f"model {model_name}", has_model,
521
+ "available" if has_model else "not pulled",
522
+ f"Run: ollama pull {model_name}")
523
+ except Exception:
524
+ _check("model check", False, "could not query Ollama")
525
+ click.echo()
526
+
527
+ # ── Services ──────────────────────────────────────────────────
528
+ click.echo(click.style("Services", bold=True))
529
+ api_port = int((cfg or {}).get("api_port", 8741))
530
+ api_pid = _port_pid(api_port)
531
+ _check(f"HTTP API / web UI (port {api_port})",
532
+ api_pid is not None,
533
+ f"pid {api_pid}" if api_pid else "not running",
534
+ "Run: github-sync start")
535
+ click.echo()
536
+
537
+ # ── Summary ───────────────────────────────────────────────────
538
+ if all_ok:
539
+ click.echo(click.style(" All checks passed ✓", fg="green", bold=True))
540
+ else:
541
+ click.echo(click.style(" Some checks failed — see above for fixes.", fg="yellow"))
542
+ click.echo()
543
+
544
+
545
+ @cli.command()
546
+ @click.option("--no-ollama", is_flag=True, help="Skip starting Ollama")
547
+ @click.option("--no-api", is_flag=True, help="Skip starting the HTTP API server")
548
+ @click.pass_context
549
+ def start(ctx: click.Context, no_ollama: bool, no_api: bool) -> None:
550
+ """Start all agent services (Ollama + HTTP API / web chat)."""
551
+ import subprocess
552
+ cfg = ctx.obj["cfg"]
553
+ _setup_logging(cfg)
554
+
555
+ url = _ollama_url(cfg)
556
+ api_port = int(cfg.get("api_port", 8741))
557
+ api_host = cfg.get("api_host", "127.0.0.1")
558
+
559
+ click.echo()
560
+ click.echo(click.style("=== Starting GitHub Sync Agent services ===", bold=True))
561
+ click.echo()
562
+
563
+ # ── Ollama ────────────────────────────────────────────────────
564
+ if not no_ollama:
565
+ if _ollama_running(url):
566
+ click.echo(click.style(" ✓", fg="green") + f" Ollama already running at {url}")
567
+ else:
568
+ click.echo(" ↗ Starting Ollama...")
569
+ started = False
570
+
571
+ # Try user systemd service first (no sudo needed)
572
+ r = subprocess.run(["systemctl", "--user", "start", "ollama"], capture_output=True)
573
+ if r.returncode == 0:
574
+ started = True
575
+
576
+ # Try system systemd service via sudo (passwordless sudo only)
577
+ if not started:
578
+ r2 = subprocess.run(
579
+ ["sudo", "-n", "systemctl", "start", "ollama"],
580
+ capture_output=True
581
+ )
582
+ if r2.returncode == 0:
583
+ started = True
584
+
585
+ if started:
586
+ import time
587
+ for _ in range(15):
588
+ time.sleep(1)
589
+ if _ollama_running(url):
590
+ click.echo(click.style(" ✓", fg="green") + f" Ollama started at {url}")
591
+ break
592
+ else:
593
+ click.echo(click.style(" ✗ Ollama did not respond after 15s.", fg="red"))
594
+ else:
595
+ click.echo(click.style(" ✗ Cannot start Ollama automatically.", fg="yellow"))
596
+ click.echo(" Run: sudo systemctl start ollama")
597
+ click.echo(" Then: github-sync start --no-ollama")
598
+
599
+ # ── HTTP API ──────────────────────────────────────────────────
600
+ if not no_api:
601
+ if _port_pid(api_port):
602
+ click.echo(click.style(" ✓", fg="green") + f" API already running at http://{api_host}:{api_port}")
603
+ else:
604
+ # Check API deps before trying to start
605
+ try:
606
+ import uvicorn # noqa: F401
607
+ except ImportError:
608
+ click.echo(click.style(" ✗ API dependencies not installed.", fg="red"))
609
+ click.echo(" Run: pip install 'github-sync-agent[api]'")
610
+ click.echo(" Or: pipx inject github-sync-agent fastapi uvicorn")
611
+ click.echo()
612
+ click.echo(" Run " + click.style("github-sync doctor", bold=True) + " to verify everything is healthy.")
613
+ click.echo()
614
+ return
615
+
616
+ click.echo(" ↗ Starting HTTP API / web chat...")
617
+ log_file = cfg.get("log_file", str(Path.home() / ".local/share/github-sync/github-sync.log"))
618
+ Path(log_file).parent.mkdir(parents=True, exist_ok=True)
619
+ subprocess.Popen(
620
+ [sys.argv[0], "serve", "--host", api_host, "--port", str(api_port)],
621
+ stdout=open(log_file, "a"), stderr=subprocess.STDOUT,
622
+ start_new_session=True,
623
+ )
624
+ import time; time.sleep(3)
625
+ if _port_pid(api_port):
626
+ click.echo(click.style(" ✓", fg="green") + f" API started — http://{api_host}:{api_port}")
627
+ click.echo(f" Web chat: http://{api_host}:{api_port}")
628
+ click.echo(f" Logs: {log_file}")
629
+ else:
630
+ click.echo(click.style(" ✗ API failed to start. Last log lines:", fg="red"))
631
+ try:
632
+ lines = Path(log_file).read_text().strip().split("\n")
633
+ for line in lines[-5:]:
634
+ click.echo(f" {line}")
635
+ except Exception:
636
+ click.echo(f" (check {log_file})")
637
+
638
+ click.echo()
639
+ click.echo(" Run " + click.style("github-sync doctor", bold=True) + " to verify everything is healthy.")
640
+ click.echo()
641
+
642
+
643
+ @cli.command()
644
+ @click.option("--no-ollama", is_flag=True, help="Skip stopping Ollama")
645
+ @click.option("--no-api", is_flag=True, help="Skip stopping the HTTP API server")
646
+ @click.pass_context
647
+ def stop(ctx: click.Context, no_ollama: bool, no_api: bool) -> None:
648
+ """Stop all agent services (HTTP API + optionally Ollama)."""
649
+ import subprocess
650
+ cfg = ctx.obj["cfg"]
651
+ api_port = int(cfg.get("api_port", 8741))
652
+
653
+ click.echo()
654
+ click.echo(click.style("=== Stopping GitHub Sync Agent services ===", bold=True))
655
+ click.echo()
656
+
657
+ # ── HTTP API ──────────────────────────────────────────────────
658
+ if not no_api:
659
+ pid = _port_pid(api_port)
660
+ if pid:
661
+ import signal
662
+ import os
663
+ os.kill(pid, signal.SIGTERM)
664
+ click.echo(click.style(" ✓", fg="green") + f" API server stopped (pid {pid})")
665
+ else:
666
+ click.echo(f" – API server not running on port {api_port}")
667
+
668
+ # ── Ollama ────────────────────────────────────────────────────
669
+ if not no_ollama:
670
+ url = _ollama_url(cfg)
671
+ if _ollama_running(url):
672
+ stopped = False
673
+ # 1. Try user systemd (no sudo)
674
+ r = subprocess.run(["systemctl", "--user", "stop", "ollama"], capture_output=True)
675
+ if r.returncode == 0:
676
+ stopped = True
677
+ click.echo(click.style(" ✓", fg="green") + " Ollama stopped (systemctl --user)")
678
+ # 2. Try system systemd via passwordless sudo
679
+ if not stopped:
680
+ r2 = subprocess.run(["sudo", "-n", "systemctl", "stop", "ollama"], capture_output=True)
681
+ if r2.returncode == 0:
682
+ stopped = True
683
+ click.echo(click.style(" ✓", fg="green") + " Ollama stopped (sudo systemctl)")
684
+ if not stopped:
685
+ click.echo(click.style(" ✗ Cannot stop Ollama automatically.", fg="yellow"))
686
+ click.echo(" Run: sudo systemctl stop ollama")
687
+ import getpass as _gp
688
+ _u = _gp.getuser()
689
+ click.echo(f" For passwordless control: echo '{_u} ALL=(ALL) NOPASSWD: /usr/bin/systemctl start ollama, /usr/bin/systemctl stop ollama' | sudo tee /etc/sudoers.d/ollama")
690
+ else:
691
+ click.echo(" – Ollama not running")
692
+
693
+ click.echo()
694
+
695
+
294
696
  # ---------------------------------------------------------------------------
295
697
  # Entry point
296
698
  # ---------------------------------------------------------------------------
github_sync/config.py CHANGED
@@ -29,6 +29,9 @@ def _apply_defaults(cfg: dict) -> None:
29
29
  cfg.setdefault("api_host", "127.0.0.1")
30
30
  cfg.setdefault("api_port", 8741)
31
31
  cfg.setdefault("api_key", "")
32
+ cfg.setdefault("ollama_url", "http://localhost:11434")
33
+ cfg.setdefault("llm_model", "gemma3:4b")
34
+ cfg.setdefault("llm_timeout", 120)
32
35
 
33
36
  if not cfg.get("github_user"):
34
37
  raise ValueError("Missing required config key: 'github_user'")