github-sync-agent 0.2.1__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 +1 -1
- github_sync/api.py +81 -23
- github_sync/cli.py +405 -3
- github_sync/config.py +3 -0
- github_sync/llm.py +218 -0
- github_sync/mcp_server.py +81 -0
- github_sync/static/chat.html +434 -0
- github_sync/tools.py +179 -0
- github_sync_agent-0.3.0.dist-info/METADATA +391 -0
- github_sync_agent-0.3.0.dist-info/RECORD +15 -0
- {github_sync_agent-0.2.1.dist-info → github_sync_agent-0.3.0.dist-info}/entry_points.txt +1 -0
- github_sync_agent-0.2.1.dist-info/METADATA +0 -175
- github_sync_agent-0.2.1.dist-info/RECORD +0 -11
- {github_sync_agent-0.2.1.dist-info → github_sync_agent-0.3.0.dist-info}/WHEEL +0 -0
- {github_sync_agent-0.2.1.dist-info → github_sync_agent-0.3.0.dist-info}/top_level.txt +0 -0
github_sync/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "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
|
|
13
|
+
from pydantic import BaseModel
|
|
11
14
|
|
|
12
15
|
from . import __version__
|
|
13
|
-
from . import
|
|
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
|
-
|
|
101
|
+
result = await asyncio.to_thread(tools.run_tool, "get_status", cfg, {})
|
|
102
|
+
data = result["data"]
|
|
74
103
|
return StatusResponse(
|
|
75
|
-
github_user=
|
|
76
|
-
projects_root=
|
|
77
|
-
projects=[StatusRow(**row) for row in
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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=
|
|
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"{'
|
|
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'")
|