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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/ov_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ Create `~/.openviking/ovcli.conf`:
```json
{
"url": "http://localhost:1933",
"api_key": "your-api-key"
"api_key": "your-api-key",
"account": "acme",
"user": "alice",
"agent_id": "assistant-1"
}
```

`account` and `user` are optional with a regular user key because the server can derive them from the key. They are recommended when you use `trusted` auth mode or a root key against tenant-scoped APIs.

## Quick Start

```bash
Expand Down Expand Up @@ -126,6 +131,9 @@ ov find "API authentication" --threshold 0.7 --limit 5
# Recursive list
ov ls viking://resources --recursive

# Temporarily override identity from CLI flags
ov --account acme --user alice --agent-id assistant-2 ls viking://

# Glob search
ov glob "**/*.md" --uri viking://resources

Expand Down
165 changes: 128 additions & 37 deletions crates/ov_cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use serde_json::Value;
use std::fs::File;
use std::path::Path;
use tempfile::NamedTempFile;
use zip::write::FileOptions;
use zip::CompressionMethod;
use zip::write::FileOptions;

use crate::error::{Error, Result};

Expand All @@ -15,6 +15,8 @@ pub struct HttpClient {
http: ReqwestClient,
base_url: String,
api_key: Option<String>,
account: Option<String>,
user: Option<String>,
agent_id: Option<String>,
}

Expand All @@ -24,6 +26,8 @@ impl HttpClient {
base_url: impl Into<String>,
api_key: Option<String>,
agent_id: Option<String>,
account: Option<String>,
user: Option<String>,
timeout_secs: f64,
) -> Self {
let http = ReqwestClient::builder()
Expand All @@ -35,6 +39,8 @@ impl HttpClient {
http,
base_url: base_url.into().trim_end_matches('/').to_string(),
api_key,
account,
user,
agent_id,
}
}
Expand All @@ -51,7 +57,8 @@ impl HttpClient {
let temp_file = NamedTempFile::new()?;
let file = File::create(temp_file.path())?;
let mut zip = zip::ZipWriter::new(file);
let options: FileOptions<'_, ()> = FileOptions::default().compression_method(CompressionMethod::Deflated);
let options: FileOptions<'_, ()> =
FileOptions::default().compression_method(CompressionMethod::Deflated);

let walkdir = walkdir::WalkDir::new(dir_path);
for entry in walkdir.into_iter().filter_map(|e| e.ok()) {
Expand All @@ -78,14 +85,13 @@ impl HttpClient {

// Read file content
let file_content = tokio::fs::read(file_path).await?;

// Create multipart form
let part = reqwest::multipart::Part::bytes(file_content)
.file_name(file_name.to_string());

let part = part.mime_str("application/octet-stream").map_err(|e| {
Error::Network(format!("Failed to set mime type: {}", e))
})?;
let part = reqwest::multipart::Part::bytes(file_content).file_name(file_name.to_string());

let part = part
.mime_str("application/octet-stream")
.map_err(|e| Error::Network(format!("Failed to set mime type: {}", e)))?;

let form = reqwest::multipart::Form::new().part("file", part);

Expand Down Expand Up @@ -126,6 +132,16 @@ impl HttpClient {
headers.insert("X-OpenViking-Agent", value);
}
}
if let Some(account) = &self.account {
if let Ok(value) = reqwest::header::HeaderValue::from_str(account) {
headers.insert("X-OpenViking-Account", value);
}
}
if let Some(user) = &self.user {
if let Ok(value) = reqwest::header::HeaderValue::from_str(user) {
headers.insert("X-OpenViking-User", value);
}
}
headers
}

Expand Down Expand Up @@ -224,10 +240,7 @@ impl HttpClient {
self.handle_response(response).await
}

async fn handle_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> Result<T> {
async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
let status = response.status();

// Handle empty response (204 No Content, etc.)
Expand All @@ -248,7 +261,11 @@ impl HttpClient {
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.or_else(|| json.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string()))
.or_else(|| {
json.get("detail")
.and_then(|d| d.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| format!("HTTP error {}", status));
return Err(Error::Api(error_msg));
}
Expand Down Expand Up @@ -296,7 +313,12 @@ impl HttpClient {
self.get("/api/v1/content/overview", &params).await
}

pub async fn reindex(&self, uri: &str, regenerate: bool, wait: bool) -> Result<serde_json::Value> {
pub async fn reindex(
&self,
uri: &str,
regenerate: bool,
wait: bool,
) -> Result<serde_json::Value> {
let body = serde_json::json!({
"uri": uri,
"regenerate": regenerate,
Expand All @@ -309,7 +331,7 @@ impl HttpClient {
pub async fn get_bytes(&self, uri: &str) -> Result<Vec<u8>> {
let url = format!("{}/api/v1/content/download", self.base_url);
let params = vec![("uri".to_string(), uri.to_string())];

let response = self
.http
.get(&url)
Expand All @@ -326,20 +348,22 @@ impl HttpClient {
.json()
.await
.map_err(|e| Error::Network(format!("Failed to parse error response: {}", e)));

let error_msg = match json_result {
Ok(json) => {
json
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.or_else(|| json.get("detail").and_then(|d| d.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| format!("HTTP error {}", status))
}
Ok(json) => json
.get("error")
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.or_else(|| {
json.get("detail")
.and_then(|d| d.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| format!("HTTP error {}", status)),
Err(_) => format!("HTTP error {}", status),
};

return Err(Error::Api(error_msg));
}

Expand All @@ -352,7 +376,16 @@ impl HttpClient {

// ============ Filesystem Methods ============

pub async fn ls(&self, uri: &str, simple: bool, recursive: bool, output: &str, abs_limit: i32, show_all_hidden: bool, node_limit: i32) -> Result<serde_json::Value> {
pub async fn ls(
&self,
uri: &str,
simple: bool,
recursive: bool,
output: &str,
abs_limit: i32,
show_all_hidden: bool,
node_limit: i32,
) -> Result<serde_json::Value> {
let params = vec![
("uri".to_string(), uri.to_string()),
("simple".to_string(), simple.to_string()),
Expand All @@ -365,7 +398,15 @@ impl HttpClient {
self.get("/api/v1/fs/ls", &params).await
}

pub async fn tree(&self, uri: &str, output: &str, abs_limit: i32, show_all_hidden: bool, node_limit: i32, level_limit: i32) -> Result<serde_json::Value> {
pub async fn tree(
&self,
uri: &str,
output: &str,
abs_limit: i32,
show_all_hidden: bool,
node_limit: i32,
level_limit: i32,
) -> Result<serde_json::Value> {
let params = vec![
("uri".to_string(), uri.to_string()),
("output".to_string(), output.to_string()),
Expand Down Expand Up @@ -442,7 +483,13 @@ impl HttpClient {
self.post("/api/v1/search/search", &body).await
}

pub async fn grep(&self, uri: &str, pattern: &str, ignore_case: bool, node_limit: i32) -> Result<serde_json::Value> {
pub async fn grep(
&self,
uri: &str,
pattern: &str,
ignore_case: bool,
node_limit: i32,
) -> Result<serde_json::Value> {
let body = serde_json::json!({
"uri": uri,
"pattern": pattern,
Expand All @@ -452,8 +499,12 @@ impl HttpClient {
self.post("/api/v1/search/grep", &body).await
}


pub async fn glob(&self, pattern: &str, uri: &str, node_limit: i32) -> Result<serde_json::Value> {
pub async fn glob(
&self,
pattern: &str,
uri: &str,
node_limit: i32,
) -> Result<serde_json::Value> {
let body = serde_json::json!({
"pattern": pattern,
"uri": uri,
Expand Down Expand Up @@ -726,11 +777,7 @@ impl HttpClient {
self.put(&path, &body).await
}

pub async fn admin_regenerate_key(
&self,
account_id: &str,
user_id: &str,
) -> Result<Value> {
pub async fn admin_regenerate_key(&self, account_id: &str, user_id: &str) -> Result<Value> {
let path = format!(
"/api/v1/admin/accounts/{}/users/{}/key",
account_id, user_id
Expand Down Expand Up @@ -790,3 +837,47 @@ impl HttpClient {
Ok(count)
}
}

#[cfg(test)]
mod tests {
use super::HttpClient;

#[test]
fn build_headers_includes_tenant_identity_headers() {
let client = HttpClient::new(
"http://localhost:1933",
Some("test-key".to_string()),
Some("assistant-1".to_string()),
Some("acme".to_string()),
Some("alice".to_string()),
5.0,
);

let headers = client.build_headers();

assert_eq!(
headers
.get("X-API-Key")
.and_then(|value| value.to_str().ok()),
Some("test-key")
);
assert_eq!(
headers
.get("X-OpenViking-Agent")
.and_then(|value| value.to_str().ok()),
Some("assistant-1")
);
assert_eq!(
headers
.get("X-OpenViking-Account")
.and_then(|value| value.to_str().ok()),
Some("acme")
);
assert_eq!(
headers
.get("X-OpenViking-User")
.and_then(|value| value.to_str().ok()),
Some("alice")
);
}
}
27 changes: 27 additions & 0 deletions crates/ov_cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub struct Config {
#[serde(default = "default_url")]
pub url: String,
pub api_key: Option<String>,
pub account: Option<String>,
pub user: Option<String>,
pub agent_id: Option<String>,
#[serde(default = "default_timeout")]
pub timeout: f64,
Expand Down Expand Up @@ -40,6 +42,8 @@ impl Default for Config {
Self {
url: "http://localhost:1933".to_string(),
api_key: None,
account: None,
user: None,
agent_id: None,
timeout: 60.0,
output: "table".to_string(),
Expand Down Expand Up @@ -108,3 +112,26 @@ pub fn get_or_create_machine_id() -> Result<String> {
Err(_) => Ok("default".to_string()),
}
}

#[cfg(test)]
mod tests {
use super::Config;

#[test]
fn config_deserializes_account_and_user_fields() {
let config: Config = serde_json::from_str(
r#"{
"url": "http://localhost:1933",
"api_key": "test-key",
"account": "acme",
"user": "alice",
"agent_id": "assistant-1"
}"#,
)
.expect("config should deserialize");

assert_eq!(config.account.as_deref(), Some("acme"));
assert_eq!(config.user.as_deref(), Some("alice"));
assert_eq!(config.agent_id.as_deref(), Some("assistant-1"));
}
}
Loading
Loading