Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/gemstack/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,38 @@ class InstallResult:

@runtime_checkable
class AgentAdapter(Protocol):
"""Protocol that all installable agent adapters must implement."""
"""Protocol that all installable agent adapters must implement.

Adapters are responsible for installing/uninstalling Gemstack
workflow commands into a specific AI agent tool (e.g., Gemini CLI,
Cursor, Claude Desktop).

Example — a minimal adapter for a custom agent::

class MyAgentAdapter:
@property
def name(self) -> str:
return "My Agent"

@property
def is_available(self) -> bool:
return shutil.which("myagent") is not None

def install(self, data_dir: Path, copy_mode: bool = False) -> InstallResult:
target = Path.home() / ".myagent" / "workflows"
target.mkdir(parents=True, exist_ok=True)
count = 0
for md_file in data_dir.glob("*.md"):
(target / md_file.name).write_text(md_file.read_text())
count += 1
return InstallResult(success=True, installed_count=count, skipped_count=0)

def uninstall(self) -> InstallResult:
return InstallResult(success=True, installed_count=0, skipped_count=0)

def verify(self) -> list[str]:
return []
"""

@property
def name(self) -> str:
Expand Down
118 changes: 95 additions & 23 deletions src/gemstack/ai/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,75 @@ class AIBootstrapper:
"""

def __init__(self, model: str = "gemini-3.1-pro-preview") -> None:
import shutil

self.has_cli = shutil.which("gemini") is not None
self.model = model
self.client = None

try:
from google import genai
except ImportError:
raise ImportError(
"The 'ai' extra is required for AI bootstrapping. "
"Install with: pip install gemstack[ai]"
) from None
from gemstack.project.config import GemstackConfig

config = GemstackConfig.load()
api_key = config.get_api_key()

self._genai = genai
if api_key:
self.client = genai.Client(api_key=api_key)
else:
# Fall back to env vars (which the SDK checks automatically)
self.client = genai.Client()

self.model = model
from gemstack.project.config import GemstackConfig

config = GemstackConfig.load()
api_key = config.get_api_key()

self._genai = genai
if api_key:
self.client = genai.Client(api_key=api_key)
else:
self.client = genai.Client()
except ImportError:
if not self.has_cli:
raise ImportError(
"The 'ai' extra or Gemini CLI is required for AI bootstrapping. "
"Install SDK with: pip install gemstack[ai] or install CLI."
) from None

async def analyze(self, profile: ProjectProfile) -> BootstrapResult:
"""Analyze the codebase, preferring Gemini CLI over SDK."""
if self.has_cli:
try:
return await self._analyze_with_cli(profile)
except Exception as e:
logger.warning(f"CLI analysis failed: {e}. Falling back to SDK if available.")
if not self.client:
raise

return await self._analyze_with_sdk(profile)

async def _analyze_with_cli(self, profile: ProjectProfile) -> BootstrapResult:
"""Send key source files to Gemini CLI via subprocess."""
context_parts = self._build_context(profile)
system_instruction = self._load_system_instruction()

payload = "\n\n".join(context_parts)

logger.info("Executing Gemini CLI for AI analysis...")
process = await asyncio.create_subprocess_exec(
"gemini",
system_instruction,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

stdout, stderr = await process.communicate(input=payload.encode("utf-8"))

if process.returncode != 0:
error_msg = stderr.decode("utf-8").strip()
raise RuntimeError(
f"Gemini CLI failed with exit code {process.returncode}: {error_msg}"
)

class DummyResponse:
def __init__(self, text: str):
self.text = text

return self._parse_response(DummyResponse(stdout.decode("utf-8")))

async def _analyze_with_sdk(self, profile: ProjectProfile) -> BootstrapResult:
"""Send key source files to Gemini for deep analysis.

Strategy:
Expand All @@ -119,9 +166,12 @@ async def analyze(self, profile: ProjectProfile) -> BootstrapResult:
system_instruction = self._load_system_instruction()

# Call Gemini (run sync SDK in thread to avoid blocking)
try:
response = await asyncio.to_thread(
self.client.models.generate_content,
max_retries = 3
base_delay = 10

def _call_api() -> object:
assert self.client is not None, "Gemini SDK client not initialized"
return self.client.models.generate_content(
model=self.model,
contents=context_parts,
config={
Expand All @@ -130,9 +180,31 @@ async def analyze(self, profile: ProjectProfile) -> BootstrapResult:
"max_output_tokens": 8192,
},
)
except Exception as e:
logger.error(f"Gemini API call failed: {e}")
raise

for attempt in range(max_retries):
try:
response = await asyncio.to_thread(_call_api)
break
except Exception as e:
error_str = str(e).lower()
is_rate_limit = "429" in error_str or "resource_exhausted" in error_str
if is_rate_limit and attempt < max_retries - 1:
import re

delay = base_delay * (2 ** attempt)
match = re.search(r"'retryDelay':\s*'(\d+)s'", error_str, re.IGNORECASE)
if match:
delay = int(match.group(1)) + 1 # Add 1s buffer

logger.warning(
f"Rate limit hit. Retrying in {delay}s... "
f"(Attempt {attempt + 1}/{max_retries})"
)
await asyncio.sleep(delay)
continue

logger.error(f"Gemini API call failed: {e}")
raise

return self._parse_response(response)

Expand Down
6 changes: 5 additions & 1 deletion src/gemstack/cli/compile_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,8 @@ def _copy_to_clipboard(text: str) -> None:
elif sys.platform == "win32":
subprocess.run(["clip"], input=text.encode(), check=True)
else:
console.print("[yellow]⚠️ Clipboard not supported on this platform[/yellow]")
console.print(
"[yellow]⚠️ Clipboard not supported on this platform. "
"Try installing `xclip` or use the `--out <file>` flag "
"to save to a file instead.[/yellow]"
)
180 changes: 180 additions & 0 deletions src/gemstack/cli/diagnose_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""gemstack diagnose — Unified diagnostic dashboard.

Aggregates ``doctor`` (environment), ``check`` (.agent/ integrity),
and ``diff`` (context drift) into a single report so users don't need
to remember which diagnostic command to run.
"""

from __future__ import annotations

from pathlib import Path
from typing import Annotated

import typer
from rich.panel import Panel
from rich.table import Table

from gemstack.cli.context import console


def diagnose(
project_root: Annotated[
Path, typer.Argument(help="Project root directory", resolve_path=True)
] = Path("."),
) -> None:
"""Run all diagnostics (doctor + check + diff) in a single dashboard."""
console.print(
Panel(
"[bold cyan]Running unified diagnostics...[/bold cyan]",
title="🩺 gemstack diagnose",
border_style="cyan",
)
)

issues: list[tuple[str, str, str]] = [] # (category, severity, message)

# ── Section 1: Environment (doctor) ─────────────────────────
issues.extend(_run_doctor_checks())

# ── Section 2: .agent/ Integrity (check) ────────────────────
issues.extend(_run_check_validation(project_root))

# ── Section 3: Context Drift (diff) ─────────────────────────
issues.extend(_run_diff_analysis(project_root))

# ── Render unified table ────────────────────────────────────
if not issues:
console.print(
Panel(
"[bold green]All diagnostics passed![/bold green]\n"
"[dim]Environment, .agent/ integrity, and context drift — all clear.[/dim]",
title="✅ System Health",
border_style="green",
)
)
return

table = Table(title="Diagnostic Results", show_lines=True)
table.add_column("Category", style="bold", width=14)
table.add_column("Severity", justify="center", width=8)
table.add_column("Issue")

has_errors = False
for category, severity, message in issues:
sev_style = "[red]ERROR[/red]" if severity == "ERROR" else "[yellow]WARN[/yellow]"
if severity == "ERROR":
has_errors = True
table.add_row(category, sev_style, message)

console.print(table)

if has_errors:
console.print(
"\n[dim]Fix the errors above, then re-run "
"`gemstack diagnose` to verify.[/dim]"
)
raise typer.Exit(code=1)


def _run_doctor_checks() -> list[tuple[str, str, str]]:
"""Run environment checks (subset of ``gemstack doctor``)."""
import platform

issues: list[tuple[str, str, str]] = []

# Antigravity directory
antigravity_dir = Path.home() / ".gemini" / "antigravity"
if not antigravity_dir.exists():
issues.append(("Environment", "WARN", "Antigravity directory not found"))

# Global workflows
workflows_dir = antigravity_dir / "global_workflows"
if not workflows_dir.exists():
issues.append(("Environment", "WARN", "Global workflows directory not found"))

# Bundled data
try:
from importlib.resources import files

files("gemstack.data")
except Exception:
issues.append(("Environment", "ERROR", "Bundled data not accessible"))

# Platform info (informational — check for known issues)
if platform.system() == "Windows" and not _is_wsl():
issues.append(
("Environment", "WARN", "Native Windows detected — WSL is recommended")
)

return issues


def _run_check_validation(project_root: Path) -> list[tuple[str, str, str]]:
"""Run .agent/ validation (subset of ``gemstack check``)."""
issues: list[tuple[str, str, str]] = []

agent_dir = project_root / ".agent"
if not agent_dir.exists():
issues.append(
(
"Integrity",
"ERROR",
"No .agent/ directory found — run `gemstack init`",
)
)
return issues

try:
from gemstack.project.validator import ProjectValidator

validator = ProjectValidator()
result = validator.validate(project_root, auto_fix=False)

for err in result.errors:
issues.append(("Integrity", "ERROR", err))
for warn in result.warnings:
issues.append(("Integrity", "WARN", warn))
except Exception as e:
issues.append(("Integrity", "ERROR", f"Validation failed: {e}"))

return issues


def _run_diff_analysis(project_root: Path) -> list[tuple[str, str, str]]:
"""Run context drift detection (subset of ``gemstack diff``)."""
issues: list[tuple[str, str, str]] = []

agent_dir = project_root / ".agent"
if not agent_dir.exists():
# Already reported by check — skip silently
return issues

try:
from gemstack.utils.differ import ContextDiffer

differ = ContextDiffer()
report = differ.analyze(project_root)

if report.has_drift:
for dep in report.new_dependencies:
issues.append(("Drift", "WARN", f"New dependency: {dep}"))
for dep in report.removed_dependencies:
issues.append(("Drift", "WARN", f"Removed dependency: {dep}"))
for var in report.new_env_vars:
issues.append(("Drift", "WARN", f"New env var: {var}"))
for var in report.removed_env_vars:
issues.append(("Drift", "WARN", f"Removed env var: {var}"))
for ref in report.stale_file_refs:
issues.append(("Drift", "WARN", f"Stale file ref: {ref}"))
except Exception as e:
issues.append(("Drift", "WARN", f"Drift analysis failed: {e}"))

return issues


def _is_wsl() -> bool:
"""Detect if running inside WSL."""
try:
return "microsoft" in Path("/proc/version").read_text().lower()
except (FileNotFoundError, OSError):
return False
10 changes: 8 additions & 2 deletions src/gemstack/cli/hook_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ def install(
hooks_dir = project_root / ".git" / "hooks"

if not hooks_dir.exists():
console.print("[red]❌ No .git/hooks/ directory found. Are you in a git repository?[/red]")
console.print(
"[red]❌ No .git/hooks/ directory found. "
"Run `git init` to initialize a repository first.[/red]"
)
raise typer.Exit(code=1)

# Determine which hooks to install
Expand Down Expand Up @@ -116,7 +119,10 @@ def uninstall(
hooks_dir = project_root / ".git" / "hooks"

if not hooks_dir.exists():
console.print("[yellow]⚠️ No .git/hooks/ directory found.[/yellow]")
console.print(
"[yellow]⚠️ No .git/hooks/ directory found. "
"Run `git init` to initialize a repository first.[/yellow]"
)
return

count = 0
Expand Down
Loading
Loading