+18
.pre-commit-config.yaml
+18
.pre-commit-config.yaml
···
1
+
# Pre-commit configuration for Tangled workspace
2
+
# Uses local hooks to avoid network fetches and to run with system toolchain
3
+
4
+
repos:
5
+
- repo: local
6
+
hooks:
7
+
- id: rustfmt
8
+
name: rustfmt (cargo fmt --check)
9
+
entry: cargo fmt --all -- --check
10
+
language: system
11
+
types: [rust]
12
+
pass_filenames: false
13
+
- id: clippy
14
+
name: clippy (cargo clippy -D warnings)
15
+
entry: bash -lc 'cargo clippy --all-targets -- -D warnings'
16
+
language: system
17
+
types: [rust]
18
+
pass_filenames: false
+189
-292
AGENTS.md
+189
-292
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`.
61
+
## Architecture Overview
40
62
41
-
--------------------------------------------------------------------------------
63
+
### Workspace Structure
42
64
43
-
## 2) Current State Snapshot
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)
44
69
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.
70
+
### Key Patterns
51
71
52
-
Goal: replace CLI stubs with real API calls for auth + repo list.
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
53
78
54
-
--------------------------------------------------------------------------------
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
55
83
56
-
## 3) Endpoints & Data Shapes
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)
57
89
58
-
### 3.1 AT Protocol – Create Session
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}`
59
94
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`
95
+
### Base URLs and Defaults
70
96
71
-
Persist to keyring using `SessionManager`.
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`
72
100
73
-
### 3.2 Tangled – Repo List (tentative)
101
+
### Session Management
74
102
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, ... }] }`
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
80
107
81
-
If method name or response shape differs, adapt the client code; keep CLI interface stable.
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
+
```
82
118
83
-
--------------------------------------------------------------------------------
119
+
## Working with tangled-core
84
120
85
-
## 4) Implementation Plan
121
+
The `../tangled-core` repository contains the server implementation and lexicon definitions.
86
122
87
-
### 4.1 Add XRPC helpers and methods in `tangled-api`
123
+
### Key Files to Check
88
124
89
-
File: `tangled/crates/tangled-api/src/client.rs`
125
+
- **Lexicons**: `../tangled-core/lexicons/**/*.json`
126
+
- Defines XRPC method schemas (NSIDs, parameters, responses)
127
+
- Example: `sh.tangled.repo.create`, `sh.tangled.repo.merge`
90
128
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.
129
+
- **XRPC Routes**: `../tangled-core/knotserver/xrpc/xrpc.go`
130
+
- Shows which endpoints require ServiceAuth
131
+
- Maps NSIDs to handler functions
96
132
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: [...] }`.
133
+
- **API Handlers**: `../tangled-core/knotserver/xrpc/*.go`
134
+
- Implementation details for server-side operations
135
+
- Example: `create_repo.go`, `merge.go`
104
136
105
-
Error handling: For non-2xx, read the response body, return `anyhow!("{status}: {body}")`.
137
+
### Useful Search Commands
106
138
107
-
### 4.2 Wire CLI auth commands
139
+
```bash
140
+
# Find a specific NSID
141
+
rg -n "sh\.tangled\.repo\.create" ../tangled-core
108
142
109
-
File: `tangled/crates/tangled-cli/src/commands/auth.rs`
143
+
# List all lexicons
144
+
ls ../tangled-core/lexicons/repo
110
145
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})`.
146
+
# Check ServiceAuth usage
147
+
rg -n "ServiceAuth|VerifyServiceAuth" ../tangled-core
148
+
```
118
149
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`.
150
+
## Next Steps for Contributors
123
151
124
-
- `logout`:
125
-
- `SessionManager::default().clear()?`.
126
-
- Print `Logged out` if something was cleared; otherwise `No session found` is acceptable.
152
+
### Priority: Implement `spindle run`
127
153
128
-
### 4.3 Wire CLI repo list
154
+
The only remaining stub is `spindle run` for manually triggering workflows. Implementation plan:
129
155
130
-
File: `tangled/crates/tangled-cli/src/commands/repo.rs`
156
+
1. **Parse `.tangled.yml`** in the current repository to extract workflow definitions
157
+
- Look for workflow names, triggers, and manual trigger inputs
131
158
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.
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
+
```
140
171
141
-
--------------------------------------------------------------------------------
172
+
3. **Notify spindle** (if needed) or let the ingester pick up the new record
142
173
143
-
## 5) Code Snippets (Copy/Paste Friendly)
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
144
177
145
-
### 5.1 In `tangled-api/src/client.rs`
178
+
5. **Support manual inputs** (if workflow defines them):
179
+
- Prompt for input values or accept via flags
146
180
147
-
```rust
148
-
use anyhow::{anyhow, bail, Result};
149
-
use serde::{de::DeserializeOwned, Deserialize, Serialize};
150
-
use tangled_config::session::Session;
181
+
### Code Quality Tasks
151
182
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
-
}
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
162
188
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
-
}
189
+
### Documentation Tasks
181
190
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
-
}
191
+
- Add man pages for all commands
192
+
- Create video tutorials for common workflows
193
+
- Add troubleshooting guide for common issues
200
194
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
-
}
195
+
## Development Workflow
210
196
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
-
}
197
+
### Building
222
198
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 }
199
+
```sh
200
+
cargo build # Debug build
201
+
cargo build --release # Release build
225
202
```
226
203
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 session = client.login_with_password(&handle, &password, &pds).await?;
249
-
SessionManager::default().save(&session)?;
250
-
println!("Logged in as '{}' ({})", session.handle, session.did);
251
-
Ok(())
252
-
}
253
-
254
-
async fn status() -> Result<()> {
255
-
let mgr = SessionManager::default();
256
-
match mgr.load()? {
257
-
Some(s) => println!("Logged in as '{}' ({})", s.handle, s.did),
258
-
None => println!("Not logged in. Run: tangled auth login"),
259
-
}
260
-
Ok(())
261
-
}
204
+
### Running
262
205
263
-
async fn logout() -> Result<()> {
264
-
let mgr = SessionManager::default();
265
-
if mgr.load()?.is_some() { mgr.clear()?; println!("Logged out"); } else { println!("No session found"); }
266
-
Ok(())
267
-
}
206
+
```sh
207
+
cargo run -p tangled-cli -- <command>
268
208
```
269
209
270
-
### 5.3 In `tangled-cli/src/commands/repo.rs`
271
-
272
-
```rust
273
-
use anyhow::{anyhow, Result};
274
-
use tangled_config::session::SessionManager;
275
-
use crate::cli::{Cli, RepoCommand, RepoListArgs};
276
-
277
-
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
278
-
match cmd { RepoCommand::List(args) => list(args).await, _ => Ok(println!("not implemented")) }
279
-
}
210
+
### Testing
280
211
281
-
async fn list(args: RepoListArgs) -> Result<()> {
282
-
let mgr = SessionManager::default();
283
-
let session = mgr.load()?.ok_or_else(|| anyhow!("Please login first: tangled auth login"))?;
284
-
let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tangled.org".into());
285
-
let client = tangled_api::TangledClient::new(base);
286
-
let repos = client.list_repos(args.user.as_deref(), args.knot.as_deref(), args.starred, Some(session.access_jwt.as_str())).await?;
287
-
// Simple output: table or JSON to be improved later
288
-
println!("NAME\tKNOT\tPRIVATE");
289
-
for r in repos { println!("{}\t{}\t{}", r.name, r.knot.unwrap_or_default(), r.private); }
290
-
Ok(())
291
-
}
212
+
```sh
213
+
cargo test # Run all tests
214
+
cargo test -- --nocapture # Show println output
292
215
```
293
216
294
-
--------------------------------------------------------------------------------
295
-
296
-
## 6) Configuration, Env Vars, and Security
297
-
298
-
- PDS base (auth): default `https://bsky.social`. Accept CLI flag `--pds`; later read from config.
299
-
- Tangled API base (repo list): default `https://tangled.org`; allow override via `TANGLED_API_BASE` env var.
300
-
- Do not log passwords or tokens.
301
-
- Store tokens only in keyring (already implemented).
302
-
303
-
--------------------------------------------------------------------------------
304
-
305
-
## 7) Testing Plan (MVP)
306
-
307
-
- Client unit tests with `mockito` for `createSession` and `repo list` endpoints; simulate expected JSON.
308
-
- CLI smoke tests optional for this pass. If added, use `assert_cmd` to check printed output strings.
309
-
- Avoid live network calls in tests.
310
-
311
-
--------------------------------------------------------------------------------
312
-
313
-
## 8) Acceptance Criteria
314
-
315
-
- `tangled auth login`:
316
-
- Prompts or uses flags; successful call saves session and prints `Logged in as ...`.
317
-
- On failure, shows HTTP status and short message.
318
-
- `tangled auth status`:
319
-
- Shows handle + did if session exists; otherwise says not logged in.
320
-
- `tangled auth logout`:
321
-
- Clears keyring; prints confirmation.
322
-
- `tangled repo list`:
323
-
- Performs authenticated GET and prints a list (even if empty) without panicking.
324
-
- JSON output possible later; table output acceptable for now.
217
+
### Code Quality
325
218
326
-
--------------------------------------------------------------------------------
219
+
```sh
220
+
cargo fmt # Format code
221
+
cargo clippy # Run linter
222
+
cargo clippy -- -W clippy::all # Strict linting
223
+
```
327
224
328
-
## 9) Troubleshooting Notes
329
-
330
-
- Keyring errors on Linux may indicate no secret service running; suggest enabling GNOME Keyring or KWallet.
331
-
- If `repo list` returns 404, the method name or base URL may be wrong; adjust `sh.tangled.repo.list` or `TANGLED_API_BASE`.
332
-
- If 401, session may be missing/expired; run `auth login` again.
225
+
## Troubleshooting Common Issues
333
226
334
-
--------------------------------------------------------------------------------
227
+
### Keyring Errors on Linux
335
228
336
-
## 10) Non‑Goals for This Pass
229
+
Ensure a secret service is running:
230
+
```sh
231
+
systemctl --user enable --now gnome-keyring-daemon
232
+
```
337
233
338
-
- Refresh token flow, device code, OAuth.
339
-
- PRs, issues, knots, spindle implementation.
340
-
- Advanced formatting, paging, completions.
234
+
### Invalid Token Errors
341
235
342
-
--------------------------------------------------------------------------------
236
+
- For record operations: Use PDS client, not Tangled API client
237
+
- For server operations: Ensure ServiceAuth audience DID matches target host
343
238
344
-
## 11) Future Follow‑ups
239
+
### Repository Not Found
345
240
346
-
- Refresh flow (`com.atproto.server.refreshSession`) and retry once on 401.
347
-
- Persist base URLs and profiles in config; add `tangled config` commands.
348
-
- Proper table/json formatting and shell completions.
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
349
244
350
-
--------------------------------------------------------------------------------
245
+
### WebSocket Connection Failures
351
246
352
-
## 12) Quick Operator Commands
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
353
250
354
-
- Build CLI: `cargo build -p tangled-cli`
355
-
- Help: `cargo run -p tangled-cli -- --help`
356
-
- Login: `cargo run -p tangled-cli -- auth login --handle <handle>`
357
-
- Status: `cargo run -p tangled-cli -- auth status`
358
-
- Repo list: `TANGLED_API_BASE=https://tangled.org cargo run -p tangled-cli -- repo list --user <handle>`
251
+
## Additional Resources
359
252
360
-
--------------------------------------------------------------------------------
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
361
257
362
-
End of handoff. Implement auth login and repo list as described, keeping changes focused and testable.
258
+
---
363
259
260
+
Last updated: 2025-10-14
+156
-4
Cargo.lock
+156
-4
Cargo.lock
···
204
204
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
205
205
206
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]]
207
216
name = "bumpalo"
208
217
version = "3.19.0"
209
218
source = "registry+https://github.com/rust-lang/crates.io-index"
210
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"
211
226
212
227
[[package]]
213
228
name = "bytes"
···
388
403
]
389
404
390
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]]
391
415
name = "crc32fast"
392
416
version = "1.5.0"
393
417
source = "registry+https://github.com/rust-lang/crates.io-index"
···
397
421
]
398
422
399
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]]
400
434
name = "data-encoding"
401
435
version = "2.9.0"
402
436
source = "registry+https://github.com/rust-lang/crates.io-index"
···
455
489
"tempfile",
456
490
"thiserror 1.0.69",
457
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",
458
502
]
459
503
460
504
[[package]]
···
631
675
"pin-project-lite",
632
676
"pin-utils",
633
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",
634
688
]
635
689
636
690
[[package]]
···
1472
1526
"bytes",
1473
1527
"getrandom 0.3.3",
1474
1528
"lru-slab",
1475
-
"rand",
1529
+
"rand 0.9.2",
1476
1530
"ring",
1477
1531
"rustc-hash",
1478
1532
"rustls",
···
1515
1569
1516
1570
[[package]]
1517
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"
1518
1583
version = "0.9.2"
1519
1584
source = "registry+https://github.com/rust-lang/crates.io-index"
1520
1585
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
1521
1586
dependencies = [
1522
-
"rand_chacha",
1523
-
"rand_core",
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",
1524
1599
]
1525
1600
1526
1601
[[package]]
···
1530
1605
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1531
1606
dependencies = [
1532
1607
"ppv-lite86",
1533
-
"rand_core",
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",
1534
1618
]
1535
1619
1536
1620
[[package]]
···
1851
1935
]
1852
1936
1853
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]]
1854
1949
name = "shell-words"
1855
1950
version = "1.1.0"
1856
1951
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1994
2089
version = "0.1.0"
1995
2090
dependencies = [
1996
2091
"anyhow",
2092
+
"chrono",
1997
2093
"clap",
1998
2094
"colored",
1999
2095
"dialoguer",
2096
+
"futures-util",
2097
+
"git2",
2000
2098
"indicatif",
2001
2099
"serde",
2002
2100
"serde_json",
···
2004
2102
"tangled-config",
2005
2103
"tangled-git",
2006
2104
"tokio",
2105
+
"tokio-tungstenite",
2106
+
"url",
2007
2107
]
2008
2108
2009
2109
[[package]]
···
2167
2267
]
2168
2268
2169
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]]
2170
2284
name = "tokio-util"
2171
2285
version = "0.7.16"
2172
2286
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2302
2416
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2303
2417
2304
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]]
2305
2445
name = "unicase"
2306
2446
version = "2.8.1"
2307
2447
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2344
2484
]
2345
2485
2346
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]]
2347
2493
name = "utf8_iter"
2348
2494
version = "1.0.4"
2349
2495
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2360
2506
version = "0.2.15"
2361
2507
source = "registry+https://github.com/rust-lang/crates.io-index"
2362
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"
2363
2515
2364
2516
[[package]]
2365
2517
name = "want"
+4
Cargo.toml
+4
Cargo.toml
+173
-16
README.md
+173
-16
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)
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
6
99
7
-
## Workspace
100
+
## Configuration
8
101
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
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"
14
120
15
-
## Quick start
121
+
# List issues
122
+
tangled issue list --repo myrepo
16
123
124
+
# Comment on an issue
125
+
tangled issue comment <issue-id> --body "I'll fix this"
17
126
```
18
-
cargo run -p tangled-cli -- --help
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>
19
139
```
20
140
21
-
Building requires network to fetch crates.
141
+
### CI/CD with Spindle
22
142
23
-
## Next steps
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
24
181
25
-
- Implement `com.atproto.server.createSession` for auth
26
-
- Wire repo list/create endpoints under `sh.tangled.*`
27
-
- Persist sessions via keyring and load in CLI
28
-
- Add output formatting (table/json)
182
+
Contributions are welcome! Please feel free to submit issues or pull requests.
183
+
184
+
## License
29
185
186
+
MIT OR Apache-2.0
+1239
-4
crates/tangled-api/src/client.rs
+1239
-4
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>(
···
49
56
Ok(res.json::<TRes>().await?)
50
57
}
51
58
52
-
async fn get_json<TRes: DeserializeOwned>(
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>(
53
83
&self,
54
84
method: &str,
55
85
params: &[(&str, String)],
···
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>,
···
152
214
153
215
#[derive(Deserialize)]
154
216
struct RecordItem {
217
+
uri: String,
155
218
value: Repository,
156
219
}
157
220
#[derive(Deserialize)]
···
169
232
let res: ListRes = self
170
233
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
171
234
.await?;
172
-
let mut repos: Vec<Repository> = res.records.into_iter().map(|r| r.value).collect();
235
+
let mut repos: Vec<Repository> = res
236
+
.records
237
+
.into_iter()
238
+
.map(|r| {
239
+
let mut val = r.value;
240
+
if val.rkey.is_none() {
241
+
if let Some(k) = Self::uri_rkey(&r.uri) {
242
+
val.rkey = Some(k);
243
+
}
244
+
}
245
+
if val.did.is_none() {
246
+
if let Some(d) = Self::uri_did(&r.uri) {
247
+
val.did = Some(d);
248
+
}
249
+
}
250
+
val
251
+
})
252
+
.collect();
173
253
// Apply optional filters client-side
174
254
if let Some(k) = knot {
175
255
repos.retain(|r| r.knot.as_deref().unwrap_or("") == k);
···
246
326
struct GetSARes {
247
327
token: String,
248
328
}
329
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
249
330
let params = [
250
331
("aud", audience),
251
-
("exp", (chrono::Utc::now().timestamp() + 600).to_string()),
332
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
252
333
];
253
334
let sa: GetSARes = pds_client
254
335
.get_json(
···
277
358
let _: serde_json::Value = self.post_json(REPO_CREATE, &req, Some(&sa.token)).await?;
278
359
Ok(())
279
360
}
361
+
362
+
pub async fn get_repo_info(
363
+
&self,
364
+
owner: &str,
365
+
name: &str,
366
+
bearer: Option<&str>,
367
+
) -> Result<RepoRecord> {
368
+
let did = if owner.starts_with("did:") {
369
+
owner.to_string()
370
+
} else {
371
+
#[derive(Deserialize)]
372
+
struct Res {
373
+
did: String,
374
+
}
375
+
let params = [("handle", owner.to_string())];
376
+
let res: Res = self
377
+
.get_json("com.atproto.identity.resolveHandle", ¶ms, bearer)
378
+
.await?;
379
+
res.did
380
+
};
381
+
382
+
#[derive(Deserialize)]
383
+
struct RecordItem {
384
+
uri: String,
385
+
value: Repository,
386
+
}
387
+
#[derive(Deserialize)]
388
+
struct ListRes {
389
+
#[serde(default)]
390
+
records: Vec<RecordItem>,
391
+
}
392
+
let params = vec![
393
+
("repo", did.clone()),
394
+
("collection", "sh.tangled.repo".to_string()),
395
+
("limit", "100".to_string()),
396
+
];
397
+
let res: ListRes = self
398
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
399
+
.await?;
400
+
for item in res.records {
401
+
if item.value.name == name {
402
+
let rkey =
403
+
Self::uri_rkey(&item.uri).ok_or_else(|| anyhow!("missing rkey in uri"))?;
404
+
let knot = item.value.knot.unwrap_or_default();
405
+
return Ok(RepoRecord {
406
+
did: did.clone(),
407
+
name: name.to_string(),
408
+
rkey,
409
+
knot,
410
+
description: item.value.description,
411
+
spindle: item.value.spindle,
412
+
});
413
+
}
414
+
}
415
+
Err(anyhow!("repo not found for owner/name"))
416
+
}
417
+
418
+
pub async fn delete_repo(
419
+
&self,
420
+
did: &str,
421
+
name: &str,
422
+
pds_base: &str,
423
+
access_jwt: &str,
424
+
) -> Result<()> {
425
+
let pds_client = TangledClient::new(pds_base);
426
+
let info = pds_client
427
+
.get_repo_info(did, name, Some(access_jwt))
428
+
.await?;
429
+
430
+
#[derive(Serialize)]
431
+
struct DeleteRecordReq<'a> {
432
+
repo: &'a str,
433
+
collection: &'a str,
434
+
rkey: &'a str,
435
+
}
436
+
let del = DeleteRecordReq {
437
+
repo: did,
438
+
collection: "sh.tangled.repo",
439
+
rkey: &info.rkey,
440
+
};
441
+
let _: serde_json::Value = pds_client
442
+
.post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
443
+
.await?;
444
+
445
+
let host = self
446
+
.base_url
447
+
.trim_end_matches('/')
448
+
.strip_prefix("https://")
449
+
.or_else(|| self.base_url.trim_end_matches('/').strip_prefix("http://"))
450
+
.ok_or_else(|| anyhow!("invalid base_url"))?;
451
+
let audience = format!("did:web:{}", host);
452
+
#[derive(Deserialize)]
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(
463
+
"com.atproto.server.getServiceAuth",
464
+
¶ms,
465
+
Some(access_jwt),
466
+
)
467
+
.await?;
468
+
469
+
#[derive(Serialize)]
470
+
struct DeleteReq<'a> {
471
+
did: &'a str,
472
+
name: &'a str,
473
+
rkey: &'a str,
474
+
}
475
+
let body = DeleteReq {
476
+
did,
477
+
name,
478
+
rkey: &info.rkey,
479
+
};
480
+
let _: serde_json::Value = self
481
+
.post_json("sh.tangled.repo.delete", &body, Some(&sa.token))
482
+
.await?;
483
+
Ok(())
484
+
}
485
+
486
+
pub async fn update_repo_knot(
487
+
&self,
488
+
did: &str,
489
+
rkey: &str,
490
+
new_knot: &str,
491
+
pds_base: &str,
492
+
access_jwt: &str,
493
+
) -> Result<()> {
494
+
let pds_client = TangledClient::new(pds_base);
495
+
#[derive(Deserialize, Serialize, Clone)]
496
+
struct Rec {
497
+
name: String,
498
+
knot: String,
499
+
#[serde(skip_serializing_if = "Option::is_none")]
500
+
description: Option<String>,
501
+
#[serde(rename = "createdAt")]
502
+
created_at: String,
503
+
}
504
+
#[derive(Deserialize)]
505
+
struct GetRes {
506
+
value: Rec,
507
+
}
508
+
let params = [
509
+
("repo", did.to_string()),
510
+
("collection", "sh.tangled.repo".to_string()),
511
+
("rkey", rkey.to_string()),
512
+
];
513
+
let got: GetRes = pds_client
514
+
.get_json("com.atproto.repo.getRecord", ¶ms, Some(access_jwt))
515
+
.await?;
516
+
let mut rec = got.value;
517
+
rec.knot = new_knot.to_string();
518
+
#[derive(Serialize)]
519
+
struct PutReq<'a> {
520
+
repo: &'a str,
521
+
collection: &'a str,
522
+
rkey: &'a str,
523
+
validate: bool,
524
+
record: Rec,
525
+
}
526
+
let req = PutReq {
527
+
repo: did,
528
+
collection: "sh.tangled.repo",
529
+
rkey,
530
+
validate: true,
531
+
record: rec,
532
+
};
533
+
let _: serde_json::Value = pds_client
534
+
.post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
535
+
.await?;
536
+
Ok(())
537
+
}
538
+
539
+
pub async fn get_default_branch(
540
+
&self,
541
+
knot_host: &str,
542
+
did: &str,
543
+
name: &str,
544
+
) -> Result<DefaultBranch> {
545
+
#[derive(Deserialize)]
546
+
struct Res {
547
+
name: String,
548
+
hash: String,
549
+
#[serde(rename = "shortHash")]
550
+
short_hash: Option<String>,
551
+
when: String,
552
+
message: Option<String>,
553
+
}
554
+
let knot_client = TangledClient::new(knot_host);
555
+
let repo_param = format!("{}/{}", did, name);
556
+
let params = [("repo", repo_param)];
557
+
let res: Res = knot_client
558
+
.get_json("sh.tangled.repo.getDefaultBranch", ¶ms, None)
559
+
.await?;
560
+
Ok(DefaultBranch {
561
+
name: res.name,
562
+
hash: res.hash,
563
+
short_hash: res.short_hash,
564
+
when: res.when,
565
+
message: res.message,
566
+
})
567
+
}
568
+
569
+
pub async fn get_languages(&self, knot_host: &str, did: &str, name: &str) -> Result<Languages> {
570
+
let knot_client = TangledClient::new(knot_host);
571
+
let repo_param = format!("{}/{}", did, name);
572
+
let params = [("repo", repo_param)];
573
+
let res: serde_json::Value = knot_client
574
+
.get_json("sh.tangled.repo.languages", ¶ms, None)
575
+
.await?;
576
+
let langs = res
577
+
.get("languages")
578
+
.cloned()
579
+
.unwrap_or(serde_json::json!([]));
580
+
let languages: Vec<Language> = serde_json::from_value(langs)?;
581
+
let total_size = res.get("totalSize").and_then(|v| v.as_u64());
582
+
let total_files = res.get("totalFiles").and_then(|v| v.as_u64());
583
+
Ok(Languages {
584
+
languages,
585
+
total_size,
586
+
total_files,
587
+
})
588
+
}
589
+
590
+
pub async fn star_repo(
591
+
&self,
592
+
pds_base: &str,
593
+
access_jwt: &str,
594
+
subject_at_uri: &str,
595
+
user_did: &str,
596
+
) -> Result<String> {
597
+
#[derive(Serialize)]
598
+
struct Rec<'a> {
599
+
subject: &'a str,
600
+
#[serde(rename = "createdAt")]
601
+
created_at: String,
602
+
}
603
+
#[derive(Serialize)]
604
+
struct Req<'a> {
605
+
repo: &'a str,
606
+
collection: &'a str,
607
+
validate: bool,
608
+
record: Rec<'a>,
609
+
}
610
+
#[derive(Deserialize)]
611
+
struct Res {
612
+
uri: String,
613
+
}
614
+
let now = chrono::Utc::now().to_rfc3339();
615
+
let rec = Rec {
616
+
subject: subject_at_uri,
617
+
created_at: now,
618
+
};
619
+
let req = Req {
620
+
repo: user_did,
621
+
collection: "sh.tangled.feed.star",
622
+
validate: true,
623
+
record: rec,
624
+
};
625
+
let pds_client = TangledClient::new(pds_base);
626
+
let res: Res = pds_client
627
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
628
+
.await?;
629
+
let rkey = Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in star uri"))?;
630
+
Ok(rkey)
631
+
}
632
+
633
+
pub async fn unstar_repo(
634
+
&self,
635
+
pds_base: &str,
636
+
access_jwt: &str,
637
+
subject_at_uri: &str,
638
+
user_did: &str,
639
+
) -> Result<()> {
640
+
#[derive(Deserialize)]
641
+
struct Item {
642
+
uri: String,
643
+
value: StarRecord,
644
+
}
645
+
#[derive(Deserialize)]
646
+
struct ListRes {
647
+
#[serde(default)]
648
+
records: Vec<Item>,
649
+
}
650
+
let pds_client = TangledClient::new(pds_base);
651
+
let params = vec![
652
+
("repo", user_did.to_string()),
653
+
("collection", "sh.tangled.feed.star".to_string()),
654
+
("limit", "100".to_string()),
655
+
];
656
+
let res: ListRes = pds_client
657
+
.get_json("com.atproto.repo.listRecords", ¶ms, Some(access_jwt))
658
+
.await?;
659
+
let mut rkey = None;
660
+
for item in res.records {
661
+
if item.value.subject == subject_at_uri {
662
+
rkey = Self::uri_rkey(&item.uri);
663
+
if rkey.is_some() {
664
+
break;
665
+
}
666
+
}
667
+
}
668
+
let rkey = rkey.ok_or_else(|| anyhow!("star record not found"))?;
669
+
#[derive(Serialize)]
670
+
struct Del<'a> {
671
+
repo: &'a str,
672
+
collection: &'a str,
673
+
rkey: &'a str,
674
+
}
675
+
let del = Del {
676
+
repo: user_did,
677
+
collection: "sh.tangled.feed.star",
678
+
rkey: &rkey,
679
+
};
680
+
let _: serde_json::Value = pds_client
681
+
.post_json("com.atproto.repo.deleteRecord", &del, Some(access_jwt))
682
+
.await?;
683
+
Ok(())
684
+
}
685
+
686
+
fn uri_rkey(uri: &str) -> Option<String> {
687
+
uri.rsplit('/').next().map(|s| s.to_string())
688
+
}
689
+
fn uri_did(uri: &str) -> Option<String> {
690
+
let parts: Vec<&str> = uri.split('/').collect();
691
+
if parts.len() >= 3 {
692
+
Some(parts[2].to_string())
693
+
} else {
694
+
None
695
+
}
696
+
}
697
+
698
+
// ========== Issues ==========
699
+
pub async fn list_issues(
700
+
&self,
701
+
author_did: &str,
702
+
repo_at_uri: Option<&str>,
703
+
bearer: Option<&str>,
704
+
) -> Result<Vec<IssueRecord>> {
705
+
#[derive(Deserialize)]
706
+
struct Item {
707
+
uri: String,
708
+
value: Issue,
709
+
}
710
+
#[derive(Deserialize)]
711
+
struct ListRes {
712
+
#[serde(default)]
713
+
records: Vec<Item>,
714
+
}
715
+
let params = vec![
716
+
("repo", author_did.to_string()),
717
+
("collection", "sh.tangled.repo.issue".to_string()),
718
+
("limit", "100".to_string()),
719
+
];
720
+
let res: ListRes = self
721
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
722
+
.await?;
723
+
let mut out = vec![];
724
+
for it in res.records {
725
+
if let Some(filter_repo) = repo_at_uri {
726
+
if it.value.repo.as_str() != filter_repo {
727
+
continue;
728
+
}
729
+
}
730
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
731
+
out.push(IssueRecord {
732
+
author_did: author_did.to_string(),
733
+
rkey,
734
+
issue: it.value,
735
+
});
736
+
}
737
+
Ok(out)
738
+
}
739
+
740
+
#[allow(clippy::too_many_arguments)]
741
+
pub async fn create_issue(
742
+
&self,
743
+
author_did: &str,
744
+
repo_did: &str,
745
+
repo_rkey: &str,
746
+
title: &str,
747
+
body: Option<&str>,
748
+
pds_base: &str,
749
+
access_jwt: &str,
750
+
) -> Result<String> {
751
+
#[derive(Serialize)]
752
+
struct Rec<'a> {
753
+
repo: &'a str,
754
+
title: &'a str,
755
+
#[serde(skip_serializing_if = "Option::is_none")]
756
+
body: Option<&'a str>,
757
+
#[serde(rename = "createdAt")]
758
+
created_at: String,
759
+
}
760
+
#[derive(Serialize)]
761
+
struct Req<'a> {
762
+
repo: &'a str,
763
+
collection: &'a str,
764
+
validate: bool,
765
+
record: Rec<'a>,
766
+
}
767
+
#[derive(Deserialize)]
768
+
struct Res {
769
+
uri: String,
770
+
}
771
+
let issue_repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
772
+
let now = chrono::Utc::now().to_rfc3339();
773
+
let rec = Rec {
774
+
repo: &issue_repo_at,
775
+
title,
776
+
body,
777
+
created_at: now,
778
+
};
779
+
let req = Req {
780
+
repo: author_did,
781
+
collection: "sh.tangled.repo.issue",
782
+
validate: true,
783
+
record: rec,
784
+
};
785
+
let pds_client = TangledClient::new(pds_base);
786
+
let res: Res = pds_client
787
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
788
+
.await?;
789
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue uri"))
790
+
}
791
+
792
+
pub async fn comment_issue(
793
+
&self,
794
+
author_did: &str,
795
+
issue_at: &str,
796
+
body: &str,
797
+
pds_base: &str,
798
+
access_jwt: &str,
799
+
) -> Result<String> {
800
+
#[derive(Serialize)]
801
+
struct Rec<'a> {
802
+
issue: &'a str,
803
+
body: &'a str,
804
+
#[serde(rename = "createdAt")]
805
+
created_at: String,
806
+
}
807
+
#[derive(Serialize)]
808
+
struct Req<'a> {
809
+
repo: &'a str,
810
+
collection: &'a str,
811
+
validate: bool,
812
+
record: Rec<'a>,
813
+
}
814
+
#[derive(Deserialize)]
815
+
struct Res {
816
+
uri: String,
817
+
}
818
+
let now = chrono::Utc::now().to_rfc3339();
819
+
let rec = Rec {
820
+
issue: issue_at,
821
+
body,
822
+
created_at: now,
823
+
};
824
+
let req = Req {
825
+
repo: author_did,
826
+
collection: "sh.tangled.repo.issue.comment",
827
+
validate: true,
828
+
record: rec,
829
+
};
830
+
let pds_client = TangledClient::new(pds_base);
831
+
let res: Res = pds_client
832
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
833
+
.await?;
834
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue comment uri"))
835
+
}
836
+
837
+
pub async fn get_issue_record(
838
+
&self,
839
+
author_did: &str,
840
+
rkey: &str,
841
+
bearer: Option<&str>,
842
+
) -> Result<Issue> {
843
+
#[derive(Deserialize)]
844
+
struct GetRes {
845
+
value: Issue,
846
+
}
847
+
let params = [
848
+
("repo", author_did.to_string()),
849
+
("collection", "sh.tangled.repo.issue".to_string()),
850
+
("rkey", rkey.to_string()),
851
+
];
852
+
let res: GetRes = self
853
+
.get_json("com.atproto.repo.getRecord", ¶ms, bearer)
854
+
.await?;
855
+
Ok(res.value)
856
+
}
857
+
858
+
pub async fn put_issue_record(
859
+
&self,
860
+
author_did: &str,
861
+
rkey: &str,
862
+
record: &Issue,
863
+
bearer: Option<&str>,
864
+
) -> Result<()> {
865
+
#[derive(Serialize)]
866
+
struct PutReq<'a> {
867
+
repo: &'a str,
868
+
collection: &'a str,
869
+
rkey: &'a str,
870
+
validate: bool,
871
+
record: &'a Issue,
872
+
}
873
+
let req = PutReq {
874
+
repo: author_did,
875
+
collection: "sh.tangled.repo.issue",
876
+
rkey,
877
+
validate: true,
878
+
record,
879
+
};
880
+
let _: serde_json::Value = self
881
+
.post_json("com.atproto.repo.putRecord", &req, bearer)
882
+
.await?;
883
+
Ok(())
884
+
}
885
+
886
+
pub async fn set_issue_state(
887
+
&self,
888
+
author_did: &str,
889
+
issue_at: &str,
890
+
state_nsid: &str,
891
+
pds_base: &str,
892
+
access_jwt: &str,
893
+
) -> Result<String> {
894
+
#[derive(Serialize)]
895
+
struct Rec<'a> {
896
+
issue: &'a str,
897
+
state: &'a str,
898
+
}
899
+
#[derive(Serialize)]
900
+
struct Req<'a> {
901
+
repo: &'a str,
902
+
collection: &'a str,
903
+
validate: bool,
904
+
record: Rec<'a>,
905
+
}
906
+
#[derive(Deserialize)]
907
+
struct Res {
908
+
uri: String,
909
+
}
910
+
let rec = Rec {
911
+
issue: issue_at,
912
+
state: state_nsid,
913
+
};
914
+
let req = Req {
915
+
repo: author_did,
916
+
collection: "sh.tangled.repo.issue.state",
917
+
validate: true,
918
+
record: rec,
919
+
};
920
+
let pds_client = TangledClient::new(pds_base);
921
+
let res: Res = pds_client
922
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
923
+
.await?;
924
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in issue state uri"))
925
+
}
926
+
927
+
pub async fn get_pull_record(
928
+
&self,
929
+
author_did: &str,
930
+
rkey: &str,
931
+
bearer: Option<&str>,
932
+
) -> Result<Pull> {
933
+
#[derive(Deserialize)]
934
+
struct GetRes {
935
+
value: Pull,
936
+
}
937
+
let params = [
938
+
("repo", author_did.to_string()),
939
+
("collection", "sh.tangled.repo.pull".to_string()),
940
+
("rkey", rkey.to_string()),
941
+
];
942
+
let res: GetRes = self
943
+
.get_json("com.atproto.repo.getRecord", ¶ms, bearer)
944
+
.await?;
945
+
Ok(res.value)
946
+
}
947
+
948
+
// ========== Pull Requests ==========
949
+
pub async fn list_pulls(
950
+
&self,
951
+
author_did: &str,
952
+
target_repo_at_uri: Option<&str>,
953
+
bearer: Option<&str>,
954
+
) -> Result<Vec<PullRecord>> {
955
+
#[derive(Deserialize)]
956
+
struct Item {
957
+
uri: String,
958
+
value: Pull,
959
+
}
960
+
#[derive(Deserialize)]
961
+
struct ListRes {
962
+
#[serde(default)]
963
+
records: Vec<Item>,
964
+
}
965
+
let params = vec![
966
+
("repo", author_did.to_string()),
967
+
("collection", "sh.tangled.repo.pull".to_string()),
968
+
("limit", "100".to_string()),
969
+
];
970
+
let res: ListRes = self
971
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
972
+
.await?;
973
+
let mut out = vec![];
974
+
for it in res.records {
975
+
if let Some(target) = target_repo_at_uri {
976
+
if it.value.target.repo.as_str() != target {
977
+
continue;
978
+
}
979
+
}
980
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
981
+
out.push(PullRecord {
982
+
author_did: author_did.to_string(),
983
+
rkey,
984
+
pull: it.value,
985
+
});
986
+
}
987
+
Ok(out)
988
+
}
989
+
990
+
#[allow(clippy::too_many_arguments)]
991
+
pub async fn create_pull(
992
+
&self,
993
+
author_did: &str,
994
+
repo_did: &str,
995
+
repo_rkey: &str,
996
+
target_branch: &str,
997
+
patch: &str,
998
+
title: &str,
999
+
body: Option<&str>,
1000
+
pds_base: &str,
1001
+
access_jwt: &str,
1002
+
) -> Result<String> {
1003
+
#[derive(Serialize)]
1004
+
struct Target<'a> {
1005
+
repo: &'a str,
1006
+
branch: &'a str,
1007
+
}
1008
+
#[derive(Serialize)]
1009
+
struct Rec<'a> {
1010
+
target: Target<'a>,
1011
+
title: &'a str,
1012
+
#[serde(skip_serializing_if = "Option::is_none")]
1013
+
body: Option<&'a str>,
1014
+
patch: &'a str,
1015
+
#[serde(rename = "createdAt")]
1016
+
created_at: String,
1017
+
}
1018
+
#[derive(Serialize)]
1019
+
struct Req<'a> {
1020
+
repo: &'a str,
1021
+
collection: &'a str,
1022
+
validate: bool,
1023
+
record: Rec<'a>,
1024
+
}
1025
+
#[derive(Deserialize)]
1026
+
struct Res {
1027
+
uri: String,
1028
+
}
1029
+
let repo_at = format!("at://{}/sh.tangled.repo/{}", repo_did, repo_rkey);
1030
+
let now = chrono::Utc::now().to_rfc3339();
1031
+
let rec = Rec {
1032
+
target: Target {
1033
+
repo: &repo_at,
1034
+
branch: target_branch,
1035
+
},
1036
+
title,
1037
+
body,
1038
+
patch,
1039
+
created_at: now,
1040
+
};
1041
+
let req = Req {
1042
+
repo: author_did,
1043
+
collection: "sh.tangled.repo.pull",
1044
+
validate: true,
1045
+
record: rec,
1046
+
};
1047
+
let pds_client = TangledClient::new(pds_base);
1048
+
let res: Res = pds_client
1049
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
1050
+
.await?;
1051
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull uri"))
1052
+
}
1053
+
1054
+
// ========== Spindle: Secrets Management ==========
1055
+
pub async fn list_repo_secrets(
1056
+
&self,
1057
+
pds_base: &str,
1058
+
access_jwt: &str,
1059
+
repo_at: &str,
1060
+
) -> Result<Vec<Secret>> {
1061
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1062
+
#[derive(Deserialize)]
1063
+
struct Res {
1064
+
secrets: Vec<Secret>,
1065
+
}
1066
+
let params = [("repo", repo_at.to_string())];
1067
+
let res: Res = self
1068
+
.get_json("sh.tangled.repo.listSecrets", ¶ms, Some(&sa))
1069
+
.await?;
1070
+
Ok(res.secrets)
1071
+
}
1072
+
1073
+
pub async fn add_repo_secret(
1074
+
&self,
1075
+
pds_base: &str,
1076
+
access_jwt: &str,
1077
+
repo_at: &str,
1078
+
key: &str,
1079
+
value: &str,
1080
+
) -> Result<()> {
1081
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1082
+
#[derive(Serialize)]
1083
+
struct Req<'a> {
1084
+
repo: &'a str,
1085
+
key: &'a str,
1086
+
value: &'a str,
1087
+
}
1088
+
let body = Req {
1089
+
repo: repo_at,
1090
+
key,
1091
+
value,
1092
+
};
1093
+
self.post("sh.tangled.repo.addSecret", &body, Some(&sa))
1094
+
.await
1095
+
}
1096
+
1097
+
pub async fn remove_repo_secret(
1098
+
&self,
1099
+
pds_base: &str,
1100
+
access_jwt: &str,
1101
+
repo_at: &str,
1102
+
key: &str,
1103
+
) -> Result<()> {
1104
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1105
+
#[derive(Serialize)]
1106
+
struct Req<'a> {
1107
+
repo: &'a str,
1108
+
key: &'a str,
1109
+
}
1110
+
let body = Req { repo: repo_at, key };
1111
+
self.post("sh.tangled.repo.removeSecret", &body, Some(&sa))
1112
+
.await
1113
+
}
1114
+
1115
+
async fn service_auth_token(&self, pds_base: &str, access_jwt: &str) -> Result<String> {
1116
+
let base_trimmed = self.base_url.trim_end_matches('/');
1117
+
let host = base_trimmed
1118
+
.strip_prefix("https://")
1119
+
.or_else(|| base_trimmed.strip_prefix("http://"))
1120
+
.unwrap_or(base_trimmed); // If no protocol, use the URL as-is
1121
+
let audience = format!("did:web:{}", host);
1122
+
#[derive(Deserialize)]
1123
+
struct GetSARes {
1124
+
token: String,
1125
+
}
1126
+
let pds = TangledClient::new(pds_base);
1127
+
// Method-less ServiceAuth tokens must expire within 60 seconds per AT Protocol spec
1128
+
let params = [
1129
+
("aud", audience),
1130
+
("exp", (chrono::Utc::now().timestamp() + 60).to_string()),
1131
+
];
1132
+
let sa: GetSARes = pds
1133
+
.get_json(
1134
+
"com.atproto.server.getServiceAuth",
1135
+
¶ms,
1136
+
Some(access_jwt),
1137
+
)
1138
+
.await?;
1139
+
Ok(sa.token)
1140
+
}
1141
+
1142
+
pub async fn comment_pull(
1143
+
&self,
1144
+
author_did: &str,
1145
+
pull_at: &str,
1146
+
body: &str,
1147
+
pds_base: &str,
1148
+
access_jwt: &str,
1149
+
) -> Result<String> {
1150
+
#[derive(Serialize)]
1151
+
struct Rec<'a> {
1152
+
pull: &'a str,
1153
+
body: &'a str,
1154
+
#[serde(rename = "createdAt")]
1155
+
created_at: String,
1156
+
}
1157
+
#[derive(Serialize)]
1158
+
struct Req<'a> {
1159
+
repo: &'a str,
1160
+
collection: &'a str,
1161
+
validate: bool,
1162
+
record: Rec<'a>,
1163
+
}
1164
+
#[derive(Deserialize)]
1165
+
struct Res {
1166
+
uri: String,
1167
+
}
1168
+
let now = chrono::Utc::now().to_rfc3339();
1169
+
let rec = Rec {
1170
+
pull: pull_at,
1171
+
body,
1172
+
created_at: now,
1173
+
};
1174
+
let req = Req {
1175
+
repo: author_did,
1176
+
collection: "sh.tangled.repo.pull.comment",
1177
+
validate: true,
1178
+
record: rec,
1179
+
};
1180
+
let pds_client = TangledClient::new(pds_base);
1181
+
let res: Res = pds_client
1182
+
.post_json("com.atproto.repo.createRecord", &req, Some(access_jwt))
1183
+
.await?;
1184
+
Self::uri_rkey(&res.uri).ok_or_else(|| anyhow!("missing rkey in pull comment uri"))
1185
+
}
1186
+
1187
+
pub async fn merge_pull(
1188
+
&self,
1189
+
pull_did: &str,
1190
+
pull_rkey: &str,
1191
+
repo_did: &str,
1192
+
repo_name: &str,
1193
+
pds_base: &str,
1194
+
access_jwt: &str,
1195
+
) -> Result<()> {
1196
+
// Fetch the pull request to get patch and target branch
1197
+
let pds_client = TangledClient::new(pds_base);
1198
+
let pull = pds_client
1199
+
.get_pull_record(pull_did, pull_rkey, Some(access_jwt))
1200
+
.await?;
1201
+
1202
+
// Get service auth token for the knot
1203
+
let sa = self.service_auth_token(pds_base, access_jwt).await?;
1204
+
1205
+
#[derive(Serialize)]
1206
+
struct MergeReq<'a> {
1207
+
did: &'a str,
1208
+
name: &'a str,
1209
+
patch: &'a str,
1210
+
branch: &'a str,
1211
+
#[serde(skip_serializing_if = "Option::is_none")]
1212
+
#[serde(rename = "commitMessage")]
1213
+
commit_message: Option<&'a str>,
1214
+
#[serde(skip_serializing_if = "Option::is_none")]
1215
+
#[serde(rename = "commitBody")]
1216
+
commit_body: Option<&'a str>,
1217
+
}
1218
+
1219
+
let commit_body = if pull.body.is_empty() {
1220
+
None
1221
+
} else {
1222
+
Some(pull.body.as_str())
1223
+
};
1224
+
1225
+
let req = MergeReq {
1226
+
did: repo_did,
1227
+
name: repo_name,
1228
+
patch: &pull.patch,
1229
+
branch: &pull.target.branch,
1230
+
commit_message: Some(&pull.title),
1231
+
commit_body,
1232
+
};
1233
+
1234
+
let _: serde_json::Value = self
1235
+
.post_json("sh.tangled.repo.merge", &req, Some(&sa))
1236
+
.await?;
1237
+
Ok(())
1238
+
}
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
+
1262
+
pub async fn update_repo_spindle(
1263
+
&self,
1264
+
did: &str,
1265
+
rkey: &str,
1266
+
new_spindle: Option<&str>,
1267
+
pds_base: &str,
1268
+
access_jwt: &str,
1269
+
) -> Result<()> {
1270
+
let pds_client = TangledClient::new(pds_base);
1271
+
#[derive(Deserialize, Serialize, Clone)]
1272
+
struct Rec {
1273
+
name: String,
1274
+
knot: String,
1275
+
#[serde(skip_serializing_if = "Option::is_none")]
1276
+
description: Option<String>,
1277
+
#[serde(skip_serializing_if = "Option::is_none")]
1278
+
spindle: Option<String>,
1279
+
#[serde(rename = "createdAt")]
1280
+
created_at: String,
1281
+
}
1282
+
#[derive(Deserialize)]
1283
+
struct GetRes {
1284
+
value: Rec,
1285
+
}
1286
+
let params = [
1287
+
("repo", did.to_string()),
1288
+
("collection", "sh.tangled.repo".to_string()),
1289
+
("rkey", rkey.to_string()),
1290
+
];
1291
+
let got: GetRes = pds_client
1292
+
.get_json("com.atproto.repo.getRecord", ¶ms, Some(access_jwt))
1293
+
.await?;
1294
+
let mut rec = got.value;
1295
+
rec.spindle = new_spindle.map(|s| s.to_string());
1296
+
#[derive(Serialize)]
1297
+
struct PutReq<'a> {
1298
+
repo: &'a str,
1299
+
collection: &'a str,
1300
+
rkey: &'a str,
1301
+
validate: bool,
1302
+
record: Rec,
1303
+
}
1304
+
let req = PutReq {
1305
+
repo: did,
1306
+
collection: "sh.tangled.repo",
1307
+
rkey,
1308
+
validate: true,
1309
+
record: rec,
1310
+
};
1311
+
let _: serde_json::Value = pds_client
1312
+
.post_json("com.atproto.repo.putRecord", &req, Some(access_jwt))
1313
+
.await?;
1314
+
Ok(())
1315
+
}
1316
+
1317
+
pub async fn list_pipelines(
1318
+
&self,
1319
+
repo_did: &str,
1320
+
bearer: Option<&str>,
1321
+
) -> Result<Vec<PipelineRecord>> {
1322
+
#[derive(Deserialize)]
1323
+
struct Item {
1324
+
uri: String,
1325
+
value: Pipeline,
1326
+
}
1327
+
#[derive(Deserialize)]
1328
+
struct ListRes {
1329
+
#[serde(default)]
1330
+
records: Vec<Item>,
1331
+
}
1332
+
let params = vec![
1333
+
("repo", repo_did.to_string()),
1334
+
("collection", "sh.tangled.pipeline".to_string()),
1335
+
("limit", "100".to_string()),
1336
+
];
1337
+
let res: ListRes = self
1338
+
.get_json("com.atproto.repo.listRecords", ¶ms, bearer)
1339
+
.await?;
1340
+
let mut out = vec![];
1341
+
for it in res.records {
1342
+
let rkey = Self::uri_rkey(&it.uri).unwrap_or_default();
1343
+
out.push(PipelineRecord {
1344
+
rkey,
1345
+
pipeline: it.value,
1346
+
});
1347
+
}
1348
+
Ok(out)
1349
+
}
280
1350
}
281
1351
282
1352
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
···
286
1356
pub name: String,
287
1357
pub knot: Option<String>,
288
1358
pub description: Option<String>,
1359
+
pub spindle: Option<String>,
289
1360
#[serde(default)]
290
1361
pub private: bool,
291
1362
}
292
1363
1364
+
// Issue record value
1365
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1366
+
pub struct Issue {
1367
+
pub repo: String,
1368
+
pub title: String,
1369
+
#[serde(default)]
1370
+
pub body: String,
1371
+
#[serde(rename = "createdAt")]
1372
+
pub created_at: String,
1373
+
}
1374
+
1375
+
#[derive(Debug, Clone)]
1376
+
pub struct IssueRecord {
1377
+
pub author_did: String,
1378
+
pub rkey: String,
1379
+
pub issue: Issue,
1380
+
}
1381
+
1382
+
// Pull record value (subset)
1383
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1384
+
pub struct PullTarget {
1385
+
pub repo: String,
1386
+
pub branch: String,
1387
+
}
1388
+
1389
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1390
+
pub struct Pull {
1391
+
pub target: PullTarget,
1392
+
pub title: String,
1393
+
#[serde(default)]
1394
+
pub body: String,
1395
+
pub patch: String,
1396
+
#[serde(rename = "createdAt")]
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>,
1405
+
}
1406
+
1407
+
#[derive(Debug, Clone)]
1408
+
pub struct PullRecord {
1409
+
pub author_did: String,
1410
+
pub rkey: String,
1411
+
pub pull: Pull,
1412
+
}
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
+
1440
+
#[derive(Debug, Clone)]
1441
+
pub struct RepoRecord {
1442
+
pub did: String,
1443
+
pub name: String,
1444
+
pub rkey: String,
1445
+
pub knot: String,
1446
+
pub description: Option<String>,
1447
+
pub spindle: Option<String>,
1448
+
}
1449
+
1450
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1451
+
pub struct DefaultBranch {
1452
+
pub name: String,
1453
+
pub hash: String,
1454
+
#[serde(skip_serializing_if = "Option::is_none")]
1455
+
pub short_hash: Option<String>,
1456
+
pub when: String,
1457
+
#[serde(skip_serializing_if = "Option::is_none")]
1458
+
pub message: Option<String>,
1459
+
}
1460
+
1461
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1462
+
pub struct Language {
1463
+
pub name: String,
1464
+
pub size: u64,
1465
+
pub percentage: u64,
1466
+
}
1467
+
1468
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1469
+
pub struct Languages {
1470
+
pub languages: Vec<Language>,
1471
+
#[serde(skip_serializing_if = "Option::is_none")]
1472
+
pub total_size: Option<u64>,
1473
+
#[serde(skip_serializing_if = "Option::is_none")]
1474
+
pub total_files: Option<u64>,
1475
+
}
1476
+
1477
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1478
+
pub struct StarRecord {
1479
+
pub subject: String,
1480
+
#[serde(rename = "createdAt")]
1481
+
pub created_at: String,
1482
+
}
1483
+
1484
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1485
+
pub struct Secret {
1486
+
pub repo: String,
1487
+
pub key: String,
1488
+
#[serde(rename = "createdAt")]
1489
+
pub created_at: String,
1490
+
#[serde(rename = "createdBy")]
1491
+
pub created_by: String,
1492
+
}
1493
+
293
1494
#[derive(Debug, Clone)]
294
1495
pub struct CreateRepoOptions<'a> {
295
1496
pub did: &'a str,
···
301
1502
pub pds_base: &'a str,
302
1503
pub access_jwt: &'a str,
303
1504
}
1505
+
1506
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1507
+
pub struct TriggerMetadata {
1508
+
pub kind: String,
1509
+
pub repo: TriggerRepo,
1510
+
}
1511
+
1512
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1513
+
pub struct TriggerRepo {
1514
+
pub knot: String,
1515
+
pub did: String,
1516
+
pub repo: String,
1517
+
#[serde(rename = "defaultBranch")]
1518
+
pub default_branch: String,
1519
+
}
1520
+
1521
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1522
+
pub struct Workflow {
1523
+
pub name: String,
1524
+
pub engine: String,
1525
+
}
1526
+
1527
+
#[derive(Debug, Clone, Serialize, Deserialize)]
1528
+
pub struct Pipeline {
1529
+
#[serde(rename = "triggerMetadata")]
1530
+
pub trigger_metadata: TriggerMetadata,
1531
+
pub workflows: Vec<Workflow>,
1532
+
}
1533
+
1534
+
#[derive(Debug, Clone)]
1535
+
pub struct PipelineRecord {
1536
+
pub rkey: String,
1537
+
pub pipeline: Pipeline,
1538
+
}
+4
crates/tangled-api/src/lib.rs
+4
crates/tangled-api/src/lib.rs
+5
-1
crates/tangled-cli/Cargo.toml
+5
-1
crates/tangled-cli/Cargo.toml
···
14
14
serde = { workspace = true, features = ["derive"] }
15
15
serde_json = { workspace = true }
16
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 }
17
22
18
23
# Internal crates
19
24
tangled-config = { path = "../tangled-config" }
20
25
tangled-api = { path = "../tangled-api" }
21
26
tangled-git = { path = "../tangled-git" }
22
-
+57
-36
crates/tangled-cli/src/cli.rs
+57
-36
crates/tangled-cli/src/cli.rs
···
284
284
#[derive(Args, Debug, Clone)]
285
285
pub struct PrMergeArgs {
286
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
287
}
294
288
295
289
#[derive(Subcommand, Debug, Clone)]
296
290
pub enum KnotCommand {
297
-
List(KnotListArgs),
298
-
Add(KnotAddArgs),
299
-
Verify(KnotVerifyArgs),
300
-
SetDefault(KnotRefArgs),
301
-
Remove(KnotRefArgs),
302
-
}
303
-
304
-
#[derive(Args, Debug, Clone)]
305
-
pub struct KnotListArgs {
306
-
#[arg(long, default_value_t = false)]
307
-
pub public: bool,
308
-
#[arg(long, default_value_t = false)]
309
-
pub owned: bool,
291
+
/// Migrate a repository to another knot
292
+
Migrate(KnotMigrateArgs),
310
293
}
311
294
312
295
#[derive(Args, Debug, Clone)]
313
-
pub struct KnotAddArgs {
314
-
pub url: String,
296
+
pub struct KnotMigrateArgs {
297
+
/// Repo to migrate: <owner>/<name> (owner defaults to your handle)
315
298
#[arg(long)]
316
-
pub did: Option<String>,
317
-
#[arg(long)]
318
-
pub name: Option<String>,
319
-
#[arg(long, default_value_t = false)]
320
-
pub verify: bool,
321
-
}
322
-
323
-
#[derive(Args, Debug, Clone)]
324
-
pub struct KnotVerifyArgs {
325
-
pub url: String,
326
-
}
327
-
328
-
#[derive(Args, Debug, Clone)]
329
-
pub struct KnotRefArgs {
330
-
pub url: String,
299
+
pub repo: String,
300
+
/// Target knot hostname (e.g. knot1.tangled.sh)
301
+
#[arg(long, value_name = "HOST")]
302
+
pub to: String,
303
+
/// Use HTTPS source when seeding new repo
304
+
#[arg(long, default_value_t = true)]
305
+
pub https: bool,
306
+
/// Update PDS record knot field after seeding
307
+
#[arg(long, default_value_t = true)]
308
+
pub update_record: bool,
331
309
}
332
310
333
311
#[derive(Subcommand, Debug, Clone)]
···
336
314
Config(SpindleConfigArgs),
337
315
Run(SpindleRunArgs),
338
316
Logs(SpindleLogsArgs),
317
+
/// Secrets management
318
+
#[command(subcommand)]
319
+
Secret(SpindleSecretCommand),
339
320
}
340
321
341
322
#[derive(Args, Debug, Clone)]
···
374
355
#[arg(long)]
375
356
pub lines: Option<usize>,
376
357
}
358
+
359
+
#[derive(Subcommand, Debug, Clone)]
360
+
pub enum SpindleSecretCommand {
361
+
/// List secrets for a repo
362
+
List(SpindleSecretListArgs),
363
+
/// Add or update a secret
364
+
Add(SpindleSecretAddArgs),
365
+
/// Remove a secret
366
+
Remove(SpindleSecretRemoveArgs),
367
+
}
368
+
369
+
#[derive(Args, Debug, Clone)]
370
+
pub struct SpindleSecretListArgs {
371
+
/// Repo: <owner>/<name>
372
+
#[arg(long)]
373
+
pub repo: String,
374
+
}
375
+
376
+
#[derive(Args, Debug, Clone)]
377
+
pub struct SpindleSecretAddArgs {
378
+
/// Repo: <owner>/<name>
379
+
#[arg(long)]
380
+
pub repo: String,
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
+
}
388
+
389
+
#[derive(Args, Debug, Clone)]
390
+
pub struct SpindleSecretRemoveArgs {
391
+
/// Repo: <owner>/<name>
392
+
#[arg(long)]
393
+
pub repo: String,
394
+
/// Secret key
395
+
#[arg(long)]
396
+
pub key: String,
397
+
}
+7
-1
crates/tangled-cli/src/commands/auth.rs
+7
-1
crates/tangled-cli/src/commands/auth.rs
···
26
26
.unwrap_or_else(|| "https://bsky.social".to_string());
27
27
28
28
let client = tangled_api::TangledClient::new(&pds);
29
-
let mut session = client.login_with_password(&handle, &password, &pds).await?;
29
+
let mut session = match client.login_with_password(&handle, &password, &pds).await {
30
+
Ok(sess) => sess,
31
+
Err(e) => {
32
+
println!("\x1b[93mIf you're on your own PDS, make sure to pass the --pds flag\x1b[0m");
33
+
return Err(e);
34
+
}
35
+
};
30
36
session.pds = Some(pds.clone());
31
37
SessionManager::default().save(&session)?;
32
38
println!("Logged in as '{}' ({})", session.handle, session.did);
+192
-21
crates/tangled-cli/src/commands/issue.rs
+192
-21
crates/tangled-cli/src/commands/issue.rs
···
2
2
Cli, IssueCommand, IssueCommentArgs, IssueCreateArgs, IssueEditArgs, IssueListArgs,
3
3
IssueShowArgs,
4
4
};
5
-
use anyhow::Result;
5
+
use anyhow::{anyhow, Result};
6
+
use tangled_api::Issue;
6
7
7
8
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
8
9
match cmd {
···
15
16
}
16
17
17
18
async fn list(args: IssueListArgs) -> Result<()> {
18
-
println!(
19
-
"Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}",
20
-
args.repo, args.state, args.author, args.label, args.assigned
21
-
);
19
+
let session = crate::util::load_session_with_refresh().await?;
20
+
let pds = session
21
+
.pds
22
+
.clone()
23
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
24
+
.unwrap_or_else(|| "https://bsky.social".into());
25
+
let client = tangled_api::TangledClient::new(&pds);
26
+
27
+
let repo_filter_at = if let Some(repo) = &args.repo {
28
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
29
+
let info = client
30
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
31
+
.await?;
32
+
Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey))
33
+
} else {
34
+
None
35
+
};
36
+
37
+
let items = client
38
+
.list_issues(
39
+
&session.did,
40
+
repo_filter_at.as_deref(),
41
+
Some(session.access_jwt.as_str()),
42
+
)
43
+
.await?;
44
+
if items.is_empty() {
45
+
println!("No issues found (showing only issues you created)");
46
+
} else {
47
+
println!("RKEY\tTITLE\tREPO");
48
+
for it in items {
49
+
println!("{}\t{}\t{}", it.rkey, it.issue.title, it.issue.repo);
50
+
}
51
+
}
22
52
Ok(())
23
53
}
24
54
25
55
async fn create(args: IssueCreateArgs) -> Result<()> {
26
-
println!(
27
-
"Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}",
28
-
args.repo, args.title, args.body, args.label, args.assign
29
-
);
56
+
let session = crate::util::load_session_with_refresh().await?;
57
+
let pds = session
58
+
.pds
59
+
.clone()
60
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
61
+
.unwrap_or_else(|| "https://bsky.social".into());
62
+
let client = tangled_api::TangledClient::new(&pds);
63
+
64
+
let repo = args
65
+
.repo
66
+
.as_ref()
67
+
.ok_or_else(|| anyhow!("--repo is required for issue create"))?;
68
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
69
+
let info = client
70
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
71
+
.await?;
72
+
let title = args
73
+
.title
74
+
.as_deref()
75
+
.ok_or_else(|| anyhow!("--title is required for issue create"))?;
76
+
let rkey = client
77
+
.create_issue(
78
+
&session.did,
79
+
&info.did,
80
+
&info.rkey,
81
+
title,
82
+
args.body.as_deref(),
83
+
&pds,
84
+
&session.access_jwt,
85
+
)
86
+
.await?;
87
+
println!("Created issue rkey={} in {}/{}", rkey, owner, name);
30
88
Ok(())
31
89
}
32
90
33
91
async fn show(args: IssueShowArgs) -> Result<()> {
34
-
println!(
35
-
"Issue show (stub) id={} comments={} json={}",
36
-
args.id, args.comments, args.json
37
-
);
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
97
+
.pds
98
+
.clone()
99
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
100
+
.unwrap_or_else(|| "https://bsky.social".into());
101
+
let client = tangled_api::TangledClient::new(&pds);
102
+
// Fetch all issues by this DID and find rkey
103
+
let items = client
104
+
.list_issues(&did, None, Some(session.access_jwt.as_str()))
105
+
.await?;
106
+
if let Some(it) = items.into_iter().find(|i| i.rkey == rkey) {
107
+
println!("TITLE: {}", it.issue.title);
108
+
if !it.issue.body.is_empty() {
109
+
println!("BODY:\n{}", it.issue.body);
110
+
}
111
+
println!("REPO: {}", it.issue.repo);
112
+
println!("AUTHOR: {}", it.author_did);
113
+
println!("RKEY: {}", rkey);
114
+
} else {
115
+
println!("Issue not found for did={} rkey={}", did, rkey);
116
+
}
38
117
Ok(())
39
118
}
40
119
41
120
async fn edit(args: IssueEditArgs) -> Result<()> {
42
-
println!(
43
-
"Issue edit (stub) id={} title={:?} body={:?} state={:?}",
44
-
args.id, args.title, args.body, args.state
45
-
);
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
126
+
.clone()
127
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
128
+
.unwrap_or_else(|| "https://bsky.social".into());
129
+
// Get existing
130
+
let client = tangled_api::TangledClient::new(&pds);
131
+
let mut rec: Issue = client
132
+
.get_issue_record(&did, &rkey, Some(session.access_jwt.as_str()))
133
+
.await?;
134
+
if let Some(t) = args.title.as_deref() {
135
+
rec.title = t.to_string();
136
+
}
137
+
if let Some(b) = args.body.as_deref() {
138
+
rec.body = b.to_string();
139
+
}
140
+
// Put record back
141
+
client
142
+
.put_issue_record(&did, &rkey, &rec, Some(session.access_jwt.as_str()))
143
+
.await?;
144
+
145
+
// Optional state change
146
+
if let Some(state) = args.state.as_deref() {
147
+
let state_nsid = match state {
148
+
"open" => "sh.tangled.repo.issue.state.open",
149
+
"closed" => "sh.tangled.repo.issue.state.closed",
150
+
other => {
151
+
return Err(anyhow!(format!(
152
+
"unknown state '{}', expected 'open' or 'closed'",
153
+
other
154
+
)))
155
+
}
156
+
};
157
+
let issue_at = rec.repo.clone();
158
+
client
159
+
.set_issue_state(
160
+
&session.did,
161
+
&issue_at,
162
+
state_nsid,
163
+
&pds,
164
+
&session.access_jwt,
165
+
)
166
+
.await?;
167
+
}
168
+
println!("Updated issue {}:{}", did, rkey);
46
169
Ok(())
47
170
}
48
171
49
172
async fn comment(args: IssueCommentArgs) -> Result<()> {
50
-
println!(
51
-
"Issue comment (stub) id={} close={} body={:?}",
52
-
args.id, args.close, args.body
53
-
);
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
177
+
.clone()
178
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
179
+
.unwrap_or_else(|| "https://bsky.social".into());
180
+
// Resolve issue AT-URI
181
+
let client = tangled_api::TangledClient::new(&pds);
182
+
let issue_at = client
183
+
.get_issue_record(&did, &rkey, Some(session.access_jwt.as_str()))
184
+
.await?
185
+
.repo;
186
+
if let Some(body) = args.body.as_deref() {
187
+
client
188
+
.comment_issue(&session.did, &issue_at, body, &pds, &session.access_jwt)
189
+
.await?;
190
+
println!("Comment posted");
191
+
}
192
+
if args.close {
193
+
client
194
+
.set_issue_state(
195
+
&session.did,
196
+
&issue_at,
197
+
"sh.tangled.repo.issue.state.closed",
198
+
&pds,
199
+
&session.access_jwt,
200
+
)
201
+
.await?;
202
+
println!("Issue closed");
203
+
}
54
204
Ok(())
55
205
}
206
+
207
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
208
+
if let Some((owner, name)) = spec.split_once('/') {
209
+
(owner, name)
210
+
} else {
211
+
(default_owner, spec)
212
+
}
213
+
}
214
+
215
+
fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
216
+
if let Some(rest) = id.strip_prefix("at://") {
217
+
let parts: Vec<&str> = rest.split('/').collect();
218
+
if parts.len() >= 4 {
219
+
return Ok((parts[0].to_string(), parts[3].to_string()));
220
+
}
221
+
}
222
+
if let Some((did, rkey)) = id.split_once(':') {
223
+
return Ok((did.to_string(), rkey.to_string()));
224
+
}
225
+
Ok((default_did.to_string(), id.to_string()))
226
+
}
+174
-27
crates/tangled-cli/src/commands/knot.rs
+174
-27
crates/tangled-cli/src/commands/knot.rs
···
1
-
use crate::cli::{Cli, KnotAddArgs, KnotCommand, KnotListArgs, KnotRefArgs, KnotVerifyArgs};
1
+
use crate::cli::{Cli, KnotCommand, KnotMigrateArgs};
2
+
use anyhow::anyhow;
2
3
use anyhow::Result;
4
+
use git2::{Direction, Repository as GitRepository, StatusOptions};
5
+
use std::path::Path;
3
6
4
7
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
5
8
match cmd {
6
-
KnotCommand::List(args) => list(args).await,
7
-
KnotCommand::Add(args) => add(args).await,
8
-
KnotCommand::Verify(args) => verify(args).await,
9
-
KnotCommand::SetDefault(args) => set_default(args).await,
10
-
KnotCommand::Remove(args) => remove(args).await,
9
+
KnotCommand::Migrate(args) => migrate(args).await,
11
10
}
12
11
}
13
12
14
-
async fn list(args: KnotListArgs) -> Result<()> {
15
-
println!(
16
-
"Knot list (stub) public={} owned={}",
17
-
args.public, args.owned
18
-
);
19
-
Ok(())
20
-
}
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();
18
+
status_opts.include_untracked(false).include_ignored(false);
19
+
let statuses = repo.statuses(Some(&mut status_opts))?;
20
+
if !statuses.is_empty() {
21
+
return Err(anyhow!(
22
+
"working tree has uncommitted changes; commit/push before migrating"
23
+
));
24
+
}
25
+
26
+
// 2) Derive current branch and ensure it's pushed to origin
27
+
let head = match repo.head() {
28
+
Ok(h) => h,
29
+
Err(_) => return Err(anyhow!("repository does not have a HEAD")),
30
+
};
31
+
let head_oid = head
32
+
.target()
33
+
.ok_or_else(|| anyhow!("failed to resolve HEAD OID"))?;
34
+
let head_name = head.shorthand().unwrap_or("");
35
+
let full_ref = head.name().unwrap_or("").to_string();
36
+
if !full_ref.starts_with("refs/heads/") {
37
+
return Err(anyhow!(
38
+
"HEAD is detached; please checkout a branch before migrating"
39
+
));
40
+
}
41
+
let branch = head_name.to_string();
42
+
43
+
let origin = repo.find_remote("origin").or_else(|_| {
44
+
repo.remotes().and_then(|rems| {
45
+
rems.get(0)
46
+
.ok_or(git2::Error::from_str("no remotes configured"))
47
+
.and_then(|name| repo.find_remote(name))
48
+
})
49
+
})?;
50
+
51
+
// Connect and list remote heads to find refs/heads/<branch>
52
+
let mut remote = origin;
53
+
remote.connect(Direction::Fetch)?;
54
+
let remote_heads = remote.list()?;
55
+
let remote_oid = remote_heads
56
+
.iter()
57
+
.find_map(|h| {
58
+
if h.name() == format!("refs/heads/{}", branch) {
59
+
Some(h.oid())
60
+
} else {
61
+
None
62
+
}
63
+
})
64
+
.ok_or_else(|| anyhow!("origin does not have branch '{}' — push first", branch))?;
65
+
if remote_oid != head_oid {
66
+
return Err(anyhow!(
67
+
"local {} ({}) != origin {} ({}); please push before migrating",
68
+
branch,
69
+
head_oid,
70
+
branch,
71
+
remote_oid
72
+
));
73
+
}
74
+
75
+
// 3) Parse origin URL to verify repo identity
76
+
let origin_url = remote
77
+
.url()
78
+
.ok_or_else(|| anyhow!("origin has no URL"))?
79
+
.to_string();
80
+
let (origin_owner, origin_name, _origin_host) = parse_remote_url(&origin_url)
81
+
.ok_or_else(|| anyhow!("unsupported origin URL: {}", origin_url))?;
82
+
83
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
84
+
if origin_owner.trim_start_matches('@') != owner.trim_start_matches('@') || origin_name != name
85
+
{
86
+
return Err(anyhow!(
87
+
"repo mismatch: current checkout '{}'/{} != argument '{}'/{}",
88
+
origin_owner,
89
+
origin_name,
90
+
owner,
91
+
name
92
+
));
93
+
}
94
+
95
+
let pds = session
96
+
.pds
97
+
.clone()
98
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
99
+
.unwrap_or_else(|| "https://bsky.social".into());
100
+
let pds_client = tangled_api::TangledClient::new(&pds);
101
+
let info = pds_client
102
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
103
+
.await?;
21
104
22
-
async fn add(args: KnotAddArgs) -> Result<()> {
105
+
// Build a publicly accessible source URL on tangled.org for the existing repo
106
+
let owner_path = if owner.starts_with('@') {
107
+
owner.to_string()
108
+
} else {
109
+
format!("@{}", owner)
110
+
};
111
+
let source = if args.https {
112
+
format!("https://tangled.org/{}/{}", owner_path, name)
113
+
} else {
114
+
format!(
115
+
"git@{}:{}/{}",
116
+
info.knot,
117
+
owner.trim_start_matches('@'),
118
+
name
119
+
)
120
+
};
121
+
122
+
// Create the repo on the target knot, seeding from source
123
+
let client = tangled_api::TangledClient::default();
124
+
let opts = tangled_api::client::CreateRepoOptions {
125
+
did: &session.did,
126
+
name: &name,
127
+
knot: &args.to,
128
+
description: info.description.as_deref(),
129
+
default_branch: None,
130
+
source: Some(&source),
131
+
pds_base: &pds,
132
+
access_jwt: &session.access_jwt,
133
+
};
134
+
client.create_repo(opts).await?;
135
+
136
+
// Update the PDS record to point to the new knot
137
+
if args.update_record {
138
+
client
139
+
.update_repo_knot(
140
+
&session.did,
141
+
&info.rkey,
142
+
&args.to,
143
+
&pds,
144
+
&session.access_jwt,
145
+
)
146
+
.await?;
147
+
}
148
+
149
+
println!("Migrated repo '{}' to knot {}", name, args.to);
23
150
println!(
24
-
"Knot add (stub) url={} did={:?} name={:?} verify={}",
25
-
args.url, args.did, args.name, args.verify
151
+
"Note: old repository on {} is not deleted automatically.",
152
+
info.knot
26
153
);
27
154
Ok(())
28
155
}
29
156
30
-
async fn verify(args: KnotVerifyArgs) -> Result<()> {
31
-
println!("Knot verify (stub) url={}", args.url);
32
-
Ok(())
33
-
}
34
-
35
-
async fn set_default(args: KnotRefArgs) -> Result<()> {
36
-
println!("Knot set-default (stub) url={}", args.url);
37
-
Ok(())
157
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) {
158
+
if let Some((owner, name)) = spec.split_once('/') {
159
+
(owner, name.to_string())
160
+
} else {
161
+
(default_owner, spec.to_string())
162
+
}
38
163
}
39
164
40
-
async fn remove(args: KnotRefArgs) -> Result<()> {
41
-
println!("Knot remove (stub) url={}", args.url);
42
-
Ok(())
165
+
fn parse_remote_url(url: &str) -> Option<(String, String, String)> {
166
+
// Returns (owner, name, host)
167
+
if let Some(rest) = url.strip_prefix("git@") {
168
+
// git@host:owner/name(.git)
169
+
let mut parts = rest.split(':');
170
+
let host = parts.next()?.to_string();
171
+
let path = parts.next()?;
172
+
let mut segs = path.trim_end_matches(".git").split('/');
173
+
let owner = segs.next()?.to_string();
174
+
let name = segs.next()?.to_string();
175
+
return Some((owner, name, host));
176
+
}
177
+
if url.starts_with("http://") || url.starts_with("https://") {
178
+
if let Ok(parsed) = url::Url::parse(url) {
179
+
let host = parsed.host_str().unwrap_or("").to_string();
180
+
let path = parsed.path().trim_matches('/');
181
+
// paths may be like '@owner/name' or 'owner/name'
182
+
let mut segs = path.trim_end_matches(".git").split('/');
183
+
let first = segs.next()?;
184
+
let owner = first.trim_start_matches('@').to_string();
185
+
let name = segs.next()?.to_string();
186
+
return Some((owner, name, host));
187
+
}
188
+
}
189
+
None
43
190
}
+518
-17
crates/tangled-cli/src/commands/pr.rs
+518
-17
crates/tangled-cli/src/commands/pr.rs
···
1
1
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrMergeArgs, PrReviewArgs, PrShowArgs};
2
-
use anyhow::Result;
2
+
use anyhow::{anyhow, Result};
3
+
use std::path::Path;
4
+
use std::process::Command;
3
5
4
6
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
5
7
match cmd {
···
12
14
}
13
15
14
16
async fn list(args: PrListArgs) -> Result<()> {
15
-
println!(
16
-
"PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}",
17
-
args.repo, args.state, args.author, args.reviewer
18
-
);
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
+
let target_repo_at = if let Some(repo) = &args.repo {
25
+
let (owner, name) = parse_repo_ref(repo, &session.handle);
26
+
let info = client
27
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
28
+
.await?;
29
+
Some(format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey))
30
+
} else {
31
+
None
32
+
};
33
+
let pulls = client
34
+
.list_pulls(
35
+
&session.did,
36
+
target_repo_at.as_deref(),
37
+
Some(session.access_jwt.as_str()),
38
+
)
39
+
.await?;
40
+
if pulls.is_empty() {
41
+
println!("No pull requests found (showing only those you created)");
42
+
} else {
43
+
println!("RKEY\tTITLE\tTARGET");
44
+
for pr in pulls {
45
+
println!("{}\t{}\t{}", pr.rkey, pr.pull.title, pr.pull.target.repo);
46
+
}
47
+
}
19
48
Ok(())
20
49
}
21
50
22
51
async fn create(args: PrCreateArgs) -> Result<()> {
52
+
// Must be run inside the repo checkout; we will use git format-patch to build the patch
53
+
let session = crate::util::load_session_with_refresh().await?;
54
+
let pds = session
55
+
.pds
56
+
.clone()
57
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
58
+
.unwrap_or_else(|| "https://bsky.social".into());
59
+
let client = tangled_api::TangledClient::new(&pds);
60
+
61
+
let repo = args
62
+
.repo
63
+
.as_ref()
64
+
.ok_or_else(|| anyhow!("--repo is required for pr create"))?;
65
+
let (owner, name) = parse_repo_ref(repo, "");
66
+
let info = client
67
+
.get_repo_info(owner, name, Some(session.access_jwt.as_str()))
68
+
.await?;
69
+
70
+
let base = args
71
+
.base
72
+
.as_deref()
73
+
.ok_or_else(|| anyhow!("--base is required (target branch)"))?;
74
+
let head = args
75
+
.head
76
+
.as_deref()
77
+
.ok_or_else(|| anyhow!("--head is required (source range/branch)"))?;
78
+
79
+
// Generate format-patch using external git for fidelity
80
+
let output = Command::new("git")
81
+
.arg("format-patch")
82
+
.arg("--stdout")
83
+
.arg(format!("{}..{}", base, head))
84
+
.current_dir(Path::new("."))
85
+
.output()?;
86
+
if !output.status.success() {
87
+
return Err(anyhow!("failed to run git format-patch"));
88
+
}
89
+
let patch = String::from_utf8_lossy(&output.stdout).to_string();
90
+
if patch.trim().is_empty() {
91
+
return Err(anyhow!("no changes between base and head"));
92
+
}
93
+
94
+
let title_buf;
95
+
let title = if let Some(t) = args.title.as_deref() {
96
+
t
97
+
} else {
98
+
title_buf = format!("{} -> {}", head, base);
99
+
&title_buf
100
+
};
101
+
let rkey = client
102
+
.create_pull(
103
+
&session.did,
104
+
&info.did,
105
+
&info.rkey,
106
+
base,
107
+
&patch,
108
+
title,
109
+
args.body.as_deref(),
110
+
&pds,
111
+
&session.access_jwt,
112
+
)
113
+
.await?;
23
114
println!(
24
-
"PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
25
-
args.repo, args.base, args.head, args.title, args.draft
115
+
"Created PR rkey={} targeting {} branch {}",
116
+
rkey, info.did, base
26
117
);
27
118
Ok(())
28
119
}
29
120
30
121
async fn show(args: PrShowArgs) -> Result<()> {
31
-
println!(
32
-
"PR show (stub) id={} diff={} comments={} checks={}",
33
-
args.id, args.diff, args.comments, args.checks
34
-
);
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
126
+
.clone()
127
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
128
+
.unwrap_or_else(|| "https://bsky.social".into());
129
+
let client = tangled_api::TangledClient::new(&pds);
130
+
let pr = client
131
+
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
132
+
.await?;
133
+
println!("TITLE: {}", pr.title);
134
+
if !pr.body.is_empty() {
135
+
println!("BODY:\n{}", pr.body);
136
+
}
137
+
println!("TARGET: {} @ {}", pr.target.repo, pr.target.branch);
138
+
if args.diff {
139
+
println!("PATCH:\n{}", pr.patch);
140
+
}
35
141
Ok(())
36
142
}
37
143
38
144
async fn review(args: PrReviewArgs) -> Result<()> {
39
-
println!(
40
-
"PR review (stub) id={} approve={} request_changes={} comment={:?}",
41
-
args.id, args.approve, args.request_changes, args.comment
42
-
);
145
+
let session = crate::util::load_session_with_refresh().await?;
146
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
147
+
let pds = session
148
+
.pds
149
+
.clone()
150
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
151
+
.unwrap_or_else(|| "https://bsky.social".into());
152
+
let pr_at = format!("at://{}/sh.tangled.repo.pull/{}", did, rkey);
153
+
let note = if let Some(c) = args.comment.as_deref() {
154
+
c
155
+
} else if args.approve {
156
+
"LGTM"
157
+
} else if args.request_changes {
158
+
"Requesting changes"
159
+
} else {
160
+
""
161
+
};
162
+
if note.is_empty() {
163
+
return Err(anyhow!("provide --comment or --approve/--request-changes"));
164
+
}
165
+
let client = tangled_api::TangledClient::new(&pds);
166
+
client
167
+
.comment_pull(&session.did, &pr_at, note, &pds, &session.access_jwt)
168
+
.await?;
169
+
println!("Review comment posted");
43
170
Ok(())
44
171
}
45
172
46
173
async fn merge(args: PrMergeArgs) -> Result<()> {
174
+
let session = crate::util::load_session_with_refresh().await?;
175
+
let (did, rkey) = parse_record_id(&args.id, &session.did)?;
176
+
let pds = session
177
+
.pds
178
+
.clone()
179
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
180
+
.unwrap_or_else(|| "https://bsky.social".into());
181
+
182
+
// Get the PR
183
+
let pds_client = tangled_api::TangledClient::new(&pds);
184
+
let pull = pds_client
185
+
.get_pull_record(&did, &rkey, Some(session.access_jwt.as_str()))
186
+
.await?;
187
+
188
+
// Parse target repo info
189
+
let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?;
190
+
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?;
205
+
} else {
206
+
// Single PR merge (existing logic)
207
+
merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?;
208
+
}
209
+
210
+
Ok(())
211
+
}
212
+
213
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
214
+
if let Some((owner, name)) = spec.split_once('/') {
215
+
if !owner.is_empty() {
216
+
(owner, name)
217
+
} else {
218
+
(default_owner, name)
219
+
}
220
+
} else {
221
+
(default_owner, spec)
222
+
}
223
+
}
224
+
225
+
fn parse_record_id<'a>(id: &'a str, default_did: &'a str) -> Result<(String, String)> {
226
+
if let Some(rest) = id.strip_prefix("at://") {
227
+
let parts: Vec<&str> = rest.split('/').collect();
228
+
if parts.len() >= 4 {
229
+
return Ok((parts[0].to_string(), parts[3].to_string()));
230
+
}
231
+
}
232
+
if let Some((did, rkey)) = id.split_once(':') {
233
+
return Ok((did.to_string(), rkey.to_string()));
234
+
}
235
+
Ok((default_did.to_string(), id.to_string()))
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
+
47
278
println!(
48
-
"PR merge (stub) id={} squash={} rebase={} no_ff={}",
49
-
args.id, args.squash, args.rebase, args.no_ff
279
+
"✓ Detected PR is part of stack (stack has {} total PRs)",
280
+
stack.len()
50
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
+
51
350
Ok(())
52
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
+
}
+185
-23
crates/tangled-cli/src/commands/repo.rs
+185
-23
crates/tangled-cli/src/commands/repo.rs
···
1
1
use anyhow::{anyhow, Result};
2
+
use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
2
3
use serde_json;
3
-
use tangled_config::session::SessionManager;
4
+
use std::path::PathBuf;
4
5
5
6
use crate::cli::{
6
7
Cli, OutputFormat, RepoCloneArgs, RepoCommand, RepoCreateArgs, RepoDeleteArgs, RepoInfoArgs,
···
20
21
}
21
22
22
23
async fn list(cli: &Cli, args: RepoListArgs) -> Result<()> {
23
-
let mgr = SessionManager::default();
24
-
let session = match mgr.load()? {
25
-
Some(s) => s,
26
-
None => return Err(anyhow!("Please login first: tangled auth login")),
27
-
};
24
+
let session = crate::util::load_session_with_refresh().await?;
28
25
29
26
// Use the PDS to list repo records for the user
30
27
let pds = session
···
61
58
}
62
59
63
60
async fn create(args: RepoCreateArgs) -> Result<()> {
64
-
let mgr = SessionManager::default();
65
-
let session = match mgr.load()? {
66
-
Some(s) => s,
67
-
None => return Err(anyhow!("Please login first: tangled auth login")),
68
-
};
61
+
let session = crate::util::load_session_with_refresh().await?;
69
62
70
63
let base = std::env::var("TANGLED_API_BASE").unwrap_or_else(|_| "https://tngl.sh".into());
71
64
let client = tangled_api::TangledClient::new(base);
···
95
88
}
96
89
97
90
async fn clone(args: RepoCloneArgs) -> Result<()> {
98
-
println!(
99
-
"Cloning repo '{}' (stub) https={} depth={:?}",
100
-
args.repo, args.https, args.depth
101
-
);
102
-
Ok(())
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
95
+
.pds
96
+
.clone()
97
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
98
+
.unwrap_or_else(|| "https://bsky.social".into());
99
+
let pds_client = tangled_api::TangledClient::new(&pds);
100
+
let info = pds_client
101
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
102
+
.await?;
103
+
104
+
let remote = if args.https {
105
+
let owner_path = if owner.starts_with('@') {
106
+
owner.to_string()
107
+
} else {
108
+
format!("@{}", owner)
109
+
};
110
+
format!("https://tangled.org/{}/{}", owner_path, name)
111
+
} else {
112
+
let knot = if info.knot == "knot1.tangled.sh" {
113
+
"tangled.org".to_string()
114
+
} else {
115
+
info.knot.clone()
116
+
};
117
+
format!("git@{}:{}/{}", knot, owner.trim_start_matches('@'), name)
118
+
};
119
+
120
+
let target = PathBuf::from(&name);
121
+
println!("Cloning {} -> {:?}", remote, target);
122
+
123
+
let mut callbacks = RemoteCallbacks::new();
124
+
callbacks.credentials(|_url, username_from_url, _allowed| {
125
+
if let Some(user) = username_from_url {
126
+
Cred::ssh_key_from_agent(user)
127
+
} else {
128
+
Cred::default()
129
+
}
130
+
});
131
+
let mut fetch_opts = FetchOptions::new();
132
+
fetch_opts.remote_callbacks(callbacks);
133
+
if let Some(d) = args.depth {
134
+
fetch_opts.depth(d as i32);
135
+
}
136
+
let mut builder = RepoBuilder::new();
137
+
builder.fetch_options(fetch_opts);
138
+
match builder.clone(&remote, &target) {
139
+
Ok(_) => Ok(()),
140
+
Err(e) => {
141
+
println!("Failed to clone via libgit2: {}", e);
142
+
println!(
143
+
"Hint: try: git clone{} {}",
144
+
args.depth
145
+
.map(|d| format!(" --depth {}", d))
146
+
.unwrap_or_default(),
147
+
remote
148
+
);
149
+
Err(anyhow!(e.to_string()))
150
+
}
151
+
}
103
152
}
104
153
105
154
async fn info(args: RepoInfoArgs) -> Result<()> {
106
-
println!(
107
-
"Repository info '{}' (stub) stats={} contributors={}",
108
-
args.repo, args.stats, args.contributors
109
-
);
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
159
+
.clone()
160
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
161
+
.unwrap_or_else(|| "https://bsky.social".into());
162
+
let pds_client = tangled_api::TangledClient::new(&pds);
163
+
let info = pds_client
164
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
165
+
.await?;
166
+
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);
178
+
}
179
+
}
180
+
181
+
let knot_host = if info.knot == "knot1.tangled.sh" {
182
+
"tangled.org".to_string()
183
+
} else {
184
+
info.knot.clone()
185
+
};
186
+
if args.stats {
187
+
let client = tangled_api::TangledClient::default();
188
+
if let Ok(def) = client
189
+
.get_default_branch(&knot_host, &info.did, &info.name)
190
+
.await
191
+
{
192
+
println!(
193
+
"DEFAULT BRANCH: {} ({})",
194
+
def.name,
195
+
def.short_hash.unwrap_or(def.hash)
196
+
);
197
+
if let Some(msg) = def.message {
198
+
if !msg.is_empty() {
199
+
println!("LAST COMMIT: {}", msg);
200
+
}
201
+
}
202
+
}
203
+
if let Ok(langs) = client
204
+
.get_languages(&knot_host, &info.did, &info.name)
205
+
.await
206
+
{
207
+
if !langs.languages.is_empty() {
208
+
println!("LANGUAGES:");
209
+
for l in langs.languages.iter().take(6) {
210
+
println!(" - {} ({}%)", l.name, l.percentage);
211
+
}
212
+
}
213
+
}
214
+
}
215
+
216
+
if args.contributors {
217
+
println!("Contributors: not implemented yet");
218
+
}
110
219
Ok(())
111
220
}
112
221
113
222
async fn delete(args: RepoDeleteArgs) -> Result<()> {
114
-
println!("Deleting repo '{}' (stub) force={}", args.repo, args.force);
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
227
+
.clone()
228
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
229
+
.unwrap_or_else(|| "https://bsky.social".into());
230
+
let pds_client = tangled_api::TangledClient::new(&pds);
231
+
let record = pds_client
232
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
233
+
.await?;
234
+
let did = record.did;
235
+
let api = tangled_api::TangledClient::default();
236
+
api.delete_repo(&did, &name, &pds, &session.access_jwt)
237
+
.await?;
238
+
println!("Deleted repo '{}'", name);
115
239
Ok(())
116
240
}
117
241
118
242
async fn star(args: RepoRefArgs) -> Result<()> {
119
-
println!("Starring repo '{}' (stub)", args.repo);
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
247
+
.clone()
248
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
249
+
.unwrap_or_else(|| "https://bsky.social".into());
250
+
let pds_client = tangled_api::TangledClient::new(&pds);
251
+
let info = pds_client
252
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
253
+
.await?;
254
+
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
255
+
let api = tangled_api::TangledClient::default();
256
+
api.star_repo(&pds, &session.access_jwt, &subject, &session.did)
257
+
.await?;
258
+
println!("Starred {}/{}", owner, name);
120
259
Ok(())
121
260
}
122
261
123
262
async fn unstar(args: RepoRefArgs) -> Result<()> {
124
-
println!("Unstarring repo '{}' (stub)", args.repo);
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
267
+
.clone()
268
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
269
+
.unwrap_or_else(|| "https://bsky.social".into());
270
+
let pds_client = tangled_api::TangledClient::new(&pds);
271
+
let info = pds_client
272
+
.get_repo_info(owner, &name, Some(session.access_jwt.as_str()))
273
+
.await?;
274
+
let subject = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
275
+
let api = tangled_api::TangledClient::default();
276
+
api.unstar_repo(&pds, &session.access_jwt, &subject, &session.did)
277
+
.await?;
278
+
println!("Unstarred {}/{}", owner, name);
125
279
Ok(())
126
280
}
281
+
282
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, String) {
283
+
if let Some((owner, name)) = spec.split_once('/') {
284
+
(owner, name.to_string())
285
+
} else {
286
+
(default_owner, spec.to_string())
287
+
}
288
+
}
+284
-9
crates/tangled-cli/src/commands/spindle.rs
+284
-9
crates/tangled-cli/src/commands/spindle.rs
···
1
1
use crate::cli::{
2
2
Cli, SpindleCommand, SpindleConfigArgs, SpindleListArgs, SpindleLogsArgs, SpindleRunArgs,
3
+
SpindleSecretAddArgs, SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs,
3
4
};
4
-
use anyhow::Result;
5
+
use anyhow::{anyhow, Result};
6
+
use futures_util::StreamExt;
7
+
use tokio_tungstenite::{connect_async, tungstenite::Message};
5
8
6
9
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
7
10
match cmd {
···
9
12
SpindleCommand::Config(args) => config(args).await,
10
13
SpindleCommand::Run(args) => run_pipeline(args).await,
11
14
SpindleCommand::Logs(args) => logs(args).await,
15
+
SpindleCommand::Secret(cmd) => secret(cmd).await,
12
16
}
13
17
}
14
18
15
19
async fn list(args: SpindleListArgs) -> Result<()> {
16
-
println!("Spindle list (stub) repo={:?}", args.repo);
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
+
}
17
60
Ok(())
18
61
}
19
62
20
63
async fn config(args: SpindleConfigArgs) -> Result<()> {
21
-
println!(
22
-
"Spindle config (stub) repo={:?} url={:?} enable={} disable={}",
23
-
args.repo, args.url, args.enable, args.disable
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
24
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
+
}
25
116
Ok(())
26
117
}
27
118
···
34
125
}
35
126
36
127
async fn logs(args: SpindleLogsArgs) -> Result<()> {
37
-
println!(
38
-
"Spindle logs (stub) job_id={} follow={} lines={:?}",
39
-
args.job_id, args.follow, args.lines
40
-
);
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
+
189
+
async fn secret(cmd: SpindleSecretCommand) -> Result<()> {
190
+
match cmd {
191
+
SpindleSecretCommand::List(args) => secret_list(args).await,
192
+
SpindleSecretCommand::Add(args) => secret_add(args).await,
193
+
SpindleSecretCommand::Remove(args) => secret_remove(args).await,
194
+
}
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()
202
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
203
+
.unwrap_or_else(|| "https://bsky.social".into());
204
+
let pds_client = tangled_api::TangledClient::new(&pds);
205
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
206
+
let info = pds_client
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?;
221
+
if secrets.is_empty() {
222
+
println!("No secrets configured for {}", args.repo);
223
+
} else {
224
+
println!("KEY\tCREATED AT\tCREATED BY");
225
+
for s in secrets {
226
+
println!("{}\t{}\t{}", s.key, s.created_at, s.created_by);
227
+
}
228
+
}
41
229
Ok(())
42
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()
237
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
238
+
.unwrap_or_else(|| "https://bsky.social".into());
239
+
let pds_client = tangled_api::TangledClient::new(&pds);
240
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
241
+
let info = pds_client
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()
289
+
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
290
+
.unwrap_or_else(|| "https://bsky.social".into());
291
+
let pds_client = tangled_api::TangledClient::new(&pds);
292
+
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
293
+
let info = pds_client
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);
308
+
Ok(())
309
+
}
310
+
311
+
fn parse_repo_ref<'a>(spec: &'a str, default_owner: &'a str) -> (&'a str, &'a str) {
312
+
if let Some((owner, name)) = spec.split_once('/') {
313
+
(owner, name)
314
+
} else {
315
+
(default_owner, spec)
316
+
}
317
+
}
+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! 🧶