Rust CLI for tangled

Auto-detect repo from git remote when --repo is omitted

Add shared parse_repo_ref, parse_remote_url, resolve_repo_from_remote,
and require_repo utilities to util.rs. Commands now auto-detect the
repository from the git origin remote (for tangled.org/*.tangled.sh
hosts) when --repo is not provided, removing the need to specify it
explicitly inside a cloned tangled repo directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+140 -120
+8 -8
crates/tangled-cli/src/cli.rs
··· 294 294 295 295 #[derive(Args, Debug, Clone)] 296 296 pub struct KnotMigrateArgs { 297 - /// Repo to migrate: <owner>/<name> (owner defaults to your handle) 297 + /// Repo to migrate: <owner>/<name> (auto-detected from git remote if omitted) 298 298 #[arg(long)] 299 - pub repo: String, 299 + pub repo: Option<String>, 300 300 /// Target knot hostname (e.g. knot1.tangled.sh) 301 301 #[arg(long, value_name = "HOST")] 302 302 pub to: String, ··· 368 368 369 369 #[derive(Args, Debug, Clone)] 370 370 pub struct SpindleSecretListArgs { 371 - /// Repo: <owner>/<name> 371 + /// Repo: <owner>/<name> (auto-detected from git remote if omitted) 372 372 #[arg(long)] 373 - pub repo: String, 373 + pub repo: Option<String>, 374 374 } 375 375 376 376 #[derive(Args, Debug, Clone)] 377 377 pub struct SpindleSecretAddArgs { 378 - /// Repo: <owner>/<name> 378 + /// Repo: <owner>/<name> (auto-detected from git remote if omitted) 379 379 #[arg(long)] 380 - pub repo: String, 380 + pub repo: Option<String>, 381 381 /// Secret key 382 382 #[arg(long)] 383 383 pub key: String, ··· 388 388 389 389 #[derive(Args, Debug, Clone)] 390 390 pub struct SpindleSecretRemoveArgs { 391 - /// Repo: <owner>/<name> 391 + /// Repo: <owner>/<name> (auto-detected from git remote if omitted) 392 392 #[arg(long)] 393 - pub repo: String, 393 + pub repo: Option<String>, 394 394 /// Secret key 395 395 #[arg(long)] 396 396 pub key: String,
+8 -15
crates/tangled-cli/src/commands/issue.rs
··· 24 24 .unwrap_or_else(|| "https://bsky.social".into()); 25 25 let client = tangled_api::TangledClient::new(&pds); 26 26 27 - let repo_filter_at = if let Some(repo) = &args.repo { 28 - let (owner, name) = parse_repo_ref(repo, &session.handle); 27 + let effective_repo = args 28 + .repo 29 + .clone() 30 + .or_else(|| crate::util::resolve_repo_from_remote()); 31 + let repo_filter_at = if let Some(repo) = &effective_repo { 32 + let (owner, name) = crate::util::parse_repo_ref(repo, &session.handle); 29 33 let info = client 30 34 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 31 35 .await?; ··· 61 65 .unwrap_or_else(|| "https://bsky.social".into()); 62 66 let client = tangled_api::TangledClient::new(&pds); 63 67 64 - let repo = args 65 - .repo 66 - .as_ref() 67 - .ok_or_else(|| anyhow!("--repo is required for issue create"))?; 68 - let (owner, name) = parse_repo_ref(repo, &session.handle); 68 + let repo = crate::util::require_repo(args.repo.as_deref())?; 69 + let (owner, name) = crate::util::parse_repo_ref(&repo, &session.handle); 69 70 let info = client 70 71 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 71 72 .await?; ··· 202 203 println!("Issue closed"); 203 204 } 204 205 Ok(()) 205 - } 206 - 207 - fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 208 - if let Some((owner, name)) = spec.split_once('/') { 209 - (owner, name) 210 - } else { 211 - (default_owner, spec) 212 - } 213 206 } 214 207 215 208 fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
+5 -38
crates/tangled-cli/src/commands/knot.rs
··· 77 77 .url() 78 78 .ok_or_else(|| anyhow!("origin has no URL"))? 79 79 .to_string(); 80 - let (origin_owner, origin_name, _origin_host) = parse_remote_url(&origin_url) 80 + let (origin_owner, origin_name, _origin_host) = crate::util::parse_remote_url(&origin_url) 81 81 .ok_or_else(|| anyhow!("unsupported origin URL: {}", origin_url))?; 82 82 83 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 83 + let repo_str = crate::util::require_repo(args.repo.as_deref())?; 84 + let (owner, name) = crate::util::parse_repo_ref(&repo_str, &session.handle); 84 85 if origin_owner.trim_start_matches('@') != owner.trim_start_matches('@') || origin_name != name 85 86 { 86 87 return Err(anyhow!( ··· 99 100 .unwrap_or_else(|| "https://bsky.social".into()); 100 101 let pds_client = tangled_api::TangledClient::new(&pds); 101 102 let info = pds_client 102 - .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 103 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 103 104 .await?; 104 105 105 106 // Build a publicly accessible source URL on tangled.org for the existing repo ··· 123 124 let client = tangled_api::TangledClient::default(); 124 125 let opts = tangled_api::client::CreateRepoOptions { 125 126 did: &session.did, 126 - name: &name, 127 + name, 127 128 knot: &args.to, 128 129 description: info.description.as_deref(), 129 130 default_branch: None, ··· 154 155 Ok(()) 155 156 } 156 157 157 - fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) { 158 - if let Some((owner, name)) = spec.split_once('/') { 159 - (owner, name.to_string()) 160 - } else { 161 - (default_owner, spec.to_string()) 162 - } 163 - } 164 - 165 - fn parse_remote_url(url: &str) -> Option<(String, String, String)> { 166 - // Returns (owner, name, host) 167 - if let Some(rest) = url.strip_prefix("git@") { 168 - // git@host:owner/name(.git) 169 - let mut parts = rest.split(':'); 170 - let host = parts.next()?.to_string(); 171 - let path = parts.next()?; 172 - let mut segs = path.trim_end_matches(".git").split('/'); 173 - let owner = segs.next()?.to_string(); 174 - let name = segs.next()?.to_string(); 175 - return Some((owner, name, host)); 176 - } 177 - if url.starts_with("http://") || url.starts_with("https://") { 178 - if let Ok(parsed) = url::Url::parse(url) { 179 - let host = parsed.host_str().unwrap_or("").to_string(); 180 - let path = parsed.path().trim_matches('/'); 181 - // paths may be like '@owner/name' or 'owner/name' 182 - let mut segs = path.trim_end_matches(".git").split('/'); 183 - let first = segs.next()?; 184 - let owner = first.trim_start_matches('@').to_string(); 185 - let name = segs.next()?.to_string(); 186 - return Some((owner, name, host)); 187 - } 188 - } 189 - None 190 - }
+8 -19
crates/tangled-cli/src/commands/pr.rs
··· 21 21 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 22 22 .unwrap_or_else(|| "https://bsky.social".into()); 23 23 let client = tangled_api::TangledClient::new(&pds); 24 - let target_repo_at = if let Some(repo) = &args.repo { 25 - let (owner, name) = parse_repo_ref(repo, &session.handle); 24 + let effective_repo = args 25 + .repo 26 + .clone() 27 + .or_else(|| crate::util::resolve_repo_from_remote()); 28 + let target_repo_at = if let Some(repo) = &effective_repo { 29 + let (owner, name) = crate::util::parse_repo_ref(repo, &session.handle); 26 30 let info = client 27 31 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 28 32 .await?; ··· 58 62 .unwrap_or_else(|| "https://bsky.social".into()); 59 63 let client = tangled_api::TangledClient::new(&pds); 60 64 61 - let repo = args 62 - .repo 63 - .as_ref() 64 - .ok_or_else(|| anyhow!("--repo is required for pr create"))?; 65 - let (owner, name) = parse_repo_ref(repo, ""); 65 + let repo = crate::util::require_repo(args.repo.as_deref())?; 66 + let (owner, name) = crate::util::parse_repo_ref(&repo, ""); 66 67 let info = client 67 68 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 68 69 .await?; ··· 208 209 } 209 210 210 211 Ok(()) 211 - } 212 - 213 - fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 214 - if let Some((owner, name)) = spec.split_once('/') { 215 - if !owner.is_empty() { 216 - (owner, name) 217 - } else { 218 - (default_owner, name) 219 - } 220 - } else { 221 - (default_owner, spec) 222 - } 223 212 } 224 213 225 214 fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
+12 -19
crates/tangled-cli/src/commands/repo.rs
··· 90 90 async fn clone(args: RepoCloneArgs) -> Result<()> { 91 91 let session = crate::util::load_session_with_refresh().await?; 92 92 93 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 93 + let (owner, name) = crate::util::parse_repo_ref(&args.repo, &session.handle); 94 94 let pds = session 95 95 .pds 96 96 .clone() ··· 98 98 .unwrap_or_else(|| "https://bsky.social".into()); 99 99 let pds_client = tangled_api::TangledClient::new(&pds); 100 100 let info = pds_client 101 - .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 101 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 102 102 .await?; 103 103 104 104 let remote = if args.https { ··· 117 117 format!("git@{}:{}/{}", knot, owner.trim_start_matches('@'), name) 118 118 }; 119 119 120 - let target = PathBuf::from(&name); 120 + let target = PathBuf::from(name); 121 121 println!("Cloning {} -> {:?}", remote, target); 122 122 123 123 let mut callbacks = RemoteCallbacks::new(); ··· 153 153 154 154 async fn info(args: RepoInfoArgs) -> Result<()> { 155 155 let session = crate::util::load_session_with_refresh().await?; 156 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 156 + let (owner, name) = crate::util::parse_repo_ref(&args.repo, &session.handle); 157 157 let pds = session 158 158 .pds 159 159 .clone() ··· 161 161 .unwrap_or_else(|| "https://bsky.social".into()); 162 162 let pds_client = tangled_api::TangledClient::new(&pds); 163 163 let info = pds_client 164 - .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 164 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 165 165 .await?; 166 166 167 167 println!("NAME: {}", info.name); ··· 221 221 222 222 async fn delete(args: RepoDeleteArgs) -> Result<()> { 223 223 let session = crate::util::load_session_with_refresh().await?; 224 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 224 + let (owner, name) = crate::util::parse_repo_ref(&args.repo, &session.handle); 225 225 let pds = session 226 226 .pds 227 227 .clone() ··· 229 229 .unwrap_or_else(|| "https://bsky.social".into()); 230 230 let pds_client = tangled_api::TangledClient::new(&pds); 231 231 let record = pds_client 232 - .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 232 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 233 233 .await?; 234 234 let did = record.did; 235 235 let api = tangled_api::TangledClient::default(); 236 - api.delete_repo(&did, &name, &pds, &session.access_jwt) 236 + api.delete_repo(&did, name, &pds, &session.access_jwt) 237 237 .await?; 238 238 println!("Deleted repo '{}'", name); 239 239 Ok(()) ··· 241 241 242 242 async fn star(args: RepoRefArgs) -> Result<()> { 243 243 let session = crate::util::load_session_with_refresh().await?; 244 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 244 + let (owner, name) = crate::util::parse_repo_ref(&args.repo, &session.handle); 245 245 let pds = session 246 246 .pds 247 247 .clone() ··· 249 249 .unwrap_or_else(|| "https://bsky.social".into()); 250 250 let pds_client = tangled_api::TangledClient::new(&pds); 251 251 let info = pds_client 252 - .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 252 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 253 253 .await?; 254 254 let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 255 255 let api = tangled_api::TangledClient::default(); ··· 261 261 262 262 async fn unstar(args: RepoRefArgs) -> Result<()> { 263 263 let session = crate::util::load_session_with_refresh().await?; 264 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 264 + let (owner, name) = crate::util::parse_repo_ref(&args.repo, &session.handle); 265 265 let pds = session 266 266 .pds 267 267 .clone() ··· 269 269 .unwrap_or_else(|| "https://bsky.social".into()); 270 270 let pds_client = tangled_api::TangledClient::new(&pds); 271 271 let info = pds_client 272 - .get_repo_info(owner, &name, Some(session.access_jwt.as_str())) 272 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 273 273 .await?; 274 274 let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 275 275 let api = tangled_api::TangledClient::default(); ··· 279 279 Ok(()) 280 280 } 281 281 282 - fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) { 283 - if let Some((owner, name)) = spec.split_once('/') { 284 - (owner, name.to_string()) 285 - } else { 286 - (default_owner, spec.to_string()) 287 - } 288 - }
+28 -21
crates/tangled-cli/src/commands/spindle.rs
··· 26 26 .unwrap_or_else(|| "https://bsky.social".into()); 27 27 let pds_client = tangled_api::TangledClient::new(&pds); 28 28 29 - let (owner, name) = parse_repo_ref( 30 - args.repo.as_deref().unwrap_or(&session.handle), 31 - &session.handle 29 + let effective_repo = args 30 + .repo 31 + .clone() 32 + .or_else(|| crate::util::resolve_repo_from_remote()); 33 + let (owner, name) = crate::util::parse_repo_ref( 34 + effective_repo.as_deref().unwrap_or(&session.handle), 35 + &session.handle, 32 36 ); 33 37 let info = pds_client 34 38 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) ··· 80 84 .unwrap_or_else(|| "https://bsky.social".into()); 81 85 let pds_client = tangled_api::TangledClient::new(&pds); 82 86 83 - let (owner, name) = parse_repo_ref( 84 - args.repo.as_deref().unwrap_or(&session.handle), 85 - &session.handle 87 + let effective_repo = args 88 + .repo 89 + .clone() 90 + .or_else(|| crate::util::resolve_repo_from_remote()); 91 + let (owner, name) = crate::util::parse_repo_ref( 92 + effective_repo.as_deref().unwrap_or(&session.handle), 93 + &session.handle, 86 94 ); 87 95 let info = pds_client 88 96 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) ··· 117 125 } 118 126 119 127 async fn run_pipeline(args: SpindleRunArgs) -> Result<()> { 128 + let effective_repo = args 129 + .repo 130 + .clone() 131 + .or_else(|| crate::util::resolve_repo_from_remote()); 120 132 println!( 121 133 "Spindle run (stub) repo={:?} branch={:?} wait={}", 122 - args.repo, args.branch, args.wait 134 + effective_repo, args.branch, args.wait 123 135 ); 124 136 Ok(()) 125 137 } ··· 196 208 197 209 async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { 198 210 let session = crate::util::load_session_with_refresh().await?; 211 + let repo_str = crate::util::require_repo(args.repo.as_deref())?; 199 212 let pds = session 200 213 .pds 201 214 .clone() 202 215 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 203 216 .unwrap_or_else(|| "https://bsky.social".into()); 204 217 let pds_client = tangled_api::TangledClient::new(&pds); 205 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 218 + let (owner, name) = crate::util::parse_repo_ref(&repo_str, &session.handle); 206 219 let info = pds_client 207 220 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 208 221 .await?; ··· 219 232 .list_repo_secrets(&pds, &session.access_jwt, &repo_at) 220 233 .await?; 221 234 if secrets.is_empty() { 222 - println!("No secrets configured for {}", args.repo); 235 + println!("No secrets configured for {}", repo_str); 223 236 } else { 224 237 println!("KEY\tCREATED AT\tCREATED BY"); 225 238 for s in secrets { ··· 231 244 232 245 async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { 233 246 let session = crate::util::load_session_with_refresh().await?; 247 + let repo_str = crate::util::require_repo(args.repo.as_deref())?; 234 248 let pds = session 235 249 .pds 236 250 .clone() 237 251 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 238 252 .unwrap_or_else(|| "https://bsky.social".into()); 239 253 let pds_client = tangled_api::TangledClient::new(&pds); 240 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 254 + let (owner, name) = crate::util::parse_repo_ref(&repo_str, &session.handle); 241 255 let info = pds_client 242 256 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 243 257 .await?; ··· 277 291 278 292 api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &value) 279 293 .await?; 280 - println!("Added secret '{}' to {}", args.key, args.repo); 294 + println!("Added secret '{}' to {}", args.key, repo_str); 281 295 Ok(()) 282 296 } 283 297 284 298 async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { 285 299 let session = crate::util::load_session_with_refresh().await?; 300 + let repo_str = crate::util::require_repo(args.repo.as_deref())?; 286 301 let pds = session 287 302 .pds 288 303 .clone() 289 304 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 290 305 .unwrap_or_else(|| "https://bsky.social".into()); 291 306 let pds_client = tangled_api::TangledClient::new(&pds); 292 - let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 307 + let (owner, name) = crate::util::parse_repo_ref(&repo_str, &session.handle); 293 308 let info = pds_client 294 309 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 295 310 .await?; ··· 304 319 305 320 api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) 306 321 .await?; 307 - println!("Removed secret '{}' from {}", args.key, args.repo); 322 + println!("Removed secret '{}' from {}", args.key, repo_str); 308 323 Ok(()) 309 324 } 310 - 311 - fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 312 - if let Some((owner, name)) = spec.split_once('/') { 313 - (owner, name) 314 - } else { 315 - (default_owner, spec) 316 - } 317 - }
+71
crates/tangled-cli/src/util.rs
··· 1 1 use anyhow::{anyhow, Result}; 2 + use std::path::Path; 2 3 use tangled_config::session::{Session, SessionManager}; 3 4 4 5 /// Load session and automatically refresh if expired ··· 53 54 54 55 Ok(session) 55 56 } 57 + 58 + /// Split "owner/name" into (owner, name), defaulting owner if no slash present. 59 + pub fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) { 60 + if let Some((owner, name)) = spec.split_once('/') { 61 + if !owner.is_empty() { 62 + (owner, name) 63 + } else { 64 + (default_owner, name) 65 + } 66 + } else { 67 + (default_owner, spec) 68 + } 69 + } 70 + 71 + /// Parse a git remote URL into (owner, name, host). 72 + pub fn parse_remote_url(url: &str) -> Option<(String, String, String)> { 73 + if let Some(rest) = url.strip_prefix("git@") { 74 + let mut parts = rest.split(':'); 75 + let host = parts.next()?.to_string(); 76 + let path = parts.next()?; 77 + let mut segs = path.trim_end_matches(".git").split('/'); 78 + let owner = segs.next()?.to_string(); 79 + let name = segs.next()?.to_string(); 80 + return Some((owner, name, host)); 81 + } 82 + if url.starts_with("http://") || url.starts_with("https://") { 83 + if let Ok(parsed) = url::Url::parse(url) { 84 + let host = parsed.host_str().unwrap_or("").to_string(); 85 + let path = parsed.path().trim_matches('/'); 86 + let mut segs = path.trim_end_matches(".git").split('/'); 87 + let first = segs.next()?; 88 + let owner = first.trim_start_matches('@').to_string(); 89 + let name = segs.next()?.to_string(); 90 + return Some((owner, name, host)); 91 + } 92 + } 93 + None 94 + } 95 + 96 + /// Try to detect "owner/name" from the git remote of the current directory. 97 + pub fn resolve_repo_from_remote() -> Option<String> { 98 + let repo = git2::Repository::discover(Path::new(".")).ok()?; 99 + let remote = repo 100 + .find_remote("origin") 101 + .or_else(|_| { 102 + repo.remotes().and_then(|rems| { 103 + rems.get(0) 104 + .ok_or(git2::Error::from_str("no remotes configured")) 105 + .and_then(|name| repo.find_remote(name)) 106 + }) 107 + }) 108 + .ok()?; 109 + let url = remote.url()?.to_string(); 110 + let (owner, name, host) = parse_remote_url(&url)?; 111 + if host == "tangled.org" || host.ends_with(".tangled.sh") { 112 + Some(format!("{}/{}", owner, name)) 113 + } else { 114 + None 115 + } 116 + } 117 + 118 + /// Resolve repo from --repo flag or git remote, returning a clear error if neither works. 119 + pub fn require_repo(flag: Option<&str>) -> Result<String> { 120 + if let Some(repo) = flag { 121 + return Ok(repo.to_string()); 122 + } 123 + resolve_repo_from_remote().ok_or_else(|| { 124 + anyhow!("could not determine repository. Use --repo or run from inside a tangled repo directory") 125 + }) 126 + }