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.
···370370371371---
372372373373+## 5b. Metadata Management
374374+375375+### 5b.1 Show metadata
376376+377377+```bash
378378+A$ opake metadata show $FILENAME
379379+# prints: Name, MIME type, Size, Tags, Description
380380+```
381381+382382+### 5b.2 Rename
383383+384384+```bash
385385+A$ opake metadata rename $FILENAME new-name.txt
386386+# prints: Renamed to: new-name.txt
387387+A$ opake ls
388388+# verify: old name gone, new-name.txt present
389389+```
390390+391391+### 5b.3 Add and remove tags
392392+393393+```bash
394394+A$ opake metadata tag add new-name.txt finance
395395+# prints: Tags: finance
396396+A$ opake metadata tag add new-name.txt 2025
397397+# prints: Tags: finance, 2025
398398+A$ opake metadata tag remove new-name.txt finance
399399+# prints: Tags: 2025
400400+A$ opake metadata show new-name.txt
401401+# verify: Tags line shows "2025" only
402402+```
403403+404404+### 5b.4 Set and clear description
405405+406406+```bash
407407+A$ opake metadata describe new-name.txt "Annual tax return"
408408+# prints: Description updated.
409409+A$ opake metadata show new-name.txt
410410+# verify: Description: Annual tax return
411411+A$ opake metadata describe new-name.txt --clear
412412+# prints: Description cleared.
413413+A$ opake metadata show new-name.txt
414414+# verify: no Description line
415415+```
416416+417417+### 5b.5 Duplicate tag is idempotent
418418+419419+```bash
420420+A$ opake metadata tag add new-name.txt 2025
421421+A$ opake metadata tag add new-name.txt 2025
422422+A$ opake metadata show new-name.txt
423423+# verify: Tags shows "2025" once, not twice
424424+```
425425+426426+---
427427+373428## 6. Keyrings
374429375430### 6.1 Create a keyring
+1
CHANGELOG.md
···1212- Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)
13131414### Added
1515+- Add metadata CLI command for rename, tag, and description management [#190](https://issues.opake.app/issues/190.html)
1516- Consolidate DNS and transport into opake-core, unify handle resolution [#185](https://issues.opake.app/issues/185.html)
1617- Build web login, callback, setup, and recover routes [#173](https://issues.opake.app/issues/173.html)
1718- Add device-to-device key pairing via PDS [#183](https://issues.opake.app/issues/183.html)
+11-4
README.md
···9898opake rm Photos/photo.jpg
9999opake rm -r Photos
100100101101-# move and rename
102102-opake mv photo.jpg Photos/
103103-opake mv photo.jpg vacation-photo.jpg
101101+# move a file to another directory
102102+opake move photo.jpg Photos/
103103+104104+# view or edit document metadata
105105+opake metadata show photo.jpg
106106+opake metadata rename photo.jpg vacation-photo.jpg
107107+opake metadata tag add photo.jpg travel
108108+opake metadata tag remove photo.jpg travel
109109+opake metadata describe photo.jpg "Beach sunset from last summer"
110110+opake metadata describe photo.jpg --clear
104111105112# resolve a handle or DID to see their public key
106113opake resolve alice.example.com
···138145139146Commands 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.
140147141141-The `--as` flag works with document commands (`upload`, `download`, `ls`, `rm`, `mv`, `cat`, `tree`, `share`, `shared`, `revoke`) and accepts a handle or DID.
148148+The `--as` flag works with document commands (`upload`, `download`, `ls`, `rm`, `move`, `cat`, `tree`, `metadata`, `share`, `shared`, `revoke`) and accepts a handle or DID.
142149143150## AppView
144151
+267
crates/opake-cli/src/commands/metadata.rs
···11+use anyhow::{Context, Result};
22+use clap::{Args, Subcommand};
33+use opake_core::atproto;
44+use opake_core::client::Session;
55+use opake_core::crypto::{ContentKey, OsRng, X25519PrivateKey};
66+use opake_core::metadata;
77+88+use crate::commands::Execute;
99+use crate::document_resolve;
1010+use crate::identity;
1111+use crate::keyring_store;
1212+use crate::session::{self, CommandContext};
1313+1414+#[derive(Args)]
1515+/// View or modify document metadata (name, tags, description)
1616+pub struct MetadataCommand {
1717+ #[command(subcommand)]
1818+ action: MetadataAction,
1919+}
2020+2121+#[derive(Subcommand)]
2222+enum MetadataAction {
2323+ /// Display a document's metadata
2424+ Show(ShowArgs),
2525+ /// Rename a document
2626+ Rename(RenameArgs),
2727+ /// Set or clear a document's description
2828+ Describe(DescribeArgs),
2929+ /// Add or remove tags
3030+ Tag(TagCommand),
3131+}
3232+3333+#[derive(Args)]
3434+struct ShowArgs {
3535+ /// Document name, path, or AT-URI
3636+ document: String,
3737+}
3838+3939+#[derive(Args)]
4040+struct RenameArgs {
4141+ /// Document name, path, or AT-URI
4242+ document: String,
4343+ /// New name for the document
4444+ new_name: String,
4545+}
4646+4747+#[derive(Args)]
4848+struct DescribeArgs {
4949+ /// Document name, path, or AT-URI
5050+ document: String,
5151+ /// New description text (omit with --clear to remove)
5252+ text: Option<String>,
5353+ /// Clear the description
5454+ #[arg(long, conflicts_with = "text")]
5555+ clear: bool,
5656+}
5757+5858+#[derive(Args)]
5959+struct TagCommand {
6060+ #[command(subcommand)]
6161+ action: TagAction,
6262+}
6363+6464+#[derive(Subcommand)]
6565+enum TagAction {
6666+ /// Add a tag to a document
6767+ Add(TagArgs),
6868+ /// Remove a tag from a document
6969+ Remove(TagArgs),
7070+}
7171+7272+#[derive(Args)]
7373+struct TagArgs {
7474+ /// Document name, path, or AT-URI
7575+ document: String,
7676+ /// Tag to add or remove
7777+ tag: String,
7878+}
7979+8080+impl Execute for MetadataCommand {
8181+ async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> {
8282+ let id =
8383+ identity::load_identity(&ctx.storage, &ctx.did).context("run `opake login` first")?;
8484+ let private_key = id.private_key_bytes()?;
8585+ let mut client = session::load_client(&ctx.storage, &ctx.did)?;
8686+8787+ match self.action {
8888+ MetadataAction::Show(args) => {
8989+ let uri = resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?;
9090+ let group_key = peek_group_key(&mut client, &uri, ctx).await?;
9191+9292+ let result = metadata::fetch_document_metadata(
9393+ &mut client,
9494+ &uri,
9595+ &ctx.did,
9696+ &private_key,
9797+ group_key.as_ref(),
9898+ )
9999+ .await?;
100100+101101+ print_metadata(&result.metadata);
102102+ }
103103+ MetadataAction::Rename(args) => {
104104+ let uri = resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?;
105105+ let group_key = peek_group_key(&mut client, &uri, ctx).await?;
106106+107107+ let updated = metadata::update_document_metadata(
108108+ &mut client,
109109+ &uri,
110110+ &ctx.did,
111111+ &private_key,
112112+ group_key.as_ref(),
113113+ &mut OsRng,
114114+ |m| m.name = args.new_name.clone(),
115115+ )
116116+ .await?;
117117+118118+ println!("Renamed to: {}", updated.name);
119119+ }
120120+ MetadataAction::Describe(args) => {
121121+ let uri = resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?;
122122+ let group_key = peek_group_key(&mut client, &uri, ctx).await?;
123123+124124+ if args.clear {
125125+ metadata::update_document_metadata(
126126+ &mut client,
127127+ &uri,
128128+ &ctx.did,
129129+ &private_key,
130130+ group_key.as_ref(),
131131+ &mut OsRng,
132132+ |m| m.description = None,
133133+ )
134134+ .await?;
135135+ println!("Description cleared.");
136136+ } else if let Some(text) = args.text {
137137+ metadata::update_document_metadata(
138138+ &mut client,
139139+ &uri,
140140+ &ctx.did,
141141+ &private_key,
142142+ group_key.as_ref(),
143143+ &mut OsRng,
144144+ |m| m.description = Some(text.clone()),
145145+ )
146146+ .await?;
147147+ println!("Description updated.");
148148+ } else {
149149+ anyhow::bail!("provide description text or --clear");
150150+ }
151151+ }
152152+ MetadataAction::Tag(tag_cmd) => match tag_cmd.action {
153153+ TagAction::Add(args) => {
154154+ let uri =
155155+ resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?;
156156+ let group_key = peek_group_key(&mut client, &uri, ctx).await?;
157157+158158+ let updated = metadata::update_document_metadata(
159159+ &mut client,
160160+ &uri,
161161+ &ctx.did,
162162+ &private_key,
163163+ group_key.as_ref(),
164164+ &mut OsRng,
165165+ |m| {
166166+ if !m.tags.contains(&args.tag) {
167167+ m.tags.push(args.tag.clone());
168168+ }
169169+ },
170170+ )
171171+ .await?;
172172+173173+ println!(
174174+ "Tags: {}",
175175+ if updated.tags.is_empty() {
176176+ "(none)".into()
177177+ } else {
178178+ updated.tags.join(", ")
179179+ }
180180+ );
181181+ }
182182+ TagAction::Remove(args) => {
183183+ let uri =
184184+ resolve(&mut client, &args.document, &ctx.did, &private_key, ctx).await?;
185185+ let group_key = peek_group_key(&mut client, &uri, ctx).await?;
186186+187187+ let updated = metadata::update_document_metadata(
188188+ &mut client,
189189+ &uri,
190190+ &ctx.did,
191191+ &private_key,
192192+ group_key.as_ref(),
193193+ &mut OsRng,
194194+ |m| m.tags.retain(|t| t != &args.tag),
195195+ )
196196+ .await?;
197197+198198+ println!(
199199+ "Tags: {}",
200200+ if updated.tags.is_empty() {
201201+ "(none)".into()
202202+ } else {
203203+ updated.tags.join(", ")
204204+ }
205205+ );
206206+ }
207207+ },
208208+ }
209209+210210+ Ok(session::refreshed_session(&client))
211211+ }
212212+}
213213+214214+/// Resolve a document reference to an AT-URI.
215215+async fn resolve(
216216+ client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>,
217217+ reference: &str,
218218+ did: &str,
219219+ private_key: &X25519PrivateKey,
220220+ ctx: &CommandContext,
221221+) -> Result<String> {
222222+ let uri =
223223+ document_resolve::resolve_uri(client, reference, did, private_key, &ctx.storage).await?;
224224+ Ok(uri)
225225+}
226226+227227+/// Peek at a document's encryption type and load the group key if keyring-encrypted.
228228+async fn peek_group_key(
229229+ client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>,
230230+ uri: &str,
231231+ ctx: &CommandContext,
232232+) -> Result<Option<ContentKey>> {
233233+ let at_uri = atproto::parse_at_uri(uri)?;
234234+ let entry = client
235235+ .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey)
236236+ .await?;
237237+ let doc: opake_core::records::Document = serde_json::from_value(entry.value)?;
238238+239239+ match &doc.encryption {
240240+ opake_core::records::Encryption::Keyring(kr_enc) => {
241241+ let kr_uri = atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?;
242242+ Ok(Some(keyring_store::load_group_key(
243243+ &ctx.storage,
244244+ &ctx.did,
245245+ &kr_uri.rkey,
246246+ kr_enc.keyring_ref.rotation,
247247+ )?))
248248+ }
249249+ opake_core::records::Encryption::Direct(_) => Ok(None),
250250+ }
251251+}
252252+253253+fn print_metadata(metadata: &opake_core::crypto::DocumentMetadata) {
254254+ println!("Name: {}", metadata.name);
255255+ if let Some(mime) = &metadata.mime_type {
256256+ println!("MIME type: {mime}");
257257+ }
258258+ if let Some(size) = metadata.size {
259259+ println!("Size: {size} bytes");
260260+ }
261261+ if !metadata.tags.is_empty() {
262262+ println!("Tags: {}", metadata.tags.join(", "));
263263+ }
264264+ if let Some(desc) = &metadata.description {
265265+ println!("Description: {desc}");
266266+ }
267267+}
+1
crates/opake-cli/src/commands/mod.rs
···66pub mod login;
77pub mod logout;
88pub mod ls;
99+pub mod metadata;
910pub mod mkdir;
1011pub mod move_cmd;
1112pub mod pair;