From 2d8a5a66cabf054e4335b0e4a5cb19d668b2669b Mon Sep 17 00:00:00 2001 From: zphinx Date: Mon, 11 May 2026 21:54:21 +0200 Subject: [PATCH] feat(cli): add clean analysis export with markdown/json output --- CHANGELOG.md | 5 +++ README.md | 22 ++++++++- ROADMAP.md | 2 +- docs/ARCHITECTURE.md | 2 +- src/tai/__init__.py | 2 +- src/tai/cli.py | 77 ++++++++++++++++++++++++++++++-- tests/test_cli.py | 103 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 206 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6f743..3c96a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,20 @@ ______________________________________________________________________ - `--session-memory` option on `tai run` - prior-session retrieval injected into analysis/follow-up prompts - final response indexing at session end +- `--output-file` option on `tai run` to persist final AI analysis output as Markdown +- `--output-format markdown|json` for `--output-file` exports - Planner enhancements for broader service detection: - generic service candidate extraction from free text - package presence probes in plans (`rpm -q` and `dpkg-query -W`) - SSH read-only allowlist expanded to permit package presence commands (`rpm`, `dpkg-query`) - Session memory tests in `tests/test_session_store.py` +- CLI test coverage for analysis output file writing (`tests/test_cli.py`) +- CLI test coverage for JSON export and ANSI stripping in written output (`tests/test_cli.py`) ### Changed - Documentation alignment updates in README and ROADMAP to reflect implemented session memory and package-presence capabilities. +- Package version metadata alignment: `src/tai/__init__.py` now matches project version `0.4.0`. ______________________________________________________________________ diff --git a/README.md b/README.md index 81e74c7..f6f5dc9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The tool may suggest remediation commands in output, but does not execute them. - Live probe mode (`uname -a`) - Diagnostics collection mode - AI analysis mode +- Optional analysis export via `--output-file ` (`--output-format markdown|json`) - Interactive loop with `/collect`, `/analyze`, `/help`, `/quit` ### AI and Prompting @@ -164,6 +165,25 @@ tai run "docker daemon keeps failing" \ --runbooks ~/.tai/runbooks ``` +### Write Analysis to File + +```bash +tai run "sshd authentication failed" \ + --host bastion01 \ + --collect --analyze \ + --output-file ./reports/sshd-analysis.md +``` + +JSON export: + +```bash +tai run "sshd authentication failed" \ + --host bastion01 \ + --collect --analyze \ + --output-file ./reports/sshd-analysis.json \ + --output-format json +``` + ## Runbook Workflow 1. Write Markdown runbooks in `runbooks/` with frontmatter keys: `service`, `symptoms`, `tags`. @@ -192,7 +212,7 @@ pytest tests/test_plan.py tests/test_ai.py tests/test_cli.py ## Known Limits - Deep service-specific probes (known binary/config/package aliases) are richer for recognized services than generic service names. -- Session memory is available via `--session-memory`, but dedicated history UX commands (`tai history`, `/history`) are not implemented yet. +- Clipboard export is intentionally not implemented. ## Changelog and Roadmap diff --git a/ROADMAP.md b/ROADMAP.md index bc2485f..c5dff0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,7 +101,7 @@ Polish the interface for real-world use. - [x] Design CLI interface with run command, interactive prompts, and runbook subcommands - [x] Implement structured output sections (Root Cause, Evidence, Recommended Actions) - [x] Add RAG debug mode (`--rag-debug`) showing retrieval scores -- [ ] Support output to file or clipboard +- [x] Support output to file (`--output-file`) - [x] Provide comprehensive `--help` command documentation via Typer options ______________________________________________________________________ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac0cbee..fa2be9e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -60,7 +60,7 @@ This document describes tai's current runtime architecture, module responsibilit - Tier 1 (implemented): in-memory semantic retrieval over diagnostic chunks - Tier 2 (implemented): persistent semantic retrieval over runbook corpus -- Tier 3 (pending): persistent retrieval over prior sessions +- Tier 3 (implemented core): persistent retrieval over prior sessions (dedicated UX commands pending) ## Safety Boundaries diff --git a/src/tai/__init__.py b/src/tai/__init__.py index 42d8357..75dd0ae 100644 --- a/src/tai/__init__.py +++ b/src/tai/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.0" +__version__ = "0.4.0" diff --git a/src/tai/cli.py b/src/tai/cli.py index 7029e66..d88a54d 100644 --- a/src/tai/cli.py +++ b/src/tai/cli.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import json +import re import sys from time import perf_counter from typing import Annotated @@ -201,6 +203,21 @@ def run( help="Optional JSONL file path to log AI and session output.", ), ] = None, + output_file: Annotated[ + str | None, + typer.Option( + "--output-file", + help="Optional Markdown file path to write final AI analysis output.", + ), + ] = None, + output_format: Annotated[ + str, + typer.Option( + "--output-format", + help="Output format for --output-file: markdown or json.", + case_sensitive=False, + ), + ] = "markdown", no_rag: Annotated[ bool, typer.Option( @@ -307,7 +324,7 @@ def run( console.print(f"[yellow]Session memory unavailable:[/yellow] {exc}") try: - asyncio.run( + final_response = asyncio.run( _async_main( config, req, @@ -323,6 +340,18 @@ def run( logger=logger, ) ) + if output_file is not None: + if final_response is None: + console.print("[yellow]No AI analysis output available to write.[/yellow]") + else: + _write_analysis_output( + output_file, + final_response, + output_format=output_format, + issue=req.issue, + host=req.host, + model=model, + ) except typer.Exit: raise except TimeoutError as exc: @@ -347,7 +376,7 @@ async def _async_main( runbook_store: RunbookStore | None, session_store: SessionStore | None, logger: SessionLogger | None, -) -> None: +) -> str | None: """Open a single SSH session and run probe / collection / analysis through it.""" client = SSHClient(config) if logger is not None: @@ -422,6 +451,7 @@ async def _async_main( final_response = interactive_response or initial_response if session_store is not None and final_response: _index_session_memory(session_store, ai_config, req, final_response, logger=logger) + return final_response async def _interactive_loop( @@ -641,7 +671,6 @@ async def _interactive_loop( logger.log_event("interactive_followup", {"question": "/analyze"}) last_response = response continue - continue if report is None: plan = plan_from_request(req) @@ -1076,6 +1105,48 @@ def _format_history_markdown( return "\n".join(lines).rstrip() + "\n" +def _strip_ansi(text: str) -> str: + """Remove ANSI escape sequences to keep exported files plain and portable.""" + ansi_pattern = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + return ansi_pattern.sub("", text) + + +def _write_analysis_output( + file_path: str, + content: str, + *, + output_format: str, + issue: str, + host: str, + model: str, +) -> None: + """Persist final AI analysis output to a file in markdown or json format.""" + from pathlib import Path + + fmt = output_format.strip().lower() + if fmt not in {"markdown", "json"}: + console.print(f"[red]Invalid --output-format:[/red] {output_format}") + raise typer.Exit(code=2) + + clean_content = _strip_ansi(content).rstrip() + "\n" + + path = Path(file_path).expanduser().resolve() + path.parent.mkdir(parents=True, exist_ok=True) + + if fmt == "json": + payload = { + "issue": issue, + "host": host, + "model": model, + "analysis": clean_content.rstrip("\n"), + } + path.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + else: + path.write_text(clean_content, encoding="utf-8") + + console.print(f"[green]✓ Wrote analysis output[/green] {path}") + + # --------------------------------------------------------------------------- # runbooks sub-app # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 657fd2a..c68b3af 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -444,3 +445,105 @@ def test_interactive_history_without_store_shows_hint(monkeypatch) -> None: # t assert result.exit_code == 0 assert "Session memory is disabled" in result.stdout + + +def test_run_analyze_writes_output_file(monkeypatch, tmp_path: Path) -> 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="", + ), + ), + ], + ) + + monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan) + monkeypatch.setattr( + "tai.cli.AIClient.complete", + lambda *_args, **_kwargs: SimpleNamespace(content="Root Cause\n\nEvidence\n\nRecommended Actions"), + ) + + output_path = tmp_path / "analysis.md" + runner = CliRunner() + result = runner.invoke( + app, + [ + "run", "apache failed", + "--host", + "ssh.archflux.net", + "--port", + "5566", + "--no-probe", + "--analyze", + "--output-file", + str(output_path), + ], + ) + + assert result.exit_code == 0 + assert "Wrote analysis output" in result.stdout + assert output_path.exists() + assert "Root Cause" in output_path.read_text(encoding="utf-8") + + +def test_run_analyze_writes_json_output_and_strips_ansi(monkeypatch, tmp_path: Path) -> 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="", + ), + ), + ], + ) + + monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan) + monkeypatch.setattr( + "tai.cli.AIClient.complete", + lambda *_args, **_kwargs: SimpleNamespace( + content="\x1b[31mRoot Cause\x1b[0m\n\nEvidence\n\nRecommended Actions" + ), + ) + + output_path = tmp_path / "analysis.json" + runner = CliRunner() + result = runner.invoke( + app, + [ + "run", "apache failed", + "--host", + "ssh.archflux.net", + "--port", + "5566", + "--no-probe", + "--analyze", + "--output-file", + str(output_path), + "--output-format", + "json", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(output_path.read_text(encoding="utf-8")) + assert payload["issue"] == "apache failed" + assert payload["host"] == "ssh.archflux.net" + assert "Root Cause" in payload["analysis"] + assert "\u001b" not in payload["analysis"]