Rust CLI for tangled

Compare changes

Choose any two refs to compare.

+188 -375
AGENTS.md
··· 1 - # Tangled CLI – Agent Handoff (Massive Context) 2 3 - This document is a complete handoff for the next Codex instance working on the Tangled CLI (Rust). It explains what exists, what to build next, where to edit, how to call the APIs, how to persist sessions, how to print output, and how to validate success. 4 5 - Primary focus for this session: implement authentication (auth login/status/logout) and repository listing (repo list). 6 7 - -------------------------------------------------------------------------------- 8 9 - ## 0) TL;DR – Immediate Actions 10 11 - - Implement `auth login` using AT Protocol `com.atproto.server.createSession`. 12 - - Prompt for handle/password if flags aren’t provided. 13 - - POST to `/xrpc/com.atproto.server.createSession` at the configured PDS (default `https://bsky.social`). 14 - - Persist `{accessJwt, refreshJwt, did, handle}` via `SessionManager` (keyring-backed). 15 - - `auth status` reads keyring and prints handle + did; `auth logout` clears keyring. 16 17 - - Implement `repo list` using Tangled’s repo list method (tentative `sh.tangled.repo.list`). 18 - - GET `/xrpc/sh.tangled.repo.list` with optional params: `user`, `knot`, `starred`. 19 - - Include `Authorization: Bearer <accessJwt>` if required. 20 - - Print results as table (default) or JSON (`--format json`). 21 22 - Keep edits minimal and scoped to these features. 23 24 - -------------------------------------------------------------------------------- 25 26 - ## 1) Repository Map (Paths You Will Touch) 27 28 - - CLI (binary): 29 - - `tangled/crates/tangled-cli/src/commands/auth.rs` → implement login/status/logout. 30 - - `tangled/crates/tangled-cli/src/commands/repo.rs` → implement list. 31 - - `tangled/crates/tangled-cli/src/cli.rs` → already contains arguments and subcommands; no structural changes needed. 32 - - `tangled/crates/tangled-cli/src/main.rs` → no change. 33 34 - - Config + session: 35 - - `tangled/crates/tangled-config/src/session.rs` → already provides `Session` + `SessionManager` (keyring). 36 - - `tangled/crates/tangled-config/src/config.rs` → optional use for PDS/base URL (MVP can use CLI flags/env vars). 37 38 - - API client: 39 - - `tangled/crates/tangled-api/src/client.rs` → add XRPC helpers and implement `login_with_password` and `list_repos`. 40 - 41 - -------------------------------------------------------------------------------- 42 - 43 - ## 2) Current State Snapshot 44 - 45 - - Workspace is scaffolded and compiles after wiring dependencies (network needed to fetch crates): 46 - - `tangled-cli`: clap CLI with subcommands; commands currently log stubs. 47 - - `tangled-config`: TOML config loader/saver; keyring-backed session store. 48 - - `tangled-api`: client struct with placeholder methods. 49 - - `tangled-git`: stubs for future. 50 - - Placeholder lexicons in `tangled/lexicons/sh.tangled/*` are not authoritative; use AT Protocol docs and inspect real endpoints later. 51 - 52 - Goal: replace CLI stubs with real API calls for auth + repo list. 53 - 54 - -------------------------------------------------------------------------------- 55 - 56 - ## 3) Endpoints & Data Shapes 57 - 58 - ### 3.1 AT Protocol – Create Session 59 - 60 - - Method: `com.atproto.server.createSession` 61 - - HTTP: `POST /xrpc/com.atproto.server.createSession` 62 - - Request JSON: 63 - - `identifier: string` → user handle or email (e.g., `alice.bsky.social`). 64 - - `password: string` → password or app password. 65 - - Response JSON (subset used): 66 - - `accessJwt: string` 67 - - `refreshJwt: string` 68 - - `did: string` (e.g., `did:plc:...`) 69 - - `handle: string` 70 - 71 - Persist to keyring using `SessionManager`. 72 - 73 - ### 3.2 Tangled – Repo List (tentative) 74 - 75 - - Method: `sh.tangled.repo.list` (subject to change; wire in a constant to adjust easily). 76 - - HTTP: `GET /xrpc/sh.tangled.repo.list?user=<..>&knot=<..>&starred=<true|false>` 77 - - Auth: likely required; include `Authorization: Bearer <accessJwt>`. 78 - - Response JSON (envelope): 79 - - `{ "repos": [{ "name": string, "knot": string, "private": bool, ... }] }` 80 - 81 - If method name or response shape differs, adapt the client code; keep CLI interface stable. 82 - 83 - -------------------------------------------------------------------------------- 84 - 85 - ## 4) Implementation Plan 86 - 87 - ### 4.1 Add XRPC helpers and methods in `tangled-api` 88 - 89 - File: `tangled/crates/tangled-api/src/client.rs` 90 - 91 - - Extend `TangledClient` with: 92 - - `fn xrpc_url(&self, method: &str) -> String` → combines `base_url` + `/xrpc/` + `method`. 93 - - `async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(&self, method, req, bearer) -> Result<TRes>`. 94 - - `async fn get_json<TRes: DeserializeOwned>(&self, method, params, bearer) -> Result<TRes>`. 95 - - Include `Authorization: Bearer <token>` when `bearer` is provided. 96 - 97 - - Implement: 98 - - `pub async fn login_with_password(&self, handle: &str, password: &str, pds: &str) -> Result<Session>` 99 - - POST to `com.atproto.server.createSession` at `self.base_url` (which should be the PDS base). 100 - - Map response to `tangled_config::session::Session` and return it (caller will persist). 101 - - `pub async fn list_repos(&self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>) -> Result<Vec<Repository>>` 102 - - GET `sh.tangled.repo.list` with params present only if set. 103 - - Return parsed `Vec<Repository>` from an envelope `{ repos: [...] }`. 104 - 105 - Error handling: For non-2xx, read the response body, return `anyhow!("{status}: {body}")`. 106 107 - ### 4.2 Wire CLI auth commands 108 109 - File: `tangled/crates/tangled-cli/src/commands/auth.rs` 110 111 - - `login`: 112 - - Determine PDS: use `--pds` arg if provided, else default `https://bsky.social` (later from config/env). 113 - - Prompt for missing handle/password. 114 - - `let client = tangled_api::TangledClient::new(&pds);` 115 - - `let session = client.login_with_password(&handle, &password, &pds).await?;` 116 - - `tangled_config::session::SessionManager::default().save(&session)?;` 117 - - Print: `Logged in as '{handle}' ({did})`. 118 119 - - `status`: 120 - - Load `SessionManager::default().load()?`. 121 - - If Some: print `Logged in as '{handle}' ({did})`. 122 - - Else: print `Not logged in. Run: tangled auth login`. 123 124 - - `logout`: 125 - - `SessionManager::default().clear()?`. 126 - - Print `Logged out` if something was cleared; otherwise `No session found` is acceptable. 127 128 - ### 4.3 Wire CLI repo list 129 130 - File: `tangled/crates/tangled-cli/src/commands/repo.rs` 131 132 - - Load session; if absent, print `Please login first: tangled auth login` and exit 1 (or 0 with friendly message; choose one and be consistent). 133 - - Build a client for Tangled API base (for now, default to `https://tangled.org` or allow `TANGLED_API_BASE` env var to override): 134 - - `let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into());` 135 - - `let client = tangled_api::TangledClient::new(base);` 136 - - Call `client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?`. 137 - - Print: 138 - - If `Cli.format == OutputFormat::Json`: `serde_json::to_string_pretty(&repos)`. 139 - - Else: simple columns `NAME KNOT PRIVATE` using `println!` formatting for now. 140 141 - -------------------------------------------------------------------------------- 142 143 - ## 5) Code Snippets (Copy/Paste Friendly) 144 145 - ### 5.1 In `tangled-api/src/client.rs` 146 147 ```rust 148 - use anyhow::{anyhow, bail, Result}; 149 - use serde::{de::DeserializeOwned, Deserialize, Serialize}; 150 - use tangled_config::session::Session; 151 - 152 - #[derive(Clone, Debug)] 153 - pub struct TangledClient { pub(crate) base_url: String } 154 - 155 - impl TangledClient { 156 - pub fn new(base_url: impl Into<String>) -> Self { Self { base_url: base_url.into() } } 157 - pub fn default() -> Self { Self::new("https://tangled.org") } 158 - 159 - fn xrpc_url(&self, method: &str) -> String { 160 - format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 161 - } 162 - 163 - async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( 164 - &self, 165 - method: &str, 166 - req: &TReq, 167 - bearer: Option<&str>, 168 - ) -> Result<TRes> { 169 - let url = self.xrpc_url(method); 170 - let client = reqwest::Client::new(); 171 - let mut reqb = client.post(url).header(reqwest::header::CONTENT_TYPE, "application/json"); 172 - if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); } 173 - let res = reqb.json(req).send().await?; 174 - let status = res.status(); 175 - if !status.is_success() { 176 - let body = res.text().await.unwrap_or_default(); 177 - return Err(anyhow!("{}: {}", status, body)); 178 - } 179 - Ok(res.json::<TRes>().await?) 180 - } 181 - 182 - async fn get_json<TRes: DeserializeOwned>( 183 - &self, 184 - method: &str, 185 - params: &[(&str, String)], 186 - bearer: Option<&str>, 187 - ) -> Result<TRes> { 188 - let url = self.xrpc_url(method); 189 - let client = reqwest::Client::new(); 190 - let mut reqb = client.get(url).query(&params); 191 - if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); } 192 - let res = reqb.send().await?; 193 - let status = res.status(); 194 - if !status.is_success() { 195 - let body = res.text().await.unwrap_or_default(); 196 - return Err(anyhow!("{}: {}", status, body)); 197 - } 198 - Ok(res.json::<TRes>().await?) 199 - } 200 - 201 - pub async fn login_with_password(&self, handle: &str, password: &str, _pds: &str) -> Result<Session> { 202 - #[derive(Serialize)] 203 - struct Req<'a> { #[serde(rename = "identifier")] identifier: &'a str, #[serde(rename = "password")] password: &'a str } 204 - #[derive(Deserialize)] 205 - struct Res { #[serde(rename = "accessJwt")] access_jwt: String, #[serde(rename = "refreshJwt")] refresh_jwt: String, did: String, handle: String } 206 - let body = Req { identifier: handle, password }; 207 - let res: Res = self.post_json("com.atproto.server.createSession", &body, None).await?; 208 - Ok(Session { access_jwt: res.access_jwt, refresh_jwt: res.refresh_jwt, did: res.did, handle: res.handle, ..Default::default() }) 209 - } 210 - 211 - pub async fn list_repos(&self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>) -> Result<Vec<Repository>> { 212 - #[derive(Deserialize)] 213 - struct Envelope { repos: Vec<Repository> } 214 - let mut q = vec![]; 215 - if let Some(u) = user { q.push(("user", u.to_string())); } 216 - if let Some(k) = knot { q.push(("knot", k.to_string())); } 217 - if starred { q.push(("starred", true.to_string())); } 218 - let env: Envelope = self.get_json("sh.tangled.repo.list", &q, bearer).await?; 219 - Ok(env.repos) 220 - } 221 } 222 - 223 - #[derive(Debug, Clone, Serialize, Deserialize, Default)] 224 - pub struct Repository { pub did: Option<String>, pub rkey: Option<String>, pub name: String, pub knot: Option<String>, pub description: Option<String>, pub private: bool } 225 ``` 226 227 - ### 5.2 In `tangled-cli/src/commands/auth.rs` 228 229 - ```rust 230 - use anyhow::Result; 231 - use dialoguer::{Input, Password}; 232 - use tangled_config::session::SessionManager; 233 - use crate::cli::{AuthCommand, AuthLoginArgs, Cli}; 234 235 - pub async fn run(_cli: &Cli, cmd: AuthCommand) -> Result<()> { 236 - match cmd { 237 - AuthCommand::Login(args) => login(args).await, 238 - AuthCommand::Status => status().await, 239 - AuthCommand::Logout => logout().await, 240 - } 241 - } 242 243 - async fn login(mut args: AuthLoginArgs) -> Result<()> { 244 - let handle: String = match args.handle.take() { Some(h) => h, None => Input::new().with_prompt("Handle").interact_text()? }; 245 - let password: String = match args.password.take() { Some(p) => p, None => Password::new().with_prompt("Password").interact()? }; 246 - let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string()); 247 - let client = tangled_api::TangledClient::new(&pds); 248 - let mut session = match client.login_with_password(&handle, &password, &pds).await { 249 - Ok(sess) => sess, 250 - Err(e) => { 251 - println!("\x1b[93mIf you're on your own PDS, make sure to pass the --pds flag\x1b[0m"); 252 - return Err(e); 253 - } 254 - }; 255 - SessionManager::default().save(&session)?; 256 - println!("Logged in as '{}' ({})", session.handle, session.did); 257 - Ok(()) 258 - } 259 260 - async fn status() -> Result<()> { 261 - let mgr = SessionManager::default(); 262 - match mgr.load()? { 263 - Some(s) => println!("Logged in as '{}' ({})", s.handle, s.did), 264 - None => println!("Not logged in. Run: tangled auth login"), 265 - } 266 - Ok(()) 267 - } 268 269 - async fn logout() -> Result<()> { 270 - let mgr = SessionManager::default(); 271 - if mgr.load()?.is_some() { mgr.clear()?; println!("Logged out"); } else { println!("No session found"); } 272 - Ok(()) 273 - } 274 - ``` 275 276 - ### 5.3 In `tangled-cli/src/commands/repo.rs` 277 278 - ```rust 279 - use anyhow::{anyhow, Result}; 280 - use tangled_config::session::SessionManager; 281 - use crate::cli::{Cli, RepoCommand, RepoListArgs}; 282 283 - pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> { 284 - match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) } 285 - } 286 287 - async fn list(args: RepoListArgs) -> Result<()> { 288 - let mgr = SessionManager::default(); 289 - let session = mgr.load()?.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 290 - let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into()); 291 - let client = tangled_api::TangledClient::new(base); 292 - let repos = client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?; 293 - // Simple output: table or JSON to be improved later 294 - println!("NAME\tKNOT\tPRIVATE"); 295 - for r in repos { println!("{}\t{}\t{}", r.name, r.knot.unwrap_or_default(), r.private); } 296 - Ok(()) 297 - } 298 ``` 299 300 - -------------------------------------------------------------------------------- 301 302 - ## 6) Configuration, Env Vars, and Security 303 304 - - PDS base (auth): default `https://bsky.social`. Accept CLI flag `--pds`; later read from config. 305 - - Tangled API base (repo list): default `https://tangled.org`; allow override via `TANGLED_API_BASE` env var. 306 - - Do not log passwords or tokens. 307 - - Store tokens only in keyring (already implemented). 308 309 - -------------------------------------------------------------------------------- 310 311 - ## 7) Testing Plan (MVP) 312 313 - - Client unit tests with `mockito` for `createSession` and `repo list` endpoints; simulate expected JSON. 314 - - CLI smoke tests optional for this pass. If added, use `assert_cmd` to check printed output strings. 315 - - Avoid live network calls in tests. 316 317 - -------------------------------------------------------------------------------- 318 319 - ## 8) Acceptance Criteria 320 321 - - `tangled auth login`: 322 - - Prompts or uses flags; successful call saves session and prints `Logged in as ...`. 323 - - On failure, shows HTTP status and error message, plus helpful hint about --pds flag for users on their own PDS. 324 - - `tangled auth status`: 325 - - Shows handle + did if session exists; otherwise says not logged in. 326 - - `tangled auth logout`: 327 - - Clears keyring; prints confirmation. 328 - - `tangled repo list`: 329 - - Performs authenticated GET and prints a list (even if empty) without panicking. 330 - - JSON output possible later; table output acceptable for now. 331 332 - -------------------------------------------------------------------------------- 333 334 - ## 9) Troubleshooting Notes 335 - 336 - - Keyring errors on Linux may indicate no secret service running; suggest enabling GNOME Keyring or KWallet. 337 - - If `repo list` returns 404, the method name or base URL may be wrong; adjust `sh.tangled.repo.list` or `TANGLED_API_BASE`. 338 - - If 401, session may be missing/expired; run `auth login` again. 339 - 340 - -------------------------------------------------------------------------------- 341 - 342 - ## 10) Non‑Goals for This Pass 343 344 - - Refresh token flow, device code, OAuth. 345 - - PRs, issues, knots, spindle implementation. 346 - - Advanced formatting, paging, completions. 347 - 348 - -------------------------------------------------------------------------------- 349 350 - ## 11) Future Follow‑ups 351 - 352 - - Refresh flow (`com.atproto.server.refreshSession`) and retry once on 401. 353 - - Persist base URLs and profiles in config; add `tangled config` commands. 354 - - Proper table/json formatting and shell completions. 355 - 356 - -------------------------------------------------------------------------------- 357 - 358 - ## 12) Quick Operator Commands 359 - 360 - - Build CLI: `cargo build -p tangled-cli` 361 - - Help: `cargo run -p tangled-cli -- --help` 362 - - Login: `cargo run -p tangled-cli -- auth login --handle <handle>` 363 - - Status: `cargo run -p tangled-cli -- auth status` 364 - - Repo list: `TANGLED_API_BASE=https://tangled.org cargo run -p tangled-cli -- repo list --user <handle>` 365 - 366 - -------------------------------------------------------------------------------- 367 368 - End of handoff. Implement auth login and repo list as described, keeping changes focused and testable. 369 370 371 - -------------------------------------------------------------------------------- 372 373 - ## 13) Tangled Core (../tangled-core) – Practical Notes 374 375 - This workspace often needs to peek at the Tangled monorepo to confirm XRPC endpoints and shapes. Here are concise tips and findings that informed this CLI implementation. 376 377 - ### Where To Look 378 379 - - Lexicons (authoritative NSIDs and shapes): `../tangled-core/lexicons/**` 380 - - Repo create: `../tangled-core/lexicons/repo/create.json` → `sh.tangled.repo.create` 381 - - Repo record schema: `../tangled-core/lexicons/repo/repo.json` → `sh.tangled.repo` 382 - - Misc repo queries (tree, log, tags, etc.) under `../tangled-core/lexicons/repo/` 383 - - Note: there is no `sh.tangled.repo.list` lexicon in the core right now; listing is done via ATproto records. 384 - - Knotserver XRPC routes (what requires auth vs open): `../tangled-core/knotserver/xrpc/xrpc.go` 385 - - Mutating repo ops (e.g., `sh.tangled.repo.create`) are behind ServiceAuth middleware. 386 - - Read-only repo queries (tree, log, etc.) are open. 387 - - Create repo handler (server-side flow): `../tangled-core/knotserver/xrpc/create_repo.go` 388 - - Validates ServiceAuth; expects rkey for the `sh.tangled.repo` record that already exists on the user's PDS. 389 - - ServiceAuth middleware (how Bearer is validated): `../tangled-core/xrpc/serviceauth/service_auth.go` 390 - - Validates a ServiceAuth token with Audience = `did:web:<knot-or-service-host>`. 391 - - Appview client for ServiceAuth: `../tangled-core/appview/xrpcclient/xrpc.go` (method: `ServerGetServiceAuth`). 392 393 - ### How To Search Quickly (rg examples) 394 395 - - Find a specific NSID across the repo: 396 - - `rg -n "sh\.tangled\.repo\.create" ../tangled-core` 397 - - See which endpoints are routed and whether they’re behind ServiceAuth: 398 - - `rg -n "chi\..*Get\(|chi\..*Post\(" ../tangled-core/knotserver/xrpc` 399 - - Then open `xrpc.go` and respective handlers. 400 - - Discover ServiceAuth usage and audience DID: 401 - - `rg -n "ServerGetServiceAuth|VerifyServiceAuth|serviceauth" ../tangled-core` 402 - - List lexicons by area: 403 - - `ls ../tangled-core/lexicons/repo` or `rg -n "\bid\": \"sh\.tangled\..*\"" ../tangled-core/lexicons` 404 405 - ### Repo Listing (client-side pattern) 406 407 - - There is no `sh.tangled.repo.list` in core. To list a user’s repos: 408 - 1) Resolve handle → DID if needed via PDS: `GET com.atproto.identity.resolveHandle`. 409 - 2) List records from the user’s PDS: `GET com.atproto.repo.listRecords` with `collection=sh.tangled.repo`. 410 - 3) Filter client-side (e.g., by `knot`). “Starred” filtering is not currently defined in core. 411 412 - ### Repo Creation (two-step flow) 413 414 - - Step 1 (PDS): create the `sh.tangled.repo` record in the user’s repo: 415 - - `POST com.atproto.repo.createRecord` with `{ repo: <did>, collection: "sh.tangled.repo", record: { name, knot, description?, createdAt } }`. 416 - - Extract `rkey` from the returned `uri` (`at://<did>/<collection>/<rkey>`). 417 - - Step 2 (Tangled API base): call the server to initialize the bare repo on the knot: 418 - - Obtain ServiceAuth: `GET com.atproto.server.getServiceAuth` from PDS with `aud=did:web:<tngl.sh or target-host>`. 419 - - `POST sh.tangled.repo.create` on the Tangled API base with `{ rkey, defaultBranch?, source? }` and `Authorization: Bearer <serviceAuth>`. 420 - - Server validates token via `xrpc/serviceauth`, confirms actor permissions, and creates the git repo. 421 422 - ### Base URLs, DIDs, and Defaults 423 424 - - Tangled API base (server): default is `https://tngl.sh`. Do not use the marketing/landing site. 425 - - PDS base (auth + record ops): default `https://bsky.social` unless a different PDS was chosen on login. 426 - - ServiceAuth audience DID is `did:web:<host>` where `<host>` is the Tangled API base hostname. 427 - - CLI stores the PDS URL in the session to keep the CLI stateful. 428 429 - ### Common Errors and Fixes 430 431 - - `InvalidToken` when listing repos: listing should use the PDS (`com.atproto.repo.listRecords`), not the Tangled API base. 432 - - 404 on `repo.create`: verify ServiceAuth audience matches the target host and that the rkey exists on the PDS. 433 - - Keychain issues on Linux: ensure a Secret Service (e.g., GNOME Keyring or KWallet) is running. 434 435 - ### Implementation Pointers (CLI) 436 437 - - Auth 438 - - `com.atproto.server.createSession` against the PDS, save `{accessJwt, refreshJwt, did, handle, pds}` in keyring. 439 - - List repos 440 - - Use session.handle by default; resolve to DID, then `com.atproto.repo.listRecords` on PDS. 441 - - Create repo 442 - - Build the PDS record first; then ServiceAuth → `sh.tangled.repo.create` on `tngl.sh`. 443 444 - ### Testing Hints 445 446 - - Avoid live calls; use `mockito` to stub both PDS and Tangled API base endpoints. 447 - - Unit test decoding with minimal JSON envelopes: record lists, createRecord `uri`, and repo.create (empty body or simple ack).
··· 1 + # Tangled CLI – Current Implementation Status 2 3 + This document provides an overview of the Tangled CLI implementation status for AI agents or developers working on the project. 4 5 + ## Implementation Status 6 7 + ### ✅ Fully Implemented 8 9 + #### Authentication (`auth`) 10 + - `login` - Authenticate with AT Protocol using `com.atproto.server.createSession` 11 + - `status` - Show current authentication status 12 + - `logout` - Clear stored session from keyring 13 14 + #### Repositories (`repo`) 15 + - `list` - List repositories using `com.atproto.repo.listRecords` with `collection=sh.tangled.repo` 16 + - `create` - Create repositories with two-step flow: 17 + 1. Create PDS record via `com.atproto.repo.createRecord` 18 + 2. Initialize bare repo via `sh.tangled.repo.create` with ServiceAuth 19 + - `clone` - Clone repositories using libgit2 with SSH agent support 20 + - `info` - Display repository information including stats and languages 21 + - `delete` - Delete repositories (both PDS record and knot repo) 22 + - `star` / `unstar` - Star/unstar repositories via `sh.tangled.feed.star` 23 24 + #### Issues (`issue`) 25 + - `list` - List issues via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo.issue` 26 + - `create` - Create issues via `com.atproto.repo.createRecord` 27 + - `show` - Show issue details and comments 28 + - `edit` - Edit issue title, body, or state 29 + - `comment` - Add comments to issues 30 31 + #### Pull Requests (`pr`) 32 + - `list` - List PRs via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo.pull` 33 + - `create` - Create PRs using `git format-patch` for patches 34 + - `show` - Show PR details and diff 35 + - `review` - Review PRs with approve/request-changes flags 36 + - `merge` - Merge PRs via `sh.tangled.repo.merge` with ServiceAuth 37 38 + #### Knot Management (`knot`) 39 + - `migrate` - Migrate repositories between knots 40 + - Validates working tree is clean and pushed 41 + - Creates new repo on target knot with source seeding 42 + - Updates PDS record to point to new knot 43 44 + #### Spindle CI/CD (`spindle`) 45 + - `config` - Enable/disable or configure spindle URL for a repository 46 + - Updates the `spindle` field in `sh.tangled.repo` record 47 + - `list` - List pipeline runs via `com.atproto.repo.listRecords` with `collection=sh.tangled.pipeline` 48 + - `logs` - Stream workflow logs via WebSocket (`wss://spindle.tangled.sh/spindle/logs/{knot}/{rkey}/{name}`) 49 + - `secret list` - List secrets via `sh.tangled.repo.listSecrets` with ServiceAuth 50 + - `secret add` - Add secrets via `sh.tangled.repo.addSecret` with ServiceAuth 51 + - `secret remove` - Remove secrets via `sh.tangled.repo.removeSecret` with ServiceAuth 52 53 + ### 🚧 Partially Implemented / Stubs 54 55 + #### Spindle CI/CD (`spindle`) 56 + - `run` - Manually trigger a workflow (stub) 57 + - **TODO**: Parse `.tangled.yml` to determine workflows 58 + - **TODO**: Create pipeline record and trigger spindle ingestion 59 + - **TODO**: Support manual trigger inputs 60 61 + ## Architecture Overview 62 63 + ### Workspace Structure 64 65 + - `crates/tangled-cli` - CLI binary with clap-based argument parsing 66 + - `crates/tangled-config` - Configuration and keyring-backed session management 67 + - `crates/tangled-api` - XRPC client wrapper for AT Protocol and Tangled APIs 68 + - `crates/tangled-git` - Git operation helpers (currently unused) 69 70 + ### Key Patterns 71 72 + #### ServiceAuth Flow 73 + Many Tangled API operations require ServiceAuth tokens: 74 + 1. Obtain token via `com.atproto.server.getServiceAuth` from PDS 75 + - `aud` parameter must be `did:web:<target-host>` 76 + - `exp` parameter should be Unix timestamp + 600 seconds 77 + 2. Use token as `Authorization: Bearer <serviceAuth>` for Tangled API calls 78 79 + #### Repository Creation Flow 80 + Two-step process: 81 + 1. **PDS**: Create `sh.tangled.repo` record via `com.atproto.repo.createRecord` 82 + 2. **Tangled API**: Initialize bare repo via `sh.tangled.repo.create` with ServiceAuth 83 84 + #### Repository Listing 85 + Done entirely via PDS (not Tangled API): 86 + 1. Resolve handle → DID if needed via `com.atproto.identity.resolveHandle` 87 + 2. List records via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo` 88 + 3. Filter client-side (e.g., by knot) 89 90 + #### Pull Request Merging 91 + 1. Fetch PR record to get patch and target branch 92 + 2. Obtain ServiceAuth token 93 + 3. Call `sh.tangled.repo.merge` with `{did, name, patch, branch, commitMessage, commitBody}` 94 95 + ### Base URLs and Defaults 96 97 + - **PDS Base** (auth + record operations): Default `https://bsky.social`, stored in session 98 + - **Tangled API Base** (server operations): Default `https://tngl.sh`, can override via `TANGLED_API_BASE` 99 + - **Spindle Base** (CI/CD): Default `wss://spindle.tangled.sh` for WebSocket logs, can override via `TANGLED_SPINDLE_BASE` 100 101 + ### Session Management 102 103 + Sessions are stored in the system keyring: 104 + - Linux: GNOME Keyring / KWallet via Secret Service API 105 + - macOS: macOS Keychain 106 + - Windows: Windows Credential Manager 107 108 + Session includes: 109 ```rust 110 + struct Session { 111 + access_jwt: String, 112 + refresh_jwt: String, 113 + did: String, 114 + handle: String, 115 + pds: Option<String>, // PDS base URL 116 } 117 ``` 118 119 + ## Working with tangled-core 120 121 + The `../tangled-core` repository contains the server implementation and lexicon definitions. 122 123 + ### Key Files to Check 124 125 + - **Lexicons**: `../tangled-core/lexicons/**/*.json` 126 + - Defines XRPC method schemas (NSIDs, parameters, responses) 127 + - Example: `sh.tangled.repo.create`, `sh.tangled.repo.merge` 128 129 + - **XRPC Routes**: `../tangled-core/knotserver/xrpc/xrpc.go` 130 + - Shows which endpoints require ServiceAuth 131 + - Maps NSIDs to handler functions 132 133 + - **API Handlers**: `../tangled-core/knotserver/xrpc/*.go` 134 + - Implementation details for server-side operations 135 + - Example: `create_repo.go`, `merge.go` 136 137 + ### Useful Search Commands 138 139 + ```bash 140 + # Find a specific NSID 141 + rg -n "sh\.tangled\.repo\.create" ../tangled-core 142 143 + # List all lexicons 144 + ls ../tangled-core/lexicons/repo 145 146 + # Check ServiceAuth usage 147 + rg -n "ServiceAuth|VerifyServiceAuth" ../tangled-core 148 ``` 149 150 + ## Next Steps for Contributors 151 152 + ### Priority: Implement `spindle run` 153 154 + The only remaining stub is `spindle run` for manually triggering workflows. Implementation plan: 155 156 + 1. **Parse `.tangled.yml`** in the current repository to extract workflow definitions 157 + - Look for workflow names, triggers, and manual trigger inputs 158 159 + 2. **Create pipeline record** on PDS via `com.atproto.repo.createRecord`: 160 + ```rust 161 + collection: "sh.tangled.pipeline" 162 + record: { 163 + triggerMetadata: { 164 + kind: "manual", 165 + repo: { knot, did, repo, defaultBranch }, 166 + manual: { inputs: [...] } 167 + }, 168 + workflows: [{ name, engine, clone, raw }] 169 + } 170 + ``` 171 172 + 3. **Notify spindle** (if needed) or let the ingester pick up the new record 173 174 + 4. **Support workflow selection** when multiple workflows exist: 175 + - `--workflow <name>` flag to select specific workflow 176 + - Default to first workflow if not specified 177 178 + 5. **Support manual inputs** (if workflow defines them): 179 + - Prompt for input values or accept via flags 180 181 + ### Code Quality Tasks 182 183 + - Add more comprehensive error messages for common failure cases 184 + - Improve table formatting for list commands (consider using `tabled` crate features) 185 + - Add shell completion generation (bash, zsh, fish) 186 + - Add more unit tests with `mockito` for API client methods 187 + - Add integration tests with `assert_cmd` for CLI commands 188 189 + ### Documentation Tasks 190 191 + - Add man pages for all commands 192 + - Create video tutorials for common workflows 193 + - Add troubleshooting guide for common issues 194 195 + ## Development Workflow 196 197 + ### Building 198 199 + ```sh 200 + cargo build # Debug build 201 + cargo build --release # Release build 202 + ``` 203 204 + ### Running 205 206 + ```sh 207 + cargo run -p tangled-cli -- <command> 208 + ``` 209 210 + ### Testing 211 212 + ```sh 213 + cargo test # Run all tests 214 + cargo test -- --nocapture # Show println output 215 + ``` 216 217 + ### Code Quality 218 219 + ```sh 220 + cargo fmt # Format code 221 + cargo clippy # Run linter 222 + cargo clippy -- -W clippy::all # Strict linting 223 + ``` 224 225 + ## Troubleshooting Common Issues 226 227 + ### Keyring Errors on Linux 228 229 + Ensure a secret service is running: 230 + ```sh 231 + systemctl --user enable --now gnome-keyring-daemon 232 + ``` 233 234 + ### Invalid Token Errors 235 236 + - For record operations: Use PDS client, not Tangled API client 237 + - For server operations: Ensure ServiceAuth audience DID matches target host 238 239 + ### Repository Not Found 240 241 + - Verify repo exists: `tangled repo info owner/name` 242 + - Check you're using the correct owner (handle or DID) 243 + - Ensure you have access permissions 244 245 + ### WebSocket Connection Failures 246 247 + - Check spindle base URL is correct (default: `wss://spindle.tangled.sh`) 248 + - Verify the job_id format: `knot:rkey:name` 249 + - Ensure the workflow has actually run and has logs 250 251 + ## Additional Resources 252 253 + - Main README: `README.md` - User-facing documentation 254 + - Getting Started Guide: `docs/getting-started.md` - Tutorial for new users 255 + - Lexicons: `../tangled-core/lexicons/` - XRPC method definitions 256 + - Server Implementation: `../tangled-core/knotserver/` - Server-side code 257 258 + --- 259 260 + Last updated: 2025-10-14
+154 -4
Cargo.lock
··· 204 checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 205 206 [[package]] 207 name = "bumpalo" 208 version = "3.19.0" 209 source = "registry+https://github.com/rust-lang/crates.io-index" 210 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 211 212 [[package]] 213 name = "bytes" ··· 388 ] 389 390 [[package]] 391 name = "crc32fast" 392 version = "1.5.0" 393 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 397 ] 398 399 [[package]] 400 name = "data-encoding" 401 version = "2.9.0" 402 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 455 "tempfile", 456 "thiserror 1.0.69", 457 "zeroize", 458 ] 459 460 [[package]] ··· 631 "pin-project-lite", 632 "pin-utils", 633 "slab", 634 ] 635 636 [[package]] ··· 1472 "bytes", 1473 "getrandom 0.3.3", 1474 "lru-slab", 1475 - "rand", 1476 "ring", 1477 "rustc-hash", 1478 "rustls", ··· 1515 1516 [[package]] 1517 name = "rand" 1518 version = "0.9.2" 1519 source = "registry+https://github.com/rust-lang/crates.io-index" 1520 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1521 dependencies = [ 1522 - "rand_chacha", 1523 - "rand_core", 1524 ] 1525 1526 [[package]] ··· 1530 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1531 dependencies = [ 1532 "ppv-lite86", 1533 - "rand_core", 1534 ] 1535 1536 [[package]] ··· 1851 ] 1852 1853 [[package]] 1854 name = "shell-words" 1855 version = "1.1.0" 1856 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1994 version = "0.1.0" 1995 dependencies = [ 1996 "anyhow", 1997 "clap", 1998 "colored", 1999 "dialoguer", 2000 "git2", 2001 "indicatif", 2002 "serde", ··· 2005 "tangled-config", 2006 "tangled-git", 2007 "tokio", 2008 "url", 2009 ] 2010 ··· 2169 ] 2170 2171 [[package]] 2172 name = "tokio-util" 2173 version = "0.7.16" 2174 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2304 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2305 2306 [[package]] 2307 name = "unicase" 2308 version = "2.8.1" 2309 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2346 ] 2347 2348 [[package]] 2349 name = "utf8_iter" 2350 version = "1.0.4" 2351 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2362 version = "0.2.15" 2363 source = "registry+https://github.com/rust-lang/crates.io-index" 2364 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2365 2366 [[package]] 2367 name = "want"
··· 204 checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 205 206 [[package]] 207 + name = "block-buffer" 208 + version = "0.10.4" 209 + source = "registry+https://github.com/rust-lang/crates.io-index" 210 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 211 + dependencies = [ 212 + "generic-array", 213 + ] 214 + 215 + [[package]] 216 name = "bumpalo" 217 version = "3.19.0" 218 source = "registry+https://github.com/rust-lang/crates.io-index" 219 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 220 + 221 + [[package]] 222 + name = "byteorder" 223 + version = "1.5.0" 224 + source = "registry+https://github.com/rust-lang/crates.io-index" 225 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 226 227 [[package]] 228 name = "bytes" ··· 403 ] 404 405 [[package]] 406 + name = "cpufeatures" 407 + version = "0.2.17" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 410 + dependencies = [ 411 + "libc", 412 + ] 413 + 414 + [[package]] 415 name = "crc32fast" 416 version = "1.5.0" 417 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 421 ] 422 423 [[package]] 424 + name = "crypto-common" 425 + version = "0.1.6" 426 + source = "registry+https://github.com/rust-lang/crates.io-index" 427 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 428 + dependencies = [ 429 + "generic-array", 430 + "typenum", 431 + ] 432 + 433 + [[package]] 434 name = "data-encoding" 435 version = "2.9.0" 436 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 489 "tempfile", 490 "thiserror 1.0.69", 491 "zeroize", 492 + ] 493 + 494 + [[package]] 495 + name = "digest" 496 + version = "0.10.7" 497 + source = "registry+https://github.com/rust-lang/crates.io-index" 498 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 499 + dependencies = [ 500 + "block-buffer", 501 + "crypto-common", 502 ] 503 504 [[package]] ··· 675 "pin-project-lite", 676 "pin-utils", 677 "slab", 678 + ] 679 + 680 + [[package]] 681 + name = "generic-array" 682 + version = "0.14.9" 683 + source = "registry+https://github.com/rust-lang/crates.io-index" 684 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 685 + dependencies = [ 686 + "typenum", 687 + "version_check", 688 ] 689 690 [[package]] ··· 1526 "bytes", 1527 "getrandom 0.3.3", 1528 "lru-slab", 1529 + "rand 0.9.2", 1530 "ring", 1531 "rustc-hash", 1532 "rustls", ··· 1569 1570 [[package]] 1571 name = "rand" 1572 + version = "0.8.5" 1573 + source = "registry+https://github.com/rust-lang/crates.io-index" 1574 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1575 + dependencies = [ 1576 + "libc", 1577 + "rand_chacha 0.3.1", 1578 + "rand_core 0.6.4", 1579 + ] 1580 + 1581 + [[package]] 1582 + name = "rand" 1583 version = "0.9.2" 1584 source = "registry+https://github.com/rust-lang/crates.io-index" 1585 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1586 dependencies = [ 1587 + "rand_chacha 0.9.0", 1588 + "rand_core 0.9.3", 1589 + ] 1590 + 1591 + [[package]] 1592 + name = "rand_chacha" 1593 + version = "0.3.1" 1594 + source = "registry+https://github.com/rust-lang/crates.io-index" 1595 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1596 + dependencies = [ 1597 + "ppv-lite86", 1598 + "rand_core 0.6.4", 1599 ] 1600 1601 [[package]] ··· 1605 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 1606 dependencies = [ 1607 "ppv-lite86", 1608 + "rand_core 0.9.3", 1609 + ] 1610 + 1611 + [[package]] 1612 + name = "rand_core" 1613 + version = "0.6.4" 1614 + source = "registry+https://github.com/rust-lang/crates.io-index" 1615 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1616 + dependencies = [ 1617 + "getrandom 0.2.16", 1618 ] 1619 1620 [[package]] ··· 1935 ] 1936 1937 [[package]] 1938 + name = "sha1" 1939 + version = "0.10.6" 1940 + source = "registry+https://github.com/rust-lang/crates.io-index" 1941 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 1942 + dependencies = [ 1943 + "cfg-if", 1944 + "cpufeatures", 1945 + "digest", 1946 + ] 1947 + 1948 + [[package]] 1949 name = "shell-words" 1950 version = "1.1.0" 1951 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2089 version = "0.1.0" 2090 dependencies = [ 2091 "anyhow", 2092 + "chrono", 2093 "clap", 2094 "colored", 2095 "dialoguer", 2096 + "futures-util", 2097 "git2", 2098 "indicatif", 2099 "serde", ··· 2102 "tangled-config", 2103 "tangled-git", 2104 "tokio", 2105 + "tokio-tungstenite", 2106 "url", 2107 ] 2108 ··· 2267 ] 2268 2269 [[package]] 2270 + name = "tokio-tungstenite" 2271 + version = "0.21.0" 2272 + source = "registry+https://github.com/rust-lang/crates.io-index" 2273 + checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" 2274 + dependencies = [ 2275 + "futures-util", 2276 + "log", 2277 + "native-tls", 2278 + "tokio", 2279 + "tokio-native-tls", 2280 + "tungstenite", 2281 + ] 2282 + 2283 + [[package]] 2284 name = "tokio-util" 2285 version = "0.7.16" 2286 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2416 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2417 2418 [[package]] 2419 + name = "tungstenite" 2420 + version = "0.21.0" 2421 + source = "registry+https://github.com/rust-lang/crates.io-index" 2422 + checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" 2423 + dependencies = [ 2424 + "byteorder", 2425 + "bytes", 2426 + "data-encoding", 2427 + "http", 2428 + "httparse", 2429 + "log", 2430 + "native-tls", 2431 + "rand 0.8.5", 2432 + "sha1", 2433 + "thiserror 1.0.69", 2434 + "url", 2435 + "utf-8", 2436 + ] 2437 + 2438 + [[package]] 2439 + name = "typenum" 2440 + version = "1.19.0" 2441 + source = "registry+https://github.com/rust-lang/crates.io-index" 2442 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2443 + 2444 + [[package]] 2445 name = "unicase" 2446 version = "2.8.1" 2447 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2484 ] 2485 2486 [[package]] 2487 + name = "utf-8" 2488 + version = "0.7.6" 2489 + source = "registry+https://github.com/rust-lang/crates.io-index" 2490 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2491 + 2492 + [[package]] 2493 name = "utf8_iter" 2494 version = "1.0.4" 2495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2506 version = "0.2.15" 2507 source = "registry+https://github.com/rust-lang/crates.io-index" 2508 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 2509 + 2510 + [[package]] 2511 + name = "version_check" 2512 + version = "0.9.5" 2513 + source = "registry+https://github.com/rust-lang/crates.io-index" 2514 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2515 2516 [[package]] 2517 name = "want"
+4
Cargo.toml
··· 53 base64 = "0.22" 54 regex = "1.10" 55 56 # Testing 57 mockito = "1.4" 58 tempfile = "3.10"
··· 53 base64 = "0.22" 54 regex = "1.10" 55 56 + # WebSocket 57 + tokio-tungstenite = { version = "0.21", features = ["native-tls"] } 58 + futures-util = "0.3" 59 + 60 # Testing 61 mockito = "1.4" 62 tempfile = "3.10"
+168 -17
README.md
··· 1 - # Tangled CLI (Rust) 2 3 A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol. 4 5 - Status: project scaffold with CLI, config, API and git crates. Commands are stubs pending endpoint wiring. 6 7 - ## Workspace 8 9 - - `crates/tangled-cli`: CLI binary (clap-based) 10 - - `crates/tangled-config`: Config + session management 11 - - `crates/tangled-api`: XRPC client wrapper (stubs) 12 - - `crates/tangled-git`: Git helpers (stubs) 13 - - `lexicons/sh.tangled`: Placeholder lexicons 14 15 - ## Quick start 16 17 ```sh 18 - cargo run -p tangled-cli -- --help 19 ``` 20 21 - ### Install from AUR (community maintained) 22 23 ```sh 24 yay -S tangled-cli-git 25 ``` 26 27 - Building requires network to fetch crates. 28 29 - ## Next steps 30 31 - - Implement `com.atproto.server.createSession` for auth 32 - - Wire repo list/create endpoints under `sh.tangled.*` 33 - - Persist sessions via keyring and load in CLI 34 - - Add output formatting (table/json) 35
··· 1 + # Tangled CLI 2 3 A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol. 4 5 + ## Features 6 + 7 + Tangled CLI is a fully functional tool for managing repositories, issues, pull requests, and CI/CD workflows on the Tangled platform. 8 + 9 + ### Implemented Commands 10 + 11 + - **Authentication** (`auth`) 12 + - `login` - Authenticate with AT Protocol credentials 13 + - `status` - Show current authentication status 14 + - `logout` - Clear stored session 15 + 16 + - **Repositories** (`repo`) 17 + - `list` - List your repositories or another user's repos 18 + - `create` - Create a new repository on a knot 19 + - `clone` - Clone a repository to your local machine 20 + - `info` - Show detailed repository information 21 + - `delete` - Delete a repository 22 + - `star` / `unstar` - Star or unstar repositories 23 + 24 + - **Issues** (`issue`) 25 + - `list` - List issues for a repository 26 + - `create` - Create a new issue 27 + - `show` - Show issue details and comments 28 + - `edit` - Edit issue title, body, or state 29 + - `comment` - Add a comment to an issue 30 + 31 + - **Pull Requests** (`pr`) 32 + - `list` - List pull requests for a repository 33 + - `create` - Create a pull request from a branch 34 + - `show` - Show pull request details and diff 35 + - `review` - Review a pull request (approve/request changes) 36 + - `merge` - Merge a pull request 37 + 38 + - **Knot Management** (`knot`) 39 + - `migrate` - Migrate a repository to another knot 40 + 41 + - **CI/CD with Spindle** (`spindle`) 42 + - `config` - Enable/disable or configure spindle for a repository 43 + - `list` - List pipeline runs for a repository 44 + - `logs` - Stream logs from a workflow execution 45 + - `secret` - Manage secrets for CI/CD workflows 46 + - `list` - List secrets for a repository 47 + - `add` - Add or update a secret 48 + - `remove` - Remove a secret 49 + - `run` - Manually trigger a workflow (not yet implemented) 50 51 + ## Installation 52 53 + ### Build from Source 54 55 + Requires Rust toolchain (1.70+) and network access to fetch dependencies. 56 57 ```sh 58 + cargo build --release 59 ``` 60 61 + The binary will be available at `target/release/tangled-cli`. 62 + 63 + ### Install from AUR (Arch Linux) 64 + 65 + Community-maintained package: 66 67 ```sh 68 yay -S tangled-cli-git 69 ``` 70 71 + ## Quick Start 72 + 73 + 1. **Login to Tangled**: 74 + ```sh 75 + tangled auth login --handle your.handle.bsky.social 76 + ``` 77 + 78 + 2. **List your repositories**: 79 + ```sh 80 + tangled repo list 81 + ``` 82 + 83 + 3. **Create a new repository**: 84 + ```sh 85 + tangled repo create myproject --description "My cool project" 86 + ``` 87 + 88 + 4. **Clone a repository**: 89 + ```sh 90 + tangled repo clone username/reponame 91 + ``` 92 + 93 + ## Workspace Structure 94 + 95 + - `crates/tangled-cli` - CLI binary with clap-based argument parsing 96 + - `crates/tangled-config` - Configuration and session management (keyring-backed) 97 + - `crates/tangled-api` - XRPC client wrapper for AT Protocol and Tangled APIs 98 + - `crates/tangled-git` - Git operation helpers 99 + 100 + ## Configuration 101 + 102 + The CLI stores session credentials securely in your system keyring and configuration in: 103 + - Linux: `~/.config/tangled/config.toml` 104 + - macOS: `~/Library/Application Support/tangled/config.toml` 105 + - Windows: `%APPDATA%\tangled\config.toml` 106 + 107 + ### Environment Variables 108 + 109 + - `TANGLED_PDS_BASE` - Override the PDS base URL (default: `https://bsky.social`) 110 + - `TANGLED_API_BASE` - Override the Tangled API base URL (default: `https://tngl.sh`) 111 + - `TANGLED_SPINDLE_BASE` - Override the Spindle base URL (default: `wss://spindle.tangled.sh`) 112 113 + ## Examples 114 + 115 + ### Working with Issues 116 117 + ```sh 118 + # Create an issue 119 + tangled issue create --repo myrepo --title "Bug: Fix login" --body "Description here" 120 + 121 + # List issues 122 + tangled issue list --repo myrepo 123 + 124 + # Comment on an issue 125 + tangled issue comment <issue-id> --body "I'll fix this" 126 + ``` 127 + 128 + ### Working with Pull Requests 129 + 130 + ```sh 131 + # Create a PR from a branch 132 + tangled pr create --repo myrepo --base main --head feature-branch --title "Add new feature" 133 + 134 + # Review a PR 135 + tangled pr review <pr-id> --approve --comment "LGTM!" 136 + 137 + # Merge a PR 138 + tangled pr merge <pr-id> 139 + ``` 140 141 + ### CI/CD with Spindle 142 + 143 + ```sh 144 + # Enable spindle for your repo 145 + tangled spindle config --repo myrepo --enable 146 + 147 + # List pipeline runs 148 + tangled spindle list --repo myrepo 149 + 150 + # Stream logs from a workflow 151 + tangled spindle logs knot:rkey:workflow-name --follow 152 + 153 + # Manage secrets 154 + tangled spindle secret add --repo myrepo --key API_KEY --value "secret-value" 155 + tangled spindle secret list --repo myrepo 156 + ``` 157 + 158 + ## Development 159 + 160 + Run tests: 161 + ```sh 162 + cargo test 163 + ``` 164 + 165 + Run with debug output: 166 + ```sh 167 + cargo run -p tangled-cli -- --verbose <command> 168 + ``` 169 + 170 + Format code: 171 + ```sh 172 + cargo fmt 173 + ``` 174 + 175 + Check for issues: 176 + ```sh 177 + cargo clippy 178 + ``` 179 + 180 + ## Contributing 181 + 182 + Contributions are welcome! Please feel free to submit issues or pull requests. 183 + 184 + ## License 185 + 186 + MIT OR Apache-2.0
+36
TODO.md
···
··· 1 + # TODO - Tech Debt 2 + 3 + ## Pull Request Support 4 + 5 + ### Branch-Based PR Merge 6 + - [ ] Implement branch-based PR merge support in CLI 7 + - **Issue**: Currently only patch-based PRs can be merged via `tangled pr merge` 8 + - **Location**: `crates/tangled-api/src/client.rs:1250-1253` 9 + - **Current behavior**: Returns error: "Cannot merge branch-based PR via CLI. Please use the web interface." 10 + - **Required**: Add support for merging PRs that have a `source` field with SHA/branch info instead of a `patch` field 11 + - **Related**: Server-side merge API may need updates to support branch merges 12 + 13 + ### PR Comments Display 14 + - [ ] Implement `--comments` flag functionality in `pr show` command 15 + - **Issue**: Flag is defined but not implemented 16 + - **Location**: `crates/tangled-cli/src/commands/pr.rs:145-180` 17 + - **Current behavior**: `tangled pr show <id> --comments` doesn't display any comments 18 + - **Required**: 19 + - Fetch comments from the API 20 + - Display comment author, timestamp, and content 21 + - Handle threaded/nested comments if supported 22 + - **API**: Need to determine correct endpoint for fetching PR comments 23 + 24 + ### PR Format Compatibility 25 + - [x] Support both patch-based and branch-based PR formats 26 + - **Completed**: Added `PullSource` struct and made `patch` field optional 27 + - **Location**: `crates/tangled-api/src/client.rs:1392-1413` 28 + - **Details**: PRs can now have either: 29 + - `patch: String` (legacy format) 30 + - `source: { sha, repo?, branch? }` (new format) 31 + 32 + ## Related Issues 33 + 34 + - Consider adding `--format json` output for programmatic access to PR data 35 + - Add better error messages when operations aren't supported for certain PR types 36 + - Document the differences between patch-based and branch-based PRs in user docs
+315 -19
crates/tangled-api/src/client.rs
··· 23 } 24 25 fn xrpc_url(&self, method: &str) -> String { 26 - format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method) 27 } 28 29 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( ··· 49 Ok(res.json::<TRes>().await?) 50 } 51 52 - async fn get_json<TRes: DeserializeOwned>( 53 &self, 54 method: &str, 55 params: &[(&str, String)], ··· 119 }) 120 } 121 122 pub async fn list_repos( 123 &self, 124 user: Option<&str>, ··· 264 struct GetSARes { 265 token: String, 266 } 267 let params = [ 268 ("aud", audience), 269 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 270 ]; 271 let sa: GetSARes = pds_client 272 .get_json( ··· 345 rkey, 346 knot, 347 description: item.value.description, 348 }); 349 } 350 } ··· 389 struct GetSARes { 390 token: String, 391 } 392 let params = [ 393 ("aud", audience), 394 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 395 ]; 396 let sa: GetSARes = pds_client 397 .get_json( ··· 922 Ok(out) 923 } 924 925 #[allow(clippy::too_many_arguments)] 926 pub async fn create_pull( 927 &self, ··· 1025 key, 1026 value, 1027 }; 1028 - let _: serde_json::Value = self 1029 - .post_json("sh.tangled.repo.addSecret", &body, Some(&sa)) 1030 - .await?; 1031 - Ok(()) 1032 } 1033 1034 pub async fn remove_repo_secret( ··· 1045 key: &'a str, 1046 } 1047 let body = Req { repo: repo_at, key }; 1048 - let _: serde_json::Value = self 1049 - .post_json("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1050 - .await?; 1051 - Ok(()) 1052 } 1053 1054 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1055 - let host = self 1056 - .base_url 1057 - .trim_end_matches('/') 1058 .strip_prefix("https://") 1059 - .or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://")) 1060 - .ok_or_else(|| anyhow!("invalid base_url"))?; 1061 let audience = format!("did:web:{}", host); 1062 #[derive(Deserialize)] 1063 struct GetSARes { 1064 token: String, 1065 } 1066 let pds = TangledClient::new(pds_base); 1067 let params = [ 1068 ("aud", audience), 1069 - ("exp", (chrono::Utc::now().timestamp() + 600).to_string()), 1070 ]; 1071 let sa: GetSARes = pds 1072 .get_json( ··· 1122 .await?; 1123 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1124 } 1125 } 1126 1127 #[derive(Debug, Clone, Serialize, Deserialize, Default)] ··· 1131 pub name: String, 1132 pub knot: Option<String>, 1133 pub description: Option<String>, 1134 #[serde(default)] 1135 pub private: bool, 1136 } ··· 1161 } 1162 1163 #[derive(Debug, Clone, Serialize, Deserialize)] 1164 pub struct Pull { 1165 pub target: PullTarget, 1166 pub title: String, 1167 #[serde(default)] 1168 pub body: String, 1169 - pub patch: String, 1170 #[serde(rename = "createdAt")] 1171 pub created_at: String, 1172 } ··· 1178 pub pull: Pull, 1179 } 1180 1181 #[derive(Debug, Clone)] 1182 pub struct RepoRecord { 1183 pub did: String, ··· 1185 pub rkey: String, 1186 pub knot: String, 1187 pub description: Option<String>, 1188 } 1189 1190 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 1242 pub pds_base: &'a str, 1243 pub access_jwt: &'a str, 1244 }
··· 23 } 24 25 fn xrpc_url(&self, method: &str) -> String { 26 + let base = self.base_url.trim_end_matches('/'); 27 + // Add https:// if no protocol is present 28 + let base_with_protocol = if base.starts_with("http://") || base.starts_with("https://") { 29 + base.to_string() 30 + } else { 31 + format!("https://{}", base) 32 + }; 33 + format!("{}/xrpc/{}", base_with_protocol, method) 34 } 35 36 async fn post_json<TReq: Serialize, TRes: DeserializeOwned>( ··· 56 Ok(res.json::<TRes>().await?) 57 } 58 59 + async fn post<TReq: Serialize>( 60 + &self, 61 + method: &str, 62 + req: &TReq, 63 + bearer: Option<&str>, 64 + ) -> Result<()> { 65 + let url = self.xrpc_url(method); 66 + let client = reqwest::Client::new(); 67 + let mut reqb = client 68 + .post(url) 69 + .header(reqwest::header::CONTENT_TYPE, "application/json"); 70 + if let Some(token) = bearer { 71 + reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); 72 + } 73 + let res = reqb.json(req).send().await?; 74 + let status = res.status(); 75 + if !status.is_success() { 76 + let body = res.text().await.unwrap_or_default(); 77 + return Err(anyhow!("{}: {}", status, body)); 78 + } 79 + Ok(()) 80 + } 81 + 82 + pub async fn get_json<TRes: DeserializeOwned>( 83 &self, 84 method: &str, 85 params: &[(&str, String)], ··· 149 }) 150 } 151 152 + pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<Session> { 153 + #[derive(Deserialize)] 154 + struct Res { 155 + #[serde(rename = "accessJwt")] 156 + access_jwt: String, 157 + #[serde(rename = "refreshJwt")] 158 + refresh_jwt: String, 159 + did: String, 160 + handle: String, 161 + } 162 + let url = self.xrpc_url("com.atproto.server.refreshSession"); 163 + let client = reqwest::Client::new(); 164 + let res = client 165 + .post(url) 166 + .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", refresh_jwt)) 167 + .send() 168 + .await?; 169 + let status = res.status(); 170 + if !status.is_success() { 171 + let body = res.text().await.unwrap_or_default(); 172 + return Err(anyhow!("{}: {}", status, body)); 173 + } 174 + let res_data: Res = res.json().await?; 175 + Ok(Session { 176 + access_jwt: res_data.access_jwt, 177 + refresh_jwt: res_data.refresh_jwt, 178 + did: res_data.did, 179 + handle: res_data.handle, 180 + ..Default::default() 181 + }) 182 + } 183 + 184 pub async fn list_repos( 185 &self, 186 user: Option<&str>, ··· 326 struct GetSARes { 327 token: String, 328 } 329 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 330 let params = [ 331 ("aud", audience), 332 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 333 ]; 334 let sa: GetSARes = pds_client 335 .get_json( ··· 408 rkey, 409 knot, 410 description: item.value.description, 411 + spindle: item.value.spindle, 412 }); 413 } 414 } ··· 453 struct GetSARes { 454 token: String, 455 } 456 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 457 let params = [ 458 ("aud", audience), 459 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 460 ]; 461 let sa: GetSARes = pds_client 462 .get_json( ··· 987 Ok(out) 988 } 989 990 + pub async fn list_repo_pulls( 991 + &self, 992 + repo_at: &str, 993 + state: Option<&str>, 994 + pds_base: &str, 995 + access_jwt: &str, 996 + ) -> Result<Vec<RepoPull>> { 997 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 998 + 999 + #[derive(Deserialize)] 1000 + struct Res { 1001 + pulls: Vec<RepoPull>, 1002 + } 1003 + 1004 + let mut params = vec![("repo", repo_at.to_string())]; 1005 + if let Some(s) = state { 1006 + params.push(("state", s.to_string())); 1007 + } 1008 + 1009 + let res: Res = self 1010 + .get_json("sh.tangled.repo.listPulls", &params, Some(&sa)) 1011 + .await?; 1012 + Ok(res.pulls) 1013 + } 1014 + 1015 #[allow(clippy::too_many_arguments)] 1016 pub async fn create_pull( 1017 &self, ··· 1115 key, 1116 value, 1117 }; 1118 + self.post("sh.tangled.repo.addSecret", &body, Some(&sa)) 1119 + .await 1120 } 1121 1122 pub async fn remove_repo_secret( ··· 1133 key: &'a str, 1134 } 1135 let body = Req { repo: repo_at, key }; 1136 + self.post("sh.tangled.repo.removeSecret", &body, Some(&sa)) 1137 + .await 1138 } 1139 1140 async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> { 1141 + let base_trimmed = self.base_url.trim_end_matches('/'); 1142 + let host = base_trimmed 1143 .strip_prefix("https://") 1144 + .or_else(|| base_trimmed.strip_prefix("http://")) 1145 + .unwrap_or(base_trimmed); // If no protocol, use the URL as-is 1146 let audience = format!("did:web:{}", host); 1147 #[derive(Deserialize)] 1148 struct GetSARes { 1149 token: String, 1150 } 1151 let pds = TangledClient::new(pds_base); 1152 + // Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec 1153 let params = [ 1154 ("aud", audience), 1155 + ("exp", (chrono::Utc::now().timestamp() + 60).to_string()), 1156 ]; 1157 let sa: GetSARes = pds 1158 .get_json( ··· 1208 .await?; 1209 Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri")) 1210 } 1211 + 1212 + pub async fn merge_pull( 1213 + &self, 1214 + pull_did: &str, 1215 + pull_rkey: &str, 1216 + repo_did: &str, 1217 + repo_name: &str, 1218 + pds_base: &str, 1219 + access_jwt: &str, 1220 + ) -> Result<()> { 1221 + // Fetch the pull request to get patch and target branch 1222 + let pds_client = TangledClient::new(pds_base); 1223 + let pull = pds_client 1224 + .get_pull_record(pull_did, pull_rkey, Some(access_jwt)) 1225 + .await?; 1226 + 1227 + // Get service auth token for the knot 1228 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1229 + 1230 + #[derive(Serialize)] 1231 + struct MergeReq<'a> { 1232 + did: &'a str, 1233 + name: &'a str, 1234 + patch: &'a str, 1235 + branch: &'a str, 1236 + #[serde(skip_serializing_if = "Option::is_none")] 1237 + #[serde(rename = "commitMessage")] 1238 + commit_message: Option<&'a str>, 1239 + #[serde(skip_serializing_if = "Option::is_none")] 1240 + #[serde(rename = "commitBody")] 1241 + commit_body: Option<&'a str>, 1242 + } 1243 + 1244 + let commit_body = if pull.body.is_empty() { 1245 + None 1246 + } else { 1247 + Some(pull.body.as_str()) 1248 + }; 1249 + 1250 + // For now, only patch-based PRs can be merged via CLI 1251 + // Branch-based PRs need to be merged via the web interface 1252 + let patch_str = pull.patch.as_deref() 1253 + .ok_or_else(|| anyhow!("Cannot merge branch-based PR via CLI. Please use the web interface."))?; 1254 + 1255 + let req = MergeReq { 1256 + did: repo_did, 1257 + name: repo_name, 1258 + patch: patch_str, 1259 + branch: &pull.target.branch, 1260 + commit_message: Some(&pull.title), 1261 + commit_body, 1262 + }; 1263 + 1264 + let _: serde_json::Value = self 1265 + .post_json("sh.tangled.repo.merge", &req, Some(&sa)) 1266 + .await?; 1267 + Ok(()) 1268 + } 1269 + 1270 + pub async fn update_repo_spindle( 1271 + &self, 1272 + did: &str, 1273 + rkey: &str, 1274 + new_spindle: Option<&str>, 1275 + pds_base: &str, 1276 + access_jwt: &str, 1277 + ) -> Result<()> { 1278 + let pds_client = TangledClient::new(pds_base); 1279 + #[derive(Deserialize, Serialize, Clone)] 1280 + struct Rec { 1281 + name: String, 1282 + knot: String, 1283 + #[serde(skip_serializing_if = "Option::is_none")] 1284 + description: Option<String>, 1285 + #[serde(skip_serializing_if = "Option::is_none")] 1286 + spindle: Option<String>, 1287 + #[serde(rename = "createdAt")] 1288 + created_at: String, 1289 + } 1290 + #[derive(Deserialize)] 1291 + struct GetRes { 1292 + value: Rec, 1293 + } 1294 + let params = [ 1295 + ("repo", did.to_string()), 1296 + ("collection", "sh.tangled.repo".to_string()), 1297 + ("rkey", rkey.to_string()), 1298 + ]; 1299 + let got: GetRes = pds_client 1300 + .get_json("com.atproto.repo.getRecord", &params, Some(access_jwt)) 1301 + .await?; 1302 + let mut rec = got.value; 1303 + rec.spindle = new_spindle.map(|s| s.to_string()); 1304 + #[derive(Serialize)] 1305 + struct PutReq<'a> { 1306 + repo: &'a str, 1307 + collection: &'a str, 1308 + rkey: &'a str, 1309 + validate: bool, 1310 + record: Rec, 1311 + } 1312 + let req = PutReq { 1313 + repo: did, 1314 + collection: "sh.tangled.repo", 1315 + rkey, 1316 + validate: true, 1317 + record: rec, 1318 + }; 1319 + let _: serde_json::Value = pds_client 1320 + .post_json("com.atproto.repo.putRecord", &req, Some(access_jwt)) 1321 + .await?; 1322 + Ok(()) 1323 + } 1324 + 1325 + pub async fn list_pipelines( 1326 + &self, 1327 + repo_did: &str, 1328 + bearer: Option<&str>, 1329 + ) -> Result<Vec<PipelineRecord>> { 1330 + #[derive(Deserialize)] 1331 + struct Item { 1332 + uri: String, 1333 + value: Pipeline, 1334 + } 1335 + #[derive(Deserialize)] 1336 + struct ListRes { 1337 + #[serde(default)] 1338 + records: Vec<Item>, 1339 + } 1340 + let params = vec![ 1341 + ("repo", repo_did.to_string()), 1342 + ("collection", "sh.tangled.pipeline".to_string()), 1343 + ("limit", "100".to_string()), 1344 + ]; 1345 + let res: ListRes = self 1346 + .get_json("com.atproto.repo.listRecords", &params, bearer) 1347 + .await?; 1348 + let mut out = vec![]; 1349 + for it in res.records { 1350 + let rkey = Self::uri_rkey(&it.uri).unwrap_or_default(); 1351 + out.push(PipelineRecord { 1352 + rkey, 1353 + pipeline: it.value, 1354 + }); 1355 + } 1356 + Ok(out) 1357 + } 1358 } 1359 1360 #[derive(Debug, Clone, Serialize, Deserialize, Default)] ··· 1364 pub name: String, 1365 pub knot: Option<String>, 1366 pub description: Option<String>, 1367 + pub spindle: Option<String>, 1368 #[serde(default)] 1369 pub private: bool, 1370 } ··· 1395 } 1396 1397 #[derive(Debug, Clone, Serialize, Deserialize)] 1398 + pub struct PullSource { 1399 + pub sha: String, 1400 + #[serde(default)] 1401 + pub repo: Option<String>, 1402 + #[serde(default)] 1403 + pub branch: Option<String>, 1404 + } 1405 + 1406 + #[derive(Debug, Clone, Serialize, Deserialize)] 1407 pub struct Pull { 1408 pub target: PullTarget, 1409 pub title: String, 1410 #[serde(default)] 1411 pub body: String, 1412 + #[serde(default)] 1413 + pub patch: Option<String>, 1414 + #[serde(default)] 1415 + pub source: Option<PullSource>, 1416 #[serde(rename = "createdAt")] 1417 pub created_at: String, 1418 } ··· 1424 pub pull: Pull, 1425 } 1426 1427 + #[derive(Debug, Clone, Deserialize)] 1428 + pub struct RepoPull { 1429 + pub rkey: String, 1430 + #[serde(rename = "ownerDid")] 1431 + pub owner_did: String, 1432 + #[serde(rename = "pullId")] 1433 + pub pull_id: i32, 1434 + pub title: String, 1435 + pub state: i32, 1436 + #[serde(rename = "targetBranch")] 1437 + pub target_branch: String, 1438 + #[serde(rename = "createdAt")] 1439 + pub created_at: String, 1440 + } 1441 + 1442 #[derive(Debug, Clone)] 1443 pub struct RepoRecord { 1444 pub did: String, ··· 1446 pub rkey: String, 1447 pub knot: String, 1448 pub description: Option<String>, 1449 + pub spindle: Option<String>, 1450 } 1451 1452 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 1504 pub pds_base: &'a str, 1505 pub access_jwt: &'a str, 1506 } 1507 + 1508 + #[derive(Debug, Clone, Serialize, Deserialize)] 1509 + pub struct TriggerMetadata { 1510 + pub kind: String, 1511 + pub repo: TriggerRepo, 1512 + } 1513 + 1514 + #[derive(Debug, Clone, Serialize, Deserialize)] 1515 + pub struct TriggerRepo { 1516 + pub knot: String, 1517 + pub did: String, 1518 + pub repo: String, 1519 + #[serde(rename = "defaultBranch")] 1520 + pub default_branch: String, 1521 + } 1522 + 1523 + #[derive(Debug, Clone, Serialize, Deserialize)] 1524 + pub struct Workflow { 1525 + pub name: String, 1526 + pub engine: String, 1527 + } 1528 + 1529 + #[derive(Debug, Clone, Serialize, Deserialize)] 1530 + pub struct Pipeline { 1531 + #[serde(rename = "triggerMetadata")] 1532 + pub trigger_metadata: TriggerMetadata, 1533 + pub workflows: Vec<Workflow>, 1534 + } 1535 + 1536 + #[derive(Debug, Clone)] 1537 + pub struct PipelineRecord { 1538 + pub rkey: String, 1539 + pub pipeline: Pipeline, 1540 + }
+3
crates/tangled-cli/Cargo.toml
··· 16 tokio = { workspace = true, features = ["full"] } 17 git2 = { workspace = true } 18 url = { workspace = true } 19 20 # Internal crates 21 tangled-config = { path = "../tangled-config" }
··· 16 tokio = { workspace = true, features = ["full"] } 17 git2 = { workspace = true } 18 url = { workspace = true } 19 + tokio-tungstenite = { workspace = true } 20 + futures-util = { workspace = true } 21 + chrono = { workspace = true } 22 23 # Internal crates 24 tangled-config = { path = "../tangled-config" }
+1 -41
crates/tangled-cli/src/cli.rs
··· 284 #[derive(Args, Debug, Clone)] 285 pub struct PrMergeArgs { 286 pub id: String, 287 - #[arg(long, default_value_t = false)] 288 - pub squash: bool, 289 - #[arg(long, default_value_t = false)] 290 - pub rebase: bool, 291 - #[arg(long, default_value_t = false)] 292 - pub no_ff: bool, 293 } 294 295 #[derive(Subcommand, Debug, Clone)] 296 pub enum KnotCommand { 297 - List(KnotListArgs), 298 - Add(KnotAddArgs), 299 - Verify(KnotVerifyArgs), 300 - SetDefault(KnotRefArgs), 301 - Remove(KnotRefArgs), 302 /// Migrate a repository to another knot 303 Migrate(KnotMigrateArgs), 304 - } 305 - 306 - #[derive(Args, Debug, Clone)] 307 - pub struct KnotListArgs { 308 - #[arg(long, default_value_t = false)] 309 - pub public: bool, 310 - #[arg(long, default_value_t = false)] 311 - pub owned: bool, 312 - } 313 - 314 - #[derive(Args, Debug, Clone)] 315 - pub struct KnotAddArgs { 316 - pub url: String, 317 - #[arg(long)] 318 - pub did: Option<String>, 319 - #[arg(long)] 320 - pub name: Option<String>, 321 - #[arg(long, default_value_t = false)] 322 - pub verify: bool, 323 - } 324 - 325 - #[derive(Args, Debug, Clone)] 326 - pub struct KnotVerifyArgs { 327 - pub url: String, 328 - } 329 - 330 - #[derive(Args, Debug, Clone)] 331 - pub struct KnotRefArgs { 332 - pub url: String, 333 } 334 335 #[derive(Args, Debug, Clone)] ··· 421 /// Secret key 422 #[arg(long)] 423 pub key: String, 424 - /// Secret value 425 #[arg(long)] 426 pub value: String, 427 }
··· 284 #[derive(Args, Debug, Clone)] 285 pub struct PrMergeArgs { 286 pub id: String, 287 } 288 289 #[derive(Subcommand, Debug, Clone)] 290 pub enum KnotCommand { 291 /// Migrate a repository to another knot 292 Migrate(KnotMigrateArgs), 293 } 294 295 #[derive(Args, Debug, Clone)] ··· 381 /// Secret key 382 #[arg(long)] 383 pub key: String, 384 + /// Secret value (use '@filename' to read from file, '-' to read from stdin) 385 #[arg(long)] 386 pub value: String, 387 }
+5 -21
crates/tangled-cli/src/commands/issue.rs
··· 4 }; 5 use anyhow::{anyhow, Result}; 6 use tangled_api::Issue; 7 - use tangled_config::session::SessionManager; 8 9 pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { 10 match cmd { ··· 17 } 18 19 async fn list(args: IssueListArgs) -> Result<()> { 20 - let mgr = SessionManager::default(); 21 - let session = mgr 22 - .load()? 23 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 24 let pds = session 25 .pds 26 .clone() ··· 57 } 58 59 async fn create(args: IssueCreateArgs) -> Result<()> { 60 - let mgr = SessionManager::default(); 61 - let session = mgr 62 - .load()? 63 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 64 let pds = session 65 .pds 66 .clone() ··· 97 98 async fn show(args: IssueShowArgs) -> Result<()> { 99 // For now, show only accepts at-uri or did:rkey or rkey (for your DID) 100 - let mgr = SessionManager::default(); 101 - let session = mgr 102 - .load()? 103 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 104 let id = args.id; 105 let (did, rkey) = parse_record_id(&id, &session.did)?; 106 let pds = session ··· 129 130 async fn edit(args: IssueEditArgs) -> Result<()> { 131 // Simple edit: fetch existing record and putRecord with new title/body 132 - let mgr = SessionManager::default(); 133 - let session = mgr 134 - .load()? 135 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 136 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 137 let pds = session 138 .pds ··· 183 } 184 185 async fn comment(args: IssueCommentArgs) -> Result<()> { 186 - let mgr = SessionManager::default(); 187 - let session = mgr 188 - .load()? 189 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 190 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 191 let pds = session 192 .pds
··· 4 }; 5 use anyhow::{anyhow, Result}; 6 use tangled_api::Issue; 7 8 pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> { 9 match cmd { ··· 16 } 17 18 async fn list(args: IssueListArgs) -> Result<()> { 19 + let session = crate::util::load_session_with_refresh().await?; 20 let pds = session 21 .pds 22 .clone() ··· 53 } 54 55 async fn create(args: IssueCreateArgs) -> Result<()> { 56 + let session = crate::util::load_session_with_refresh().await?; 57 let pds = session 58 .pds 59 .clone() ··· 90 91 async fn show(args: IssueShowArgs) -> Result<()> { 92 // For now, show only accepts at-uri or did:rkey or rkey (for your DID) 93 + let session = crate::util::load_session_with_refresh().await?; 94 let id = args.id; 95 let (did, rkey) = parse_record_id(&id, &session.did)?; 96 let pds = session ··· 119 120 async fn edit(args: IssueEditArgs) -> Result<()> { 121 // Simple edit: fetch existing record and putRecord with new title/body 122 + let session = crate::util::load_session_with_refresh().await?; 123 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 124 let pds = session 125 .pds ··· 170 } 171 172 async fn comment(args: IssueCommentArgs) -> Result<()> { 173 + let session = crate::util::load_session_with_refresh().await?; 174 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 175 let pds = session 176 .pds
+2 -44
crates/tangled-cli/src/commands/knot.rs
··· 1 - use crate::cli::{ 2 - Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs, 3 - }; 4 use anyhow::anyhow; 5 use anyhow::Result; 6 use git2::{Direction, Repository as GitRepository, StatusOptions}; 7 use std::path::Path; 8 - use tangled_config::session::SessionManager; 9 10 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 11 match cmd { 12 - KnotCommand::List(args) => list(args).await, 13 - KnotCommand::Add(args) => add(args).await, 14 - KnotCommand::Verify(args) => verify(args).await, 15 - KnotCommand::SetDefault(args) => set_default(args).await, 16 - KnotCommand::Remove(args) => remove(args).await, 17 KnotCommand::Migrate(args) => migrate(args).await, 18 } 19 } 20 21 - async fn list(args: KnotListArgs) -> Result<()> { 22 - println!( 23 - "Knot list (stub) public={} owned={}", 24 - args.public, args.owned 25 - ); 26 - Ok(()) 27 - } 28 - 29 - async fn add(args: KnotAddArgs) -> Result<()> { 30 - println!( 31 - "Knot add (stub) url={} did={:?} name={:?} verify={}", 32 - args.url, args.did, args.name, args.verify 33 - ); 34 - Ok(()) 35 - } 36 - 37 - async fn verify(args: KnotVerifyArgs) -> Result<()> { 38 - println!("Knot verify (stub) url={}", args.url); 39 - Ok(()) 40 - } 41 - 42 - async fn set_default(args: KnotRefArgs) -> Result<()> { 43 - println!("Knot set-default (stub) url={}", args.url); 44 - Ok(()) 45 - } 46 - 47 - async fn remove(args: KnotRefArgs) -> Result<()> { 48 - println!("Knot remove (stub) url={}", args.url); 49 - Ok(()) 50 - } 51 - 52 async fn migrate(args: KnotMigrateArgs) -> Result<()> { 53 - let mgr = SessionManager::default(); 54 - let session = mgr 55 - .load()? 56 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 57 // 1) Ensure we're inside a git repository and working tree is clean 58 let repo = GitRepository::discover(Path::new("."))?; 59 let mut status_opts = StatusOptions::new();
··· 1 + use crate::cli::{Cli, KnotCommand, KnotMigrateArgs}; 2 use anyhow::anyhow; 3 use anyhow::Result; 4 use git2::{Direction, Repository as GitRepository, StatusOptions}; 5 use std::path::Path; 6 7 pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> { 8 match cmd { 9 KnotCommand::Migrate(args) => migrate(args).await, 10 } 11 } 12 13 async fn migrate(args: KnotMigrateArgs) -> Result<()> { 14 + let session = crate::util::load_session_with_refresh().await?; 15 // 1) Ensure we're inside a git repository and working tree is clean 16 let repo = GitRepository::discover(Path::new("."))?; 17 let mut status_opts = StatusOptions::new();
+122 -38
crates/tangled-cli/src/commands/pr.rs
··· 2 use anyhow::{anyhow, Result}; 3 use std::path::Path; 4 use std::process::Command; 5 - use tangled_config::session::SessionManager; 6 7 pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { 8 match cmd { ··· 15 } 16 17 async fn list(args: PrListArgs) -> Result<()> { 18 - let mgr = SessionManager::default(); 19 - let session = mgr 20 - .load()? 21 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 22 let pds = session 23 .pds 24 .clone() 25 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 26 .unwrap_or_else(|| "https://bsky.social".into()); 27 let client = tangled_api::TangledClient::new(&pds); 28 - let target_repo_at = if let Some(repo) = &args.repo { 29 let (owner, name) = parse_repo_ref(repo, &session.handle); 30 let info = client 31 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 32 .await?; 33 - Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey)) 34 - } else { 35 - None 36 - }; 37 - let pulls = client 38 - .list_pulls( 39 - &session.did, 40 - target_repo_at.as_deref(), 41 - Some(session.access_jwt.as_str()), 42 - ) 43 - .await?; 44 - if pulls.is_empty() { 45 - println!("No pull requests found (showing only those you created)"); 46 } else { 47 - println!("RKEY\tTITLE\tTARGET"); 48 - for pr in pulls { 49 - println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); 50 } 51 } 52 Ok(()) ··· 54 55 async fn create(args: PrCreateArgs) -> Result<()> { 56 // Must be run inside the repo checkout; we will use git format-patch to build the patch 57 - let mgr = SessionManager::default(); 58 - let session = mgr 59 - .load()? 60 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 61 let pds = session 62 .pds 63 .clone() ··· 126 } 127 128 async fn show(args: PrShowArgs) -> Result<()> { 129 - let mgr = SessionManager::default(); 130 - let session = mgr 131 - .load()? 132 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 133 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 134 let pds = session 135 .pds ··· 145 println!("BODY:\n{}", pr.body); 146 } 147 println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch); 148 if args.diff { 149 - println!("PATCH:\n{}", pr.patch); 150 } 151 Ok(()) 152 } 153 154 async fn review(args: PrReviewArgs) -> Result<()> { 155 - let mgr = SessionManager::default(); 156 - let session = mgr 157 - .load()? 158 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 159 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 160 let pds = session 161 .pds ··· 183 Ok(()) 184 } 185 186 - async fn merge(_args: PrMergeArgs) -> Result<()> { 187 - // Placeholder: merging requires server-side merge call with the patch and target branch. 188 - println!("Merge via CLI is not implemented yet. Use the web UI for now."); 189 Ok(()) 190 } 191
··· 2 use anyhow::{anyhow, Result}; 3 use std::path::Path; 4 use std::process::Command; 5 6 pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> { 7 match cmd { ··· 14 } 15 16 async fn list(args: PrListArgs) -> Result<()> { 17 + let session = crate::util::load_session_with_refresh().await?; 18 let pds = session 19 .pds 20 .clone() 21 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 22 .unwrap_or_else(|| "https://bsky.social".into()); 23 let client = tangled_api::TangledClient::new(&pds); 24 + 25 + // NEW: If --repo is specified, use the new API to list all PRs for that repo 26 + if let Some(repo) = &args.repo { 27 let (owner, name) = parse_repo_ref(repo, &session.handle); 28 let info = client 29 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 30 .await?; 31 + let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 32 + 33 + // Use Tangled API (tngl.sh) instead of PDS for aggregated query 34 + let api_client = tangled_api::TangledClient::default(); 35 + let state = args.state.as_deref(); 36 + let pulls = api_client 37 + .list_repo_pulls(&repo_at, state, &pds, &session.access_jwt) 38 + .await?; 39 + 40 + if pulls.is_empty() { 41 + println!("No pull requests found for this repository"); 42 + } else { 43 + println!("OWNER\tID\tTITLE\tSTATE"); 44 + for pr in pulls { 45 + let state_str = match pr.state { 46 + 1 => "open", 47 + 0 => "closed", 48 + 2 => "merged", 49 + _ => "unknown", 50 + }; 51 + println!("{}\t{}\t{}\t{}", pr.owner_did, pr.pull_id, pr.title, state_str); 52 + } 53 + } 54 } else { 55 + // OLD: Without --repo, show only user's PRs (existing behavior) 56 + let pulls = client 57 + .list_pulls( 58 + &session.did, 59 + None, 60 + Some(session.access_jwt.as_str()), 61 + ) 62 + .await?; 63 + if pulls.is_empty() { 64 + println!("No pull requests found (showing only those you created)"); 65 + } else { 66 + println!("RKEY\tTITLE\tTARGET"); 67 + for pr in pulls { 68 + println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo); 69 + } 70 } 71 } 72 Ok(()) ··· 74 75 async fn create(args: PrCreateArgs) -> Result<()> { 76 // Must be run inside the repo checkout; we will use git format-patch to build the patch 77 + let session = crate::util::load_session_with_refresh().await?; 78 let pds = session 79 .pds 80 .clone() ··· 143 } 144 145 async fn show(args: PrShowArgs) -> Result<()> { 146 + let session = crate::util::load_session_with_refresh().await?; 147 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 148 let pds = session 149 .pds ··· 159 println!("BODY:\n{}", pr.body); 160 } 161 println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch); 162 + 163 + // Display source info if it's a branch-based PR 164 + if let Some(source) = &pr.source { 165 + println!("SOURCE: {} ({})", source.sha, 166 + source.branch.as_deref().unwrap_or("detached")); 167 + if let Some(repo) = &source.repo { 168 + println!("SOURCE REPO: {}", repo); 169 + } 170 + } 171 + 172 if args.diff { 173 + if let Some(patch) = &pr.patch { 174 + println!("PATCH:\n{}", patch); 175 + } else { 176 + println!("(No patch available - this is a branch-based PR)"); 177 + } 178 } 179 Ok(()) 180 } 181 182 async fn review(args: PrReviewArgs) -> Result<()> { 183 + let session = crate::util::load_session_with_refresh().await?; 184 let (did, rkey) = parse_record_id(&args.id, &session.did)?; 185 let pds = session 186 .pds ··· 208 Ok(()) 209 } 210 211 + async fn merge(args: PrMergeArgs) -> Result<()> { 212 + let session = crate::util::load_session_with_refresh().await?; 213 + let (did, rkey) = parse_record_id(&args.id, &session.did)?; 214 + let pds = session 215 + .pds 216 + .clone() 217 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 218 + .unwrap_or_else(|| "https://bsky.social".into()); 219 + 220 + // Get the PR to find the target repo 221 + let pds_client = tangled_api::TangledClient::new(&pds); 222 + let pull = pds_client 223 + .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) 224 + .await?; 225 + 226 + // Parse the target repo AT-URI to get did and name 227 + let target_repo = &pull.target.repo; 228 + // Format: at://did:plc:.../sh.tangled.repo/rkey 229 + let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect(); 230 + if parts.len() < 2 { 231 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 232 + } 233 + let repo_did = parts[0]; 234 + 235 + // Get repo info to find the name 236 + // Parse rkey from target repo AT-URI 237 + let repo_rkey = if parts.len() >= 4 { 238 + parts[3] 239 + } else { 240 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 241 + }; 242 + 243 + #[derive(serde::Deserialize)] 244 + struct Rec { 245 + name: String, 246 + } 247 + #[derive(serde::Deserialize)] 248 + struct GetRes { 249 + value: Rec, 250 + } 251 + let params = [ 252 + ("repo", repo_did.to_string()), 253 + ("collection", "sh.tangled.repo".to_string()), 254 + ("rkey", repo_rkey.to_string()), 255 + ]; 256 + let repo_rec: GetRes = pds_client 257 + .get_json("com.atproto.repo.getRecord", &params, Some(session.access_jwt.as_str())) 258 + .await?; 259 + 260 + // Call merge on the default Tangled API base (tngl.sh) 261 + let api = tangled_api::TangledClient::default(); 262 + api.merge_pull( 263 + &did, 264 + &rkey, 265 + repo_did, 266 + &repo_rec.value.name, 267 + &pds, 268 + &session.access_jwt, 269 + ) 270 + .await?; 271 + 272 + println!("Merged PR {}:{}", did, rkey); 273 Ok(()) 274 } 275
+12 -31
crates/tangled-cli/src/commands/repo.rs
··· 2 use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks}; 3 use serde_json; 4 use std::path::PathBuf; 5 - use tangled_config::session::SessionManager; 6 7 use crate::cli::{ 8 Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs, ··· 22 } 23 24 async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> { 25 - let mgr = SessionManager::default(); 26 - let session = match mgr.load()? { 27 - Some(s) => s, 28 - None => return Err(anyhow!("Please login first: tangled auth login")), 29 - }; 30 31 // Use the PDS to list repo records for the user 32 let pds = session ··· 63 } 64 65 async fn create(args: RepoCreateArgs) -> Result<()> { 66 - let mgr = SessionManager::default(); 67 - let session = match mgr.load()? { 68 - Some(s) => s, 69 - None => return Err(anyhow!("Please login first: tangled auth login")), 70 - }; 71 72 let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into()); 73 let client = tangled_api::TangledClient::new(base); ··· 97 } 98 99 async fn clone(args: RepoCloneArgs) -> Result<()> { 100 - let mgr = SessionManager::default(); 101 - let session = mgr 102 - .load()? 103 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 104 105 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 106 let pds = session ··· 164 } 165 166 async fn info(args: RepoInfoArgs) -> Result<()> { 167 - let mgr = SessionManager::default(); 168 - let session = mgr 169 - .load()? 170 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 171 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 172 let pds = session 173 .pds ··· 182 println!("NAME: {}", info.name); 183 println!("OWNER DID: {}", info.did); 184 println!("KNOT: {}", info.knot); 185 if let Some(desc) = info.description.as_deref() { 186 if !desc.is_empty() { 187 println!("DESCRIPTION: {}", desc); ··· 230 } 231 232 async fn delete(args: RepoDeleteArgs) -> Result<()> { 233 - let mgr = SessionManager::default(); 234 - let session = mgr 235 - .load()? 236 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 237 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 238 let pds = session 239 .pds ··· 253 } 254 255 async fn star(args: RepoRefArgs) -> Result<()> { 256 - let mgr = SessionManager::default(); 257 - let session = mgr 258 - .load()? 259 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 260 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 261 let pds = session 262 .pds ··· 276 } 277 278 async fn unstar(args: RepoRefArgs) -> Result<()> { 279 - let mgr = SessionManager::default(); 280 - let session = mgr 281 - .load()? 282 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 283 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 284 let pds = session 285 .pds
··· 2 use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks}; 3 use serde_json; 4 use std::path::PathBuf; 5 6 use crate::cli::{ 7 Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs, ··· 21 } 22 23 async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> { 24 + let session = crate::util::load_session_with_refresh().await?; 25 26 // Use the PDS to list repo records for the user 27 let pds = session ··· 58 } 59 60 async fn create(args: RepoCreateArgs) -> Result<()> { 61 + let session = crate::util::load_session_with_refresh().await?; 62 63 let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into()); 64 let client = tangled_api::TangledClient::new(base); ··· 88 } 89 90 async fn clone(args: RepoCloneArgs) -> Result<()> { 91 + let session = crate::util::load_session_with_refresh().await?; 92 93 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 94 let pds = session ··· 152 } 153 154 async fn info(args: RepoInfoArgs) -> Result<()> { 155 + let session = crate::util::load_session_with_refresh().await?; 156 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 157 let pds = session 158 .pds ··· 167 println!("NAME: {}", info.name); 168 println!("OWNER DID: {}", info.did); 169 println!("KNOT: {}", info.knot); 170 + if let Some(spindle) = info.spindle.as_deref() { 171 + if !spindle.is_empty() { 172 + println!("SPINDLE: {}", spindle); 173 + } 174 + } 175 if let Some(desc) = info.description.as_deref() { 176 if !desc.is_empty() { 177 println!("DESCRIPTION: {}", desc); ··· 220 } 221 222 async fn delete(args: RepoDeleteArgs) -> Result<()> { 223 + let session = crate::util::load_session_with_refresh().await?; 224 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 225 let pds = session 226 .pds ··· 240 } 241 242 async fn star(args: RepoRefArgs) -> Result<()> { 243 + let session = crate::util::load_session_with_refresh().await?; 244 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 245 let pds = session 246 .pds ··· 260 } 261 262 async fn unstar(args: RepoRefArgs) -> Result<()> { 263 + let session = crate::util::load_session_with_refresh().await?; 264 let (owner, name) = parse_repo_ref(&args.repo, &session.handle); 265 let pds = session 266 .pds
+204 -25
crates/tangled-cli/src/commands/spindle.rs
··· 3 SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs, 4 }; 5 use anyhow::{anyhow, Result}; 6 - use tangled_config::session::SessionManager; 7 8 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { 9 match cmd { ··· 16 } 17 18 async fn list(args: SpindleListArgs) -> Result<()> { 19 - println!("Spindle list (stub) repo={:?}", args.repo); 20 Ok(()) 21 } 22 23 async fn config(args: SpindleConfigArgs) -> Result<()> { 24 - println!( 25 - "Spindle config (stub) repo={:?} url={:?} enable={} disable={}", 26 - args.repo, args.url, args.enable, args.disable 27 ); 28 Ok(()) 29 } 30 ··· 37 } 38 39 async fn logs(args: SpindleLogsArgs) -> Result<()> { 40 - println!( 41 - "Spindle logs (stub) job_id={} follow={} lines={:?}", 42 - args.job_id, args.follow, args.lines 43 - ); 44 Ok(()) 45 } 46 ··· 53 } 54 55 async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { 56 - let mgr = SessionManager::default(); 57 - let session = mgr 58 - .load()? 59 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 60 let pds = session 61 .pds 62 .clone() ··· 68 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 69 .await?; 70 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 71 - let api = tangled_api::TangledClient::default(); // base tngl.sh 72 let secrets = api 73 .list_repo_secrets(&pds, &session.access_jwt, &repo_at) 74 .await?; ··· 84 } 85 86 async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { 87 - let mgr = SessionManager::default(); 88 - let session = mgr 89 - .load()? 90 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 91 let pds = session 92 .pds 93 .clone() ··· 99 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 100 .await?; 101 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 102 - let api = tangled_api::TangledClient::default(); 103 - api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) 104 .await?; 105 println!("Added secret '{}' to {}", args.key, args.repo); 106 Ok(()) 107 } 108 109 async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { 110 - let mgr = SessionManager::default(); 111 - let session = mgr 112 - .load()? 113 - .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 114 let pds = session 115 .pds 116 .clone() ··· 122 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 123 .await?; 124 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 125 - let api = tangled_api::TangledClient::default(); 126 api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) 127 .await?; 128 println!("Removed secret '{}' from {}", args.key, args.repo);
··· 3 SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs, 4 }; 5 use anyhow::{anyhow, Result}; 6 + use futures_util::StreamExt; 7 + use tokio_tungstenite::{connect_async, tungstenite::Message}; 8 9 pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> { 10 match cmd { ··· 17 } 18 19 async fn list(args: SpindleListArgs) -> Result<()> { 20 + let session = crate::util::load_session_with_refresh().await?; 21 + 22 + let pds = session 23 + .pds 24 + .clone() 25 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 26 + .unwrap_or_else(|| "https://bsky.social".into()); 27 + let pds_client = tangled_api::TangledClient::new(&pds); 28 + 29 + let (owner, name) = parse_repo_ref( 30 + args.repo.as_deref().unwrap_or(&session.handle), 31 + &session.handle 32 + ); 33 + let info = pds_client 34 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 35 + .await?; 36 + 37 + let pipelines = pds_client 38 + .list_pipelines(&info.did, Some(session.access_jwt.as_str())) 39 + .await?; 40 + 41 + if pipelines.is_empty() { 42 + println!("No pipelines found for {}/{}", owner, name); 43 + } else { 44 + println!("RKEY\tKIND\tREPO\tWORKFLOWS"); 45 + for p in pipelines { 46 + let workflows = p.pipeline.workflows 47 + .iter() 48 + .map(|w| w.name.as_str()) 49 + .collect::<Vec<_>>() 50 + .join(","); 51 + println!( 52 + "{}\t{}\t{}\t{}", 53 + p.rkey, 54 + p.pipeline.trigger_metadata.kind, 55 + p.pipeline.trigger_metadata.repo.repo, 56 + workflows 57 + ); 58 + } 59 + } 60 Ok(()) 61 } 62 63 async fn config(args: SpindleConfigArgs) -> Result<()> { 64 + let session = crate::util::load_session_with_refresh().await?; 65 + 66 + if args.enable && args.disable { 67 + return Err(anyhow!("Cannot use --enable and --disable together")); 68 + } 69 + 70 + if !args.enable && !args.disable && args.url.is_none() { 71 + return Err(anyhow!( 72 + "Must provide --enable, --disable, or --url" 73 + )); 74 + } 75 + 76 + let pds = session 77 + .pds 78 + .clone() 79 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 80 + .unwrap_or_else(|| "https://bsky.social".into()); 81 + let pds_client = tangled_api::TangledClient::new(&pds); 82 + 83 + let (owner, name) = parse_repo_ref( 84 + args.repo.as_deref().unwrap_or(&session.handle), 85 + &session.handle 86 ); 87 + let info = pds_client 88 + .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 89 + .await?; 90 + 91 + let new_spindle = if args.disable { 92 + None 93 + } else if let Some(url) = args.url.as_deref() { 94 + Some(url) 95 + } else if args.enable { 96 + // Default spindle URL 97 + Some("https://spindle.tangled.sh") 98 + } else { 99 + return Err(anyhow!("Invalid flags combination")); 100 + }; 101 + 102 + pds_client 103 + .update_repo_spindle(&info.did, &info.rkey, new_spindle, &pds, &session.access_jwt) 104 + .await?; 105 + 106 + if args.disable { 107 + println!("Disabled spindle for {}/{}", owner, name); 108 + } else { 109 + println!( 110 + "Enabled spindle for {}/{} ({})", 111 + owner, 112 + name, 113 + new_spindle.unwrap_or_default() 114 + ); 115 + } 116 Ok(()) 117 } 118 ··· 125 } 126 127 async fn logs(args: SpindleLogsArgs) -> Result<()> { 128 + // Parse job_id: format is "knot:rkey:name" or just "name" (use repo context) 129 + let parts: Vec<&str> = args.job_id.split(':').collect(); 130 + let (knot, rkey, name) = if parts.len() == 3 { 131 + (parts[0].to_string(), parts[1].to_string(), parts[2].to_string()) 132 + } else if parts.len() == 1 { 133 + // Use repo context - need to get repo info 134 + let session = crate::util::load_session_with_refresh().await?; 135 + let pds = session 136 + .pds 137 + .clone() 138 + .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 139 + .unwrap_or_else(|| "https://bsky.social".into()); 140 + let pds_client = tangled_api::TangledClient::new(&pds); 141 + // Get repo info from current directory context or default to user's handle 142 + let info = pds_client 143 + .get_repo_info(&session.handle, &session.handle, Some(session.access_jwt.as_str())) 144 + .await?; 145 + (info.knot, info.rkey, parts[0].to_string()) 146 + } else { 147 + return Err(anyhow!("Invalid job_id format. Expected 'knot:rkey:name' or 'name'")); 148 + }; 149 + 150 + // Build WebSocket URL - spindle base is typically https://spindle.tangled.sh 151 + let spindle_base = std::env::var("TANGLED_SPINDLE_BASE") 152 + .unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string()); 153 + let ws_url = format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name); 154 + 155 + println!("Connecting to logs stream for {}:{}:{}...", knot, rkey, name); 156 + 157 + // Connect to WebSocket 158 + let (ws_stream, _) = connect_async(&ws_url).await 159 + .map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?; 160 + 161 + let (mut _write, mut read) = ws_stream.split(); 162 + 163 + // Stream log messages 164 + let mut line_count = 0; 165 + let max_lines = args.lines.unwrap_or(usize::MAX); 166 + 167 + while let Some(msg) = read.next().await { 168 + match msg { 169 + Ok(Message::Text(text)) => { 170 + println!("{}", text); 171 + line_count += 1; 172 + if line_count >= max_lines { 173 + break; 174 + } 175 + } 176 + Ok(Message::Close(_)) => { 177 + break; 178 + } 179 + Err(e) => { 180 + return Err(anyhow!("WebSocket error: {}", e)); 181 + } 182 + _ => {} 183 + } 184 + } 185 + 186 Ok(()) 187 } 188 ··· 195 } 196 197 async fn secret_list(args: SpindleSecretListArgs) -> Result<()> { 198 + let session = crate::util::load_session_with_refresh().await?; 199 let pds = session 200 .pds 201 .clone() ··· 207 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 208 .await?; 209 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 210 + 211 + // Get spindle base from repo config or use default 212 + let spindle_base = info.spindle 213 + .clone() 214 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 215 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 216 + let api = tangled_api::TangledClient::new(&spindle_base); 217 + 218 let secrets = api 219 .list_repo_secrets(&pds, &session.access_jwt, &repo_at) 220 .await?; ··· 230 } 231 232 async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> { 233 + let session = crate::util::load_session_with_refresh().await?; 234 let pds = session 235 .pds 236 .clone() ··· 242 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 243 .await?; 244 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 245 + 246 + // Get spindle base from repo config or use default 247 + let spindle_base = info.spindle 248 + .clone() 249 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 250 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 251 + let api = tangled_api::TangledClient::new(&spindle_base); 252 + 253 + // Handle special value patterns: @file or - (stdin) 254 + let value = if args.value == "-" { 255 + // Read from stdin 256 + use std::io::Read; 257 + let mut buffer = String::new(); 258 + std::io::stdin().read_to_string(&mut buffer)?; 259 + buffer 260 + } else if let Some(path) = args.value.strip_prefix('@') { 261 + // Read from file, expand ~ if needed 262 + let expanded_path = if path.starts_with("~/") { 263 + if let Some(home) = std::env::var("HOME").ok() { 264 + path.replacen("~/", &format!("{}/", home), 1) 265 + } else { 266 + path.to_string() 267 + } 268 + } else { 269 + path.to_string() 270 + }; 271 + std::fs::read_to_string(&expanded_path) 272 + .map_err(|e| anyhow!("Failed to read file '{}': {}", expanded_path, e))? 273 + } else { 274 + // Use value as-is 275 + args.value 276 + }; 277 + 278 + api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &value) 279 .await?; 280 println!("Added secret '{}' to {}", args.key, args.repo); 281 Ok(()) 282 } 283 284 async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> { 285 + let session = crate::util::load_session_with_refresh().await?; 286 let pds = session 287 .pds 288 .clone() ··· 294 .get_repo_info(owner, name, Some(session.access_jwt.as_str())) 295 .await?; 296 let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey); 297 + 298 + // Get spindle base from repo config or use default 299 + let spindle_base = info.spindle 300 + .clone() 301 + .or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok()) 302 + .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 303 + let api = tangled_api::TangledClient::new(&spindle_base); 304 + 305 api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key) 306 .await?; 307 println!("Removed secret '{}' from {}", args.key, args.repo);
+1
crates/tangled-cli/src/main.rs
··· 1 mod cli; 2 mod commands; 3 4 use anyhow::Result; 5 use clap::Parser;
··· 1 mod cli; 2 mod commands; 3 + mod util; 4 5 use anyhow::Result; 6 use clap::Parser;
+55
crates/tangled-cli/src/util.rs
···
··· 1 + use anyhow::{anyhow, Result}; 2 + use tangled_config::session::{Session, SessionManager}; 3 + 4 + /// Load session and automatically refresh if expired 5 + pub async fn load_session() -> Result<Session> { 6 + let mgr = SessionManager::default(); 7 + let session = mgr 8 + .load()? 9 + .ok_or_else(|| anyhow!("Please login first: tangled auth login"))?; 10 + 11 + Ok(session) 12 + } 13 + 14 + /// Refresh the session using the refresh token 15 + pub async fn refresh_session(session: &Session) -> Result<Session> { 16 + let pds = session 17 + .pds 18 + .clone() 19 + .unwrap_or_else(|| "https://bsky.social".to_string()); 20 + 21 + let client = tangled_api::TangledClient::new(&pds); 22 + let mut new_session = client.refresh_session(&session.refresh_jwt).await?; 23 + 24 + // Preserve PDS from old session 25 + new_session.pds = session.pds.clone(); 26 + 27 + // Save the refreshed session 28 + let mgr = SessionManager::default(); 29 + mgr.save(&new_session)?; 30 + 31 + Ok(new_session) 32 + } 33 + 34 + /// Load session with automatic refresh on ExpiredToken 35 + pub async fn load_session_with_refresh() -> Result<Session> { 36 + let session = load_session().await?; 37 + 38 + // Check if session is older than 30 minutes - if so, proactively refresh 39 + let age = chrono::Utc::now() 40 + .signed_duration_since(session.created_at) 41 + .num_minutes(); 42 + 43 + if age > 30 { 44 + // Session is old, proactively refresh 45 + match refresh_session(&session).await { 46 + Ok(new_session) => return Ok(new_session), 47 + Err(_) => { 48 + // If refresh fails, try with the old session anyway 49 + // It might still work 50 + } 51 + } 52 + } 53 + 54 + Ok(session) 55 + }
+303 -7
docs/getting-started.md
··· 1 - # Getting Started 2 3 - This project is a scaffold of a Tangled CLI in Rust. The commands are present as stubs and will be wired to XRPC endpoints iteratively. 4 5 - ## Build 6 7 - Requires Rust toolchain and network access to fetch dependencies. 8 9 ``` 10 - cargo build 11 ``` 12 13 - ## Run 14 15 ``` 16 - cargo run -p tangled-cli -- --help 17 ``` 18
··· 1 + # Getting Started with Tangled CLI 2 + 3 + This guide will help you get up and running with the Tangled CLI. 4 + 5 + ## Installation 6 + 7 + ### Prerequisites 8 + 9 + - Rust toolchain 1.70 or later 10 + - Git 11 + - A Bluesky/AT Protocol account 12 + 13 + ### Build from Source 14 + 15 + 1. Clone the repository: 16 + ```sh 17 + git clone https://tangled.org/tangled/tangled-cli 18 + cd tangled-cli 19 + ``` 20 + 21 + 2. Build the project: 22 + ```sh 23 + cargo build --release 24 + ``` 25 + 26 + 3. The binary will be available at `target/release/tangled-cli`. Optionally, add it to your PATH or create an alias: 27 + ```sh 28 + alias tangled='./target/release/tangled-cli' 29 + ``` 30 + 31 + ### Install from AUR (Arch Linux) 32 + 33 + If you're on Arch Linux, you can install from the AUR: 34 + 35 + ```sh 36 + yay -S tangled-cli-git 37 + ``` 38 + 39 + ## First Steps 40 + 41 + ### 1. Authenticate 42 + 43 + Login with your AT Protocol credentials (your Bluesky account): 44 + 45 + ```sh 46 + tangled auth login 47 + ``` 48 + 49 + You'll be prompted for your handle (e.g., `alice.bsky.social`) and password. If you're using a custom PDS, specify it with the `--pds` flag: 50 + 51 + ```sh 52 + tangled auth login --pds https://your-pds.example.com 53 + ``` 54 + 55 + Your credentials are stored securely in your system keyring. 56 + 57 + ### 2. Check Your Status 58 + 59 + Verify you're logged in: 60 + 61 + ```sh 62 + tangled auth status 63 + ``` 64 + 65 + ### 3. List Your Repositories 66 + 67 + See all your repositories: 68 + 69 + ```sh 70 + tangled repo list 71 + ``` 72 + 73 + Or view someone else's public repositories: 74 + 75 + ```sh 76 + tangled repo list --user alice.bsky.social 77 + ``` 78 + 79 + ### 4. Create a Repository 80 + 81 + Create a new repository on Tangled: 82 + 83 + ```sh 84 + tangled repo create my-project --description "My awesome project" 85 + ``` 86 + 87 + By default, repositories are created on the default knot (`tngl.sh`). You can specify a different knot: 88 + 89 + ```sh 90 + tangled repo create my-project --knot knot1.tangled.sh 91 + ``` 92 + 93 + ### 5. Clone a Repository 94 + 95 + Clone a repository to start working on it: 96 + 97 + ```sh 98 + tangled repo clone alice/my-project 99 + ``` 100 + 101 + This uses SSH by default. For HTTPS: 102 + 103 + ```sh 104 + tangled repo clone alice/my-project --https 105 + ``` 106 + 107 + ## Working with Issues 108 + 109 + ### Create an Issue 110 + 111 + ```sh 112 + tangled issue create --repo my-project --title "Add new feature" --body "We should add feature X" 113 + ``` 114 + 115 + ### List Issues 116 + 117 + ```sh 118 + tangled issue list --repo my-project 119 + ``` 120 121 + ### View Issue Details 122 123 + ```sh 124 + tangled issue show <issue-id> 125 + ``` 126 127 + ### Comment on an Issue 128 129 + ```sh 130 + tangled issue comment <issue-id> --body "I'm working on this!" 131 ``` 132 + 133 + ## Working with Pull Requests 134 + 135 + ### Create a Pull Request 136 + 137 + ```sh 138 + tangled pr create --repo my-project --base main --head feature-branch --title "Add feature X" 139 ``` 140 141 + The CLI will use `git format-patch` to create a patch from your branch. 142 143 + ### List Pull Requests 144 + 145 + ```sh 146 + tangled pr list --repo my-project 147 ``` 148 + 149 + ### Review a Pull Request 150 + 151 + ```sh 152 + tangled pr review <pr-id> --approve --comment "Looks good!" 153 ``` 154 155 + Or request changes: 156 + 157 + ```sh 158 + tangled pr review <pr-id> --request-changes --comment "Please fix the tests" 159 + ``` 160 + 161 + ### Merge a Pull Request 162 + 163 + ```sh 164 + tangled pr merge <pr-id> 165 + ``` 166 + 167 + ## CI/CD with Spindle 168 + 169 + Spindle is Tangled's integrated CI/CD system. 170 + 171 + ### Enable Spindle for Your Repository 172 + 173 + ```sh 174 + tangled spindle config --repo my-project --enable 175 + ``` 176 + 177 + Or use a custom spindle URL: 178 + 179 + ```sh 180 + tangled spindle config --repo my-project --url https://my-spindle.example.com 181 + ``` 182 + 183 + ### View Pipeline Runs 184 + 185 + ```sh 186 + tangled spindle list --repo my-project 187 + ``` 188 + 189 + ### Stream Workflow Logs 190 + 191 + ```sh 192 + tangled spindle logs knot:rkey:workflow-name 193 + ``` 194 + 195 + Add `--follow` to tail the logs in real-time. 196 + 197 + ### Manage Secrets 198 + 199 + Add secrets for your CI/CD workflows: 200 + 201 + ```sh 202 + tangled spindle secret add --repo my-project --key API_KEY --value "my-secret-value" 203 + ``` 204 + 205 + List secrets: 206 + 207 + ```sh 208 + tangled spindle secret list --repo my-project 209 + ``` 210 + 211 + Remove a secret: 212 + 213 + ```sh 214 + tangled spindle secret remove --repo my-project --key API_KEY 215 + ``` 216 + 217 + ## Advanced Topics 218 + 219 + ### Repository Migration 220 + 221 + Move a repository to a different knot: 222 + 223 + ```sh 224 + tangled knot migrate --repo my-project --to knot2.tangled.sh 225 + ``` 226 + 227 + This command must be run from within the repository's working directory, and your working tree must be clean and pushed. 228 + 229 + ### Output Formats 230 + 231 + Most commands support JSON output: 232 + 233 + ```sh 234 + tangled repo list --format json 235 + ``` 236 + 237 + ### Quiet and Verbose Modes 238 + 239 + Reduce output: 240 + 241 + ```sh 242 + tangled --quiet repo list 243 + ``` 244 + 245 + Increase verbosity for debugging: 246 + 247 + ```sh 248 + tangled --verbose repo list 249 + ``` 250 + 251 + ## Configuration 252 + 253 + The CLI stores configuration in: 254 + - Linux: `~/.config/tangled/config.toml` 255 + - macOS: `~/Library/Application Support/tangled/config.toml` 256 + - Windows: `%APPDATA%\tangled\config.toml` 257 + 258 + Session credentials are stored securely in your system keyring (GNOME Keyring, KWallet, macOS Keychain, or Windows Credential Manager). 259 + 260 + ### Environment Variables 261 + 262 + - `TANGLED_PDS_BASE` - Override the default PDS (default: `https://bsky.social`) 263 + - `TANGLED_API_BASE` - Override the Tangled API base (default: `https://tngl.sh`) 264 + - `TANGLED_SPINDLE_BASE` - Override the Spindle base (default: `wss://spindle.tangled.sh`) 265 + 266 + ## Troubleshooting 267 + 268 + ### Keyring Issues on Linux 269 + 270 + If you see keyring errors on Linux, ensure you have a secret service running: 271 + 272 + ```sh 273 + # For GNOME 274 + systemctl --user enable --now gnome-keyring-daemon 275 + 276 + # For KDE 277 + # KWallet should start automatically with Plasma 278 + ``` 279 + 280 + ### Authentication Failures 281 + 282 + If authentication fails with your custom PDS: 283 + 284 + ```sh 285 + tangled auth login --pds https://your-pds.example.com 286 + ``` 287 + 288 + Make sure the PDS URL is correct and accessible. 289 + 290 + ### "Repository not found" Errors 291 + 292 + Verify the repository exists and you have access: 293 + 294 + ```sh 295 + tangled repo info owner/reponame 296 + ``` 297 + 298 + ## Getting Help 299 + 300 + For command-specific help, use the `--help` flag: 301 + 302 + ```sh 303 + tangled --help 304 + tangled repo --help 305 + tangled repo create --help 306 + ``` 307 + 308 + ## Next Steps 309 + 310 + - Explore all available commands with `tangled --help` 311 + - Set up CI/CD workflows with `.tangled.yml` in your repository 312 + - Check out the main README for more examples and advanced usage 313 + 314 + Happy collaborating! 🧶