An encrypted personal cloud built on the AT Protocol.

Add metadata CLI command for rename, tag, and description management

Wire up opake metadata {show,rename,describe,tag} subcommands using
the existing update_document_metadata closure API. Supports direct
and keyring-encrypted documents.

+338 -4
+55
AGENT-BLACKBOX-TEST.md
··· 370 370 371 371 --- 372 372 373 + ## 5b. Metadata Management 374 + 375 + ### 5b.1 Show metadata 376 + 377 + ```bash 378 + A$ opake metadata show $FILENAME 379 + # prints: Name, MIME type, Size, Tags, Description 380 + ``` 381 + 382 + ### 5b.2 Rename 383 + 384 + ```bash 385 + A$ opake metadata rename $FILENAME new-name.txt 386 + # prints: Renamed to: new-name.txt 387 + A$ opake ls 388 + # verify: old name gone, new-name.txt present 389 + ``` 390 + 391 + ### 5b.3 Add and remove tags 392 + 393 + ```bash 394 + A$ opake metadata tag add new-name.txt finance 395 + # prints: Tags: finance 396 + A$ opake metadata tag add new-name.txt 2025 397 + # prints: Tags: finance, 2025 398 + A$ opake metadata tag remove new-name.txt finance 399 + # prints: Tags: 2025 400 + A$ opake metadata show new-name.txt 401 + # verify: Tags line shows "2025" only 402 + ``` 403 + 404 + ### 5b.4 Set and clear description 405 + 406 + ```bash 407 + A$ opake metadata describe new-name.txt "Annual tax return" 408 + # prints: Description updated. 409 + A$ opake metadata show new-name.txt 410 + # verify: Description: Annual tax return 411 + A$ opake metadata describe new-name.txt --clear 412 + # prints: Description cleared. 413 + A$ opake metadata show new-name.txt 414 + # verify: no Description line 415 + ``` 416 + 417 + ### 5b.5 Duplicate tag is idempotent 418 + 419 + ```bash 420 + A$ opake metadata tag add new-name.txt 2025 421 + A$ opake metadata tag add new-name.txt 2025 422 + A$ opake metadata show new-name.txt 423 + # verify: Tags shows "2025" once, not twice 424 + ``` 425 + 426 + --- 427 + 373 428 ## 6. Keyrings 374 429 375 430 ### 6.1 Create a keyring
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Add metadata CLI command for rename, tag, and description management [#190](https://issues.opake.app/issues/190.html) 15 16 - Consolidate DNS and transport into opake-core, unify handle resolution [#185](https://issues.opake.app/issues/185.html) 16 17 - Build web login, callback, setup, and recover routes [#173](https://issues.opake.app/issues/173.html) 17 18 - Add device-to-device key pairing via PDS [#183](https://issues.opake.app/issues/183.html)
+11 -4
README.md
··· 98 98 opake rm Photos/photo.jpg 99 99 opake rm -r Photos 100 100 101 - # move and rename 102 - opake mv photo.jpg Photos/ 103 - opake mv photo.jpg vacation-photo.jpg 101 + # move a file to another directory 102 + opake move photo.jpg Photos/ 103 + 104 + # view or edit document metadata 105 + opake metadata show photo.jpg 106 + opake metadata rename photo.jpg vacation-photo.jpg 107 + opake metadata tag add photo.jpg travel 108 + opake metadata tag remove photo.jpg travel 109 + opake metadata describe photo.jpg "Beach sunset from last summer" 110 + opake metadata describe photo.jpg --clear 104 111 105 112 # resolve a handle or DID to see their public key 106 113 opake resolve alice.example.com ··· 138 145 139 146 Commands accept a filename, a path (`Photos/beach.jpg`), or an `at://` URI. If a filename matches multiple documents, you'll be prompted to use the full URI. 140 147 141 - The `--as` flag works with document commands (`upload`, `download`, `ls`, `rm`, `mv`, `cat`, `tree`, `share`, `shared`, `revoke`) and accepts a handle or DID. 148 + The `--as` flag works with document commands (`upload`, `download`, `ls`, `rm`, `move`, `cat`, `tree`, `metadata`, `share`, `shared`, `revoke`) and accepts a handle or DID. 142 149 143 150 ## AppView 144 151
+267
crates/opake-cli/src/commands/metadata.rs
··· 1 + use anyhow::{Context, Result}; 2 + use clap::{Args, Subcommand}; 3 + use opake_core::atproto; 4 + use opake_core::client::Session; 5 + use opake_core::crypto::{ContentKey, OsRng, X25519PrivateKey}; 6 + use opake_core::metadata; 7 + 8 + use crate::commands::Execute; 9 + use crate::document_resolve; 10 + use crate::identity; 11 + use crate::keyring_store; 12 + use crate::session::{self, CommandContext}; 13 + 14 + #[derive(Args)] 15 + /// View or modify document metadata (name, tags, description) 16 + pub struct MetadataCommand { 17 + #[command(subcommand)] 18 + action: MetadataAction, 19 + } 20 + 21 + #[derive(Subcommand)] 22 + enum MetadataAction { 23 + /// Display a document's metadata 24 + Show(ShowArgs), 25 + /// Rename a document 26 + Rename(RenameArgs), 27 + /// Set or clear a document's description 28 + Describe(DescribeArgs), 29 + /// Add or remove tags 30 + Tag(TagCommand), 31 + } 32 + 33 + #[derive(Args)] 34 + struct ShowArgs { 35 + /// Document name, path, or AT-URI 36 + document: String, 37 + } 38 + 39 + #[derive(Args)] 40 + struct RenameArgs { 41 + /// Document name, path, or AT-URI 42 + document: String, 43 + /// New name for the document 44 + new_name: String, 45 + } 46 + 47 + #[derive(Args)] 48 + struct DescribeArgs { 49 + /// Document name, path, or AT-URI 50 + document: String, 51 + /// New description text (omit with --clear to remove) 52 + text: Option<String>, 53 + /// Clear the description 54 + #[arg(long, conflicts_with = "text")] 55 + clear: bool, 56 + } 57 + 58 + #[derive(Args)] 59 + struct TagCommand { 60 + #[command(subcommand)] 61 + action: TagAction, 62 + } 63 + 64 + #[derive(Subcommand)] 65 + enum TagAction { 66 + /// Add a tag to a document 67 + Add(TagArgs), 68 + /// Remove a tag from a document 69 + Remove(TagArgs), 70 + } 71 + 72 + #[derive(Args)] 73 + struct TagArgs { 74 + /// Document name, path, or AT-URI 75 + document: String, 76 + /// Tag to add or remove 77 + tag: String, 78 + } 79 + 80 + impl Execute for MetadataCommand { 81 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 82 + let id = 83 + identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?; 84 + let private_key = id.private_key_bytes()?; 85 + let mut client = session::load_client(&ctx.storage, &ctx.did)?; 86 + 87 + match self.action { 88 + MetadataAction::Show(args) => { 89 + let uri = resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?; 90 + let group_key = peek_group_key(&mut client, &uri, ctx).await?; 91 + 92 + let result = metadata::fetch_document_metadata( 93 + &mut client, 94 + &uri, 95 + &ctx.did, 96 + &private_key, 97 + group_key.as_ref(), 98 + ) 99 + .await?; 100 + 101 + print_metadata(&result.metadata); 102 + } 103 + MetadataAction::Rename(args) => { 104 + let uri = resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?; 105 + let group_key = peek_group_key(&mut client, &uri, ctx).await?; 106 + 107 + let updated = metadata::update_document_metadata( 108 + &mut client, 109 + &uri, 110 + &ctx.did, 111 + &private_key, 112 + group_key.as_ref(), 113 + &mut OsRng, 114 + |m| m.name = args.new_name.clone(), 115 + ) 116 + .await?; 117 + 118 + println!("Renamed to: {}", updated.name); 119 + } 120 + MetadataAction::Describe(args) => { 121 + let uri = resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?; 122 + let group_key = peek_group_key(&mut client, &uri, ctx).await?; 123 + 124 + if args.clear { 125 + metadata::update_document_metadata( 126 + &mut client, 127 + &uri, 128 + &ctx.did, 129 + &private_key, 130 + group_key.as_ref(), 131 + &mut OsRng, 132 + |m| m.description = None, 133 + ) 134 + .await?; 135 + println!("Description cleared."); 136 + } else if let Some(text) = args.text { 137 + metadata::update_document_metadata( 138 + &mut client, 139 + &uri, 140 + &ctx.did, 141 + &private_key, 142 + group_key.as_ref(), 143 + &mut OsRng, 144 + |m| m.description = Some(text.clone()), 145 + ) 146 + .await?; 147 + println!("Description updated."); 148 + } else { 149 + anyhow::bail!("provide description text or --clear"); 150 + } 151 + } 152 + MetadataAction::Tag(tag_cmd) => match tag_cmd.action { 153 + TagAction::Add(args) => { 154 + let uri = 155 + resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?; 156 + let group_key = peek_group_key(&mut client, &uri, ctx).await?; 157 + 158 + let updated = metadata::update_document_metadata( 159 + &mut client, 160 + &uri, 161 + &ctx.did, 162 + &private_key, 163 + group_key.as_ref(), 164 + &mut OsRng, 165 + |m| { 166 + if !m.tags.contains(&args.tag) { 167 + m.tags.push(args.tag.clone()); 168 + } 169 + }, 170 + ) 171 + .await?; 172 + 173 + println!( 174 + "Tags: {}", 175 + if updated.tags.is_empty() { 176 + "(none)".into() 177 + } else { 178 + updated.tags.join(", ") 179 + } 180 + ); 181 + } 182 + TagAction::Remove(args) => { 183 + let uri = 184 + resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?; 185 + let group_key = peek_group_key(&mut client, &uri, ctx).await?; 186 + 187 + let updated = metadata::update_document_metadata( 188 + &mut client, 189 + &uri, 190 + &ctx.did, 191 + &private_key, 192 + group_key.as_ref(), 193 + &mut OsRng, 194 + |m| m.tags.retain(|t| t != &args.tag), 195 + ) 196 + .await?; 197 + 198 + println!( 199 + "Tags: {}", 200 + if updated.tags.is_empty() { 201 + "(none)".into() 202 + } else { 203 + updated.tags.join(", ") 204 + } 205 + ); 206 + } 207 + }, 208 + } 209 + 210 + Ok(session::refreshed_session(&client)) 211 + } 212 + } 213 + 214 + /// Resolve a document reference to an AT-URI. 215 + async fn resolve( 216 + client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 217 + reference: &str, 218 + did: &str, 219 + private_key: &X25519PrivateKey, 220 + ctx: &CommandContext, 221 + ) -> Result<String> { 222 + let uri = 223 + document_resolve::resolve_uri(client, reference, did, private_key, &ctx.storage).await?; 224 + Ok(uri) 225 + } 226 + 227 + /// Peek at a document's encryption type and load the group key if keyring-encrypted. 228 + async fn peek_group_key( 229 + client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 230 + uri: &str, 231 + ctx: &CommandContext, 232 + ) -> Result<Option<ContentKey>> { 233 + let at_uri = atproto::parse_at_uri(uri)?; 234 + let entry = client 235 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 236 + .await?; 237 + let doc: opake_core::records::Document = serde_json::from_value(entry.value)?; 238 + 239 + match &doc.encryption { 240 + opake_core::records::Encryption::Keyring(kr_enc) => { 241 + let kr_uri = atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?; 242 + Ok(Some(keyring_store::load_group_key( 243 + &ctx.storage, 244 + &ctx.did, 245 + &kr_uri.rkey, 246 + kr_enc.keyring_ref.rotation, 247 + )?)) 248 + } 249 + opake_core::records::Encryption::Direct(_) => Ok(None), 250 + } 251 + } 252 + 253 + fn print_metadata(metadata: &opake_core::crypto::DocumentMetadata) { 254 + println!("Name: {}", metadata.name); 255 + if let Some(mime) = &metadata.mime_type { 256 + println!("MIME type: {mime}"); 257 + } 258 + if let Some(size) = metadata.size { 259 + println!("Size: {size} bytes"); 260 + } 261 + if !metadata.tags.is_empty() { 262 + println!("Tags: {}", metadata.tags.join(", ")); 263 + } 264 + if let Some(desc) = &metadata.description { 265 + println!("Description: {desc}"); 266 + } 267 + }
+1
crates/opake-cli/src/commands/mod.rs
··· 6 6 pub mod login; 7 7 pub mod logout; 8 8 pub mod ls; 9 + pub mod metadata; 9 10 pub mod mkdir; 10 11 pub mod move_cmd; 11 12 pub mod pair;
+2
crates/opake-cli/src/main.rs
··· 42 42 Cat(commands::cat::CatCommand), 43 43 Inbox(commands::inbox::InboxCommand), 44 44 Ls(commands::ls::LsCommand), 45 + Metadata(commands::metadata::MetadataCommand), 45 46 Mkdir(commands::mkdir::MkdirCommand), 46 47 /// Moves a file to another directory. Use the metadata command for that. 47 48 Move(commands::move_cmd::MoveCommand), ··· 110 111 Command::Cat(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 111 112 Command::Inbox(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 112 113 Command::Ls(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 114 + Command::Metadata(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 113 115 Command::Mkdir(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 114 116 Command::Move(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?, 115 117 Command::Rm(cmd) => run_with_context(&storage, as_flag.as_deref(), cmd).await?,
+1
docs/ARCHITECTURE.md
··· 133 133 upload.rs File → encrypt → upload (direct or --keyring) 134 134 download.rs Download + decrypt (direct, keyring, or --grant) 135 135 ls.rs List documents 136 + metadata.rs View/edit document metadata (rename, tags, description) 136 137 mkdir.rs Create directory 137 138 rm.rs Path-aware delete (documents, directories, recursive) 138 139 resolve.rs Identity resolution display