···
-
# Tangled CLI – Agent Handoff (Massive Context)
-
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.
-
Primary focus for this session: implement authentication (auth login/status/logout) and repository listing (repo list).
-
--------------------------------------------------------------------------------
-
## 0) TL;DR – Immediate Actions
-
- Implement `auth login` using AT Protocol `com.atproto.server.createSession`.
-
- Prompt for handle/password if flags aren’t provided.
-
- POST to `/xrpc/com.atproto.server.createSession` at the configured PDS (default `https://bsky.social`).
-
- Persist `{accessJwt, refreshJwt, did, handle}` via `SessionManager` (keyring-backed).
-
- `auth status` reads keyring and prints handle + did; `auth logout` clears keyring.
-
- Implement `repo list` using Tangled’s repo list method (tentative `sh.tangled.repo.list`).
-
- GET `/xrpc/sh.tangled.repo.list` with optional params: `user`, `knot`, `starred`.
-
- Include `Authorization: Bearer <accessJwt>` if required.
-
- Print results as table (default) or JSON (`--format json`).
-
Keep edits minimal and scoped to these features.
-
--------------------------------------------------------------------------------
-
## 1) Repository Map (Paths You Will Touch)
-
- `tangled/crates/tangled-cli/src/commands/auth.rs` → implement login/status/logout.
-
- `tangled/crates/tangled-cli/src/commands/repo.rs` → implement list.
-
- `tangled/crates/tangled-cli/src/cli.rs` → already contains arguments and subcommands; no structural changes needed.
-
- `tangled/crates/tangled-cli/src/main.rs` → no change.
-
- `tangled/crates/tangled-config/src/session.rs` → already provides `Session` + `SessionManager` (keyring).
-
- `tangled/crates/tangled-config/src/config.rs` → optional use for PDS/base URL (MVP can use CLI flags/env vars).
-
- `tangled/crates/tangled-api/src/client.rs` → add XRPC helpers and implement `login_with_password` and `list_repos`.
-
--------------------------------------------------------------------------------
-
## 2) Current State Snapshot
-
- Workspace is scaffolded and compiles after wiring dependencies (network needed to fetch crates):
-
- `tangled-cli`: clap CLI with subcommands; commands currently log stubs.
-
- `tangled-config`: TOML config loader/saver; keyring-backed session store.
-
- `tangled-api`: client struct with placeholder methods.
-
- `tangled-git`: stubs for future.
-
- Placeholder lexicons in `tangled/lexicons/sh.tangled/*` are not authoritative; use AT Protocol docs and inspect real endpoints later.
-
Goal: replace CLI stubs with real API calls for auth + repo list.
-
--------------------------------------------------------------------------------
-
## 3) Endpoints & Data Shapes
-
### 3.1 AT Protocol – Create Session
-
- Method: `com.atproto.server.createSession`
-
- HTTP: `POST /xrpc/com.atproto.server.createSession`
-
- `identifier: string` → user handle or email (e.g., `alice.bsky.social`).
-
- `password: string` → password or app password.
-
- Response JSON (subset used):
-
- `did: string` (e.g., `did:plc:...`)
-
Persist to keyring using `SessionManager`.
-
### 3.2 Tangled – Repo List (tentative)
-
- Method: `sh.tangled.repo.list` (subject to change; wire in a constant to adjust easily).
-
- HTTP: `GET /xrpc/sh.tangled.repo.list?user=<..>&knot=<..>&starred=<true|false>`
-
- Auth: likely required; include `Authorization: Bearer <accessJwt>`.
-
- Response JSON (envelope):
-
- `{ "repos": [{ "name": string, "knot": string, "private": bool, ... }] }`
-
If method name or response shape differs, adapt the client code; keep CLI interface stable.
-
--------------------------------------------------------------------------------
-
## 4) Implementation Plan
-
### 4.1 Add XRPC helpers and methods in `tangled-api`
-
File: `tangled/crates/tangled-api/src/client.rs`
-
- Extend `TangledClient` with:
-
- `fn xrpc_url(&self, method: &str) -> String` → combines `base_url` + `/xrpc/` + `method`.
-
- `async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(&self, method, req, bearer) -> Result<TRes>`.
-
- `async fn get_json<TRes: DeserializeOwned>(&self, method, params, bearer) -> Result<TRes>`.
-
- Include `Authorization: Bearer <token>` when `bearer` is provided.
-
- `pub async fn login_with_password(&self, handle: &str, password: &str, pds: &str) -> Result<Session>`
-
- POST to `com.atproto.server.createSession` at `self.base_url` (which should be the PDS base).
-
- Map response to `tangled_config::session::Session` and return it (caller will persist).
-
- `pub async fn list_repos(&self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>) -> Result<Vec<Repository>>`
-
- GET `sh.tangled.repo.list` with params present only if set.
-
- Return parsed `Vec<Repository>` from an envelope `{ repos: [...] }`.
-
Error handling: For non-2xx, read the response body, return `anyhow!("{status}: {body}")`.
-
### 4.2 Wire CLI auth commands
-
File: `tangled/crates/tangled-cli/src/commands/auth.rs`
-
- Determine PDS: use `--pds` arg if provided, else default `https://bsky.social` (later from config/env).
-
- Prompt for missing handle/password.
-
- `let client = tangled_api::TangledClient::new(&pds);`
-
- `let session = client.login_with_password(&handle, &password, &pds).await?;`
-
- `tangled_config::session::SessionManager::default().save(&session)?;`
-
- Print: `Logged in as '{handle}' ({did})`.
-
- Load `SessionManager::default().load()?`.
-
- If Some: print `Logged in as '{handle}' ({did})`.
-
- Else: print `Not logged in. Run: tangled auth login`.
-
- `SessionManager::default().clear()?`.
-
- Print `Logged out` if something was cleared; otherwise `No session found` is acceptable.
-
### 4.3 Wire CLI repo list
-
File: `tangled/crates/tangled-cli/src/commands/repo.rs`
-
- Load session; if absent, print `Please login first: tangled auth login` and exit 1 (or 0 with friendly message; choose one and be consistent).
-
- Build a client for Tangled API base (for now, default to `https://tangled.org` or allow `TANGLED_API_BASE` env var to override):
-
- `let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into());`
-
- `let client = tangled_api::TangledClient::new(base);`
-
- Call `client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?`.
-
- If `Cli.format == OutputFormat::Json`: `serde_json::to_string_pretty(&repos)`.
-
- Else: simple columns `NAME KNOT PRIVATE` using `println!` formatting for now.
-
--------------------------------------------------------------------------------
-
## 5) Code Snippets (Copy/Paste Friendly)
-
### 5.1 In `tangled-api/src/client.rs`
-
use anyhow::{anyhow, bail, Result};
-
use serde::{de::DeserializeOwned, Deserialize, Serialize};
-
use tangled_config::session::Session;
-
#[derive(Clone, Debug)]
-
pub struct TangledClient { pub(crate) base_url: String }
-
pub fn new(base_url: impl Into<String>) -> Self { Self { base_url: base_url.into() } }
-
pub fn default() -> Self { Self::new("https://tangled.org") }
-
fn xrpc_url(&self, method: &str) -> String {
-
format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method)
-
async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(
-
let url = self.xrpc_url(method);
-
let client = reqwest::Client::new();
-
let mut reqb = client.post(url).header(reqwest::header::CONTENT_TYPE, "application/json");
-
if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); }
-
let res = reqb.json(req).send().await?;
-
let status = res.status();
-
if !status.is_success() {
-
let body = res.text().await.unwrap_or_default();
-
return Err(anyhow!("{}: {}", status, body));
-
Ok(res.json::<TRes>().await?)
-
async fn get_json<TRes: DeserializeOwned>(
-
params: &[(&str, String)],
-
let url = self.xrpc_url(method);
-
let client = reqwest::Client::new();
-
let mut reqb = client.get(url).query(¶ms);
-
if let Some(token) = bearer { reqb = reqb.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)); }
-
let res = reqb.send().await?;
-
let status = res.status();
-
if !status.is_success() {
-
let body = res.text().await.unwrap_or_default();
-
return Err(anyhow!("{}: {}", status, body));
-
Ok(res.json::<TRes>().await?)
-
pub async fn login_with_password(&self, handle: &str, password: &str, _pds: &str) -> Result<Session> {
-
struct Req<'a> { #[serde(rename = "identifier")] identifier: &'a str, #[serde(rename = "password")] password: &'a str }
-
struct Res { #[serde(rename = "accessJwt")] access_jwt: String, #[serde(rename = "refreshJwt")] refresh_jwt: String, did: String, handle: String }
-
let body = Req { identifier: handle, password };
-
let res: Res = self.post_json("com.atproto.server.createSession", &body, None).await?;
-
Ok(Session { access_jwt: res.access_jwt, refresh_jwt: res.refresh_jwt, did: res.did, handle: res.handle, ..Default::default() })
-
pub async fn list_repos(&self, user: Option<&str>, knot: Option<&str>, starred: bool, bearer: Option<&str>) -> Result<Vec<Repository>> {
-
struct Envelope { repos: Vec<Repository> }
-
if let Some(u) = user { q.push(("user", u.to_string())); }
-
if let Some(k) = knot { q.push(("knot", k.to_string())); }
-
if starred { q.push(("starred", true.to_string())); }
-
let env: Envelope = self.get_json("sh.tangled.repo.list", &q, bearer).await?;
-
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-
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 }
-
### 5.2 In `tangled-cli/src/commands/auth.rs`
-
use dialoguer::{Input, Password};
-
use tangled_config::session::SessionManager;
-
use crate::cli::{AuthCommand, AuthLoginArgs, Cli};
-
pub async fn run(_cli: &Cli, cmd: AuthCommand) -> Result<()> {
-
AuthCommand::Login(args) => login(args).await,
-
AuthCommand::Status => status().await,
-
AuthCommand::Logout => logout().await,
-
async fn login(mut args: AuthLoginArgs) -> Result<()> {
-
let handle: String = match args.handle.take() { Some(h) => h, None => Input::new().with_prompt("Handle").interact_text()? };
-
let password: String = match args.password.take() { Some(p) => p, None => Password::new().with_prompt("Password").interact()? };
-
let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string());
-
let client = tangled_api::TangledClient::new(&pds);
-
let mut session = match client.login_with_password(&handle, &password, &pds).await {
-
println!("\x1b[93mIf you're on your own PDS, make sure to pass the --pds flag\x1b[0m");
-
SessionManager::default().save(&session)?;
-
println!("Logged in as '{}' ({})", session.handle, session.did);
-
async fn status() -> Result<()> {
-
let mgr = SessionManager::default();
-
Some(s) => println!("Logged in as '{}' ({})", s.handle, s.did),
-
None => println!("Not logged in. Run: tangled auth login"),
-
async fn logout() -> Result<()> {
-
let mgr = SessionManager::default();
-
if mgr.load()?.is_some() { mgr.clear()?; println!("Logged out"); } else { println!("No session found"); }
-
### 5.3 In `tangled-cli/src/commands/repo.rs`
-
use anyhow::{anyhow, Result};
-
use tangled_config::session::SessionManager;
-
use crate::cli::{Cli, RepoCommand, RepoListArgs};
-
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
-
match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) }
-
async fn list(args: RepoListArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr.load()?.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
-
let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into());
-
let client = tangled_api::TangledClient::new(base);
-
let repos = client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?;
-
// Simple output: table or JSON to be improved later
-
println!("NAME\tKNOT\tPRIVATE");
-
for r in repos { println!("{}\t{}\t{}", r.name, r.knot.unwrap_or_default(), r.private); }
-
--------------------------------------------------------------------------------
-
## 6) Configuration, Env Vars, and Security
-
- PDS base (auth): default `https://bsky.social`. Accept CLI flag `--pds`; later read from config.
-
- Tangled API base (repo list): default `https://tangled.org`; allow override via `TANGLED_API_BASE` env var.
-
- Do not log passwords or tokens.
-
- Store tokens only in keyring (already implemented).
-
--------------------------------------------------------------------------------
-
## 7) Testing Plan (MVP)
-
- Client unit tests with `mockito` for `createSession` and `repo list` endpoints; simulate expected JSON.
-
- CLI smoke tests optional for this pass. If added, use `assert_cmd` to check printed output strings.
-
- Avoid live network calls in tests.
-
--------------------------------------------------------------------------------
-
## 8) Acceptance Criteria
-
- `tangled auth login`:
-
- Prompts or uses flags; successful call saves session and prints `Logged in as ...`.
-
- On failure, shows HTTP status and error message, plus helpful hint about --pds flag for users on their own PDS.
-
- `tangled auth status`:
-
- Shows handle + did if session exists; otherwise says not logged in.
-
- `tangled auth logout`:
-
- Clears keyring; prints confirmation.
-
- Performs authenticated GET and prints a list (even if empty) without panicking.
-
- JSON output possible later; table output acceptable for now.
-
--------------------------------------------------------------------------------
-
## 9) Troubleshooting Notes
-
- Keyring errors on Linux may indicate no secret service running; suggest enabling GNOME Keyring or KWallet.
-
- If `repo list` returns 404, the method name or base URL may be wrong; adjust `sh.tangled.repo.list` or `TANGLED_API_BASE`.
-
- If 401, session may be missing/expired; run `auth login` again.
-
--------------------------------------------------------------------------------
-
## 10) Non‑Goals for This Pass
-
- Refresh token flow, device code, OAuth.
-
- PRs, issues, knots, spindle implementation.
-
- Advanced formatting, paging, completions.
-
--------------------------------------------------------------------------------
-
## 11) Future Follow‑ups
-
- Refresh flow (`com.atproto.server.refreshSession`) and retry once on 401.
-
- Persist base URLs and profiles in config; add `tangled config` commands.
-
- Proper table/json formatting and shell completions.
-
--------------------------------------------------------------------------------
-
## 12) Quick Operator Commands
-
- Build CLI: `cargo build -p tangled-cli`
-
- Help: `cargo run -p tangled-cli -- --help`
-
- Login: `cargo run -p tangled-cli -- auth login --handle <handle>`
-
- Status: `cargo run -p tangled-cli -- auth status`
-
- Repo list: `TANGLED_API_BASE=https://tangled.org cargo run -p tangled-cli -- repo list --user <handle>`
-
--------------------------------------------------------------------------------
-
End of handoff. Implement auth login and repo list as described, keeping changes focused and testable.
-
--------------------------------------------------------------------------------
-
## 13) Tangled Core (../tangled-core) – Practical Notes
-
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.
-
- Lexicons (authoritative NSIDs and shapes): `../tangled-core/lexicons/**`
-
- Repo create: `../tangled-core/lexicons/repo/create.json` → `sh.tangled.repo.create`
-
- Repo record schema: `../tangled-core/lexicons/repo/repo.json` → `sh.tangled.repo`
-
- Misc repo queries (tree, log, tags, etc.) under `../tangled-core/lexicons/repo/`
-
- Note: there is no `sh.tangled.repo.list` lexicon in the core right now; listing is done via ATproto records.
-
- Knotserver XRPC routes (what requires auth vs open): `../tangled-core/knotserver/xrpc/xrpc.go`
-
- Mutating repo ops (e.g., `sh.tangled.repo.create`) are behind ServiceAuth middleware.
-
- Read-only repo queries (tree, log, etc.) are open.
-
- Create repo handler (server-side flow): `../tangled-core/knotserver/xrpc/create_repo.go`
-
- Validates ServiceAuth; expects rkey for the `sh.tangled.repo` record that already exists on the user's PDS.
-
- ServiceAuth middleware (how Bearer is validated): `../tangled-core/xrpc/serviceauth/service_auth.go`
-
- Validates a ServiceAuth token with Audience = `did:web:<knot-or-service-host>`.
-
- Appview client for ServiceAuth: `../tangled-core/appview/xrpcclient/xrpc.go` (method: `ServerGetServiceAuth`).
-
### How To Search Quickly (rg examples)
-
- Find a specific NSID across the repo:
-
- `rg -n "sh\.tangled\.repo\.create" ../tangled-core`
-
- See which endpoints are routed and whether they’re behind ServiceAuth:
-
- `rg -n "chi\..*Get\(|chi\..*Post\(" ../tangled-core/knotserver/xrpc`
-
- Then open `xrpc.go` and respective handlers.
-
- Discover ServiceAuth usage and audience DID:
-
- `rg -n "ServerGetServiceAuth|VerifyServiceAuth|serviceauth" ../tangled-core`
-
- List lexicons by area:
-
- `ls ../tangled-core/lexicons/repo` or `rg -n "\bid\": \"sh\.tangled\..*\"" ../tangled-core/lexicons`
-
### Repo Listing (client-side pattern)
-
- There is no `sh.tangled.repo.list` in core. To list a user’s repos:
-
1) Resolve handle → DID if needed via PDS: `GET com.atproto.identity.resolveHandle`.
-
2) List records from the user’s PDS: `GET com.atproto.repo.listRecords` with `collection=sh.tangled.repo`.
-
3) Filter client-side (e.g., by `knot`). “Starred” filtering is not currently defined in core.
-
### Repo Creation (two-step flow)
-
- Step 1 (PDS): create the `sh.tangled.repo` record in the user’s repo:
-
- `POST com.atproto.repo.createRecord` with `{ repo: <did>, collection: "sh.tangled.repo", record: { name, knot, description?, createdAt } }`.
-
- Extract `rkey` from the returned `uri` (`at://<did>/<collection>/<rkey>`).
-
- Step 2 (Tangled API base): call the server to initialize the bare repo on the knot:
-
- Obtain ServiceAuth: `GET com.atproto.server.getServiceAuth` from PDS with `aud=did:web:<tngl.sh or target-host>`.
-
- `POST sh.tangled.repo.create` on the Tangled API base with `{ rkey, defaultBranch?, source? }` and `Authorization: Bearer <serviceAuth>`.
-
- Server validates token via `xrpc/serviceauth`, confirms actor permissions, and creates the git repo.
-
### Base URLs, DIDs, and Defaults
-
- Tangled API base (server): default is `https://tngl.sh`. Do not use the marketing/landing site.
-
- PDS base (auth + record ops): default `https://bsky.social` unless a different PDS was chosen on login.
-
- ServiceAuth audience DID is `did:web:<host>` where `<host>` is the Tangled API base hostname.
-
- CLI stores the PDS URL in the session to keep the CLI stateful.
-
### Common Errors and Fixes
-
- `InvalidToken` when listing repos: listing should use the PDS (`com.atproto.repo.listRecords`), not the Tangled API base.
-
- 404 on `repo.create`: verify ServiceAuth audience matches the target host and that the rkey exists on the PDS.
-
- Keychain issues on Linux: ensure a Secret Service (e.g., GNOME Keyring or KWallet) is running.
-
### Implementation Pointers (CLI)
-
- `com.atproto.server.createSession` against the PDS, save `{accessJwt, refreshJwt, did, handle, pds}` in keyring.
-
- Use session.handle by default; resolve to DID, then `com.atproto.repo.listRecords` on PDS.
-
- Build the PDS record first; then ServiceAuth → `sh.tangled.repo.create` on `tngl.sh`.
-
- Avoid live calls; use `mockito` to stub both PDS and Tangled API base endpoints.
-
- Unit test decoding with minimal JSON envelopes: record lists, createRecord `uri`, and repo.create (empty body or simple ack).