1# Tangled CLI – Agent Handoff (Massive Context) 2 3This 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 5Primary 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 22Keep 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 52Goal: 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 71Persist 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 81If 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 89File: `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 105Error handling: For non-2xx, read the response body, return `anyhow!("{status}: {body}")`. 106 107### 4.2 Wire CLI auth commands 108 109File: `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 130File: `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 148use anyhow::{anyhow, bail, Result}; 149use serde::{de::DeserializeOwned, Deserialize, Serialize}; 150use tangled_config::session::Session; 151 152#[derive(Clone, Debug)] 153pub struct TangledClient { pub(crate) base_url: String } 154 155impl 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)] 224pub 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 230use anyhow::Result; 231use dialoguer::{Input, Password}; 232use tangled_config::session::SessionManager; 233use crate::cli::{AuthCommand, AuthLoginArgs, Cli}; 234 235pub 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 243async 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 260async 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 269async 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 279use anyhow::{anyhow, Result}; 280use tangled_config::session::SessionManager; 281use crate::cli::{Cli, RepoCommand, RepoListArgs}; 282 283pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> { 284 match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) } 285} 286 287async 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 368End 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 375This 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).