Skip to content
Open
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
7 changes: 7 additions & 0 deletions crates/ov_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,18 @@ Run `ov --help` and `ov <command> --help` for the exact command surface of your
- `tree` - Show a hierarchical tree.
- `mkdir` - Create a directory.
- `rm` - Remove a resource or directory.
- `cp` - Copy a file, or copy a directory recursively with `-r`.
- `mv` - Move or rename a resource.
- `stat` - Show resource metadata.
- `attrs` - Get logical extended attributes.
- `get` - Download a file to a local path.

```bash
# The destination parent must exist and the destination itself must not exist.
ov cp viking://resources/docs/guide.md viking://resources/archive/guide-copy.md
ov cp -r viking://resources/docs viking://resources/docs-backup
```

### Content Access

- `read` - Read L2 full content.
Expand Down
7 changes: 7 additions & 0 deletions crates/ov_cli/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,18 @@ ov grep "openviking" --uri viking://resources
- `tree` - 显示目录树。
- `mkdir` - 创建目录。
- `rm` - 删除资源或目录。
- `cp` - 复制文件,或使用 `-r` 递归复制目录。
- `mv` - 移动或重命名资源。
- `stat` - 查看资源元数据。
- `attrs` - 获取逻辑扩展属性。
- `get` - 下载文件到本地路径。

```bash
# 目标父目录必须已存在,目标本身必须不存在。
ov cp viking://resources/docs/guide.md viking://resources/archive/guide-copy.md
ov cp -r viking://resources/docs viking://resources/docs-backup
```

### 内容访问

- `read` - 读取 L2 全量内容。
Expand Down
35 changes: 35 additions & 0 deletions crates/ov_cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,20 @@ impl HttpClient {
self.post("/api/v1/fs/mv", &body).await
}

pub async fn cp(
&self,
from_uri: &str,
to_uri: &str,
recursive: bool,
) -> Result<serde_json::Value> {
let body = serde_json::json!({
"from_uri": from_uri,
"to_uri": to_uri,
"recursive": recursive,
});
self.post("/api/v1/fs/cp", &body).await
}

pub async fn stat(&self, uri: &str) -> Result<serde_json::Value> {
let params = vec![("uri".to_string(), uri.to_string())];
self.get("/api/v1/fs/stat", &params).await
Expand Down Expand Up @@ -2268,6 +2282,27 @@ mod tests {
assert!(!request.contains("include_mod_time_iso="));
}

#[tokio::test]
async fn cp_posts_recursive_request_body() {
let (base_url, request_rx) = spawn_request_capture_server().await;
let client = HttpClient::new(base_url, None, None, None, None, 5.0, false, None);

client
.cp(
"viking://resources/source",
"viking://resources/target",
true,
)
.await
.expect("cp request should succeed");

let request = request_rx.await.expect("request should be captured");
assert!(request.starts_with("POST /api/v1/fs/cp "));
assert!(request.contains(r#""from_uri":"viking://resources/source""#));
assert!(request.contains(r#""to_uri":"viking://resources/target""#));
assert!(request.contains(r#""recursive":true"#));
}

#[tokio::test]
async fn gateway_token_is_not_sent_without_a_gateway_challenge() {
let (base_url, request_rx) = spawn_request_capture_server().await;
Expand Down
18 changes: 18 additions & 0 deletions crates/ov_cli/src/commands/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,24 @@ pub async fn mv(
Ok(())
}

pub async fn cp(
client: &HttpClient,
from_uri: &str,
to_uri: &str,
recursive: bool,
output_format: OutputFormat,
compact: bool,
) -> Result<()> {
let result = client.cp(from_uri, to_uri, recursive).await?;
output_message_result(
result,
format!("Copied: {} -> {}", from_uri, to_uri),
output_format,
compact,
);
Ok(())
}

pub async fn stat(
client: &HttpClient,
uri: &str,
Expand Down
18 changes: 18 additions & 0 deletions crates/ov_cli/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,24 @@ pub async fn handle_mv(from_uri: String, to_uri: String, ctx: CliContext) -> Res
commands::filesystem::mv(&client, &from_uri, &to_uri, ctx.output_format, ctx.compact).await
}

pub async fn handle_cp(
from_uri: String,
to_uri: String,
recursive: bool,
ctx: CliContext,
) -> Result<()> {
let client = ctx.get_client();
commands::filesystem::cp(
&client,
&from_uri,
&to_uri,
recursive,
ctx.output_format,
ctx.compact,
)
.await
}

pub async fn handle_stat(uri: String, ctx: CliContext) -> Result<()> {
let client = ctx.get_client();
commands::filesystem::stat(&client, &uri, ctx.output_format, ctx.compact).await
Expand Down
33 changes: 32 additions & 1 deletion crates/ov_cli/src/help_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const CORE_WORKFLOW: &[HelpCommand] = help_commands![
];

const FILESYSTEM: &[HelpCommand] = help_commands![
"ls", "tree", "mkdir", "rm", "mv", "stat", "attrs", "acl", "get"
"ls", "tree", "mkdir", "rm", "cp", "mv", "stat", "attrs", "acl", "get"
];

const SEARCH_CONTEXT: &[HelpCommand] = help_commands![
Expand Down Expand Up @@ -290,6 +290,30 @@ const COMMAND_HELP_SPECS: &[CommandHelpSpec] = &[
},
],
},
CommandHelpSpec {
path: &["cp"],
purpose: "Copy a file or directory without reparsing or regenerating vectors.",
examples: &[
HelpItem {
label: "ov cp viking://resources/notes/draft.md viking://resources/notes/draft-copy.md",
description: "Copy one file and its vector records.",
},
HelpItem {
label: "ov cp -r viking://resources/projects/source viking://resources/projects/backup",
description: "Recursively copy a directory and all vector records.",
},
],
next_steps: &[
HelpItem {
label: "ov stat <target-uri>",
description: "Confirm the copied resource metadata.",
},
HelpItem {
label: "ov read <target-uri>",
description: "Read the copied resource.",
},
],
},
CommandHelpSpec {
path: &["mv"],
purpose: "Move or rename a resource.",
Expand Down Expand Up @@ -2734,6 +2758,13 @@ mod tests {
assert!(rendered.contains("experimental"));
assert!(rendered.contains("ov <command> --help"));
assert!(!rendered.contains("Commands:\n add-resource"));
assert!(rendered.contains("cp"));
let cp = command_spec(&["cp".to_string()]).expect("cp command help");
assert!(
cp.examples
.iter()
.any(|item| item.label.contains("ov cp -r"))
);
}

#[test]
Expand Down
53 changes: 53 additions & 0 deletions crates/ov_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,18 @@ enum Commands {
)]
timeout: Option<f64>,
},
/// [Data] Copy a file or directory
Cp {
/// Source URI
#[arg(value_name = "source")]
from_uri: String,
/// Target URI
#[arg(value_name = "target")]
to_uri: String,
/// Copy a directory recursively
#[arg(short, long, help_heading = "Common options")]
recursive: bool,
},
/// [Data] Move or rename resource
#[command(alias = "rename")]
Mv {
Expand Down Expand Up @@ -3503,6 +3515,11 @@ async fn main() {
wait,
timeout,
} => handlers::handle_rm(uri, recursive, wait, timeout, ctx).await,
Commands::Cp {
from_uri,
to_uri,
recursive,
} => handlers::handle_cp(from_uri, to_uri, recursive, ctx).await,
Commands::Mv { from_uri, to_uri } => handlers::handle_mv(from_uri, to_uri, ctx).await,
Commands::Stat { uri } => handlers::handle_stat(uri, ctx).await,
Commands::Attrs { action } => match action {
Expand Down Expand Up @@ -3793,6 +3810,42 @@ mod tests {
assert_eq!(cli.actor_peer_id.as_deref(), Some("peer-a"));
}

#[test]
fn cli_parses_copy_recursive_flag() {
let file = Cli::try_parse_from([
"ov",
"cp",
"viking://resources/a.md",
"viking://resources/b.md",
])
.expect("file copy should parse");
match file.command {
Commands::Cp {
from_uri,
to_uri,
recursive,
} => {
assert_eq!(from_uri, "viking://resources/a.md");
assert_eq!(to_uri, "viking://resources/b.md");
assert!(!recursive);
}
_ => panic!("expected cp command"),
}

let directory = Cli::try_parse_from([
"ov",
"cp",
"-r",
"viking://resources/src",
"viking://resources/dst",
])
.expect("recursive directory copy should parse");
match directory.command {
Commands::Cp { recursive, .. } => assert!(recursive),
_ => panic!("expected cp command"),
}
}

#[test]
fn cli_parses_snapshot_diff_refs() {
let cli = Cli::try_parse_from([
Expand Down
1 change: 1 addition & 0 deletions docs/en/api/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ This catalog follows the routes actually mounted by the server. Each group headi
| POST | `/api/v1/fs/attrs/set_tags` | Set retrieval tags (compatibility alias) |
| POST | `/api/v1/fs/mkdir` | Create a directory |
| DELETE | `/api/v1/fs` | Delete a resource |
| POST | `/api/v1/fs/cp` | Copy a file or directory together with its vector records |
| POST | `/api/v1/fs/mv` | Move or rename a resource |

### [ACL](12-acl.md)
Expand Down
84 changes: 84 additions & 0 deletions docs/en/api/03-filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,90 @@ When deleting `viking://resources/...`, the response may include `memory_cleanup

---

### cp()

Copy a file or directory to a new Viking URI. The source remains unchanged. Existing vector records under the source URI are copied and rewritten for the destination, so the copied content does not need to be parsed, described by a VLM, or embedded again.

The destination parent directory must already exist, and the destination itself must not exist. Copying a directory requires `recursive=true` (or `-r` in the CLI). The destination cannot equal the source or be inside the source directory tree.

**Parameters**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| from_uri | str | Yes | - | Source Viking URI |
| to_uri | str | Yes | - | Destination Viking URI, including the new file or directory name |
| recursive | bool | No | False | Required when the source is a directory |

**HTTP API**

```
POST /api/v1/fs/cp
```

```bash
# Copy one file
curl -X POST http://localhost:1933/api/v1/fs/cp \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{
"from_uri": "viking://resources/docs/guide.md",
"to_uri": "viking://resources/archive/guide-copy.md",
"recursive": false
}'

# Copy a directory recursively
curl -X POST http://localhost:1933/api/v1/fs/cp \
-H "Content-Type: application/json" \
-H "X-API-Key: your-key" \
-d '{
"from_uri": "viking://resources/docs",
"to_uri": "viking://resources/docs-backup",
"recursive": true
}'
```

**CLI**

```bash
# Copy one file
ov cp viking://resources/docs/guide.md viking://resources/archive/guide-copy.md

# Copy a directory recursively
ov cp -r viking://resources/docs viking://resources/docs-backup
```

**Response**

```json
{
"status": "ok",
"result": {
"operation_id": "61ec2a80bf5f46a28aa3497fbdcb56dd",
"operation": "copy",
"from": "viking://resources/docs/guide.md",
"to": "viking://resources/archive/guide-copy.md",
"recursive": false,
"phase": "completed",
"files_created": 1,
"vectors": {
"scanned": 3,
"written": 3,
"deleted": 0,
"restored": 0,
"batches": 1
},
"semantic_root_uri": "viking://resources/archive",
"semantic_status": "queued"
}
}
```

`semantic_status: "queued"` means the copy has already committed and the destination parent's overview and abstract will be rebuilt asynchronously from summaries available at the destination. The API does not wait for that refresh. A refresh enqueue failure may return `semantic_status: "failed"` and `semantic_error`; it does not roll back the completed file and vector copy.

Common errors include `NOT_FOUND` when the source or destination parent is missing, `CONFLICT` when the destination already exists or a path lock is busy, `FAILED_PRECONDITION` when a directory is copied without `recursive=true`, and `INVALID_ARGUMENT` for invalid source/destination relationships.

---

### mv()

Move file or directory.
Expand Down
1 change: 1 addition & 0 deletions docs/zh/api/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ JSON 输出 - 错误:
| POST | `/api/v1/fs/attrs/set_tags` | 设置检索标签(兼容别名) |
| POST | `/api/v1/fs/mkdir` | 创建目录 |
| DELETE | `/api/v1/fs` | 删除资源 |
| POST | `/api/v1/fs/cp` | 复制文件或目录及其向量记录 |
| POST | `/api/v1/fs/mv` | 移动或重命名资源 |

### [ACL](12-acl.md)
Expand Down
Loading
Loading