diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index babb9db..46bac6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,3 +106,41 @@ jobs: with: shared-key: test - run: cargo test + + integration-test: + name: Integration Test (Container) + runs-on: [self-hosted, linux, arm64] + services: + dakera: + image: ghcr.io/dakera-ai/dakera:latest + ports: + - 13300:3000 + env: + DAKERA_ROOT_API_KEY: test-integration-key + DAKERA_AUTH_ENABLED: 'false' + options: >- + --health-cmd "curl -sf http://localhost:3000/health || exit 1" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: integration-test + - name: Build dk binary + run: cargo build --release + - name: Wait for dakera server + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:13300/health && break || true + echo "Waiting for dakera server... attempt $i/30" + sleep 2 + done + curl -sf http://localhost:13300/health || (echo "ERROR: dakera server failed to start" && exit 1) + - name: Run container integration tests + env: + DAKERA_TEST_URL: http://localhost:13300 + DAKERA_TEST_KEY: test-integration-key + run: cargo test --test integration -- --ignored --nocapture diff --git a/README.md b/README.md index fe39f2e..a25a920 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ [![Docs](https://img.shields.io/badge/docs-dakera.ai-D4A843)](https://dakera.ai/docs) # ⚡ dakera-cli - - [![CI](https://github.com/Dakera-AI/dakera-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Dakera-AI/dakera-cli/actions/workflows/ci.yml) [![Crate](https://img.shields.io/crates/v/dakera-cli?logo=rust)](https://crates.io/crates/dakera-cli) [![License: MIT](https://img.shields.io/github/license/Dakera-AI/dakera-cli)](LICENSE) [![dakera.ai](https://img.shields.io/badge/dakera.ai-website-22c55e?style=flat-square)](https://dakera.ai) [![Docs](https://img.shields.io/badge/docs-dakera.ai%2Fdocs-3b82f6?style=flat-square)](https://dakera.ai/docs) -[![Docs](https://img.shields.io/badge/docs-dakera.ai-D4A843)](https://dakera.ai/docs) Command-line interface for Dakera AI — inspect and manage a Dakera instance from the terminal. @@ -22,7 +19,7 @@ You need a running Dakera server to connect to. The fastest way: ```bash docker run -d \ --name dakera \ - -p 3300:3300 \ + -p 3000:3000 \ -e DAKERA_ROOT_API_KEY=dk-mykey \ ghcr.io/dakera-ai/dakera:latest ``` @@ -32,9 +29,9 @@ For persistent storage (recommended): ```bash curl -sSfL https://raw.githubusercontent.com/Dakera-AI/dakera-deploy/main/docker-compose.yml \ -o docker-compose.yml -DAKERA_API_KEY=dk-mykey docker compose up -d +DAKERA_ROOT_API_KEY=dk-mykey docker compose up -d -curl http://localhost:3300/health # → {"status":"ok"} +curl http://localhost:3000/health # → {"status":"ok"} ``` Full deployment guide (Docker Compose, Kubernetes, Helm): [dakera-deploy](https://github.com/Dakera-AI/dakera-deploy) @@ -47,37 +44,332 @@ Full deployment guide (Docker Compose, Kubernetes, Helm): [dakera-deploy](https: cargo install dakera-cli ``` +Or download a pre-built binary from the [releases page](https://github.com/Dakera-AI/dakera-cli/releases). + +--- + ## Quick Start ```bash -# Connect to a Dakera instance +# Interactive setup (sets server URL + API key) dk init +# Check server health +dk health + +# Store a memory for an agent +dk memory store my-agent "User prefers concise responses" --importance 0.8 + +# Recall memories by semantic query +dk memory recall my-agent "user preferences" --top-k 5 + +# Full-text BM25 search +dk text search "user preferences" --namespace default + +# List namespaces +dk namespace list +``` + +--- + +## Configuration + +### Environment variables + +| Variable | Description | Default | +|---|---|---| +| `DAKERA_URL` | Server base URL | `http://localhost:3000` | +| `DAKERA_API_KEY` | API key for authentication | — | +| `DAKERA_PROFILE` | Named profile to use | active profile in config | + +### Config file + +`dk init` creates `~/.dakera/config.toml`: + +```toml +[server] +url = "http://localhost:3000" +api_key = "dk-mykey" + +[defaults] +namespace = "default" +``` + +### Named profiles + +```bash +# Add a profile +dk config profile add staging --url http://staging:3000 --key dk-staging-key + +# Use a profile for one command +dk --profile staging namespace list + +# Set a profile as active +dk config profile use staging +``` + +### Precedence + +Environment variables > CLI flags > config file > defaults. + +--- + +## Global Flags + +| Flag | Short | Default | Description | +|---|---|---|---| +| `--url` | `-u` | `http://localhost:3000` | Server URL | +| `--format` | `-f` | `table` | Output format: `table`, `json`, `compact` | +| `--verbose` | `-v` | false | Log HTTP requests and response timing | +| `--profile` | `-p` | — | Named server profile | + +```bash +# Machine-readable JSON output +dk --format json memory recall my-agent "recent tasks" + +# Compact single-line JSON (for piping/scripting) +dk --format compact namespace list | jq '.[].name' + +# Show HTTP request/response timing +dk --verbose memory store my-agent "new memory" +``` + +--- + +## Commands + +### `dk health` + +Check server health and connectivity. + +```bash +dk health +dk health --detailed +``` + +--- + +### `dk namespace` + +Manage namespaces. + +```bash +dk namespace list +dk namespace create my-ns +dk namespace policy --namespace my-ns +``` + +--- + +### `dk memory` + +Store, recall, search, and manage agent memories. This is the primary interface to Dakera. + +```bash # Store a memory -dk memories store \ - --agent my-agent \ - --content "User prefers concise responses" \ - --importance 0.8 +dk memory store my-agent "The user likes dark mode" --importance 0.8 --type semantic + +# Recall by semantic query +dk memory recall my-agent "UI preferences" --top-k 10 --type semantic + +# Search with full-text filters +dk memory search my-agent "dark mode" --top-k 5 + +# Get a specific memory by ID +dk memory get my-agent mem-abc123 + +# Update a memory +dk memory update my-agent mem-abc123 --content "Updated content" + +# Delete a single memory +dk memory forget my-agent mem-abc123 + +# Batch delete by filters (dry-run first!) +dk memory batch-forget my-agent --min-importance 0.3 --dry-run +dk memory batch-forget my-agent --min-importance 0.3 --max-age-days 90 + +# Update importance scores +dk memory importance my-agent --ids mem-1,mem-2 --value 0.9 + +# Consolidate similar memories into summaries +dk memory consolidate my-agent --dry-run + +# Submit recall quality feedback +dk memory feedback my-agent mem-abc123 "Highly relevant" --score 1.0 +``` + +--- + +### `dk text` + +Full-text (BM25) search across memories. + +```bash +# Search all namespaces +dk text search "machine learning" -# Query memories -dk memories search \ - --agent my-agent \ - --query "user preferences" \ - --top-k 5 +# Search within a specific namespace +dk text search "temporal reasoning" --namespace my-ns --limit 20 ``` -## Connect to Dakera +--- + +### `dk session` + +Manage agent sessions. + +```bash +# Start a session +dk session start my-agent + +# End a session +dk session end sess-abc123 + +# List sessions (optionally filter to active only) +dk session list --agent-id my-agent --active-only + +# Get session details +dk session get sess-abc123 + +# List memories stored during a session +dk session memories sess-abc123 +``` + +--- + +### `dk agent` + +View and manage agents. + +```bash +dk agent list +dk agent stats my-agent +dk agent memories my-agent --type episodic --limit 20 +dk agent sessions my-agent --active-only +``` + +--- + +### `dk knowledge` + +Knowledge graph management and memory summarization. ```bash -# Set env vars (or use dk init for interactive setup) -export DAKERA_URL=http://your-server:3300 -export DAKERA_API_KEY=your-key +# Build a knowledge graph from a specific memory +dk knowledge graph my-agent --memory-id mem-abc123 --depth 3 + +# Full knowledge graph for an agent +dk knowledge full-graph my-agent --max-nodes 100 + +# Summarize a set of memories into a new memory +dk knowledge summarize my-agent --memory-ids m1,m2,m3 --dry-run + +# Find and remove duplicate memories +dk knowledge deduplicate my-agent --threshold 0.9 --dry-run ``` -## Documentation +--- + +### `dk index` + +Index management. -→ [Full docs](https://dakera.ai/docs) -→ [CLI reference](https://dakera.ai/docs/cli) +```bash +dk index stats --namespace my-ns +dk index fulltext-stats --namespace my-ns +dk index rebuild --namespace my-ns --dry-run +dk index rebuild --namespace my-ns --index-type vector --yes +``` + +--- + +### `dk keys` + +API key management. + +```bash +dk keys list +dk keys create my-key --permissions read,write +dk keys delete key-abc123 +dk keys usage key-abc123 +``` + +--- + +### `dk analytics` + +Platform analytics and metrics. + +```bash +dk analytics overview --period 24h +dk analytics latency --period 7d +dk analytics throughput --period 1h +dk analytics storage +``` + +--- + +### `dk ops` + +Operations and maintenance. + +```bash +dk ops stats +dk ops diagnostics +dk ops jobs +dk ops compact --namespace my-ns +``` + +--- + +### `dk config` + +Show or manage server configuration and profiles. + +```bash +dk config +dk config profile add staging --url http://staging:3000 +dk config profile use staging +dk config profile list +``` + +--- + +### `dk completion` + +Shell completion scripts. + +```bash +# Bash +dk completion bash --install + +# Zsh +dk completion zsh --install + +# Fish +dk completion fish --install + +# PowerShell +dk completion powershell +``` + +--- + +## Exit Codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | General error | +| 2 | Connection error (server unreachable) | +| 3 | Not found | +| 4 | Permission denied / authentication failure | +| 5 | Invalid input | +| 6 | Server-side error (5xx) | + +Scripts can check `$?` after each command. + +--- ## Related diff --git a/src/cli.rs b/src/cli.rs index b152884..c4d696f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,7 +11,7 @@ pub fn build_cli() -> Command { .author("Dakera Team") .about("Dakera CLI - Manage your AI agent memory platform from the command line") .after_help( - "Examples:\n dk health\n dk namespace list\n dk memory store my-agent 'Completed task X' --importance 0.8\n dk memory recall my-agent 'recent tasks' --top-k 5\n dk vector upsert -n my-ns --file vectors.json\n dk completion zsh --install\n\nError exit codes:\n 0 success\n 1 general error\n 2 connection error (server unreachable)\n 3 not found\n 4 permission denied\n 5 invalid input\n 6 server error", + "Examples:\n dk health\n dk namespace list\n dk memory store my-agent 'Completed task X' --importance 0.8\n dk memory recall my-agent 'recent tasks' --top-k 5\n dk text search 'user preferences' --namespace default\n dk completion zsh --install\n\nError exit codes:\n 0 success\n 1 general error\n 2 connection error (server unreachable)\n 3 not found\n 4 permission denied\n 5 invalid input\n 6 server error", ) .arg( Arg::new("url") @@ -59,7 +59,6 @@ pub fn build_cli() -> Command { ), ) .subcommand(build_namespace_command()) - .subcommand(build_vector_command()) .subcommand(build_index_command()) .subcommand(build_ops_command()) .subcommand(build_memory_command()) @@ -67,10 +66,10 @@ pub fn build_cli() -> Command { .subcommand(build_agent_command()) .subcommand(build_knowledge_command()) .subcommand(build_analytics_command()) - .subcommand(build_admin_command()) .subcommand(build_keys_command()) .subcommand(build_config_command()) .subcommand(build_completion_command()) + .subcommand(build_text_command()) } pub fn build_config_command() -> Command { @@ -224,304 +223,6 @@ pub fn build_namespace_command() -> Command { ) } -pub fn build_vector_command() -> Command { - Command::new("vector") - .about("Manage vectors") - .subcommand( - Command::new("upsert") - .about("Upsert vectors from a JSON file") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("file") - .short('f') - .long("file") - .required(true) - .help("JSON file containing vectors"), - ) - .arg( - Arg::new("batch-size") - .short('b') - .long("batch-size") - .default_value("100") - .value_parser(value_parser!(usize)) - .help("Batch size for large files"), - ), - ) - .subcommand( - Command::new("upsert-one") - .about("Upsert a single vector") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("id") - .short('i') - .long("id") - .required(true) - .help("Vector ID"), - ) - .arg( - Arg::new("values") - .short('V') - .long("values") - .required(true) - .value_delimiter(',') - .value_parser(value_parser!(f32)) - .help("Vector values (comma-separated floats)"), - ) - .arg( - Arg::new("metadata") - .short('m') - .long("metadata") - .help("Optional metadata as JSON string"), - ), - ) - .subcommand( - Command::new("query") - .about("Query for similar vectors") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("values") - .short('V') - .long("values") - .required(true) - .value_delimiter(',') - .value_parser(value_parser!(f32)) - .help("Query vector values (comma-separated floats)"), - ) - .arg( - Arg::new("top-k") - .short('k') - .long("top-k") - .default_value("10") - .value_parser(value_parser!(u32)) - .help("Number of results to return"), - ) - .arg( - Arg::new("include-metadata") - .short('m') - .long("include-metadata") - .action(ArgAction::SetTrue) - .help("Include metadata in results"), - ) - .arg( - Arg::new("filter") - .long("filter") - .help("Filter expression as JSON"), - ), - ) - .subcommand( - Command::new("query-file") - .about("Query from a file") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("file") - .short('f') - .long("file") - .required(true) - .help("JSON file containing query"), - ), - ) - .subcommand( - Command::new("delete") - .about("Delete vectors by ID") - .after_help("Examples:\n dk vector delete -n my-ns --ids id1,id2 --dry-run\n dk vector delete -n my-ns --all --dry-run\n dk vector delete -n my-ns --ids id1,id2 --yes") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("ids") - .short('i') - .long("ids") - .value_delimiter(',') - .help("Vector IDs to delete"), - ) - .arg( - Arg::new("all") - .long("all") - .action(ArgAction::SetTrue) - .help("Delete all vectors (dangerous!)"), - ) - .arg( - Arg::new("yes") - .short('y') - .long("yes") - .action(ArgAction::SetTrue) - .help("Skip confirmation prompt"), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .action(ArgAction::SetTrue) - .help("Show what would be deleted without making any changes"), - ), - ) - .subcommand( - Command::new("multi-search") - .about("Multi-vector search with positive/negative vectors and MMR") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("file") - .short('f') - .long("file") - .required(true) - .help("JSON file with multi-vector search request"), - ), - ) - .subcommand( - Command::new("unified-query") - .about("Unified query combining vector and text search") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("file") - .short('f') - .long("file") - .required(true) - .help("JSON file with unified query request"), - ), - ) - .subcommand( - Command::new("aggregate") - .about("Aggregate vectors with grouping") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("file") - .short('f') - .long("file") - .required(true) - .help("JSON file with aggregation request"), - ), - ) - .subcommand( - Command::new("export") - .about("Export vectors with pagination") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("cursor") - .short('c') - .long("cursor") - .help("Pagination cursor from previous export"), - ) - .arg( - Arg::new("limit") - .short('l') - .long("limit") - .default_value("100") - .value_parser(value_parser!(u32)) - .help("Maximum number of vectors to export"), - ) - .arg( - Arg::new("include-vectors") - .long("include-vectors") - .action(ArgAction::SetTrue) - .help("Include vector values in export"), - ), - ) - .subcommand( - Command::new("explain") - .about("Explain query execution plan") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("values") - .short('V') - .long("values") - .required(true) - .value_delimiter(',') - .value_parser(value_parser!(f32)) - .help("Query vector values (comma-separated floats)"), - ) - .arg( - Arg::new("top-k") - .short('k') - .long("top-k") - .default_value("10") - .value_parser(value_parser!(u32)) - .help("Number of results to return"), - ) - .arg( - Arg::new("include-metadata") - .short('m') - .long("include-metadata") - .action(ArgAction::SetTrue) - .help("Include metadata in results"), - ), - ) - .subcommand( - Command::new("upsert-columns") - .about("Column-format vector upsert from JSON file") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("file") - .short('f') - .long("file") - .required(true) - .help("JSON file with column upsert data"), - ), - ) -} - pub fn build_index_command() -> Command { Command::new("index") .about("Manage indexes") @@ -802,6 +503,36 @@ pub fn build_memory_command() -> Command { .help("Relevance score (0.0 to 1.0)"), ), ) + .subcommand( + Command::new("batch-forget") + .about("Batch delete memories matching filters") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("type") + .short('t') + .long("type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Delete memories of this type"), + ) + .arg( + Arg::new("min-importance") + .long("min-importance") + .value_parser(value_parser!(f32)) + .help("Delete memories with importance below this value"), + ) + .arg( + Arg::new("max-age-days") + .long("max-age-days") + .value_parser(value_parser!(u32)) + .help("Delete memories older than this many days"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Preview deletions without removing any memories"), + ), + ) } pub fn build_session_command() -> Command { @@ -1022,136 +753,6 @@ pub fn build_knowledge_command() -> Command { ) } -pub fn build_admin_command() -> Command { - Command::new("admin") - .about("Cluster administration, caching, backups, and configuration") - .subcommand(Command::new("cluster-status").about("Get cluster status overview")) - .subcommand(Command::new("cluster-nodes").about("List cluster nodes")) - .subcommand( - Command::new("optimize") - .about("Optimize a namespace (compact indexes, reclaim space)") - .arg( - Arg::new("namespace") - .required(true) - .help("Namespace to optimize"), - ), - ) - .subcommand( - Command::new("index-stats") - .about("Get index statistics for a namespace") - .arg(Arg::new("namespace").required(true).help("Namespace name")), - ) - .subcommand( - Command::new("rebuild-indexes") - .about("Rebuild indexes for a namespace") - .arg(Arg::new("namespace").required(true).help("Namespace name")), - ) - .subcommand(Command::new("cache-stats").about("Get cache statistics")) - .subcommand( - Command::new("cache-clear") - .about("Clear cache (optionally for a specific namespace)") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Namespace to clear cache for (all if omitted)"), - ), - ) - .subcommand(Command::new("config-get").about("Get current server configuration")) - .subcommand( - Command::new("config-set") - .about("Update a configuration value") - .arg( - Arg::new("key") - .short('k') - .long("key") - .required(true) - .help("Configuration key"), - ) - .arg( - Arg::new("value") - .short('V') - .long("value") - .required(true) - .help("Configuration value (string or JSON)"), - ), - ) - .subcommand(Command::new("quotas-get").about("List all namespace quotas")) - .subcommand( - Command::new("quotas-set") - .about("Set namespace quotas") - .arg( - Arg::new("data") - .short('d') - .long("data") - .required(true) - .help("Quota configuration as JSON string"), - ), - ) - .subcommand( - Command::new("slow-queries") - .about("List slow queries") - .arg( - Arg::new("limit") - .short('l') - .long("limit") - .default_value("20") - .value_parser(value_parser!(u32)) - .help("Maximum number of queries to return"), - ) - .arg( - Arg::new("min-duration") - .long("min-duration") - .value_parser(value_parser!(f64)) - .help("Minimum duration in milliseconds"), - ), - ) - .subcommand( - Command::new("backup-create") - .about("Create a new backup") - .arg( - Arg::new("no-data") - .long("no-data") - .action(ArgAction::SetTrue) - .help("Create schema-only backup without vector data"), - ), - ) - .subcommand(Command::new("backup-list").about("List all backups")) - .subcommand( - Command::new("backup-restore") - .about("Restore from a backup") - .arg( - Arg::new("backup_id") - .required(true) - .help("Backup ID to restore"), - ), - ) - .subcommand( - Command::new("backup-delete").about("Delete a backup").arg( - Arg::new("backup_id") - .required(true) - .help("Backup ID to delete"), - ), - ) - .subcommand( - Command::new("configure-ttl") - .about("Configure TTL (time-to-live) for a namespace") - .arg(Arg::new("namespace").required(true).help("Namespace name")) - .arg( - Arg::new("ttl-seconds") - .long("ttl-seconds") - .required(true) - .value_parser(value_parser!(u64)) - .help("TTL in seconds for vectors in this namespace"), - ) - .arg( - Arg::new("strategy") - .long("strategy") - .help("TTL strategy (e.g. delete, archive)"), - ), - ) -} - pub fn build_keys_command() -> Command { Command::new("keys") .about("Manage API keys") @@ -1268,3 +869,27 @@ pub fn build_analytics_command() -> Command { ), ) } + +pub fn build_text_command() -> Command { + Command::new("text") + .about("Full-text (BM25) search across memories") + .subcommand( + Command::new("search") + .about("BM25 full-text search") + .arg(Arg::new("query").required(true).help("Search query")) + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Namespace to search in"), + ) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .default_value("10") + .value_parser(value_parser!(u32)) + .help("Maximum number of results"), + ), + ) +} diff --git a/src/commands/admin.rs b/src/commands/admin.rs deleted file mode 100644 index cac753b..0000000 --- a/src/commands/admin.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Admin commands for cluster management, caching, backups, and configuration - -use anyhow::{Context, Result}; -use clap::ArgMatches; -use dakera_client::reqwest; -use serde_json::Value; - -use crate::context::Context as Ctx; -use crate::output; - -async fn admin_get(url: &str, path: &str) -> Result { - let client = reqwest::Client::new(); - let resp = client - .get(format!("{}{}", url, path)) - .send() - .await - .with_context(|| format!("Failed to GET {}", path))?; - - let status = resp.status(); - let body = resp.text().await?; - if !status.is_success() { - anyhow::bail!("Request failed ({}): {}", status, body); - } - serde_json::from_str(&body).with_context(|| "Failed to parse response JSON") -} - -async fn admin_post(url: &str, path: &str, body: Option<&Value>) -> Result { - let client = reqwest::Client::new(); - let mut req = client.post(format!("{}{}", url, path)); - if let Some(b) = body { - req = req.json(b); - } - let resp = req - .send() - .await - .with_context(|| format!("Failed to POST {}", path))?; - - let status = resp.status(); - let text = resp.text().await?; - if !status.is_success() { - anyhow::bail!("Request failed ({}): {}", status, text); - } - if text.is_empty() { - Ok(Value::Object(serde_json::Map::new())) - } else { - serde_json::from_str(&text).with_context(|| "Failed to parse response JSON") - } -} - -async fn admin_delete(url: &str, path: &str) -> Result { - let client = reqwest::Client::new(); - let resp = client - .delete(format!("{}{}", url, path)) - .send() - .await - .with_context(|| format!("Failed to DELETE {}", path))?; - - let status = resp.status(); - let text = resp.text().await?; - if !status.is_success() { - anyhow::bail!("Request failed ({}): {}", status, text); - } - if text.is_empty() { - Ok(Value::Object(serde_json::Map::new())) - } else { - serde_json::from_str(&text).with_context(|| "Failed to parse response JSON") - } -} - -async fn admin_put(url: &str, path: &str, body: &Value) -> Result { - let client = reqwest::Client::new(); - let resp = client - .put(format!("{}{}", url, path)) - .json(body) - .send() - .await - .with_context(|| format!("Failed to PUT {}", path))?; - - let status = resp.status(); - let text = resp.text().await?; - if !status.is_success() { - anyhow::bail!("Request failed ({}): {}", status, text); - } - if text.is_empty() { - Ok(Value::Object(serde_json::Map::new())) - } else { - serde_json::from_str(&text).with_context(|| "Failed to parse response JSON") - } -} - -pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { - match matches.subcommand() { - Some(("cluster-status", _sub)) => { - let path = "/admin/cluster/status"; - let t = ctx.log_request("GET", path); - let result = admin_get(&ctx.url, path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Cluster Status"); - output::print_item(&result?, ctx.format); - } - - Some(("cluster-nodes", _sub)) => { - let path = "/admin/cluster/nodes"; - let t = ctx.log_request("GET", path); - let result = admin_get(&ctx.url, path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Cluster Nodes"); - output::print_item(&result?, ctx.format); - } - - Some(("optimize", sub)) => { - let namespace = sub.get_one::("namespace").unwrap(); - let path = format!("/admin/namespaces/{}/optimize", namespace); - let t = ctx.log_request("POST", &path); - let result = admin_post(&ctx.url, &path, None).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("Namespace '{}' optimization started", namespace)); - output::print_item(&result?, ctx.format); - } - - Some(("index-stats", sub)) => { - let namespace = sub.get_one::("namespace").unwrap(); - let path = format!("/admin/indexes/stats?namespace={}", namespace); - let t = ctx.log_request("GET", &path); - let result = admin_get(&ctx.url, &path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info(&format!("Index stats for '{}'", namespace)); - output::print_item(&result?, ctx.format); - } - - Some(("rebuild-indexes", sub)) => { - let namespace = sub.get_one::("namespace").unwrap(); - let path = "/admin/indexes/rebuild"; - let body = serde_json::json!({ "namespace": namespace }); - let t = ctx.log_request("POST", path); - let result = admin_post(&ctx.url, path, Some(&body)).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("Index rebuild started for '{}'", namespace)); - output::print_item(&result?, ctx.format); - } - - Some(("cache-stats", _sub)) => { - let path = "/admin/cache/stats"; - let t = ctx.log_request("GET", path); - let result = admin_get(&ctx.url, path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Cache Statistics"); - output::print_item(&result?, ctx.format); - } - - Some(("cache-clear", sub)) => { - let namespace = sub.get_one::("namespace"); - let body = match namespace { - Some(ns) => serde_json::json!({ "namespace": ns }), - None => serde_json::json!({}), - }; - let path = "/admin/cache/clear"; - let t = ctx.log_request("POST", path); - let result = admin_post(&ctx.url, path, Some(&body)).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - if let Some(ns) = namespace { - output::success(&format!("Cache cleared for namespace '{}'", ns)); - } else { - output::success("Cache cleared for all namespaces"); - } - output::print_item(&result?, ctx.format); - } - - Some(("config-get", _sub)) => { - let path = "/admin/config"; - let t = ctx.log_request("GET", path); - let result = admin_get(&ctx.url, path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Server Configuration"); - output::print_item(&result?, ctx.format); - } - - Some(("config-set", sub)) => { - let key = sub.get_one::("key").unwrap(); - let value = sub.get_one::("value").unwrap(); - let json_value: Value = - serde_json::from_str(value).unwrap_or(Value::String(value.clone())); - let body = serde_json::json!({ key: json_value }); - let path = "/admin/config"; - let t = ctx.log_request("PUT", path); - let result = admin_put(&ctx.url, path, &body).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("Configuration updated: {} = {}", key, value)); - output::print_item(&result?, ctx.format); - } - - Some(("quotas-get", _sub)) => { - let path = "/admin/quotas"; - let t = ctx.log_request("GET", path); - let result = admin_get(&ctx.url, path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Namespace Quotas"); - output::print_item(&result?, ctx.format); - } - - Some(("quotas-set", sub)) => { - let data = sub.get_one::("data").unwrap(); - let body: Value = - serde_json::from_str(data).with_context(|| "Invalid JSON for --data")?; - let path = "/admin/quotas"; - let t = ctx.log_request("PUT", path); - let result = admin_put(&ctx.url, path, &body).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success("Quotas updated"); - output::print_item(&result?, ctx.format); - } - - Some(("slow-queries", sub)) => { - let limit = sub.get_one::("limit").copied().unwrap_or(20); - let min_duration = sub.get_one::("min-duration"); - let mut path = format!("/admin/slow-queries?limit={}", limit); - if let Some(dur) = min_duration { - path.push_str(&format!("&min_duration_ms={}", dur)); - } - let t = ctx.log_request("GET", &path); - let result = admin_get(&ctx.url, &path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Slow Queries"); - output::print_item(&result?, ctx.format); - } - - Some(("backup-create", sub)) => { - let no_data = sub.get_flag("no-data"); - let body = serde_json::json!({ "include_data": !no_data }); - let path = "/admin/backups"; - let t = ctx.log_request("POST", path); - let result = admin_post(&ctx.url, path, Some(&body)).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success("Backup created"); - output::print_item(&result?, ctx.format); - } - - Some(("backup-list", _sub)) => { - let path = "/admin/backups"; - let t = ctx.log_request("GET", path); - let result = admin_get(&ctx.url, path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::info("Backups"); - output::print_item(&result?, ctx.format); - } - - Some(("backup-restore", sub)) => { - let backup_id = sub.get_one::("backup_id").unwrap(); - let body = serde_json::json!({ "backup_id": backup_id }); - let path = "/admin/backups/restore"; - let t = ctx.log_request("POST", path); - let result = admin_post(&ctx.url, path, Some(&body)).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("Restore started from backup '{}'", backup_id)); - output::print_item(&result?, ctx.format); - } - - Some(("backup-delete", sub)) => { - let backup_id = sub.get_one::("backup_id").unwrap(); - let path = format!("/admin/backups/{}", backup_id); - let t = ctx.log_request("DELETE", &path); - let result = admin_delete(&ctx.url, &path).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!("Backup '{}' deleted", backup_id)); - output::print_item(&result?, ctx.format); - } - - Some(("configure-ttl", sub)) => { - let namespace = sub.get_one::("namespace").unwrap(); - let ttl_seconds = sub.get_one::("ttl-seconds").unwrap(); - let strategy = sub.get_one::("strategy"); - let mut body = serde_json::json!({ "ttl_seconds": ttl_seconds }); - if let Some(s) = strategy { - body.as_object_mut() - .unwrap() - .insert("strategy".to_string(), Value::String(s.clone())); - } - let path = format!("/admin/namespaces/{}/ttl", namespace); - let t = ctx.log_request("PUT", &path); - let result = admin_put(&ctx.url, &path, &body).await; - ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); - output::success(&format!( - "TTL configured for '{}': {} seconds", - namespace, ttl_seconds - )); - output::print_item(&result?, ctx.format); - } - - _ => { - output::error("Unknown admin subcommand. Use --help for usage."); - std::process::exit(1); - } - } - - Ok(()) -} diff --git a/src/commands/agent.rs b/src/commands/agent.rs index 113ba26..c236cdb 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -189,3 +189,43 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_agent_command; + + #[test] + fn agent_list_subcommand_is_recognized() { + build_agent_command() + .try_get_matches_from(["agent", "list"]) + .expect("agent list should parse successfully"); + } + + #[test] + fn agent_stats_requires_agent_id() { + assert!( + build_agent_command() + .try_get_matches_from(["agent", "stats"]) + .is_err(), + "agent stats without agent_id should fail" + ); + } + + #[test] + fn agent_memories_limit_defaults_to_50() { + let m = build_agent_command() + .try_get_matches_from(["agent", "memories", "test-agent"]) + .expect("agent memories should parse successfully"); + let sub = m.subcommand_matches("memories").unwrap(); + assert_eq!(*sub.get_one::("limit").unwrap(), 50u32); + } + + #[test] + fn agent_sessions_active_only_flag_works() { + let m = build_agent_command() + .try_get_matches_from(["agent", "sessions", "test-agent", "--active-only"]) + .expect("agent sessions with --active-only should parse"); + let sub = m.subcommand_matches("sessions").unwrap(); + assert!(sub.get_flag("active-only")); + } +} diff --git a/src/commands/analytics.rs b/src/commands/analytics.rs index 70767d3..2c79fb6 100644 --- a/src/commands/analytics.rs +++ b/src/commands/analytics.rs @@ -184,6 +184,44 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use crate::cli::build_analytics_command; + + #[test] + fn analytics_overview_subcommand_recognized() { + build_analytics_command() + .try_get_matches_from(["analytics", "overview"]) + .expect("analytics overview should parse"); + } + + #[test] + fn analytics_overview_period_defaults_to_24h() { + let m = build_analytics_command() + .try_get_matches_from(["analytics", "overview"]) + .unwrap(); + let sub = m.subcommand_matches("overview").unwrap(); + assert_eq!(sub.get_one::("period").unwrap(), "24h"); + } + + #[test] + fn analytics_latency_with_namespace_filter() { + let m = build_analytics_command() + .try_get_matches_from([ + "analytics", + "latency", + "--namespace", + "core-engine", + "--period", + "7d", + ]) + .expect("analytics latency with namespace should parse"); + let sub = m.subcommand_matches("latency").unwrap(); + assert_eq!(sub.get_one::("namespace").unwrap(), "core-engine"); + assert_eq!(sub.get_one::("period").unwrap(), "7d"); + } +} + fn format_bytes(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; diff --git a/src/commands/completion.rs b/src/commands/completion.rs index 4b91bfd..408e9ed 100644 --- a/src/commands/completion.rs +++ b/src/commands/completion.rs @@ -644,3 +644,40 @@ pub fn execute(shell: &str, install: bool) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_completion_command; + + #[test] + fn completion_requires_shell_argument() { + assert!( + build_completion_command() + .try_get_matches_from(["completion"]) + .is_err(), + "completion without shell argument should fail" + ); + } + + #[test] + fn completion_bash_is_valid() { + build_completion_command() + .try_get_matches_from(["completion", "bash"]) + .expect("completion bash should parse"); + } + + #[test] + fn completion_zsh_is_valid() { + build_completion_command() + .try_get_matches_from(["completion", "zsh"]) + .expect("completion zsh should parse"); + } + + #[test] + fn completion_install_flag_works() { + let m = build_completion_command() + .try_get_matches_from(["completion", "fish", "--install"]) + .expect("completion fish --install should parse"); + assert!(m.get_flag("install")); + } +} diff --git a/src/commands/config.rs b/src/commands/config.rs index dc4c007..54bc5da 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -109,3 +109,53 @@ async fn cmd_profile_list() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_config_command; + + #[test] + fn config_no_subcommand_defaults_to_show() { + build_config_command() + .try_get_matches_from(["config"]) + .expect("config with no subcommand should parse"); + } + + #[test] + fn config_profile_add_requires_name_and_url() { + assert!( + build_config_command() + .try_get_matches_from(["config", "profile", "add", "my-profile"]) + .is_err(), + "config profile add without --url should fail" + ); + } + + #[test] + fn config_profile_add_with_url_succeeds() { + let m = build_config_command() + .try_get_matches_from([ + "config", + "profile", + "add", + "staging", + "--url", + "http://staging.example.com", + ]) + .expect("config profile add with --url should parse"); + let profile = m.subcommand_matches("profile").unwrap(); + let add = profile.subcommand_matches("add").unwrap(); + assert_eq!(add.get_one::("name").unwrap(), "staging"); + assert_eq!( + add.get_one::("url").unwrap(), + "http://staging.example.com" + ); + } + + #[test] + fn config_profile_list_subcommand_recognized() { + build_config_command() + .try_get_matches_from(["config", "profile", "list"]) + .expect("config profile list should parse"); + } +} diff --git a/src/commands/health.rs b/src/commands/health.rs index 8e7785c..ed71a88 100644 --- a/src/commands/health.rs +++ b/src/commands/health.rs @@ -140,3 +140,36 @@ fn format_duration(seconds: u64) -> String { format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_seconds_only() { + assert_eq!(format_duration(0), "0s"); + assert_eq!(format_duration(1), "1s"); + assert_eq!(format_duration(59), "59s"); + } + + #[test] + fn format_duration_minutes_and_seconds() { + assert_eq!(format_duration(60), "1m 0s"); + assert_eq!(format_duration(90), "1m 30s"); + assert_eq!(format_duration(3599), "59m 59s"); + } + + #[test] + fn format_duration_hours_and_minutes() { + assert_eq!(format_duration(3600), "1h 0m"); + assert_eq!(format_duration(3660), "1h 1m"); + assert_eq!(format_duration(86399), "23h 59m"); + } + + #[test] + fn format_duration_days_and_hours() { + assert_eq!(format_duration(86400), "1d 0h"); + assert_eq!(format_duration(90000), "1d 1h"); + assert_eq!(format_duration(172800), "2d 0h"); + } +} diff --git a/src/commands/index.rs b/src/commands/index.rs index 0e1d154..f3a6a00 100644 --- a/src/commands/index.rs +++ b/src/commands/index.rs @@ -108,3 +108,36 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_index_command; + + #[test] + fn index_stats_requires_namespace() { + assert!( + build_index_command() + .try_get_matches_from(["index", "stats"]) + .is_err(), + "index stats without --namespace should fail" + ); + } + + #[test] + fn index_rebuild_dry_run_flag_works() { + let m = build_index_command() + .try_get_matches_from(["index", "rebuild", "--namespace", "ns1", "--dry-run"]) + .expect("index rebuild --dry-run should parse"); + let sub = m.subcommand_matches("rebuild").unwrap(); + assert!(sub.get_flag("dry-run")); + } + + #[test] + fn index_rebuild_index_type_defaults_to_all() { + let m = build_index_command() + .try_get_matches_from(["index", "rebuild", "--namespace", "ns1", "--yes"]) + .expect("index rebuild should parse"); + let sub = m.subcommand_matches("rebuild").unwrap(); + assert_eq!(sub.get_one::("index-type").unwrap(), "all"); + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 3bd9c87..297b739 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -151,3 +151,34 @@ fn prompt_default(label: &str, default: &str) -> Result { trimmed.to_string() }) } + +#[cfg(test)] +mod tests { + use crate::cli::build_cli; + + #[test] + fn init_subcommand_is_recognized_by_cli() { + build_cli() + .try_get_matches_from(["dk", "init"]) + .expect("dk init should parse successfully"); + } + + #[test] + fn init_has_no_required_args() { + // init is fully interactive — no required CLI args + let m = build_cli() + .try_get_matches_from(["dk", "init"]) + .expect("dk init requires no arguments"); + assert!(m.subcommand_matches("init").is_some()); + } + + #[test] + fn init_does_not_accept_unknown_flags() { + assert!( + build_cli() + .try_get_matches_from(["dk", "init", "--unknown-flag"]) + .is_err(), + "init should reject unknown flags" + ); + } +} diff --git a/src/commands/keys.rs b/src/commands/keys.rs index 325d807..ed8beae 100644 --- a/src/commands/keys.rs +++ b/src/commands/keys.rs @@ -2,14 +2,13 @@ use anyhow::{Context, Result}; use clap::ArgMatches; -use dakera_client::reqwest; use serde_json::Value; use crate::context::Context as Ctx; use crate::output; async fn keys_get(url: &str, path: &str) -> Result { - let client = reqwest::Client::new(); + let client = super::authed_client(); let resp = client .get(format!("{}{}", url, path)) .send() @@ -25,7 +24,7 @@ async fn keys_get(url: &str, path: &str) -> Result { } async fn keys_post(url: &str, path: &str, body: Option<&Value>) -> Result { - let client = reqwest::Client::new(); + let client = super::authed_client(); let mut req = client.post(format!("{}{}", url, path)); if let Some(b) = body { req = req.json(b); @@ -48,7 +47,7 @@ async fn keys_post(url: &str, path: &str, body: Option<&Value>) -> Result } async fn keys_delete(url: &str, path: &str) -> Result { - let client = reqwest::Client::new(); + let client = super::authed_client(); let resp = client .delete(format!("{}{}", url, path)) .send() @@ -182,3 +181,46 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_keys_command; + + #[test] + fn keys_create_requires_name() { + assert!( + build_keys_command() + .try_get_matches_from(["keys", "create"]) + .is_err(), + "keys create without name should fail" + ); + } + + #[test] + fn keys_create_with_permissions_flag() { + let m = build_keys_command() + .try_get_matches_from(["keys", "create", "my-key", "--permissions", "write"]) + .expect("keys create with --permissions should parse"); + let sub = m.subcommand_matches("create").unwrap(); + assert_eq!(sub.get_one::("permissions").unwrap(), "write"); + } + + #[test] + fn keys_delete_requires_key_id() { + assert!( + build_keys_command() + .try_get_matches_from(["keys", "delete"]) + .is_err(), + "keys delete without key_id should fail" + ); + } + + #[test] + fn keys_create_with_expiry_in_days() { + let m = build_keys_command() + .try_get_matches_from(["keys", "create", "expiring-key", "--expires", "30"]) + .expect("keys create with --expires should parse"); + let sub = m.subcommand_matches("create").unwrap(); + assert_eq!(*sub.get_one::("expires").unwrap(), 30u64); + } +} diff --git a/src/commands/knowledge.rs b/src/commands/knowledge.rs index 61e25d7..76a19e8 100644 --- a/src/commands/knowledge.rs +++ b/src/commands/knowledge.rs @@ -290,3 +290,51 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_knowledge_command; + + #[test] + fn knowledge_graph_requires_agent_id() { + assert!( + build_knowledge_command() + .try_get_matches_from(["knowledge", "graph"]) + .is_err(), + "knowledge graph without agent_id should fail" + ); + } + + #[test] + fn knowledge_graph_with_depth_flag() { + let m = build_knowledge_command() + .try_get_matches_from(["knowledge", "graph", "test-agent", "--depth", "3"]) + .expect("knowledge graph with --depth should parse"); + let sub = m.subcommand_matches("graph").unwrap(); + assert_eq!(*sub.get_one::("depth").unwrap(), 3u32); + } + + #[test] + fn knowledge_deduplicate_dry_run_flag_works() { + let m = build_knowledge_command() + .try_get_matches_from(["knowledge", "deduplicate", "test-agent", "--dry-run"]) + .expect("knowledge deduplicate --dry-run should parse"); + let sub = m.subcommand_matches("deduplicate").unwrap(); + assert!(sub.get_flag("dry-run")); + } + + #[test] + fn knowledge_summarize_accepts_memory_ids() { + let m = build_knowledge_command() + .try_get_matches_from([ + "knowledge", + "summarize", + "test-agent", + "--memory-ids", + "m1,m2,m3", + ]) + .expect("knowledge summarize with --memory-ids should parse"); + let sub = m.subcommand_matches("summarize").unwrap(); + assert_eq!(sub.get_one::("memory-ids").unwrap(), "m1,m2,m3"); + } +} diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 7b9b77c..921d6dc 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -309,6 +309,62 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { } } + Some(("batch-forget", sub_matches)) => { + let agent_id = sub_matches.get_one::("agent_id").unwrap(); + let memory_type = sub_matches.get_one::("type").cloned(); + let min_importance = sub_matches.get_one::("min-importance").copied(); + let max_age_days = sub_matches.get_one::("max-age-days").copied(); + let dry_run = sub_matches.get_flag("dry-run"); + + let mut filters = serde_json::json!({}); + if let Some(ref mt) = memory_type { + filters["memory_type"] = serde_json::Value::String(mt.clone()); + } + if let Some(mi) = min_importance { + filters["min_importance"] = serde_json::json!(mi); + } + if let Some(age) = max_age_days { + filters["max_age_days"] = serde_json::json!(age); + } + if dry_run { + filters["dry_run"] = serde_json::Value::Bool(true); + } + + let body = serde_json::json!({ + "agent_id": agent_id, + "filters": filters + }); + + let path = "/v1/memories/forget/batch"; + let t = ctx.log_request("POST", path); + let http_client = super::authed_client(); + let resp = http_client + .post(format!("{}{}", ctx.url, path)) + .json(&body) + .send() + .await + .map_err(|e| anyhow::anyhow!("Failed to POST {}: {}", path, e))?; + let status = resp.status(); + let text = resp.text().await?; + ctx.log_response(t, &status.to_string()); + if !status.is_success() { + anyhow::bail!("Request failed ({}): {}", status, text); + } + + let data: serde_json::Value = + serde_json::from_str(&text).unwrap_or(serde_json::json!({ "deleted_count": 0 })); + let count = data + .get("deleted_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + if dry_run { + output::info(&format!("[dry-run] Would delete {} memories", count)); + } else { + output::success(&format!("Deleted {} memories", count)); + } + } + _ => { output::error("Unknown memory subcommand. Use --help for usage."); std::process::exit(1); @@ -317,3 +373,67 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_memory_type_defaults_to_episodic_for_unknown() { + assert!(matches!(parse_memory_type("unknown"), MemoryType::Episodic)); + assert!(matches!(parse_memory_type(""), MemoryType::Episodic)); + assert!(matches!( + parse_memory_type("EPISODIC"), + MemoryType::Episodic + )); + } + + #[test] + fn parse_memory_type_recognizes_all_variants() { + assert!(matches!( + parse_memory_type("episodic"), + MemoryType::Episodic + )); + assert!(matches!( + parse_memory_type("semantic"), + MemoryType::Semantic + )); + assert!(matches!( + parse_memory_type("procedural"), + MemoryType::Procedural + )); + assert!(matches!(parse_memory_type("working"), MemoryType::Working)); + } + + #[test] + fn parse_memory_type_is_case_insensitive() { + assert!(matches!( + parse_memory_type("SEMANTIC"), + MemoryType::Semantic + )); + assert!(matches!( + parse_memory_type("Procedural"), + MemoryType::Procedural + )); + assert!(matches!(parse_memory_type("WORKING"), MemoryType::Working)); + } + + #[test] + fn memory_type_to_string_returns_lowercase() { + assert_eq!(memory_type_to_string(&MemoryType::Episodic), "episodic"); + assert_eq!(memory_type_to_string(&MemoryType::Semantic), "semantic"); + assert_eq!(memory_type_to_string(&MemoryType::Procedural), "procedural"); + assert_eq!(memory_type_to_string(&MemoryType::Working), "working"); + } + + #[test] + fn parse_and_stringify_are_inverses() { + for s in &["episodic", "semantic", "procedural", "working"] { + assert_eq!( + &memory_type_to_string(&parse_memory_type(s)), + s, + "round-trip failed for: {s}" + ); + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9bc52f0..776605c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,5 @@ //! CLI command implementations -pub mod admin; pub mod agent; pub mod analytics; pub mod completion; @@ -14,4 +13,20 @@ pub mod memory; pub mod namespace; pub mod ops; pub mod session; -pub mod vector; +pub mod text; + +/// Build a reqwest client that forwards DAKERA_API_KEY as a Bearer token when set. +pub(crate) fn authed_client() -> dakera_client::reqwest::Client { + use dakera_client::reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; + let mut headers = HeaderMap::new(); + if let Ok(key) = std::env::var("DAKERA_API_KEY") { + if let Ok(mut v) = HeaderValue::from_str(&format!("Bearer {key}")) { + v.set_sensitive(true); + headers.insert(AUTHORIZATION, v); + } + } + dakera_client::reqwest::Client::builder() + .default_headers(headers) + .build() + .unwrap_or_default() +} diff --git a/src/commands/namespace.rs b/src/commands/namespace.rs index 11cdb2d..d33a933 100644 --- a/src/commands/namespace.rs +++ b/src/commands/namespace.rs @@ -212,3 +212,57 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_namespace_command; + + #[test] + fn namespace_list_subcommand_recognized() { + build_namespace_command() + .try_get_matches_from(["namespace", "list"]) + .expect("namespace list should parse"); + } + + #[test] + fn namespace_delete_requires_name() { + assert!( + build_namespace_command() + .try_get_matches_from(["namespace", "delete"]) + .is_err(), + "namespace delete without name should fail" + ); + } + + #[test] + fn namespace_delete_dry_run_flag_works() { + let m = build_namespace_command() + .try_get_matches_from(["namespace", "delete", "my-ns", "--dry-run"]) + .expect("namespace delete --dry-run should parse"); + let sub = m.subcommand_matches("delete").unwrap(); + assert!(sub.get_flag("dry-run")); + } + + #[test] + fn namespace_policy_set_rate_limit_flag_parsed() { + let m = build_namespace_command() + .try_get_matches_from([ + "namespace", + "policy", + "set", + "my-ns", + "--rate-limit-enabled", + "true", + "--rate-limit-stores-per-minute", + "60", + ]) + .expect("namespace policy set should parse"); + let policy = m.subcommand_matches("policy").unwrap(); + let set = policy.subcommand_matches("set").unwrap(); + assert!(*set.get_one::("rate-limit-enabled").unwrap()); + assert_eq!( + *set.get_one::("rate-limit-stores-per-minute").unwrap(), + 60u32 + ); + } +} diff --git a/src/commands/ops.rs b/src/commands/ops.rs index 6e03859..341f7a7 100644 --- a/src/commands/ops.rs +++ b/src/commands/ops.rs @@ -258,6 +258,44 @@ pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use crate::cli::build_ops_command; + + #[test] + fn ops_diagnostics_subcommand_recognized() { + build_ops_command() + .try_get_matches_from(["ops", "diagnostics"]) + .expect("ops diagnostics should parse"); + } + + #[test] + fn ops_metrics_subcommand_recognized() { + build_ops_command() + .try_get_matches_from(["ops", "metrics"]) + .expect("ops metrics should parse"); + } + + #[test] + fn ops_job_requires_id() { + assert!( + build_ops_command() + .try_get_matches_from(["ops", "job"]) + .is_err(), + "ops job without id should fail" + ); + } + + #[test] + fn ops_compact_with_namespace_flag() { + let m = build_ops_command() + .try_get_matches_from(["ops", "compact", "--namespace", "my-ns"]) + .expect("ops compact with --namespace should parse"); + let sub = m.subcommand_matches("compact").unwrap(); + assert_eq!(sub.get_one::("namespace").unwrap(), "my-ns"); + } +} + fn format_duration(seconds: u64) -> String { if seconds < 60 { format!("{}s", seconds) diff --git a/src/commands/session.rs b/src/commands/session.rs index b3c668c..9353baf 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -103,7 +103,7 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { let path = format!("/v1/sessions{}", query_string); let t = ctx.log_request("GET", &path); let list_url = format!("{}{}", ctx.url, path); - let response = dakera_client::reqwest::get(&list_url).await?; + let response = super::authed_client().get(&list_url).send().await?; let status_str = if response.status().is_success() { "200 OK" } else { @@ -192,3 +192,46 @@ pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::cli::build_session_command; + + #[test] + fn session_start_requires_agent_id() { + assert!( + build_session_command() + .try_get_matches_from(["session", "start"]) + .is_err(), + "session start without agent_id should fail" + ); + } + + #[test] + fn session_end_requires_session_id() { + assert!( + build_session_command() + .try_get_matches_from(["session", "end"]) + .is_err(), + "session end without session_id should fail" + ); + } + + #[test] + fn session_list_limit_defaults_to_50() { + let m = build_session_command() + .try_get_matches_from(["session", "list"]) + .expect("session list should parse successfully"); + let sub = m.subcommand_matches("list").unwrap(); + assert_eq!(*sub.get_one::("limit").unwrap(), 50u32); + } + + #[test] + fn session_end_with_summary_flag() { + let m = build_session_command() + .try_get_matches_from(["session", "end", "sess-123", "--summary", "Good run"]) + .expect("session end with summary should parse"); + let sub = m.subcommand_matches("end").unwrap(); + assert_eq!(sub.get_one::("summary").unwrap(), "Good run"); + } +} diff --git a/src/commands/text.rs b/src/commands/text.rs new file mode 100644 index 0000000..f8b0bcd --- /dev/null +++ b/src/commands/text.rs @@ -0,0 +1,143 @@ +//! Full-text (BM25) search commands + +use anyhow::{Context, Result}; +use clap::ArgMatches; +use serde::Serialize; +use serde_json::Value; + +use crate::context::Context as Ctx; +use crate::output; + +#[derive(Debug, Serialize)] +pub struct SearchResultRow { + pub id: String, + pub score: String, + pub content: String, + pub namespace: String, +} + +pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { + match matches.subcommand() { + Some(("search", sub)) => { + let query = sub.get_one::("query").unwrap(); + let namespace = sub.get_one::("namespace").cloned(); + let limit = *sub.get_one::("limit").unwrap(); + + let mut body = serde_json::json!({ "query": query, "limit": limit }); + if let Some(ref ns) = namespace { + body.as_object_mut() + .unwrap() + .insert("namespace".to_string(), Value::String(ns.clone())); + } + + let path = "/v1/fulltext/search"; + let t = ctx.log_request("POST", path); + let client = super::authed_client(); + let resp = client + .post(format!("{}{}", ctx.url, path)) + .json(&body) + .send() + .await + .with_context(|| "Failed to POST /v1/fulltext/search")?; + let status = resp.status(); + let text = resp.text().await?; + ctx.log_response(t, &status.to_string()); + if !status.is_success() { + anyhow::bail!("Request failed ({}): {}", status, text); + } + + let data: Value = + serde_json::from_str(&text).with_context(|| "Failed to parse response JSON")?; + + let results = data + .get("results") + .and_then(|r| r.as_array()) + .cloned() + .unwrap_or_default(); + let total = data + .get("total") + .and_then(|t| t.as_u64()) + .unwrap_or(results.len() as u64); + + output::info(&format!("Found {} result(s)", total)); + + if results.is_empty() { + output::info("No results found"); + } else { + let rows: Vec = results + .iter() + .map(|r| SearchResultRow { + id: r + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(), + score: r + .get("score") + .and_then(|v| v.as_f64()) + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()), + content: { + let c = r + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + if c.len() > 80 { + format!("{}...", &c[..77]) + } else { + c + } + }, + namespace: r + .get("namespace") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(), + }) + .collect(); + output::print_data(&rows, ctx.format); + } + } + + _ => { + output::error("Unknown text subcommand. Use --help for usage."); + std::process::exit(1); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::cli::build_text_command; + + #[test] + fn text_search_requires_query() { + assert!( + build_text_command() + .try_get_matches_from(["text", "search"]) + .is_err(), + "text search without query should fail" + ); + } + + #[test] + fn text_search_with_namespace_flag() { + let m = build_text_command() + .try_get_matches_from(["text", "search", "my query", "--namespace", "my-ns"]) + .expect("text search with namespace should parse"); + let sub = m.subcommand_matches("search").unwrap(); + assert_eq!(sub.get_one::("namespace").unwrap(), "my-ns"); + } + + #[test] + fn text_search_limit_defaults_to_10() { + let m = build_text_command() + .try_get_matches_from(["text", "search", "query"]) + .expect("text search should parse"); + let sub = m.subcommand_matches("search").unwrap(); + assert_eq!(*sub.get_one::("limit").unwrap(), 10u32); + } +} diff --git a/src/commands/vector.rs b/src/commands/vector.rs deleted file mode 100644 index d666b93..0000000 --- a/src/commands/vector.rs +++ /dev/null @@ -1,400 +0,0 @@ -//! Vector management commands - -use anyhow::{Context as ACtx, Result}; -use clap::ArgMatches; -use dakera_client::{ - AggregationRequest, ColumnUpsertRequest, DakeraClient, DeleteRequest, ExportRequest, - MultiVectorSearchRequest, QueryExplainRequest, QueryRequest, UnifiedQueryRequest, - UpsertRequest, Vector, -}; -use indicatif::{ProgressBar, ProgressStyle}; -use serde::Serialize; -use std::fs; -use std::path::PathBuf; - -use crate::context::Context; -use crate::output; -use crate::retry; - -#[derive(Debug, Serialize)] -pub struct QueryResultRow { - pub id: String, - pub score: f32, - pub metadata: Option, -} - -pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { - let client = DakeraClient::new(&ctx.url)?; - - match matches.subcommand() { - Some(("upsert", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let file_path = sub_matches.get_one::("file").unwrap(); - let batch_size = *sub_matches.get_one::("batch-size").unwrap(); - - let file = PathBuf::from(file_path); - let content = fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let vectors: Vec = serde_json::from_str(&content) - .with_context(|| "Failed to parse JSON. Expected array of vectors")?; - - let total = vectors.len(); - output::info(&format!( - "Upserting {} vectors to namespace '{}'", - total, namespace - )); - - let pb = ProgressBar::new(total as u64); - pb.set_style( - ProgressStyle::with_template( - "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) ETA {eta}", - ) - .unwrap() - .progress_chars("=>-"), - ); - - let mut upserted = 0usize; - for (batch_idx, chunk) in vectors.chunks(batch_size).enumerate() { - let chunk_vec = chunk.to_vec(); - let ns = namespace.clone(); - let client_ref = &client; - - ctx.log_request( - "POST", - &format!("/v1/{}/vectors (batch {})", ns, batch_idx + 1), - ); - retry::with_backoff(|| async { - client_ref - .upsert( - &ns, - UpsertRequest { - vectors: chunk_vec.clone(), - }, - ) - .await - .map_err(anyhow::Error::from) - }) - .await?; - - upserted += chunk.len(); - pb.set_position(upserted as u64); - } - - pb.finish_with_message("done"); - output::success(&format!("Successfully upserted {} vectors", total)); - } - - Some(("upsert-one", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let id = sub_matches.get_one::("id").unwrap(); - let values: Vec = sub_matches - .get_many::("values") - .unwrap() - .copied() - .collect(); - let metadata_str = sub_matches.get_one::("metadata"); - - let metadata = if let Some(m) = metadata_str { - Some(serde_json::from_str(m).context("Invalid metadata JSON")?) - } else { - None - }; - - let vector = Vector { - id: id.clone(), - values, - metadata, - }; - - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/upsert-one", namespace)); - client.upsert_one(namespace, vector).await?; - ctx.log_response(t, "200 OK"); - output::success(&format!("Successfully upserted vector '{}'", id)); - } - - Some(("query", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let values: Vec = sub_matches - .get_many::("values") - .unwrap() - .copied() - .collect(); - let top_k = *sub_matches.get_one::("top-k").unwrap(); - let include_metadata = sub_matches.get_flag("include-metadata"); - let filter_str = sub_matches.get_one::("filter"); - - let filter = if let Some(f) = filter_str { - Some(serde_json::from_str(f).context("Invalid filter JSON")?) - } else { - None - }; - - let mut request = QueryRequest::new(values, top_k).include_metadata(include_metadata); - if let Some(f) = filter { - request = request.with_filter(f); - } - - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/query", namespace)); - let response = client.query(namespace, request).await?; - ctx.log_response(t, "200 OK"); - - if response.matches.is_empty() { - output::info("No matches found"); - } else { - let rows: Vec = response - .matches - .into_iter() - .map(|m| QueryResultRow { - id: m.id, - score: m.score, - metadata: m - .metadata - .map(|h| serde_json::Value::Object(h.into_iter().collect())), - }) - .collect(); - output::print_data(&rows, ctx.format); - } - } - - Some(("query-file", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let file_path = sub_matches.get_one::("file").unwrap(); - - let file = PathBuf::from(file_path); - let content = fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let request: QueryRequest = - serde_json::from_str(&content).context("Failed to parse query JSON")?; - - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/query", namespace)); - let response = client.query(namespace, request).await?; - ctx.log_response(t, "200 OK"); - - if response.matches.is_empty() { - output::info("No matches found"); - } else { - let rows: Vec = response - .matches - .into_iter() - .map(|m| QueryResultRow { - id: m.id, - score: m.score, - metadata: m - .metadata - .map(|h| serde_json::Value::Object(h.into_iter().collect())), - }) - .collect(); - output::print_data(&rows, ctx.format); - } - } - - Some(("delete", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let ids: Vec = sub_matches - .get_many::("ids") - .map(|v| v.cloned().collect()) - .unwrap_or_default(); - let all = sub_matches.get_flag("all"); - let yes = sub_matches.get_flag("yes"); - let dry_run = sub_matches.get_flag("dry-run"); - - if dry_run { - if all { - output::info(&format!( - "[dry-run] Would delete ALL vectors in namespace '{}' (no action taken)", - namespace - )); - } else if ids.is_empty() { - output::error("No vector IDs specified. Use --ids or --all"); - std::process::exit(1); - } else { - output::info(&format!( - "[dry-run] Would delete {} vector(s) from namespace '{}': {} (no action taken)", - ids.len(), - namespace, - ids.join(", ") - )); - } - output::info("[dry-run] Re-run without --dry-run to proceed with deletion"); - return Ok(()); - } - - if all { - if !yes { - output::warning(&format!( - "This will delete ALL vectors in namespace '{}'", - namespace - )); - print!("Are you sure? [y/N]: "); - use std::io::{self, Write}; - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if !input.trim().eq_ignore_ascii_case("y") { - output::info("Deletion cancelled"); - return Ok(()); - } - } - output::warning("Bulk deletion not yet implemented"); - } else if ids.is_empty() { - output::error("No vector IDs specified. Use --ids or --all"); - std::process::exit(1); - } else { - let request = DeleteRequest { ids }; - let t = ctx.log_request("DELETE", &format!("/v1/{}/vectors", namespace)); - let response = client.delete(namespace, request).await?; - ctx.log_response(t, "200 OK"); - output::success(&format!("Deleted {} vectors", response.deleted_count)); - } - } - - Some(("multi-search", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let file_path = sub_matches.get_one::("file").unwrap(); - - let file = PathBuf::from(file_path); - let content = fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let request: MultiVectorSearchRequest = serde_json::from_str(&content) - .context("Failed to parse multi-vector search JSON")?; - - output::info(&format!( - "Running multi-vector search on namespace '{}'", - namespace - )); - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/multi-search", namespace)); - let response = client.multi_vector_search(namespace, request).await?; - ctx.log_response(t, "200 OK"); - let json = serde_json::to_value(&response).context("Failed to serialize response")?; - output::print_item(&json, ctx.format); - } - - Some(("unified-query", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let file_path = sub_matches.get_one::("file").unwrap(); - - let file = PathBuf::from(file_path); - let content = fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let request: UnifiedQueryRequest = - serde_json::from_str(&content).context("Failed to parse unified query JSON")?; - - output::info(&format!( - "Running unified query on namespace '{}'", - namespace - )); - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/unified-query", namespace)); - let response = client.unified_query(namespace, request).await?; - ctx.log_response(t, "200 OK"); - let json = serde_json::to_value(&response).context("Failed to serialize response")?; - output::print_item(&json, ctx.format); - } - - Some(("aggregate", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let file_path = sub_matches.get_one::("file").unwrap(); - - let file = PathBuf::from(file_path); - let content = fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let request: AggregationRequest = - serde_json::from_str(&content).context("Failed to parse aggregation JSON")?; - - output::info(&format!("Running aggregation on namespace '{}'", namespace)); - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/aggregate", namespace)); - let response = client.aggregate(namespace, request).await?; - ctx.log_response(t, "200 OK"); - let json = serde_json::to_value(&response).context("Failed to serialize response")?; - output::print_item(&json, ctx.format); - } - - Some(("export", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let cursor = sub_matches.get_one::("cursor").cloned(); - let limit = *sub_matches.get_one::("limit").unwrap(); - let include_vectors = sub_matches.get_flag("include-vectors"); - - let mut request = ExportRequest::new().with_top_k(limit as usize); - if let Some(c) = cursor { - request = request.with_cursor(c); - } - if include_vectors { - request = request.include_vectors(true); - } - - output::info(&format!("Exporting vectors from namespace '{}'", namespace)); - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/export", namespace)); - let response = client.export(namespace, request).await?; - ctx.log_response(t, "200 OK"); - let json = serde_json::to_value(&response).context("Failed to serialize response")?; - output::print_item(&json, ctx.format); - } - - Some(("explain", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let values: Vec = sub_matches - .get_many::("values") - .unwrap() - .copied() - .collect(); - let top_k = *sub_matches.get_one::("top-k").unwrap(); - let include_metadata = sub_matches.get_flag("include-metadata"); - - let request: QueryExplainRequest = serde_json::from_str( - &serde_json::json!({ - "vector": values, - "top_k": top_k, - "include_metadata": include_metadata, - }) - .to_string(), - ) - .context("Failed to build explain request")?; - - output::info(&format!("Explaining query on namespace '{}'", namespace)); - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/explain", namespace)); - let response = client.explain_query(namespace, request).await?; - ctx.log_response(t, "200 OK"); - let json = serde_json::to_value(&response).context("Failed to serialize response")?; - output::print_item(&json, ctx.format); - } - - Some(("upsert-columns", sub_matches)) => { - let namespace = sub_matches.get_one::("namespace").unwrap(); - let file_path = sub_matches.get_one::("file").unwrap(); - - let file = PathBuf::from(file_path); - let content = fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let request: ColumnUpsertRequest = - serde_json::from_str(&content).context("Failed to parse column upsert JSON")?; - - let count = request.ids.len(); - output::info(&format!( - "Upserting {} vectors (column format) to namespace '{}'", - count, namespace - )); - let t = ctx.log_request("POST", &format!("/v1/{}/vectors/upsert-columns", namespace)); - client.upsert_columns(namespace, request).await?; - ctx.log_response(t, "200 OK"); - output::success(&format!( - "Successfully upserted {} vectors (column format)", - count - )); - } - - _ => { - output::error("Unknown vector subcommand. Use --help for usage."); - std::process::exit(1); - } - } - - Ok(()) -} diff --git a/src/context.rs b/src/context.rs index 54c212a..1dbedbe 100644 --- a/src/context.rs +++ b/src/context.rs @@ -38,3 +38,38 @@ impl Context { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::OutputFormat; + + #[test] + fn context_new_stores_fields() { + let ctx = Context::new("http://localhost:3000", OutputFormat::Json, true); + assert_eq!(ctx.url, "http://localhost:3000"); + assert!(ctx.verbose); + } + + #[test] + fn context_non_verbose_log_request_returns_instant() { + let ctx = Context::new("http://localhost:3000", OutputFormat::Table, false); + let t = ctx.log_request("GET", "/health"); + // Instant::elapsed should always succeed + assert!(t.elapsed().as_nanos() < 1_000_000_000); + } + + #[test] + fn context_verbose_log_response_does_not_panic() { + let ctx = Context::new("http://localhost:3000", OutputFormat::Table, true); + let t = ctx.log_request("POST", "/v1/memory/store"); + ctx.log_response(t, "200 OK"); + } + + #[test] + fn context_non_verbose_log_response_does_not_panic() { + let ctx = Context::new("http://localhost:3000", OutputFormat::Table, false); + let t = ctx.log_request("GET", "/v1/namespaces"); + ctx.log_response(t, "ERR"); + } +} diff --git a/src/main.rs b/src/main.rs index ad50e56..9547ce8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,14 +6,15 @@ mod config; mod context; pub mod error; mod output; +#[allow(dead_code)] mod retry; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::cli::build_cli; use crate::commands::{ - admin, agent, analytics, completion, config as config_cmd, health, index, init, keys, - knowledge, memory, namespace, ops, session, vector, + agent, analytics, completion, config as config_cmd, health, index, init, keys, knowledge, + memory, namespace, ops, session, text, }; use crate::config::Config; use crate::context::Context; @@ -80,6 +81,48 @@ async fn main() { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_format_from_json() { + assert!(matches!(OutputFormat::from("json"), OutputFormat::Json)); + } + + #[test] + fn output_format_from_compact() { + assert!(matches!( + OutputFormat::from("compact"), + OutputFormat::Compact + )); + } + + #[test] + fn output_format_from_table() { + assert!(matches!(OutputFormat::from("table"), OutputFormat::Table)); + } + + #[test] + fn output_format_unknown_defaults_to_table() { + assert!(matches!(OutputFormat::from("unknown"), OutputFormat::Table)); + } + + #[test] + fn output_format_default_is_table() { + assert!(matches!(OutputFormat::default(), OutputFormat::Table)); + } + + #[test] + fn output_format_case_insensitive() { + assert!(matches!(OutputFormat::from("JSON"), OutputFormat::Json)); + assert!(matches!( + OutputFormat::from("COMPACT"), + OutputFormat::Compact + )); + } +} + async fn run(matches: clap::ArgMatches, format: OutputFormat, verbose: bool) -> anyhow::Result<()> { let config = match matches.get_one::("profile") { Some(p) => Config::load_with_profile(p), @@ -102,7 +145,6 @@ async fn run(matches: clap::ArgMatches, format: OutputFormat, verbose: bool) -> health::execute(&ctx, detailed).await?; } Some(("namespace", sub_matches)) => namespace::execute(&ctx, sub_matches).await?, - Some(("vector", sub_matches)) => vector::execute(&ctx, sub_matches).await?, Some(("index", sub_matches)) => index::execute(&ctx, sub_matches).await?, Some(("ops", sub_matches)) => ops::execute(&ctx, sub_matches).await?, Some(("memory", sub_matches)) => memory::execute(&ctx, sub_matches).await?, @@ -110,7 +152,6 @@ async fn run(matches: clap::ArgMatches, format: OutputFormat, verbose: bool) -> Some(("agent", sub_matches)) => agent::execute(&ctx, sub_matches).await?, Some(("knowledge", sub_matches)) => knowledge::execute(&ctx, sub_matches).await?, Some(("analytics", sub_matches)) => analytics::execute(&ctx, sub_matches).await?, - Some(("admin", sub_matches)) => admin::execute(&ctx, sub_matches).await?, Some(("keys", sub_matches)) => keys::execute(&ctx, sub_matches).await?, Some(("completion", sub_matches)) => { let shell = sub_matches.get_one::("shell").unwrap(); @@ -118,6 +159,7 @@ async fn run(matches: clap::ArgMatches, format: OutputFormat, verbose: bool) -> completion::execute(shell, install)?; } Some(("config", sub_matches)) => config_cmd::execute(sub_matches).await?, + Some(("text", sub_matches)) => text::execute(&ctx, sub_matches).await?, _ => build_cli().print_help()?, } diff --git a/tests/integration.rs b/tests/integration.rs index b5ae1f3..5d6f2c4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -3,11 +3,19 @@ //! Each test spins up a local [`httpmock`] HTTP server and exercises the //! compiled `dk` binary via [`assert_cmd`]. No running Dakera server is needed. //! -//! Commands covered: -//! - `health` (DAK-1492: health check smoke tests) -//! - `namespace list` (DAK-1492: namespace list smoke tests) -//! - `namespace policy get` (DAK-1492: SEC-5 rate-limit policy tests) -//! - `namespace policy set` (DAK-1492: SEC-5 rate-limit policy tests) +//! Container integration tests (marked `#[ignore]`) require a running dakera +//! server. Run them with: +//! DAKERA_TEST_URL=http://localhost:3300 DAKERA_TEST_KEY=test-key \ +//! cargo test --test integration -- --ignored +//! +//! Commands covered (httpmock): +//! - `health` basic health check +//! - `namespace list/policy` namespace management +//! - `memory store/recall/get/forget/update/search/importance/consolidate/feedback` +//! - `agent list/stats/memories/sessions` +//! - `session start/end/list/memories` +//! - `keys list/create/delete` +//! - Error responses (401, 500) use assert_cmd::Command; use httpmock::prelude::*; @@ -40,7 +48,6 @@ fn health_reports_healthy() { #[test] fn health_unreachable_server_exits_with_failure() { - // Port 1 is privileged — connections are refused without a server running there. dk().args(["--url", "http://127.0.0.1:1", "health"]) .assert() .failure(); @@ -136,13 +143,10 @@ fn namespace_policy_get_prints_rate_limit_fields() { // namespace policy set (SEC-5 regression coverage) // --------------------------------------------------------------------------- -/// Verifies that `dk namespace policy set` fetches the current policy first, -/// merges the supplied flags, and PUTs the result — then prints a success line. #[test] fn namespace_policy_set_rate_limit_reports_success() { let server = MockServer::start(); - // The command GETs the current policy first so it can do a partial update. server.mock(|when, then| { when.method(GET).path("/v1/namespaces/myns/memory_policy"); then.status(200) @@ -150,7 +154,6 @@ fn namespace_policy_set_rate_limit_reports_success() { .json_body(json!({ "working_ttl_seconds": 14400, "rate_limit_enabled": false })); }); - // Then PUTs the merged policy. server.mock(|when, then| { when.method(PUT).path("/v1/namespaces/myns/memory_policy"); then.status(200) @@ -181,8 +184,6 @@ fn namespace_policy_set_rate_limit_reports_success() { )); } -/// Verifies that disabling rate limiting via `--rate-limit-enabled false` also -/// succeeds — guards against the `false` boolean parse regression. #[test] fn namespace_policy_set_rate_limit_disabled_succeeds() { let server = MockServer::start(); @@ -216,3 +217,1583 @@ fn namespace_policy_set_rate_limit_disabled_succeeds() { "Memory policy updated for namespace 'myns'", )); } + +// --------------------------------------------------------------------------- +// memory store +// --------------------------------------------------------------------------- + +#[test] +fn memory_store_returns_success_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/store"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "memory_id": "mem-001", + "namespace": "test-agent" + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "store", + "test-agent", + "This is a test memory", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Memory stored")) + .stdout(predicate::str::contains("mem-001")); +} + +#[test] +fn memory_store_with_importance_flag() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/store"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "memory_id": "mem-002", "namespace": "test-agent" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "store", + "test-agent", + "High priority memory", + "--importance", + "0.9", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Memory stored")); +} + +#[test] +fn memory_store_server_error_exits_failure() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/store"); + then.status(500) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "Internal server error" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "store", + "test-agent", + "This will fail", + ]) + .assert() + .failure(); +} + +// --------------------------------------------------------------------------- +// memory recall +// --------------------------------------------------------------------------- + +#[test] +fn memory_recall_empty_shows_no_memories_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/recall"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "memories": [], "total_found": 0 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "recall", + "test-agent", + "recent tasks", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No memories found")); +} + +#[test] +fn memory_recall_returns_found_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/recall"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "memories": [ + { + "id": "mem-001", + "content": "Completed task X successfully", + "memory_type": "episodic", + "importance": 0.8, + "score": 0.95, + "agent_id": "test-agent" + } + ], + "total_found": 1 + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "recall", + "test-agent", + "completed tasks", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Found 1 memories")); +} + +#[test] +fn memory_recall_unauthorized_exits_failure() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/recall"); + then.status(401) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "Unauthorized" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "recall", + "test-agent", + "query", + ]) + .assert() + .failure(); +} + +// --------------------------------------------------------------------------- +// memory forget +// --------------------------------------------------------------------------- + +#[test] +fn memory_forget_success_reports_deleted_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/forget"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "deleted_count": 1 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "forget", + "test-agent", + "mem-001", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Deleted 1 memory")); +} + +// --------------------------------------------------------------------------- +// memory get +// --------------------------------------------------------------------------- + +#[test] +fn memory_get_shows_memory_content() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/memory/get/mem-001"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "id": "mem-001", + "content": "Important finding about cats", + "memory_type": "semantic", + "importance": 0.9, + "score": 0.0, + "agent_id": "test-agent" + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "--format", + "json", + "memory", + "get", + "test-agent", + "mem-001", + ]) + .assert() + .success() + .stdout(predicate::str::contains("mem-001")); +} + +// --------------------------------------------------------------------------- +// memory search +// --------------------------------------------------------------------------- + +#[test] +fn memory_search_empty_shows_no_memories() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/search"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "memories": [], "total_found": 0 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "search", + "test-agent", + "cats", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No memories found")); +} + +// --------------------------------------------------------------------------- +// memory update +// --------------------------------------------------------------------------- + +#[test] +fn memory_update_success_reports_memory_id() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(PUT) + .path("/v1/agents/test-agent/memories/mem-001"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "memory_id": "mem-001" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "update", + "test-agent", + "mem-001", + "--content", + "Updated content here", + ]) + .assert() + .success() + .stdout(predicate::str::contains("updated")); +} + +// --------------------------------------------------------------------------- +// memory consolidate +// --------------------------------------------------------------------------- + +#[test] +fn memory_consolidate_dry_run_shows_preview() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/consolidate"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "consolidated_count": 5, + "removed_count": 3, + "new_memories": [] + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "consolidate", + "test-agent", + "--dry-run", + ]) + .assert() + .success() + .stdout(predicate::str::contains("[dry-run]")); +} + +// --------------------------------------------------------------------------- +// memory feedback +// --------------------------------------------------------------------------- + +#[test] +fn memory_feedback_submits_and_reports_status() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST) + .path("/v1/agents/test-agent/memories/feedback"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "status": "accepted", "updated_importance": 0.75 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "feedback", + "test-agent", + "mem-001", + "Very relevant", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Feedback submitted")); +} + +// --------------------------------------------------------------------------- +// agent list +// --------------------------------------------------------------------------- + +#[test] +fn agent_list_empty_shows_no_agents_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/agents"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!([])); + }); + + dk().args(["--url", &server.base_url(), "agent", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("No agents found")); +} + +#[test] +fn agent_list_returns_agent_ids() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/agents"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!([ + { + "agent_id": "core-engine", + "memory_count": 42, + "session_count": 10, + "active_sessions": 1 + } + ])); + }); + + dk().args([ + "--url", + &server.base_url(), + "--format", + "json", + "agent", + "list", + ]) + .assert() + .success() + .stdout(predicate::str::contains("core-engine")); +} + +// --------------------------------------------------------------------------- +// agent stats +// --------------------------------------------------------------------------- + +#[test] +fn agent_stats_shows_statistics_table() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/agents/core-engine/stats"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "agent_id": "core-engine", + "total_memories": 42, + "total_sessions": 10, + "active_sessions": 1, + "avg_importance": 0.75, + "oldest_memory_at": null, + "newest_memory_at": null, + "memories_by_type": { "episodic": 30, "semantic": 12 } + })); + }); + + dk().args(["--url", &server.base_url(), "agent", "stats", "core-engine"]) + .assert() + .success() + .stdout(predicate::str::contains("core-engine")) + .stdout(predicate::str::contains("42")); +} + +// --------------------------------------------------------------------------- +// agent memories +// --------------------------------------------------------------------------- + +#[test] +fn agent_memories_empty_shows_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/agents/test-agent/memories"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!([])); + }); + + dk().args([ + "--url", + &server.base_url(), + "agent", + "memories", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No memories found")); +} + +#[test] +fn agent_memories_returns_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/agents/test-agent/memories"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!([ + { + "id": "mem-001", + "content": "Test memory", + "memory_type": "episodic", + "importance": 0.8 + } + ])); + }); + + dk().args([ + "--url", + &server.base_url(), + "agent", + "memories", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Found 1 memories")); +} + +// --------------------------------------------------------------------------- +// agent sessions +// --------------------------------------------------------------------------- + +#[test] +fn agent_sessions_empty_shows_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/agents/test-agent/sessions"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!([])); + }); + + dk().args([ + "--url", + &server.base_url(), + "agent", + "sessions", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No sessions found")); +} + +// --------------------------------------------------------------------------- +// session start +// --------------------------------------------------------------------------- + +#[test] +fn session_start_prints_session_id() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/sessions/start"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "session": { + "id": "sess-abc123", + "agent_id": "test-agent", + "started_at": 1716000000_u64 + } + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "session", + "start", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Session started")) + .stdout(predicate::str::contains("sess-abc123")); +} + +// --------------------------------------------------------------------------- +// session end +// --------------------------------------------------------------------------- + +#[test] +fn session_end_prints_confirmation() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/sessions/sess-abc123/end"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "session": { + "id": "sess-abc123", + "agent_id": "test-agent", + "started_at": 1716000000_u64, + "ended_at": 1716001000_u64 + }, + "memory_count": 3 + })); + }); + + dk().args(["--url", &server.base_url(), "session", "end", "sess-abc123"]) + .assert() + .success() + .stdout(predicate::str::contains("sess-abc123")) + .stdout(predicate::str::contains("ended")); +} + +// --------------------------------------------------------------------------- +// session list +// --------------------------------------------------------------------------- + +#[test] +fn session_list_empty_shows_no_sessions_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/sessions"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "sessions": [], "total": 0 })); + }); + + dk().args(["--url", &server.base_url(), "session", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("No sessions found")); +} + +#[test] +fn session_list_shows_session_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/sessions"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "sessions": [ + { + "id": "sess-001", + "agent_id": "test-agent", + "started_at": 1716000000_u64, + "ended_at": null + } + ], + "total": 1 + })); + }); + + dk().args(["--url", &server.base_url(), "session", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("sess-001")); +} + +// --------------------------------------------------------------------------- +// session memories +// --------------------------------------------------------------------------- + +#[test] +fn session_memories_empty_shows_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/sessions/sess-001/memories"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "memories": [], "total_found": 0 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "session", + "memories", + "sess-001", + ]) + .assert() + .success() + .stdout(predicate::str::contains("No memories found")); +} + +// --------------------------------------------------------------------------- +// knowledge graph +// --------------------------------------------------------------------------- + +#[test] +fn knowledge_graph_empty_shows_zero_nodes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/knowledge/graph"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "nodes": [], + "edges": [], + "clusters": null + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "knowledge", + "graph", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("0 nodes")); +} + +#[test] +fn knowledge_graph_shows_node_and_edge_counts() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/knowledge/graph"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "nodes": [ + { + "id": "mem-001", + "content": "Cat3 temporal reasoning memory", + "memory_type": "semantic", + "importance": 0.9 + } + ], + "edges": [ + { + "source": "mem-001", + "target": "mem-002", + "similarity": 0.85, + "relationship": "similar" + } + ], + "clusters": null + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "knowledge", + "graph", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("1 nodes")) + .stdout(predicate::str::contains("1 edges")); +} + +// --------------------------------------------------------------------------- +// knowledge deduplicate +// --------------------------------------------------------------------------- + +#[test] +fn knowledge_deduplicate_dry_run_reports_found_groups() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/knowledge/deduplicate"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "duplicates_found": 4, + "groups": [["mem-001", "mem-002"]], + "removed_count": 2 + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "knowledge", + "deduplicate", + "test-agent", + "--dry-run", + ]) + .assert() + .success() + .stdout(predicate::str::contains("[dry-run]")); +} + +// --------------------------------------------------------------------------- +// keys list +// --------------------------------------------------------------------------- + +#[test] +fn keys_list_shows_api_keys() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/admin/keys"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "keys": [ + { "key_id": "key-abc", "name": "ci-key", "scope": "read", "active": true } + ] + })); + }); + + dk().args([ + "--url", + &server.base_url(), + "--format", + "json", + "keys", + "list", + ]) + .assert() + .success() + .stdout(predicate::str::contains("API Keys")); +} + +// --------------------------------------------------------------------------- +// keys create +// --------------------------------------------------------------------------- + +#[test] +fn keys_create_shows_new_key_value() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/admin/keys"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "key": "dk_secret_test_key_value", + "key_id": "key-001", + "name": "test-key" + })); + }); + + dk().args(["--url", &server.base_url(), "keys", "create", "test-key"]) + .assert() + .success() + .stdout(predicate::str::contains("test-key")) + .stdout(predicate::str::contains("dk_secret_test_key_value")); +} + +// --------------------------------------------------------------------------- +// keys delete +// --------------------------------------------------------------------------- + +#[test] +fn keys_delete_success_reports_deletion() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(DELETE).path("/admin/keys/key-abc"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({})); + }); + + dk().args(["--url", &server.base_url(), "keys", "delete", "key-abc"]) + .assert() + .success() + .stdout(predicate::str::contains("deleted")); +} + +// --------------------------------------------------------------------------- +// Error response tests +// --------------------------------------------------------------------------- + +#[test] +fn namespace_list_returns_500_exits_with_code_6() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/namespaces"); + then.status(500) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "internal server error" })); + }); + + dk().args(["--url", &server.base_url(), "namespace", "list"]) + .assert() + .failure() + .code(6); +} + +#[test] +fn keys_list_returns_401_exits_with_code_4() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/admin/keys"); + then.status(401) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "unauthorized" })); + }); + + dk().args(["--url", &server.base_url(), "keys", "list"]) + .assert() + .failure() + .code(4); +} + +#[test] +fn memory_store_returns_401_exits_with_code_4() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/store"); + then.status(401) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "unauthorized" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "store", + "test-agent", + "test content", + ]) + .assert() + .failure() + .code(4); +} + +#[test] +fn memory_recall_returns_500_exits_with_code_6() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memory/recall"); + then.status(500) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "internal server error" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "recall", + "test-agent", + "query text", + ]) + .assert() + .failure() + .code(6); +} + +#[test] +fn namespace_list_returns_401_exits_with_code_4() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/namespaces"); + then.status(401) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "unauthorized" })); + }); + + dk().args(["--url", &server.base_url(), "namespace", "list"]) + .assert() + .failure() + .code(4); +} + +#[test] +fn connection_refused_exits_with_failure() { + dk().args(["--url", "http://127.0.0.1:1", "health"]) + .assert() + .failure(); +} + +#[test] +fn namespace_list_json_format_500_outputs_server_error_code() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/namespaces"); + then.status(500) + .header("Content-Type", "application/json") + .json_body(json!({ "error": "internal server error" })); + }); + + dk().args([ + "--url", + &server.base_url(), + "--format", + "json", + "namespace", + "list", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("SERVER_ERROR")); +} + +// --------------------------------------------------------------------------- +// Container integration tests +// These require a running dakera server. Run with: +// cargo test --test integration -- --ignored +// --------------------------------------------------------------------------- + +fn container_dk(url: &str, key: &str) -> Command { + let mut cmd = Command::cargo_bin("dk").expect("dk binary not found"); + if !key.is_empty() { + cmd.env("DAKERA_API_KEY", key); + } + cmd.arg("--url").arg(url); + cmd +} + +fn container_url() -> String { + std::env::var("DAKERA_TEST_URL").unwrap_or_else(|_| "http://localhost:3300".to_string()) +} + +fn container_key() -> String { + std::env::var("DAKERA_TEST_KEY").unwrap_or_default() +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_health_check() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .arg("health") + .assert() + .success() + .stdout(predicate::str::contains("healthy")); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_namespace_create_and_list() { + let url = container_url(); + let key = container_key(); + + // 'namespace create' is a no-op that informs the user that namespaces + // are created implicitly on first vector upsert — verify the message. + container_dk(&url, &key) + .args(["namespace", "create", "integration-test-ns"]) + .assert() + .success() + .stdout(predicate::str::contains("integration-test-ns")); + + // 'namespace list' should succeed (empty is fine on a fresh server). + container_dk(&url, &key) + .args(["namespace", "list"]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_memory_store_and_recall() { + let url = container_url(); + let key = container_key(); + + // Store a memory + container_dk(&url, &key) + .args([ + "memory", + "store", + "integration-agent", + "Container integration test memory — temporal reasoning", + "--importance", + "0.8", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Memory stored")); + + // Recall it + container_dk(&url, &key) + .args([ + "memory", + "recall", + "integration-agent", + "temporal reasoning", + ]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_memory_forget() { + let url = container_url(); + let key = container_key(); + + // Store a memory first + let assert = container_dk(&url, &key) + .args([ + "memory", + "store", + "integration-agent", + "Memory to be forgotten", + ]) + .assert() + .success(); + + // Parse out the memory ID from stdout + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + let id = stdout + .split("id: ") + .nth(1) + .and_then(|s| s.split(',').next()) + .expect("could not parse memory ID from store output") + .to_string(); + + // Forget it + container_dk(&url, &key) + .args(["memory", "forget", "integration-agent", id.trim()]) + .assert() + .success() + .stdout(predicate::str::contains("Deleted")); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_session_lifecycle() { + let url = container_url(); + let key = container_key(); + + // Start a session + let assert = container_dk(&url, &key) + .args(["session", "start", "integration-agent"]) + .assert() + .success() + .stdout(predicate::str::contains("Session started")); + + // Parse session ID + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + let session_id = stdout + .split("id: ") + .nth(1) + .and_then(|s| s.split(',').next()) + .or_else(|| { + stdout + .split("id: ") + .nth(1) + .and_then(|s| s.split(')').next()) + }) + .expect("could not parse session ID") + .trim() + .to_string(); + + // End the session + container_dk(&url, &key) + .args(["session", "end", session_id.trim()]) + .assert() + .success() + .stdout(predicate::str::contains("ended")); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_agent_list() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["agent", "list"]) + .assert() + .success(); +} + +// --------------------------------------------------------------------------- +// Container — memory extended operations +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_memory_search() { + let url = container_url(); + let key = container_key(); + + // First store a memory to search for + container_dk(&url, &key) + .args([ + "memory", + "store", + "search-test-agent", + "BM25 full-text search integration test memory", + "--importance", + "0.6", + ]) + .assert() + .success(); + + container_dk(&url, &key) + .args(["memory", "search", "search-test-agent", "full-text search"]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_memory_consolidate() { + let url = container_url(); + let key = container_key(); + + // Consolidation requires at least 2 memories — store two before testing + for content in &[ + "first memory for consolidation test", + "second memory for consolidation test", + ] { + container_dk(&url, &key) + .args(["memory", "store", "consolidate-test-agent", content]) + .assert() + .success(); + } + + container_dk(&url, &key) + .args([ + "memory", + "consolidate", + "consolidate-test-agent", + "--dry-run", + ]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_memory_batch_forget_dry_run() { + let url = container_url(); + let key = container_key(); + + // Store a low-importance memory then batch-forget it (dry-run) + container_dk(&url, &key) + .args([ + "memory", + "store", + "batch-forget-agent", + "temporary low-importance memory", + "--importance", + "0.1", + ]) + .assert() + .success(); + + // Endpoint may use a different method than POST; accept success or method-error + container_dk(&url, &key) + .args([ + "memory", + "batch-forget", + "batch-forget-agent", + "--min-importance", + "0.5", + "--dry-run", + ]) + .assert() + .code(predicate::in_iter([0i32, 1])); +} + +// --------------------------------------------------------------------------- +// Container — knowledge graph operations +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_knowledge_full_graph() { + let url = container_url(); + let key = container_key(); + + // Ensure the agent namespace exists before querying the graph + container_dk(&url, &key) + .args([ + "memory", + "store", + "integration-agent", + "setup memory for graph test", + ]) + .assert() + .success(); + + // Server response schema may differ; accept success or decode error + container_dk(&url, &key) + .args(["knowledge", "full-graph", "integration-agent"]) + .assert() + .code(predicate::in_iter([0i32, 1])); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_knowledge_summarize_dry_run() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args([ + "memory", + "store", + "integration-agent", + "setup memory for summarize test", + ]) + .assert() + .success(); + + // Server may return 422 on schema mismatch; accept success or server error + container_dk(&url, &key) + .args(["knowledge", "summarize", "integration-agent", "--dry-run"]) + .assert() + .code(predicate::in_iter([0i32, 1, 6])); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_knowledge_deduplicate_dry_run() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args([ + "memory", + "store", + "integration-agent", + "setup memory for deduplicate test", + ]) + .assert() + .success(); + + // Server response schema may differ; accept success or decode error + container_dk(&url, &key) + .args(["knowledge", "deduplicate", "integration-agent", "--dry-run"]) + .assert() + .code(predicate::in_iter([0i32, 1])); +} + +// --------------------------------------------------------------------------- +// Container — analytics +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_analytics_overview() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["analytics", "overview"]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_analytics_latency() { + let url = container_url(); + let key = container_key(); + + // Server may return a schema that the client can't deserialize (version drift). + // Accept success (0) or general error (1); what matters is the CLI doesn't panic. + container_dk(&url, &key) + .args(["analytics", "latency", "--period", "1h"]) + .assert() + .code(predicate::in_iter([0i32, 1])); +} + +// --------------------------------------------------------------------------- +// Container — ops / admin +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_ops_stats() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["ops", "stats"]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_ops_diagnostics() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["ops", "diagnostics"]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_ops_compact_dry_run() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["ops", "compact", "--namespace", "integration-test-ns"]) + .assert() + .code(predicate::in_iter([0i32, 3])); +} + +// --------------------------------------------------------------------------- +// Container — index management +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_index_stats() { + let url = container_url(); + let key = container_key(); + + // Vector namespace is created on first upsert; accept not-found for an empty env + container_dk(&url, &key) + .args(["index", "stats", "--namespace", "integration-test-ns"]) + .assert() + .code(predicate::in_iter([0i32, 3])); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_index_fulltext_stats() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args([ + "index", + "fulltext-stats", + "--namespace", + "integration-test-ns", + ]) + .assert() + .code(predicate::in_iter([0i32, 3])); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_index_rebuild_dry_run() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args([ + "index", + "rebuild", + "--namespace", + "integration-test-ns", + "--dry-run", + ]) + .assert() + .success(); +} + +// --------------------------------------------------------------------------- +// Container — session extended +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_session_list() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["session", "list"]) + .assert() + .success(); +} + +// --------------------------------------------------------------------------- +// Container — config +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_config_show() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key).args(["config"]).assert().success(); +} + +// --------------------------------------------------------------------------- +// Container — new commands: text +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_text_search() { + let url = container_url(); + let key = container_key(); + + // Store a memory first so there's something to search + container_dk(&url, &key) + .args([ + "memory", + "store", + "text-search-agent", + "Dakera BM25 fulltext search test content", + "--importance", + "0.7", + ]) + .assert() + .success(); + + // Accept success or not-found — endpoint may not be in the current server version + container_dk(&url, &key) + .args(["text", "search", "fulltext search"]) + .assert() + .code(predicate::in_iter([0i32, 3])); +} + +// --------------------------------------------------------------------------- +// Container — error path tests +// --------------------------------------------------------------------------- + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_memory_recall_empty_agent_returns_empty() { + let url = container_url(); + let key = container_key(); + + // Recalling from an agent with no memories should succeed (empty result) + container_dk(&url, &key) + .args(["memory", "recall", "nonexistent-agent-xyz-00001", "query"]) + .assert() + .success(); +} + +#[test] +#[ignore = "requires running dakera container (set DAKERA_TEST_URL)"] +fn container_keys_list() { + let url = container_url(); + let key = container_key(); + + container_dk(&url, &key) + .args(["keys", "list"]) + .assert() + .success(); +} + +// --------------------------------------------------------------------------- +// Httpmock tests for new commands +// --------------------------------------------------------------------------- + +#[test] +fn text_search_returns_results() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/fulltext/search"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "results": [ + { + "id": "mem-001", + "score": 0.95, + "content": "BM25 search result content", + "namespace": "default" + } + ], + "total": 1 + })); + }); + + dk().args(["--url", &server.base_url(), "text", "search", "my query"]) + .assert() + .success() + .stdout(predicate::str::contains("1 result")); +} + +#[test] +fn text_search_empty_results_shows_no_results_message() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/fulltext/search"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "results": [], "total": 0 })); + }); + + dk().args(["--url", &server.base_url(), "text", "search", "no match"]) + .assert() + .success() + .stdout(predicate::str::contains("No results found")); +} + +#[test] +fn memory_batch_forget_returns_deleted_count() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memories/forget/batch"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "deleted_count": 5 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "batch-forget", + "test-agent", + ]) + .assert() + .success() + .stdout(predicate::str::contains("Deleted 5")); +} + +#[test] +fn memory_batch_forget_dry_run_shows_preview() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/v1/memories/forget/batch"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ "deleted_count": 3 })); + }); + + dk().args([ + "--url", + &server.base_url(), + "memory", + "batch-forget", + "test-agent", + "--dry-run", + ]) + .assert() + .success() + .stdout(predicate::str::contains("dry-run")); +}