3 Commits

Author SHA1 Message Date
27feeed8bf feat: add combined release zip with binary and deb package
All checks were successful
CI / test (push) Successful in 20s
2026-05-04 06:24:19 +02:00
96178c1438 chore: remove logs from tracking, add requirements.txt, improve .gitignore
All checks were successful
CI / test (push) Successful in 20s
2026-05-04 06:21:40 +02:00
021e95b04f test
All checks were successful
CI / test (push) Successful in 19s
2026-05-04 06:16:30 +02:00
4 changed files with 61 additions and 65 deletions

View File

@@ -131,6 +131,16 @@ jobs:
dpkg-deb --build "${deb_dir}" "${out_dir}/${pkg_name}_${deb_version}_${arch}.deb" dpkg-deb --build "${deb_dir}" "${out_dir}/${pkg_name}_${deb_version}_${arch}.deb"
- name: Create release zip with binary and deb
run: |
cd dist
deb_version="${{ steps.version.outputs.deb_version }}"
zip_name="tai-${deb_version}-linux-amd64.zip"
zip "${zip_name}" \
tai \
"tai_${deb_version}_amd64.deb"
cd ..
- name: Upload binary artifact - name: Upload binary artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
@@ -146,3 +156,11 @@ jobs:
path: dist/tai_${{ steps.version.outputs.deb_version }}_amd64.deb path: dist/tai_${{ steps.version.outputs.deb_version }}_amd64.deb
if-no-files-found: error if-no-files-found: error
retention-days: 90 retention-days: 90
- name: Upload combined release zip
uses: actions/upload-artifact@v3
with:
name: tai-release-${{ steps.version.outputs.tag }}
path: dist/tai-${{ steps.version.outputs.deb_version }}-linux-amd64.zip
if-no-files-found: error
retention-days: 90

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
# Core dependencies
typer>=0.12,<1.0
rich>=13.7,<14.0
asyncssh>=2.14,<3.0
openai>=1.30,<2.0
# Development dependencies
pytest>=8.2,<9.0
ruff>=0.5,<1.0
mypy>=1.10,<2.0
mdformat>=0.7,<1.0
yamllint>=1.35,<2.0
# Build dependencies
nuitka>=2.4,<3.0

View File

@@ -8,9 +8,6 @@ from typing import Annotated
import typer import typer
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
from rich.rule import Rule
from rich.text import Text
from tai.ai_client import DEFAULT_AI_HOST, DEFAULT_MODEL, AIClient, AIConfig from tai.ai_client import DEFAULT_AI_HOST, DEFAULT_MODEL, AIClient, AIConfig
from tai.ai_guardrails import validate_ai_response from tai.ai_guardrails import validate_ai_response
@@ -122,12 +119,11 @@ def run(
) )
summary = SSHClient(config).summary() summary = SSHClient(config).summary()
console.print(Rule("[bold green]tai[/bold green]", style="green")) console.print("[bold green]tai[/bold green]")
console.print(f" [bold]Issue:[/bold] {req.issue}") console.print(f"Issue: {req.issue}")
console.print(f" [bold]SSH:[/bold] {summary}") console.print(f"SSH: {summary}")
if req.target_paths: if req.target_paths:
console.print(f" [bold]Paths:[/bold] {', '.join(str(p) for p in req.target_paths)}") console.print(f"Paths: {', '.join(str(p) for p in req.target_paths)}")
console.print()
if not (probe or collect or analyze or interactive): if not (probe or collect or analyze or interactive):
return # nothing SSH-related requested return # nothing SSH-related requested
@@ -231,20 +227,15 @@ async def _interactive_loop(
) -> None: ) -> None:
"""Run a follow-up loop for collecting and conversational analysis.""" """Run a follow-up loop for collecting and conversational analysis."""
console.print( console.print(
Panel( "[cyan]Interactive mode:[/cyan] "
"Ask questions directly, or use [bold]/collect[/bold], " "ask questions directly, or use /collect, /analyze, /help, /quit"
"[bold]/analyze[/bold], [bold]/help[/bold], [bold]/quit[/bold]",
title="[bold cyan]Interactive Mode[/bold cyan]",
border_style="cyan",
padding=(0, 1),
)
) )
prior_questions: list[str] = [] prior_questions: list[str] = []
while True: while True:
try: try:
command = console.input("\n[bold cyan]tai[/bold cyan][dim] >[/dim] ").strip() command = input("tai> ").strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
console.print("\n[yellow]Exiting interactive mode.[/yellow]") console.print("\n[yellow]Exiting interactive mode.[/yellow]")
if logger is not None: if logger is not None:
@@ -261,18 +252,8 @@ async def _interactive_loop(
return return
if command == "/help": if command == "/help":
console.print( console.print("Commands: /collect, /analyze, /help, /quit")
Panel( console.print("Tip: any non-slash text is treated as a follow-up AI question.")
"[bold]/collect[/bold] — re-run diagnostics\n"
"[bold]/analyze[/bold] — re-analyze current diagnostics\n"
"[bold]/help[/bold] — show this message\n"
"[bold]/quit[/bold] — end session\n"
"[dim]Anything else is sent directly to the AI as a question.[/dim]",
title="[bold]Commands[/bold]",
border_style="dim",
padding=(0, 1),
)
)
continue continue
if command == "/collect": if command == "/collect":
@@ -338,32 +319,26 @@ async def _interactive_loop(
def _handle_probe_result(result: SSHCommandResult) -> None: def _handle_probe_result(result: SSHCommandResult) -> None:
"""Handle and render probe output for success or failure.""" """Handle and render probe output for success or failure."""
console.print("[dim]▶ SSH probe:[/dim] uname -a") console.print("[cyan]Running SSH probe:[/cyan] uname -a")
if result.exit_code != 0: if result.exit_code != 0:
details = result.stderr or result.stdout or "no error output from ssh" details = result.stderr or result.stdout or "no error output from ssh"
console.print(f"[bold red]Probe failed[/bold red] (exit {result.exit_code}): {details}") console.print(f"[red]Probe failed (exit {result.exit_code}):[/red] {details}")
raise typer.Exit(code=1) raise typer.Exit(code=1)
output = result.stdout or "(no output)" output = result.stdout or "(no output)"
console.print("[bold green]Probe succeeded.[/bold green]") console.print("[bold green]Probe succeeded.[/bold green]")
console.print(f" [dim]{output}[/dim]") console.print(f"Remote: {output}")
def _handle_collection_report(report: CollectionReport) -> None: def _handle_collection_report(report: CollectionReport) -> None:
"""Render collected command status and truncation hints.""" """Render collected command status and truncation hints."""
failed_label = (
f"[red]{report.failed} failed[/red]" if report.failed else "[green]0 failed[/green]"
)
console.print(f"[bold]Collection complete:[/bold] {report.total} commands, {failed_label}")
for item in report.items:
truncated = item.result.stdout_truncated or item.result.stderr_truncated
trunc_label = " [dim](truncated)[/dim]" if truncated else ""
if item.result.exit_code == 0:
console.print(f" [green]✓[/green] [dim]{item.name}[/dim]{trunc_label}")
else:
console.print( console.print(
f" [red]✗[/red] {item.name} " f"[bold]Collection complete:[/bold] {report.total} commands, {report.failed} failed"
f"[red](exit {item.result.exit_code})[/red]{trunc_label}"
) )
for item in report.items:
status = "ok" if item.result.exit_code == 0 else f"exit {item.result.exit_code}"
truncated = item.result.stdout_truncated or item.result.stderr_truncated
trunc = " (truncated)" if truncated else ""
console.print(f"- {item.name}: {status}{trunc}")
def _run_analysis( def _run_analysis(
@@ -374,9 +349,7 @@ def _run_analysis(
logger: SessionLogger | None, logger: SessionLogger | None,
) -> None: ) -> None:
"""Send collected data to the AI and stream the analysis to stdout.""" """Send collected data to the AI and stream the analysis to stdout."""
console.print() console.print("[cyan]Analyzing...[/cyan]\n")
console.print(Rule("[bold cyan]Analysis[/bold cyan]", style="cyan"))
console.print()
ai = AIClient(ai_config) ai = AIClient(ai_config)
system_prompt = build_system_prompt() system_prompt = build_system_prompt()
user_message = build_user_message(issue, report) user_message = build_user_message(issue, report)
@@ -389,10 +362,7 @@ def _run_analysis(
warnings = validate_ai_response(response) warnings = validate_ai_response(response)
for item in warnings: for item in warnings:
warn_text = Text() console.print(f"[yellow]Guardrail warning:[/yellow] {item}")
warn_text.append("⚠ Guardrail: ", style="bold yellow")
warn_text.append(item, style="yellow")
console.print(warn_text)
if logger is not None: if logger is not None:
logger.log_event( logger.log_event(
@@ -420,9 +390,7 @@ def _run_followup_analysis(
logger: SessionLogger | None, logger: SessionLogger | None,
) -> str: ) -> str:
"""Run grounded follow-up analysis re-anchored to current diagnostics.""" """Run grounded follow-up analysis re-anchored to current diagnostics."""
console.print() console.print("[cyan]Analyzing...[/cyan]\n")
console.print(Rule("[bold cyan]AI Response[/bold cyan]", style="cyan"))
console.print()
ai = AIClient(ai_config) ai = AIClient(ai_config)
system_prompt = build_system_prompt() system_prompt = build_system_prompt()
user_message = build_followup_message(issue, report, question, prior_questions) user_message = build_followup_message(issue, report, question, prior_questions)
@@ -433,14 +401,10 @@ def _run_followup_analysis(
chunks.append(chunk) chunks.append(chunk)
response = "".join(chunks) response = "".join(chunks)
console.print(Markdown(response)) console.print(Markdown(response))
console.print(Rule(style="dim"))
warnings = validate_ai_response(response) warnings = validate_ai_response(response)
for item in warnings: for item in warnings:
warn_text = Text() console.print(f"[yellow]Guardrail warning:[/yellow] {item}")
warn_text.append("⚠ Guardrail: ", style="bold yellow")
warn_text.append(item, style="yellow")
console.print(warn_text)
if logger is not None: if logger is not None:
logger.log_event( logger.log_event(

View File

@@ -137,9 +137,8 @@ def test_collect_success_prints_summary(monkeypatch) -> None: # type: ignore[no
assert result.exit_code == 0 assert result.exit_code == 0
assert "Collection complete" in result.stdout assert "Collection complete" in result.stdout
assert "kernel" in result.stdout assert "kernel: ok" in result.stdout
assert "journal" in result.stdout assert "journal: ok (truncated)" in result.stdout
assert "truncated" in result.stdout
def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-untyped-def] def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-untyped-def]
@@ -164,7 +163,7 @@ def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-
commands = iter(["/collect", "/quit"]) commands = iter(["/collect", "/quit"])
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan) monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands)) monkeypatch.setattr("builtins.input", lambda _prompt: next(commands))
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -181,7 +180,7 @@ def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "ask questions directly" in result.stdout.lower() assert "Interactive mode" in result.stdout
assert "Collection complete" in result.stdout assert "Collection complete" in result.stdout
assert "Bye." in result.stdout assert "Bye." in result.stdout
@@ -211,7 +210,7 @@ def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type:
"tai.cli.AIClient.stream", "tai.cli.AIClient.stream",
lambda *_args, **_kwargs: iter(["Check logs."]), lambda *_args, **_kwargs: iter(["Check logs."]),
) )
monkeypatch.setattr("tai.cli.console.input", lambda _prompt: next(commands)) monkeypatch.setattr("builtins.input", lambda _prompt: next(commands))
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -228,5 +227,5 @@ def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type:
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "AI Response" in result.stdout assert "Analyzing..." in result.stdout
assert "Check logs." in result.stdout assert "Check logs." in result.stdout