From c05c491d7722afd0cb0d252480a9327521878d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Fri, 10 Apr 2026 16:44:56 +0200 Subject: [PATCH] feat(cli): cover every OpenAPI endpoint and request-body param (#968) Wires the Rust CLI up to every endpoint exposed by the Hindsight OpenAPI spec and adds CI enforcement so new endpoints or new request-body fields cannot slip in without matching CLI coverage. Endpoints - New `hindsight webhook {list,create,update,delete,deliveries}` and `hindsight audit {list,stats}` subcommands. - `hindsight bank` gains `set-disposition`, `consolidation-recover`, `export-template`, `import-template`, `template-schema`. - `hindsight memory` gains `history` and per-memory `clear-observations`. - `hindsight document update`, `hindsight operation retry` added. - Brings CLI coverage from 46/62 to 62/62 operations. Request-body parameters - Expose missing flags that the CLI was silently hardcoding: directive `--priority`; mental-model `--tags` / `--max-tokens` / `--trigger-refresh-after-consolidation`; recall `--query-timestamp`; reflect `--fact-types` / `--exclude-mental-models` / `--exclude-mental-model-ids`; retain `--document-tags`. CI enforcement - New `cli-coverage-check` entry point in `hindsight-dev` parses openapi.json and verifies that (a) every operationId is called from hindsight-cli/src/ (the progenitor client method names match the operationId), and (b) every request-body property is present in main.rs as a clap field or `long = "..."` attribute. - Intentional non-exposures live in `hindsight-cli/.openapi-coverage.toml` under `[skip]` / `[fields.]` with a reason each (38 documented field skips for flattened structs, nested structs, or fields surfaced via a different subcommand). - New `check-cli-coverage` job in .github/workflows/test.yml, triggered on cli/core/dev/ci path changes, runs the script on every PR. - smoke-test.sh exercises the new webhook / audit / bank-template / set-disposition / consolidation-recover commands. --- .github/workflows/test.yml | 35 + hindsight-cli/.openapi-coverage.toml | 90 ++ hindsight-cli/smoke-test.sh | 35 + hindsight-cli/src/api.rs | 692 +++++++++++-- hindsight-cli/src/commands/audit.rs | 118 +++ hindsight-cli/src/commands/bank.rs | 388 ++++++- hindsight-cli/src/commands/directive.rs | 14 +- hindsight-cli/src/commands/document.rs | 88 +- hindsight-cli/src/commands/memory.rs | 202 +++- hindsight-cli/src/commands/mental_model.rs | 61 +- hindsight-cli/src/commands/mod.rs | 4 +- hindsight-cli/src/commands/operation.rs | 45 +- hindsight-cli/src/commands/webhook.rs | 249 +++++ hindsight-cli/src/main.rs | 951 ++++++++++++++++-- .../hindsight_dev/cli_coverage_check.py | 304 ++++++ hindsight-dev/pyproject.toml | 1 + 16 files changed, 2990 insertions(+), 287 deletions(-) create mode 100644 hindsight-cli/.openapi-coverage.toml create mode 100644 hindsight-cli/src/commands/audit.rs create mode 100644 hindsight-cli/src/commands/webhook.rs create mode 100644 hindsight-dev/hindsight_dev/cli_coverage_check.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b0c6e45..22aba551 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2510,6 +2510,40 @@ jobs: cd hindsight-dev uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json + check-cli-coverage: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.core == 'true' || + needs.detect-changes.outputs.cli == 'true' || + needs.detect-changes.outputs.dev == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + + - name: Install hindsight-dev dependencies + run: | + cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match + + - name: Check CLI covers every OpenAPI operation + run: | + cd hindsight-dev + uv run cli-coverage-check + # Report CI status back to the PR for pull_request_review events. # GitHub does not automatically link pull_request_review check runs to the PR, # so we create a commit status on the PR head SHA and post a comment. @@ -2553,6 +2587,7 @@ jobs: - test-upgrade - verify-generated-files - check-openapi-compatibility + - check-cli-coverage runs-on: ubuntu-latest permissions: statuses: write diff --git a/hindsight-cli/.openapi-coverage.toml b/hindsight-cli/.openapi-coverage.toml new file mode 100644 index 00000000..5d4ffa80 --- /dev/null +++ b/hindsight-cli/.openapi-coverage.toml @@ -0,0 +1,90 @@ +# Hindsight CLI ↔ OpenAPI coverage manifest. +# +# The CI job `cli-coverage-check` (see hindsight-dev/hindsight_dev/cli_coverage_check.py) +# enforces both endpoint-level and parameter-level coverage: +# +# 1. Every operationId in hindsight-docs/static/openapi.json must be either +# called from hindsight-cli/src/**/*.rs (the progenitor-generated client +# methods are named identically to the operationId) or listed under +# [skip] below with a reason. +# +# 2. For each operation with a JSON request body, every top-level property +# of that body must be either present in hindsight-cli/src/main.rs as a +# clap command variant field (`field_name: `) or a `long = "..."` +# attribute, OR listed under [fields.] below with a reason. +# +# Skip entries should explain *why* the field/operation is not exposed (e.g. +# flattened into several CLI flags, complex nested struct, available via a +# different subcommand). + +# --------------------------------------------------------------------------- +# Operation-level skips +# --------------------------------------------------------------------------- +[skip] +# (empty — every operation is currently wired) + +# --------------------------------------------------------------------------- +# Per-operation parameter skips +# --------------------------------------------------------------------------- + +[fields.add_bank_background] +update_disposition = "Exposed inverted as --no-update-disposition on `bank background`." + +[fields.create_or_update_bank] +disposition = "Flattened into --skepticism / --literalism / --empathy on `bank create`." +disposition_skepticism = "Covered by --skepticism; the flat form is an API alias." +disposition_literalism = "Covered by --literalism; the flat form is an API alias." +disposition_empathy = "Covered by --empathy; the flat form is an API alias." +background = "Set via the dedicated `bank background` subcommand." +reflect_mission = "Set via `bank set-config --reflect-mission`." +retain_mission = "Set via `bank set-config --retain-mission`." +retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`." +retain_custom_instructions = "Set via `bank set-config` (hierarchical config)." +retain_chunk_size = "Set via `bank set-config` (hierarchical config)." +enable_observations = "Set via `bank set-config` (hierarchical config)." +observations_mission = "Set via `bank set-config --observations-mission`." + +[fields.update_bank] +disposition = "Flattened into --skepticism / --literalism / --empathy on `bank update`." +disposition_skepticism = "Covered by --skepticism; the flat form is an API alias." +disposition_literalism = "Covered by --literalism; the flat form is an API alias." +disposition_empathy = "Covered by --empathy; the flat form is an API alias." +background = "Set via the dedicated `bank background` subcommand." +reflect_mission = "Set via `bank set-config --reflect-mission`." +retain_mission = "Set via `bank set-config --retain-mission`." +retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`." +retain_custom_instructions = "Set via `bank set-config` (hierarchical config)." +retain_chunk_size = "Set via `bank set-config` (hierarchical config)." +enable_observations = "Set via `bank set-config` (hierarchical config)." +observations_mission = "Set via `bank set-config --observations-mission`." + +[fields.update_bank_disposition] +disposition = "Flattened into --skepticism / --literalism / --empathy on `bank set-disposition`." + +[fields.update_bank_config] +updates = "Flattened into per-setting flags (--llm-provider, --llm-model, etc) on `bank set-config`." + +[fields.create_webhook] +http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed." + +[fields.update_webhook] +http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed." + +[fields.recall_memories] +types = "CLI exposes this as --fact-type (the schema property is named `types` but it holds fact types)." +include = "Flattened into --include-chunks / --chunk-max-tokens (facts are always included)." +tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases." + +[fields.reflect] +include = "Flattened into --include-facts and related flags." +response_schema = "Exposed as --schema (path to a JSON schema file)." +tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases." + +[fields.retain_memories] +items = "Constructed from the single positional content argument on `memory retain`." + +[fields.create_mental_model] +trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model create` (other nested trigger fields like fact_types/tag_groups are not exposed yet)." + +[fields.update_mental_model] +trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model update` (other nested trigger fields like fact_types/tag_groups are not exposed yet)." diff --git a/hindsight-cli/smoke-test.sh b/hindsight-cli/smoke-test.sh index 55efca1b..f80b11fe 100755 --- a/hindsight-cli/smoke-test.sh +++ b/hindsight-cli/smoke-test.sh @@ -118,6 +118,41 @@ run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1 # Test 15: List operations run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1 +# --- Coverage-critical commands (added to ensure CLI exercises every endpoint) --- + +# Test: Set disposition directly (PUT /profile) +run_test "bank set-disposition" "$HINDSIGHT_CLI" bank set-disposition "$TEST_BANK" \ + --skepticism 3 --literalism 3 --empathy 3 || FAILED=1 + +# Test: Recover consolidation (no-op when nothing stalled, but exercises the endpoint) +run_test "bank consolidation-recover" "$HINDSIGHT_CLI" bank consolidation-recover "$TEST_BANK" || FAILED=1 + +# Test: Bank template schema +run_test "bank template-schema" "$HINDSIGHT_CLI" bank template-schema -o json || FAILED=1 + +# Test: Export bank template +run_test "bank export-template" "$HINDSIGHT_CLI" bank export-template "$TEST_BANK" -o json || FAILED=1 + +# Test: Audit log list + stats +run_test "audit list" "$HINDSIGHT_CLI" audit list "$TEST_BANK" -o json || FAILED=1 +run_test "audit stats" "$HINDSIGHT_CLI" audit stats "$TEST_BANK" -o json || FAILED=1 + +# Test: Webhook lifecycle (list / create / update / deliveries / delete) +run_test "webhook list (empty)" "$HINDSIGHT_CLI" webhook list "$TEST_BANK" -o json || FAILED=1 + +WEBHOOK_OUT=$("$HINDSIGHT_CLI" webhook create "$TEST_BANK" https://example.invalid/hook -o json 2>/tmp/cli-test-output.txt || true) +if echo "$WEBHOOK_OUT" | grep -q '"id"'; then + echo "Testing: webhook create... OK" + WEBHOOK_ID=$(echo "$WEBHOOK_OUT" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + run_test "webhook update" "$HINDSIGHT_CLI" webhook update "$TEST_BANK" "$WEBHOOK_ID" --enabled false || FAILED=1 + run_test "webhook deliveries" "$HINDSIGHT_CLI" webhook deliveries "$TEST_BANK" "$WEBHOOK_ID" -o json || FAILED=1 + run_test "webhook delete" "$HINDSIGHT_CLI" webhook delete "$TEST_BANK" "$WEBHOOK_ID" -y || FAILED=1 +else + echo "Testing: webhook create... FAILED" + cat /tmp/cli-test-output.txt | sed 's/^/ /' + FAILED=1 +fi + # Test 16: Delete bank run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1 diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 2519059c..bd0db4ce 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -4,8 +4,8 @@ //! to bridge from the CLI's synchronous code to the async API client. use anyhow::Result; -use hindsight_client::Client as AsyncClient; pub use hindsight_client::types; +use hindsight_client::Client as AsyncClient; use serde::{Deserialize, Serialize}; use serde_json; use std::collections::HashMap; @@ -76,8 +76,8 @@ impl ApiClient { let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?); // Create HTTP client with 2-minute timeout and optional auth header - let mut client_builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)); + let mut client_builder = + reqwest::Client::builder().timeout(std::time::Duration::from_secs(120)); if let Some(key) = api_key { let mut headers = reqwest::header::HeaderMap::new(); @@ -92,7 +92,12 @@ impl ApiClient { let http_client = client_builder.build()?; let client = AsyncClient::new_with_client(&base_url, http_client.clone()); - Ok(ApiClient { client, http_client, base_url, runtime }) + Ok(ApiClient { + client, + http_client, + base_url, + runtime, + }) } pub fn list_agents(&self, _verbose: bool) -> Result> { @@ -102,7 +107,11 @@ impl ApiClient { }) } - pub fn get_profile(&self, agent_id: &str, _verbose: bool) -> Result { + pub fn get_profile( + &self, + agent_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.get_bank_profile(agent_id, None).await?; Ok(response.into_inner()) @@ -120,7 +129,12 @@ impl ApiClient { }) } - pub fn update_agent_name(&self, agent_id: &str, name: &str, _verbose: bool) -> Result { + pub fn update_agent_name( + &self, + agent_id: &str, + name: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let request = types::CreateBankRequest { name: Some(name.to_string()), @@ -129,25 +143,45 @@ impl ApiClient { disposition: None, ..Default::default() }; - let response = self.client.create_or_update_bank(agent_id, None, &request).await?; + let response = self + .client + .create_or_update_bank(agent_id, None, &request) + .await?; Ok(response.into_inner()) }) } - pub fn add_background(&self, agent_id: &str, content: &str, update_disposition: bool, _verbose: bool) -> Result { + pub fn add_background( + &self, + agent_id: &str, + content: &str, + update_disposition: bool, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let request = types::AddBackgroundRequest { content: content.to_string(), update_disposition, }; - let response = self.client.add_bank_background(agent_id, None, &request).await?; + let response = self + .client + .add_bank_background(agent_id, None, &request) + .await?; Ok(response.into_inner()) }) } - pub fn recall(&self, agent_id: &str, request: &types::RecallRequest, verbose: bool) -> Result { + pub fn recall( + &self, + agent_id: &str, + request: &types::RecallRequest, + verbose: bool, + ) -> Result { if verbose { - eprintln!("Request body: {}", serde_json::to_string_pretty(request).unwrap_or_default()); + eprintln!( + "Request body: {}", + serde_json::to_string_pretty(request).unwrap_or_default() + ); } self.runtime.block_on(async { let response = self.client.recall_memories(agent_id, None, request).await?; @@ -155,14 +189,25 @@ impl ApiClient { }) } - pub fn reflect(&self, agent_id: &str, request: &types::ReflectRequest, _verbose: bool) -> Result { + pub fn reflect( + &self, + agent_id: &str, + request: &types::ReflectRequest, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.reflect(agent_id, None, request).await?; Ok(response.into_inner()) }) } - pub fn retain(&self, agent_id: &str, request: &types::RetainRequest, _async_mode: bool, _verbose: bool) -> Result { + pub fn retain( + &self, + agent_id: &str, + request: &types::RetainRequest, + _async_mode: bool, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.retain_memories(agent_id, None, request).await?; let result = response.into_inner(); @@ -186,7 +231,10 @@ impl ApiClient { verbose: bool, ) -> Result { self.runtime.block_on(async { - let url = format!("{}/v1/default/banks/{}/files/retain", self.base_url, bank_id); + let url = format!( + "{}/v1/default/banks/{}/files/retain", + self.base_url, bank_id + ); let files_metadata: Vec = files .iter() @@ -210,8 +258,8 @@ impl ApiClient { "files_metadata": files_metadata, }); - let mut form = reqwest::multipart::Form::new() - .text("request", request_json.to_string()); + let mut form = + reqwest::multipart::Form::new().text("request", request_json.to_string()); for (filename, content) in files { let part = reqwest::multipart::Part::bytes(content) @@ -239,10 +287,18 @@ impl ApiClient { /// Poll an operation until it completes or fails. /// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error. - pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option)> { + pub fn poll_operation( + &self, + agent_id: &str, + operation_id: &str, + verbose: bool, + ) -> Result<(bool, Option)> { self.runtime.block_on(async { loop { - let response = self.client.list_operations(agent_id, None, None, None, None, None).await?; + let response = self + .client + .list_operations(agent_id, None, None, None, None, None) + .await?; let ops = response.into_inner(); // Find our operation @@ -267,7 +323,10 @@ impl ApiClient { } _ => { // Unknown status, treat as failed - return Ok((false, Some(format!("Unknown status: {}", operation.status)))); + return Ok(( + false, + Some(format!("Unknown status: {}", operation.status)), + )); } } } @@ -280,43 +339,82 @@ impl ApiClient { }) } - pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result { + pub fn delete_memory( + &self, + _agent_id: &str, + _unit_id: &str, + _verbose: bool, + ) -> Result { // Note: Individual memory deletion is no longer supported in the API anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.") } - pub fn clear_memories(&self, agent_id: &str, fact_type: Option<&str>, _verbose: bool) -> Result { + pub fn clear_memories( + &self, + agent_id: &str, + fact_type: Option<&str>, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.clear_bank_memories(agent_id, None, Some(fact_type)).await?; + let response = self + .client + .clear_bank_memories(agent_id, None, Some(fact_type)) + .await?; Ok(response.into_inner()) }) } - pub fn list_documents(&self, agent_id: &str, q: Option<&str>, limit: Option, offset: Option, _verbose: bool) -> Result { + pub fn list_documents( + &self, + agent_id: &str, + q: Option<&str>, + limit: Option, + offset: Option, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.list_documents( - agent_id, - limit.map(|l| l as i64), - offset.map(|o| o as i64), - q, - None, - None, - None, - ).await?; + let response = self + .client + .list_documents( + agent_id, + limit.map(|l| l as i64), + offset.map(|o| o as i64), + q, + None, + None, + None, + ) + .await?; Ok(response.into_inner()) }) } - pub fn get_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result { + pub fn get_document( + &self, + agent_id: &str, + document_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.get_document(agent_id, document_id, None).await?; + let response = self + .client + .get_document(agent_id, document_id, None) + .await?; Ok(response.into_inner()) }) } - pub fn delete_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result { + pub fn delete_document( + &self, + agent_id: &str, + document_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.delete_document(agent_id, document_id, None).await?; + let response = self + .client + .delete_document(agent_id, document_id, None) + .await?; let value = response.into_inner(); // Convert typed response to DeleteResponse Ok(types::DeleteResponse { @@ -329,7 +427,10 @@ impl ApiClient { pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result { self.runtime.block_on(async { - let response = self.client.list_operations(agent_id, None, None, None, None, None).await?; + let response = self + .client + .list_operations(agent_id, None, None, None, None, None) + .await?; let value = response.into_inner(); // Convert to JSON Value first, then parse into our type let json_value = serde_json::to_value(&value)?; @@ -338,9 +439,17 @@ impl ApiClient { }) } - pub fn cancel_operation(&self, agent_id: &str, operation_id: &str, _verbose: bool) -> Result { + pub fn cancel_operation( + &self, + agent_id: &str, + operation_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.cancel_operation(agent_id, operation_id, None).await?; + let response = self + .client + .cancel_operation(agent_id, operation_id, None) + .await?; let value = response.into_inner(); // Convert typed response to DeleteResponse Ok(types::DeleteResponse { @@ -351,30 +460,63 @@ impl ApiClient { }) } - pub fn list_memories(&self, bank_id: &str, type_filter: Option<&str>, q: Option<&str>, limit: Option, offset: Option, _verbose: bool) -> Result { + pub fn list_memories( + &self, + bank_id: &str, + type_filter: Option<&str>, + q: Option<&str>, + limit: Option, + offset: Option, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.list_memories(bank_id, limit, offset, q, type_filter, None).await?; + let response = self + .client + .list_memories(bank_id, limit, offset, q, type_filter, None) + .await?; Ok(response.into_inner()) }) } - pub fn list_entities(&self, bank_id: &str, limit: Option, offset: Option, _verbose: bool) -> Result { + pub fn list_entities( + &self, + bank_id: &str, + limit: Option, + offset: Option, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.list_entities(bank_id, limit, offset, None).await?; + let response = self + .client + .list_entities(bank_id, limit, offset, None) + .await?; Ok(response.into_inner()) }) } - pub fn get_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result { + pub fn get_entity( + &self, + bank_id: &str, + entity_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.get_entity(bank_id, entity_id, None).await?; Ok(response.into_inner()) }) } - pub fn regenerate_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result { + pub fn regenerate_entity( + &self, + bank_id: &str, + entity_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.regenerate_entity_observations(bank_id, entity_id, None).await?; + let response = self + .client + .regenerate_entity_observations(bank_id, entity_id, None) + .await?; Ok(response.into_inner()) }) } @@ -394,7 +536,12 @@ impl ApiClient { impl ApiClient { // --- Memory Methods --- - pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result { + pub fn get_memory( + &self, + bank_id: &str, + memory_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.get_memory(bank_id, memory_id, None).await?; Ok(response.into_inner()) @@ -410,7 +557,10 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.create_or_update_bank(bank_id, None, request).await?; + let response = self + .client + .create_or_update_bank(bank_id, None, request) + .await?; Ok(response.into_inner()) }) } @@ -454,7 +604,10 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.get_graph(bank_id, limit, type_filter, None, None, None, None).await?; + let response = self + .client + .get_graph(bank_id, limit, type_filter, None, None, None, None) + .await?; Ok(response.into_inner()) }) } @@ -478,9 +631,15 @@ impl ApiClient { ) -> Result { self.runtime.block_on(async { // Convert HashMap to serde_json::Map - let updates_map: serde_json::Map = updates.into_iter().collect(); - let request = types::BankConfigUpdate { updates: updates_map }; - let response = self.client.update_bank_config(bank_id, None, &request).await?; + let updates_map: serde_json::Map = + updates.into_iter().collect(); + let request = types::BankConfigUpdate { + updates: updates_map, + }; + let response = self + .client + .update_bank_config(bank_id, None, &request) + .await?; Ok(response.into_inner()) }) } @@ -507,7 +666,10 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.list_tags(bank_id, limit, offset, q, None).await?; + let response = self + .client + .list_tags(bank_id, limit, offset, q, None) + .await?; Ok(response.into_inner()) }) } @@ -523,9 +685,17 @@ impl ApiClient { // --- Operation Methods --- - pub fn get_operation(&self, bank_id: &str, operation_id: &str, _verbose: bool) -> Result { + pub fn get_operation( + &self, + bank_id: &str, + operation_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.get_operation_status(bank_id, operation_id, None).await?; + let response = self + .client + .get_operation_status(bank_id, operation_id, None) + .await?; Ok(response.into_inner()) }) } @@ -548,16 +718,31 @@ impl ApiClient { // --- Mental Model Methods --- - pub fn list_mental_models(&self, bank_id: &str, _verbose: bool) -> Result { + pub fn list_mental_models( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.list_mental_models(bank_id, None, None, None, None, None, None).await?; + let response = self + .client + .list_mental_models(bank_id, None, None, None, None, None, None) + .await?; Ok(response.into_inner()) }) } - pub fn get_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result { + pub fn get_mental_model( + &self, + bank_id: &str, + mental_model_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.get_mental_model(bank_id, mental_model_id, None, None).await?; + let response = self + .client + .get_mental_model(bank_id, mental_model_id, None, None) + .await?; Ok(response.into_inner()) }) } @@ -569,7 +754,10 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.create_mental_model(bank_id, None, request).await?; + let response = self + .client + .create_mental_model(bank_id, None, request) + .await?; Ok(response.into_inner()) }) } @@ -582,44 +770,86 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.update_mental_model(bank_id, mental_model_id, None, request).await?; + let response = self + .client + .update_mental_model(bank_id, mental_model_id, None, request) + .await?; Ok(response.into_inner()) }) } - pub fn delete_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result { + pub fn delete_mental_model( + &self, + bank_id: &str, + mental_model_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.delete_mental_model(bank_id, mental_model_id, None).await?; + let response = self + .client + .delete_mental_model(bank_id, mental_model_id, None) + .await?; Ok(response.into_inner()) }) } - pub fn refresh_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result { + pub fn refresh_mental_model( + &self, + bank_id: &str, + mental_model_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.refresh_mental_model(bank_id, mental_model_id, None).await?; + let response = self + .client + .refresh_mental_model(bank_id, mental_model_id, None) + .await?; Ok(response.into_inner()) }) } - pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result { + pub fn get_mental_model_history( + &self, + bank_id: &str, + mental_model_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?; + let response = self + .client + .get_mental_model_history(bank_id, mental_model_id, None) + .await?; Ok(response.into_inner()) }) } // --- Directive Methods --- - pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result { + pub fn list_directives( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.list_directives(bank_id, None, None, None, None, None, None).await?; + let response = self + .client + .list_directives(bank_id, None, None, None, None, None, None) + .await?; Ok(response.into_inner()) }) } - pub fn get_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result { + pub fn get_directive( + &self, + bank_id: &str, + directive_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.get_directive(bank_id, directive_id, None).await?; + let response = self + .client + .get_directive(bank_id, directive_id, None) + .await?; Ok(response.into_inner()) }) } @@ -644,28 +874,47 @@ impl ApiClient { _verbose: bool, ) -> Result { self.runtime.block_on(async { - let response = self.client.update_directive(bank_id, directive_id, None, request).await?; + let response = self + .client + .update_directive(bank_id, directive_id, None, request) + .await?; Ok(response.into_inner()) }) } - pub fn delete_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result { + pub fn delete_directive( + &self, + bank_id: &str, + directive_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { - let response = self.client.delete_directive(bank_id, directive_id, None).await?; + let response = self + .client + .delete_directive(bank_id, directive_id, None) + .await?; Ok(response.into_inner()) }) } // --- Consolidation Methods --- - pub fn trigger_consolidation(&self, bank_id: &str, _verbose: bool) -> Result { + pub fn trigger_consolidation( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.trigger_consolidation(bank_id, None).await?; Ok(response.into_inner()) }) } - pub fn clear_observations(&self, bank_id: &str, _verbose: bool) -> Result { + pub fn clear_observations( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { self.runtime.block_on(async { let response = self.client.clear_observations(bank_id, None).await?; Ok(response.into_inner()) @@ -682,16 +931,291 @@ impl ApiClient { } } +// ============================================================================ +// Webhooks, audit logs, bank templates, and other endpoints added for full +// OpenAPI coverage. Enforced by `uv run cli-coverage-check` in hindsight-dev. +// ============================================================================ + +impl ApiClient { + // --- Webhook Methods --- + + pub fn list_webhooks( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.list_webhooks(bank_id, None).await?; + Ok(response.into_inner()) + }) + } + + pub fn create_webhook( + &self, + bank_id: &str, + request: &types::CreateWebhookRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.create_webhook(bank_id, None, request).await?; + Ok(response.into_inner()) + }) + } + + pub fn update_webhook( + &self, + bank_id: &str, + webhook_id: &str, + request: &types::UpdateWebhookRequest, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .update_webhook(bank_id, webhook_id, None, request) + .await?; + Ok(response.into_inner()) + }) + } + + pub fn delete_webhook( + &self, + bank_id: &str, + webhook_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .delete_webhook(bank_id, webhook_id, None) + .await?; + Ok(response.into_inner()) + }) + } + + pub fn list_webhook_deliveries( + &self, + bank_id: &str, + webhook_id: &str, + cursor: Option<&str>, + limit: Option, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .list_webhook_deliveries(bank_id, webhook_id, cursor, limit, None) + .await?; + Ok(response.into_inner()) + }) + } + + // --- Audit Log Methods --- + + pub fn list_audit_logs( + &self, + bank_id: &str, + action: Option<&str>, + transport: Option<&str>, + start_date: Option<&str>, + end_date: Option<&str>, + limit: Option, + offset: Option, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let limit_nz = limit.and_then(std::num::NonZeroU64::new); + let response = self + .client + .list_audit_logs( + bank_id, action, end_date, limit_nz, offset, start_date, transport, None, + ) + .await?; + Ok(response.into_inner()) + }) + } + + pub fn audit_log_stats( + &self, + bank_id: &str, + action: Option<&str>, + period: Option<&str>, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .audit_log_stats(bank_id, action, period, None) + .await?; + Ok(response.into_inner()) + }) + } + + // --- Bank Template Methods --- + + pub fn get_bank_template_schema(&self, _verbose: bool) -> Result { + self.runtime.block_on(async { + let response = self.client.get_bank_template_schema().await?; + Ok(response.into_inner()) + }) + } + + pub fn export_bank_template( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.export_bank_template(bank_id, None).await?; + Ok(response.into_inner()) + }) + } + + /// Import a bank template manifest. The OpenAPI spec does not declare a + /// request body for this endpoint, so the progenitor-generated client does + /// not expose one — we POST the manifest JSON via raw HTTP instead. + pub fn import_bank_template( + &self, + bank_id: &str, + manifest: &serde_json::Value, + dry_run: bool, + verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let mut url = format!("{}/v1/default/banks/{}/import", self.base_url, bank_id); + if dry_run { + url.push_str("?dry_run=true"); + } + if verbose { + eprintln!("POST {}", url); + } + let response = self.http_client.post(&url).json(manifest).send().await?; + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + anyhow::bail!("Import failed ({}): {}", status, text); + } + let result: types::BankTemplateImportResponse = response.json().await?; + Ok(result) + }) + } + + // --- Document Methods --- + + pub fn update_document( + &self, + bank_id: &str, + document_id: &str, + tags: Option>, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let request = types::UpdateDocumentRequest { tags }; + let response = self + .client + .update_document(bank_id, document_id, None, &request) + .await?; + Ok(response.into_inner()) + }) + } + + // --- Memory Observation Methods --- + + pub fn get_observation_history( + &self, + bank_id: &str, + memory_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .get_observation_history(bank_id, memory_id, None) + .await?; + Ok(response.into_inner()) + }) + } + + pub fn clear_memory_observations( + &self, + bank_id: &str, + memory_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .clear_memory_observations(bank_id, memory_id, None) + .await?; + Ok(response.into_inner()) + }) + } + + // --- Operation Methods --- + + pub fn retry_operation( + &self, + bank_id: &str, + operation_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self + .client + .retry_operation(bank_id, operation_id, None) + .await?; + Ok(response.into_inner()) + }) + } + + // --- Consolidation Recovery --- + + pub fn recover_consolidation( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let response = self.client.recover_consolidation(bank_id, None).await?; + Ok(response.into_inner()) + }) + } + + // --- Bank Disposition --- + + pub fn update_bank_disposition( + &self, + bank_id: &str, + skepticism: u64, + literalism: u64, + empathy: u64, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let to_nz = |v: u64| -> Result { + std::num::NonZeroU64::new(v) + .ok_or_else(|| anyhow::anyhow!("disposition traits must be 1-5")) + }; + let request = types::UpdateDispositionRequest { + disposition: types::DispositionTraits { + skepticism: to_nz(skepticism)?, + literalism: to_nz(literalism)?, + empathy: to_nz(empathy)?, + }, + }; + let response = self + .client + .update_bank_disposition(bank_id, None, &request) + .await?; + Ok(response.into_inner()) + }) + } +} + // Re-export types from the generated client for use in commands pub use types::{ - BankProfileResponse, - MemoryItem, - RecallRequest, - RecallResponse, - RecallResult, - ReflectRequest, - ReflectResponse, - RetainRequest, + BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest, + ReflectResponse, RetainRequest, }; #[cfg(test)] diff --git a/hindsight-cli/src/commands/audit.rs b/hindsight-cli/src/commands/audit.rs new file mode 100644 index 00000000..a6a07a4f --- /dev/null +++ b/hindsight-cli/src/commands/audit.rs @@ -0,0 +1,118 @@ +//! Audit log commands. + +use anyhow::Result; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +/// List audit log entries for a bank +#[allow(clippy::too_many_arguments)] +pub fn list( + client: &ApiClient, + bank_id: &str, + action: Option, + transport: Option, + start_date: Option, + end_date: Option, + limit: Option, + offset: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching audit logs...")) + } else { + None + }; + + let response = client.list_audit_logs( + bank_id, + action.as_deref(), + transport.as_deref(), + start_date.as_deref(), + end_date.as_deref(), + limit, + offset, + verbose, + ); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Audit logs: {}", bank_id)); + println!( + " {} {} ({} total)", + ui::dim("Showing:"), + result.items.len(), + result.total + ); + println!(); + if result.items.is_empty() { + println!(" {}", ui::dim("No audit log entries.")); + } else { + for entry in &result.items { + let started = entry.started_at.as_deref().unwrap_or("-"); + let duration = entry + .duration_ms + .map(|d| format!("{}ms", d)) + .unwrap_or_else(|| "-".to_string()); + println!( + " {} {} [{}] {}", + ui::dim(started), + ui::gradient_start(&entry.action), + entry.transport, + duration + ); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + +/// Get audit log statistics for a bank +pub fn stats( + client: &ApiClient, + bank_id: &str, + action: Option, + period: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching audit log stats...")) + } else { + None + }; + + let response = client.audit_log_stats(bank_id, action.as_deref(), period.as_deref(), verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Audit stats: {}", bank_id)); + println!(" {} {}", ui::dim("Period:"), result.period); + println!(" {} {}", ui::dim("Start:"), result.start); + println!(" {} {}", ui::dim("Bucket:"), result.trunc); + println!(); + if result.buckets.is_empty() { + println!(" {}", ui::dim("No activity in this period.")); + } else { + for bucket in &result.buckets { + let json = serde_json::to_value(bucket)?; + println!(" {}", json); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} diff --git a/hindsight-cli/src/commands/bank.rs b/hindsight-cli/src/commands/bank.rs index f9bdde4b..f4ecfa18 100644 --- a/hindsight-cli/src/commands/bank.rs +++ b/hindsight-cli/src/commands/bank.rs @@ -1,7 +1,7 @@ -use anyhow::{anyhow, Result}; use crate::api::ApiClient; use crate::output::{self, OutputFormat}; use crate::ui; +use anyhow::{anyhow, Result}; pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> Result<()> { let spinner = if output_format == OutputFormat::Pretty { @@ -32,11 +32,16 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } -pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> { +pub fn disposition( + client: &ApiClient, + bank_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { let spinner = if output_format == OutputFormat::Pretty { Some(ui::create_spinner("Fetching disposition...")) } else { @@ -58,11 +63,16 @@ pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_form } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } -pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> { +pub fn stats( + client: &ApiClient, + bank_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { let spinner = if output_format == OutputFormat::Pretty { Some(ui::create_spinner("Fetching statistics...")) } else { @@ -80,9 +90,21 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou if output_format == OutputFormat::Pretty { ui::print_section_header(&format!("Statistics: {}", bank_id)); - println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string())); - println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string())); - println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string())); + println!( + " {} {}", + ui::dim("memory units:"), + ui::gradient_start(&stats.total_nodes.to_string()) + ); + println!( + " {} {}", + ui::dim("links:"), + ui::gradient_mid(&stats.total_links.to_string()) + ); + println!( + " {} {}", + ui::dim("documents:"), + ui::gradient_end(&stats.total_documents.to_string()) + ); println!(); println!("{}", ui::gradient_text("─── Memory Units by Type ───")); @@ -90,7 +112,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou fact_types.sort_by_key(|(k, _)| *k); for (i, (fact_type, count)) in fact_types.iter().enumerate() { let t = i as f32 / fact_types.len().max(1) as f32; - println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t)); + println!( + " {:<10} {}", + fact_type, + ui::gradient(&count.to_string(), t) + ); } println!(); @@ -99,7 +125,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou link_types.sort_by_key(|(k, _)| *k); for (i, (link_type, count)) in link_types.iter().enumerate() { let t = i as f32 / link_types.len().max(1) as f32; - println!(" {:<10} {}", link_type, ui::gradient(&count.to_string(), t)); + println!( + " {:<10} {}", + link_type, + ui::gradient(&count.to_string(), t) + ); } println!(); @@ -108,7 +138,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou fact_type_links.sort_by_key(|(k, _)| *k); for (i, (fact_type, count)) in fact_type_links.iter().enumerate() { let t = i as f32 / fact_type_links.len().max(1) as f32; - println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t)); + println!( + " {:<10} {}", + fact_type, + ui::gradient(&count.to_string(), t) + ); } println!(); @@ -141,11 +175,17 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } -pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool, output_format: OutputFormat) -> Result<()> { +pub fn update_name( + client: &ApiClient, + bank_id: &str, + name: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { let spinner = if output_format == OutputFormat::Pretty { Some(ui::create_spinner("Updating bank name...")) } else { @@ -167,7 +207,7 @@ pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool, } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -177,7 +217,7 @@ pub fn update_background( content: &str, no_update_disposition: bool, verbose: bool, - output_format: OutputFormat + output_format: OutputFormat, ) -> Result<()> { let current_profile = if !no_update_disposition { client.get_profile(bank_id, verbose).ok() @@ -204,9 +244,10 @@ pub fn update_background( println!("\n{}", profile.mission); if !no_update_disposition { - if let (Some(old_p), Some(new_p)) = - (current_profile.as_ref().map(|p| p.disposition.clone()), &profile.disposition) - { + if let (Some(old_p), Some(new_p)) = ( + current_profile.as_ref().map(|p| p.disposition.clone()), + &profile.disposition, + ) { println!("\nDisposition changes:"); println!(" Skepticism: {} → {}", old_p.skepticism, new_p.skepticism); println!(" Literalism: {} → {}", old_p.literalism, new_p.literalism); @@ -218,7 +259,7 @@ pub fn update_background( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -329,7 +370,12 @@ pub fn update( verbose: bool, output_format: OutputFormat, ) -> Result<()> { - if name.is_none() && mission_text.is_none() && skepticism.is_none() && literalism.is_none() && empathy.is_none() { + if name.is_none() + && mission_text.is_none() + && skepticism.is_none() + && literalism.is_none() + && empathy.is_none() + { anyhow::bail!("At least one field must be provided (--name, --mission, --skepticism, --literalism, --empathy)"); } @@ -407,20 +453,27 @@ pub fn graph( if output_format == OutputFormat::Pretty { ui::print_section_header(&format!("Memory Graph: {}", bank_id)); - println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string())); - println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string())); + println!( + " {} {}", + ui::dim("Nodes:"), + ui::gradient_start(&result.nodes.len().to_string()) + ); + println!( + " {} {}", + ui::dim("Edges:"), + ui::gradient_end(&result.edges.len().to_string()) + ); println!(); // Show sample of nodes if !result.nodes.is_empty() { println!("{}", ui::gradient_text("─── Sample Nodes ───")); for node in result.nodes.iter().take(5) { - let fact_type = node.get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let id = node.get("id") + let fact_type = node + .get("type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); + let id = node.get("id").and_then(|v| v.as_str()).unwrap_or("unknown"); println!(" {} [{}]", ui::dim(id), fact_type); if let Some(text) = node.get("text").and_then(|v| v.as_str()) { let preview: String = text.chars().take(60).collect(); @@ -429,12 +482,18 @@ pub fn graph( } } if result.nodes.len() > 5 { - println!(" {} more...", ui::dim(&format!("+ {}", result.nodes.len() - 5))); + println!( + " {} more...", + ui::dim(&format!("+ {}", result.nodes.len() - 5)) + ); } println!(); } - println!("{}", ui::dim("Use JSON output for full graph data: -o json")); + println!( + "{}", + ui::dim("Use JSON output for full graph data: -o json") + ); } else { output::print_output(&result, output_format)?; } @@ -449,7 +508,7 @@ pub fn delete( bank_id: &str, yes: bool, verbose: bool, - output_format: OutputFormat + output_format: OutputFormat, ) -> Result<()> { // Confirmation prompt unless -y flag is used if !yes && output_format == OutputFormat::Pretty { @@ -494,7 +553,7 @@ pub fn delete( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -527,7 +586,11 @@ pub fn consolidate( ui::print_success("Consolidation triggered"); println!(" {} {}", ui::dim("Operation ID:"), operation_id); if result.deduplicated { - println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task"); + println!( + " {} {}", + ui::dim("Note:"), + "Reusing existing pending consolidation task" + ); } } else { output::print_output(&result, output_format)?; @@ -544,7 +607,13 @@ pub fn consolidate( // Poll for completion if output_format == OutputFormat::Pretty { println!(); - println!("{}", ui::dim(&format!("Polling every {}s for completion...", poll_interval))); + println!( + "{}", + ui::dim(&format!( + "Polling every {}s for completion...", + poll_interval + )) + ); } let start = std::time::Instant::now(); @@ -561,7 +630,10 @@ pub fn consolidate( match op.map(|o| o.status.as_str()) { Some("completed") => { if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Consolidation completed ({}s)", elapsed)); + ui::print_success(&format!( + "Consolidation completed ({}s)", + elapsed + )); } break; } @@ -571,7 +643,10 @@ pub fn consolidate( .map(|s| s.as_str()) .unwrap_or("Unknown error"); if output_format == OutputFormat::Pretty { - ui::print_error(&format!("Consolidation failed: {}", error_msg)); + ui::print_error(&format!( + "Consolidation failed: {}", + error_msg + )); } std::process::exit(1); } @@ -582,7 +657,10 @@ pub fn consolidate( } None => { if output_format == OutputFormat::Pretty { - ui::print_warning(&format!("Operation {} not found in list", operation_id)); + ui::print_warning(&format!( + "Operation {} not found in list", + operation_id + )); } break; } @@ -732,37 +810,67 @@ pub fn set_config( let mut updates: HashMap = HashMap::new(); if let Some(provider) = llm_provider { - updates.insert("llm_provider".to_string(), serde_json::Value::String(provider)); + updates.insert( + "llm_provider".to_string(), + serde_json::Value::String(provider), + ); } if let Some(model) = llm_model { updates.insert("llm_model".to_string(), serde_json::Value::String(model)); } if let Some(api_key) = llm_api_key { - updates.insert("llm_api_key".to_string(), serde_json::Value::String(api_key)); + updates.insert( + "llm_api_key".to_string(), + serde_json::Value::String(api_key), + ); } if let Some(base_url) = llm_base_url { - updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url)); + updates.insert( + "llm_base_url".to_string(), + serde_json::Value::String(base_url), + ); } if let Some(mission) = retain_mission { - updates.insert("retain_mission".to_string(), serde_json::Value::String(mission)); + updates.insert( + "retain_mission".to_string(), + serde_json::Value::String(mission), + ); } if let Some(mode) = retain_extraction_mode { - updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode)); + updates.insert( + "retain_extraction_mode".to_string(), + serde_json::Value::String(mode), + ); } if let Some(mission) = observations_mission { - updates.insert("observations_mission".to_string(), serde_json::Value::String(mission)); + updates.insert( + "observations_mission".to_string(), + serde_json::Value::String(mission), + ); } if let Some(mission) = reflect_mission { - updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission)); + updates.insert( + "reflect_mission".to_string(), + serde_json::Value::String(mission), + ); } if let Some(skepticism) = disposition_skepticism { - updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into())); + updates.insert( + "disposition_skepticism".to_string(), + serde_json::Value::Number(skepticism.into()), + ); } if let Some(literalism) = disposition_literalism { - updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into())); + updates.insert( + "disposition_literalism".to_string(), + serde_json::Value::Number(literalism.into()), + ); } if let Some(empathy) = disposition_empathy { - updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into())); + updates.insert( + "disposition_empathy".to_string(), + serde_json::Value::Number(empathy.into()), + ); } if updates.is_empty() { @@ -832,7 +940,10 @@ pub fn reset_config( match response { Ok(result) => { if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Configuration reset to defaults for bank '{}'", bank_id)); + ui::print_success(&format!( + "Configuration reset to defaults for bank '{}'", + bank_id + )); } else { output::print_output(&result, output_format)?; } @@ -841,3 +952,188 @@ pub fn reset_config( Err(e) => Err(e), } } + +/// Set disposition traits (skepticism, literalism, empathy) via PUT /profile +pub fn set_disposition( + client: &ApiClient, + bank_id: &str, + skepticism: u64, + literalism: u64, + empathy: u64, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating disposition...")) + } else { + None + }; + + let response = + client.update_bank_disposition(bank_id, skepticism, literalism, empathy, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let profile = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Disposition updated for bank '{}'", bank_id)); + ui::print_disposition(&profile); + } else { + output::print_output(&profile, output_format)?; + } + Ok(()) +} + +/// Recover from a stalled consolidation +pub fn consolidation_recover( + client: &ApiClient, + bank_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Recovering consolidation...")) + } else { + None + }; + + let response = client.recover_consolidation(bank_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Consolidation recovered for bank '{}'", bank_id)); + let json = serde_json::to_value(&result)?; + println!( + " {}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + +/// Export a bank template manifest (bank config + mental models + directives) +pub fn export_template( + client: &ApiClient, + bank_id: &str, + out_path: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Exporting bank template...")) + } else { + None + }; + + let response = client.export_bank_template(bank_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let manifest = response?; + let json = serde_json::to_string_pretty(&manifest)?; + + if let Some(path) = out_path { + std::fs::write(&path, &json) + .map_err(|e| anyhow!("Failed to write {}: {}", path.display(), e))?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Template written to {}", path.display())); + } + } else if output_format == OutputFormat::Pretty { + println!("{}", json); + } else { + output::print_output(&manifest, output_format)?; + } + Ok(()) +} + +/// Import a bank template manifest from a JSON file +pub fn import_template( + client: &ApiClient, + bank_id: &str, + manifest_path: &std::path::Path, + dry_run: bool, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let raw = std::fs::read_to_string(manifest_path) + .map_err(|e| anyhow!("Failed to read {}: {}", manifest_path.display(), e))?; + let manifest: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| anyhow!("Invalid JSON in {}: {}", manifest_path.display(), e))?; + + let spinner = if output_format == OutputFormat::Pretty { + let msg = if dry_run { + "Validating bank template (dry run)..." + } else { + "Importing bank template..." + }; + Some(ui::create_spinner(msg)) + } else { + None + }; + + let response = client.import_bank_template(bank_id, &manifest, dry_run, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + if dry_run { + ui::print_success(&format!("Template for bank '{}' validated", bank_id)); + } else { + ui::print_success(&format!("Template imported into bank '{}'", bank_id)); + } + println!(" directives created: {:?}", result.directives_created); + println!(" directives updated: {:?}", result.directives_updated); + println!( + " mental models created: {:?}", + result.mental_models_created + ); + println!( + " mental models updated: {:?}", + result.mental_models_updated + ); + println!(" config applied: {}", result.config_applied); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + +/// Fetch the bank template JSON schema +pub fn template_schema( + client: &ApiClient, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching template schema...")) + } else { + None + }; + + let response = client.get_bank_template_schema(verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let schema = response?; + if output_format == OutputFormat::Pretty { + println!("{}", serde_json::to_string_pretty(&schema)?); + } else { + output::print_output(&schema, output_format)?; + } + Ok(()) +} diff --git a/hindsight-cli/src/commands/directive.rs b/hindsight-cli/src/commands/directive.rs index c0a08502..410b3064 100644 --- a/hindsight-cli/src/commands/directive.rs +++ b/hindsight-cli/src/commands/directive.rs @@ -99,11 +99,13 @@ pub fn get( } /// Create a new directive +#[allow(clippy::too_many_arguments)] pub fn create( client: &ApiClient, bank_id: &str, name: &str, content: &str, + priority: i64, verbose: bool, output_format: OutputFormat, ) -> Result<()> { @@ -117,7 +119,7 @@ pub fn create( name: name.to_string(), content: content.to_string(), is_active: true, - priority: 0, + priority, tags: vec![], }; @@ -143,6 +145,7 @@ pub fn create( } /// Update a directive +#[allow(clippy::too_many_arguments)] pub fn update( client: &ApiClient, bank_id: &str, @@ -150,11 +153,14 @@ pub fn update( name: Option, content: Option, is_active: Option, + priority: Option, verbose: bool, output_format: OutputFormat, ) -> Result<()> { - if name.is_none() && content.is_none() && is_active.is_none() { - anyhow::bail!("At least one of --name, --content, or --is-active must be provided"); + if name.is_none() && content.is_none() && is_active.is_none() && priority.is_none() { + anyhow::bail!( + "At least one of --name, --content, --is-active, or --priority must be provided" + ); } let spinner = if output_format == OutputFormat::Pretty { @@ -167,7 +173,7 @@ pub fn update( name, content, is_active, - priority: None, + priority, tags: None, }; diff --git a/hindsight-cli/src/commands/document.rs b/hindsight-cli/src/commands/document.rs index 35a435ec..0648ab2a 100644 --- a/hindsight-cli/src/commands/document.rs +++ b/hindsight-cli/src/commands/document.rs @@ -1,9 +1,9 @@ -use anyhow::Result; -use chrono::{Duration as ChronoDuration, NaiveDate, Utc}; -use std::collections::BTreeMap; use crate::api::ApiClient; use crate::output::{self, OutputFormat}; use crate::ui; +use anyhow::Result; +use chrono::{Duration as ChronoDuration, NaiveDate, Utc}; +use std::collections::BTreeMap; pub fn list( client: &ApiClient, @@ -26,7 +26,13 @@ pub fn list( None }; - let response = client.list_documents(agent_id, query.as_deref(), Some(limit), Some(offset), verbose); + let response = client.list_documents( + agent_id, + query.as_deref(), + Some(limit), + Some(offset), + verbose, + ); if let Some(mut sp) = spinner { sp.finish(); @@ -35,13 +41,25 @@ pub fn list( match response { Ok(docs_response) => { if output_format == OutputFormat::Pretty { - ui::print_info(&format!("Documents for bank '{}' (total: {})", agent_id, docs_response.total)); + ui::print_info(&format!( + "Documents for bank '{}' (total: {})", + agent_id, docs_response.total + )); for doc in &docs_response.items { let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown"); - let created = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("unknown"); - let updated = doc.get("updated_at").and_then(|v| v.as_str()).unwrap_or("unknown"); + let created = doc + .get("created_at") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let updated = doc + .get("updated_at") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); let text_len = doc.get("text_length").and_then(|v| v.as_i64()).unwrap_or(0); - let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0); + let mem_count = doc + .get("memory_unit_count") + .and_then(|v| v.as_i64()) + .unwrap_or(0); println!("\n Document ID: {}", id); println!(" Created: {}", created); @@ -54,7 +72,7 @@ pub fn list( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -87,9 +105,7 @@ fn list_with_date( let mut filtered_count = 0; for doc in all_docs { - let created_at = doc.get("created_at") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let created_at = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or(""); // Parse the date part (YYYY-MM-DD) from created_at let doc_date = created_at.split('T').next().unwrap_or(""); @@ -126,7 +142,10 @@ fn list_with_date( println!(" {} ({} documents)", date_str, docs.len()); for doc in docs { let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown"); - let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0); + let mem_count = doc + .get("memory_unit_count") + .and_then(|v| v.as_i64()) + .unwrap_or(0); println!(" - {} ({} memories)", id, mem_count); } println!(); @@ -224,7 +243,7 @@ pub fn get( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -260,6 +279,45 @@ pub fn delete( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } + +/// Update a document (currently only supports replacing tags) +pub fn update( + client: &ApiClient, + bank_id: &str, + document_id: &str, + tags: Option>, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if tags.is_none() { + anyhow::bail!("At least one of --tags must be provided"); + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating document...")) + } else { + None + }; + + let response = client.update_document(bank_id, document_id, tags, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Document '{}' updated", document_id)); + let json = serde_json::to_value(&result)?; + println!( + " {}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index 52820546..6ce99274 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -3,13 +3,16 @@ use std::fs; use std::path::PathBuf; use walkdir::WalkDir; -use crate::api::{ApiClient, RecallRequest, ReflectRequest, MemoryItem, RetainRequest}; +use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest}; use crate::config; use crate::output::{self, OutputFormat}; use crate::ui; // Import types from generated client -use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch}; +use hindsight_client::types::{ + Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, + TagsMatch, +}; use serde::Deserialize; use serde_json; @@ -45,7 +48,12 @@ fn parse_budget(budget: &str) -> Budget { // Helper function to parse tags_match string to TagsMatch enum fn parse_tags_match(tags_match: &Option) -> TagsMatch { - match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() { + match tags_match + .as_deref() + .unwrap_or("any") + .to_lowercase() + .as_str() + { "all" => TagsMatch::All, "any_strict" => TagsMatch::AnyStrict, "all_strict" => TagsMatch::AllStrict, @@ -86,13 +94,19 @@ pub fn list( match response { Ok(result) => { if output_format == OutputFormat::Pretty { - ui::print_section_header(&format!("Memories: {} (showing {}-{})", bank_id, offset + 1, offset + result.items.len() as i64)); + ui::print_section_header(&format!( + "Memories: {} (showing {}-{})", + bank_id, + offset + 1, + offset + result.items.len() as i64 + )); if result.items.is_empty() { println!(" {}", ui::dim("No memories found.")); } else { for item in &result.items { - let fact_type = item.get("type") + let fact_type = item + .get("type") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let type_t = match fact_type { @@ -102,9 +116,7 @@ pub fn list( _ => 0.5, }; - let id = item.get("id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("unknown"); println!( " {} {}", @@ -172,7 +184,11 @@ pub fn get( ui::print_section_header(&format!("Memory: {}", memory_id)); - println!(" {} {}", ui::dim("Type:"), ui::gradient(&fact_type.to_uppercase(), type_t)); + println!( + " {} {}", + ui::dim("Type:"), + ui::gradient(&fact_type.to_uppercase(), type_t) + ); println!(" {} {}", ui::dim("ID:"), result.id); if let Some(doc_id) = &result.document_id { @@ -234,12 +250,9 @@ pub fn get( fn is_supported_file(path: &std::path::Path) -> bool { const SUPPORTED_EXTENSIONS: &[&str] = &[ // Documents - "pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", - // Images (OCR) - "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", - // Web / markup - "html", "htm", - // Text / data + "pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", // Images (OCR) + "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", // Web / markup + "html", "htm", // Text / data "txt", "md", "csv", "json", "yaml", "yml", "toml", "xml", "rst", "adoc", "log", // Audio (transcription) "mp3", "wav", "ogg", "flac", @@ -250,6 +263,7 @@ fn is_supported_file(path: &std::path::Path) -> bool { .unwrap_or(false) } +#[allow(clippy::too_many_arguments)] pub fn recall( client: &ApiClient, agent_id: &str, @@ -262,6 +276,7 @@ pub fn recall( chunk_max_tokens: i64, tags: Vec, tags_match: Option, + query_timestamp: Option, verbose: bool, output_format: OutputFormat, ) -> Result<()> { @@ -286,11 +301,15 @@ pub fn recall( let request = RecallRequest { query, - types: if fact_type.is_empty() { None } else { Some(fact_type) }, + types: if fact_type.is_empty() { + None + } else { + Some(fact_type) + }, budget: Some(parse_budget(&budget)), max_tokens, trace, - query_timestamp: None, + query_timestamp, include, tags: if tags.is_empty() { None } else { Some(tags) }, tags_match: parse_tags_match(&tags_match), @@ -312,10 +331,11 @@ pub fn recall( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } +#[allow(clippy::too_many_arguments)] pub fn reflect( client: &ApiClient, agent_id: &str, @@ -327,6 +347,9 @@ pub fn reflect( tags: Vec, tags_match: Option, include_facts: bool, + fact_types: Option>, + exclude_mental_models: bool, + exclude_mental_model_ids: Option>, verbose: bool, output_format: OutputFormat, ) -> Result<()> { @@ -340,8 +363,9 @@ pub fn reflect( let response_schema = if let Some(path) = schema_path { let schema_content = fs::read_to_string(&path) .with_context(|| format!("Failed to read schema file: {}", path.display()))?; - let schema: serde_json::Map = serde_json::from_str(&schema_content) - .with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?; + let schema: serde_json::Map = + serde_json::from_str(&schema_content) + .with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?; Some(schema) } else { None @@ -356,6 +380,21 @@ pub fn reflect( None }; + // Map the CLI fact-type strings (world, experience, observation) into the + // generated FactTypesItem enum. Unknown values are dropped — the server + // would reject them anyway. + let mapped_fact_types = fact_types.as_ref().map(|types| { + types + .iter() + .filter_map(|t| match t.to_lowercase().as_str() { + "world" => Some(hindsight_client::types::FactTypesItem::World), + "experience" => Some(hindsight_client::types::FactTypesItem::Experience), + "observation" => Some(hindsight_client::types::FactTypesItem::Observation), + _ => None, + }) + .collect::>() + }); + let request = ReflectRequest { query, budget: Some(parse_budget(&budget)), @@ -366,9 +405,9 @@ pub fn reflect( tags: if tags.is_empty() { None } else { Some(tags) }, tags_match: parse_tags_match(&tags_match), tag_groups: None, - fact_types: None, - exclude_mental_models: false, - exclude_mental_model_ids: None, + fact_types: mapped_fact_types, + exclude_mental_models, + exclude_mental_model_ids, }; let response = client.reflect(agent_id, &request, verbose); @@ -386,10 +425,11 @@ pub fn reflect( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } +#[allow(clippy::too_many_arguments)] pub fn retain( client: &ApiClient, agent_id: &str, @@ -397,6 +437,7 @@ pub fn retain( doc_id: Option, context: Option, r#async: bool, + document_tags: Option>, verbose: bool, output_format: OutputFormat, ) -> Result<()> { @@ -424,7 +465,7 @@ pub fn retain( let request = RetainRequest { items: vec![item], async_: r#async, - document_tags: None, + document_tags, }; let response = client.retain(agent_id, &request, r#async, verbose); @@ -451,7 +492,7 @@ pub fn retain( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -618,7 +659,7 @@ pub fn delete( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -688,10 +729,85 @@ pub fn clear( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } +/// Get the observation history for a memory unit +pub fn history( + client: &ApiClient, + bank_id: &str, + memory_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching observation history...")) + } else { + None + }; + + let response = client.get_observation_history(bank_id, memory_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + +/// Clear the observations attached to a specific memory unit +pub fn clear_observations( + client: &ApiClient, + bank_id: &str, + memory_id: &str, + yes: bool, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if !yes && output_format == OutputFormat::Pretty { + let msg = format!( + "Clear observations for memory '{}'? They will be re-derived on next consolidation.", + memory_id + ); + if !ui::prompt_confirmation(&msg)? { + ui::print_info("Operation cancelled"); + return Ok(()); + } + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Clearing observations...")) + } else { + None + }; + + let response = client.clear_memory_observations(bank_id, memory_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Cleared observations for memory '{}'", memory_id)); + let json = serde_json::to_value(&result)?; + println!( + " {}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -700,8 +816,17 @@ mod tests { #[test] fn test_is_supported_file_text_extensions() { let supported = [ - "file.txt", "file.md", "file.json", "file.yaml", "file.yml", - "file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc", + "file.txt", + "file.md", + "file.json", + "file.yaml", + "file.yml", + "file.toml", + "file.xml", + "file.csv", + "file.log", + "file.rst", + "file.adoc", ]; for filename in supported { assert!( @@ -715,9 +840,16 @@ mod tests { #[test] fn test_is_supported_file_binary_extensions() { let supported = [ - "file.pdf", "file.docx", "file.pptx", "file.xlsx", - "file.png", "file.jpg", "file.jpeg", "file.gif", - "file.mp3", "file.wav", + "file.pdf", + "file.docx", + "file.pptx", + "file.xlsx", + "file.png", + "file.jpg", + "file.jpeg", + "file.gif", + "file.mp3", + "file.wav", ]; for filename in supported { assert!( @@ -739,9 +871,7 @@ mod tests { #[test] fn test_is_supported_file_unsupported_extensions() { - let unsupported = [ - "file.exe", "file.bin", "file.zip", "file.tar", "file.gz", - ]; + let unsupported = ["file.exe", "file.bin", "file.zip", "file.tar", "file.gz"]; for filename in unsupported { assert!( !is_supported_file(Path::new(filename)), diff --git a/hindsight-cli/src/commands/mental_model.rs b/hindsight-cli/src/commands/mental_model.rs index bf063fc1..507543c9 100644 --- a/hindsight-cli/src/commands/mental_model.rs +++ b/hindsight-cli/src/commands/mental_model.rs @@ -95,12 +95,16 @@ pub fn get( } /// Create a new mental model +#[allow(clippy::too_many_arguments)] pub fn create( client: &ApiClient, bank_id: &str, name: &str, source_query: &str, id: Option<&str>, + tags: Vec, + max_tokens: i64, + trigger_refresh_after_consolidation: bool, verbose: bool, output_format: OutputFormat, ) -> Result<()> { @@ -110,13 +114,28 @@ pub fn create( None }; + // Only send a trigger when the user opted in, so the server's default + // behaviour is preserved otherwise. + let trigger = if trigger_refresh_after_consolidation { + Some(types::MentalModelTriggerInput { + refresh_after_consolidation: true, + exclude_mental_models: false, + exclude_mental_model_ids: None, + fact_types: None, + tag_groups: None, + tags_match: None, + }) + } else { + None + }; + let request = types::CreateMentalModelRequest { id: id.map(|s| s.to_string()), name: name.to_string(), source_query: source_query.to_string(), - max_tokens: 2048, - tags: vec![], - trigger: None, + max_tokens, + tags, + trigger, }; let response = client.create_mental_model(bank_id, &request, verbose); @@ -139,16 +158,29 @@ pub fn create( } /// Update a mental model +#[allow(clippy::too_many_arguments)] pub fn update( client: &ApiClient, bank_id: &str, mental_model_id: &str, name: Option, + source_query: Option, + max_tokens: Option, + tags: Option>, + trigger_refresh_after_consolidation: Option, verbose: bool, output_format: OutputFormat, ) -> Result<()> { - if name.is_none() { - anyhow::bail!("--name must be provided"); + if name.is_none() + && source_query.is_none() + && max_tokens.is_none() + && tags.is_none() + && trigger_refresh_after_consolidation.is_none() + { + anyhow::bail!( + "At least one of --name, --source-query, --max-tokens, --tags, or \ + --trigger-refresh-after-consolidation must be provided" + ); } let spinner = if output_format == OutputFormat::Pretty { @@ -157,12 +189,23 @@ pub fn update( None }; + // Only build a trigger override when the user actually passed the flag; + // sending None leaves the existing trigger config untouched on the server. + let trigger = trigger_refresh_after_consolidation.map(|refresh| types::MentalModelTriggerInput { + refresh_after_consolidation: refresh, + exclude_mental_models: false, + exclude_mental_model_ids: None, + fact_types: None, + tag_groups: None, + tags_match: None, + }); + let request = types::UpdateMentalModelRequest { name, - source_query: None, - max_tokens: None, - tags: None, - trigger: None, + source_query, + max_tokens, + tags, + trigger, }; let response = client.update_mental_model(bank_id, mental_model_id, &request, verbose); diff --git a/hindsight-cli/src/commands/mod.rs b/hindsight-cli/src/commands/mod.rs index ee057f1c..978d6fe7 100644 --- a/hindsight-cli/src/commands/mod.rs +++ b/hindsight-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod audit; pub mod bank; pub mod chunk; pub mod directive; @@ -6,6 +7,7 @@ pub mod entity; pub mod explore; pub mod health; pub mod memory; -pub mod operation; pub mod mental_model; +pub mod operation; pub mod tag; +pub mod webhook; diff --git a/hindsight-cli/src/commands/operation.rs b/hindsight-cli/src/commands/operation.rs index 80aa7806..11e49a86 100644 --- a/hindsight-cli/src/commands/operation.rs +++ b/hindsight-cli/src/commands/operation.rs @@ -1,7 +1,7 @@ -use anyhow::Result; use crate::api::ApiClient; use crate::output::{self, OutputFormat}; use crate::ui; +use anyhow::Result; pub fn list( client: &ApiClient, @@ -27,7 +27,10 @@ pub fn list( if ops_response.operations.is_empty() { ui::print_info("No operations found"); } else { - ui::print_info(&format!("Found {} operation(s)", ops_response.operations.len())); + ui::print_info(&format!( + "Found {} operation(s)", + ops_response.operations.len() + )); for op in &ops_response.operations { println!("\n Operation ID: {}", op.id); println!(" Type: {}", op.task_type); @@ -43,7 +46,7 @@ pub fn list( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } @@ -128,6 +131,40 @@ pub fn cancel( } Ok(()) } - Err(e) => Err(e) + Err(e) => Err(e), } } + +/// Retry a failed async operation +pub fn retry( + client: &ApiClient, + agent_id: &str, + operation_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Retrying operation...")) + } else { + None + }; + + let response = client.retry_operation(agent_id, operation_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Operation '{}' retried", operation_id)); + let json = serde_json::to_value(&result)?; + println!( + " {}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} diff --git a/hindsight-cli/src/commands/webhook.rs b/hindsight-cli/src/commands/webhook.rs new file mode 100644 index 00000000..16535d0d --- /dev/null +++ b/hindsight-cli/src/commands/webhook.rs @@ -0,0 +1,249 @@ +//! Webhook commands for managing event delivery hooks. + +use anyhow::Result; + +use crate::api::ApiClient; +use crate::output::{self, OutputFormat}; +use crate::ui; + +use hindsight_client::types; + +/// List webhooks for a bank +pub fn list( + client: &ApiClient, + bank_id: &str, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching webhooks...")) + } else { + None + }; + + let response = client.list_webhooks(bank_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Webhooks: {}", bank_id)); + if result.items.is_empty() { + println!(" {}", ui::dim("No webhooks configured.")); + } else { + for wh in &result.items { + let status = if wh.enabled { + ui::gradient_start("enabled") + } else { + ui::dim("disabled") + }; + println!(" {} [{}] {}", ui::gradient_start(&wh.id), status, wh.url); + if !wh.event_types.is_empty() { + println!(" events: {}", wh.event_types.join(", ")); + } + println!(); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + +/// Create a new webhook +#[allow(clippy::too_many_arguments)] +pub fn create( + client: &ApiClient, + bank_id: &str, + url: &str, + event_types: Vec, + enabled: bool, + secret: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Creating webhook...")) + } else { + None + }; + + let effective_events = if event_types.is_empty() { + vec!["consolidation.completed".to_string()] + } else { + event_types + }; + + let request = types::CreateWebhookRequest { + enabled, + event_types: effective_events, + http_config: None, + secret, + url: url.to_string(), + }; + + let response = client.create_webhook(bank_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let wh = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Webhook '{}' created", wh.id)); + println!(" URL: {}", wh.url); + println!(" Events: {}", wh.event_types.join(", ")); + } else { + output::print_output(&wh, output_format)?; + } + Ok(()) +} + +/// Update a webhook +#[allow(clippy::too_many_arguments)] +pub fn update( + client: &ApiClient, + bank_id: &str, + webhook_id: &str, + url: Option, + event_types: Option>, + enabled: Option, + secret: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if url.is_none() && event_types.is_none() && enabled.is_none() && secret.is_none() { + anyhow::bail!( + "At least one of --url, --event-types, --enabled, or --secret must be provided" + ); + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Updating webhook...")) + } else { + None + }; + + let request = types::UpdateWebhookRequest { + enabled, + event_types, + http_config: None, + secret, + url, + }; + + let response = client.update_webhook(bank_id, webhook_id, &request, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let wh = response?; + if output_format == OutputFormat::Pretty { + ui::print_success(&format!("Webhook '{}' updated", wh.id)); + } else { + output::print_output(&wh, output_format)?; + } + Ok(()) +} + +/// Delete a webhook +pub fn delete( + client: &ApiClient, + bank_id: &str, + webhook_id: &str, + yes: bool, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + if !yes && output_format == OutputFormat::Pretty { + let message = format!( + "Are you sure you want to delete webhook '{}'? This cannot be undone.", + webhook_id + ); + if !ui::prompt_confirmation(&message)? { + ui::print_info("Operation cancelled"); + return Ok(()); + } + } + + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Deleting webhook...")) + } else { + None + }; + + let response = client.delete_webhook(bank_id, webhook_id, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + if result.success { + ui::print_success(&format!("Webhook '{}' deleted", webhook_id)); + } else { + ui::print_error("Failed to delete webhook"); + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} + +/// List recent delivery attempts for a webhook +pub fn deliveries( + client: &ApiClient, + bank_id: &str, + webhook_id: &str, + cursor: Option, + limit: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + let spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Fetching deliveries...")) + } else { + None + }; + + let response = + client.list_webhook_deliveries(bank_id, webhook_id, cursor.as_deref(), limit, verbose); + + if let Some(mut sp) = spinner { + sp.finish(); + } + + let result = response?; + if output_format == OutputFormat::Pretty { + ui::print_section_header(&format!("Deliveries for {}", webhook_id)); + if result.items.is_empty() { + println!(" {}", ui::dim("No delivery attempts recorded.")); + } else { + for d in &result.items { + println!( + " {} [{}] {} — attempts: {}", + ui::gradient_start(&d.id), + d.event_type, + d.last_response_status + .map(|s| s.to_string()) + .unwrap_or_else(|| "-".to_string()), + d.attempts + ); + if let Some(err) = &d.last_error { + println!(" {} {}", ui::dim("error:"), err); + } + } + if let Some(cursor) = &result.next_cursor { + println!(); + println!(" {} {}", ui::dim("next cursor:"), cursor); + } + } + } else { + output::print_output(&result, output_format)?; + } + Ok(()) +} diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index fe4f1a18..c57c3df9 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -103,6 +103,14 @@ enum Commands { #[command(subcommand)] Directive(DirectiveCommands), + /// Manage webhooks (list, create, update, delete, deliveries) + #[command(subcommand)] + Webhook(WebhookCommands), + + /// Inspect audit logs (list, stats) + #[command(subcommand)] + Audit(AuditCommands), + /// Check API health status Health, @@ -120,7 +128,9 @@ enum Commands { Ui, /// Configure the CLI (API URL, API key, etc.) - #[command(after_help = "Configuration priority:\n 1. Environment variables (HINDSIGHT_API_URL, HINDSIGHT_API_KEY) - highest priority\n 2. Config file (~/.hindsight/config)\n 3. Default (http://localhost:8888)")] + #[command( + after_help = "Configuration priority:\n 1. Environment variables (HINDSIGHT_API_URL, HINDSIGHT_API_KEY) - highest priority\n 2. Config file (~/.hindsight/config)\n 3. Default (http://localhost:8888)" + )] Configure { /// API URL to connect to (interactive prompt if not provided) #[arg(long)] @@ -349,6 +359,53 @@ enum BankCommands { #[arg(short = 'y', long)] yes: bool, }, + + /// Set disposition traits directly (1-5 each, via PUT /profile) + SetDisposition { + /// Bank ID + bank_id: String, + + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=5))] + skepticism: u64, + + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=5))] + literalism: u64, + + #[arg(long, value_parser = clap::value_parser!(u64).range(1..=5))] + empathy: u64, + }, + + /// Recover from a stalled consolidation + ConsolidationRecover { + /// Bank ID + bank_id: String, + }, + + /// Export a bank template manifest (config + mental models + directives) + ExportTemplate { + /// Bank ID + bank_id: String, + + /// Write manifest to this file instead of stdout + #[arg(short = 'o', long)] + out: Option, + }, + + /// Import a bank template manifest from a JSON file + ImportTemplate { + /// Bank ID + bank_id: String, + + /// Path to a JSON manifest file + manifest: PathBuf, + + /// Validate the manifest without applying changes + #[arg(long)] + dry_run: bool, + }, + + /// Print the bank template JSON schema + TemplateSchema, } #[derive(Subcommand)] @@ -423,6 +480,10 @@ enum MemoryCommands { /// Tag matching mode: any, all, any_strict, all_strict (default: any) #[arg(long)] tags_match: Option, + + /// Reference timestamp for recall (ISO 8601, e.g. 2023-05-30T23:40:00) + #[arg(long)] + query_timestamp: Option, }, /// Generate answers using bank identity (reflect/reasoning) @@ -460,6 +521,18 @@ enum MemoryCommands { /// Include source facts (based_on) in the response #[arg(long)] include_facts: bool, + + /// Restrict fact retrieval to these fact types (comma-separated: world, experience, observation) + #[arg(long, value_delimiter = ',')] + fact_types: Option>, + + /// Exclude all mental models from the reflect loop + #[arg(long)] + exclude_mental_models: bool, + + /// Exclude specific mental models by ID (comma-separated) + #[arg(long, value_delimiter = ',')] + exclude_mental_model_ids: Option>, }, /// Store (retain) a single memory @@ -481,6 +554,10 @@ enum MemoryCommands { /// Queue for background processing #[arg(long)] r#async: bool, + + /// Deprecated document-level tags (comma-separated). Prefer item-level tags. + #[arg(long, value_delimiter = ',')] + document_tags: Option>, }, /// Bulk import memories from files (retain) @@ -526,6 +603,28 @@ enum MemoryCommands { #[arg(short = 'y', long)] yes: bool, }, + + /// Show the observation history for a memory unit + History { + /// Bank ID + bank_id: String, + + /// Memory unit ID + memory_id: String, + }, + + /// Clear the observations derived from a single memory unit + ClearObservations { + /// Bank ID + bank_id: String, + + /// Memory unit ID + memory_id: String, + + /// Skip confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, } #[derive(Subcommand)] @@ -569,6 +668,19 @@ enum DocumentCommands { /// Document ID document_id: String, }, + + /// Update a document (currently only supports replacing tags) + Update { + /// Bank ID + bank_id: String, + + /// Document ID + document_id: String, + + /// New tag list (comma-separated). Triggers observation invalidation + re-consolidation. + #[arg(long, value_delimiter = ',')] + tags: Vec, + }, } #[derive(Subcommand)] @@ -627,6 +739,147 @@ enum OperationCommands { /// Operation ID operation_id: String, }, + + /// Retry a failed async operation + Retry { + /// Bank ID + bank_id: String, + + /// Operation ID + operation_id: String, + }, +} + +#[derive(Subcommand)] +enum WebhookCommands { + /// List webhooks configured for a bank + List { + /// Bank ID + bank_id: String, + }, + + /// Create a new webhook + Create { + /// Bank ID + bank_id: String, + + /// Target URL (http/https) + url: String, + + /// Event types (comma-separated). Defaults to consolidation.completed + #[arg(long, value_delimiter = ',')] + event_types: Vec, + + /// Start disabled + #[arg(long)] + disabled: bool, + + /// HMAC-SHA256 signing secret + #[arg(long)] + secret: Option, + }, + + /// Update an existing webhook + Update { + /// Bank ID + bank_id: String, + + /// Webhook ID + webhook_id: String, + + /// New target URL + #[arg(long)] + url: Option, + + /// Replace event types (comma-separated) + #[arg(long, value_delimiter = ',')] + event_types: Option>, + + /// Enable or disable + #[arg(long)] + enabled: Option, + + /// Replace the signing secret + #[arg(long)] + secret: Option, + }, + + /// Delete a webhook + Delete { + /// Bank ID + bank_id: String, + + /// Webhook ID + webhook_id: String, + + /// Skip confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + }, + + /// List recent delivery attempts for a webhook + Deliveries { + /// Bank ID + bank_id: String, + + /// Webhook ID + webhook_id: String, + + /// Pagination cursor + #[arg(long)] + cursor: Option, + + /// Maximum number of deliveries to return + #[arg(short = 'l', long)] + limit: Option, + }, +} + +#[derive(Subcommand)] +enum AuditCommands { + /// List audit log entries for a bank + List { + /// Bank ID + bank_id: String, + + /// Filter by action (e.g. recall, retain) + #[arg(long)] + action: Option, + + /// Filter by transport (e.g. http, mcp) + #[arg(long)] + transport: Option, + + /// Start date/time (ISO 8601) + #[arg(long)] + start_date: Option, + + /// End date/time (ISO 8601) + #[arg(long)] + end_date: Option, + + /// Maximum number of entries + #[arg(short = 'l', long)] + limit: Option, + + /// Offset for pagination + #[arg(short = 's', long)] + offset: Option, + }, + + /// Show audit log statistics bucketed over time + Stats { + /// Bank ID + bank_id: String, + + /// Filter by action + #[arg(long)] + action: Option, + + /// Time period (e.g. day, week, month) + #[arg(long)] + period: Option, + }, } #[derive(Subcommand)] @@ -690,6 +943,18 @@ enum MentalModelCommands { /// Optional custom ID for the mental model (alphanumeric lowercase with hyphens) #[arg(long)] id: Option, + + /// Tags for scoped visibility (comma-separated) + #[arg(long, value_delimiter = ',')] + tags: Vec, + + /// Maximum tokens for generated content (256-8192) + #[arg(long, default_value = "2048")] + max_tokens: i64, + + /// Refresh this mental model automatically after observations consolidation + #[arg(long)] + trigger_refresh_after_consolidation: bool, }, /// Update a mental model @@ -703,6 +968,22 @@ enum MentalModelCommands { /// New name #[arg(long)] name: Option, + + /// New source query + #[arg(long)] + source_query: Option, + + /// New maximum tokens for generated content + #[arg(long)] + max_tokens: Option, + + /// Replace tags (comma-separated) + #[arg(long, value_delimiter = ',')] + tags: Option>, + + /// Enable/disable automatic refresh after observations consolidation + #[arg(long)] + trigger_refresh_after_consolidation: Option, }, /// Delete a mental model @@ -764,6 +1045,10 @@ enum DirectiveCommands { /// Directive content (the text to inject into prompts) content: String, + + /// Priority — higher-priority directives are injected first + #[arg(long, default_value = "0")] + priority: i64, }, /// Update a directive @@ -785,6 +1070,10 @@ enum DirectiveCommands { /// Enable or disable the directive #[arg(long)] is_active: Option, + + /// New priority (higher = injected first) + #[arg(long)] + priority: Option, }, /// Delete a directive @@ -841,7 +1130,7 @@ fn run() -> Result<()> { // Execute command and handle errors let result: Result<()> = match cli.command { Commands::Configure { .. } => unreachable!(), // Handled above - Commands::Ui => unreachable!(), // Handled above + Commands::Ui => unreachable!(), // Handled above Commands::Explore => commands::explore::run(&client), // Health, Metrics, and Version @@ -852,83 +1141,344 @@ fn run() -> Result<()> { // Bank commands Commands::Bank(bank_cmd) => match bank_cmd { BankCommands::List => commands::bank::list(&client, verbose, output_format), - BankCommands::Create { bank_id, name, mission, skepticism, literalism, empathy } => { - commands::bank::create(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format) + BankCommands::Create { + bank_id, + name, + mission, + skepticism, + literalism, + empathy, + } => commands::bank::create( + &client, + &bank_id, + name, + mission, + skepticism, + literalism, + empathy, + verbose, + output_format, + ), + BankCommands::Update { + bank_id, + name, + mission, + skepticism, + literalism, + empathy, + } => commands::bank::update( + &client, + &bank_id, + name, + mission, + skepticism, + literalism, + empathy, + verbose, + output_format, + ), + BankCommands::Disposition { bank_id } => { + commands::bank::disposition(&client, &bank_id, verbose, output_format) } - BankCommands::Update { bank_id, name, mission, skepticism, literalism, empathy } => { - commands::bank::update(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format) + BankCommands::Stats { bank_id } => { + commands::bank::stats(&client, &bank_id, verbose, output_format) + } + BankCommands::Name { bank_id, name } => { + commands::bank::update_name(&client, &bank_id, &name, verbose, output_format) } - BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format), - BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format), - BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format), BankCommands::Mission { bank_id, mission } => { commands::bank::mission(&client, &bank_id, &mission, verbose, output_format) } - BankCommands::Background { bank_id, content, no_update_disposition } => { - commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format) - } - BankCommands::Graph { bank_id, fact_type, limit } => { - commands::bank::graph(&client, &bank_id, fact_type, limit, verbose, output_format) - } + BankCommands::Background { + bank_id, + content, + no_update_disposition, + } => commands::bank::update_background( + &client, + &bank_id, + &content, + no_update_disposition, + verbose, + output_format, + ), + BankCommands::Graph { + bank_id, + fact_type, + limit, + } => commands::bank::graph(&client, &bank_id, fact_type, limit, verbose, output_format), BankCommands::Delete { bank_id, yes } => { commands::bank::delete(&client, &bank_id, yes, verbose, output_format) } - BankCommands::Consolidate { bank_id, wait, poll_interval } => { - commands::bank::consolidate(&client, &bank_id, wait, poll_interval, verbose, output_format) - } + BankCommands::Consolidate { + bank_id, + wait, + poll_interval, + } => commands::bank::consolidate( + &client, + &bank_id, + wait, + poll_interval, + verbose, + output_format, + ), BankCommands::ClearObservations { bank_id, yes } => { commands::bank::clear_observations(&client, &bank_id, yes, verbose, output_format) } - BankCommands::Config { bank_id, overrides_only } => { - commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format) - } - BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy } => { - commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy, verbose, output_format) - } + BankCommands::Config { + bank_id, + overrides_only, + } => commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format), + BankCommands::SetConfig { + bank_id, + llm_provider, + llm_model, + llm_api_key, + llm_base_url, + retain_mission, + retain_extraction_mode, + observations_mission, + reflect_mission, + disposition_skepticism, + disposition_literalism, + disposition_empathy, + } => commands::bank::set_config( + &client, + &bank_id, + llm_provider, + llm_model, + llm_api_key, + llm_base_url, + retain_mission, + retain_extraction_mode, + observations_mission, + reflect_mission, + disposition_skepticism, + disposition_literalism, + disposition_empathy, + verbose, + output_format, + ), BankCommands::ResetConfig { bank_id, yes } => { commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format) } + BankCommands::SetDisposition { + bank_id, + skepticism, + literalism, + empathy, + } => commands::bank::set_disposition( + &client, + &bank_id, + skepticism, + literalism, + empathy, + verbose, + output_format, + ), + BankCommands::ConsolidationRecover { bank_id } => { + commands::bank::consolidation_recover(&client, &bank_id, verbose, output_format) + } + BankCommands::ExportTemplate { bank_id, out } => { + commands::bank::export_template(&client, &bank_id, out, verbose, output_format) + } + BankCommands::ImportTemplate { + bank_id, + manifest, + dry_run, + } => commands::bank::import_template( + &client, + &bank_id, + &manifest, + dry_run, + verbose, + output_format, + ), + BankCommands::TemplateSchema => { + commands::bank::template_schema(&client, verbose, output_format) + } }, // Memory commands Commands::Memory(memory_cmd) => match memory_cmd { - MemoryCommands::List { bank_id, fact_type, query, limit, offset } => { - commands::memory::list(&client, &bank_id, fact_type, query, limit, offset, verbose, output_format) - } + MemoryCommands::List { + bank_id, + fact_type, + query, + limit, + offset, + } => commands::memory::list( + &client, + &bank_id, + fact_type, + query, + limit, + offset, + verbose, + output_format, + ), MemoryCommands::Get { bank_id, memory_id } => { commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format) } - MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match } => { - commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match, verbose, output_format) - } - MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts } => { - commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts, verbose, output_format) - } - MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => { - commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format) - } - MemoryCommands::RetainFiles { bank_id, path, recursive, context, r#async } => { - commands::memory::retain_files(&client, &bank_id, path, recursive, context, r#async, verbose, output_format) - } + MemoryCommands::Recall { + bank_id, + query, + fact_type, + budget, + max_tokens, + trace, + include_chunks, + chunk_max_tokens, + tags, + tags_match, + query_timestamp, + } => commands::memory::recall( + &client, + &bank_id, + query, + fact_type, + budget, + max_tokens, + trace, + include_chunks, + chunk_max_tokens, + tags, + tags_match, + query_timestamp, + verbose, + output_format, + ), + MemoryCommands::Reflect { + bank_id, + query, + budget, + context, + max_tokens, + schema, + tags, + tags_match, + include_facts, + fact_types, + exclude_mental_models, + exclude_mental_model_ids, + } => commands::memory::reflect( + &client, + &bank_id, + query, + budget, + context, + max_tokens, + schema, + tags, + tags_match, + include_facts, + fact_types, + exclude_mental_models, + exclude_mental_model_ids, + verbose, + output_format, + ), + MemoryCommands::Retain { + bank_id, + content, + doc_id, + context, + r#async, + document_tags, + } => commands::memory::retain( + &client, + &bank_id, + content, + doc_id, + context, + r#async, + document_tags, + verbose, + output_format, + ), + MemoryCommands::RetainFiles { + bank_id, + path, + recursive, + context, + r#async, + } => commands::memory::retain_files( + &client, + &bank_id, + path, + recursive, + context, + r#async, + verbose, + output_format, + ), MemoryCommands::Delete { bank_id, unit_id } => { commands::memory::delete(&client, &bank_id, &unit_id, verbose, output_format) } - MemoryCommands::Clear { bank_id, fact_type, yes } => { - commands::memory::clear(&client, &bank_id, fact_type, yes, verbose, output_format) + MemoryCommands::Clear { + bank_id, + fact_type, + yes, + } => commands::memory::clear(&client, &bank_id, fact_type, yes, verbose, output_format), + MemoryCommands::History { bank_id, memory_id } => { + commands::memory::history(&client, &bank_id, &memory_id, verbose, output_format) } + MemoryCommands::ClearObservations { + bank_id, + memory_id, + yes, + } => commands::memory::clear_observations( + &client, + &bank_id, + &memory_id, + yes, + verbose, + output_format, + ), }, // Document commands Commands::Document(doc_cmd) => match doc_cmd { - DocumentCommands::List { bank_id, query, date, limit, offset } => { - commands::document::list(&client, &bank_id, query, date, limit, offset, verbose, output_format) - } - DocumentCommands::Get { bank_id, document_id } => { - commands::document::get(&client, &bank_id, &document_id, verbose, output_format) - } - DocumentCommands::Delete { bank_id, document_id } => { + DocumentCommands::List { + bank_id, + query, + date, + limit, + offset, + } => commands::document::list( + &client, + &bank_id, + query, + date, + limit, + offset, + verbose, + output_format, + ), + DocumentCommands::Get { + bank_id, + document_id, + } => commands::document::get(&client, &bank_id, &document_id, verbose, output_format), + DocumentCommands::Delete { + bank_id, + document_id, + } => { commands::document::delete(&client, &bank_id, &document_id, verbose, output_format) } + DocumentCommands::Update { + bank_id, + document_id, + tags, + } => { + let tag_opt = if tags.is_empty() { None } else { Some(tags) }; + commands::document::update( + &client, + &bank_id, + &document_id, + tag_opt, + verbose, + output_format, + ) + } }, // Entity commands @@ -946,9 +1496,20 @@ fn run() -> Result<()> { // Tag commands Commands::Tag(tag_cmd) => match tag_cmd { - TagCommands::List { bank_id, query, limit, offset } => { - commands::tag::list(&client, &bank_id, query, limit, offset, verbose, output_format) - } + TagCommands::List { + bank_id, + query, + limit, + offset, + } => commands::tag::list( + &client, + &bank_id, + query, + limit, + offset, + verbose, + output_format, + ), }, // Chunk commands @@ -963,11 +1524,25 @@ fn run() -> Result<()> { OperationCommands::List { bank_id } => { commands::operation::list(&client, &bank_id, verbose, output_format) } - OperationCommands::Get { bank_id, operation_id } => { - commands::operation::get(&client, &bank_id, &operation_id, verbose, output_format) - } - OperationCommands::Cancel { bank_id, operation_id } => { - commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format) + OperationCommands::Get { + bank_id, + operation_id, + } => commands::operation::get(&client, &bank_id, &operation_id, verbose, output_format), + OperationCommands::Cancel { + bank_id, + operation_id, + } => commands::operation::cancel( + &client, + &bank_id, + &operation_id, + verbose, + output_format, + ), + OperationCommands::Retry { + bank_id, + operation_id, + } => { + commands::operation::retry(&client, &bank_id, &operation_id, verbose, output_format) } }, @@ -976,24 +1551,88 @@ fn run() -> Result<()> { MentalModelCommands::List { bank_id } => { commands::mental_model::list(&client, &bank_id, verbose, output_format) } - MentalModelCommands::Get { bank_id, mental_model_id } => { - commands::mental_model::get(&client, &bank_id, &mental_model_id, verbose, output_format) - } - MentalModelCommands::Create { bank_id, name, source_query, id } => { - commands::mental_model::create(&client, &bank_id, &name, &source_query, id.as_deref(), verbose, output_format) - } - MentalModelCommands::Update { bank_id, mental_model_id, name } => { - commands::mental_model::update(&client, &bank_id, &mental_model_id, name, verbose, output_format) - } - MentalModelCommands::Delete { bank_id, mental_model_id, yes } => { - commands::mental_model::delete(&client, &bank_id, &mental_model_id, yes, verbose, output_format) - } - MentalModelCommands::Refresh { bank_id, mental_model_id } => { - commands::mental_model::refresh(&client, &bank_id, &mental_model_id, verbose, output_format) - } - MentalModelCommands::History { bank_id, mental_model_id } => { - commands::mental_model::history(&client, &bank_id, &mental_model_id, verbose, output_format) - } + MentalModelCommands::Get { + bank_id, + mental_model_id, + } => commands::mental_model::get( + &client, + &bank_id, + &mental_model_id, + verbose, + output_format, + ), + MentalModelCommands::Create { + bank_id, + name, + source_query, + id, + tags, + max_tokens, + trigger_refresh_after_consolidation, + } => commands::mental_model::create( + &client, + &bank_id, + &name, + &source_query, + id.as_deref(), + tags, + max_tokens, + trigger_refresh_after_consolidation, + verbose, + output_format, + ), + MentalModelCommands::Update { + bank_id, + mental_model_id, + name, + source_query, + max_tokens, + tags, + trigger_refresh_after_consolidation, + } => commands::mental_model::update( + &client, + &bank_id, + &mental_model_id, + name, + source_query, + max_tokens, + tags, + trigger_refresh_after_consolidation, + verbose, + output_format, + ), + MentalModelCommands::Delete { + bank_id, + mental_model_id, + yes, + } => commands::mental_model::delete( + &client, + &bank_id, + &mental_model_id, + yes, + verbose, + output_format, + ), + MentalModelCommands::Refresh { + bank_id, + mental_model_id, + } => commands::mental_model::refresh( + &client, + &bank_id, + &mental_model_id, + verbose, + output_format, + ), + MentalModelCommands::History { + bank_id, + mental_model_id, + } => commands::mental_model::history( + &client, + &bank_id, + &mental_model_id, + verbose, + output_format, + ), }, // Directive commands @@ -1001,18 +1640,150 @@ fn run() -> Result<()> { DirectiveCommands::List { bank_id } => { commands::directive::list(&client, &bank_id, verbose, output_format) } - DirectiveCommands::Get { bank_id, directive_id } => { - commands::directive::get(&client, &bank_id, &directive_id, verbose, output_format) - } - DirectiveCommands::Create { bank_id, name, content } => { - commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format) - } - DirectiveCommands::Update { bank_id, directive_id, name, content, is_active } => { - commands::directive::update(&client, &bank_id, &directive_id, name, content, is_active, verbose, output_format) - } - DirectiveCommands::Delete { bank_id, directive_id, yes } => { - commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format) + DirectiveCommands::Get { + bank_id, + directive_id, + } => commands::directive::get(&client, &bank_id, &directive_id, verbose, output_format), + DirectiveCommands::Create { + bank_id, + name, + content, + priority, + } => commands::directive::create( + &client, + &bank_id, + &name, + &content, + priority, + verbose, + output_format, + ), + DirectiveCommands::Update { + bank_id, + directive_id, + name, + content, + is_active, + priority, + } => commands::directive::update( + &client, + &bank_id, + &directive_id, + name, + content, + is_active, + priority, + verbose, + output_format, + ), + DirectiveCommands::Delete { + bank_id, + directive_id, + yes, + } => commands::directive::delete( + &client, + &bank_id, + &directive_id, + yes, + verbose, + output_format, + ), + }, + + // Webhook commands + Commands::Webhook(wh_cmd) => match wh_cmd { + WebhookCommands::List { bank_id } => { + commands::webhook::list(&client, &bank_id, verbose, output_format) } + WebhookCommands::Create { + bank_id, + url, + event_types, + disabled, + secret, + } => commands::webhook::create( + &client, + &bank_id, + &url, + event_types, + !disabled, + secret, + verbose, + output_format, + ), + WebhookCommands::Update { + bank_id, + webhook_id, + url, + event_types, + enabled, + secret, + } => commands::webhook::update( + &client, + &bank_id, + &webhook_id, + url, + event_types, + enabled, + secret, + verbose, + output_format, + ), + WebhookCommands::Delete { + bank_id, + webhook_id, + yes, + } => commands::webhook::delete( + &client, + &bank_id, + &webhook_id, + yes, + verbose, + output_format, + ), + WebhookCommands::Deliveries { + bank_id, + webhook_id, + cursor, + limit, + } => commands::webhook::deliveries( + &client, + &bank_id, + &webhook_id, + cursor, + limit, + verbose, + output_format, + ), + }, + + // Audit commands + Commands::Audit(audit_cmd) => match audit_cmd { + AuditCommands::List { + bank_id, + action, + transport, + start_date, + end_date, + limit, + offset, + } => commands::audit::list( + &client, + &bank_id, + action, + transport, + start_date, + end_date, + limit, + offset, + verbose, + output_format, + ), + AuditCommands::Stats { + bank_id, + action, + period, + } => commands::audit::stats(&client, &bank_id, action, period, verbose, output_format), }, }; @@ -1024,7 +1795,11 @@ fn run() -> Result<()> { Ok(()) } -fn handle_configure(api_url: Option, api_key: Option, output_format: OutputFormat) -> Result<()> { +fn handle_configure( + api_url: Option, + api_key: Option, + output_format: OutputFormat, +) -> Result<()> { // Load current config to show current state let current_config = Config::load().ok(); @@ -1038,7 +1813,7 @@ fn handle_configure(api_url: Option, api_key: Option, output_for if let Some(ref key) = config.api_key { // Mask the API key for display let masked = if key.len() > 8 { - format!("{}...{}", &key[..4], &key[key.len()-4..]) + format!("{}...{}", &key[..4], &key[key.len() - 4..]) } else { "****".to_string() }; @@ -1080,7 +1855,7 @@ fn handle_configure(api_url: Option, api_key: Option, output_for println!(" API URL: {}", new_api_url); if let Some(ref key) = new_api_key { let masked = if key.len() > 8 { - format!("{}...{}", &key[..4], &key[key.len()-4..]) + format!("{}...{}", &key[..4], &key[key.len() - 4..]) } else { "****".to_string() }; diff --git a/hindsight-dev/hindsight_dev/cli_coverage_check.py b/hindsight-dev/hindsight_dev/cli_coverage_check.py new file mode 100644 index 00000000..87ab57d0 --- /dev/null +++ b/hindsight-dev/hindsight_dev/cli_coverage_check.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +Check that the Hindsight CLI covers every operation AND every request-body +parameter in the OpenAPI spec. + +Endpoint-level check +-------------------- +For each ``operationId`` in ``hindsight-docs/static/openapi.json`` we require +that one of the following is true: + 1. A call of the form ``.(`` appears somewhere in + ``hindsight-cli/src/**/*.rs``. The progenitor-generated Rust client + methods are named identically to the OpenAPI ``operationId``, so this is + a strong signal the CLI wires the endpoint. + 2. The ``operationId`` is listed in ``hindsight-cli/.openapi-coverage.toml`` + under ``[skip]`` with a reason. + +Parameter-level check +--------------------- +For each operation whose request body schema has named properties, we require +that every property is one of: + 1. Present in ``hindsight-cli/src/main.rs`` as a clap command variant field + (``field_name: ``) or as a ``long = "..."`` clap attribute. main.rs + is where the user-facing CLI args live, so absence there means the user + has no way to set that field. + 2. Listed in ``.openapi-coverage.toml`` under + ``[fields.]`` with a reason explaining why it is not + exposed (for example, flattened into several CLI flags, or a complex + nested struct). + +Stale manifest entries (skips for things that no longer need skipping) also +fail the check. + +Usage: + cd hindsight-dev + uv run cli-coverage-check +""" + +from __future__ import annotations + +import json +import re +import sys +import tomllib +from pathlib import Path + +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} + +# OpenAPI request body fields sometimes collide with Rust reserved keywords; +# the progenitor client serde-renames them (e.g. `async` → `async_`). When we +# look up these fields in main.rs, we also accept the aliased name. +RUST_KEYWORD_ALIASES: dict[str, set[str]] = { + "async": {"async_", "async_mode", "r#async"}, + "type": {"type_", "r#type"}, +} + + +def get_repo_root() -> Path: + here = Path(__file__).resolve().parent + for parent in (here, *here.parents): + if (parent / "hindsight-cli").is_dir() and (parent / "hindsight-docs").is_dir(): + return parent + raise RuntimeError(f"Could not locate repo root from {here}") + + +def load_operations(spec_path: Path) -> tuple[set[str], dict[str, list[str]]]: + """Return (operation_ids, op_id -> list of request-body property names). + + Operations without a request body are omitted from the property map. + """ + spec = json.loads(spec_path.read_text()) + op_ids: set[str] = set() + op_props: dict[str, list[str]] = {} + schemas = spec.get("components", {}).get("schemas", {}) + + for path_item in spec.get("paths", {}).values(): + for method, op in path_item.items(): + if method.lower() not in HTTP_METHODS or not isinstance(op, dict): + continue + op_id = op.get("operationId") + if not op_id: + continue + op_ids.add(op_id) + + body = op.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema") + if not body: + continue + if "$ref" in body: + schema_name = body["$ref"].split("/")[-1] + schema = schemas.get(schema_name, {}) + else: + schema = body + props = list((schema.get("properties") or {}).keys()) + if props: + op_props[op_id] = props + + return op_ids, op_props + + +def load_manifest(path: Path) -> tuple[dict[str, str], dict[str, dict[str, str]]]: + """Return (operation-level skips, per-operation field skips).""" + if not path.exists(): + return {}, {} + data = tomllib.loads(path.read_text()) + + skip_raw = data.get("skip", {}) or {} + if not isinstance(skip_raw, dict): + raise ValueError(f"{path}: [skip] must be a table") + op_skips: dict[str, str] = {} + for op_id, reason in skip_raw.items(): + if not isinstance(reason, str) or not reason.strip(): + raise ValueError(f"{path}: skip.{op_id} must be a non-empty string reason") + op_skips[op_id] = reason.strip() + + fields_raw = data.get("fields", {}) or {} + if not isinstance(fields_raw, dict): + raise ValueError(f"{path}: [fields] must be a table") + field_skips: dict[str, dict[str, str]] = {} + for op_id, table in fields_raw.items(): + if not isinstance(table, dict): + raise ValueError(f"{path}: [fields.{op_id}] must be a table") + per_op: dict[str, str] = {} + for field, reason in table.items(): + if not isinstance(reason, str) or not reason.strip(): + raise ValueError(f"{path}: fields.{op_id}.{field} must be a non-empty string reason") + per_op[field] = reason.strip() + field_skips[op_id] = per_op + + return op_skips, field_skips + + +def load_rust_source(cli_src: Path) -> str: + chunks: list[str] = [] + for rs in cli_src.rglob("*.rs"): + try: + chunks.append(rs.read_text()) + except OSError: + continue + return "\n".join(chunks) + + +def find_implemented_ops(all_src: str, op_ids: set[str]) -> set[str]: + """Subset of op_ids that appear as ``.(`` in any Rust file.""" + implemented: set[str] = set() + for op_id in op_ids: + if re.search(r"\.\s*" + re.escape(op_id) + r"\s*\(", all_src): + implemented.add(op_id) + return implemented + + +def field_in_main_rs(prop: str, main_src: str) -> bool: + """Is `prop` exposed as a CLI arg in main.rs? + + We look for either a struct variant field declaration + (``field_name: ``) or an explicit clap ``long = "..."`` attribute + matching the property name (snake_case or kebab-case). + """ + kebab = prop.replace("_", "-") + patterns = [ + # Struct variant field declaration. We require a type after the colon + # to distinguish from URL path tokens like `/v1/default/banks/...`. + r"\b" + re.escape(prop) + r"\s*:\s*(?:Option<|Vec<|bool|i\d+|u\d+|f\d+|String|PathBuf|Path)", + # Explicit clap long attribute (either spelling). + rf'long\s*=\s*"{re.escape(prop)}"', + rf'long\s*=\s*"{re.escape(kebab)}"', + ] + return any(re.search(p, main_src) for p in patterns) + + +def field_covered(prop: str, main_src: str) -> bool: + if field_in_main_rs(prop, main_src): + return True + for alias in RUST_KEYWORD_ALIASES.get(prop, set()): + if field_in_main_rs(alias, main_src): + return True + return False + + +def main() -> None: + root = get_repo_root() + spec_path = root / "hindsight-docs" / "static" / "openapi.json" + manifest_path = root / "hindsight-cli" / ".openapi-coverage.toml" + cli_src_dir = root / "hindsight-cli" / "src" + main_rs_path = cli_src_dir / "main.rs" + + if not spec_path.exists(): + print(f"ERROR: OpenAPI spec not found at {spec_path}", file=sys.stderr) + print(" Run ./scripts/generate-openapi.sh first.", file=sys.stderr) + sys.exit(1) + if not cli_src_dir.is_dir(): + print(f"ERROR: CLI source dir not found at {cli_src_dir}", file=sys.stderr) + sys.exit(1) + if not main_rs_path.exists(): + print(f"ERROR: main.rs not found at {main_rs_path}", file=sys.stderr) + sys.exit(1) + + spec_ops, op_props = load_operations(spec_path) + op_skips, field_skips = load_manifest(manifest_path) + all_src = load_rust_source(cli_src_dir) + main_src = main_rs_path.read_text() + implemented = find_implemented_ops(all_src, spec_ops) + + errors: list[str] = [] + + # ----- endpoint-level ----- + unmapped_ops = sorted(spec_ops - implemented - op_skips.keys()) + for op_id in unmapped_ops: + errors.append( + f"MISSING OP {op_id}: not called from hindsight-cli/src/ and not in .openapi-coverage.toml [skip]" + ) + + stale_op_skips = sorted(op_skips.keys() - spec_ops) + for op_id in stale_op_skips: + errors.append( + f"STALE OP {op_id}: listed in [skip] but not present in " + f"openapi.json. Remove it from .openapi-coverage.toml." + ) + + redundant_op_skips = sorted(op_skips.keys() & implemented) + for op_id in redundant_op_skips: + errors.append( + f"REDUNDANT OP {op_id}: listed in [skip] but is now called from " + f"the CLI. Remove it from .openapi-coverage.toml." + ) + + # ----- parameter-level ----- + total_props = 0 + skipped_props = 0 + covered_props = 0 + for op_id, props in op_props.items(): + # If the whole operation is intentionally skipped, its params don't + # need to be covered either. + if op_id in op_skips: + continue + per_op_skips = field_skips.get(op_id, {}) + for prop in props: + total_props += 1 + if prop in per_op_skips: + skipped_props += 1 + continue + if field_covered(prop, main_src): + covered_props += 1 + continue + errors.append( + f"MISSING PARAM {op_id}.{prop}: request body field not exposed " + f"as a CLI arg in main.rs and not listed in " + f"[fields.{op_id}]" + ) + + # Stale field skips (unknown operations or unknown fields) + for op_id, per_op in field_skips.items(): + if op_id not in op_props: + for field in per_op: + errors.append( + f"STALE PARAM {op_id}.{field}: [fields.{op_id}] references " + f"an operation with no request body in the spec" + ) + continue + known = set(op_props[op_id]) + for field in per_op: + if field not in known: + errors.append( + f"STALE PARAM {op_id}.{field}: field not present in the " + f"{op_id} request schema. Remove from [fields.{op_id}]." + ) + + # Note: we intentionally do NOT flag [fields.] entries as "redundant" + # when the field name also appears in main.rs. Field names like + # `retain_mission` can be a struct variant field under one command (e.g. + # `bank set-config`) while being legitimately skipped for another + # (e.g. `bank create`, which the skip points to). A global redundancy + # check can't tell those apart without a per-operation → command-variant + # mapping, so we keep only the stricter checks above. + + total = len(spec_ops) + impl_count = len(implemented & spec_ops) + op_skip_count = len(op_skips.keys() & spec_ops) + + print("Hindsight CLI OpenAPI coverage check") + print(f" Spec: {spec_path.relative_to(root)}") + print(f" Manifest: {manifest_path.relative_to(root)}") + print(f" Operations: {total}") + print(f" implemented: {impl_count}") + print(f" skipped: {op_skip_count}") + print(f" Request params: {total_props}") + print(f" covered: {covered_props}") + print(f" skipped: {skipped_props}") + print() + + if errors: + print(f"FAILED: {len(errors)} issue(s):") + for e in errors: + print(f" {e}") + print() + print( + "Fix by either exposing the endpoint/field as a CLI arg, or adding\n" + "an entry to hindsight-cli/.openapi-coverage.toml with a reason." + ) + sys.exit(1) + + print(f"OK: all {total} operations and {total_props} request params covered.") + + +if __name__ == "__main__": + main() diff --git a/hindsight-dev/pyproject.toml b/hindsight-dev/pyproject.toml index 3b67b23f..c7e3b5f9 100644 --- a/hindsight-dev/pyproject.toml +++ b/hindsight-dev/pyproject.toml @@ -36,6 +36,7 @@ generate-changelog = "hindsight_dev.generate_changelog:main" sync-cookbook = "hindsight_dev.sync_cookbook:main" generate-llms-full = "hindsight_dev.generate_llms_full:main" check-openapi-compatibility = "hindsight_dev.check_openapi_compatibility:main" +cli-coverage-check = "hindsight_dev.cli_coverage_check:main" [dependency-groups] dev = [