Compare changes

Choose any two refs to compare.

+188 -369
AGENTS.md
···
-
# Tangled CLI – Agent Handoff (Massive Context)
+
# Tangled CLI – Current Implementation Status
-
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.
+
This document provides an overview of the Tangled CLI implementation status for AI agents or developers working on the project.
-
Primary focus for this session: implement authentication (auth login/status/logout) and repository listing (repo list).
+
## Implementation Status
-
--------------------------------------------------------------------------------
+
### ✅ Fully Implemented
-
## 0) TL;DR – Immediate Actions
+
#### Authentication (`auth`)
+
- `login` - Authenticate with AT Protocol using `com.atproto.server.createSession`
+
- `status` - Show current authentication status
+
- `logout` - Clear stored session from keyring
-
- 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.
+
#### Repositories (`repo`)
+
- `list` - List repositories using `com.atproto.repo.listRecords` with `collection=sh.tangled.repo`
+
- `create` - Create repositories with two-step flow:
+
1. Create PDS record via `com.atproto.repo.createRecord`
+
2. Initialize bare repo via `sh.tangled.repo.create` with ServiceAuth
+
- `clone` - Clone repositories using libgit2 with SSH agent support
+
- `info` - Display repository information including stats and languages
+
- `delete` - Delete repositories (both PDS record and knot repo)
+
- `star` / `unstar` - Star/unstar repositories via `sh.tangled.feed.star`
-
- 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`).
+
#### Issues (`issue`)
+
- `list` - List issues via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo.issue`
+
- `create` - Create issues via `com.atproto.repo.createRecord`
+
- `show` - Show issue details and comments
+
- `edit` - Edit issue title, body, or state
+
- `comment` - Add comments to issues
-
Keep edits minimal and scoped to these features.
+
#### Pull Requests (`pr`)
+
- `list` - List PRs via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo.pull`
+
- `create` - Create PRs using `git format-patch` for patches
+
- `show` - Show PR details and diff
+
- `review` - Review PRs with approve/request-changes flags
+
- `merge` - Merge PRs via `sh.tangled.repo.merge` with ServiceAuth
-
--------------------------------------------------------------------------------
+
#### Knot Management (`knot`)
+
- `migrate` - Migrate repositories between knots
+
- Validates working tree is clean and pushed
+
- Creates new repo on target knot with source seeding
+
- Updates PDS record to point to new knot
-
## 1) Repository Map (Paths You Will Touch)
+
#### Spindle CI/CD (`spindle`)
+
- `config` - Enable/disable or configure spindle URL for a repository
+
- Updates the `spindle` field in `sh.tangled.repo` record
+
- `list` - List pipeline runs via `com.atproto.repo.listRecords` with `collection=sh.tangled.pipeline`
+
- `logs` - Stream workflow logs via WebSocket (`wss://spindle.tangled.sh/spindle/logs/{knot}/{rkey}/{name}`)
+
- `secret list` - List secrets via `sh.tangled.repo.listSecrets` with ServiceAuth
+
- `secret add` - Add secrets via `sh.tangled.repo.addSecret` with ServiceAuth
+
- `secret remove` - Remove secrets via `sh.tangled.repo.removeSecret` with ServiceAuth
-
- CLI (binary):
-
- `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.
+
### 🚧 Partially Implemented / Stubs
-
- Config + session:
-
- `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).
+
#### Spindle CI/CD (`spindle`)
+
- `run` - Manually trigger a workflow (stub)
+
- **TODO**: Parse `.tangled.yml` to determine workflows
+
- **TODO**: Create pipeline record and trigger spindle ingestion
+
- **TODO**: Support manual trigger inputs
-
- API client:
-
- `tangled/crates/tangled-api/src/client.rs` → add XRPC helpers and implement `login_with_password` and `list_repos`.
+
## Architecture Overview
-
--------------------------------------------------------------------------------
+
### Workspace Structure
-
## 2) Current State Snapshot
+
- `crates/tangled-cli` - CLI binary with clap-based argument parsing
+
- `crates/tangled-config` - Configuration and keyring-backed session management
+
- `crates/tangled-api` - XRPC client wrapper for AT Protocol and Tangled APIs
+
- `crates/tangled-git` - Git operation helpers (currently unused)
-
- 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.
+
### Key Patterns
-
Goal: replace CLI stubs with real API calls for auth + repo list.
+
#### ServiceAuth Flow
+
Many Tangled API operations require ServiceAuth tokens:
+
1. Obtain token via `com.atproto.server.getServiceAuth` from PDS
+
- `aud` parameter must be `did:web:<target-host>`
+
- `exp` parameter should be Unix timestamp + 600 seconds
+
2. Use token as `Authorization: Bearer <serviceAuth>` for Tangled API calls
-
--------------------------------------------------------------------------------
+
#### Repository Creation Flow
+
Two-step process:
+
1. **PDS**: Create `sh.tangled.repo` record via `com.atproto.repo.createRecord`
+
2. **Tangled API**: Initialize bare repo via `sh.tangled.repo.create` with ServiceAuth
-
## 3) Endpoints & Data Shapes
+
#### Repository Listing
+
Done entirely via PDS (not Tangled API):
+
1. Resolve handle → DID if needed via `com.atproto.identity.resolveHandle`
+
2. List records via `com.atproto.repo.listRecords` with `collection=sh.tangled.repo`
+
3. Filter client-side (e.g., by knot)
-
### 3.1 AT Protocol – Create Session
+
#### Pull Request Merging
+
1. Fetch PR record to get patch and target branch
+
2. Obtain ServiceAuth token
+
3. Call `sh.tangled.repo.merge` with `{did, name, patch, branch, commitMessage, commitBody}`
-
- Method: `com.atproto.server.createSession`
-
- HTTP: `POST /xrpc/com.atproto.server.createSession`
-
- Request JSON:
-
- `identifier: string` → user handle or email (e.g., `alice.bsky.social`).
-
- `password: string` → password or app password.
-
- Response JSON (subset used):
-
- `accessJwt: string`
-
- `refreshJwt: string`
-
- `did: string` (e.g., `did:plc:...`)
-
- `handle: string`
+
### Base URLs and Defaults
-
Persist to keyring using `SessionManager`.
+
- **PDS Base** (auth + record operations): Default `https://bsky.social`, stored in session
+
- **Tangled API Base** (server operations): Default `https://tngl.sh`, can override via `TANGLED_API_BASE`
+
- **Spindle Base** (CI/CD): Default `wss://spindle.tangled.sh` for WebSocket logs, can override via `TANGLED_SPINDLE_BASE`
-
### 3.2 Tangled – Repo List (tentative)
+
### Session Management
-
- 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, ... }] }`
+
Sessions are stored in the system keyring:
+
- Linux: GNOME Keyring / KWallet via Secret Service API
+
- macOS: macOS Keychain
+
- Windows: Windows Credential Manager
-
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.
-
-
- Implement:
-
- `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`
-
-
- `login`:
-
- 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})`.
-
-
- `status`:
-
- Load `SessionManager::default().load()?`.
-
- If Some: print `Logged in as '{handle}' ({did})`.
-
- Else: print `Not logged in. Run: tangled auth login`.
-
-
- `logout`:
-
- `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?`.
-
- Print:
-
- 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`
-
+
Session includes:
```rust
-
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 }
-
-
impl TangledClient {
-
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>(
-
&self,
-
method: &str,
-
req: &TReq,
-
bearer: Option<&str>,
-
) -> Result<TRes> {
-
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>(
-
&self,
-
method: &str,
-
params: &[(&str, String)],
-
bearer: Option<&str>,
-
) -> Result<TRes> {
-
let url = self.xrpc_url(method);
-
let client = reqwest::Client::new();
-
let mut reqb = client.get(url).query(&params);
-
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> {
-
#[derive(Serialize)]
-
struct Req<'a> { #[serde(rename = "identifier")] identifier: &'a str, #[serde(rename = "password")] password: &'a str }
-
#[derive(Deserialize)]
-
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>> {
-
#[derive(Deserialize)]
-
struct Envelope { repos: Vec<Repository> }
-
let mut q = vec![];
-
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?;
-
Ok(env.repos)
-
}
+
struct Session {
+
access_jwt: String,
+
refresh_jwt: String,
+
did: String,
+
handle: String,
+
pds: Option<String>, // PDS base URL
}
-
-
#[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`
+
## Working with tangled-core
-
```rust
-
use anyhow::Result;
-
use dialoguer::{Input, Password};
-
use tangled_config::session::SessionManager;
-
use crate::cli::{AuthCommand, AuthLoginArgs, Cli};
+
The `../tangled-core` repository contains the server implementation and lexicon definitions.
-
pub async fn run(_cli: &Cli, cmd: AuthCommand) -> Result<()> {
-
match cmd {
-
AuthCommand::Login(args) => login(args).await,
-
AuthCommand::Status => status().await,
-
AuthCommand::Logout => logout().await,
-
}
-
}
+
### Key Files to Check
-
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 session = client.login_with_password(&handle, &password, &pds).await?;
-
SessionManager::default().save(&session)?;
-
println!("Logged in as '{}' ({})", session.handle, session.did);
-
Ok(())
-
}
+
- **Lexicons**: `../tangled-core/lexicons/**/*.json`
+
- Defines XRPC method schemas (NSIDs, parameters, responses)
+
- Example: `sh.tangled.repo.create`, `sh.tangled.repo.merge`
-
async fn status() -> Result<()> {
-
let mgr = SessionManager::default();
-
match mgr.load()? {
-
Some(s) => println!("Logged in as '{}' ({})", s.handle, s.did),
-
None => println!("Not logged in. Run: tangled auth login"),
-
}
-
Ok(())
-
}
+
- **XRPC Routes**: `../tangled-core/knotserver/xrpc/xrpc.go`
+
- Shows which endpoints require ServiceAuth
+
- Maps NSIDs to handler functions
-
async fn logout() -> Result<()> {
-
let mgr = SessionManager::default();
-
if mgr.load()?.is_some() { mgr.clear()?; println!("Logged out"); } else { println!("No session found"); }
-
Ok(())
-
}
-
```
+
- **API Handlers**: `../tangled-core/knotserver/xrpc/*.go`
+
- Implementation details for server-side operations
+
- Example: `create_repo.go`, `merge.go`
-
### 5.3 In `tangled-cli/src/commands/repo.rs`
+
### Useful Search Commands
-
```rust
-
use anyhow::{anyhow, Result};
-
use tangled_config::session::SessionManager;
-
use crate::cli::{Cli, RepoCommand, RepoListArgs};
+
```bash
+
# Find a specific NSID
+
rg -n "sh\.tangled\.repo\.create" ../tangled-core
-
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
-
match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) }
-
}
+
# List all lexicons
+
ls ../tangled-core/lexicons/repo
-
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); }
-
Ok(())
-
}
+
# Check ServiceAuth usage
+
rg -n "ServiceAuth|VerifyServiceAuth" ../tangled-core
```
-
--------------------------------------------------------------------------------
+
## Next Steps for Contributors
-
## 6) Configuration, Env Vars, and Security
+
### Priority: Implement `spindle run`
-
- 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).
+
The only remaining stub is `spindle run` for manually triggering workflows. Implementation plan:
-
--------------------------------------------------------------------------------
+
1. **Parse `.tangled.yml`** in the current repository to extract workflow definitions
+
- Look for workflow names, triggers, and manual trigger inputs
-
## 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 short message.
-
- `tangled auth status`:
-
- Shows handle + did if session exists; otherwise says not logged in.
-
- `tangled auth logout`:
-
- Clears keyring; prints confirmation.
-
- `tangled repo list`:
-
- 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
+
2. **Create pipeline record** on PDS via `com.atproto.repo.createRecord`:
+
```rust
+
collection: "sh.tangled.pipeline"
+
record: {
+
triggerMetadata: {
+
kind: "manual",
+
repo: { knot, did, repo, defaultBranch },
+
manual: { inputs: [...] }
+
},
+
workflows: [{ name, engine, clone, raw }]
+
}
+
```
-
- Refresh token flow, device code, OAuth.
-
- PRs, issues, knots, spindle implementation.
-
- Advanced formatting, paging, completions.
+
3. **Notify spindle** (if needed) or let the ingester pick up the new record
-
--------------------------------------------------------------------------------
+
4. **Support workflow selection** when multiple workflows exist:
+
- `--workflow <name>` flag to select specific workflow
+
- Default to first workflow if not specified
-
## 11) Future Follow‑ups
+
5. **Support manual inputs** (if workflow defines them):
+
- Prompt for input values or accept via flags
-
- 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.
+
### Code Quality Tasks
-
--------------------------------------------------------------------------------
+
- Add more comprehensive error messages for common failure cases
+
- Improve table formatting for list commands (consider using `tabled` crate features)
+
- Add shell completion generation (bash, zsh, fish)
+
- Add more unit tests with `mockito` for API client methods
+
- Add integration tests with `assert_cmd` for CLI commands
-
## 12) Quick Operator Commands
+
### Documentation Tasks
-
- 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>`
+
- Add man pages for all commands
+
- Create video tutorials for common workflows
+
- Add troubleshooting guide for common issues
-
--------------------------------------------------------------------------------
+
## Development Workflow
-
End of handoff. Implement auth login and repo list as described, keeping changes focused and testable.
+
### Building
+
```sh
+
cargo build # Debug build
+
cargo build --release # Release build
+
```
-
--------------------------------------------------------------------------------
+
### Running
-
## 13) Tangled Core (../tangled-core) – Practical Notes
+
```sh
+
cargo run -p tangled-cli -- <command>
+
```
-
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.
+
### Testing
-
### Where To Look
+
```sh
+
cargo test # Run all tests
+
cargo test -- --nocapture # Show println output
+
```
-
- 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`).
+
### Code Quality
-
### How To Search Quickly (rg examples)
+
```sh
+
cargo fmt # Format code
+
cargo clippy # Run linter
+
cargo clippy -- -W clippy::all # Strict linting
+
```
-
- 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`
+
## Troubleshooting Common Issues
-
### Repo Listing (client-side pattern)
+
### Keyring Errors on Linux
-
- 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.
+
Ensure a secret service is running:
+
```sh
+
systemctl --user enable --now gnome-keyring-daemon
+
```
-
### Repo Creation (two-step flow)
+
### Invalid Token Errors
-
- 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.
+
- For record operations: Use PDS client, not Tangled API client
+
- For server operations: Ensure ServiceAuth audience DID matches target host
-
### Base URLs, DIDs, and Defaults
+
### Repository Not Found
-
- 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.
+
- Verify repo exists: `tangled repo info owner/name`
+
- Check you're using the correct owner (handle or DID)
+
- Ensure you have access permissions
-
### Common Errors and Fixes
+
### WebSocket Connection Failures
-
- `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.
+
- Check spindle base URL is correct (default: `wss://spindle.tangled.sh`)
+
- Verify the job_id format: `knot:rkey:name`
+
- Ensure the workflow has actually run and has logs
-
### Implementation Pointers (CLI)
+
## Additional Resources
-
- Auth
-
- `com.atproto.server.createSession` against the PDS, save `{accessJwt, refreshJwt, did, handle, pds}` in keyring.
-
- List repos
-
- Use session.handle by default; resolve to DID, then `com.atproto.repo.listRecords` on PDS.
-
- Create repo
-
- Build the PDS record first; then ServiceAuth → `sh.tangled.repo.create` on `tngl.sh`.
+
- Main README: `README.md` - User-facing documentation
+
- Getting Started Guide: `docs/getting-started.md` - Tutorial for new users
+
- Lexicons: `../tangled-core/lexicons/` - XRPC method definitions
+
- Server Implementation: `../tangled-core/knotserver/` - Server-side code
-
### Testing Hints
+
---
-
- 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).
+
Last updated: 2025-10-14
+184 -7
Cargo.lock
···
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]]
+
name = "block-buffer"
+
version = "0.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+
dependencies = [
+
"generic-array",
+
]
+
+
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+
[[package]]
+
name = "byteorder"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
···
]
[[package]]
+
name = "core-foundation"
+
version = "0.10.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+
dependencies = [
+
"core-foundation-sys",
+
"libc",
+
]
+
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "cpufeatures"
+
version = "0.2.17"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+
dependencies = [
+
"libc",
+
]
+
+
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
+
]
+
+
[[package]]
+
name = "crypto-common"
+
version = "0.1.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+
dependencies = [
+
"generic-array",
+
"typenum",
]
[[package]]
···
]
[[package]]
+
name = "digest"
+
version = "0.10.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+
dependencies = [
+
"block-buffer",
+
"crypto-common",
+
]
+
+
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
]
[[package]]
+
name = "generic-array"
+
version = "0.14.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+
dependencies = [
+
"typenum",
+
"version_check",
+
]
+
+
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
+
"byteorder",
"dbus-secret-service",
"log",
"openssl",
+
"security-framework 2.11.1",
+
"security-framework 3.5.1",
+
"windows-sys 0.60.2",
"zeroize",
···
"openssl-probe",
"openssl-sys",
"schannel",
-
"security-framework",
+
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
···
"bytes",
"getrandom 0.3.3",
"lru-slab",
-
"rand",
+
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
···
[[package]]
name = "rand"
+
version = "0.8.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+
dependencies = [
+
"libc",
+
"rand_chacha 0.3.1",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
-
"rand_chacha",
-
"rand_core",
+
"rand_chacha 0.9.0",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_chacha"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+
dependencies = [
+
"ppv-lite86",
+
"rand_core 0.6.4",
[[package]]
···
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
-
"rand_core",
+
"rand_core 0.9.3",
+
]
+
+
[[package]]
+
name = "rand_core"
+
version = "0.6.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
dependencies = [
+
"getrandom 0.2.16",
[[package]]
···
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
-
"core-foundation",
+
"core-foundation 0.9.4",
+
"core-foundation-sys",
+
"libc",
+
"security-framework-sys",
+
]
+
+
[[package]]
+
name = "security-framework"
+
version = "3.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
+
dependencies = [
+
"bitflags",
+
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
···
[[package]]
+
name = "sha1"
+
version = "0.10.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+
dependencies = [
+
"cfg-if",
+
"cpufeatures",
+
"digest",
+
]
+
+
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags",
-
"core-foundation",
+
"core-foundation 0.9.4",
"system-configuration-sys",
···
version = "0.1.0"
dependencies = [
"anyhow",
+
"chrono",
"clap",
"colored",
"dialoguer",
+
"futures-util",
"git2",
"indicatif",
"serde",
···
"tangled-config",
"tangled-git",
"tokio",
+
"tokio-tungstenite",
"url",
···
[[package]]
+
name = "tokio-tungstenite"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
+
dependencies = [
+
"futures-util",
+
"log",
+
"native-tls",
+
"tokio",
+
"tokio-native-tls",
+
"tungstenite",
+
]
+
+
[[package]]
name = "tokio-util"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
+
name = "tungstenite"
+
version = "0.21.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
+
dependencies = [
+
"byteorder",
+
"bytes",
+
"data-encoding",
+
"http",
+
"httparse",
+
"log",
+
"native-tls",
+
"rand 0.8.5",
+
"sha1",
+
"thiserror 1.0.69",
+
"url",
+
"utf-8",
+
]
+
+
[[package]]
+
name = "typenum"
+
version = "1.19.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
+
name = "utf-8"
+
version = "0.7.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+
[[package]]
+
name = "version_check"
+
version = "0.9.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
+5 -1
Cargo.toml
···
# Storage
dirs = "5.0"
-
keyring = { version = "3.6", features = ["sync-secret-service", "vendored"] }
+
keyring = "3.6"
# Error Handling
anyhow = "1.0"
···
url = "2.5"
base64 = "0.22"
regex = "1.10"
+
+
# WebSocket
+
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
+
futures-util = "0.3"
# Testing
mockito = "1.4"
+173 -16
README.md
···
-
# Tangled CLI (Rust)
+
# Tangled CLI
A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol.
-
Status: project scaffold with CLI, config, API and git crates. Commands are stubs pending endpoint wiring.
+
## Features
+
+
Tangled CLI is a fully functional tool for managing repositories, issues, pull requests, and CI/CD workflows on the Tangled platform.
+
+
### Implemented Commands
+
+
- **Authentication** (`auth`)
+
- `login` - Authenticate with AT Protocol credentials
+
- `status` - Show current authentication status
+
- `logout` - Clear stored session
+
+
- **Repositories** (`repo`)
+
- `list` - List your repositories or another user's repos
+
- `create` - Create a new repository on a knot
+
- `clone` - Clone a repository to your local machine
+
- `info` - Show detailed repository information
+
- `delete` - Delete a repository
+
- `star` / `unstar` - Star or unstar repositories
+
+
- **Issues** (`issue`)
+
- `list` - List issues for a repository
+
- `create` - Create a new issue
+
- `show` - Show issue details and comments
+
- `edit` - Edit issue title, body, or state
+
- `comment` - Add a comment to an issue
+
+
- **Pull Requests** (`pr`)
+
- `list` - List pull requests for a repository
+
- `create` - Create a pull request from a branch
+
- `show` - Show pull request details and diff
+
- `review` - Review a pull request (approve/request changes)
+
- `merge` - Merge a pull request
+
+
- **Knot Management** (`knot`)
+
- `migrate` - Migrate a repository to another knot
+
+
- **CI/CD with Spindle** (`spindle`)
+
- `config` - Enable/disable or configure spindle for a repository
+
- `list` - List pipeline runs for a repository
+
- `logs` - Stream logs from a workflow execution
+
- `secret` - Manage secrets for CI/CD workflows
+
- `list` - List secrets for a repository
+
- `add` - Add or update a secret
+
- `remove` - Remove a secret
+
- `run` - Manually trigger a workflow (not yet implemented)
+
+
## Installation
+
+
### Build from Source
+
+
Requires Rust toolchain (1.70+) and network access to fetch dependencies.
+
+
```sh
+
cargo build --release
+
```
+
+
The binary will be available at `target/release/tangled-cli`.
+
+
### Install from AUR (Arch Linux)
+
+
Community-maintained package:
+
+
```sh
+
yay -S tangled-cli-git
+
```
+
+
## Quick Start
+
+
1. **Login to Tangled**:
+
```sh
+
tangled auth login --handle your.handle.bsky.social
+
```
+
+
2. **List your repositories**:
+
```sh
+
tangled repo list
+
```
+
+
3. **Create a new repository**:
+
```sh
+
tangled repo create myproject --description "My cool project"
+
```
+
+
4. **Clone a repository**:
+
```sh
+
tangled repo clone username/reponame
+
```
+
+
## Workspace Structure
+
+
- `crates/tangled-cli` - CLI binary with clap-based argument parsing
+
- `crates/tangled-config` - Configuration and session management (keyring-backed)
+
- `crates/tangled-api` - XRPC client wrapper for AT Protocol and Tangled APIs
+
- `crates/tangled-git` - Git operation helpers
-
## Workspace
+
## Configuration
-
- `crates/tangled-cli`: CLI binary (clap-based)
-
- `crates/tangled-config`: Config + session management
-
- `crates/tangled-api`: XRPC client wrapper (stubs)
-
- `crates/tangled-git`: Git helpers (stubs)
-
- `lexicons/sh.tangled`: Placeholder lexicons
+
The CLI stores session credentials securely in your system keyring and configuration in:
+
- Linux: `~/.config/tangled/config.toml`
+
- macOS: `~/Library/Application Support/tangled/config.toml`
+
- Windows: `%APPDATA%\tangled\config.toml`
+
+
### Environment Variables
+
+
- `TANGLED_PDS_BASE` - Override the PDS base URL (default: `https://bsky.social`)
+
- `TANGLED_API_BASE` - Override the Tangled API base URL (default: `https://tngl.sh`)
+
- `TANGLED_SPINDLE_BASE` - Override the Spindle base URL (default: `wss://spindle.tangled.sh`)
+
+
## Examples
+
+
### Working with Issues
+
+
```sh
+
# Create an issue
+
tangled issue create --repo myrepo --title "Bug: Fix login" --body "Description here"
-
## Quick start
+
# List issues
+
tangled issue list --repo myrepo
+
# Comment on an issue
+
tangled issue comment <issue-id> --body "I'll fix this"
```
-
cargo run -p tangled-cli -- --help
+
+
### Working with Pull Requests
+
+
```sh
+
# Create a PR from a branch
+
tangled pr create --repo myrepo --base main --head feature-branch --title "Add new feature"
+
+
# Review a PR
+
tangled pr review <pr-id> --approve --comment "LGTM!"
+
+
# Merge a PR
+
tangled pr merge <pr-id>
```
-
Building requires network to fetch crates.
+
### CI/CD with Spindle
-
## Next steps
+
```sh
+
# Enable spindle for your repo
+
tangled spindle config --repo myrepo --enable
+
+
# List pipeline runs
+
tangled spindle list --repo myrepo
+
+
# Stream logs from a workflow
+
tangled spindle logs knot:rkey:workflow-name --follow
+
+
# Manage secrets
+
tangled spindle secret add --repo myrepo --key API_KEY --value "secret-value"
+
tangled spindle secret list --repo myrepo
+
```
+
+
## Development
+
+
Run tests:
+
```sh
+
cargo test
+
```
+
+
Run with debug output:
+
```sh
+
cargo run -p tangled-cli -- --verbose <command>
+
```
+
+
Format code:
+
```sh
+
cargo fmt
+
```
+
+
Check for issues:
+
```sh
+
cargo clippy
+
```
+
+
## Contributing
-
- Implement `com.atproto.server.createSession` for auth
-
- Wire repo list/create endpoints under `sh.tangled.*`
-
- Persist sessions via keyring and load in CLI
-
- Add output formatting (table/json)
+
Contributions are welcome! Please feel free to submit issues or pull requests.
+
+
## License
+
MIT OR Apache-2.0
+872 -7
crates/tangled-api/src/client.rs
···
}
fn xrpc_url(&self, method: &str) -> String {
-
format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method)
+
let base = self.base_url.trim_end_matches('/');
+
// Add https:// if no protocol is present
+
let base_with_protocol = if base.starts_with("http://") || base.starts_with("https://") {
+
base.to_string()
+
} else {
+
format!("https://{}", base)
+
};
+
format!("{}/xrpc/{}", base_with_protocol, method)
}
async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(
···
Ok(res.json::<TRes>().await?)
}
-
async fn get_json<TRes: DeserializeOwned>(
+
async fn post<TReq: Serialize>(
+
&self,
+
method: &str,
+
req: &TReq,
+
bearer: Option<&str>,
+
) -> Result<()> {
+
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(())
+
}
+
+
pub async fn get_json<TRes: DeserializeOwned>(
&self,
method: &str,
params: &[(&str, String)],
···
})
}
+
pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<Session> {
+
#[derive(Deserialize)]
+
struct Res {
+
#[serde(rename = "accessJwt")]
+
access_jwt: String,
+
#[serde(rename = "refreshJwt")]
+
refresh_jwt: String,
+
did: String,
+
handle: String,
+
}
+
let url = self.xrpc_url("com.atproto.server.refreshSession");
+
let client = reqwest::Client::new();
+
let res = client
+
.post(url)
+
.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", refresh_jwt))
+
.send()
+
.await?;
+
let status = res.status();
+
if !status.is_success() {
+
let body = res.text().await.unwrap_or_default();
+
return Err(anyhow!("{}: {}", status, body));
+
}
+
let res_data: Res = res.json().await?;
+
Ok(Session {
+
access_jwt: res_data.access_jwt,
+
refresh_jwt: res_data.refresh_jwt,
+
did: res_data.did,
+
handle: res_data.handle,
+
..Default::default()
+
})
+
}
+
pub async fn list_repos(
&self,
user: Option<&str>,
···
let create_req = CreateRecordReq {
repo: opts.did,
collection: "sh.tangled.repo",
-
validate: true,
+
validate: false,
record: rec,
};
···
struct GetSARes {
token: String,
}
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
let params = [
("aud", audience),
-
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
];
let sa: GetSARes = pds_client
.get_json(
···
rkey,
knot,
description: item.value.description,
+
spindle: item.value.spindle,
});
}
}
Err(anyhow!("repo not found for owner/name"))
}
+
pub async fn get_repo_by_rkey(
+
&self,
+
did: &str,
+
rkey: &str,
+
bearer: Option<&str>,
+
) -> Result<Repository> {
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Repository,
+
}
+
let params = [
+
("repo", did.to_string()),
+
("collection", "sh.tangled.repo".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let res: GetRes = self
+
.get_json("com.atproto.repo.getRecord", &params, bearer)
+
.await?;
+
Ok(res.value)
+
}
+
+
pub async fn resolve_did_to_handle(
+
&self,
+
did: &str,
+
bearer: Option<&str>,
+
) -> Result<String> {
+
#[derive(Deserialize)]
+
struct Res {
+
handle: String,
+
}
+
let params = [("repo", did.to_string())];
+
let res: Res = self
+
.get_json("com.atproto.repo.describeRepo", &params, bearer)
+
.await?;
+
Ok(res.handle)
+
}
+
pub async fn delete_repo(
&self,
did: &str,
···
struct GetSARes {
token: String,
}
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
let params = [
("aud", audience),
-
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
];
let sa: GetSARes = pds_client
.get_json(
···
repo: did,
collection: "sh.tangled.repo",
rkey,
-
validate: true,
+
validate: false,
record: rec,
};
let _: serde_json::Value = pds_client
···
let req = Req {
repo: user_did,
collection: "sh.tangled.feed.star",
-
validate: true,
+
validate: false,
record: rec,
};
let pds_client = TangledClient::new(pds_base);
···
None
}
}
+
+
// ========== Issues ==========
+
pub async fn list_issues(
+
&self,
+
author_did: &str,
+
repo_at_uri: Option<&str>,
+
bearer: Option<&str>,
+
) -> Result<Vec<IssueRecord>> {
+
#[derive(Deserialize)]
+
struct Item {
+
uri: String,
+
#[allow(dead_code)]
+
cid: Option<String>,
+
value: Issue,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
let params = vec![
+
("repo", author_did.to_string()),
+
("collection", "sh.tangled.repo.issue".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = self
+
.get_json("com.atproto.repo.listRecords", &params, bearer)
+
.await?;
+
let mut out = vec![];
+
for it in res.records {
+
if let Some(filter_repo) = repo_at_uri {
+
if it.value.repo.as_str() != filter_repo {
+
continue;
+
}
+
}
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
+
out.push(IssueRecord {
+
author_did: author_did.to_string(),
+
rkey,
+
issue: it.value,
+
});
+
}
+
Ok(out)
+
}
+
+
#[allow(clippy::too_many_arguments)]
+
pub async fn create_issue(
+
&self,
+
author_did: &str,
+
repo_did: &str,
+
repo_rkey: &str,
+
title: &str,
+
body: Option<&str>,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<String> {
+
#[derive(Serialize)]
+
struct Rec<'a> {
+
repo: &'a str,
+
title: &'a str,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
body: Option<&'a str>,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
validate: bool,
+
record: Rec<'a>,
+
}
+
#[derive(Deserialize)]
+
struct Res {
+
uri: String,
+
}
+
let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
+
let now = chrono::Utc::now().to_rfc3339();
+
let rec = Rec {
+
repo: &issue_repo_at,
+
title,
+
body,
+
created_at: now,
+
};
+
let req = Req {
+
repo: author_did,
+
collection: "sh.tangled.repo.issue",
+
validate: false,
+
record: rec,
+
};
+
let pds_client = TangledClient::new(pds_base);
+
let res: Res = pds_client
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
+
.await?;
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri"))
+
}
+
+
pub async fn comment_issue(
+
&self,
+
author_did: &str,
+
issue_at: &str,
+
body: &str,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<String> {
+
#[derive(Serialize)]
+
struct Rec<'a> {
+
issue: &'a str,
+
body: &'a str,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
validate: bool,
+
record: Rec<'a>,
+
}
+
#[derive(Deserialize)]
+
struct Res {
+
uri: String,
+
}
+
let now = chrono::Utc::now().to_rfc3339();
+
let rec = Rec {
+
issue: issue_at,
+
body,
+
created_at: now,
+
};
+
let req = Req {
+
repo: author_did,
+
collection: "sh.tangled.repo.issue.comment",
+
validate: false,
+
record: rec,
+
};
+
let pds_client = TangledClient::new(pds_base);
+
let res: Res = pds_client
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
+
.await?;
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri"))
+
}
+
+
pub async fn get_issue_record(
+
&self,
+
author_did: &str,
+
rkey: &str,
+
bearer: Option<&str>,
+
) -> Result<Issue> {
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Issue,
+
}
+
let params = [
+
("repo", author_did.to_string()),
+
("collection", "sh.tangled.repo.issue".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let res: GetRes = self
+
.get_json("com.atproto.repo.getRecord", &params, bearer)
+
.await?;
+
Ok(res.value)
+
}
+
+
pub async fn put_issue_record(
+
&self,
+
author_did: &str,
+
rkey: &str,
+
record: &Issue,
+
bearer: Option<&str>,
+
) -> Result<()> {
+
#[derive(Serialize)]
+
struct PutReq<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
rkey: &'a str,
+
validate: bool,
+
record: &'a Issue,
+
}
+
let req = PutReq {
+
repo: author_did,
+
collection: "sh.tangled.repo.issue",
+
rkey,
+
validate: false,
+
record,
+
};
+
let _: serde_json::Value = self
+
.post_json("com.atproto.repo.putRecord", &req, bearer)
+
.await?;
+
Ok(())
+
}
+
+
pub async fn set_issue_state(
+
&self,
+
author_did: &str,
+
issue_at: &str,
+
state_nsid: &str,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<String> {
+
#[derive(Serialize)]
+
struct Rec<'a> {
+
issue: &'a str,
+
state: &'a str,
+
}
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
validate: bool,
+
record: Rec<'a>,
+
}
+
#[derive(Deserialize)]
+
struct Res {
+
uri: String,
+
}
+
let rec = Rec {
+
issue: issue_at,
+
state: state_nsid,
+
};
+
let req = Req {
+
repo: author_did,
+
collection: "sh.tangled.repo.issue.state",
+
validate: false,
+
record: rec,
+
};
+
let pds_client = TangledClient::new(pds_base);
+
let res: Res = pds_client
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
+
.await?;
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri"))
+
}
+
+
pub async fn list_issue_states(
+
&self,
+
author_did: &str,
+
bearer: Option<&str>,
+
) -> Result<Vec<IssueState>> {
+
#[derive(Deserialize)]
+
struct Item {
+
#[allow(dead_code)]
+
uri: String,
+
#[allow(dead_code)]
+
cid: Option<String>,
+
value: IssueState,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
let params = vec![
+
("repo", author_did.to_string()),
+
("collection", "sh.tangled.repo.issue.state".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = self
+
.get_json("com.atproto.repo.listRecords", &params, bearer)
+
.await?;
+
Ok(res.records.into_iter().map(|it| it.value).collect())
+
}
+
+
pub async fn get_pull_record(
+
&self,
+
author_did: &str,
+
rkey: &str,
+
bearer: Option<&str>,
+
) -> Result<Pull> {
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Pull,
+
}
+
let params = [
+
("repo", author_did.to_string()),
+
("collection", "sh.tangled.repo.pull".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let res: GetRes = self
+
.get_json("com.atproto.repo.getRecord", &params, bearer)
+
.await?;
+
Ok(res.value)
+
}
+
+
// ========== Pull Requests ==========
+
pub async fn list_pulls(
+
&self,
+
author_did: &str,
+
target_repo_at_uri: Option<&str>,
+
bearer: Option<&str>,
+
) -> Result<Vec<PullRecord>> {
+
#[derive(Deserialize)]
+
struct Item {
+
uri: String,
+
value: Pull,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
let params = vec![
+
("repo", author_did.to_string()),
+
("collection", "sh.tangled.repo.pull".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = self
+
.get_json("com.atproto.repo.listRecords", &params, bearer)
+
.await?;
+
let mut out = vec![];
+
for it in res.records {
+
if let Some(target) = target_repo_at_uri {
+
if it.value.target.repo.as_str() != target {
+
continue;
+
}
+
}
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
+
out.push(PullRecord {
+
author_did: author_did.to_string(),
+
rkey,
+
pull: it.value,
+
});
+
}
+
Ok(out)
+
}
+
+
#[allow(clippy::too_many_arguments)]
+
pub async fn create_pull(
+
&self,
+
author_did: &str,
+
repo_did: &str,
+
repo_rkey: &str,
+
target_branch: &str,
+
patch: &str,
+
title: &str,
+
body: Option<&str>,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<String> {
+
#[derive(Serialize)]
+
struct Target<'a> {
+
repo: &'a str,
+
branch: &'a str,
+
}
+
#[derive(Serialize)]
+
struct Rec<'a> {
+
target: Target<'a>,
+
title: &'a str,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
body: Option<&'a str>,
+
patch: &'a str,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
validate: bool,
+
record: Rec<'a>,
+
}
+
#[derive(Deserialize)]
+
struct Res {
+
uri: String,
+
}
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
+
let now = chrono::Utc::now().to_rfc3339();
+
let rec = Rec {
+
target: Target {
+
repo: &repo_at,
+
branch: target_branch,
+
},
+
title,
+
body,
+
patch,
+
created_at: now,
+
};
+
let req = Req {
+
repo: author_did,
+
collection: "sh.tangled.repo.pull",
+
validate: false,
+
record: rec,
+
};
+
let pds_client = TangledClient::new(pds_base);
+
let res: Res = pds_client
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
+
.await?;
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri"))
+
}
+
+
// ========== Spindle: Secrets Management ==========
+
pub async fn list_repo_secrets(
+
&self,
+
pds_base: &str,
+
access_jwt: &str,
+
repo_at: &str,
+
) -> Result<Vec<Secret>> {
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
+
#[derive(Deserialize)]
+
struct Res {
+
secrets: Vec<Secret>,
+
}
+
let params = [("repo", repo_at.to_string())];
+
let res: Res = self
+
.get_json("sh.tangled.repo.listSecrets", &params, Some(&sa))
+
.await?;
+
Ok(res.secrets)
+
}
+
+
pub async fn add_repo_secret(
+
&self,
+
pds_base: &str,
+
access_jwt: &str,
+
repo_at: &str,
+
key: &str,
+
value: &str,
+
) -> Result<()> {
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
key: &'a str,
+
value: &'a str,
+
}
+
let body = Req {
+
repo: repo_at,
+
key,
+
value,
+
};
+
self.post("sh.tangled.repo.addSecret", &body, Some(&sa))
+
.await
+
}
+
+
pub async fn remove_repo_secret(
+
&self,
+
pds_base: &str,
+
access_jwt: &str,
+
repo_at: &str,
+
key: &str,
+
) -> Result<()> {
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
key: &'a str,
+
}
+
let body = Req { repo: repo_at, key };
+
self.post("sh.tangled.repo.removeSecret", &body, Some(&sa))
+
.await
+
}
+
+
async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> {
+
let base_trimmed = self.base_url.trim_end_matches('/');
+
let host = base_trimmed
+
.strip_prefix("https://")
+
.or_else(|| base_trimmed.strip_prefix("http://"))
+
.unwrap_or(base_trimmed); // If no protocol, use the URL as-is
+
let audience = format!("did:web:{}", host);
+
#[derive(Deserialize)]
+
struct GetSARes {
+
token: String,
+
}
+
let pds = TangledClient::new(pds_base);
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
+
let params = [
+
("aud", audience),
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
+
];
+
let sa: GetSARes = pds
+
.get_json(
+
"com.atproto.server.getServiceAuth",
+
&params,
+
Some(access_jwt),
+
)
+
.await?;
+
Ok(sa.token)
+
}
+
+
pub async fn comment_pull(
+
&self,
+
author_did: &str,
+
pull_at: &str,
+
body: &str,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<String> {
+
#[derive(Serialize)]
+
struct Rec<'a> {
+
pull: &'a str,
+
body: &'a str,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Serialize)]
+
struct Req<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
validate: bool,
+
record: Rec<'a>,
+
}
+
#[derive(Deserialize)]
+
struct Res {
+
uri: String,
+
}
+
let now = chrono::Utc::now().to_rfc3339();
+
let rec = Rec {
+
pull: pull_at,
+
body,
+
created_at: now,
+
};
+
let req = Req {
+
repo: author_did,
+
collection: "sh.tangled.repo.pull.comment",
+
validate: false,
+
record: rec,
+
};
+
let pds_client = TangledClient::new(pds_base);
+
let res: Res = pds_client
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
+
.await?;
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri"))
+
}
+
+
pub async fn merge_pull(
+
&self,
+
pull_did: &str,
+
pull_rkey: &str,
+
repo_did: &str,
+
repo_name: &str,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<()> {
+
// Fetch the pull request to get patch and target branch
+
let pds_client = TangledClient::new(pds_base);
+
let pull = pds_client
+
.get_pull_record(pull_did, pull_rkey, Some(access_jwt))
+
.await?;
+
+
// Get service auth token for the knot
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
+
+
#[derive(Serialize)]
+
struct MergeReq<'a> {
+
did: &'a str,
+
name: &'a str,
+
patch: &'a str,
+
branch: &'a str,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
#[serde(rename = "commitMessage")]
+
commit_message: Option<&'a str>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
#[serde(rename = "commitBody")]
+
commit_body: Option<&'a str>,
+
}
+
+
let commit_body = if pull.body.is_empty() {
+
None
+
} else {
+
Some(pull.body.as_str())
+
};
+
+
let req = MergeReq {
+
did: repo_did,
+
name: repo_name,
+
patch: &pull.patch,
+
branch: &pull.target.branch,
+
commit_message: Some(&pull.title),
+
commit_body,
+
};
+
+
let _: serde_json::Value = self
+
.post_json("sh.tangled.repo.merge", &req, Some(&sa))
+
.await?;
+
Ok(())
+
}
+
+
pub async fn update_repo_spindle(
+
&self,
+
did: &str,
+
rkey: &str,
+
new_spindle: Option<&str>,
+
pds_base: &str,
+
access_jwt: &str,
+
) -> Result<()> {
+
let pds_client = TangledClient::new(pds_base);
+
#[derive(Deserialize, Serialize, Clone)]
+
struct Rec {
+
name: String,
+
knot: String,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
description: Option<String>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
spindle: Option<String>,
+
#[serde(rename = "createdAt")]
+
created_at: String,
+
}
+
#[derive(Deserialize)]
+
struct GetRes {
+
value: Rec,
+
}
+
let params = [
+
("repo", did.to_string()),
+
("collection", "sh.tangled.repo".to_string()),
+
("rkey", rkey.to_string()),
+
];
+
let got: GetRes = pds_client
+
.get_json("com.atproto.repo.getRecord", &params, Some(access_jwt))
+
.await?;
+
let mut rec = got.value;
+
rec.spindle = new_spindle.map(|s| s.to_string());
+
#[derive(Serialize)]
+
struct PutReq<'a> {
+
repo: &'a str,
+
collection: &'a str,
+
rkey: &'a str,
+
validate: bool,
+
record: Rec,
+
}
+
let req = PutReq {
+
repo: did,
+
collection: "sh.tangled.repo",
+
rkey,
+
validate: false,
+
record: rec,
+
};
+
let _: serde_json::Value = pds_client
+
.post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
+
.await?;
+
Ok(())
+
}
+
+
pub async fn list_pipelines(
+
&self,
+
repo_did: &str,
+
bearer: Option<&str>,
+
) -> Result<Vec<PipelineRecord>> {
+
#[derive(Deserialize)]
+
struct Item {
+
uri: String,
+
value: Pipeline,
+
}
+
#[derive(Deserialize)]
+
struct ListRes {
+
#[serde(default)]
+
records: Vec<Item>,
+
}
+
let params = vec![
+
("repo", repo_did.to_string()),
+
("collection", "sh.tangled.pipeline".to_string()),
+
("limit", "100".to_string()),
+
];
+
let res: ListRes = self
+
.get_json("com.atproto.repo.listRecords", &params, bearer)
+
.await?;
+
let mut out = vec![];
+
for it in res.records {
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
+
out.push(PipelineRecord {
+
rkey,
+
pipeline: it.value,
+
});
+
}
+
Ok(out)
+
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
pub name: String,
pub knot: Option<String>,
pub description: Option<String>,
+
pub spindle: Option<String>,
#[serde(default)]
pub private: bool,
}
+
// Issue record value
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Issue {
+
pub repo: String,
+
pub title: String,
+
#[serde(default)]
+
pub body: String,
+
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
+
pub created_at: Option<String>,
+
#[serde(rename = "$type", skip_serializing_if = "Option::is_none")]
+
pub record_type: Option<String>,
+
#[serde(skip_serializing_if = "Option::is_none")]
+
pub owner: Option<String>,
+
#[serde(rename = "issueId", skip_serializing_if = "Option::is_none")]
+
pub issue_id: Option<i64>,
+
}
+
+
#[derive(Debug, Clone)]
+
pub struct IssueRecord {
+
pub author_did: String,
+
pub rkey: String,
+
pub issue: Issue,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct IssueState {
+
pub issue: String,
+
pub state: String,
+
}
+
+
// Pull record value (subset)
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct PullTarget {
+
pub repo: String,
+
pub branch: String,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Pull {
+
pub target: PullTarget,
+
pub title: String,
+
#[serde(default)]
+
pub body: String,
+
pub patch: String,
+
#[serde(rename = "createdAt")]
+
pub created_at: String,
+
}
+
+
#[derive(Debug, Clone)]
+
pub struct PullRecord {
+
pub author_did: String,
+
pub rkey: String,
+
pub pull: Pull,
+
}
+
#[derive(Debug, Clone)]
pub struct RepoRecord {
pub did: String,
···
pub rkey: String,
pub knot: String,
pub description: Option<String>,
+
pub spindle: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
···
pub created_at: String,
}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Secret {
+
pub repo: String,
+
pub key: String,
+
#[serde(rename = "createdAt")]
+
pub created_at: String,
+
#[serde(rename = "createdBy")]
+
pub created_by: String,
+
}
+
#[derive(Debug, Clone)]
pub struct CreateRepoOptions<'a> {
pub did: &'a str,
···
pub pds_base: &'a str,
pub access_jwt: &'a str,
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct TriggerMetadata {
+
pub kind: String,
+
pub repo: TriggerRepo,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct TriggerRepo {
+
pub knot: String,
+
pub did: String,
+
pub repo: String,
+
#[serde(rename = "defaultBranch")]
+
pub default_branch: String,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Workflow {
+
pub name: String,
+
pub engine: String,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Pipeline {
+
#[serde(rename = "triggerMetadata")]
+
pub trigger_metadata: TriggerMetadata,
+
pub workflows: Vec<Workflow>,
+
}
+
+
#[derive(Debug, Clone)]
+
pub struct PipelineRecord {
+
pub rkey: String,
+
pub pipeline: Pipeline,
+
}
+4
crates/tangled-api/src/lib.rs
···
pub mod client;
pub use client::TangledClient;
+
pub use client::{
+
CreateRepoOptions, DefaultBranch, Issue, IssueRecord, IssueState, Language, Languages, Pull,
+
PullRecord, RepoRecord, Repository, Secret,
+
};
+3
crates/tangled-cli/Cargo.toml
···
tokio = { workspace = true, features = ["full"] }
git2 = { workspace = true }
url = { workspace = true }
+
tokio-tungstenite = { workspace = true }
+
futures-util = { workspace = true }
+
chrono = { workspace = true }
# Internal crates
tangled-config = { path = "../tangled-config" }
+43 -44
crates/tangled-cli/src/cli.rs
···
#[arg(long, global = true)]
pub config: Option<String>,
-
/// Use named profile
-
#[arg(long, global = true)]
-
pub profile: Option<String>,
-
/// Output format
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
pub format: OutputFormat,
···
#[derive(Args, Debug, Clone)]
pub struct PrMergeArgs {
pub id: String,
-
#[arg(long, default_value_t = false)]
-
pub squash: bool,
-
#[arg(long, default_value_t = false)]
-
pub rebase: bool,
-
#[arg(long, default_value_t = false)]
-
pub no_ff: bool,
}
#[derive(Subcommand, Debug, Clone)]
pub enum KnotCommand {
-
List(KnotListArgs),
-
Add(KnotAddArgs),
-
Verify(KnotVerifyArgs),
-
SetDefault(KnotRefArgs),
-
Remove(KnotRefArgs),
/// Migrate a repository to another knot
Migrate(KnotMigrateArgs),
}
#[derive(Args, Debug, Clone)]
-
pub struct KnotListArgs {
-
#[arg(long, default_value_t = false)]
-
pub public: bool,
-
#[arg(long, default_value_t = false)]
-
pub owned: bool,
-
}
-
-
#[derive(Args, Debug, Clone)]
-
pub struct KnotAddArgs {
-
pub url: String,
-
#[arg(long)]
-
pub did: Option<String>,
-
#[arg(long)]
-
pub name: Option<String>,
-
#[arg(long, default_value_t = false)]
-
pub verify: bool,
-
}
-
-
#[derive(Args, Debug, Clone)]
-
pub struct KnotVerifyArgs {
-
pub url: String,
-
}
-
-
#[derive(Args, Debug, Clone)]
-
pub struct KnotRefArgs {
-
pub url: String,
-
}
-
-
#[derive(Args, Debug, Clone)]
pub struct KnotMigrateArgs {
/// Repo to migrate: <owner>/<name> (owner defaults to your handle)
#[arg(long)]
···
Config(SpindleConfigArgs),
Run(SpindleRunArgs),
Logs(SpindleLogsArgs),
+
/// Secrets management
+
#[command(subcommand)]
+
Secret(SpindleSecretCommand),
}
#[derive(Args, Debug, Clone)]
···
#[arg(long)]
pub lines: Option<usize>,
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum SpindleSecretCommand {
+
/// List secrets for a repo
+
List(SpindleSecretListArgs),
+
/// Add or update a secret
+
Add(SpindleSecretAddArgs),
+
/// Remove a secret
+
Remove(SpindleSecretRemoveArgs),
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleSecretListArgs {
+
/// Repo: <owner>/<name>
+
#[arg(long)]
+
pub repo: String,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleSecretAddArgs {
+
/// Repo: <owner>/<name>
+
#[arg(long)]
+
pub repo: String,
+
/// Secret key
+
#[arg(long)]
+
pub key: String,
+
/// Secret value (use '@filename' to read from file, '-' to read from stdin)
+
#[arg(long)]
+
pub value: String,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleSecretRemoveArgs {
+
/// Repo: <owner>/<name>
+
#[arg(long)]
+
pub repo: String,
+
/// Secret key
+
#[arg(long)]
+
pub key: String,
+
}
+7 -1
crates/tangled-cli/src/commands/auth.rs
···
.unwrap_or_else(|| "https://bsky.social".to_string());
let client = tangled_api::TangledClient::new(&pds);
-
let mut session = client.login_with_password(&handle, &password, &pds).await?;
+
let mut session = match client.login_with_password(&handle, &password, &pds).await {
+
Ok(sess) => sess,
+
Err(e) => {
+
println!("\x1b[93mIf you're on your own PDS, make sure to pass the --pds flag\x1b[0m");
+
return Err(e);
+
}
+
};
session.pds = Some(pds.clone());
SessionManager::default().save(&session)?;
println!("Logged in as '{}' ({})", session.handle, session.did);
+267 -21
crates/tangled-cli/src/commands/issue.rs
···
Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs,
IssueShowArgs,
};
-
use anyhow::Result;
+
use anyhow::{anyhow, Result};
+
use tangled_api::Issue;
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
match cmd {
···
}
async fn list(args: IssueListArgs) -> Result<()> {
-
println!(
-
"Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}",
-
args.repo, args.state, args.author, args.label, args.assigned
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let client = tangled_api::TangledClient::new(&pds);
+
+
let repo_filter_at = if let Some(repo) = &args.repo {
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
+
let info = client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey))
+
} else {
+
None
+
};
+
+
let mut items = client
+
.list_issues(
+
&session.did,
+
repo_filter_at.as_deref(),
+
Some(session.access_jwt.as_str()),
+
)
+
.await?;
+
+
// Filter by state if requested
+
if let Some(state_filter) = &args.state {
+
let state_nsid = match state_filter.as_str() {
+
"open" => "sh.tangled.repo.issue.state.open",
+
"closed" => "sh.tangled.repo.issue.state.closed",
+
other => {
+
return Err(anyhow!(format!(
+
"unknown state '{}', expected 'open' or 'closed'",
+
other
+
)))
+
}
+
};
+
+
// Fetch issue states
+
let states = client
+
.list_issue_states(&session.did, Some(session.access_jwt.as_str()))
+
.await?;
+
+
// Build map of issue AT-URI to current state
+
let mut issue_states = std::collections::HashMap::new();
+
for state in states {
+
issue_states.insert(state.issue, state.state);
+
}
+
+
// Filter issues by state
+
items.retain(|it| {
+
let issue_at = format!(
+
"at://{}/sh.tangled.repo.issue/{}",
+
it.author_did, it.rkey
+
);
+
match issue_states.get(&issue_at) {
+
Some(state) => state == state_nsid,
+
None => state_nsid == "sh.tangled.repo.issue.state.open", // default to open
+
}
+
});
+
}
+
+
if items.is_empty() {
+
println!("No issues found (showing only issues you created)");
+
} else {
+
println!("RKEY\tTITLE\tREPO");
+
+
// Build cache of repo AT-URIs to formatted names
+
let mut repo_cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
+
+
for it in items {
+
let repo_display = if let Some(cached) = repo_cache.get(&it.issue.repo) {
+
cached.clone()
+
} else if let Some((repo_did, repo_rkey)) = parse_repo_at_uri(&it.issue.repo) {
+
// Fetch and format repo info
+
let formatted = match client
+
.get_repo_by_rkey(&repo_did, &repo_rkey, Some(session.access_jwt.as_str()))
+
.await
+
{
+
Ok(repo) => {
+
let handle = client
+
.resolve_did_to_handle(&repo_did, Some(session.access_jwt.as_str()))
+
.await
+
.unwrap_or(repo_did.clone());
+
format!("{}/{}", handle, repo.name)
+
}
+
Err(_) => it.issue.repo.clone(),
+
};
+
repo_cache.insert(it.issue.repo.clone(), formatted.clone());
+
formatted
+
} else {
+
it.issue.repo.clone()
+
};
+
println!("{}\t{}\t{}", it.rkey, it.issue.title, repo_display);
+
}
+
}
Ok(())
}
+
fn parse_repo_at_uri(at_uri: &str) -> Option<(String, String)> {
+
// Parse at://did/sh.tangled.repo/rkey
+
let without_prefix = at_uri.strip_prefix("at://")?;
+
let parts: Vec<&str> = without_prefix.split('/').collect();
+
if parts.len() >= 3 && parts[1] == "sh.tangled.repo" {
+
Some((parts[0].to_string(), parts[2].to_string()))
+
} else {
+
None
+
}
+
}
+
async fn create(args: IssueCreateArgs) -> Result<()> {
-
println!(
-
"Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}",
-
args.repo, args.title, args.body, args.label, args.assign
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let client = tangled_api::TangledClient::new(&pds);
+
+
let repo = args
+
.repo
+
.as_ref()
+
.ok_or_else(|| anyhow!("--repo is required for issue create"))?;
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
+
let info = client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
let title = args
+
.title
+
.as_deref()
+
.ok_or_else(|| anyhow!("--title is required for issue create"))?;
+
let rkey = client
+
.create_issue(
+
&session.did,
+
&info.did,
+
&info.rkey,
+
title,
+
args.body.as_deref(),
+
&pds,
+
&session.access_jwt,
+
)
+
.await?;
+
println!("Created issue rkey={} in {}/{}", rkey, owner, name);
Ok(())
}
async fn show(args: IssueShowArgs) -> Result<()> {
-
println!(
-
"Issue show (stub) id={} comments={} json={}",
-
args.id, args.comments, args.json
-
);
+
// For now, show only accepts at-uri or did:rkey or rkey (for your DID)
+
let session = crate::util::load_session_with_refresh().await?;
+
let id = args.id;
+
let (did, rkey) = parse_record_id(&id, &session.did)?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let client = tangled_api::TangledClient::new(&pds);
+
// Fetch all issues by this DID and find rkey
+
let items = client
+
.list_issues(&did, None, Some(session.access_jwt.as_str()))
+
.await?;
+
if let Some(it) = items.into_iter().find(|i| i.rkey == rkey) {
+
println!("TITLE: {}", it.issue.title);
+
if !it.issue.body.is_empty() {
+
println!("BODY:\n{}", it.issue.body);
+
}
+
println!("REPO: {}", it.issue.repo);
+
println!("AUTHOR: {}", it.author_did);
+
println!("RKEY: {}", rkey);
+
} else {
+
println!("Issue not found for did={} rkey={}", did, rkey);
+
}
Ok(())
}
async fn edit(args: IssueEditArgs) -> Result<()> {
-
println!(
-
"Issue edit (stub) id={} title={:?} body={:?} state={:?}",
-
args.id, args.title, args.body, args.state
-
);
+
// Simple edit: fetch existing record and putRecord with new title/body
+
let session = crate::util::load_session_with_refresh().await?;
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
// Get existing
+
let client = tangled_api::TangledClient::new(&pds);
+
let mut rec: Issue = client
+
.get_issue_record(&did, &rkey, Some(session.access_jwt.as_str()))
+
.await?;
+
if let Some(t) = args.title.as_deref() {
+
rec.title = t.to_string();
+
}
+
if let Some(b) = args.body.as_deref() {
+
rec.body = b.to_string();
+
}
+
// Put record back
+
client
+
.put_issue_record(&did, &rkey, &rec, Some(session.access_jwt.as_str()))
+
.await?;
+
+
// Optional state change
+
if let Some(state) = args.state.as_deref() {
+
let state_nsid = match state {
+
"open" => "sh.tangled.repo.issue.state.open",
+
"closed" => "sh.tangled.repo.issue.state.closed",
+
other => {
+
return Err(anyhow!(format!(
+
"unknown state '{}', expected 'open' or 'closed'",
+
other
+
)))
+
}
+
};
+
let issue_at = rec.repo.clone();
+
client
+
.set_issue_state(
+
&session.did,
+
&issue_at,
+
state_nsid,
+
&pds,
+
&session.access_jwt,
+
)
+
.await?;
+
}
+
println!("Updated issue {}:{}", did, rkey);
Ok(())
}
async fn comment(args: IssueCommentArgs) -> Result<()> {
-
println!(
-
"Issue comment (stub) id={} close={} body={:?}",
-
args.id, args.close, args.body
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
// Resolve issue AT-URI
+
let client = tangled_api::TangledClient::new(&pds);
+
let issue_at = client
+
.get_issue_record(&did, &rkey, Some(session.access_jwt.as_str()))
+
.await?
+
.repo;
+
if let Some(body) = args.body.as_deref() {
+
client
+
.comment_issue(&session.did, &issue_at, body, &pds, &session.access_jwt)
+
.await?;
+
println!("Comment posted");
+
}
+
if args.close {
+
client
+
.set_issue_state(
+
&session.did,
+
&issue_at,
+
"sh.tangled.repo.issue.state.closed",
+
&pds,
+
&session.access_jwt,
+
)
+
.await?;
+
println!("Issue closed");
+
}
Ok(())
}
+
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
+
if let Some((owner, name)) = spec.split_once('/') {
+
(owner, name)
+
} else {
+
(default_owner, spec)
+
}
+
}
+
+
fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
+
if let Some(rest) = id.strip_prefix("at://") {
+
let parts: Vec<&str> = rest.split('/').collect();
+
if parts.len() >= 4 {
+
return Ok((parts[0].to_string(), parts[3].to_string()));
+
}
+
}
+
if let Some((did, rkey)) = id.split_once(':') {
+
return Ok((did.to_string(), rkey.to_string()));
+
}
+
Ok((default_did.to_string(), id.to_string()))
+
}
+2 -44
crates/tangled-cli/src/commands/knot.rs
···
-
use crate::cli::{
-
Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotMigrateArgs, KnotRefArgs, KnotVerifyArgs,
-
};
+
use crate::cli::{Cli, KnotCommand, KnotMigrateArgs};
use anyhow::anyhow;
use anyhow::Result;
use git2::{Direction, Repository as GitRepository, StatusOptions};
use std::path::Path;
-
use tangled_config::session::SessionManager;
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
match cmd {
-
KnotCommand::List(args) => list(args).await,
-
KnotCommand::Add(args) => add(args).await,
-
KnotCommand::Verify(args) => verify(args).await,
-
KnotCommand::SetDefault(args) => set_default(args).await,
-
KnotCommand::Remove(args) => remove(args).await,
KnotCommand::Migrate(args) => migrate(args).await,
}
}
-
async fn list(args: KnotListArgs) -> Result<()> {
-
println!(
-
"Knot list (stub) public={} owned={}",
-
args.public, args.owned
-
);
-
Ok(())
-
}
-
-
async fn add(args: KnotAddArgs) -> Result<()> {
-
println!(
-
"Knot add (stub) url={} did={:?} name={:?} verify={}",
-
args.url, args.did, args.name, args.verify
-
);
-
Ok(())
-
}
-
-
async fn verify(args: KnotVerifyArgs) -> Result<()> {
-
println!("Knot verify (stub) url={}", args.url);
-
Ok(())
-
}
-
-
async fn set_default(args: KnotRefArgs) -> Result<()> {
-
println!("Knot set-default (stub) url={}", args.url);
-
Ok(())
-
}
-
-
async fn remove(args: KnotRefArgs) -> Result<()> {
-
println!("Knot remove (stub) url={}", args.url);
-
Ok(())
-
}
-
async fn migrate(args: KnotMigrateArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr
-
.load()?
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let session = crate::util::load_session_with_refresh().await?;
// 1) Ensure we're inside a git repository and working tree is clean
let repo = GitRepository::discover(Path::new("."))?;
let mut status_opts = StatusOptions::new();
+228 -19
crates/tangled-cli/src/commands/pr.rs
···
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs};
-
use anyhow::Result;
+
use anyhow::{anyhow, Result};
+
use std::path::Path;
+
use std::process::Command;
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
match cmd {
···
}
async fn list(args: PrListArgs) -> Result<()> {
-
println!(
-
"PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}",
-
args.repo, args.state, args.author, args.reviewer
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let client = tangled_api::TangledClient::new(&pds);
+
let target_repo_at = if let Some(repo) = &args.repo {
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
+
let info = client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey))
+
} else {
+
None
+
};
+
let pulls = client
+
.list_pulls(
+
&session.did,
+
target_repo_at.as_deref(),
+
Some(session.access_jwt.as_str()),
+
)
+
.await?;
+
if pulls.is_empty() {
+
println!("No pull requests found (showing only those you created)");
+
} else {
+
println!("RKEY\tTITLE\tTARGET");
+
for pr in pulls {
+
println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo);
+
}
+
}
Ok(())
}
async fn create(args: PrCreateArgs) -> Result<()> {
+
// Must be run inside the repo checkout; we will use git format-patch to build the patch
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let client = tangled_api::TangledClient::new(&pds);
+
+
let repo = args
+
.repo
+
.as_ref()
+
.ok_or_else(|| anyhow!("--repo is required for pr create"))?;
+
let (owner, name) = parse_repo_ref(repo, "");
+
let info = client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
+
let base = args
+
.base
+
.as_deref()
+
.ok_or_else(|| anyhow!("--base is required (target branch)"))?;
+
let head = args
+
.head
+
.as_deref()
+
.ok_or_else(|| anyhow!("--head is required (source range/branch)"))?;
+
+
// Generate format-patch using external git for fidelity
+
let output = Command::new("git")
+
.arg("format-patch")
+
.arg("--stdout")
+
.arg(format!("{}..{}", base, head))
+
.current_dir(Path::new("."))
+
.output()?;
+
if !output.status.success() {
+
return Err(anyhow!("failed to run git format-patch"));
+
}
+
let patch = String::from_utf8_lossy(&output.stdout).to_string();
+
if patch.trim().is_empty() {
+
return Err(anyhow!("no changes between base and head"));
+
}
+
+
let title_buf;
+
let title = if let Some(t) = args.title.as_deref() {
+
t
+
} else {
+
title_buf = format!("{} -> {}", head, base);
+
&title_buf
+
};
+
let rkey = client
+
.create_pull(
+
&session.did,
+
&info.did,
+
&info.rkey,
+
base,
+
&patch,
+
title,
+
args.body.as_deref(),
+
&pds,
+
&session.access_jwt,
+
)
+
.await?;
println!(
-
"PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
-
args.repo, args.base, args.head, args.title, args.draft
+
"Created PR rkey={} targeting {} branch {}",
+
rkey, info.did, base
);
Ok(())
}
async fn show(args: PrShowArgs) -> Result<()> {
-
println!(
-
"PR show (stub) id={} diff={} comments={} checks={}",
-
args.id, args.diff, args.comments, args.checks
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let client = tangled_api::TangledClient::new(&pds);
+
let pr = client
+
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
+
.await?;
+
println!("TITLE: {}", pr.title);
+
if !pr.body.is_empty() {
+
println!("BODY:\n{}", pr.body);
+
}
+
println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch);
+
if args.diff {
+
println!("PATCH:\n{}", pr.patch);
+
}
Ok(())
}
async fn review(args: PrReviewArgs) -> Result<()> {
-
println!(
-
"PR review (stub) id={} approve={} request_changes={} comment={:?}",
-
args.id, args.approve, args.request_changes, args.comment
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pr_at = format!("at://{}/sh.tangled.repo.pull/{}", did, rkey);
+
let note = if let Some(c) = args.comment.as_deref() {
+
c
+
} else if args.approve {
+
"LGTM"
+
} else if args.request_changes {
+
"Requesting changes"
+
} else {
+
""
+
};
+
if note.is_empty() {
+
return Err(anyhow!("provide --comment or --approve/--request-changes"));
+
}
+
let client = tangled_api::TangledClient::new(&pds);
+
client
+
.comment_pull(&session.did, &pr_at, note, &pds, &session.access_jwt)
+
.await?;
+
println!("Review comment posted");
Ok(())
}
async fn merge(args: PrMergeArgs) -> Result<()> {
-
println!(
-
"PR merge (stub) id={} squash={} rebase={} no_ff={}",
-
args.id, args.squash, args.rebase, args.no_ff
-
);
+
let session = crate::util::load_session_with_refresh().await?;
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
+
// Get the PR to find the target repo
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
let pull = pds_client
+
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
+
.await?;
+
+
// Parse the target repo AT-URI to get did and name
+
let target_repo = &pull.target.repo;
+
// Format: at://did:plc:.../sh.tangled.repo/rkey
+
let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect();
+
if parts.len() < 2 {
+
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
+
}
+
let repo_did = parts[0];
+
+
// Get repo info to find the name
+
// Parse rkey from target repo AT-URI
+
let repo_rkey = if parts.len() >= 4 {
+
parts[3]
+
} else {
+
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
+
};
+
+
#[derive(serde::Deserialize)]
+
struct Rec {
+
name: String,
+
}
+
#[derive(serde::Deserialize)]
+
struct GetRes {
+
value: Rec,
+
}
+
let params = [
+
("repo", repo_did.to_string()),
+
("collection", "sh.tangled.repo".to_string()),
+
("rkey", repo_rkey.to_string()),
+
];
+
let repo_rec: GetRes = pds_client
+
.get_json("com.atproto.repo.getRecord", &params, Some(session.access_jwt.as_str()))
+
.await?;
+
+
// Call merge on the default Tangled API base (tngl.sh)
+
let api = tangled_api::TangledClient::default();
+
api.merge_pull(
+
&did,
+
&rkey,
+
repo_did,
+
&repo_rec.value.name,
+
&pds,
+
&session.access_jwt,
+
)
+
.await?;
+
+
println!("Merged PR {}:{}", did, rkey);
Ok(())
}
+
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
+
if let Some((owner, name)) = spec.split_once('/') {
+
if !owner.is_empty() {
+
(owner, name)
+
} else {
+
(default_owner, name)
+
}
+
} else {
+
(default_owner, spec)
+
}
+
}
+
+
fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
+
if let Some(rest) = id.strip_prefix("at://") {
+
let parts: Vec<&str> = rest.split('/').collect();
+
if parts.len() >= 4 {
+
return Ok((parts[0].to_string(), parts[3].to_string()));
+
}
+
}
+
if let Some((did, rkey)) = id.split_once(':') {
+
return Ok((did.to_string(), rkey.to_string()));
+
}
+
Ok((default_did.to_string(), id.to_string()))
+
}
+12 -31
crates/tangled-cli/src/commands/repo.rs
···
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
use serde_json;
use std::path::PathBuf;
-
use tangled_config::session::SessionManager;
use crate::cli::{
Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs,
···
}
async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = match mgr.load()? {
-
Some(s) => s,
-
None => return Err(anyhow!("Please login first: tangled auth login")),
-
};
+
let session = crate::util::load_session_with_refresh().await?;
// Use the PDS to list repo records for the user
let pds = session
···
}
async fn create(args: RepoCreateArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = match mgr.load()? {
-
Some(s) => s,
-
None => return Err(anyhow!("Please login first: tangled auth login")),
-
};
+
let session = crate::util::load_session_with_refresh().await?;
let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into());
let client = tangled_api::TangledClient::new(base);
···
}
async fn clone(args: RepoCloneArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr
-
.load()?
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let session = crate::util::load_session_with_refresh().await?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
···
}
async fn info(args: RepoInfoArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr
-
.load()?
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let session = crate::util::load_session_with_refresh().await?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
···
println!("NAME: {}", info.name);
println!("OWNER DID: {}", info.did);
println!("KNOT: {}", info.knot);
+
if let Some(spindle) = info.spindle.as_deref() {
+
if !spindle.is_empty() {
+
println!("SPINDLE: {}", spindle);
+
}
+
}
if let Some(desc) = info.description.as_deref() {
if !desc.is_empty() {
println!("DESCRIPTION: {}", desc);
···
}
async fn delete(args: RepoDeleteArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr
-
.load()?
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let session = crate::util::load_session_with_refresh().await?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
···
}
async fn star(args: RepoRefArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr
-
.load()?
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let session = crate::util::load_session_with_refresh().await?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
···
}
async fn unstar(args: RepoRefArgs) -> Result<()> {
-
let mgr = SessionManager::default();
-
let session = mgr
-
.load()?
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
let session = crate::util::load_session_with_refresh().await?;
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let pds = session
.pds
+284 -9
crates/tangled-cli/src/commands/spindle.rs
···
use crate::cli::{
Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs,
+
SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs,
};
-
use anyhow::Result;
+
use anyhow::{anyhow, Result};
+
use futures_util::StreamExt;
+
use tokio_tungstenite::{connect_async, tungstenite::Message};
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
match cmd {
···
SpindleCommand::Config(args) => config(args).await,
SpindleCommand::Run(args) => run_pipeline(args).await,
SpindleCommand::Logs(args) => logs(args).await,
+
SpindleCommand::Secret(cmd) => secret(cmd).await,
}
}
async fn list(args: SpindleListArgs) -> Result<()> {
-
println!("Spindle list (stub) repo={:?}", args.repo);
+
let session = crate::util::load_session_with_refresh().await?;
+
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
+
let (owner, name) = parse_repo_ref(
+
args.repo.as_deref().unwrap_or(&session.handle),
+
&session.handle
+
);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
+
let pipelines = pds_client
+
.list_pipelines(&info.did, Some(session.access_jwt.as_str()))
+
.await?;
+
+
if pipelines.is_empty() {
+
println!("No pipelines found for {}/{}", owner, name);
+
} else {
+
println!("RKEY\tKIND\tREPO\tWORKFLOWS");
+
for p in pipelines {
+
let workflows = p.pipeline.workflows
+
.iter()
+
.map(|w| w.name.as_str())
+
.collect::<Vec<_>>()
+
.join(",");
+
println!(
+
"{}\t{}\t{}\t{}",
+
p.rkey,
+
p.pipeline.trigger_metadata.kind,
+
p.pipeline.trigger_metadata.repo.repo,
+
workflows
+
);
+
}
+
}
Ok(())
}
async fn config(args: SpindleConfigArgs) -> Result<()> {
-
println!(
-
"Spindle config (stub) repo={:?} url={:?} enable={} disable={}",
-
args.repo, args.url, args.enable, args.disable
+
let session = crate::util::load_session_with_refresh().await?;
+
+
if args.enable && args.disable {
+
return Err(anyhow!("Cannot use --enable and --disable together"));
+
}
+
+
if !args.enable && !args.disable && args.url.is_none() {
+
return Err(anyhow!(
+
"Must provide --enable, --disable, or --url"
+
));
+
}
+
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
+
let (owner, name) = parse_repo_ref(
+
args.repo.as_deref().unwrap_or(&session.handle),
+
&session.handle
);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
+
let new_spindle = if args.disable {
+
None
+
} else if let Some(url) = args.url.as_deref() {
+
Some(url)
+
} else if args.enable {
+
// Default spindle URL
+
Some("https://spindle.tangled.sh")
+
} else {
+
return Err(anyhow!("Invalid flags combination"));
+
};
+
+
pds_client
+
.update_repo_spindle(&info.did, &info.rkey, new_spindle, &pds, &session.access_jwt)
+
.await?;
+
+
if args.disable {
+
println!("Disabled spindle for {}/{}", owner, name);
+
} else {
+
println!(
+
"Enabled spindle for {}/{} ({})",
+
owner,
+
name,
+
new_spindle.unwrap_or_default()
+
);
+
}
Ok(())
}
···
}
async fn logs(args: SpindleLogsArgs) -> Result<()> {
-
println!(
-
"Spindle logs (stub) job_id={} follow={} lines={:?}",
-
args.job_id, args.follow, args.lines
-
);
+
// Parse job_id: format is "knot:rkey:name" or just "name" (use repo context)
+
let parts: Vec<&str> = args.job_id.split(':').collect();
+
let (knot, rkey, name) = if parts.len() == 3 {
+
(parts[0].to_string(), parts[1].to_string(), parts[2].to_string())
+
} else if parts.len() == 1 {
+
// Use repo context - need to get repo info
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
// Get repo info from current directory context or default to user's handle
+
let info = pds_client
+
.get_repo_info(&session.handle, &session.handle, Some(session.access_jwt.as_str()))
+
.await?;
+
(info.knot, info.rkey, parts[0].to_string())
+
} else {
+
return Err(anyhow!("Invalid job_id format. Expected 'knot:rkey:name' or 'name'"));
+
};
+
+
// Build WebSocket URL - spindle base is typically https://spindle.tangled.sh
+
let spindle_base = std::env::var("TANGLED_SPINDLE_BASE")
+
.unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string());
+
let ws_url = format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name);
+
+
println!("Connecting to logs stream for {}:{}:{}...", knot, rkey, name);
+
+
// Connect to WebSocket
+
let (ws_stream, _) = connect_async(&ws_url).await
+
.map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?;
+
+
let (mut _write, mut read) = ws_stream.split();
+
+
// Stream log messages
+
let mut line_count = 0;
+
let max_lines = args.lines.unwrap_or(usize::MAX);
+
+
while let Some(msg) = read.next().await {
+
match msg {
+
Ok(Message::Text(text)) => {
+
println!("{}", text);
+
line_count += 1;
+
if line_count >= max_lines {
+
break;
+
}
+
}
+
Ok(Message::Close(_)) => {
+
break;
+
}
+
Err(e) => {
+
return Err(anyhow!("WebSocket error: {}", e));
+
}
+
_ => {}
+
}
+
}
+
+
Ok(())
+
}
+
+
async fn secret(cmd: SpindleSecretCommand) -> Result<()> {
+
match cmd {
+
SpindleSecretCommand::List(args) => secret_list(args).await,
+
SpindleSecretCommand::Add(args) => secret_add(args).await,
+
SpindleSecretCommand::Remove(args) => secret_remove(args).await,
+
}
+
}
+
+
async fn secret_list(args: SpindleSecretListArgs) -> Result<()> {
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
+
+
// Get spindle base from repo config or use default
+
let spindle_base = info.spindle
+
.clone()
+
.or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
+
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
+
let api = tangled_api::TangledClient::new(&spindle_base);
+
+
let secrets = api
+
.list_repo_secrets(&pds, &session.access_jwt, &repo_at)
+
.await?;
+
if secrets.is_empty() {
+
println!("No secrets configured for {}", args.repo);
+
} else {
+
println!("KEY\tCREATED AT\tCREATED BY");
+
for s in secrets {
+
println!("{}\t{}\t{}", s.key, s.created_at, s.created_by);
+
}
+
}
Ok(())
}
+
+
async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> {
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
+
+
// Get spindle base from repo config or use default
+
let spindle_base = info.spindle
+
.clone()
+
.or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
+
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
+
let api = tangled_api::TangledClient::new(&spindle_base);
+
+
// Handle special value patterns: @file or - (stdin)
+
let value = if args.value == "-" {
+
// Read from stdin
+
use std::io::Read;
+
let mut buffer = String::new();
+
std::io::stdin().read_to_string(&mut buffer)?;
+
buffer
+
} else if let Some(path) = args.value.strip_prefix('@') {
+
// Read from file, expand ~ if needed
+
let expanded_path = if path.starts_with("~/") {
+
if let Some(home) = std::env::var("HOME").ok() {
+
path.replacen("~/", &format!("{}/", home), 1)
+
} else {
+
path.to_string()
+
}
+
} else {
+
path.to_string()
+
};
+
std::fs::read_to_string(&expanded_path)
+
.map_err(|e| anyhow!("Failed to read file '{}': {}", expanded_path, e))?
+
} else {
+
// Use value as-is
+
args.value
+
};
+
+
api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &value)
+
.await?;
+
println!("Added secret '{}' to {}", args.key, args.repo);
+
Ok(())
+
}
+
+
async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> {
+
let session = crate::util::load_session_with_refresh().await?;
+
let pds = session
+
.pds
+
.clone()
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
+
.unwrap_or_else(|| "https://bsky.social".into());
+
let pds_client = tangled_api::TangledClient::new(&pds);
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
+
let info = pds_client
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
+
.await?;
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
+
+
// Get spindle base from repo config or use default
+
let spindle_base = info.spindle
+
.clone()
+
.or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
+
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
+
let api = tangled_api::TangledClient::new(&spindle_base);
+
+
api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key)
+
.await?;
+
println!("Removed secret '{}' from {}", args.key, args.repo);
+
Ok(())
+
}
+
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
+
if let Some((owner, name)) = spec.split_once('/') {
+
(owner, name)
+
} else {
+
(default_owner, spec)
+
}
+
}
+1
crates/tangled-cli/src/main.rs
···
mod cli;
mod commands;
+
mod util;
use anyhow::Result;
use clap::Parser;
+55
crates/tangled-cli/src/util.rs
···
+
use anyhow::{anyhow, Result};
+
use tangled_config::session::{Session, SessionManager};
+
+
/// Load session and automatically refresh if expired
+
pub async fn load_session() -> Result<Session> {
+
let mgr = SessionManager::default();
+
let session = mgr
+
.load()?
+
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
+
+
Ok(session)
+
}
+
+
/// Refresh the session using the refresh token
+
pub async fn refresh_session(session: &Session) -> Result<Session> {
+
let pds = session
+
.pds
+
.clone()
+
.unwrap_or_else(|| "https://bsky.social".to_string());
+
+
let client = tangled_api::TangledClient::new(&pds);
+
let mut new_session = client.refresh_session(&session.refresh_jwt).await?;
+
+
// Preserve PDS from old session
+
new_session.pds = session.pds.clone();
+
+
// Save the refreshed session
+
let mgr = SessionManager::default();
+
mgr.save(&new_session)?;
+
+
Ok(new_session)
+
}
+
+
/// Load session with automatic refresh on ExpiredToken
+
pub async fn load_session_with_refresh() -> Result<Session> {
+
let session = load_session().await?;
+
+
// Check if session is older than 30 minutes - if so, proactively refresh
+
let age = chrono::Utc::now()
+
.signed_duration_since(session.created_at)
+
.num_minutes();
+
+
if age > 30 {
+
// Session is old, proactively refresh
+
match refresh_session(&session).await {
+
Ok(new_session) => return Ok(new_session),
+
Err(_) => {
+
// If refresh fails, try with the old session anyway
+
// It might still work
+
}
+
}
+
}
+
+
Ok(session)
+
}
+9 -1
crates/tangled-config/Cargo.toml
···
[dependencies]
anyhow = { workspace = true }
dirs = { workspace = true }
-
keyring = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
toml = { workspace = true }
chrono = { workspace = true }
+
[target.'cfg(target_os = "macos")'.dependencies]
+
keyring = { workspace = true, features = ["apple-native"] }
+
+
[target.'cfg(target_os = "linux")'.dependencies]
+
keyring = { workspace = true, features = ["sync-secret-service", "vendored"] }
+
+
[target.'cfg(target_os = "windows")'.dependencies]
+
keyring = { workspace = true, features = ["windows-native"] }
+
+8 -3
crates/tangled-config/src/config.rs
···
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
-
use dirs::config_dir;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
}
pub fn default_config_path() -> Result<PathBuf> {
-
let base = config_dir().context("Could not determine platform config directory")?;
-
Ok(base.join("tangled").join("config.toml"))
+
// Use ~/.config/tangled on all platforms for consistency
+
let home = std::env::var("HOME")
+
.or_else(|_| std::env::var("USERPROFILE"))
+
.context("Could not determine home directory")?;
+
Ok(PathBuf::from(home)
+
.join(".config")
+
.join("tangled")
+
.join("config.toml"))
}
pub fn load_config(path: Option<&Path>) -> Result<Option<RootConfig>> {
+2 -2
crates/tangled-config/src/keychain.rs
···
pub fn set_password(&self, secret: &str) -> Result<()> {
self.entry()?
.set_password(secret)
-
.map_err(|e| anyhow!("keyring error: {e}"))
+
.map_err(|e| anyhow!("Failed to save credentials to keychain: {e}"))
}
pub fn get_password(&self) -> Result<String> {
self.entry()?
.get_password()
-
.map_err(|e| anyhow!("keyring error: {e}"))
+
.map_err(|e| anyhow!("Failed to load credentials from keychain: {e}"))
}
pub fn delete_password(&self) -> Result<()> {
+303 -7
docs/getting-started.md
···
-
# Getting Started
+
# Getting Started with Tangled CLI
+
+
This guide will help you get up and running with the Tangled CLI.
+
+
## Installation
+
+
### Prerequisites
+
+
- Rust toolchain 1.70 or later
+
- Git
+
- A Bluesky/AT Protocol account
+
+
### Build from Source
+
+
1. Clone the repository:
+
```sh
+
git clone https://tangled.org/tangled/tangled-cli
+
cd tangled-cli
+
```
+
+
2. Build the project:
+
```sh
+
cargo build --release
+
```
+
+
3. The binary will be available at `target/release/tangled-cli`. Optionally, add it to your PATH or create an alias:
+
```sh
+
alias tangled='./target/release/tangled-cli'
+
```
+
+
### Install from AUR (Arch Linux)
+
+
If you're on Arch Linux, you can install from the AUR:
+
+
```sh
+
yay -S tangled-cli-git
+
```
+
+
## First Steps
+
+
### 1. Authenticate
+
+
Login with your AT Protocol credentials (your Bluesky account):
+
+
```sh
+
tangled auth login
+
```
+
+
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:
+
+
```sh
+
tangled auth login --pds https://your-pds.example.com
+
```
+
+
Your credentials are stored securely in your system keyring.
+
+
### 2. Check Your Status
+
+
Verify you're logged in:
+
+
```sh
+
tangled auth status
+
```
+
+
### 3. List Your Repositories
+
+
See all your repositories:
+
+
```sh
+
tangled repo list
+
```
+
+
Or view someone else's public repositories:
+
+
```sh
+
tangled repo list --user alice.bsky.social
+
```
+
+
### 4. Create a Repository
+
+
Create a new repository on Tangled:
+
+
```sh
+
tangled repo create my-project --description "My awesome project"
+
```
+
+
By default, repositories are created on the default knot (`tngl.sh`). You can specify a different knot:
+
+
```sh
+
tangled repo create my-project --knot knot1.tangled.sh
+
```
+
+
### 5. Clone a Repository
+
+
Clone a repository to start working on it:
+
+
```sh
+
tangled repo clone alice/my-project
+
```
+
+
This uses SSH by default. For HTTPS:
+
+
```sh
+
tangled repo clone alice/my-project --https
+
```
+
+
## Working with Issues
+
+
### Create an Issue
+
+
```sh
+
tangled issue create --repo my-project --title "Add new feature" --body "We should add feature X"
+
```
+
+
### List Issues
+
+
```sh
+
tangled issue list --repo my-project
+
```
-
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.
+
### View Issue Details
-
## Build
+
```sh
+
tangled issue show <issue-id>
+
```
-
Requires Rust toolchain and network access to fetch dependencies.
+
### Comment on an Issue
+
```sh
+
tangled issue comment <issue-id> --body "I'm working on this!"
```
-
cargo build
+
+
## Working with Pull Requests
+
+
### Create a Pull Request
+
+
```sh
+
tangled pr create --repo my-project --base main --head feature-branch --title "Add feature X"
```
-
## Run
+
The CLI will use `git format-patch` to create a patch from your branch.
+
### List Pull Requests
+
+
```sh
+
tangled pr list --repo my-project
```
-
cargo run -p tangled-cli -- --help
+
+
### Review a Pull Request
+
+
```sh
+
tangled pr review <pr-id> --approve --comment "Looks good!"
```
+
Or request changes:
+
+
```sh
+
tangled pr review <pr-id> --request-changes --comment "Please fix the tests"
+
```
+
+
### Merge a Pull Request
+
+
```sh
+
tangled pr merge <pr-id>
+
```
+
+
## CI/CD with Spindle
+
+
Spindle is Tangled's integrated CI/CD system.
+
+
### Enable Spindle for Your Repository
+
+
```sh
+
tangled spindle config --repo my-project --enable
+
```
+
+
Or use a custom spindle URL:
+
+
```sh
+
tangled spindle config --repo my-project --url https://my-spindle.example.com
+
```
+
+
### View Pipeline Runs
+
+
```sh
+
tangled spindle list --repo my-project
+
```
+
+
### Stream Workflow Logs
+
+
```sh
+
tangled spindle logs knot:rkey:workflow-name
+
```
+
+
Add `--follow` to tail the logs in real-time.
+
+
### Manage Secrets
+
+
Add secrets for your CI/CD workflows:
+
+
```sh
+
tangled spindle secret add --repo my-project --key API_KEY --value "my-secret-value"
+
```
+
+
List secrets:
+
+
```sh
+
tangled spindle secret list --repo my-project
+
```
+
+
Remove a secret:
+
+
```sh
+
tangled spindle secret remove --repo my-project --key API_KEY
+
```
+
+
## Advanced Topics
+
+
### Repository Migration
+
+
Move a repository to a different knot:
+
+
```sh
+
tangled knot migrate --repo my-project --to knot2.tangled.sh
+
```
+
+
This command must be run from within the repository's working directory, and your working tree must be clean and pushed.
+
+
### Output Formats
+
+
Most commands support JSON output:
+
+
```sh
+
tangled repo list --format json
+
```
+
+
### Quiet and Verbose Modes
+
+
Reduce output:
+
+
```sh
+
tangled --quiet repo list
+
```
+
+
Increase verbosity for debugging:
+
+
```sh
+
tangled --verbose repo list
+
```
+
+
## Configuration
+
+
The CLI stores configuration in:
+
- Linux: `~/.config/tangled/config.toml`
+
- macOS: `~/Library/Application Support/tangled/config.toml`
+
- Windows: `%APPDATA%\tangled\config.toml`
+
+
Session credentials are stored securely in your system keyring (GNOME Keyring, KWallet, macOS Keychain, or Windows Credential Manager).
+
+
### Environment Variables
+
+
- `TANGLED_PDS_BASE` - Override the default PDS (default: `https://bsky.social`)
+
- `TANGLED_API_BASE` - Override the Tangled API base (default: `https://tngl.sh`)
+
- `TANGLED_SPINDLE_BASE` - Override the Spindle base (default: `wss://spindle.tangled.sh`)
+
+
## Troubleshooting
+
+
### Keyring Issues on Linux
+
+
If you see keyring errors on Linux, ensure you have a secret service running:
+
+
```sh
+
# For GNOME
+
systemctl --user enable --now gnome-keyring-daemon
+
+
# For KDE
+
# KWallet should start automatically with Plasma
+
```
+
+
### Authentication Failures
+
+
If authentication fails with your custom PDS:
+
+
```sh
+
tangled auth login --pds https://your-pds.example.com
+
```
+
+
Make sure the PDS URL is correct and accessible.
+
+
### "Repository not found" Errors
+
+
Verify the repository exists and you have access:
+
+
```sh
+
tangled repo info owner/reponame
+
```
+
+
## Getting Help
+
+
For command-specific help, use the `--help` flag:
+
+
```sh
+
tangled --help
+
tangled repo --help
+
tangled repo create --help
+
```
+
+
## Next Steps
+
+
- Explore all available commands with `tangled --help`
+
- Set up CI/CD workflows with `.tangled.yml` in your repository
+
- Check out the main README for more examples and advanced usage
+
+
Happy collaborating! 🧶