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