feat(cli): add clean analysis export with markdown/json output
This commit is contained in:
@@ -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`.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
|
||||
22
README.md
22
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 <path>` (`--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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user