Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["fs"] }
rust-embed = { version = "8.11.0", features = ["mime-guess"] }
mime_guess = "2.0.5"
tempfile = "3.27.0"

# TUI
ratatui = "0.30.0"
Expand All @@ -104,7 +105,6 @@ base64 = "0.22.1"
pager = "0.16.1"

[dev-dependencies]
tempfile = "3.27.0"
serial_test = "3.4.0"
tokio = { version = "1.50.0", features = [
"rt-multi-thread",
Expand Down
56 changes: 11 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,61 +118,27 @@ Update your `claude_desktop_config.json` as follows:

#### AI Hook Forwarding

Libra can record Claude Code and Gemini CLI sessions as `ai_session` history
objects. The recommended setup is to install Libra's hook forwarding once in
your project, then use your AI CLI normally.
Libra can import Claude Agent SDK managed sessions and provider-side replay
artifacts through the `claude-sdk` command group.

Claude Code:
Run a managed session through the bundled helper:

```bash
libra hooks claude install
libra claude-sdk run --prompt "Inspect src/lib.rs and summarize the bridge state"
```

Gemini CLI:
Sync Claude provider session metadata into Libra snapshots:

```bash
libra hooks gemini install
libra claude-sdk sync-sessions
libra claude-sdk hydrate-session --provider-session-id session-a
libra claude-sdk build-evidence-input --provider-session-id session-a
```

After installation, Libra writes hook settings into the provider's project
config:
The `--cwd` flag controls which project directory Claude SDK queries, but all
artifacts and history are persisted into the current Libra repository.

- Claude Code: `.claude/settings.json`
- Gemini CLI: `.gemini/settings.json`

Those generated entries call the resolved Libra binary with provider lifecycle
subcommands such as `hooks claude <event>` and `hooks gemini <event>`.

Useful follow-up commands:

```bash
# Check whether hooks are installed
libra hooks claude is-installed
libra hooks gemini is-installed

# Remove Libra-managed hooks
libra hooks claude uninstall
libra hooks gemini uninstall
```

By default, install writes the absolute path of the current `libra` binary into
provider hook settings. If you want hooks to call a different local binary, pass
an explicit binary path:

```bash
libra hooks claude install --binary-path "/absolute/path/to/libra"
libra hooks gemini install --binary-path "/absolute/path/to/libra"
```

Provider-specific notes:

- Claude Code supports `--timeout`, for example:
`libra hooks claude install --timeout 15`
- Gemini CLI does **not** support `--timeout`
- Install / uninstall / is-installed must be run inside a Libra repository

Once installed, use Claude Code or Gemini CLI as usual. When a session ends,
Libra persists it as an `ai_session` object that you can inspect later with:
Persisted provider session and evidence artifacts are inspectable with:

```bash
libra cat-file --ai-list ai_session
Expand Down
13 changes: 8 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,15 @@ enum Commands {
Init(command::init::InitArgs),
#[command(about = "Clone a repository into a new directory")]
Clone(command::clone::CloneArgs),
#[command(
name = "claude-sdk",
about = "Run or import Claude Agent SDK managed sessions"
)]
ClaudeSdk(command::claude_sdk::ClaudeSdkArgs),
Comment on lines +75 to +79

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore legacy hooks subcommand compatibility

Removing the hooks CLI entrypoint here breaks already-installed provider hooks, because existing provider configs still invoke libra hooks ... (see src/internal/ai/hooks/providers/claude/settings.rs and src/internal/ai/hooks/providers/gemini/settings.rs). After upgrading, those commands fail as unknown subcommands, which silently stops hook ingestion and leaves users without a working uninstall path. Keep a compatibility hooks command (or a migration shim) until stored hook commands are migrated.

Useful? React with 👍 / 👎.

#[command(about = "Start Libra Code interactive TUI (with background web server)")]
Code(command::code::CodeArgs),
#[command(about = "Connect to Codex app-server via WebSocket")]
AgentCodex(command::agent_codex::AgentCodexArgs),
Comment on lines +75 to 83
#[command(about = "Unified provider hook ingestion and setup")]
Hooks(command::hooks::HooksCommand),

// The rest of the commands require a repository to be present
#[command(about = "Add file contents to the index")]
Expand Down Expand Up @@ -433,7 +436,7 @@ pub async fn parse_async(args: Option<&[&str]>) -> CliResult<()> {
},
};
match &args.command {
Commands::Init(_) | Commands::Clone(_) | Commands::Hooks(_) => {}
Commands::Init(_) | Commands::Clone(_) => {}
// Config global/system scopes don't require a repository
Commands::Config(cfg) if cfg.global || cfg.system => {}
_ => {
Expand All @@ -456,13 +459,13 @@ pub async fn parse_async(args: Option<&[&str]>) -> CliResult<()> {
})?; // restore working directory as original_dir
}
Commands::Clone(args) => command::clone::execute_safe(args).await?, //clone will use init internally,so we don't need to set hash kind here again
Commands::Code(args) => command::code::execute(args).await,
Commands::AgentCodex(args) => command::agent_codex::execute(args)
.await
.map_err(|e| CliError::fatal(e.to_string()))?,
Commands::Hooks(cmd) => command::hooks::execute(cmd)
Commands::ClaudeSdk(args) => command::claude_sdk::execute(args)
.await
.map_err(|e| CliError::fatal(e.to_string()))?,
Commands::Code(args) => command::code::execute(args).await,
Commands::Add(args) => command::add::execute_safe(args).await?,
Commands::Rm(args) => command::remove::execute_safe(args).await?,
Commands::Restore(args) => command::restore::execute_safe(args).await?,
Expand Down
120 changes: 120 additions & 0 deletions src/command/cat_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ const AI_OBJECT_TYPES: &[&str] = &[
"decision",
"snapshot",
"ai_session",
"provider_session",
"evidence_input",
];
const TAG_REF_PREFIX: &str = "refs/tags/";

Expand Down Expand Up @@ -502,6 +504,10 @@ async fn ai_pretty_print(uuid: &str) {
println!("hash: {}", hash);
if type_name == "ai_session" {
print_ai_session_summary(&value);
} else if type_name == "provider_session" {
print_provider_session_summary(&value);
} else if type_name == "evidence_input" {
print_evidence_input_summary(&value);
}
println!("---");
println!(
Expand All @@ -526,6 +532,18 @@ fn print_ai_session_summary(value: &serde_json::Value) {
}
}

fn print_provider_session_summary(value: &serde_json::Value) {
for line in provider_session_summary_lines(value) {
println!("{line}");
}
}

fn print_evidence_input_summary(value: &serde_json::Value) {
for line in evidence_input_summary_lines(value) {
println!("{line}");
}
}

fn ai_session_summary_lines(value: &serde_json::Value) -> Vec<String> {
let mut lines = Vec::new();

Expand Down Expand Up @@ -604,6 +622,108 @@ fn ai_session_summary_lines(value: &serde_json::Value) -> Vec<String> {
lines
}

fn provider_session_summary_lines(value: &serde_json::Value) -> Vec<String> {
let mut lines = Vec::new();

if let Some(schema) = value.get("schema").and_then(serde_json::Value::as_str) {
lines.push(format!("schema: {schema}"));
}
if let Some(provider) = value.get("provider").and_then(serde_json::Value::as_str) {
lines.push(format!("provider: {provider}"));
}
if let Some(object_id) = value.get("objectId").and_then(serde_json::Value::as_str) {
lines.push(format!("object_id: {object_id}"));
}
if let Some(provider_session_id) = value
.get("providerSessionId")
.and_then(serde_json::Value::as_str)
{
lines.push(format!("provider_session_id: {provider_session_id}"));
}
if let Some(summary) = value.get("summary").and_then(serde_json::Value::as_str) {
lines.push(format!("summary: {summary}"));
}
if let Some(cwd) = value.get("cwd").and_then(serde_json::Value::as_str) {
lines.push(format!("cwd: {cwd}"));
}
if let Some(message_sync) = value.get("messageSync") {
if let Some(message_count) = message_sync
.get("messageCount")
.and_then(serde_json::Value::as_u64)
{
lines.push(format!("message_count: {message_count}"));
}
if let Some(first_kind) = message_sync
.get("firstMessageKind")
.and_then(serde_json::Value::as_str)
{
lines.push(format!("first_message_kind: {first_kind}"));
}
if let Some(last_kind) = message_sync
.get("lastMessageKind")
.and_then(serde_json::Value::as_str)
{
lines.push(format!("last_message_kind: {last_kind}"));
}
}

lines
}

fn evidence_input_summary_lines(value: &serde_json::Value) -> Vec<String> {
let mut lines = Vec::new();

if let Some(schema) = value.get("schema").and_then(serde_json::Value::as_str) {
lines.push(format!("schema: {schema}"));
}
if let Some(provider) = value.get("provider").and_then(serde_json::Value::as_str) {
lines.push(format!("provider: {provider}"));
}
if let Some(object_id) = value.get("objectId").and_then(serde_json::Value::as_str) {
lines.push(format!("object_id: {object_id}"));
}
if let Some(provider_session_id) = value
.get("providerSessionId")
.and_then(serde_json::Value::as_str)
{
lines.push(format!("provider_session_id: {provider_session_id}"));
}
if let Some(summary) = value.get("summary").and_then(serde_json::Value::as_str) {
lines.push(format!("summary: {summary}"));
}
if let Some(message_count) = value
.get("messageOverview")
.and_then(|overview| overview.get("messageCount"))
.and_then(serde_json::Value::as_u64)
{
lines.push(format!("message_count: {message_count}"));
}
if let Some(assistant_count) = value
.get("contentOverview")
.and_then(|overview| overview.get("assistantMessageCount"))
.and_then(serde_json::Value::as_u64)
{
lines.push(format!("assistant_message_count: {assistant_count}"));
}
if let Some(tool_count) = value
.get("contentOverview")
.and_then(|overview| overview.get("observedTools"))
.and_then(serde_json::Value::as_object)
.map(|tools| tools.len())
{
lines.push(format!("observed_tool_count: {tool_count}"));
}
if let Some(has_structured_output) = value
.get("runtimeSignals")
.and_then(|signals| signals.get("hasStructuredOutput"))
.and_then(serde_json::Value::as_bool)
{
lines.push(format!("has_structured_output: {has_structured_output}"));
}

lines
}

/// Print the AI object type for a UUID.
async fn ai_show_type(uuid: &str) {
let hm = build_history_manager().await;
Expand Down
Loading
Loading