Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 99fb8ee4fd feat(cli): cover every OpenAPI endpoint and request-body param
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.<op>]` 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.
2026-04-10 16:13:24 +02:00
16 changed files with 2990 additions and 287 deletions
+35
View File
@@ -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
+90
View File
@@ -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: <type>`) or a `long = "..."`
# attribute, OR listed under [fields.<operation_id>] 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)."
+35
View File
@@ -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
+608 -84
View File
@@ -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<Vec<types::BankListItem>> {
@@ -102,7 +107,11 @@ impl ApiClient {
})
}
pub fn get_profile(&self, agent_id: &str, _verbose: bool) -> Result<types::BankProfileResponse> {
pub fn get_profile(
&self,
agent_id: &str,
_verbose: bool,
) -> Result<types::BankProfileResponse> {
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<types::BankProfileResponse> {
pub fn update_agent_name(
&self,
agent_id: &str,
name: &str,
_verbose: bool,
) -> Result<types::BankProfileResponse> {
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<types::BackgroundResponse> {
pub fn add_background(
&self,
agent_id: &str,
content: &str,
update_disposition: bool,
_verbose: bool,
) -> Result<types::BackgroundResponse> {
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<types::RecallResponse> {
pub fn recall(
&self,
agent_id: &str,
request: &types::RecallRequest,
verbose: bool,
) -> Result<types::RecallResponse> {
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<types::ReflectResponse> {
pub fn reflect(
&self,
agent_id: &str,
request: &types::ReflectRequest,
_verbose: bool,
) -> Result<types::ReflectResponse> {
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<MemoryPutResult> {
pub fn retain(
&self,
agent_id: &str,
request: &types::RetainRequest,
_async_mode: bool,
_verbose: bool,
) -> Result<MemoryPutResult> {
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<FileRetainResult> {
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<serde_json::Value> = 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<String>)> {
pub fn poll_operation(
&self,
agent_id: &str,
operation_id: &str,
verbose: bool,
) -> Result<(bool, Option<String>)> {
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<types::DeleteResponse> {
pub fn delete_memory(
&self,
_agent_id: &str,
_unit_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
// 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<types::DeleteResponse> {
pub fn clear_memories(
&self,
agent_id: &str,
fact_type: Option<&str>,
_verbose: bool,
) -> Result<types::DeleteResponse> {
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<i32>, offset: Option<i32>, _verbose: bool) -> Result<types::ListDocumentsResponse> {
pub fn list_documents(
&self,
agent_id: &str,
q: Option<&str>,
limit: Option<i32>,
offset: Option<i32>,
_verbose: bool,
) -> Result<types::ListDocumentsResponse> {
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<types::DocumentResponse> {
pub fn get_document(
&self,
agent_id: &str,
document_id: &str,
_verbose: bool,
) -> Result<types::DocumentResponse> {
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<types::DeleteResponse> {
pub fn delete_document(
&self,
agent_id: &str,
document_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
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<OperationsResponse> {
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<types::DeleteResponse> {
pub fn cancel_operation(
&self,
agent_id: &str,
operation_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
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<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::ListMemoryUnitsResponse> {
pub fn list_memories(
&self,
bank_id: &str,
type_filter: Option<&str>,
q: Option<&str>,
limit: Option<i64>,
offset: Option<i64>,
_verbose: bool,
) -> Result<types::ListMemoryUnitsResponse> {
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<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::EntityListResponse> {
pub fn list_entities(
&self,
bank_id: &str,
limit: Option<i64>,
offset: Option<i64>,
_verbose: bool,
) -> Result<types::EntityListResponse> {
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<types::EntityDetailResponse> {
pub fn get_entity(
&self,
bank_id: &str,
entity_id: &str,
_verbose: bool,
) -> Result<types::EntityDetailResponse> {
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<types::EntityDetailResponse> {
pub fn regenerate_entity(
&self,
bank_id: &str,
entity_id: &str,
_verbose: bool,
) -> Result<types::EntityDetailResponse> {
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<serde_json::Value> {
pub fn get_memory(
&self,
bank_id: &str,
memory_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
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<types::BankProfileResponse> {
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<types::GraphDataResponse> {
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<types::BankConfigResponse> {
self.runtime.block_on(async {
// Convert HashMap to serde_json::Map
let updates_map: serde_json::Map<String, serde_json::Value> = 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<String, serde_json::Value> =
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<types::ListTagsResponse> {
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<types::OperationStatusResponse> {
pub fn get_operation(
&self,
bank_id: &str,
operation_id: &str,
_verbose: bool,
) -> Result<types::OperationStatusResponse> {
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<types::MentalModelListResponse> {
pub fn list_mental_models(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::MentalModelListResponse> {
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<types::MentalModelResponse> {
pub fn get_mental_model(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<types::MentalModelResponse> {
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<types::CreateMentalModelResponse> {
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<types::MentalModelResponse> {
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<serde_json::Value> {
pub fn delete_mental_model(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
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<types::AsyncOperationSubmitResponse> {
pub fn refresh_mental_model(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<types::AsyncOperationSubmitResponse> {
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<serde_json::Value> {
pub fn get_mental_model_history(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
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<types::DirectiveListResponse> {
pub fn list_directives(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::DirectiveListResponse> {
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<types::DirectiveResponse> {
pub fn get_directive(
&self,
bank_id: &str,
directive_id: &str,
_verbose: bool,
) -> Result<types::DirectiveResponse> {
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<types::DirectiveResponse> {
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<serde_json::Value> {
pub fn delete_directive(
&self,
bank_id: &str,
directive_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
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<types::ConsolidationResponse> {
pub fn trigger_consolidation(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::ConsolidationResponse> {
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<types::DeleteResponse> {
pub fn clear_observations(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
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<types::WebhookListResponse> {
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<types::WebhookResponse> {
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<types::WebhookResponse> {
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<types::DeleteResponse> {
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<i64>,
_verbose: bool,
) -> Result<types::WebhookDeliveryListResponse> {
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<u64>,
offset: Option<u64>,
_verbose: bool,
) -> Result<types::AuditLogListResponse> {
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<types::AuditLogStatsResponse> {
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<serde_json::Value> {
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<types::BankTemplateManifest> {
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<types::BankTemplateImportResponse> {
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<Vec<String>>,
_verbose: bool,
) -> Result<types::UpdateDocumentResponse> {
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<serde_json::Value> {
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<types::ClearMemoryObservationsResponse> {
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<types::RetryOperationResponse> {
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<types::RecoverConsolidationResponse> {
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<types::BankProfileResponse> {
self.runtime.block_on(async {
let to_nz = |v: u64| -> Result<std::num::NonZeroU64> {
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)]
+118
View File
@@ -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<String>,
transport: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
limit: Option<u64>,
offset: Option<u64>,
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<String>,
period: Option<String>,
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(())
}
+342 -46
View File
@@ -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<String, serde_json::Value> = 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<std::path::PathBuf>,
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(())
}
+10 -4
View File
@@ -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<String>,
content: Option<String>,
is_active: Option<bool>,
priority: Option<i64>,
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,
};
+73 -15
View File
@@ -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<Vec<String>>,
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(())
}
+166 -36
View File
@@ -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<String>) -> 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<String>,
tags_match: Option<String>,
query_timestamp: Option<String>,
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<String>,
tags_match: Option<String>,
include_facts: bool,
fact_types: Option<Vec<String>>,
exclude_mental_models: bool,
exclude_mental_model_ids: Option<Vec<String>>,
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<String, serde_json::Value> = serde_json::from_str(&schema_content)
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
let schema: serde_json::Map<String, serde_json::Value> =
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::<Vec<_>>()
});
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<String>,
context: Option<String>,
r#async: bool,
document_tags: Option<Vec<String>>,
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)),
+52 -9
View File
@@ -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<String>,
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<String>,
source_query: Option<String>,
max_tokens: Option<i64>,
tags: Option<Vec<String>>,
trigger_refresh_after_consolidation: Option<bool>,
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);
+3 -1
View File
@@ -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;
+41 -4
View File
@@ -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(())
}
+249
View File
@@ -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<String>,
enabled: bool,
secret: Option<String>,
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<String>,
event_types: Option<Vec<String>>,
enabled: Option<bool>,
secret: Option<String>,
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<String>,
limit: Option<i64>,
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(())
}
+863 -88
View File
File diff suppressed because it is too large Load Diff
@@ -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 ``.<operation_id>(`` 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: <type>``) 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.<operation_id>]`` 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 ``.<op_id>(`` 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: <type>``) 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.<op>] 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()
+1
View File
@@ -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 = [