Files
Cortex-Inara/cortex/tools/git.py
Scott Idem b144d8385f feat: SSH dev routing, model registry UX, chat input toolbar, doc sync
Backend / infrastructure:
- cortex/tools/_projects.py (new): shared project alias registry with ssh_host
  for workstation projects (aether_api, aether_frontend, aether_container)
- cortex/tools/git.py: all git tools route to workstation via SSH when ssh_host set
- cortex/tools/aider.py: aider_run SSH-routes to workstation using bash -l -c
- cortex/routers/local_llm.py: POST /api/models/{id}/edit AJAX endpoint — save
  model edits without page reload or tab reset; returns JSON {ok, label, model_name}
- cortex/llm_client.py: remove Gemini CLI and Claude CLI backends; clean up
  fallback chain and process group tracking (continuation of Gemini CLI removal)
- cortex/routers/auth.py: strip Claude/Gemini CLI auth status checks (CLI removed)
- cortex/routers/chat.py: remove legacy claude/gemini backend fields
- cortex/config.py: clean up CLI-related settings
- cortex/main.py: remove CLI lifecycle hooks

UI:
- cortex/static/local_llm.html: model edit forms now save via fetch() + toast;
  stay on Models tab; update row header label in place on success
- cortex/static/index.html: restructure input area to column layout — textarea
  above, compact toolbar below (Chat/Tools/Attach + Send); fixes dead space at
  M/L/XL sizes; context panel "Role" → "Model" section label
- cortex/static/style.css: column input-area layout; #input-toolbar; flex:1 →
  width:100% on textarea (fixes scrollHeight in column flex context); compact
  send/stop button padding
- cortex/static/app.js: add XL (720px) to height cycle; default M (240px)

Docs:
- cortex/static/HELP.md: S/M/L → S/M/L/XL; add Rebuild to distill table; fix
  "Role selector" references (no such UI); fix "your active role" → Chat role;
  fix  toggle description; Model Registry section cleanup
- documentation/ARCH__BACKENDS.md: reflect CLI removal, current backend state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:14:07 -04:00

318 lines
12 KiB
Python

"""
Git tools — project-scoped.
Read-only inspection:
git_status — working tree status
git_log — recent commit history
git_diff — diff between refs or working tree vs HEAD
Write operations (admin-only, confirm-required):
git_commit — stage files and create a commit
git_push — push current branch to remote
All tools accept an optional `project` parameter using the same aliases as aider_run:
"cortex" (default), "aether_api", "aether_frontend", "aether_container"
Or pass an absolute path directly.
Projects with an ssh_host defined in _projects.py run all git commands on the remote
host via SSH, using shlex-quoted commands to handle paths and arguments safely.
"""
import asyncio
import logging
import os
import shlex
from pathlib import Path
from google.genai import types
from ._projects import PROJECT_ALIASES
logger = logging.getLogger(__name__)
_MAX_OUTPUT = 50_000
_PROJECT_PARAM = types.Schema(
type=types.Type.STRING,
description=(
"Project to run git in. Known aliases: 'cortex' (default), 'aether_api', "
"'aether_frontend', 'aether_container'. Or an absolute path. "
"Omit to use the Cortex project."
),
)
def _resolve_project(project: str) -> tuple[Path, str]:
"""Return (path, ssh_host). path may not exist locally when ssh_host is set."""
if not project:
d = PROJECT_ALIASES["cortex"]
else:
d = PROJECT_ALIASES.get(project)
if d is None:
# Raw path — no SSH routing
return Path(os.path.expanduser(project)), ""
return Path(os.path.expanduser(d.path)), d.ssh_host
async def _git(*args: str, cwd: Path, ssh_host: str = "", timeout: int = 15) -> tuple[int, str]:
"""Run a git command locally or via SSH. Returns (returncode, combined output)."""
if ssh_host:
# Build a single shell-safe command string for the remote shell
remote_cmd = shlex.join(["git", "-C", str(cwd)] + list(args))
proc = await asyncio.create_subprocess_exec(
"ssh", ssh_host, remote_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
else:
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(cwd), *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
return 1, "git command timed out"
out = (stdout or b"").decode(errors="replace").strip()
err = (stderr or b"").decode(errors="replace").strip()
return proc.returncode, (out if out else err)
def _cap(text: str) -> str:
if len(text) > _MAX_OUTPUT:
return text[:_MAX_OUTPUT] + "\n… [truncated]"
return text
# ── Read-only tools ────────────────────────────────────────────────────────────
async def git_status(project: str = "") -> str:
"""Return the working tree status for a project."""
cwd, ssh_host = _resolve_project(project)
if not ssh_host and not cwd.is_dir():
return f"Error: project directory not found: {cwd}"
rc, out = await _git("status", cwd=cwd, ssh_host=ssh_host)
if rc != 0:
return f"git status failed: {out}"
return out or "Working tree clean — nothing to report."
async def git_log(n: int = 20, path: str = "", oneline: bool = True, project: str = "") -> str:
"""Return recent commit history for a project."""
cwd, ssh_host = _resolve_project(project)
if not ssh_host and not cwd.is_dir():
return f"Error: project directory not found: {cwd}"
args = ["log"]
if oneline:
args += ["--oneline"]
else:
args += ["--format=%H %as %an%n %s", "--date=short"]
args += [f"-{max(1, min(n, 200))}"]
if path:
args += ["--", path]
rc, out = await _git(*args, cwd=cwd, ssh_host=ssh_host)
if rc != 0:
return f"git log failed: {out}"
return _cap(out) or "No commits found."
async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False, project: str = "") -> str:
"""Show a diff for a project. Defaults to working tree vs HEAD."""
cwd, ssh_host = _resolve_project(project)
if not ssh_host and not cwd.is_dir():
return f"Error: project directory not found: {cwd}"
args = ["diff"]
if stat_only:
args += ["--stat"]
if ref_a and ref_b:
args += [f"{ref_a}..{ref_b}"]
elif ref_a:
args += [ref_a]
if path:
args += ["--", path]
rc, out = await _git(*args, cwd=cwd, ssh_host=ssh_host)
# diff exits 1 when differences exist — normal
if rc not in (0, 1):
return f"git diff failed: {out}"
return _cap(out) or "No differences found."
# ── Write tools (admin-only, confirm-required) ─────────────────────────────────
async def git_commit(message: str, project: str = "", files: list[str] | None = None) -> str:
"""Stage files and create a commit in a project."""
cwd, ssh_host = _resolve_project(project)
if not ssh_host and not cwd.is_dir():
return f"Error: project directory not found: {cwd}"
if not message.strip():
return "Error: commit message is required."
if files:
for f in files:
rc, out = await _git("add", "--", f, cwd=cwd, ssh_host=ssh_host)
if rc != 0:
return f"git add '{f}' failed: {out}"
else:
rc, out = await _git("add", "-A", cwd=cwd, ssh_host=ssh_host)
if rc != 0:
return f"git add -A failed: {out}"
rc, staged = await _git("diff", "--cached", "--stat", cwd=cwd, ssh_host=ssh_host)
if not staged.strip():
return "Nothing staged to commit — working tree already clean."
rc, out = await _git("commit", "-m", message, cwd=cwd, ssh_host=ssh_host)
if rc != 0:
return f"git commit failed: {out}"
return out or "Committed successfully."
async def git_push(project: str = "", remote: str = "origin", branch: str = "") -> str:
"""Push the current branch to a remote."""
cwd, ssh_host = _resolve_project(project)
if not ssh_host and not cwd.is_dir():
return f"Error: project directory not found: {cwd}"
args = ["push", remote]
if branch:
args.append(branch)
rc, out = await _git(*args, cwd=cwd, ssh_host=ssh_host, timeout=30)
if rc != 0:
return f"git push failed: {out}"
return out or f"Pushed to {remote} successfully."
# ── Declarations ───────────────────────────────────────────────────────────────
DECLARATIONS = [
types.FunctionDeclaration(
name="git_status",
description=(
"Show the working tree status for a project: staged changes, unstaged "
"modifications, and untracked files. Use before committing to see what "
"will be included. Defaults to the Cortex project. "
"aether_api, aether_frontend, and aether_container run on the workstation via SSH."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={"project": _PROJECT_PARAM},
),
),
types.FunctionDeclaration(
name="git_log",
description=(
"Show recent commit history for a project. Returns commit hashes, dates, "
"and messages. Use after aider_run completes to see what was committed. "
"Defaults to the Cortex project. "
"aether_api, aether_frontend, and aether_container run on the workstation via SSH."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"n": types.Schema(
type=types.Type.INTEGER,
description="Number of commits to return (default 20, max 200)",
),
"path": types.Schema(
type=types.Type.STRING,
description="Optional file or directory path to filter commits by",
),
"oneline": types.Schema(
type=types.Type.BOOLEAN,
description="Compact one-line format (default true). False for more detail.",
),
"project": _PROJECT_PARAM,
},
),
),
types.FunctionDeclaration(
name="git_diff",
description=(
"Show a git diff for a project. "
"With no arguments: unstaged working tree changes vs HEAD. "
"With ref_a only: changes between that ref and HEAD. "
"With ref_a and ref_b: changes between the two refs. "
"Use after aider_run (auto_commit=False) to review changes before committing. "
"Defaults to the Cortex project. "
"aether_api, aether_frontend, and aether_container run on the workstation via SSH."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"ref_a": types.Schema(
type=types.Type.STRING,
description="First ref (commit hash, branch, or tag). Omit for working tree diff.",
),
"ref_b": types.Schema(
type=types.Type.STRING,
description="Second ref. With ref_a, shows diff between the two.",
),
"path": types.Schema(
type=types.Type.STRING,
description="Optional file or directory path to restrict the diff to",
),
"stat_only": types.Schema(
type=types.Type.BOOLEAN,
description="Return only a file-change summary (--stat) instead of the full patch",
),
"project": _PROJECT_PARAM,
},
),
),
types.FunctionDeclaration(
name="git_commit",
description=(
"Stage files and create a git commit in a project. "
"Use after reviewing changes with git_diff — especially when aider_run ran "
"with auto_commit=False. Stages all changes by default (files=None). "
"aether_api, aether_frontend, and aether_container commit on the workstation via SSH. "
"ADMIN ONLY. Requires confirmation."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"message": types.Schema(
type=types.Type.STRING,
description="Commit message. Follow the project's commit style (e.g. 'feat: ...').",
),
"project": _PROJECT_PARAM,
"files": types.Schema(
type=types.Type.ARRAY,
items=types.Schema(type=types.Type.STRING),
description=(
"Specific files to stage (paths relative to project root). "
"Omit to stage all changes (git add -A)."
),
),
},
required=["message"],
),
),
types.FunctionDeclaration(
name="git_push",
description=(
"Push the current branch to a remote. "
"Use after git_commit or after aider_run commits to share the changes. "
"aether_api, aether_frontend, and aether_container push on the workstation via SSH. "
"ADMIN ONLY. Requires confirmation."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"project": _PROJECT_PARAM,
"remote": types.Schema(
type=types.Type.STRING,
description="Remote name (default: 'origin')",
),
"branch": types.Schema(
type=types.Type.STRING,
description="Branch to push. Omit to push the current tracking branch.",
),
},
),
),
]