+188
-375
AGENTS.md
+188
-375
AGENTS.md
···
1
-
# Tangled CLI – Agent Handoff (Massive Context)
1
+
# Tangled CLI – Current Implementation Status
2
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.
3
+
This document provides an overview of the Tangled CLI implementation status for AI agents or developers working on the project.
4
4
5
-
Primary focus for this session: implement authentication (auth login/status/logout) and repository listing (repo list).
5
+
## Implementation Status
6
6
7
-
--------------------------------------------------------------------------------
7
+
### ✅ Fully Implemented
8
8
9
-
## 0) TL;DR – Immediate Actions
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
10
13
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.
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`
16
23
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`).
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
21
30
22
-
Keep edits minimal and scoped to these features.
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
23
37
24
-
--------------------------------------------------------------------------------
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
25
43
26
-
## 1) Repository Map (Paths You Will Touch)
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
27
52
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.
53
+
### 🚧 Partially Implemented / Stubs
33
54
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).
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
37
60
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}")`.
61
+
## Architecture Overview
106
62
107
-
### 4.2 Wire CLI auth commands
63
+
### Workspace Structure
108
64
109
-
File: `tangled/crates/tangled-cli/src/commands/auth.rs`
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)
110
69
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})`.
70
+
### Key Patterns
118
71
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`.
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
123
78
124
-
- `logout`:
125
-
- `SessionManager::default().clear()?`.
126
-
- Print `Logged out` if something was cleared; otherwise `No session found` is acceptable.
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
127
83
128
-
### 4.3 Wire CLI repo list
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)
129
89
130
-
File: `tangled/crates/tangled-cli/src/commands/repo.rs`
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}`
131
94
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.
95
+
### Base URLs and Defaults
140
96
141
-
--------------------------------------------------------------------------------
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`
142
100
143
-
## 5) Code Snippets (Copy/Paste Friendly)
101
+
### Session Management
144
102
145
-
### 5.1 In `tangled-api/src/client.rs`
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
146
107
108
+
Session includes:
147
109
```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
-
}
110
+
struct Session {
111
+
access_jwt: String,
112
+
refresh_jwt: String,
113
+
did: String,
114
+
handle: String,
115
+
pds: Option<String>, // PDS base URL
221
116
}
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
117
```
226
118
227
-
### 5.2 In `tangled-cli/src/commands/auth.rs`
119
+
## Working with tangled-core
228
120
229
-
```rust
230
-
use anyhow::Result;
231
-
use dialoguer::{Input, Password};
232
-
use tangled_config::session::SessionManager;
233
-
use crate::cli::{AuthCommand, AuthLoginArgs, Cli};
121
+
The `../tangled-core` repository contains the server implementation and lexicon definitions.
234
122
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
-
}
123
+
### Key Files to Check
242
124
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
-
}
125
+
- **Lexicons**: `../tangled-core/lexicons/**/*.json`
126
+
- Defines XRPC method schemas (NSIDs, parameters, responses)
127
+
- Example: `sh.tangled.repo.create`, `sh.tangled.repo.merge`
259
128
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
-
}
129
+
- **XRPC Routes**: `../tangled-core/knotserver/xrpc/xrpc.go`
130
+
- Shows which endpoints require ServiceAuth
131
+
- Maps NSIDs to handler functions
268
132
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
-
```
133
+
- **API Handlers**: `../tangled-core/knotserver/xrpc/*.go`
134
+
- Implementation details for server-side operations
135
+
- Example: `create_repo.go`, `merge.go`
275
136
276
-
### 5.3 In `tangled-cli/src/commands/repo.rs`
137
+
### Useful Search Commands
277
138
278
-
```rust
279
-
use anyhow::{anyhow, Result};
280
-
use tangled_config::session::SessionManager;
281
-
use crate::cli::{Cli, RepoCommand, RepoListArgs};
139
+
```bash
140
+
# Find a specific NSID
141
+
rg -n "sh\.tangled\.repo\.create" ../tangled-core
282
142
283
-
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
284
-
match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) }
285
-
}
143
+
# List all lexicons
144
+
ls ../tangled-core/lexicons/repo
286
145
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
-
}
146
+
# Check ServiceAuth usage
147
+
rg -n "ServiceAuth|VerifyServiceAuth" ../tangled-core
298
148
```
299
149
300
-
--------------------------------------------------------------------------------
150
+
## Next Steps for Contributors
301
151
302
-
## 6) Configuration, Env Vars, and Security
152
+
### Priority: Implement `spindle run`
303
153
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).
154
+
The only remaining stub is `spindle run` for manually triggering workflows. Implementation plan:
308
155
309
-
--------------------------------------------------------------------------------
156
+
1. **Parse `.tangled.yml`** in the current repository to extract workflow definitions
157
+
- Look for workflow names, triggers, and manual trigger inputs
310
158
311
-
## 7) Testing Plan (MVP)
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
+
```
312
171
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.
172
+
3. **Notify spindle** (if needed) or let the ingester pick up the new record
316
173
317
-
--------------------------------------------------------------------------------
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
318
177
319
-
## 8) Acceptance Criteria
178
+
5. **Support manual inputs** (if workflow defines them):
179
+
- Prompt for input values or accept via flags
320
180
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.
181
+
### Code Quality Tasks
331
182
332
-
--------------------------------------------------------------------------------
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
333
188
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
189
+
### Documentation Tasks
343
190
344
-
- Refresh token flow, device code, OAuth.
345
-
- PRs, issues, knots, spindle implementation.
346
-
- Advanced formatting, paging, completions.
347
-
348
-
--------------------------------------------------------------------------------
191
+
- Add man pages for all commands
192
+
- Create video tutorials for common workflows
193
+
- Add troubleshooting guide for common issues
349
194
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
-
--------------------------------------------------------------------------------
195
+
## Development Workflow
367
196
368
-
End of handoff. Implement auth login and repo list as described, keeping changes focused and testable.
197
+
### Building
369
198
199
+
```sh
200
+
cargo build # Debug build
201
+
cargo build --release # Release build
202
+
```
370
203
371
-
--------------------------------------------------------------------------------
204
+
### Running
372
205
373
-
## 13) Tangled Core (../tangled-core) – Practical Notes
206
+
```sh
207
+
cargo run -p tangled-cli -- <command>
208
+
```
374
209
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.
210
+
### Testing
376
211
377
-
### Where To Look
212
+
```sh
213
+
cargo test # Run all tests
214
+
cargo test -- --nocapture # Show println output
215
+
```
378
216
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`).
217
+
### Code Quality
392
218
393
-
### How To Search Quickly (rg examples)
219
+
```sh
220
+
cargo fmt # Format code
221
+
cargo clippy # Run linter
222
+
cargo clippy -- -W clippy::all # Strict linting
223
+
```
394
224
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`
225
+
## Troubleshooting Common Issues
404
226
405
-
### Repo Listing (client-side pattern)
227
+
### Keyring Errors on Linux
406
228
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.
229
+
Ensure a secret service is running:
230
+
```sh
231
+
systemctl --user enable --now gnome-keyring-daemon
232
+
```
411
233
412
-
### Repo Creation (two-step flow)
234
+
### Invalid Token Errors
413
235
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.
236
+
- For record operations: Use PDS client, not Tangled API client
237
+
- For server operations: Ensure ServiceAuth audience DID matches target host
421
238
422
-
### Base URLs, DIDs, and Defaults
239
+
### Repository Not Found
423
240
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.
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
428
244
429
-
### Common Errors and Fixes
245
+
### WebSocket Connection Failures
430
246
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.
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
434
250
435
-
### Implementation Pointers (CLI)
251
+
## Additional Resources
436
252
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`.
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
443
257
444
-
### Testing Hints
258
+
---
445
259
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).
260
+
Last updated: 2025-10-14
+1
Cargo.lock
+1
Cargo.lock
+168
-17
README.md
+168
-17
README.md
···
1
-
# Tangled CLI (Rust)
1
+
# Tangled CLI
2
2
3
3
A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol.
4
4
5
-
Status: project scaffold with CLI, config, API and git crates. Commands are stubs pending endpoint wiring.
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)
6
50
7
-
## Workspace
51
+
## Installation
8
52
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
53
+
### Build from Source
14
54
15
-
## Quick start
55
+
Requires Rust toolchain (1.70+) and network access to fetch dependencies.
16
56
17
57
```sh
18
-
cargo run -p tangled-cli -- --help
58
+
cargo build --release
19
59
```
20
60
21
-
### Install from AUR (community maintained)
61
+
The binary will be available at `target/release/tangled-cli`.
62
+
63
+
### Install from AUR (Arch Linux)
64
+
65
+
Community-maintained package:
22
66
23
67
```sh
24
68
yay -S tangled-cli-git
25
69
```
26
70
27
-
Building requires network to fetch crates.
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`)
28
112
29
-
## Next steps
113
+
## Examples
114
+
115
+
### Working with Issues
30
116
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)
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
+
```
35
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
+134
-17
crates/tangled-api/src/client.rs
+134
-17
crates/tangled-api/src/client.rs
···
23
23
}
24
24
25
25
fn xrpc_url(&self, method: &str) -> String {
26
-
format!("{}/xrpc/{}", self.base_url.trim_end_matches('/'), method)
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)
27
34
}
28
35
29
36
async fn post_json<TReq: Serialize, TRes: DeserializeOwned>(
···
47
54
return Err(anyhow!("{}: {}", status, body));
48
55
}
49
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(())
50
80
}
51
81
52
82
pub async fn get_json<TRes: DeserializeOwned>(
···
119
149
})
120
150
}
121
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
+
122
184
pub async fn list_repos(
123
185
&self,
124
186
user: Option<&str>,
···
264
326
struct GetSARes {
265
327
token: String,
266
328
}
329
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
267
330
let params = [
268
331
("aud", audience),
269
-
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
332
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
270
333
];
271
334
let sa: GetSARes = pds_client
272
335
.get_json(
···
345
408
rkey,
346
409
knot,
347
410
description: item.value.description,
411
+
spindle: item.value.spindle,
348
412
});
349
413
}
350
414
}
···
389
453
struct GetSARes {
390
454
token: String,
391
455
}
456
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
392
457
let params = [
393
458
("aud", audience),
394
-
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
459
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
395
460
];
396
461
let sa: GetSARes = pds_client
397
462
.get_json(
···
1025
1090
key,
1026
1091
value,
1027
1092
};
1028
-
let _: serde_json::Value = self
1029
-
.post_json("sh.tangled.repo.addSecret", &body, Some(&sa))
1030
-
.await?;
1031
-
Ok(())
1093
+
self.post("sh.tangled.repo.addSecret", &body, Some(&sa))
1094
+
.await
1032
1095
}
1033
1096
1034
1097
pub async fn remove_repo_secret(
···
1045
1108
key: &'a str,
1046
1109
}
1047
1110
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(())
1111
+
self.post("sh.tangled.repo.removeSecret", &body, Some(&sa))
1112
+
.await
1052
1113
}
1053
1114
1054
1115
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('/')
1116
+
let base_trimmed = self.base_url.trim_end_matches('/');
1117
+
let host = base_trimmed
1058
1118
.strip_prefix("https://")
1059
-
.or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
1060
-
.ok_or_else(|| anyhow!("invalid base_url"))?;
1119
+
.or_else(|| base_trimmed.strip_prefix("http://"))
1120
+
.unwrap_or(base_trimmed); // If no protocol, use the URL as-is
1061
1121
let audience = format!("did:web:{}", host);
1062
1122
#[derive(Deserialize)]
1063
1123
struct GetSARes {
1064
1124
token: String,
1065
1125
}
1066
1126
let pds = TangledClient::new(pds_base);
1127
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
1067
1128
let params = [
1068
1129
("aud", audience),
1069
-
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
1130
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
1070
1131
];
1071
1132
let sa: GetSARes = pds
1072
1133
.get_json(
···
1176
1237
Ok(())
1177
1238
}
1178
1239
1240
+
pub async fn merge_check(
1241
+
&self,
1242
+
repo_did: &str,
1243
+
repo_name: &str,
1244
+
branch: &str,
1245
+
patch: &str,
1246
+
pds_base: &str,
1247
+
access_jwt: &str,
1248
+
) -> Result<MergeCheckResponse> {
1249
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1250
+
1251
+
let req = MergeCheckRequest {
1252
+
did: repo_did.to_string(),
1253
+
name: repo_name.to_string(),
1254
+
branch: branch.to_string(),
1255
+
patch: patch.to_string(),
1256
+
};
1257
+
1258
+
self.post_json("sh.tangled.repo.mergeCheck", &req, Some(&sa))
1259
+
.await
1260
+
}
1261
+
1179
1262
pub async fn update_repo_spindle(
1180
1263
&self,
1181
1264
did: &str,
···
1312
1395
pub patch: String,
1313
1396
#[serde(rename = "createdAt")]
1314
1397
pub created_at: String,
1398
+
// Stack support fields
1399
+
#[serde(skip_serializing_if = "Option::is_none")]
1400
+
pub stack_id: Option<String>,
1401
+
#[serde(skip_serializing_if = "Option::is_none")]
1402
+
pub change_id: Option<String>,
1403
+
#[serde(skip_serializing_if = "Option::is_none")]
1404
+
pub parent_change_id: Option<String>,
1315
1405
}
1316
1406
1317
1407
#[derive(Debug, Clone)]
···
1321
1411
pub pull: Pull,
1322
1412
}
1323
1413
1414
+
// Merge check types for stacked diff conflict detection
1415
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1416
+
pub struct MergeCheckRequest {
1417
+
pub did: String,
1418
+
pub name: String,
1419
+
pub branch: String,
1420
+
pub patch: String,
1421
+
}
1422
+
1423
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1424
+
pub struct MergeCheckResponse {
1425
+
pub is_conflicted: bool,
1426
+
#[serde(default)]
1427
+
pub conflicts: Vec<ConflictInfo>,
1428
+
#[serde(skip_serializing_if = "Option::is_none")]
1429
+
pub message: Option<String>,
1430
+
#[serde(skip_serializing_if = "Option::is_none")]
1431
+
pub error: Option<String>,
1432
+
}
1433
+
1434
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1435
+
pub struct ConflictInfo {
1436
+
pub filename: String,
1437
+
pub reason: String,
1438
+
}
1439
+
1324
1440
#[derive(Debug, Clone)]
1325
1441
pub struct RepoRecord {
1326
1442
pub did: String,
···
1328
1444
pub rkey: String,
1329
1445
pub knot: String,
1330
1446
pub description: Option<String>,
1447
+
pub spindle: Option<String>,
1331
1448
}
1332
1449
1333
1450
#[derive(Debug, Clone, Serialize, Deserialize)]
+2
-2
crates/tangled-api/src/lib.rs
+2
-2
crates/tangled-api/src/lib.rs
···
2
2
3
3
pub use client::TangledClient;
4
4
pub use client::{
5
-
CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord,
6
-
RepoRecord, Repository, Secret,
5
+
ConflictInfo, CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages,
6
+
MergeCheckRequest, MergeCheckResponse, Pull, PullRecord, RepoRecord, Repository, Secret,
7
7
};
+1
crates/tangled-cli/Cargo.toml
+1
crates/tangled-cli/Cargo.toml
+1
-1
crates/tangled-cli/src/cli.rs
+1
-1
crates/tangled-cli/src/cli.rs
+5
-21
crates/tangled-cli/src/commands/issue.rs
+5
-21
crates/tangled-cli/src/commands/issue.rs
···
4
4
};
5
5
use anyhow::{anyhow, Result};
6
6
use tangled_api::Issue;
7
-
use tangled_config::session::SessionManager;
8
7
9
8
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
10
9
match cmd {
···
17
16
}
18
17
19
18
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"))?;
19
+
let session = crate::util::load_session_with_refresh().await?;
24
20
let pds = session
25
21
.pds
26
22
.clone()
···
57
53
}
58
54
59
55
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"))?;
56
+
let session = crate::util::load_session_with_refresh().await?;
64
57
let pds = session
65
58
.pds
66
59
.clone()
···
97
90
98
91
async fn show(args: IssueShowArgs) -> Result<()> {
99
92
// 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"))?;
93
+
let session = crate::util::load_session_with_refresh().await?;
104
94
let id = args.id;
105
95
let (did, rkey) = parse_record_id(&id, &session.did)?;
106
96
let pds = session
···
129
119
130
120
async fn edit(args: IssueEditArgs) -> Result<()> {
131
121
// 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"))?;
122
+
let session = crate::util::load_session_with_refresh().await?;
136
123
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
137
124
let pds = session
138
125
.pds
···
183
170
}
184
171
185
172
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"))?;
173
+
let session = crate::util::load_session_with_refresh().await?;
190
174
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
191
175
let pds = session
192
176
.pds
+1
-5
crates/tangled-cli/src/commands/knot.rs
+1
-5
crates/tangled-cli/src/commands/knot.rs
···
3
3
use anyhow::Result;
4
4
use git2::{Direction, Repository as GitRepository, StatusOptions};
5
5
use std::path::Path;
6
-
use tangled_config::session::SessionManager;
7
6
8
7
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
9
8
match cmd {
···
12
11
}
13
12
14
13
async fn migrate(args: KnotMigrateArgs) -> Result<()> {
15
-
let mgr = SessionManager::default();
16
-
let session = mgr
17
-
.load()?
18
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
14
+
let session = crate::util::load_session_with_refresh().await?;
19
15
// 1) Ensure we're inside a git repository and working tree is clean
20
16
let repo = GitRepository::discover(Path::new("."))?;
21
17
let mut status_opts = StatusOptions::new();
+341
-65
crates/tangled-cli/src/commands/pr.rs
+341
-65
crates/tangled-cli/src/commands/pr.rs
···
2
2
use anyhow::{anyhow, Result};
3
3
use std::path::Path;
4
4
use std::process::Command;
5
-
use tangled_config::session::SessionManager;
6
5
7
6
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
8
7
match cmd {
···
15
14
}
16
15
17
16
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"))?;
17
+
let session = crate::util::load_session_with_refresh().await?;
22
18
let pds = session
23
19
.pds
24
20
.clone()
···
54
50
55
51
async fn create(args: PrCreateArgs) -> Result<()> {
56
52
// 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"))?;
53
+
let session = crate::util::load_session_with_refresh().await?;
61
54
let pds = session
62
55
.pds
63
56
.clone()
···
126
119
}
127
120
128
121
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"))?;
122
+
let session = crate::util::load_session_with_refresh().await?;
133
123
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
134
124
let pds = session
135
125
.pds
···
152
142
}
153
143
154
144
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"))?;
145
+
let session = crate::util::load_session_with_refresh().await?;
159
146
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
160
147
let pds = session
161
148
.pds
···
184
171
}
185
172
186
173
async fn merge(args: PrMergeArgs) -> Result<()> {
187
-
let mgr = SessionManager::default();
188
-
let session = mgr
189
-
.load()?
190
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
174
+
let session = crate::util::load_session_with_refresh().await?;
191
175
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
192
176
let pds = session
193
177
.pds
···
195
179
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
196
180
.unwrap_or_else(|| "https://bsky.social".into());
197
181
198
-
// Get the PR to find the target repo
182
+
// Get the PR
199
183
let pds_client = tangled_api::TangledClient::new(&pds);
200
184
let pull = pds_client
201
185
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
202
186
.await?;
203
187
204
-
// Parse the target repo AT-URI to get did and name
205
-
let target_repo = &pull.target.repo;
206
-
// Format: at://did:plc:.../sh.tangled.repo/rkey
207
-
let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect();
208
-
if parts.len() < 2 {
209
-
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
210
-
}
211
-
let repo_did = parts[0];
188
+
// Parse target repo info
189
+
let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?;
212
190
213
-
// Get repo info to find the name
214
-
// Parse rkey from target repo AT-URI
215
-
let repo_rkey = if parts.len() >= 4 {
216
-
parts[3]
191
+
// Check if PR is part of a stack
192
+
if let Some(stack_id) = &pull.stack_id {
193
+
merge_stacked_pr(
194
+
&pds_client,
195
+
&session,
196
+
&pull,
197
+
&did,
198
+
&rkey,
199
+
&repo_did,
200
+
&repo_name,
201
+
stack_id,
202
+
&pds,
203
+
)
204
+
.await?;
217
205
} else {
218
-
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
219
-
};
220
-
221
-
#[derive(serde::Deserialize)]
222
-
struct Rec {
223
-
name: String,
224
-
}
225
-
#[derive(serde::Deserialize)]
226
-
struct GetRes {
227
-
value: Rec,
206
+
// Single PR merge (existing logic)
207
+
merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?;
228
208
}
229
-
let params = [
230
-
("repo", repo_did.to_string()),
231
-
("collection", "sh.tangled.repo".to_string()),
232
-
("rkey", repo_rkey.to_string()),
233
-
];
234
-
let repo_rec: GetRes = pds_client
235
-
.get_json("com.atproto.repo.getRecord", ¶ms, Some(session.access_jwt.as_str()))
236
-
.await?;
237
209
238
-
// Call merge on the default Tangled API base (tngl.sh)
239
-
let api = tangled_api::TangledClient::default();
240
-
api.merge_pull(
241
-
&did,
242
-
&rkey,
243
-
repo_did,
244
-
&repo_rec.value.name,
245
-
&pds,
246
-
&session.access_jwt,
247
-
)
248
-
.await?;
249
-
250
-
println!("Merged PR {}:{}", did, rkey);
251
210
Ok(())
252
211
}
253
212
···
275
234
}
276
235
Ok((default_did.to_string(), id.to_string()))
277
236
}
237
+
238
+
// Helper functions for stacked PR merge support
239
+
240
+
async fn merge_single_pr(
241
+
session: &tangled_config::session::Session,
242
+
did: &str,
243
+
rkey: &str,
244
+
repo_did: &str,
245
+
repo_name: &str,
246
+
pds: &str,
247
+
) -> Result<()> {
248
+
let api = tangled_api::TangledClient::default();
249
+
api.merge_pull(did, rkey, repo_did, repo_name, pds, &session.access_jwt)
250
+
.await?;
251
+
252
+
println!("Merged PR {}:{}", did, rkey);
253
+
Ok(())
254
+
}
255
+
256
+
async fn merge_stacked_pr(
257
+
pds_client: &tangled_api::TangledClient,
258
+
session: &tangled_config::session::Session,
259
+
current_pull: &tangled_api::Pull,
260
+
current_did: &str,
261
+
current_rkey: &str,
262
+
repo_did: &str,
263
+
repo_name: &str,
264
+
stack_id: &str,
265
+
pds: &str,
266
+
) -> Result<()> {
267
+
// Step 1: Get full stack
268
+
println!("🔍 Detecting stack...");
269
+
let stack = get_stack_pulls(pds_client, &session.did, stack_id, &session.access_jwt).await?;
270
+
271
+
if stack.is_empty() {
272
+
return Err(anyhow!("Stack is empty"));
273
+
}
274
+
275
+
// Step 2: Find substack (current PR and all below it)
276
+
let substack = find_substack(&stack, current_pull.change_id.as_deref())?;
277
+
278
+
println!(
279
+
"✓ Detected PR is part of stack (stack has {} total PRs)",
280
+
stack.len()
281
+
);
282
+
println!();
283
+
println!("The following {} PR(s) will be merged:", substack.len());
284
+
285
+
for (idx, pr) in substack.iter().enumerate() {
286
+
let marker = if pr.rkey == current_rkey {
287
+
" (current)"
288
+
} else {
289
+
""
290
+
};
291
+
println!(" [{}] {}: {}{}", idx + 1, pr.rkey, pr.pull.title, marker);
292
+
}
293
+
println!();
294
+
295
+
// Step 3: Check for conflicts
296
+
println!("✓ Checking for conflicts...");
297
+
let api = tangled_api::TangledClient::default();
298
+
let conflicts = check_stack_conflicts(
299
+
&api,
300
+
repo_did,
301
+
repo_name,
302
+
¤t_pull.target.branch,
303
+
&substack,
304
+
pds,
305
+
&session.access_jwt,
306
+
)
307
+
.await?;
308
+
309
+
if !conflicts.is_empty() {
310
+
println!("✗ Cannot merge: conflicts detected");
311
+
println!();
312
+
for (pr_rkey, conflict_resp) in conflicts {
313
+
println!(
314
+
" PR {}: Conflicts in {} file(s)",
315
+
pr_rkey,
316
+
conflict_resp.conflicts.len()
317
+
);
318
+
for conflict in conflict_resp.conflicts {
319
+
println!(" - {}: {}", conflict.filename, conflict.reason);
320
+
}
321
+
}
322
+
return Err(anyhow!("Stack has merge conflicts"));
323
+
}
324
+
325
+
println!("✓ All PRs can be merged cleanly");
326
+
println!();
327
+
328
+
// Step 4: Confirmation prompt
329
+
if !prompt_confirmation(&format!("Merge {} pull request(s)?", substack.len()))? {
330
+
println!("Merge cancelled.");
331
+
return Ok(());
332
+
}
333
+
334
+
// Step 5: Merge the stack (backend handles combined patch)
335
+
println!("Merging {} PR(s)...", substack.len());
336
+
337
+
// Use the current PR's merge endpoint - backend will handle the stack
338
+
api.merge_pull(
339
+
current_did,
340
+
current_rkey,
341
+
repo_did,
342
+
repo_name,
343
+
pds,
344
+
&session.access_jwt,
345
+
)
346
+
.await?;
347
+
348
+
println!("✓ Successfully merged {} pull request(s)", substack.len());
349
+
350
+
Ok(())
351
+
}
352
+
353
+
async fn get_stack_pulls(
354
+
client: &tangled_api::TangledClient,
355
+
user_did: &str,
356
+
stack_id: &str,
357
+
bearer: &str,
358
+
) -> Result<Vec<tangled_api::PullRecord>> {
359
+
// List all user's PRs and filter by stack_id
360
+
let all_pulls = client.list_pulls(user_did, None, Some(bearer)).await?;
361
+
362
+
let mut stack_pulls: Vec<_> = all_pulls
363
+
.into_iter()
364
+
.filter(|p| p.pull.stack_id.as_deref() == Some(stack_id))
365
+
.collect();
366
+
367
+
// Order by parent relationships (top to bottom)
368
+
order_stack(&mut stack_pulls)?;
369
+
370
+
Ok(stack_pulls)
371
+
}
372
+
373
+
fn order_stack(pulls: &mut Vec<tangled_api::PullRecord>) -> Result<()> {
374
+
if pulls.is_empty() {
375
+
return Ok(());
376
+
}
377
+
378
+
// Build parent map: parent_change_id -> pull
379
+
let mut change_id_map: std::collections::HashMap<String, usize> =
380
+
std::collections::HashMap::new();
381
+
let mut parent_map: std::collections::HashMap<String, usize> =
382
+
std::collections::HashMap::new();
383
+
384
+
for (idx, pr) in pulls.iter().enumerate() {
385
+
if let Some(cid) = &pr.pull.change_id {
386
+
change_id_map.insert(cid.clone(), idx);
387
+
}
388
+
if let Some(pcid) = &pr.pull.parent_change_id {
389
+
parent_map.insert(pcid.clone(), idx);
390
+
}
391
+
}
392
+
393
+
// Find top of stack (not a parent of any other PR)
394
+
let mut top_idx = None;
395
+
for (idx, pr) in pulls.iter().enumerate() {
396
+
if let Some(cid) = &pr.pull.change_id {
397
+
if !parent_map.contains_key(cid) {
398
+
top_idx = Some(idx);
399
+
break;
400
+
}
401
+
}
402
+
}
403
+
404
+
let top_idx = top_idx.ok_or_else(|| anyhow!("Could not find top of stack"))?;
405
+
406
+
// Walk down the stack to build ordered list
407
+
let mut ordered = Vec::new();
408
+
let mut current_idx = top_idx;
409
+
let mut visited = std::collections::HashSet::new();
410
+
411
+
loop {
412
+
if visited.contains(¤t_idx) {
413
+
return Err(anyhow!("Circular dependency in stack"));
414
+
}
415
+
visited.insert(current_idx);
416
+
ordered.push(current_idx);
417
+
418
+
// Find child (PR that has this PR as parent)
419
+
let current_parent = &pulls[current_idx].pull.parent_change_id;
420
+
if current_parent.is_none() {
421
+
break;
422
+
}
423
+
424
+
let next_idx = change_id_map.get(current_parent.as_ref().unwrap());
425
+
426
+
if let Some(&next) = next_idx {
427
+
current_idx = next;
428
+
} else {
429
+
break;
430
+
}
431
+
}
432
+
433
+
// Reorder pulls based on ordered indices
434
+
let original = pulls.clone();
435
+
pulls.clear();
436
+
for idx in ordered {
437
+
pulls.push(original[idx].clone());
438
+
}
439
+
440
+
Ok(())
441
+
}
442
+
443
+
fn find_substack<'a>(
444
+
stack: &'a [tangled_api::PullRecord],
445
+
current_change_id: Option<&str>,
446
+
) -> Result<Vec<&'a tangled_api::PullRecord>> {
447
+
let change_id = current_change_id.ok_or_else(|| anyhow!("PR has no change_id"))?;
448
+
449
+
let position = stack
450
+
.iter()
451
+
.position(|p| p.pull.change_id.as_deref() == Some(change_id))
452
+
.ok_or_else(|| anyhow!("PR not found in stack"))?;
453
+
454
+
// Return from current position to end (including current)
455
+
Ok(stack[position..].iter().collect())
456
+
}
457
+
458
+
async fn check_stack_conflicts(
459
+
api: &tangled_api::TangledClient,
460
+
repo_did: &str,
461
+
repo_name: &str,
462
+
target_branch: &str,
463
+
substack: &[&tangled_api::PullRecord],
464
+
pds: &str,
465
+
access_jwt: &str,
466
+
) -> Result<Vec<(String, tangled_api::MergeCheckResponse)>> {
467
+
let mut conflicts = Vec::new();
468
+
let mut cumulative_patch = String::new();
469
+
470
+
// Check each PR in order (bottom to top of substack)
471
+
for pr in substack.iter().rev() {
472
+
cumulative_patch.push_str(&pr.pull.patch);
473
+
cumulative_patch.push('\n');
474
+
475
+
let check = api
476
+
.merge_check(
477
+
repo_did,
478
+
repo_name,
479
+
target_branch,
480
+
&cumulative_patch,
481
+
pds,
482
+
access_jwt,
483
+
)
484
+
.await?;
485
+
486
+
if check.is_conflicted {
487
+
conflicts.push((pr.rkey.clone(), check));
488
+
}
489
+
}
490
+
491
+
Ok(conflicts)
492
+
}
493
+
494
+
fn prompt_confirmation(message: &str) -> Result<bool> {
495
+
use std::io::{self, Write};
496
+
497
+
print!("{} [y/N]: ", message);
498
+
io::stdout().flush()?;
499
+
500
+
let mut input = String::new();
501
+
io::stdin().read_line(&mut input)?;
502
+
503
+
Ok(matches!(
504
+
input.trim().to_lowercase().as_str(),
505
+
"y" | "yes"
506
+
))
507
+
}
508
+
509
+
async fn parse_target_repo_info(
510
+
pull: &tangled_api::Pull,
511
+
pds_client: &tangled_api::TangledClient,
512
+
session: &tangled_config::session::Session,
513
+
) -> Result<(String, String)> {
514
+
let target_repo = &pull.target.repo;
515
+
let parts: Vec<&str> = target_repo
516
+
.strip_prefix("at://")
517
+
.unwrap_or(target_repo)
518
+
.split('/')
519
+
.collect();
520
+
521
+
if parts.len() < 4 {
522
+
return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo));
523
+
}
524
+
525
+
let repo_did = parts[0].to_string();
526
+
let repo_rkey = parts[3];
527
+
528
+
// Get repo name
529
+
#[derive(serde::Deserialize)]
530
+
struct Rec {
531
+
name: String,
532
+
}
533
+
#[derive(serde::Deserialize)]
534
+
struct GetRes {
535
+
value: Rec,
536
+
}
537
+
538
+
let params = [
539
+
("repo", repo_did.clone()),
540
+
("collection", "sh.tangled.repo".to_string()),
541
+
("rkey", repo_rkey.to_string()),
542
+
];
543
+
544
+
let repo_rec: GetRes = pds_client
545
+
.get_json(
546
+
"com.atproto.repo.getRecord",
547
+
¶ms,
548
+
Some(&session.access_jwt),
549
+
)
550
+
.await?;
551
+
552
+
Ok((repo_did, repo_rec.value.name))
553
+
}
+12
-31
crates/tangled-cli/src/commands/repo.rs
+12
-31
crates/tangled-cli/src/commands/repo.rs
···
2
2
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
3
3
use serde_json;
4
4
use std::path::PathBuf;
5
-
use tangled_config::session::SessionManager;
6
5
7
6
use crate::cli::{
8
7
Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs,
···
22
21
}
23
22
24
23
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
-
};
24
+
let session = crate::util::load_session_with_refresh().await?;
30
25
31
26
// Use the PDS to list repo records for the user
32
27
let pds = session
···
63
58
}
64
59
65
60
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
-
};
61
+
let session = crate::util::load_session_with_refresh().await?;
71
62
72
63
let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into());
73
64
let client = tangled_api::TangledClient::new(base);
···
97
88
}
98
89
99
90
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"))?;
91
+
let session = crate::util::load_session_with_refresh().await?;
104
92
105
93
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
106
94
let pds = session
···
164
152
}
165
153
166
154
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"))?;
155
+
let session = crate::util::load_session_with_refresh().await?;
171
156
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
172
157
let pds = session
173
158
.pds
···
182
167
println!("NAME: {}", info.name);
183
168
println!("OWNER DID: {}", info.did);
184
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
+
}
185
175
if let Some(desc) = info.description.as_deref() {
186
176
if !desc.is_empty() {
187
177
println!("DESCRIPTION: {}", desc);
···
230
220
}
231
221
232
222
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"))?;
223
+
let session = crate::util::load_session_with_refresh().await?;
237
224
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
238
225
let pds = session
239
226
.pds
···
253
240
}
254
241
255
242
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"))?;
243
+
let session = crate::util::load_session_with_refresh().await?;
260
244
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
261
245
let pds = session
262
246
.pds
···
276
260
}
277
261
278
262
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"))?;
263
+
let session = crate::util::load_session_with_refresh().await?;
283
264
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
284
265
let pds = session
285
266
.pds
+56
-29
crates/tangled-cli/src/commands/spindle.rs
+56
-29
crates/tangled-cli/src/commands/spindle.rs
···
4
4
};
5
5
use anyhow::{anyhow, Result};
6
6
use futures_util::StreamExt;
7
-
use tangled_config::session::SessionManager;
8
7
use tokio_tungstenite::{connect_async, tungstenite::Message};
9
8
10
9
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
···
18
17
}
19
18
20
19
async fn list(args: SpindleListArgs) -> Result<()> {
21
-
let mgr = SessionManager::default();
22
-
let session = mgr
23
-
.load()?
24
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
20
+
let session = crate::util::load_session_with_refresh().await?;
25
21
26
22
let pds = session
27
23
.pds
···
65
61
}
66
62
67
63
async fn config(args: SpindleConfigArgs) -> Result<()> {
68
-
let mgr = SessionManager::default();
69
-
let session = mgr
70
-
.load()?
71
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
64
+
let session = crate::util::load_session_with_refresh().await?;
72
65
73
66
if args.enable && args.disable {
74
67
return Err(anyhow!("Cannot use --enable and --disable together"));
···
138
131
(parts[0].to_string(), parts[1].to_string(), parts[2].to_string())
139
132
} else if parts.len() == 1 {
140
133
// Use repo context - need to get repo info
141
-
let mgr = SessionManager::default();
142
-
let session = mgr
143
-
.load()?
144
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
134
+
let session = crate::util::load_session_with_refresh().await?;
145
135
let pds = session
146
136
.pds
147
137
.clone()
···
205
195
}
206
196
207
197
async fn secret_list(args: SpindleSecretListArgs) -> Result<()> {
208
-
let mgr = SessionManager::default();
209
-
let session = mgr
210
-
.load()?
211
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
198
+
let session = crate::util::load_session_with_refresh().await?;
212
199
let pds = session
213
200
.pds
214
201
.clone()
···
220
207
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
221
208
.await?;
222
209
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
223
-
let api = tangled_api::TangledClient::default(); // base tngl.sh
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
+
224
218
let secrets = api
225
219
.list_repo_secrets(&pds, &session.access_jwt, &repo_at)
226
220
.await?;
···
236
230
}
237
231
238
232
async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> {
239
-
let mgr = SessionManager::default();
240
-
let session = mgr
241
-
.load()?
242
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
233
+
let session = crate::util::load_session_with_refresh().await?;
243
234
let pds = session
244
235
.pds
245
236
.clone()
···
251
242
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
252
243
.await?;
253
244
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
254
-
let api = tangled_api::TangledClient::default();
255
-
api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value)
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)
256
279
.await?;
257
280
println!("Added secret '{}' to {}", args.key, args.repo);
258
281
Ok(())
259
282
}
260
283
261
284
async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> {
262
-
let mgr = SessionManager::default();
263
-
let session = mgr
264
-
.load()?
265
-
.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
285
+
let session = crate::util::load_session_with_refresh().await?;
266
286
let pds = session
267
287
.pds
268
288
.clone()
···
274
294
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
275
295
.await?;
276
296
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
277
-
let api = tangled_api::TangledClient::default();
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
+
278
305
api.remove_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key)
279
306
.await?;
280
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
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
+
```
2
120
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.
121
+
### View Issue Details
4
122
5
-
## Build
123
+
```sh
124
+
tangled issue show <issue-id>
125
+
```
6
126
7
-
Requires Rust toolchain and network access to fetch dependencies.
127
+
### Comment on an Issue
8
128
129
+
```sh
130
+
tangled issue comment <issue-id> --body "I'm working on this!"
9
131
```
10
-
cargo build
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"
11
139
```
12
140
13
-
## Run
141
+
The CLI will use `git format-patch` to create a patch from your branch.
14
142
143
+
### List Pull Requests
144
+
145
+
```sh
146
+
tangled pr list --repo my-project
15
147
```
16
-
cargo run -p tangled-cli -- --help
148
+
149
+
### Review a Pull Request
150
+
151
+
```sh
152
+
tangled pr review <pr-id> --approve --comment "Looks good!"
17
153
```
18
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! 🧶