feat(cli): add interactive follow-up loop with slash commands
This commit is contained in:
@@ -15,7 +15,7 @@ from tai.input_parser import InputValidationError, build_request
|
|||||||
from tai.models import TroubleshootRequest
|
from tai.models import TroubleshootRequest
|
||||||
from tai.plan import plan_from_request
|
from tai.plan import plan_from_request
|
||||||
from tai.prompt_builder import build_system_prompt, build_user_message
|
from tai.prompt_builder import build_system_prompt, build_user_message
|
||||||
from tai.ssh_client import SSHClient, SSHCommandResult, SSHConnectionConfig
|
from tai.ssh_client import SSHClient, SSHCommandResult, SSHConnectionConfig, SSHSession
|
||||||
|
|
||||||
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -66,6 +66,13 @@ def run(
|
|||||||
help="Send collected diagnostics to AI for analysis.",
|
help="Send collected diagnostics to AI for analysis.",
|
||||||
),
|
),
|
||||||
] = False,
|
] = False,
|
||||||
|
interactive: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--interactive/--no-interactive",
|
||||||
|
help="Start interactive follow-up mode (/collect, /analyze, /quit).",
|
||||||
|
),
|
||||||
|
] = False,
|
||||||
ai_host: Annotated[
|
ai_host: Annotated[
|
||||||
str,
|
str,
|
||||||
typer.Option("--ai-host", help="OpenAI-compatible AI backend URL."),
|
typer.Option("--ai-host", help="OpenAI-compatible AI backend URL."),
|
||||||
@@ -109,16 +116,25 @@ def run(
|
|||||||
if req.target_paths:
|
if req.target_paths:
|
||||||
console.print(f"Paths: {', '.join(str(p) for p in req.target_paths)}")
|
console.print(f"Paths: {', '.join(str(p) for p in req.target_paths)}")
|
||||||
|
|
||||||
if not (probe or collect or analyze):
|
if not (probe or collect or analyze or interactive):
|
||||||
return # nothing SSH-related requested
|
return # nothing SSH-related requested
|
||||||
|
|
||||||
ai_config = AIConfig(host=ai_host, model=model, api_key=ai_key)
|
ai_config = AIConfig(host=ai_host, model=model, api_key=ai_key)
|
||||||
if analyze:
|
if analyze or interactive:
|
||||||
console.print(f"[cyan]AI:[/cyan] {AIClient(ai_config).summary()}")
|
console.print(f"[cyan]AI:[/cyan] {AIClient(ai_config).summary()}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(_async_main(config, req, probe=probe, collect=collect, analyze=analyze,
|
asyncio.run(
|
||||||
ai_config=ai_config))
|
_async_main(
|
||||||
|
config,
|
||||||
|
req,
|
||||||
|
probe=probe,
|
||||||
|
collect=collect,
|
||||||
|
analyze=analyze,
|
||||||
|
interactive=interactive,
|
||||||
|
ai_config=ai_config,
|
||||||
|
)
|
||||||
|
)
|
||||||
except typer.Exit:
|
except typer.Exit:
|
||||||
raise
|
raise
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
@@ -136,6 +152,7 @@ async def _async_main(
|
|||||||
probe: bool,
|
probe: bool,
|
||||||
collect: bool,
|
collect: bool,
|
||||||
analyze: bool,
|
analyze: bool,
|
||||||
|
interactive: bool,
|
||||||
ai_config: AIConfig,
|
ai_config: AIConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open a single SSH session and run probe / collection / analysis through it."""
|
"""Open a single SSH session and run probe / collection / analysis through it."""
|
||||||
@@ -155,6 +172,55 @@ async def _async_main(
|
|||||||
if analyze and report is not None:
|
if analyze and report is not None:
|
||||||
_run_analysis(ai_config, req.issue, report)
|
_run_analysis(ai_config, req.issue, report)
|
||||||
|
|
||||||
|
if interactive:
|
||||||
|
await _interactive_loop(session, req, ai_config, report)
|
||||||
|
|
||||||
|
|
||||||
|
async def _interactive_loop(
|
||||||
|
session: SSHSession,
|
||||||
|
req: TroubleshootRequest,
|
||||||
|
ai_config: AIConfig,
|
||||||
|
report: CollectionReport | None,
|
||||||
|
) -> None:
|
||||||
|
"""Run a tiny follow-up loop for collecting and analyzing on demand."""
|
||||||
|
console.print("[cyan]Interactive mode:[/cyan] /collect, /analyze, /help, /quit")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
command = input("tai> ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
console.print("\n[yellow]Exiting interactive mode.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not command:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if command in {"/quit", "/exit"}:
|
||||||
|
console.print("[green]Bye.[/green]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if command == "/help":
|
||||||
|
console.print("Commands: /collect, /analyze, /help, /quit")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if command == "/collect":
|
||||||
|
plan = plan_from_request(req)
|
||||||
|
console.print(f"[cyan]Collecting diagnostics:[/cyan] {len(plan)} commands")
|
||||||
|
report = await collect_from_plan(session, plan)
|
||||||
|
_handle_collection_report(report)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if command == "/analyze":
|
||||||
|
if report is None:
|
||||||
|
plan = plan_from_request(req)
|
||||||
|
console.print(f"[cyan]Collecting diagnostics:[/cyan] {len(plan)} commands")
|
||||||
|
report = await collect_from_plan(session, plan)
|
||||||
|
_handle_collection_report(report)
|
||||||
|
_run_analysis(ai_config, req.issue, report)
|
||||||
|
continue
|
||||||
|
|
||||||
|
console.print(f"[yellow]Unknown command:[/yellow] {command}. Try /help")
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
@@ -139,3 +139,71 @@ def test_collect_success_prints_summary(monkeypatch) -> None: # type: ignore[no
|
|||||||
assert "Collection complete" in result.stdout
|
assert "Collection complete" in result.stdout
|
||||||
assert "kernel: ok" in result.stdout
|
assert "kernel: ok" in result.stdout
|
||||||
assert "journal: ok (truncated)" in result.stdout
|
assert "journal: ok (truncated)" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_collect_then_quit(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
|
_mock_session(monkeypatch)
|
||||||
|
|
||||||
|
async def fake_collect_from_plan(_session, _plan) -> CollectionReport: # type: ignore[no-untyped-def]
|
||||||
|
return CollectionReport(
|
||||||
|
host="ssh.archflux.net",
|
||||||
|
items=[
|
||||||
|
CollectedItem(
|
||||||
|
name="kernel",
|
||||||
|
result=SSHCommandResult(
|
||||||
|
command="uname -a",
|
||||||
|
exit_code=0,
|
||||||
|
stdout="Linux test",
|
||||||
|
stderr="",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = iter(["/collect", "/quit"])
|
||||||
|
|
||||||
|
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
|
||||||
|
monkeypatch.setattr("builtins.input", lambda _prompt: next(commands))
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"apache failed",
|
||||||
|
"--host",
|
||||||
|
"ssh.archflux.net",
|
||||||
|
"--port",
|
||||||
|
"5566",
|
||||||
|
"--no-probe",
|
||||||
|
"--interactive",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Interactive mode" in result.stdout
|
||||||
|
assert "Collection complete" in result.stdout
|
||||||
|
assert "Bye." in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_interactive_unknown_command_prints_hint(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
|
_mock_session(monkeypatch)
|
||||||
|
|
||||||
|
commands = iter(["/wat", "/quit"])
|
||||||
|
monkeypatch.setattr("builtins.input", lambda _prompt: next(commands))
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"apache failed",
|
||||||
|
"--host",
|
||||||
|
"ssh.archflux.net",
|
||||||
|
"--port",
|
||||||
|
"5566",
|
||||||
|
"--no-probe",
|
||||||
|
"--interactive",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Unknown command" in result.stdout
|
||||||
|
|||||||
Reference in New Issue
Block a user