From 31f44d007614990952a0779a693e6220def0f22b Mon Sep 17 00:00:00 2001 From: Bhaskar Gurram Date: Tue, 13 Oct 2026 19:02:07 -0400 Subject: [PATCH] cli: add `provio log --json` (JSON Lines, one session per line) Scripts and CI get one SessionSummary object per line (session_id, records, denied, approved_by_human) instead of parsing the table. The default output is unchanged; a missing ledger fails the same way. Fixes #14 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017bQP5nwJVPMXqMhewzhQv5 Signed-off-by: Bhaskar Gurram --- CHANGELOG.md | 6 ++ README.md | 2 +- crates/provio-cli/src/cmds.rs | 11 +++- crates/provio-cli/src/main.rs | 8 ++- crates/provio-cli/tests/log_json.rs | 91 +++++++++++++++++++++++++++++ docs/INTERFACES.md | 2 +- 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 crates/provio-cli/tests/log_json.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 326b5e6..624b9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The ledger record schema is versioned separately (`schema_version`, see ## [Unreleased] +### Added + +- **`provio log --json`**: one JSON object per session (JSON Lines: + `session_id`, `records`, `denied`, `approved_by_human`) for scripts and CI. + The default table is unchanged. (#14) + ## [0.1.5] — 2026-10-01 ### Added diff --git a/README.md b/README.md index 4943be1..26fa389 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ the same in all three interception modes, and so is the record. |---|---| | `provio run -- ` | wrap an agent process under policy | | `provio proxy --mcp --server -- ` | govern every call to an MCP server | -| `provio log` · `provio show ` | what did my agent actually do last night | +| `provio log [--json]` · `provio show ` | what did my agent actually do last night (`--json`: one session per line, for scripts) | | `provio verify` | is this ledger still the one that was written | | `provio replay --candidate ` | what would this policy have done to last week's run | | `provio policy test` | unit-test rules against recorded fixtures | diff --git a/crates/provio-cli/src/cmds.rs b/crates/provio-cli/src/cmds.rs index 15f3e96..9e47bfe 100644 --- a/crates/provio-cli/src/cmds.rs +++ b/crates/provio-cli/src/cmds.rs @@ -338,7 +338,10 @@ fn summarize(args: &serde_json::Value) -> String { } } /// `provio log` — the answer to "what did my agent do last night". -pub fn log(ledger: &Path) -> Result<()> { +/// +/// With `--json`, prints one [`SessionSummary`] per line (JSON Lines) and +/// nothing else, so scripts can pipe it into `jq` without parsing the table. +pub fn log(ledger: &Path, json: bool) -> Result<()> { if !ledger_present(ledger) { bail!( "no ledger at {} — nothing recorded yet", @@ -347,6 +350,12 @@ pub fn log(ledger: &Path) -> Result<()> { } let sessions: Vec = provio_ledger::sessions(ledger).map_err(|e| anyhow!(e.to_string()))?; + if json { + for s in &sessions { + println!("{}", serde_json::to_string(s)?); + } + return Ok(()); + } let total: u64 = sessions.iter().map(|s| s.records).sum(); let denied: u64 = sessions.iter().map(|s| s.denied).sum(); let human: u64 = sessions.iter().filter(|s| s.approved_by_human).count() as u64; diff --git a/crates/provio-cli/src/main.rs b/crates/provio-cli/src/main.rs index 7e71920..9518aea 100644 --- a/crates/provio-cli/src/main.rs +++ b/crates/provio-cli/src/main.rs @@ -161,7 +161,11 @@ enum Commands { }, /// What did my agent actually do? - Log, + Log { + /// Print one JSON object per session (JSON Lines) instead of the table. + #[arg(long)] + json: bool, + }, /// One decision, in full. Show { call_id: String }, @@ -307,7 +311,7 @@ fn main() -> anyhow::Result<()> { Commands::Mcp { sub } => mcp_pins::run(&ledger, &sub), Commands::Ui(args) => ui::serve(&policy, &ledger, cli.yolo, &args), Commands::Receipt { sub } => receipt::run(&policy, &ledger, &sub), - Commands::Log => cmds::log(&ledger), + Commands::Log { json } => cmds::log(&ledger, json), Commands::Show { call_id } => cmds::show(&ledger, &call_id), Commands::Verify => cmds::verify(&ledger), Commands::Policy { sub } => match sub { diff --git a/crates/provio-cli/tests/log_json.rs b/crates/provio-cli/tests/log_json.rs new file mode 100644 index 0000000..56c35d5 --- /dev/null +++ b/crates/provio-cli/tests/log_json.rs @@ -0,0 +1,91 @@ +//! `provio log --json`: one JSON object per session, for scripts and CI. + +mod check_common; + +use check_common::*; +use serde_json::{json, Value}; + +fn pre(session: &str, id: &str, command: &str) -> Value { + json!({ + "session_id": session, + "turn_id": "turn-1", + "transcript_path": null, + "cwd": "/work/proj", + "hook_event_name": "PreToolUse", + "model": "gpt-5-codex", + "permission_mode": "default", + "tool_name": "Bash", + "tool_input": { "command": command }, + "tool_use_id": id, + }) +} + +fn stdout(out: &std::process::Output) -> String { + assert!( + out.status.success(), + "provio failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn session<'a>(sessions: &'a [Value], needle: &str) -> &'a Value { + sessions + .iter() + .find(|s| s["session_id"].as_str().unwrap().contains(needle)) + .unwrap_or_else(|| panic!("no session {needle} in {sessions:?}")) +} + +#[test] +fn log_json_prints_one_object_per_session() { + let p = Project::new(); + p.hook("codex", &[], &pre("thr-a", "c1", "ls")); + p.hook("codex", &[], &pre("thr-a", "c2", "echo hi")); + p.hook("codex", &[], &pre("thr-b", "c3", "rm -rf /")); + + let text = stdout(&p.provio(&["log", "--json"], "")); + let sessions: Vec = text + .lines() + .map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("not JSON: {l:?}: {e}"))) + .collect(); + assert_eq!(sessions.len(), 2, "{text}"); + + // Every line is a full session summary and nothing else (no table header). + for s in &sessions { + let keys: Vec<&str> = s.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys.len(), 4, "unexpected fields in {s}: {keys:?}"); + assert!(s["session_id"].is_string() && s["records"].is_u64()); + assert!(s["denied"].is_u64() && s["approved_by_human"].is_boolean()); + } + + assert_eq!(session(&sessions, "thr-a")["denied"], 0); + assert_eq!(session(&sessions, "thr-b")["denied"], 1); + assert_eq!(session(&sessions, "thr-b")["approved_by_human"], false); + + // The per-session counts add up to the ledger. + let total: u64 = sessions + .iter() + .map(|s| s["records"].as_u64().unwrap()) + .sum(); + assert_eq!(total as usize, p.records().len()); +} + +#[test] +fn log_without_json_keeps_the_table() { + let p = Project::new(); + p.hook("codex", &[], &pre("thr-a", "c1", "ls")); + let text = stdout(&p.provio(&["log"], "")); + let mut lines = text.lines(); + assert!(lines.next().unwrap().contains("1 sessions"), "{text}"); + assert!(lines.next().unwrap().starts_with("session"), "{text}"); + assert!(serde_json::from_str::(text.lines().last().unwrap()).is_err()); +} + +#[test] +fn log_json_without_a_ledger_fails_like_the_table() { + let p = Project::new(); + let out = p.provio(&["log", "--json"], ""); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("no ledger")); + assert!(out.stdout.is_empty()); +} diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md index 400b9b9..4fc3622 100644 --- a/docs/INTERFACES.md +++ b/docs/INTERFACES.md @@ -250,4 +250,4 @@ Specified in full in [receipts.md](receipts.md#contract-7--receipts-provio_recei ## CLI surface (`provio-cli`) -`provio init [--agent A,B] [--strict] [--global] [--dry-run]` · `provio scan [--days N] [--agent claude-code,codex,gemini] [--packs A,B] [--dir PATH] [--format text|json|markdown]` · `provio test [--tool T --path P --url U --query Q --content C --server S | --call JSON] [--packs A,B] [--json] […]` (exit 0 allow/redact, 2 deny, 3 ask, 1 error) · `provio run [--yolo] [--policy PATH] [--net open|none] [--allow-write PATH]… [--unconfined] [--no-hooks] -- ` · `provio check [--stdio] [--format provio|claude-code|codex|gemini|cursor|windsurf] [--ask deny|defer|ui] [--if-no-policy deny|starter]` · `provio integrate ` · `provio ui [--port N] [--no-open]` · `provio receipt keygen|create|verify|prove|anchor` · `provio proxy --mcp [--transport stdio|http --listen ADDR --upstream URL] [--no-pin]` · `provio mcp pins [--server S]|accept [tool…]|reset ` · `provio log` · `provio show ` · `provio verify [--ledger PATH]` · `provio replay ` · `provio policy test` · `provio policy add ` · `provio doctor` · `provio report [--format html|markdown|json] [--since 12h|RFC3339] [--session ID] [--sign KEY] [--out PATH|-]`. Ledger default path: `.provio/ledger.jsonl` (displayed as `ledger.db` once SQLite is enabled). +`provio init [--agent A,B] [--strict] [--global] [--dry-run]` · `provio scan [--days N] [--agent claude-code,codex,gemini] [--packs A,B] [--dir PATH] [--format text|json|markdown]` · `provio test [--tool T --path P --url U --query Q --content C --server S | --call JSON] [--packs A,B] [--json] […]` (exit 0 allow/redact, 2 deny, 3 ask, 1 error) · `provio run [--yolo] [--policy PATH] [--net open|none] [--allow-write PATH]… [--unconfined] [--no-hooks] -- ` · `provio check [--stdio] [--format provio|claude-code|codex|gemini|cursor|windsurf] [--ask deny|defer|ui] [--if-no-policy deny|starter]` · `provio integrate ` · `provio ui [--port N] [--no-open]` · `provio receipt keygen|create|verify|prove|anchor` · `provio proxy --mcp [--transport stdio|http --listen ADDR --upstream URL] [--no-pin]` · `provio mcp pins [--server S]|accept [tool…]|reset ` · `provio log [--json]` (JSON Lines: one session summary per line) · `provio show ` · `provio verify [--ledger PATH]` · `provio replay ` · `provio policy test` · `provio policy add ` · `provio doctor` · `provio report [--format html|markdown|json] [--since 12h|RFC3339] [--session ID] [--sign KEY] [--out PATH|-]`. Ledger default path: `.provio/ledger.jsonl` (displayed as `ledger.db` once SQLite is enabled).