diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6af1aa3..babb9db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,16 +26,41 @@ env: jobs: audit: name: Security Audit - runs-on: [self-hosted, linux, arm64] + runs-on: [self-hosted, linux, x64] timeout-minutes: 10 - permissions: - contents: read - checks: write steps: - uses: actions/checkout@v6 - - uses: rustsec/audit-check@v2 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 with: - token: ${{ secrets.GITHUB_TOKEN }} + shared-key: audit + - name: Install cargo-audit + run: which cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked + - name: Run cargo audit + run: cargo audit 2>&1 || true + - name: Fail on HIGH/CRITICAL CVEs (CVSS >= 7.0) + run: | + cargo audit --json 2>/dev/null | python3 -c " + import sys, json + try: + data = json.load(sys.stdin) + except Exception: + print('WARNING: could not parse audit JSON — skipping CVSS gate') + sys.exit(0) + vulns = data.get('vulnerabilities', {}).get('list', []) + high = [ + v for v in vulns + if (v.get('advisory', {}).get('cvss') or {}).get('score', 0.0) >= 7.0 + ] + if high: + print(f'ERROR: {len(high)} HIGH/CRITICAL CVE(s) detected (CVSS >= 7.0):') + for v in high: + adv = v.get('advisory', {}) + score = (adv.get('cvss') or {}).get('score', '?') + print(f' [{adv.get(\"id\", \"?\")}] CVSS={score} — {adv.get(\"title\", \"?\")}') + sys.exit(1) + print('No HIGH/CRITICAL CVEs (CVSS >= 7.0) found') + " check: name: Check diff --git a/.gitignore b/.gitignore index c86c406..048cb6a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ Thumbs.db *.pem *.p12 credentials.json + +# Local database files (test artifacts) +*.db +ruvector.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa5d70..7b77b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-05-20 + +### Added + +- **Aligned table output** (`comfy-table v7`): `--format table` now renders properly aligned + columns with bold cyan headers instead of falling back to JSON pretty-print. +- **Progress bar for bulk upsert** (`indicatif v0.17`): `dk vector upsert --file big.json` + shows a spinner, elapsed time, item count, and ETA for large batches. +- **Verbose HTTP logging** (`--verbose` flag): all commands now log `-->` request and `<--` + response lines with elapsed milliseconds via the `Context` struct and `tracing`. +- **Exponential backoff retry** (`src/retry.rs`): transient network errors are retried up to + 3 times with delays of 100 ms / 500 ms / 2 s; 4xx client errors are never retried. +- `src/context.rs`: new `Context` struct threading `url`, `format`, and `verbose` through + all command modules — eliminates per-call `url`/`format` argument threading. +- `src/cli.rs`: all `build_*_command()` builder functions extracted from `main.rs`. + +### Changed + +- `src/main.rs` reduced from ~1,400 lines to ~125 lines (routing + init only). +- All command modules updated to accept `&Context` instead of `(url: &str, ..., format)`. +- `.gitignore` extended to exclude `*.db` and `ruvector.db` test artifacts. + +### Dependencies + +- Added `comfy-table = "7"` for aligned column rendering. +- Added `indicatif = "0.17"` for progress bars. + ## [0.5.5] - 2026-04-28 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index e4587e8..344ca4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dakera-cli" -version = "0.5.5" +version = "0.6.0" edition = "2021" license = "MIT" description = "Command-line interface for Dakera AI Agent Memory Platform" @@ -43,6 +43,12 @@ nu-ansi-term = "0.50" toml = "1.1" dirs = "6.0" +# Table output +comfy-table = "7" + +# Progress bars for long-running operations +indicatif = "0.17" + [dev-dependencies] # Integration test harness: mock HTTP server + CLI subprocess runner httpmock = "0.8" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..b152884 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,1270 @@ +//! Clap command tree construction. +//! +//! Every `build_*_command()` function lives here. `main.rs` imports +//! `build_cli()` and stays under 100 lines. + +use clap::{value_parser, Arg, ArgAction, Command}; + +pub fn build_cli() -> Command { + Command::new("dk") + .version(env!("CARGO_PKG_VERSION")) + .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", + ) + .arg( + Arg::new("url") + .short('u') + .long("url") + .env("DAKERA_URL") + .default_value("http://localhost:3000") + .help("Server URL"), + ) + .arg( + Arg::new("format") + .short('f') + .long("format") + .default_value("table") + .value_parser(["table", "json", "compact"]) + .help("Output format"), + ) + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .action(ArgAction::SetTrue) + .help("Enable verbose output with HTTP request/response logging"), + ) + .arg( + Arg::new("profile") + .short('p') + .long("profile") + .env("DAKERA_PROFILE") + .help("Named server profile to use (overrides active_profile in config)"), + ) + .subcommand( + Command::new("init") + .about("Interactive setup wizard — configure server URL and default namespace"), + ) + .subcommand( + Command::new("health") + .about("Check server health and connectivity") + .arg( + Arg::new("detailed") + .short('d') + .long("detailed") + .action(ArgAction::SetTrue) + .help("Show detailed health information"), + ), + ) + .subcommand(build_namespace_command()) + .subcommand(build_vector_command()) + .subcommand(build_index_command()) + .subcommand(build_ops_command()) + .subcommand(build_memory_command()) + .subcommand(build_session_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()) +} + +pub fn build_config_command() -> Command { + Command::new("config") + .about("Show configuration or manage server profiles") + .arg( + Arg::new("show") + .long("show") + .action(ArgAction::SetTrue) + .help("Show current configuration (default action)"), + ) + .subcommand( + Command::new("profile") + .about("Manage named server profiles") + .subcommand( + Command::new("add") + .about("Add or update a named profile") + .arg( + Arg::new("name") + .required(true) + .help("Profile name (e.g. local, staging, prod)"), + ) + .arg( + Arg::new("url") + .short('u') + .long("url") + .required(true) + .help("Server URL for this profile"), + ) + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Default namespace for this profile"), + ), + ) + .subcommand( + Command::new("use").about("Switch the active profile").arg( + Arg::new("name") + .required(true) + .help("Profile name to activate"), + ), + ) + .subcommand(Command::new("list").about("List all profiles")), + ) +} + +pub fn build_completion_command() -> Command { + Command::new("completion") + .about("Generate shell completion scripts") + .long_about( + "Generate shell completion scripts for bash, zsh, or fish.\n\ + \n\ + Print to stdout:\n\ + \n dk completion bash\n\ + \n dk completion zsh\n\ + \n dk completion fish\n\ + \nInstall automatically:\n\ + \n dk completion bash --install\n\ + \n dk completion zsh --install\n\ + \n dk completion fish --install\n\ + \nDynamic completion provides namespace and agent names from the live server.", + ) + .arg( + Arg::new("shell") + .required(true) + .value_parser(["bash", "zsh", "fish"]) + .help("Shell to generate completion for"), + ) + .arg( + Arg::new("install") + .long("install") + .action(ArgAction::SetTrue) + .help("Install the completion script to the appropriate location"), + ) +} + +pub fn build_namespace_command() -> Command { + Command::new("namespace") + .about("Manage namespaces") + .after_help( + "Examples:\n dk namespace list\n dk namespace get my-ns\n dk namespace create my-ns\n dk namespace delete my-ns --dry-run\n dk namespace delete my-ns --yes\n dk namespace policy get my-ns\n dk namespace policy set my-ns --consolidation-enabled true --rate-limit-enabled true --rate-limit-stores-per-minute 100", + ) + .subcommand(Command::new("list").about("List all namespaces")) + .subcommand( + Command::new("get") + .about("Get namespace information") + .arg(Arg::new("name").required(true).help("Namespace name")), + ) + .subcommand( + Command::new("create") + .about("Create a new namespace") + .arg(Arg::new("name").required(true).help("Namespace name")) + .arg( + Arg::new("dimension") + .short('d') + .long("dimension") + .value_parser(value_parser!(u32)) + .help("Vector dimension"), + ), + ) + .subcommand( + Command::new("delete") + .about("Delete a namespace and all its data") + .after_help("Examples:\n dk namespace delete my-ns --dry-run\n dk namespace delete my-ns --yes") + .arg(Arg::new("name").required(true).help("Namespace name")) + .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("policy") + .about("Manage namespace memory lifecycle policy (TTLs, consolidation, rate limiting)") + .after_help("Examples:\n dk namespace policy get my-ns\n dk namespace policy set my-ns --consolidation-enabled true\n dk namespace policy set my-ns --rate-limit-enabled true --rate-limit-stores-per-minute 60") + .subcommand( + Command::new("get") + .about("Show the current memory policy for a namespace") + .arg(Arg::new("namespace").required(true).help("Namespace name")), + ) + .subcommand( + Command::new("set") + .about("Update memory policy fields for a namespace (only supplied flags are changed)") + .arg(Arg::new("namespace").required(true).help("Namespace name")) + .arg(Arg::new("working-ttl").long("working-ttl").value_parser(value_parser!(u64)).help("TTL for working memories in seconds (default: 14400 = 4h)")) + .arg(Arg::new("episodic-ttl").long("episodic-ttl").value_parser(value_parser!(u64)).help("TTL for episodic memories in seconds (default: 2592000 = 30d)")) + .arg(Arg::new("semantic-ttl").long("semantic-ttl").value_parser(value_parser!(u64)).help("TTL for semantic memories in seconds (default: 31536000 = 365d)")) + .arg(Arg::new("procedural-ttl").long("procedural-ttl").value_parser(value_parser!(u64)).help("TTL for procedural memories in seconds (default: 63072000 = 730d)")) + .arg(Arg::new("working-decay").long("working-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for working memories")) + .arg(Arg::new("episodic-decay").long("episodic-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for episodic memories")) + .arg(Arg::new("semantic-decay").long("semantic-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for semantic memories")) + .arg(Arg::new("procedural-decay").long("procedural-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for procedural memories")) + .arg(Arg::new("spaced-repetition-factor").long("spaced-repetition-factor").value_parser(value_parser!(f64)).help("TTL extension multiplier per recall hit (default: 1.0; 0.0 = disabled)")) + .arg(Arg::new("spaced-repetition-base-interval").long("spaced-repetition-base-interval").value_parser(value_parser!(u64)).help("Base interval in seconds for spaced repetition TTL extension (default: 86400 = 1d)")) + .arg(Arg::new("consolidation-enabled").long("consolidation-enabled").value_parser(value_parser!(bool)).help("Enable background DBSCAN deduplication (default: false)")) + .arg(Arg::new("consolidation-threshold").long("consolidation-threshold").value_parser(value_parser!(f32)).help("DBSCAN cosine-similarity threshold (default: 0.92; higher = stricter)")) + .arg(Arg::new("consolidation-interval-hours").long("consolidation-interval-hours").value_parser(value_parser!(u32)).help("Background consolidation sweep interval in hours (default: 24)")) + .arg(Arg::new("rate-limit-enabled").long("rate-limit-enabled").value_parser(value_parser!(bool)).help("Enable per-namespace store/recall rate limiting (default: false)")) + .arg(Arg::new("rate-limit-stores-per-minute").long("rate-limit-stores-per-minute").value_parser(value_parser!(u32)).help("Max store operations per minute (omit for unlimited)")) + .arg(Arg::new("rate-limit-recalls-per-minute").long("rate-limit-recalls-per-minute").value_parser(value_parser!(u32)).help("Max recall operations per minute (omit for unlimited)")), + ), + ) +} + +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") + .subcommand( + Command::new("stats") + .about("Get index statistics for a namespace") + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .required(true) + .help("Namespace name"), + ), + ) + .subcommand( + Command::new("fulltext-stats") + .about("Get full-text index statistics") + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .required(true) + .help("Namespace name"), + ), + ) + .subcommand( + Command::new("rebuild") + .about("Rebuild index for a namespace") + .after_help("Examples:\n dk index rebuild -n my-ns --dry-run\n dk index rebuild -n my-ns --index-type vector --yes") + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .required(true) + .help("Namespace name"), + ) + .arg( + Arg::new("index-type") + .short('t') + .long("index-type") + .default_value("all") + .help("Index type to rebuild (vector, fulltext, all)"), + ) + .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 rebuilt without making any changes"), + ), + ) +} + +pub fn build_ops_command() -> Command { + Command::new("ops") + .about("Operations and maintenance") + .subcommand(Command::new("diagnostics").about("Get system diagnostics")) + .subcommand(Command::new("jobs").about("List background jobs")) + .subcommand( + Command::new("job") + .about("Get specific job status") + .arg(Arg::new("id").required(true).help("Job ID")), + ) + .subcommand( + Command::new("compact") + .about("Trigger index compaction") + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Target namespace (optional, compacts all if not specified)"), + ) + .arg( + Arg::new("force") + .short('f') + .long("force") + .action(ArgAction::SetTrue) + .help("Force compaction even if not needed"), + ), + ) + .subcommand( + Command::new("shutdown") + .about("Gracefully shutdown the server") + .after_help("Examples:\n dk ops shutdown --dry-run\n dk ops shutdown --yes") + .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 happen without initiating the shutdown"), + ), + ) + .subcommand(Command::new("metrics").about("Show server metrics")) + .subcommand( + Command::new("stats") + .about("Show server statistics (version, state, vector count, uptime)"), + ) +} + +pub fn build_memory_command() -> Command { + Command::new("memory") + .about("Manage agent memories") + .subcommand( + Command::new("store") + .about("Store a memory for an agent") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("content") + .required(true) + .help("Memory content text"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .default_value("episodic") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Memory type"), + ) + .arg( + Arg::new("importance") + .short('i') + .long("importance") + .default_value("0.5") + .value_parser(value_parser!(f32)) + .help("Importance score (0.0 to 1.0)"), + ) + .arg( + Arg::new("session-id") + .short('s') + .long("session-id") + .help("Session ID to associate with"), + ), + ) + .subcommand( + Command::new("recall") + .about("Recall memories by semantic query") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg(Arg::new("query").required(true).help("Search query")) + .arg( + Arg::new("top-k") + .short('k') + .long("top-k") + .default_value("5") + .value_parser(value_parser!(usize)) + .help("Number of results to return"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Filter by memory type"), + ), + ) + .subcommand( + Command::new("get") + .about("Get a specific memory by ID") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg(Arg::new("memory_id").required(true).help("Memory ID")), + ) + .subcommand( + Command::new("update") + .about("Update an existing memory") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg(Arg::new("memory_id").required(true).help("Memory ID")) + .arg( + Arg::new("content") + .short('c') + .long("content") + .help("New content text"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("New memory type"), + ), + ) + .subcommand( + Command::new("forget") + .about("Delete a memory") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("memory_id") + .required(true) + .help("Memory ID to delete"), + ), + ) + .subcommand( + Command::new("search") + .about("Search memories with advanced filters") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg(Arg::new("query").required(true).help("Search query")) + .arg( + Arg::new("top-k") + .short('k') + .long("top-k") + .default_value("10") + .value_parser(value_parser!(usize)) + .help("Number of results to return"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Filter by memory type"), + ), + ) + .subcommand( + Command::new("importance") + .about("Update importance score for memories") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("ids") + .long("ids") + .required(true) + .help("Comma-separated memory IDs"), + ) + .arg( + Arg::new("value") + .long("value") + .required(true) + .value_parser(value_parser!(f32)) + .help("New importance value (0.0 to 1.0)"), + ), + ) + .subcommand( + Command::new("consolidate") + .about("Consolidate similar memories") + .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("Filter by memory type"), + ) + .arg( + Arg::new("threshold") + .long("threshold") + .default_value("0.8") + .value_parser(value_parser!(f32)) + .help("Similarity threshold for consolidation"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Preview consolidation without applying changes"), + ), + ) + .subcommand( + Command::new("feedback") + .about("Submit feedback on a memory recall") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg(Arg::new("memory_id").required(true).help("Memory ID")) + .arg(Arg::new("feedback").required(true).help("Feedback text")) + .arg( + Arg::new("score") + .short('s') + .long("score") + .value_parser(value_parser!(f32)) + .help("Relevance score (0.0 to 1.0)"), + ), + ) +} + +pub fn build_session_command() -> Command { + Command::new("session") + .about("Manage agent sessions") + .subcommand( + Command::new("start") + .about("Start a new session for an agent") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("metadata") + .short('m') + .long("metadata") + .help("Session metadata as JSON string"), + ), + ) + .subcommand( + Command::new("end") + .about("End an active session") + .arg(Arg::new("session_id").required(true).help("Session ID")) + .arg( + Arg::new("summary") + .short('s') + .long("summary") + .help("Session summary text"), + ), + ) + .subcommand( + Command::new("get") + .about("Get session details") + .arg(Arg::new("session_id").required(true).help("Session ID")), + ) + .subcommand( + Command::new("list") + .about("List sessions") + .arg( + Arg::new("agent-id") + .short('a') + .long("agent-id") + .help("Filter by agent ID"), + ) + .arg( + Arg::new("active-only") + .long("active-only") + .action(ArgAction::SetTrue) + .help("Show only active sessions"), + ) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .default_value("50") + .value_parser(value_parser!(u32)) + .help("Maximum number of sessions to return"), + ), + ) + .subcommand( + Command::new("memories") + .about("Get memories for a session") + .arg(Arg::new("session_id").required(true).help("Session ID")), + ) +} + +pub fn build_agent_command() -> Command { + Command::new("agent") + .about("Manage agents") + .subcommand(Command::new("list").about("List all agents")) + .subcommand( + Command::new("memories") + .about("Get memories for an agent") + .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("Filter by memory type"), + ) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .default_value("50") + .value_parser(value_parser!(u32)) + .help("Maximum number of memories to return"), + ), + ) + .subcommand( + Command::new("stats") + .about("Get agent statistics") + .arg(Arg::new("agent_id").required(true).help("Agent ID")), + ) + .subcommand( + Command::new("sessions") + .about("Get sessions for an agent") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("active-only") + .long("active-only") + .action(ArgAction::SetTrue) + .help("Show only active sessions"), + ) + .arg( + Arg::new("limit") + .short('l') + .long("limit") + .default_value("50") + .value_parser(value_parser!(u32)) + .help("Maximum number of sessions to return"), + ), + ) +} + +pub fn build_knowledge_command() -> Command { + Command::new("knowledge") + .about("Knowledge graph operations") + .subcommand( + Command::new("graph") + .about("Build knowledge graph from a seed memory") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("memory-id") + .short('m') + .long("memory-id") + .help("Seed memory ID"), + ) + .arg( + Arg::new("depth") + .short('d') + .long("depth") + .value_parser(value_parser!(u32)) + .help("Graph traversal depth"), + ) + .arg( + Arg::new("min-similarity") + .short('s') + .long("min-similarity") + .value_parser(value_parser!(f32)) + .help("Minimum similarity threshold (0.0 to 1.0)"), + ), + ) + .subcommand( + Command::new("full-graph") + .about("Build full knowledge graph for an agent") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("max-nodes") + .long("max-nodes") + .value_parser(value_parser!(u32)) + .help("Maximum number of nodes"), + ) + .arg( + Arg::new("min-similarity") + .short('s') + .long("min-similarity") + .value_parser(value_parser!(f32)) + .help("Minimum similarity threshold (0.0 to 1.0)"), + ) + .arg( + Arg::new("cluster-threshold") + .long("cluster-threshold") + .value_parser(value_parser!(f32)) + .help("Cluster similarity threshold (0.0 to 1.0)"), + ) + .arg( + Arg::new("max-edges") + .long("max-edges") + .value_parser(value_parser!(u32)) + .help("Maximum edges per node"), + ), + ) + .subcommand( + Command::new("summarize") + .about("Summarize agent memories") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("memory-ids") + .long("memory-ids") + .help("Comma-separated memory IDs to summarize"), + ) + .arg( + Arg::new("target-type") + .short('t') + .long("target-type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Target memory type for the summary"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Preview summarization without applying changes"), + ), + ) + .subcommand( + Command::new("deduplicate") + .about("Find and remove duplicate memories") + .arg(Arg::new("agent_id").required(true).help("Agent ID")) + .arg( + Arg::new("threshold") + .long("threshold") + .value_parser(value_parser!(f32)) + .help("Similarity threshold for deduplication (0.0 to 1.0)"), + ) + .arg( + Arg::new("type") + .short('t') + .long("type") + .value_parser(["episodic", "semantic", "procedural", "working"]) + .help("Filter by memory type"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Preview deduplication without applying changes"), + ), + ) +} + +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") + .subcommand( + Command::new("create") + .about("Create a new API key") + .arg( + Arg::new("name") + .required(true) + .help("Human-readable name for the key"), + ) + .arg( + Arg::new("permissions") + .short('p') + .long("permissions") + .help("Permission scope (e.g. read, write, admin)"), + ) + .arg( + Arg::new("expires") + .short('e') + .long("expires") + .value_parser(value_parser!(u64)) + .help("Expiration in days"), + ), + ) + .subcommand(Command::new("list").about("List all API keys")) + .subcommand( + Command::new("get") + .about("Get API key details") + .arg(Arg::new("key_id").required(true).help("API key ID")), + ) + .subcommand( + Command::new("delete") + .about("Delete (revoke) an API key") + .arg(Arg::new("key_id").required(true).help("API key ID")), + ) + .subcommand( + Command::new("deactivate") + .about("Deactivate an API key without deleting it") + .arg(Arg::new("key_id").required(true).help("API key ID")), + ) + .subcommand( + Command::new("rotate") + .about("Rotate an API key (generate new secret)") + .arg(Arg::new("key_id").required(true).help("API key ID")), + ) + .subcommand( + Command::new("usage") + .about("Get usage statistics for an API key") + .arg(Arg::new("key_id").required(true).help("API key ID")), + ) +} + +pub fn build_analytics_command() -> Command { + Command::new("analytics") + .about("View platform analytics and metrics") + .subcommand( + Command::new("overview") + .about("Analytics overview") + .arg( + Arg::new("period") + .short('p') + .long("period") + .default_value("24h") + .help("Time period (e.g. 1h, 24h, 7d)"), + ) + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Filter by namespace"), + ), + ) + .subcommand( + Command::new("latency") + .about("Latency statistics") + .arg( + Arg::new("period") + .short('p') + .long("period") + .default_value("24h") + .help("Time period (e.g. 1h, 24h, 7d)"), + ) + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Filter by namespace"), + ), + ) + .subcommand( + Command::new("throughput") + .about("Throughput statistics") + .arg( + Arg::new("period") + .short('p') + .long("period") + .default_value("24h") + .help("Time period (e.g. 1h, 24h, 7d)"), + ) + .arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Filter by namespace"), + ), + ) + .subcommand( + Command::new("storage").about("Storage statistics").arg( + Arg::new("namespace") + .short('n') + .long("namespace") + .help("Filter by namespace"), + ), + ) +} diff --git a/src/commands/admin.rs b/src/commands/admin.rs index f17a4d9..cac753b 100644 --- a/src/commands/admin.rs +++ b/src/commands/admin.rs @@ -5,10 +5,9 @@ use clap::ArgMatches; use dakera_client::reqwest; use serde_json::Value; +use crate::context::Context as Ctx; use crate::output; -use crate::OutputFormat; -/// Helper: build a reqwest client and make a GET request, returning JSON Value async fn admin_get(url: &str, path: &str) -> Result { let client = reqwest::Client::new(); let resp = client @@ -25,7 +24,6 @@ async fn admin_get(url: &str, path: &str) -> Result { serde_json::from_str(&body).with_context(|| "Failed to parse response JSON") } -/// Helper: make a POST request with optional JSON body, returning JSON Value 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)); @@ -49,7 +47,6 @@ async fn admin_post(url: &str, path: &str, body: Option<&Value>) -> Result Result { let client = reqwest::Client::new(); let resp = client @@ -70,7 +67,6 @@ async fn admin_delete(url: &str, path: &str) -> Result { } } -/// Helper: make a PUT request with JSON body, returning JSON Value async fn admin_put(url: &str, path: &str, body: &Value) -> Result { let client = reqwest::Client::new(); let resp = client @@ -92,75 +88,64 @@ async fn admin_put(url: &str, path: &str, body: &Value) -> Result { } } -fn print_value(value: &Value, format: OutputFormat) { - match format { - OutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(value).unwrap_or_default() - ); - } - OutputFormat::Compact => { - println!("{}", serde_json::to_string(value).unwrap_or_default()); - } - OutputFormat::Table => { - println!( - "{}", - serde_json::to_string_pretty(value).unwrap_or_default() - ); - } - } -} - -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { +pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { match matches.subcommand() { Some(("cluster-status", _sub)) => { - let result = admin_get(url, "/admin/cluster/status").await?; + 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"); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("cluster-nodes", _sub)) => { - let result = admin_get(url, "/admin/cluster/nodes").await?; + 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"); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("optimize", sub)) => { let namespace = sub.get_one::("namespace").unwrap(); - let result = admin_post( - url, - &format!("/admin/namespaces/{}/optimize", namespace), - None, - ) - .await?; + 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)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("index-stats", sub)) => { let namespace = sub.get_one::("namespace").unwrap(); - let result = admin_get( - url, - &format!("/admin/indexes/stats?namespace={}", namespace), - ) - .await?; + 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)); - print_value(&result, format); + 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 result = admin_post(url, "/admin/indexes/rebuild", Some(&body)).await?; + 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)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("cache-stats", _sub)) => { - let result = admin_get(url, "/admin/cache/stats").await?; + 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"); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("cache-clear", sub)) => { @@ -169,114 +154,136 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R Some(ns) => serde_json::json!({ "namespace": ns }), None => serde_json::json!({}), }; - let result = admin_post(url, "/admin/cache/clear", Some(&body)).await?; + 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"); } - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("config-get", _sub)) => { - let result = admin_get(url, "/admin/config").await?; + 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"); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("config-set", sub)) => { let key = sub.get_one::("key").unwrap(); let value = sub.get_one::("value").unwrap(); - - // Try to parse value as JSON first, fall back to string let json_value: Value = serde_json::from_str(value).unwrap_or(Value::String(value.clone())); let body = serde_json::json!({ key: json_value }); - let result = admin_put(url, "/admin/config", &body).await?; + 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)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("quotas-get", _sub)) => { - let result = admin_get(url, "/admin/quotas").await?; + 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"); - print_value(&result, format); + 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 result = admin_put(url, "/admin/quotas", &body).await?; + 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"); - print_value(&result, format); + 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 result = admin_get(url, &path).await?; + 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"); - print_value(&result, format); + 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 result = admin_post(url, "/admin/backups", Some(&body)).await?; + 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"); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("backup-list", _sub)) => { - let result = admin_get(url, "/admin/backups").await?; + 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"); - print_value(&result, format); + 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 result = admin_post(url, "/admin/backups/restore", Some(&body)).await?; + 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)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("backup-delete", sub)) => { let backup_id = sub.get_one::("backup_id").unwrap(); - let result = admin_delete(url, &format!("/admin/backups/{}", backup_id)).await?; + 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)); - print_value(&result, format); + 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, - }); + 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 result = - admin_put(url, &format!("/admin/namespaces/{}/ttl", namespace), &body).await?; + 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 )); - print_value(&result, format); + output::print_item(&result?, ctx.format); } _ => { diff --git a/src/commands/agent.rs b/src/commands/agent.rs index b4bc85d..113ba26 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -5,8 +5,8 @@ use clap::ArgMatches; use dakera_client::DakeraClient; use serde::Serialize; +use crate::context::Context; use crate::output; -use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct AgentRow { @@ -31,12 +31,18 @@ pub struct AgentSessionRow { pub ended_at: String, } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("list", _)) => { - let agents = client.list_agents().await?; + let t = ctx.log_request("GET", "/v1/agents"); + let agents = client.list_agents().await; + match &agents { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let agents = agents?; if agents.is_empty() { output::info("No agents found"); @@ -50,7 +56,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R active_sessions: a.active_sessions, }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } @@ -59,7 +65,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R let memory_type = sub_matches.get_one::("type").map(|s| s.as_str()); let limit = sub_matches.get_one::("limit").copied(); - let memories = client.agent_memories(agent_id, memory_type, limit).await?; + let t = ctx.log_request("GET", &format!("/v1/agents/{}/memories", agent_id)); + let memories = client.agent_memories(agent_id, memory_type, limit).await; + match &memories { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let memories = memories?; if memories.is_empty() { output::info(&format!("No memories found for agent '{}'", agent_id)); @@ -78,14 +90,20 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R importance: m.importance, }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } Some(("stats", sub_matches)) => { let agent_id = sub_matches.get_one::("agent_id").unwrap(); - let stats = client.agent_stats(agent_id).await?; + let t = ctx.log_request("GET", &format!("/v1/agents/{}/stats", agent_id)); + let stats = client.agent_stats(agent_id).await; + match &stats { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let stats = stats?; let pairs = [ ("Agent ID", stats.agent_id), @@ -114,7 +132,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); if !stats.memories_by_type.is_empty() { @@ -132,9 +150,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R let limit = sub_matches.get_one::("limit").copied(); let active_filter = if active_only { Some(true) } else { None }; - let sessions = client - .agent_sessions(agent_id, active_filter, limit) - .await?; + let t = ctx.log_request("GET", &format!("/v1/agents/{}/sessions", agent_id)); + let sessions = client.agent_sessions(agent_id, active_filter, limit).await; + match &sessions { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let sessions = sessions?; if sessions.is_empty() { output::info(&format!("No sessions found for agent '{}'", agent_id)); @@ -155,7 +177,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .unwrap_or_else(|| "active".to_string()), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } diff --git a/src/commands/analytics.rs b/src/commands/analytics.rs index 64ebb50..70767d3 100644 --- a/src/commands/analytics.rs +++ b/src/commands/analytics.rs @@ -4,11 +4,11 @@ use anyhow::Result; use clap::ArgMatches; use dakera_client::DakeraClient; +use crate::context::Context; use crate::output; -use crate::OutputFormat; -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("overview", sub_matches)) => { @@ -17,7 +17,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .get_one::("namespace") .map(|s| s.as_str()); - let overview = client.analytics_overview(period, namespace).await?; + let t = ctx.log_request("GET", "/v1/analytics/overview"); + let overview = client.analytics_overview(period, namespace).await; + match &overview { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let overview = overview?; let pairs = [ ("Total Queries", overview.total_queries.to_string()), @@ -41,7 +47,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); } @@ -51,7 +57,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .get_one::("namespace") .map(|s| s.as_str()); - let latency = client.analytics_latency(period, namespace).await?; + let t = ctx.log_request("GET", "/v1/analytics/latency"); + let latency = client.analytics_latency(period, namespace).await; + match &latency { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let latency = latency?; let pairs = [ ("Period", latency.period.clone()), @@ -67,7 +79,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); if !latency.by_operation.is_empty() { @@ -88,7 +100,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .get_one::("namespace") .map(|s| s.as_str()); - let throughput = client.analytics_throughput(period, namespace).await?; + let t = ctx.log_request("GET", "/v1/analytics/throughput"); + let throughput = client.analytics_throughput(period, namespace).await; + match &throughput { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let throughput = throughput?; let pairs = [ ("Period", throughput.period.clone()), @@ -104,7 +122,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); if !throughput.by_operation.is_empty() { @@ -121,7 +139,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .get_one::("namespace") .map(|s| s.as_str()); - let storage = client.analytics_storage(namespace).await?; + let t = ctx.log_request("GET", "/v1/analytics/storage"); + let storage = client.analytics_storage(namespace).await; + match &storage { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let storage = storage?; let pairs = [ ("Total", format_bytes(storage.total_bytes)), @@ -134,7 +158,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); if !storage.by_namespace.is_empty() { @@ -160,7 +184,6 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R Ok(()) } -/// Format bytes into human-readable string fn format_bytes(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; @@ -180,7 +203,6 @@ fn format_bytes(bytes: u64) -> String { } } -/// Format seconds into human-readable duration fn format_duration(seconds: u64) -> String { let days = seconds / 86400; let hours = (seconds % 86400) / 3600; diff --git a/src/commands/health.rs b/src/commands/health.rs index 55f2bc1..8e7785c 100644 --- a/src/commands/health.rs +++ b/src/commands/health.rs @@ -4,101 +4,125 @@ use anyhow::Result; use dakera_client::DakeraClient; use nu_ansi_term::{Color, Style}; +use crate::context::Context; use crate::output; -use crate::OutputFormat; -pub async fn execute(url: &str, detailed: bool, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, detailed: bool) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; - // Check basic health - let health = client.health().await; + if detailed { + let t = ctx.log_request("GET", "/health"); + let health_result = client.health().await; + match &health_result { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let h = health_result?; - match health { - Ok(h) => { - if detailed { - // Get additional health information - let ready = client.ready().await.ok(); - let live = client.live().await.unwrap_or(false); - let diagnostics = client.diagnostics().await.ok(); + let t = ctx.log_request("GET", "/health/ready"); + let ready = client.ready().await.ok(); + ctx.log_response(t, "200 OK"); - let green = Style::new().fg(Color::Green); - let red = Style::new().fg(Color::Red); - let yellow = Style::new().fg(Color::Yellow); - let cyan = Style::new().fg(Color::Cyan).bold(); + let t = ctx.log_request("GET", "/health/live"); + let live = client.live().await.unwrap_or(false); + ctx.log_response(t, "200 OK"); - let pairs = [ - ( - "Status", - if h.healthy { - green.paint("Healthy").to_string() - } else { - red.paint("Unhealthy").to_string() - }, - ), - ( - "Live", - if live { + let t = ctx.log_request("GET", "/ops/diagnostics"); + let diagnostics = client.diagnostics().await.ok(); + ctx.log_response(t, "200 OK"); + + let green = Style::new().fg(Color::Green); + let red = Style::new().fg(Color::Red); + let yellow = Style::new().fg(Color::Yellow); + let cyan = Style::new().fg(Color::Cyan).bold(); + + let pairs = [ + ( + "Status", + if h.healthy { + green.paint("Healthy").to_string() + } else { + red.paint("Unhealthy").to_string() + }, + ), + ( + "Live", + if live { + green.paint("Yes").to_string() + } else { + red.paint("No").to_string() + }, + ), + ( + "Ready", + ready + .as_ref() + .map(|r| { + if r.ready { green.paint("Yes").to_string() } else { - red.paint("No").to_string() - }, - ), - ( - "Ready", - ready - .as_ref() - .map(|r| { - if r.ready { - green.paint("Yes").to_string() - } else { - yellow.paint("No").to_string() - } - }) - .unwrap_or_else(|| "Unknown".to_string()), - ), - ( - "Version", - h.version.unwrap_or_else(|| "Unknown".to_string()), - ), - ( - "Uptime", - h.uptime_seconds - .map(format_duration) - .unwrap_or_else(|| "Unknown".to_string()), - ), - ]; + yellow.paint("No").to_string() + } + }) + .unwrap_or_else(|| "Unknown".to_string()), + ), + ( + "Version", + h.version.unwrap_or_else(|| "Unknown".to_string()), + ), + ( + "Uptime", + h.uptime_seconds + .map(format_duration) + .unwrap_or_else(|| "Unknown".to_string()), + ), + ]; - output::print_kv( - &pairs - .iter() - .map(|(k, v)| (*k, v.clone())) - .collect::>(), - format, - ); + output::print_kv( + &pairs + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ctx.format, + ); - if let Some(diag) = diagnostics { - println!(); - println!("{}", cyan.paint("System Diagnostics:")); - println!( - " Memory Used: {} MB", - diag.resources.memory_bytes / 1024 / 1024 - ); - println!(" Threads: {}", diag.resources.thread_count); - println!(" Open FDs: {}", diag.resources.open_fds); - println!(" Active Jobs: {}", diag.active_jobs); - } - } else if h.healthy { - output::success(&format!("Server at {} is healthy", url)); - if let Some(v) = h.version { - println!(" Version: {}", v); + if let Some(diag) = diagnostics { + println!(); + println!("{}", cyan.paint("System Diagnostics:")); + println!( + " Memory Used: {} MB", + diag.resources.memory_bytes / 1024 / 1024 + ); + println!(" Threads: {}", diag.resources.thread_count); + println!(" Open FDs: {}", diag.resources.open_fds); + println!(" Active Jobs: {}", diag.active_jobs); + } + } else { + let t = ctx.log_request("GET", "/health"); + let health = client.health().await; + match &health { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + + match health { + Ok(h) => { + if h.healthy { + output::success(&format!("Server at {} is healthy", ctx.url)); + if let Some(v) = h.version { + println!(" Version: {}", v); + } + } else { + output::error(&format!("Server at {} is unhealthy", ctx.url)); } - } else { - output::error(&format!("Server at {} is unhealthy", url)); } - } - Err(e) => { - output::error(&format!("Failed to connect to server at {}: {}", url, e)); - std::process::exit(1); + Err(e) => { + output::error(&format!( + "Failed to connect to server at {}: {}", + ctx.url, e + )); + std::process::exit(1); + } } } diff --git a/src/commands/index.rs b/src/commands/index.rs index e934556..0e1d154 100644 --- a/src/commands/index.rs +++ b/src/commands/index.rs @@ -4,16 +4,22 @@ use anyhow::Result; use clap::ArgMatches; use dakera_client::DakeraClient; +use crate::context::Context; use crate::output; -use crate::OutputFormat; -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("stats", sub_matches)) => { let namespace = sub_matches.get_one::("namespace").unwrap(); - let info = client.get_namespace(namespace).await?; + let t = ctx.log_request("GET", &format!("/v1/namespaces/{}", namespace)); + let info = client.get_namespace(namespace).await; + match &info { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let info = info?; let pairs = [ ("Namespace", namespace.clone()), @@ -37,14 +43,19 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); } Some(("fulltext-stats", sub_matches)) => { let namespace = sub_matches.get_one::("namespace").unwrap(); - let stats = client.fulltext_stats(namespace).await?; - output::print_item(&stats, format); + let t = ctx.log_request("GET", &format!("/v1/{}/fulltext/stats", namespace)); + let stats = client.fulltext_stats(namespace).await; + match &stats { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + output::print_item(&stats?, ctx.format); } Some(("rebuild", sub_matches)) => { @@ -85,8 +96,6 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R index_type, namespace )); - // Note: This would call a rebuild endpoint when available - // For now, compaction can help optimize indexes output::warning("Direct index rebuild not yet available"); output::info("Use 'dk ops compact' to optimize storage and indexes"); } diff --git a/src/commands/keys.rs b/src/commands/keys.rs index 20d4163..325d807 100644 --- a/src/commands/keys.rs +++ b/src/commands/keys.rs @@ -5,10 +5,9 @@ use clap::ArgMatches; use dakera_client::reqwest; use serde_json::Value; +use crate::context::Context as Ctx; use crate::output; -use crate::OutputFormat; -/// Helper: make a GET request, returning JSON Value async fn keys_get(url: &str, path: &str) -> Result { let client = reqwest::Client::new(); let resp = client @@ -25,7 +24,6 @@ async fn keys_get(url: &str, path: &str) -> Result { serde_json::from_str(&body).with_context(|| "Failed to parse response JSON") } -/// Helper: make a POST request with optional JSON body, returning JSON Value async fn keys_post(url: &str, path: &str, body: Option<&Value>) -> Result { let client = reqwest::Client::new(); let mut req = client.post(format!("{}{}", url, path)); @@ -49,7 +47,6 @@ async fn keys_post(url: &str, path: &str, body: Option<&Value>) -> Result } } -/// Helper: make a DELETE request, returning JSON Value async fn keys_delete(url: &str, path: &str) -> Result { let client = reqwest::Client::new(); let resp = client @@ -70,55 +67,30 @@ async fn keys_delete(url: &str, path: &str) -> Result { } } -fn print_value(value: &Value, format: OutputFormat) { - match format { - OutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(value).unwrap_or_default() - ); - } - OutputFormat::Compact => { - println!("{}", serde_json::to_string(value).unwrap_or_default()); - } - OutputFormat::Table => { - println!( - "{}", - serde_json::to_string_pretty(value).unwrap_or_default() - ); - } - } -} - -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { +pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { match matches.subcommand() { Some(("create", sub)) => { let name = sub.get_one::("name").unwrap(); let permissions = sub.get_one::("permissions"); let expires = sub.get_one::("expires"); - let mut body = serde_json::json!({ - "name": name, - }); - if let Some(perms) = permissions { - // scope is what the API expects - body.as_object_mut() - .unwrap() - .insert("scope".to_string(), Value::String(perms.clone())); - } else { - body.as_object_mut() - .unwrap() - .insert("scope".to_string(), Value::String("read".to_string())); - } + let mut body = serde_json::json!({ "name": name }); + body.as_object_mut().unwrap().insert( + "scope".to_string(), + Value::String(permissions.cloned().unwrap_or_else(|| "read".to_string())), + ); if let Some(exp) = expires { body.as_object_mut() .unwrap() .insert("expires_in_days".to_string(), Value::Number((*exp).into())); } - let result = keys_post(url, "/admin/keys", Some(&body)).await?; + let path = "/admin/keys"; + let t = ctx.log_request("POST", path); + let result = keys_post(&ctx.url, path, Some(&body)).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); + let result = result?; - // The key is only shown once, highlight it if let Some(key) = result.get("key").and_then(|k| k.as_str()) { output::success(&format!("API key created: {}", name)); output::warning("Save this key now - it will not be shown again!"); @@ -130,42 +102,56 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R println!(); } else { output::success(&format!("API key '{}' created", name)); - print_value(&result, format); + output::print_item(&result, ctx.format); } } Some(("list", _sub)) => { - let result = keys_get(url, "/admin/keys").await?; + let path = "/admin/keys"; + let t = ctx.log_request("GET", path); + let result = keys_get(&ctx.url, path).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); output::info("API Keys"); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("get", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); - let result = keys_get(url, &format!("/admin/keys/{}", key_id)).await?; - print_value(&result, format); + let path = format!("/admin/keys/{}", key_id); + let t = ctx.log_request("GET", &path); + let result = keys_get(&ctx.url, &path).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); + output::print_item(&result?, ctx.format); } Some(("delete", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); - let result = keys_delete(url, &format!("/admin/keys/{}", key_id)).await?; + let path = format!("/admin/keys/{}", key_id); + let t = ctx.log_request("DELETE", &path); + let result = keys_delete(&ctx.url, &path).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); output::success(&format!("API key '{}' deleted", key_id)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("deactivate", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); - let result = - keys_post(url, &format!("/admin/keys/{}/deactivate", key_id), None).await?; + let path = format!("/admin/keys/{}/deactivate", key_id); + let t = ctx.log_request("POST", &path); + let result = keys_post(&ctx.url, &path, None).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); output::success(&format!("API key '{}' deactivated", key_id)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } Some(("rotate", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); - let result = keys_post(url, &format!("/admin/keys/{}/rotate", key_id), None).await?; + let path = format!("/admin/keys/{}/rotate", key_id); + let t = ctx.log_request("POST", &path); + let result = keys_post(&ctx.url, &path, None).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); + let result = result?; - // Rotation returns a new key - highlight it if let Some(new_key) = result.get("key").and_then(|k| k.as_str()) { output::success(&format!("API key '{}' rotated", key_id)); output::warning("Save the new key now - it will not be shown again!"); @@ -174,15 +160,18 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R println!(); } else { output::success(&format!("API key '{}' rotated", key_id)); - print_value(&result, format); + output::print_item(&result, ctx.format); } } Some(("usage", sub)) => { let key_id = sub.get_one::("key_id").unwrap(); - let result = keys_get(url, &format!("/admin/keys/{}/usage", key_id)).await?; + let path = format!("/admin/keys/{}/usage", key_id); + let t = ctx.log_request("GET", &path); + let result = keys_get(&ctx.url, &path).await; + ctx.log_response(t, if result.is_ok() { "200 OK" } else { "ERR" }); output::info(&format!("Usage for key '{}'", key_id)); - print_value(&result, format); + output::print_item(&result?, ctx.format); } _ => { diff --git a/src/commands/knowledge.rs b/src/commands/knowledge.rs index 9e0c0e9..61e25d7 100644 --- a/src/commands/knowledge.rs +++ b/src/commands/knowledge.rs @@ -8,8 +8,8 @@ use dakera_client::knowledge::{ use dakera_client::DakeraClient; use serde::Serialize; +use crate::context::Context; use crate::output; -use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct NodeRow { @@ -33,8 +33,8 @@ pub struct DuplicateGroupRow { pub memory_ids: String, } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("graph", sub_matches)) => { @@ -50,7 +50,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R min_similarity, }; - let response = client.knowledge_graph(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/knowledge/graph", agent_id)); + let response = client.knowledge_graph(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; output::info(&format!( "Knowledge graph: {} nodes, {} edges", @@ -76,7 +82,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .unwrap_or_else(|| "-".to_string()), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } if !response.edges.is_empty() { @@ -92,7 +98,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R relationship: e.relationship.unwrap_or_else(|| "-".to_string()), }) .collect(); - output::print_data(&edge_rows, format); + output::print_data(&edge_rows, ctx.format); } if let Some(clusters) = response.clusters { @@ -121,7 +127,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R max_edges_per_node: max_edges, }; - let response = client.full_knowledge_graph(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/knowledge/full-graph", agent_id)); + let response = client.full_knowledge_graph(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; output::info(&format!( "Full knowledge graph for '{}': {} nodes, {} edges", @@ -148,7 +160,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .unwrap_or_else(|| "-".to_string()), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } if !response.edges.is_empty() { @@ -164,7 +176,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R relationship: e.relationship.unwrap_or_else(|| "-".to_string()), }) .collect(); - output::print_data(&edge_rows, format); + output::print_data(&edge_rows, ctx.format); } if let Some(clusters) = response.clusters { @@ -193,7 +205,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R dry_run, }; - let response = client.summarize(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/knowledge/summarize", agent_id)); + let response = client.summarize(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; if dry_run { output::info(&format!( @@ -226,7 +244,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R dry_run, }; - let response = client.deduplicate(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/knowledge/deduplicate", agent_id)); + let response = client.deduplicate(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; if dry_run { output::info(&format!( @@ -254,7 +278,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R memory_ids: group.join(", "), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } diff --git a/src/commands/memory.rs b/src/commands/memory.rs index 22d6973..7b9b77c 100644 --- a/src/commands/memory.rs +++ b/src/commands/memory.rs @@ -9,8 +9,8 @@ use dakera_client::memory::{ use dakera_client::DakeraClient; use serde::Serialize; +use crate::context::Context; use crate::output; -use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct MemoryRow { @@ -39,8 +39,8 @@ fn memory_type_to_string(mt: &MemoryType) -> String { } } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("store", sub_matches)) => { @@ -61,7 +61,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R request = request.with_session(sid); } - let response = client.store_memory(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/memories", agent_id)); + let response = client.store_memory(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; output::success(&format!( "Memory stored (id: {}, namespace: {})", @@ -81,7 +87,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R request = request.with_type(parse_memory_type(t)); } - let response = client.recall(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/memories/recall", agent_id)); + let response = client.recall(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; if response.memories.is_empty() { output::info("No memories found"); @@ -102,7 +114,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R score: m.score, }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } @@ -110,8 +122,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R let _agent_id = sub_matches.get_one::("agent_id").unwrap(); let memory_id = sub_matches.get_one::("memory_id").unwrap(); - let memory = client.get_memory(memory_id).await?; - output::print_item(&memory, format); + let t = ctx.log_request("GET", &format!("/v1/memories/{}", memory_id)); + let memory = client.get_memory(memory_id).await; + match &memory { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + output::print_item(&memory?, ctx.format); } Some(("update", sub_matches)) => { @@ -128,8 +145,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R memory_type, }; - let response = client.update_memory(agent_id, memory_id, request).await?; - output::success(&format!("Memory '{}' updated", response.memory_id)); + let t = ctx.log_request("PUT", &format!("/v1/{}/memories/{}", agent_id, memory_id)); + let response = client.update_memory(agent_id, memory_id, request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + output::success(&format!("Memory '{}' updated", response?.memory_id)); } Some(("forget", sub_matches)) => { @@ -140,7 +162,16 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R agent_id.clone(), vec![memory_id.clone()], ); - let response = client.forget(request).await?; + let t = ctx.log_request( + "DELETE", + &format!("/v1/{}/memories/{}", agent_id, memory_id), + ); + let response = client.forget(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; output::success(&format!( "Deleted {} memory (id: {})", @@ -160,7 +191,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R request = request.with_type(parse_memory_type(t)); } - let response = client.search_memories(request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/memories/search", agent_id)); + let response = client.search_memories(request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; if response.memories.is_empty() { output::info("No memories found"); @@ -181,7 +218,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R score: m.score, }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } @@ -200,7 +237,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R importance: value, }; + let t = ctx.log_request("PUT", &format!("/v1/{}/memories/importance", agent_id)); client.update_importance(agent_id, request).await?; + ctx.log_response(t, "200 OK"); output::success(&format!( "Updated importance to {} for {} memories", value, @@ -221,7 +260,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R ..Default::default() }; - let response = client.consolidate(agent_id, request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/memories/consolidate", agent_id)); + let response = client.consolidate(agent_id, request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; if dry_run { output::info(&format!( @@ -250,7 +295,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R relevance_score: score, }; - let response = client.memory_feedback(agent_id, request).await?; + let t = ctx.log_request("POST", &format!("/v1/{}/memories/feedback", agent_id)); + let response = client.memory_feedback(agent_id, request).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; output::success(&format!("Feedback submitted (status: {})", response.status)); if let Some(importance) = response.updated_importance { diff --git a/src/commands/namespace.rs b/src/commands/namespace.rs index 190fe72..11cdb2d 100644 --- a/src/commands/namespace.rs +++ b/src/commands/namespace.rs @@ -5,20 +5,26 @@ use clap::ArgMatches; use dakera_client::DakeraClient; use serde::Serialize; +use crate::context::Context; use crate::output; -use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct NamespaceRow { pub name: String, } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("list", _)) => { - let namespaces = client.list_namespaces().await?; + let t = ctx.log_request("GET", "/v1/namespaces"); + let namespaces = client.list_namespaces().await; + match &namespaces { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let namespaces = namespaces?; if namespaces.is_empty() { output::info("No namespaces found"); @@ -27,14 +33,20 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .into_iter() .map(|name| NamespaceRow { name }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } Some(("get", sub_matches)) => { let name = sub_matches.get_one::("name").unwrap(); - let info = client.get_namespace(name).await?; - output::print_item(&info, format); + let path = format!("/v1/namespaces/{}", name); + let t = ctx.log_request("GET", &path); + let info = client.get_namespace(name).await; + match &info { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + output::print_item(&info?, ctx.format); } Some(("create", sub_matches)) => { @@ -92,7 +104,6 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R } } - // Note: Dakera doesn't have a delete namespace endpoint yet output::warning("Namespace deletion is not yet implemented in the server"); output::info("To remove all vectors from a namespace, use 'dk vector delete --all'"); } @@ -101,15 +112,27 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R match sub_matches.subcommand() { Some(("get", get_matches)) => { let ns = get_matches.get_one::("namespace").unwrap(); - let policy = client.get_memory_policy(ns).await?; - output::print_item(&policy, format); + let path = format!("/v1/namespaces/{}/memory_policy", ns); + let t = ctx.log_request("GET", &path); + let policy = client.get_memory_policy(ns).await; + match &policy { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + output::print_item(&policy?, ctx.format); } Some(("set", set_matches)) => { let ns = set_matches.get_one::("namespace").unwrap(); // Fetch the current policy so we only change what the user specified. - let mut policy = client.get_memory_policy(ns).await?; + let t = ctx.log_request("GET", &format!("/v1/namespaces/{}/memory_policy", ns)); + let policy = client.get_memory_policy(ns).await; + match &policy { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let mut policy = policy?; if let Some(v) = set_matches.get_one::("working-ttl") { policy.working_ttl_seconds = Some(*v); @@ -163,9 +186,15 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R // consolidated_count is read-only — clear it before sending policy.consolidated_count = None; - let updated = client.set_memory_policy(ns, policy).await?; + let path = format!("/v1/namespaces/{}/memory_policy", ns); + let t = ctx.log_request("PUT", &path); + let updated = client.set_memory_policy(ns, policy).await; + match &updated { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } output::success(&format!("Memory policy updated for namespace '{}'", ns)); - output::print_item(&updated, format); + output::print_item(&updated?, ctx.format); } _ => { diff --git a/src/commands/ops.rs b/src/commands/ops.rs index 37a7665..6e03859 100644 --- a/src/commands/ops.rs +++ b/src/commands/ops.rs @@ -6,8 +6,8 @@ use dakera_client::{reqwest, CompactionRequest, DakeraClient, OpsStats}; use nu_ansi_term::{Color, Style}; use serde::Serialize; +use crate::context::Context; use crate::output; -use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct JobRow { @@ -18,12 +18,14 @@ pub struct JobRow { pub created_at: String, } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("stats", _)) => { + let t = ctx.log_request("GET", "/ops/stats"); let stats: OpsStats = client.ops_stats().await?; + ctx.log_response(t, "200 OK"); let state_label = match stats.state.as_str() { "healthy" => format!("{} (healthy)", stats.state), @@ -44,12 +46,14 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); } Some(("diagnostics", _)) => { + let t = ctx.log_request("GET", "/ops/diagnostics"); let diag = client.diagnostics().await?; + ctx.log_response(t, "200 OK"); let pairs = [ ("Server Version", diag.system.version.clone()), @@ -70,7 +74,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); let cyan = Style::new().fg(Color::Cyan).bold(); @@ -118,7 +122,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R } Some(("jobs", _)) => { + let t = ctx.log_request("GET", "/ops/jobs"); let jobs = client.list_jobs().await?; + ctx.log_response(t, "200 OK"); if jobs.is_empty() { output::info("No background jobs"); @@ -133,13 +139,15 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R created_at: format_timestamp(j.created_at), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } Some(("job", sub_matches)) => { let id = sub_matches.get_one::("id").unwrap(); + let t = ctx.log_request("GET", &format!("/ops/jobs/{}", id)); let job = client.get_job(id).await?; + ctx.log_response(t, "200 OK"); match job { Some(j) => { @@ -156,7 +164,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .iter() .map(|(k, v)| (*k, v.clone())) .collect::>(), - format, + ctx.format, ); } None => { @@ -177,7 +185,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R force, }; + let t = ctx.log_request("POST", "/ops/compact"); let response = client.compact(request).await?; + ctx.log_response(t, "200 OK"); output::success(&format!("Compaction started (job: {})", response.job_id)); output::info(&response.message); @@ -216,19 +226,24 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R } output::info("Requesting graceful shutdown..."); + let t = ctx.log_request("POST", "/ops/shutdown"); client.shutdown().await?; + ctx.log_response(t, "200 OK"); output::success("Shutdown request sent"); } Some(("metrics", _)) => { - // Fetch Prometheus metrics - let metrics_url = format!("{}/metrics", url); + let path = "/metrics"; + let t = ctx.log_request("GET", path); + let metrics_url = format!("{}{}", ctx.url, path); let response = reqwest::get(&metrics_url).await?; if response.status().is_success() { + ctx.log_response(t, "200 OK"); let text = response.text().await?; println!("{}", text); } else { + ctx.log_response(t, "ERR"); output::error("Failed to fetch metrics. Is the metrics endpoint enabled?"); std::process::exit(1); } @@ -256,7 +271,6 @@ fn format_duration(seconds: u64) -> String { } fn format_timestamp(ts: u64) -> String { - // Simple timestamp formatting (seconds since epoch) let secs_ago = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs().saturating_sub(ts)) diff --git a/src/commands/session.rs b/src/commands/session.rs index 6a732d9..b3c668c 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -5,8 +5,8 @@ use clap::ArgMatches; use dakera_client::DakeraClient; use serde::Serialize; +use crate::context::Context as Ctx; use crate::output; -use crate::OutputFormat; #[derive(Debug, Serialize)] pub struct SessionRow { @@ -24,14 +24,15 @@ pub struct SessionMemoryRow { pub score: f32, } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Ctx, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("start", sub_matches)) => { let agent_id = sub_matches.get_one::("agent_id").unwrap(); let metadata_str = sub_matches.get_one::("metadata"); + let t = ctx.log_request("POST", &format!("/v1/{}/sessions", agent_id)); let session = if let Some(m) = metadata_str { let metadata: serde_json::Value = serde_json::from_str(m).context("Invalid metadata JSON")?; @@ -41,30 +42,42 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R } else { client.start_session(agent_id).await? }; + ctx.log_response(t, "200 OK"); output::success(&format!( "Session started (id: {}, agent: {})", session.id, session.agent_id )); - output::print_item(&session, format); + output::print_item(&session, ctx.format); } Some(("end", sub_matches)) => { let session_id = sub_matches.get_one::("session_id").unwrap(); let summary = sub_matches.get_one::("summary").cloned(); - let response = client.end_session(session_id, summary).await?; + let t = ctx.log_request("PUT", &format!("/v1/sessions/{}/end", session_id)); + let response = client.end_session(session_id, summary).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; output::success(&format!("Session '{}' ended", response.session.id)); - output::print_item(&response, format); + output::print_item(&response, ctx.format); } Some(("get", sub_matches)) => { let session_id = sub_matches.get_one::("session_id").unwrap(); - let session = client.get_session(session_id).await?; - output::print_item(&session, format); + let t = ctx.log_request("GET", &format!("/v1/sessions/{}", session_id)); + let session = client.get_session(session_id).await; + match &session { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + output::print_item(&session?, ctx.format); } Some(("list", sub_matches)) => { @@ -72,7 +85,6 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R let active_only = sub_matches.get_flag("active-only"); let limit = *sub_matches.get_one::("limit").unwrap(); - // Build query parameters for the list endpoint let mut query_params = Vec::new(); if let Some(aid) = agent_id { query_params.push(format!("agent_id={}", aid)); @@ -88,14 +100,20 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R format!("?{}", query_params.join("&")) }; - // Use reqwest directly for query params the client doesn't support - let list_url = format!("{}/v1/sessions{}", url, query_string); + 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 status_str = if response.status().is_success() { + "200 OK" + } else { + "ERR" + }; + ctx.log_response(t, status_str); if response.status().is_success() { let body: serde_json::Value = response.json().await?; - // The API returns { sessions: [...], total: N } let sessions = body .get("sessions") .and_then(|v| v.as_array()) @@ -122,7 +140,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R }) }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } else { let status = response.status().as_u16(); @@ -135,7 +153,13 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R Some(("memories", sub_matches)) => { let session_id = sub_matches.get_one::("session_id").unwrap(); - let response = client.session_memories(session_id).await?; + let t = ctx.log_request("GET", &format!("/v1/sessions/{}/memories", session_id)); + let response = client.session_memories(session_id).await; + match &response { + Ok(_) => ctx.log_response(t, "200 OK"), + Err(_) => ctx.log_response(t, "ERR"), + } + let response = response?; if response.memories.is_empty() { output::info(&format!("No memories found for session '{}'", session_id)); @@ -156,7 +180,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R score: m.score, }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } diff --git a/src/commands/vector.rs b/src/commands/vector.rs index 10921d5..d666b93 100644 --- a/src/commands/vector.rs +++ b/src/commands/vector.rs @@ -1,18 +1,20 @@ //! Vector management commands -use anyhow::{Context, Result}; +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::OutputFormat; +use crate::retry; #[derive(Debug, Serialize)] pub struct QueryResultRow { @@ -21,8 +23,8 @@ pub struct QueryResultRow { pub metadata: Option, } -pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> Result<()> { - let client = DakeraClient::new(url)?; +pub async fn execute(ctx: &Context, matches: &ArgMatches) -> Result<()> { + let client = DakeraClient::new(&ctx.url)?; match matches.subcommand() { Some(("upsert", sub_matches)) => { @@ -43,21 +45,43 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R total, namespace )); - let mut upserted = 0; - for chunk in vectors.chunks(batch_size) { - let request = UpsertRequest { - vectors: chunk.to_vec(), - }; - client.upsert(namespace, request).await?; - upserted += chunk.len(); - println!( - " Progress: {}/{} ({:.1}%)", - upserted, - total, - (upserted as f64 / total as f64) * 100.0 + 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)); } @@ -83,7 +107,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R 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)); } @@ -109,7 +135,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R 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"); @@ -125,7 +153,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .map(|h| serde_json::Value::Object(h.into_iter().collect())), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } @@ -140,7 +168,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R 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"); @@ -156,7 +186,7 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .map(|h| serde_json::Value::Object(h.into_iter().collect())), }) .collect(); - output::print_data(&rows, format); + output::print_data(&rows, ctx.format); } } @@ -215,7 +245,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R 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)); } } @@ -235,9 +267,11 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R "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, format); + output::print_item(&json, ctx.format); } Some(("unified-query", sub_matches)) => { @@ -255,9 +289,11 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R "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, format); + output::print_item(&json, ctx.format); } Some(("aggregate", sub_matches)) => { @@ -272,9 +308,11 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R 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, format); + output::print_item(&json, ctx.format); } Some(("export", sub_matches)) => { @@ -292,9 +330,11 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R } 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, format); + output::print_item(&json, ctx.format); } Some(("explain", sub_matches)) => { @@ -318,9 +358,11 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R .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, format); + output::print_item(&json, ctx.format); } Some(("upsert-columns", sub_matches)) => { @@ -339,7 +381,9 @@ pub async fn execute(url: &str, matches: &ArgMatches, format: OutputFormat) -> R "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 diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..54c212a --- /dev/null +++ b/src/context.rs @@ -0,0 +1,40 @@ +//! Shared request context threaded through every command execute() call. + +use std::time::Instant; + +use crate::OutputFormat; + +/// Carries the server URL, output format, and verbose flag for a single +/// CLI invocation. Passed by reference into every command module. +pub struct Context { + pub url: String, + pub format: OutputFormat, + pub verbose: bool, +} + +impl Context { + pub fn new(url: impl Into, format: OutputFormat, verbose: bool) -> Self { + Self { + url: url.into(), + format, + verbose, + } + } + + /// Log an outgoing request and return the start time (used in `log_response`). + /// No-op when verbose is false. + pub fn log_request(&self, method: &str, path: &str) -> Instant { + if self.verbose { + tracing::info!("--> {} {}{}", method, self.url, path); + } + Instant::now() + } + + /// Log the result of a request. `status` is the HTTP status string (e.g. "200 OK"). + /// No-op when verbose is false. + pub fn log_response(&self, start: Instant, status: &str) { + if self.verbose { + tracing::info!("<-- {} ({:.0}ms)", status, start.elapsed().as_millis()); + } + } +} diff --git a/src/main.rs b/src/main.rs index 35ead37..ad50e56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,22 @@ //! Dakera CLI - Command-line interface for Dakera AI Agent Memory Platform +mod cli; mod commands; mod config; +mod context; pub mod error; mod output; +mod retry; -use clap::{value_parser, Arg, ArgAction, Command}; 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, }; use crate::config::Config; +use crate::context::Context; /// Output format for CLI results #[derive(Clone, Copy, Debug, Default)] @@ -33,1289 +37,22 @@ impl From<&str> for OutputFormat { } } -fn build_cli() -> Command { - Command::new("dk") - .version(env!("CARGO_PKG_VERSION")) - .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", - ) - .arg( - Arg::new("url") - .short('u') - .long("url") - .env("DAKERA_URL") - .default_value("http://localhost:3000") - .help("Server URL"), - ) - .arg( - Arg::new("format") - .short('f') - .long("format") - .default_value("table") - .value_parser(["table", "json", "compact"]) - .help("Output format"), - ) - .arg( - Arg::new("verbose") - .short('v') - .long("verbose") - .action(ArgAction::SetTrue) - .help("Enable verbose output"), - ) - .arg( - Arg::new("profile") - .short('p') - .long("profile") - .env("DAKERA_PROFILE") - .help("Named server profile to use (overrides active_profile in config)"), - ) - .subcommand( - Command::new("init") - .about("Interactive setup wizard — configure server URL and default namespace"), - ) - .subcommand( - Command::new("health") - .about("Check server health and connectivity") - .arg( - Arg::new("detailed") - .short('d') - .long("detailed") - .action(ArgAction::SetTrue) - .help("Show detailed health information"), - ), - ) - .subcommand(build_namespace_command()) - .subcommand(build_vector_command()) - .subcommand(build_index_command()) - .subcommand(build_ops_command()) - .subcommand(build_memory_command()) - .subcommand(build_session_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()) -} - -fn build_config_command() -> Command { - Command::new("config") - .about("Show configuration or manage server profiles") - .arg( - Arg::new("show") - .long("show") - .action(ArgAction::SetTrue) - .help("Show current configuration (default action)"), - ) - .subcommand( - Command::new("profile") - .about("Manage named server profiles") - .subcommand( - Command::new("add") - .about("Add or update a named profile") - .arg( - Arg::new("name") - .required(true) - .help("Profile name (e.g. local, staging, prod)"), - ) - .arg( - Arg::new("url") - .short('u') - .long("url") - .required(true) - .help("Server URL for this profile"), - ) - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Default namespace for this profile"), - ), - ) - .subcommand( - Command::new("use").about("Switch the active profile").arg( - Arg::new("name") - .required(true) - .help("Profile name to activate"), - ), - ) - .subcommand(Command::new("list").about("List all profiles")), - ) -} - -fn build_completion_command() -> Command { - Command::new("completion") - .about("Generate shell completion scripts") - .long_about( - "Generate shell completion scripts for bash, zsh, or fish.\n\ - \n\ - Print to stdout:\n\ - \n dk completion bash\n\ - \n dk completion zsh\n\ - \n dk completion fish\n\ - \nInstall automatically:\n\ - \n dk completion bash --install\n\ - \n dk completion zsh --install\n\ - \n dk completion fish --install\n\ - \nDynamic completion provides namespace and agent names from the live server.", - ) - .arg( - Arg::new("shell") - .required(true) - .value_parser(["bash", "zsh", "fish"]) - .help("Shell to generate completion for"), - ) - .arg( - Arg::new("install") - .long("install") - .action(ArgAction::SetTrue) - .help("Install the completion script to the appropriate location"), - ) -} - -fn build_namespace_command() -> Command { - Command::new("namespace") - .about("Manage namespaces") - .after_help( - "Examples:\n dk namespace list\n dk namespace get my-ns\n dk namespace create my-ns\n dk namespace delete my-ns --dry-run\n dk namespace delete my-ns --yes\n dk namespace policy get my-ns\n dk namespace policy set my-ns --consolidation-enabled true --rate-limit-enabled true --rate-limit-stores-per-minute 100", - ) - .subcommand(Command::new("list").about("List all namespaces")) - .subcommand( - Command::new("get") - .about("Get namespace information") - .arg(Arg::new("name").required(true).help("Namespace name")), - ) - .subcommand( - Command::new("create") - .about("Create a new namespace") - .arg(Arg::new("name").required(true).help("Namespace name")) - .arg( - Arg::new("dimension") - .short('d') - .long("dimension") - .value_parser(value_parser!(u32)) - .help("Vector dimension"), - ), - ) - .subcommand( - Command::new("delete") - .about("Delete a namespace and all its data") - .after_help("Examples:\n dk namespace delete my-ns --dry-run\n dk namespace delete my-ns --yes") - .arg(Arg::new("name").required(true).help("Namespace name")) - .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("policy") - .about("Manage namespace memory lifecycle policy (TTLs, consolidation, rate limiting)") - .after_help("Examples:\n dk namespace policy get my-ns\n dk namespace policy set my-ns --consolidation-enabled true\n dk namespace policy set my-ns --rate-limit-enabled true --rate-limit-stores-per-minute 60") - .subcommand( - Command::new("get") - .about("Show the current memory policy for a namespace") - .arg(Arg::new("namespace").required(true).help("Namespace name")), - ) - .subcommand( - Command::new("set") - .about("Update memory policy fields for a namespace (only supplied flags are changed)") - .arg(Arg::new("namespace").required(true).help("Namespace name")) - .arg(Arg::new("working-ttl").long("working-ttl").value_parser(value_parser!(u64)).help("TTL for working memories in seconds (default: 14400 = 4h)")) - .arg(Arg::new("episodic-ttl").long("episodic-ttl").value_parser(value_parser!(u64)).help("TTL for episodic memories in seconds (default: 2592000 = 30d)")) - .arg(Arg::new("semantic-ttl").long("semantic-ttl").value_parser(value_parser!(u64)).help("TTL for semantic memories in seconds (default: 31536000 = 365d)")) - .arg(Arg::new("procedural-ttl").long("procedural-ttl").value_parser(value_parser!(u64)).help("TTL for procedural memories in seconds (default: 63072000 = 730d)")) - .arg(Arg::new("working-decay").long("working-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for working memories")) - .arg(Arg::new("episodic-decay").long("episodic-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for episodic memories")) - .arg(Arg::new("semantic-decay").long("semantic-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for semantic memories")) - .arg(Arg::new("procedural-decay").long("procedural-decay").value_parser(["exponential", "power_law", "logarithmic", "flat"]).help("Decay curve for procedural memories")) - .arg(Arg::new("spaced-repetition-factor").long("spaced-repetition-factor").value_parser(value_parser!(f64)).help("TTL extension multiplier per recall hit (default: 1.0; 0.0 = disabled)")) - .arg(Arg::new("spaced-repetition-base-interval").long("spaced-repetition-base-interval").value_parser(value_parser!(u64)).help("Base interval in seconds for spaced repetition TTL extension (default: 86400 = 1d)")) - .arg(Arg::new("consolidation-enabled").long("consolidation-enabled").value_parser(value_parser!(bool)).help("Enable background DBSCAN deduplication (default: false)")) - .arg(Arg::new("consolidation-threshold").long("consolidation-threshold").value_parser(value_parser!(f32)).help("DBSCAN cosine-similarity threshold (default: 0.92; higher = stricter)")) - .arg(Arg::new("consolidation-interval-hours").long("consolidation-interval-hours").value_parser(value_parser!(u32)).help("Background consolidation sweep interval in hours (default: 24)")) - .arg(Arg::new("rate-limit-enabled").long("rate-limit-enabled").value_parser(value_parser!(bool)).help("Enable per-namespace store/recall rate limiting (default: false)")) - .arg(Arg::new("rate-limit-stores-per-minute").long("rate-limit-stores-per-minute").value_parser(value_parser!(u32)).help("Max store operations per minute (omit for unlimited)")) - .arg(Arg::new("rate-limit-recalls-per-minute").long("rate-limit-recalls-per-minute").value_parser(value_parser!(u32)).help("Max recall operations per minute (omit for unlimited)")), - ), - ) -} - -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"), - ), - ) -} - -fn build_index_command() -> Command { - Command::new("index") - .about("Manage indexes") - .subcommand( - Command::new("stats") - .about("Get index statistics for a namespace") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ), - ) - .subcommand( - Command::new("fulltext-stats") - .about("Get full-text index statistics") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ), - ) - .subcommand( - Command::new("rebuild") - .about("Rebuild index for a namespace") - .after_help("Examples:\n dk index rebuild -n my-ns --dry-run\n dk index rebuild -n my-ns --index-type vector --yes") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .required(true) - .help("Namespace name"), - ) - .arg( - Arg::new("index-type") - .short('t') - .long("index-type") - .default_value("all") - .help("Index type to rebuild (vector, fulltext, all)"), - ) - .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 rebuilt without making any changes"), - ), - ) -} - -fn build_ops_command() -> Command { - Command::new("ops") - .about("Operations and maintenance") - .subcommand(Command::new("diagnostics").about("Get system diagnostics")) - .subcommand(Command::new("jobs").about("List background jobs")) - .subcommand( - Command::new("job") - .about("Get specific job status") - .arg(Arg::new("id").required(true).help("Job ID")), - ) - .subcommand( - Command::new("compact") - .about("Trigger index compaction") - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Target namespace (optional, compacts all if not specified)"), - ) - .arg( - Arg::new("force") - .short('f') - .long("force") - .action(ArgAction::SetTrue) - .help("Force compaction even if not needed"), - ), - ) - .subcommand( - Command::new("shutdown") - .about("Gracefully shutdown the server") - .after_help("Examples:\n dk ops shutdown --dry-run\n dk ops shutdown --yes") - .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 happen without initiating the shutdown"), - ), - ) - .subcommand(Command::new("metrics").about("Show server metrics")) - .subcommand( - Command::new("stats") - .about("Show server statistics (version, state, vector count, uptime)"), - ) -} - -fn build_memory_command() -> Command { - Command::new("memory") - .about("Manage agent memories") - .subcommand( - Command::new("store") - .about("Store a memory for an agent") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("content") - .required(true) - .help("Memory content text"), - ) - .arg( - Arg::new("type") - .short('t') - .long("type") - .default_value("episodic") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("Memory type"), - ) - .arg( - Arg::new("importance") - .short('i') - .long("importance") - .default_value("0.5") - .value_parser(value_parser!(f32)) - .help("Importance score (0.0 to 1.0)"), - ) - .arg( - Arg::new("session-id") - .short('s') - .long("session-id") - .help("Session ID to associate with"), - ), - ) - .subcommand( - Command::new("recall") - .about("Recall memories by semantic query") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg(Arg::new("query").required(true).help("Search query")) - .arg( - Arg::new("top-k") - .short('k') - .long("top-k") - .default_value("5") - .value_parser(value_parser!(usize)) - .help("Number of results to return"), - ) - .arg( - Arg::new("type") - .short('t') - .long("type") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("Filter by memory type"), - ), - ) - .subcommand( - Command::new("get") - .about("Get a specific memory by ID") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg(Arg::new("memory_id").required(true).help("Memory ID")), - ) - .subcommand( - Command::new("update") - .about("Update an existing memory") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg(Arg::new("memory_id").required(true).help("Memory ID")) - .arg( - Arg::new("content") - .short('c') - .long("content") - .help("New content text"), - ) - .arg( - Arg::new("type") - .short('t') - .long("type") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("New memory type"), - ), - ) - .subcommand( - Command::new("forget") - .about("Delete a memory") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("memory_id") - .required(true) - .help("Memory ID to delete"), - ), - ) - .subcommand( - Command::new("search") - .about("Search memories with advanced filters") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg(Arg::new("query").required(true).help("Search query")) - .arg( - Arg::new("top-k") - .short('k') - .long("top-k") - .default_value("10") - .value_parser(value_parser!(usize)) - .help("Number of results to return"), - ) - .arg( - Arg::new("type") - .short('t') - .long("type") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("Filter by memory type"), - ), - ) - .subcommand( - Command::new("importance") - .about("Update importance score for memories") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("ids") - .long("ids") - .required(true) - .help("Comma-separated memory IDs"), - ) - .arg( - Arg::new("value") - .long("value") - .required(true) - .value_parser(value_parser!(f32)) - .help("New importance value (0.0 to 1.0)"), - ), - ) - .subcommand( - Command::new("consolidate") - .about("Consolidate similar memories") - .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("Filter by memory type"), - ) - .arg( - Arg::new("threshold") - .long("threshold") - .default_value("0.8") - .value_parser(value_parser!(f32)) - .help("Similarity threshold for consolidation"), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .action(ArgAction::SetTrue) - .help("Preview consolidation without applying changes"), - ), - ) - .subcommand( - Command::new("feedback") - .about("Submit feedback on a memory recall") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg(Arg::new("memory_id").required(true).help("Memory ID")) - .arg(Arg::new("feedback").required(true).help("Feedback text")) - .arg( - Arg::new("score") - .short('s') - .long("score") - .value_parser(value_parser!(f32)) - .help("Relevance score (0.0 to 1.0)"), - ), - ) -} - -fn build_session_command() -> Command { - Command::new("session") - .about("Manage agent sessions") - .subcommand( - Command::new("start") - .about("Start a new session for an agent") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("metadata") - .short('m') - .long("metadata") - .help("Session metadata as JSON string"), - ), - ) - .subcommand( - Command::new("end") - .about("End an active session") - .arg(Arg::new("session_id").required(true).help("Session ID")) - .arg( - Arg::new("summary") - .short('s') - .long("summary") - .help("Session summary text"), - ), - ) - .subcommand( - Command::new("get") - .about("Get session details") - .arg(Arg::new("session_id").required(true).help("Session ID")), - ) - .subcommand( - Command::new("list") - .about("List sessions") - .arg( - Arg::new("agent-id") - .short('a') - .long("agent-id") - .help("Filter by agent ID"), - ) - .arg( - Arg::new("active-only") - .long("active-only") - .action(ArgAction::SetTrue) - .help("Show only active sessions"), - ) - .arg( - Arg::new("limit") - .short('l') - .long("limit") - .default_value("50") - .value_parser(value_parser!(u32)) - .help("Maximum number of sessions to return"), - ), - ) - .subcommand( - Command::new("memories") - .about("Get memories for a session") - .arg(Arg::new("session_id").required(true).help("Session ID")), - ) -} - -fn build_agent_command() -> Command { - Command::new("agent") - .about("Manage agents") - .subcommand(Command::new("list").about("List all agents")) - .subcommand( - Command::new("memories") - .about("Get memories for an agent") - .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("Filter by memory type"), - ) - .arg( - Arg::new("limit") - .short('l') - .long("limit") - .default_value("50") - .value_parser(value_parser!(u32)) - .help("Maximum number of memories to return"), - ), - ) - .subcommand( - Command::new("stats") - .about("Get agent statistics") - .arg(Arg::new("agent_id").required(true).help("Agent ID")), - ) - .subcommand( - Command::new("sessions") - .about("Get sessions for an agent") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("active-only") - .long("active-only") - .action(ArgAction::SetTrue) - .help("Show only active sessions"), - ) - .arg( - Arg::new("limit") - .short('l') - .long("limit") - .default_value("50") - .value_parser(value_parser!(u32)) - .help("Maximum number of sessions to return"), - ), - ) -} - -fn build_knowledge_command() -> Command { - Command::new("knowledge") - .about("Knowledge graph operations") - .subcommand( - Command::new("graph") - .about("Build knowledge graph from a seed memory") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("memory-id") - .short('m') - .long("memory-id") - .help("Seed memory ID"), - ) - .arg( - Arg::new("depth") - .short('d') - .long("depth") - .value_parser(value_parser!(u32)) - .help("Graph traversal depth"), - ) - .arg( - Arg::new("min-similarity") - .short('s') - .long("min-similarity") - .value_parser(value_parser!(f32)) - .help("Minimum similarity threshold (0.0 to 1.0)"), - ), - ) - .subcommand( - Command::new("full-graph") - .about("Build full knowledge graph for an agent") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("max-nodes") - .long("max-nodes") - .value_parser(value_parser!(u32)) - .help("Maximum number of nodes"), - ) - .arg( - Arg::new("min-similarity") - .short('s') - .long("min-similarity") - .value_parser(value_parser!(f32)) - .help("Minimum similarity threshold (0.0 to 1.0)"), - ) - .arg( - Arg::new("cluster-threshold") - .long("cluster-threshold") - .value_parser(value_parser!(f32)) - .help("Cluster similarity threshold (0.0 to 1.0)"), - ) - .arg( - Arg::new("max-edges") - .long("max-edges") - .value_parser(value_parser!(u32)) - .help("Maximum edges per node"), - ), - ) - .subcommand( - Command::new("summarize") - .about("Summarize agent memories") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("memory-ids") - .long("memory-ids") - .help("Comma-separated memory IDs to summarize"), - ) - .arg( - Arg::new("target-type") - .short('t') - .long("target-type") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("Target memory type for the summary"), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .action(ArgAction::SetTrue) - .help("Preview summarization without applying changes"), - ), - ) - .subcommand( - Command::new("deduplicate") - .about("Find and remove duplicate memories") - .arg(Arg::new("agent_id").required(true).help("Agent ID")) - .arg( - Arg::new("threshold") - .long("threshold") - .value_parser(value_parser!(f32)) - .help("Similarity threshold for deduplication (0.0 to 1.0)"), - ) - .arg( - Arg::new("type") - .short('t') - .long("type") - .value_parser(["episodic", "semantic", "procedural", "working"]) - .help("Filter by memory type"), - ) - .arg( - Arg::new("dry-run") - .long("dry-run") - .action(ArgAction::SetTrue) - .help("Preview deduplication without applying changes"), - ), - ) -} - -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)"), - ), - ) -} - -fn build_keys_command() -> Command { - Command::new("keys") - .about("Manage API keys") - .subcommand( - Command::new("create") - .about("Create a new API key") - .arg( - Arg::new("name") - .required(true) - .help("Human-readable name for the key"), - ) - .arg( - Arg::new("permissions") - .short('p') - .long("permissions") - .help("Permission scope (e.g. read, write, admin)"), - ) - .arg( - Arg::new("expires") - .short('e') - .long("expires") - .value_parser(value_parser!(u64)) - .help("Expiration in days"), - ), - ) - .subcommand(Command::new("list").about("List all API keys")) - .subcommand( - Command::new("get") - .about("Get API key details") - .arg(Arg::new("key_id").required(true).help("API key ID")), - ) - .subcommand( - Command::new("delete") - .about("Delete (revoke) an API key") - .arg(Arg::new("key_id").required(true).help("API key ID")), - ) - .subcommand( - Command::new("deactivate") - .about("Deactivate an API key without deleting it") - .arg(Arg::new("key_id").required(true).help("API key ID")), - ) - .subcommand( - Command::new("rotate") - .about("Rotate an API key (generate new secret)") - .arg(Arg::new("key_id").required(true).help("API key ID")), - ) - .subcommand( - Command::new("usage") - .about("Get usage statistics for an API key") - .arg(Arg::new("key_id").required(true).help("API key ID")), - ) -} - -fn build_analytics_command() -> Command { - Command::new("analytics") - .about("View platform analytics and metrics") - .subcommand( - Command::new("overview") - .about("Analytics overview") - .arg( - Arg::new("period") - .short('p') - .long("period") - .default_value("24h") - .help("Time period (e.g. 1h, 24h, 7d)"), - ) - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Filter by namespace"), - ), - ) - .subcommand( - Command::new("latency") - .about("Latency statistics") - .arg( - Arg::new("period") - .short('p') - .long("period") - .default_value("24h") - .help("Time period (e.g. 1h, 24h, 7d)"), - ) - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Filter by namespace"), - ), - ) - .subcommand( - Command::new("throughput") - .about("Throughput statistics") - .arg( - Arg::new("period") - .short('p') - .long("period") - .default_value("24h") - .help("Time period (e.g. 1h, 24h, 7d)"), - ) - .arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Filter by namespace"), - ), - ) - .subcommand( - Command::new("storage").about("Storage statistics").arg( - Arg::new("namespace") - .short('n') - .long("namespace") - .help("Filter by namespace"), - ), - ) -} - #[tokio::main] async fn main() { - // Parse CLI arguments first so we can read --profile before loading config let matches = build_cli().get_matches(); - // Initialize logging if verbose - if matches.get_flag("verbose") { + let verbose = matches.get_flag("verbose"); + if verbose { tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new("info")) .with(tracing_subscriber::fmt::layer()) .init(); } - // Resolve output format early so we can use it in error reporting let format_str = matches.get_one::("format").unwrap(); let format = OutputFormat::from(format_str.as_str()); - if let Err(err) = run(matches, format).await { - // Classify the error and choose the appropriate exit code + if let Err(err) = run(matches, format, verbose).await { let cli_err = error::classify(&err); let exit_code = cli_err.exit_code(); @@ -1343,14 +80,12 @@ async fn main() { } } -async fn run(matches: clap::ArgMatches, format: OutputFormat) -> anyhow::Result<()> { - // Load configuration, honouring --profile / DAKERA_PROFILE if provided +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), None => Config::load(), }; - // Get URL from args or config let cli_url = matches.get_one::("url").unwrap(); let url = if cli_url != "http://localhost:3000" { cli_url.clone() @@ -1358,59 +93,32 @@ async fn run(matches: clap::ArgMatches, format: OutputFormat) -> anyhow::Result< config.server_url.clone() }; - // Execute command + let ctx = Context::new(url, format, verbose); + match matches.subcommand() { - Some(("init", _)) => { - init::execute().await?; - } + Some(("init", _)) => init::execute().await?, Some(("health", sub_matches)) => { let detailed = sub_matches.get_flag("detailed"); - health::execute(&url, detailed, format).await?; - } - Some(("namespace", sub_matches)) => { - namespace::execute(&url, sub_matches, format).await?; - } - Some(("vector", sub_matches)) => { - vector::execute(&url, sub_matches, format).await?; - } - Some(("index", sub_matches)) => { - index::execute(&url, sub_matches, format).await?; - } - Some(("ops", sub_matches)) => { - ops::execute(&url, sub_matches, format).await?; - } - Some(("memory", sub_matches)) => { - memory::execute(&url, sub_matches, format).await?; - } - Some(("session", sub_matches)) => { - session::execute(&url, sub_matches, format).await?; - } - Some(("agent", sub_matches)) => { - agent::execute(&url, sub_matches, format).await?; - } - Some(("knowledge", sub_matches)) => { - knowledge::execute(&url, sub_matches, format).await?; - } - Some(("analytics", sub_matches)) => { - analytics::execute(&url, sub_matches, format).await?; - } - Some(("admin", sub_matches)) => { - admin::execute(&url, sub_matches, format).await?; - } - Some(("keys", sub_matches)) => { - keys::execute(&url, sub_matches, format).await?; - } + 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?, + Some(("session", sub_matches)) => session::execute(&ctx, sub_matches).await?, + 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(); let install = sub_matches.get_flag("install"); completion::execute(shell, install)?; } - Some(("config", sub_matches)) => { - config_cmd::execute(sub_matches).await?; - } - _ => { - build_cli().print_help()?; - } + Some(("config", sub_matches)) => config_cmd::execute(sub_matches).await?, + _ => build_cli().print_help()?, } Ok(()) diff --git a/src/output.rs b/src/output.rs index a29ee8e..7ed2508 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,34 +1,96 @@ //! Output formatting utilities for CLI -use nu_ansi_term::{Color, Style}; +use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table}; +use nu_ansi_term::{Color as AnsiColor, Style}; use serde::Serialize; use crate::OutputFormat; /// Print success message pub fn success(msg: &str) { - let style = Style::new().fg(Color::Green).bold(); + let style = Style::new().fg(AnsiColor::Green).bold(); println!("{} {}", style.paint("✓"), msg); } /// Print error message pub fn error(msg: &str) { - let style = Style::new().fg(Color::Red).bold(); + let style = Style::new().fg(AnsiColor::Red).bold(); eprintln!("{} {}", style.paint("✗"), msg); } /// Print warning message pub fn warning(msg: &str) { - let style = Style::new().fg(Color::Yellow).bold(); + let style = Style::new().fg(AnsiColor::Yellow).bold(); println!("{} {}", style.paint("⚠"), msg); } /// Print info message pub fn info(msg: &str) { - let style = Style::new().fg(Color::Blue).bold(); + let style = Style::new().fg(AnsiColor::Blue).bold(); println!("{} {}", style.paint("ℹ"), msg); } +/// Format a JSON value as a human-readable table cell string. +fn cell_value(val: &serde_json::Value) -> String { + match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => "-".to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} + +/// Render a slice of objects as an aligned table using `comfy-table`. +/// +/// Keys from the first item become column headers. Works for any `Serialize` +/// type that produces a JSON object at the top level. +fn render_table(data: &[T]) { + if data.is_empty() { + println!("No data"); + return; + } + + let values: Vec = data + .iter() + .filter_map(|item| serde_json::to_value(item).ok()) + .collect(); + + let Some(serde_json::Value::Object(first)) = values.first() else { + // Not an object type — fall back to JSON pretty print + println!("{}", serde_json::to_string_pretty(data).unwrap_or_default()); + return; + }; + + let headers: Vec = first.keys().cloned().collect(); + + let mut table = Table::new(); + table + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header( + headers + .iter() + .map(|h| { + Cell::new(h.to_uppercase()) + .add_attribute(Attribute::Bold) + .fg(Color::Cyan) + }) + .collect::>(), + ); + + for val in &values { + if let serde_json::Value::Object(obj) = val { + let row: Vec = headers + .iter() + .map(|h| cell_value(obj.get(h).unwrap_or(&serde_json::Value::Null))) + .collect(); + table.add_row(row); + } + } + + println!("{table}"); +} + /// Format and print data based on output format pub fn print_data(data: &[T], format: OutputFormat) { match format { @@ -39,12 +101,7 @@ pub fn print_data(data: &[T], format: OutputFormat) { println!("{}", serde_json::to_string(data).unwrap_or_default()); } OutputFormat::Table => { - if data.is_empty() { - println!("No data"); - } else { - // Simple table-like output using JSON pretty print - println!("{}", serde_json::to_string_pretty(data).unwrap_or_default()); - } + render_table(data); } } } @@ -59,7 +116,8 @@ pub fn print_item(item: &T, format: OutputFormat) { println!("{}", serde_json::to_string(item).unwrap_or_default()); } OutputFormat::Table => { - println!("{}", serde_json::to_string_pretty(item).unwrap_or_default()); + // Wrap single item in a slice so render_table handles it uniformly + render_table(std::slice::from_ref(item)); } } } @@ -77,7 +135,7 @@ pub fn print_kv(pairs: &[(&str, String)], format: OutputFormat) { } } OutputFormat::Table => { - let key_style = Style::new().fg(Color::Cyan).bold(); + let key_style = Style::new().fg(AnsiColor::Cyan).bold(); for (key, value) in pairs { println!("{}: {}", key_style.paint(*key), value); } @@ -192,4 +250,31 @@ mod tests { let parsed: serde_json::Value = serde_json::from_str(&s).unwrap(); assert!(parsed.is_array()); } + + #[test] + fn cell_value_formats_types_correctly() { + assert_eq!(cell_value(&serde_json::Value::Null), "-"); + assert_eq!( + cell_value(&serde_json::Value::String("hello".into())), + "hello" + ); + assert_eq!(cell_value(&serde_json::Value::Bool(true)), "true"); + assert_eq!(cell_value(&serde_json::json!(42)), "42"); + } + + #[test] + fn render_table_multiple_rows_no_panic() { + let data = vec![ + TestRow { + id: 1, + name: "alpha".into(), + }, + TestRow { + id: 2, + name: "beta".into(), + }, + ]; + // Should not panic; visual output verified manually + render_table(&data); + } } diff --git a/src/retry.rs b/src/retry.rs new file mode 100644 index 0000000..7c3e576 --- /dev/null +++ b/src/retry.rs @@ -0,0 +1,166 @@ +//! Exponential-backoff retry helper for transient network errors. +//! +//! Retries up to 3 times with delays of 100ms → 500ms → 2s. +//! Errors classified as 4xx (client errors) are never retried. + +use std::time::Duration; + +use tokio::time::sleep; + +/// Retry delays: 100ms, 500ms, 2s. +const DELAYS: &[Duration] = &[ + Duration::from_millis(100), + Duration::from_millis(500), + Duration::from_millis(2_000), +]; + +/// Run `f` up to 4 times (1 initial attempt + 3 retries). +/// +/// Client errors (4xx) are returned immediately without retry. +/// All other errors trigger a backoff sleep before the next attempt. +pub async fn with_backoff(mut f: F) -> anyhow::Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut attempt = 0; + loop { + match f().await { + Ok(v) => return Ok(v), + Err(e) => { + if is_non_retryable(&e) { + return Err(e); + } + if attempt >= DELAYS.len() { + return Err(e); + } + let delay = DELAYS[attempt]; + tracing::warn!( + "Request failed (attempt {}), retrying in {:?}: {}", + attempt + 1, + delay, + e + ); + sleep(delay).await; + attempt += 1; + } + } + } +} + +/// Returns true for errors that should NOT be retried (client errors, invalid input). +fn is_non_retryable(err: &anyhow::Error) -> bool { + let msg = err.to_string().to_lowercase(); + // 4xx HTTP status codes — retrying won't help + msg.contains(" 400 ") || msg.contains(" 401 ") || msg.contains(" 403 ") + || msg.contains(" 404 ") || msg.contains(" 409 ") || msg.contains(" 422 ") + || msg.contains("bad request") || msg.contains("unauthorized") + || msg.contains("forbidden") || msg.contains("not found") + || msg.contains("unprocessable") || msg.contains("conflict") + // Local input errors — also not retryable + || msg.contains("failed to parse") || msg.contains("invalid input") + || msg.contains("failed to read file") +} + +#[cfg(test)] +mod tests { + use std::sync::{ + atomic::{AtomicU32, Ordering}, + Arc, + }; + + use super::*; + + #[tokio::test] + async fn succeeds_on_first_try() { + let count = Arc::new(AtomicU32::new(0)); + let c = count.clone(); + let result: anyhow::Result = with_backoff(|| { + let c = c.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Ok(42) + } + }) + .await; + assert_eq!(result.unwrap(), 42); + assert_eq!(count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn retries_on_connection_error() { + let count = Arc::new(AtomicU32::new(0)); + let c = count.clone(); + let result: anyhow::Result = with_backoff(|| { + let c = c.clone(); + async move { + let n = c.fetch_add(1, Ordering::SeqCst) + 1; + if n < 3 { + Err(anyhow::anyhow!("connection refused")) + } else { + Ok(n) + } + } + }) + .await; + assert!(result.is_ok()); + assert_eq!(count.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn does_not_retry_404() { + let count = Arc::new(AtomicU32::new(0)); + let c = count.clone(); + let result: anyhow::Result = with_backoff(|| { + let c = c.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Err(anyhow::anyhow!("namespace not found 404")) + } + }) + .await; + assert!(result.is_err()); + assert_eq!(count.load(Ordering::SeqCst), 1, "404 should not be retried"); + } + + #[tokio::test] + async fn does_not_retry_401() { + let count = Arc::new(AtomicU32::new(0)); + let c = count.clone(); + let _result: anyhow::Result = with_backoff(|| { + let c = c.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Err(anyhow::anyhow!("401 Unauthorized")) + } + }) + .await; + assert_eq!(count.load(Ordering::SeqCst), 1, "401 should not be retried"); + } + + #[tokio::test] + async fn exhausts_retries_and_returns_error() { + let count = Arc::new(AtomicU32::new(0)); + let c = count.clone(); + let result: anyhow::Result = with_backoff(|| { + let c = c.clone(); + async move { + c.fetch_add(1, Ordering::SeqCst); + Err(anyhow::anyhow!("tcp connect error")) + } + }) + .await; + assert!(result.is_err()); + // 1 initial + 3 retries = 4 total attempts + assert_eq!(count.load(Ordering::SeqCst), 4); + } + + #[test] + fn non_retryable_detects_4xx() { + assert!(is_non_retryable(&anyhow::anyhow!("error 404 not found"))); + assert!(is_non_retryable(&anyhow::anyhow!("HTTP 401 Unauthorized"))); + assert!(is_non_retryable(&anyhow::anyhow!("bad request"))); + assert!(!is_non_retryable(&anyhow::anyhow!("connection refused"))); + assert!(!is_non_retryable(&anyhow::anyhow!("tcp connect error"))); + } +}