Compare commits
3 Commits
feature/in
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 27feeed8bf | |||
| 96178c1438 | |||
| 021e95b04f |
@@ -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
15
requirements.txt
Normal 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
|
||||||
@@ -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 = (
|
console.print(
|
||||||
f"[red]{report.failed} failed[/red]" if report.failed else "[green]0 failed[/green]"
|
f"[bold]Collection complete:[/bold] {report.total} commands, {report.failed} failed"
|
||||||
)
|
)
|
||||||
console.print(f"[bold]Collection complete:[/bold] {report.total} commands, {failed_label}")
|
|
||||||
for item in report.items:
|
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
|
truncated = item.result.stdout_truncated or item.result.stderr_truncated
|
||||||
trunc_label = " [dim](truncated)[/dim]" if truncated else ""
|
trunc = " (truncated)" if truncated else ""
|
||||||
if item.result.exit_code == 0:
|
console.print(f"- {item.name}: {status}{trunc}")
|
||||||
console.print(f" [green]✓[/green] [dim]{item.name}[/dim]{trunc_label}")
|
|
||||||
else:
|
|
||||||
console.print(
|
|
||||||
f" [red]✗[/red] {item.name} "
|
|
||||||
f"[red](exit {item.result.exit_code})[/red]{trunc_label}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user