+61
Cargo.toml
+61
Cargo.toml
···
1
+
[workspace]
2
+
members = [
3
+
"crates/*",
4
+
]
5
+
resolver = "2"
6
+
7
+
[workspace.package]
8
+
edition = "2021"
9
+
10
+
[workspace.dependencies]
11
+
# AT Protocol
12
+
atrium-api = "0.24"
13
+
atrium-xrpc-client = "0.5"
14
+
atrium-identity = "0.1"
15
+
atrium-oauth = "0.1"
16
+
17
+
# CLI
18
+
clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
19
+
clap_complete = "4.5"
20
+
21
+
# Async
22
+
tokio = { version = "1.40", features = ["full"] }
23
+
futures = "0.3"
24
+
25
+
# HTTP & Serialization
26
+
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
27
+
serde = { version = "1.0", features = ["derive"] }
28
+
serde_json = "1.0"
29
+
toml = "0.8"
30
+
31
+
# Git
32
+
git2 = "0.19"
33
+
git2-credentials = "0.13"
34
+
35
+
# Terminal UI
36
+
indicatif = "0.17"
37
+
colored = "2.1"
38
+
tabled = "0.16"
39
+
dialoguer = "0.11"
40
+
console = "0.15"
41
+
42
+
# Storage
43
+
dirs = "5.0"
44
+
keyring = "3.0"
45
+
46
+
# Error Handling
47
+
anyhow = "1.0"
48
+
thiserror = "2.0"
49
+
50
+
# Utilities
51
+
chrono = "0.4"
52
+
url = "2.5"
53
+
base64 = "0.22"
54
+
regex = "1.10"
55
+
56
+
# Testing
57
+
mockito = "1.4"
58
+
tempfile = "3.10"
59
+
assert_cmd = "2.0"
60
+
predicates = "3.1"
61
+
+29
README.md
+29
README.md
···
1
+
# Tangled CLI (Rust)
2
+
3
+
A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol.
4
+
5
+
Status: project scaffold with CLI, config, API and git crates. Commands are stubs pending endpoint wiring.
6
+
7
+
## Workspace
8
+
9
+
- `crates/tangled-cli`: CLI binary (clap-based)
10
+
- `crates/tangled-config`: Config + session management
11
+
- `crates/tangled-api`: XRPC client wrapper (stubs)
12
+
- `crates/tangled-git`: Git helpers (stubs)
13
+
- `lexicons/sh.tangled`: Placeholder lexicons
14
+
15
+
## Quick start
16
+
17
+
```
18
+
cargo run -p tangled-cli -- --help
19
+
```
20
+
21
+
Building requires network to fetch crates.
22
+
23
+
## Next steps
24
+
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)
29
+
+24
crates/tangled-api/Cargo.toml
+24
crates/tangled-api/Cargo.toml
···
1
+
[package]
2
+
name = "tangled-api"
3
+
version = "0.1.0"
4
+
edition = "2021"
5
+
description = "XRPC client wrapper for Tangled operations"
6
+
license = "MIT OR Apache-2.0"
7
+
8
+
[dependencies]
9
+
anyhow = { workspace = true }
10
+
serde = { workspace = true, features = ["derive"] }
11
+
serde_json = { workspace = true }
12
+
reqwest = { workspace = true }
13
+
tokio = { workspace = true, features = ["full"] }
14
+
15
+
# Optionally depend on ATrium (wired later as endpoints solidify)
16
+
atrium-api = { workspace = true, optional = true }
17
+
atrium-xrpc-client = { workspace = true, optional = true }
18
+
19
+
tangled-config = { path = "../tangled-config" }
20
+
21
+
[features]
22
+
default = []
23
+
atrium = ["dep:atrium-api", "dep:atrium-xrpc-client"]
24
+
+39
crates/tangled-api/src/client.rs
+39
crates/tangled-api/src/client.rs
···
1
+
use anyhow::{bail, Result};
2
+
use serde::{Deserialize, Serialize};
3
+
use tangled_config::session::Session;
4
+
5
+
#[derive(Clone, Debug)]
6
+
pub struct TangledClient {
7
+
base_url: String,
8
+
}
9
+
10
+
impl TangledClient {
11
+
pub fn new(base_url: impl Into<String>) -> Self {
12
+
Self { base_url: base_url.into() }
13
+
}
14
+
15
+
pub fn default() -> Self {
16
+
Self::new("https://tangled.org")
17
+
}
18
+
19
+
pub async fn login_with_password(&self, _handle: &str, _password: &str, _pds: &str) -> Result<Session> {
20
+
// TODO: implement via com.atproto.server.createSession
21
+
bail!("login_with_password not implemented")
22
+
}
23
+
24
+
pub async fn list_repos(&self, _user: Option<&str>, _knot: Option<&str>, _starred: bool) -> Result<Vec<Repository>> {
25
+
// TODO: implement XRPC sh.tangled.repo.list
26
+
Ok(vec![])
27
+
}
28
+
}
29
+
30
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31
+
pub struct Repository {
32
+
pub did: Option<String>,
33
+
pub rkey: Option<String>,
34
+
pub name: String,
35
+
pub knot: Option<String>,
36
+
pub description: Option<String>,
37
+
pub private: bool,
38
+
}
39
+
+22
crates/tangled-cli/Cargo.toml
+22
crates/tangled-cli/Cargo.toml
···
1
+
[package]
2
+
name = "tangled-cli"
3
+
version = "0.1.0"
4
+
edition = "2021"
5
+
description = "CLI for interacting with Tangled (AT Protocol-based git collaboration)."
6
+
license = "MIT OR Apache-2.0"
7
+
8
+
[dependencies]
9
+
anyhow = { workspace = true }
10
+
clap = { workspace = true, features = ["derive", "env", "unicode", "wrap_help"] }
11
+
colored = { workspace = true }
12
+
dialoguer = { workspace = true }
13
+
indicatif = { workspace = true }
14
+
serde = { workspace = true, features = ["derive"] }
15
+
serde_json = { workspace = true }
16
+
tokio = { workspace = true, features = ["full"] }
17
+
18
+
# Internal crates
19
+
tangled-config = { path = "../tangled-config" }
20
+
tangled-api = { path = "../tangled-api" }
21
+
tangled-git = { path = "../tangled-git" }
22
+
+368
crates/tangled-cli/src/cli.rs
+368
crates/tangled-cli/src/cli.rs
···
1
+
use clap::{Args, Parser, Subcommand, ValueEnum};
2
+
3
+
#[derive(Parser, Debug, Clone)]
4
+
#[command(name = "tangled", author, version, about = "Tangled CLI", long_about = None)]
5
+
pub struct Cli {
6
+
/// Config file path override
7
+
#[arg(long, global = true)]
8
+
pub config: Option<String>,
9
+
10
+
/// Use named profile
11
+
#[arg(long, global = true)]
12
+
pub profile: Option<String>,
13
+
14
+
/// Output format
15
+
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
16
+
pub format: OutputFormat,
17
+
18
+
/// Verbose output
19
+
#[arg(long, global = true, action = clap::ArgAction::Count)]
20
+
pub verbose: u8,
21
+
22
+
/// Quiet output
23
+
#[arg(long, global = true, default_value_t = false)]
24
+
pub quiet: bool,
25
+
26
+
/// Disable colors
27
+
#[arg(long, global = true, default_value_t = false)]
28
+
pub no_color: bool,
29
+
30
+
#[command(subcommand)]
31
+
pub command: Command,
32
+
}
33
+
34
+
#[derive(Copy, Clone, Debug, ValueEnum)]
35
+
pub enum OutputFormat {
36
+
Json,
37
+
Table,
38
+
}
39
+
40
+
#[derive(Subcommand, Debug, Clone)]
41
+
pub enum Command {
42
+
/// Authentication commands
43
+
Auth(AuthCommand),
44
+
/// Repository commands
45
+
Repo(RepoCommand),
46
+
/// Issue commands
47
+
Issue(IssueCommand),
48
+
/// Pull request commands
49
+
Pr(PrCommand),
50
+
/// Knot management commands
51
+
Knot(KnotCommand),
52
+
/// Spindle integration commands
53
+
Spindle(SpindleCommand),
54
+
}
55
+
56
+
#[derive(Subcommand, Debug, Clone)]
57
+
pub enum AuthCommand {
58
+
/// Login with Bluesky credentials
59
+
Login(AuthLoginArgs),
60
+
/// Show authentication status
61
+
Status,
62
+
/// Logout and clear session
63
+
Logout,
64
+
}
65
+
66
+
#[derive(Args, Debug, Clone)]
67
+
pub struct AuthLoginArgs {
68
+
/// Bluesky handle (e.g. user.bsky.social)
69
+
#[arg(long)]
70
+
pub handle: Option<String>,
71
+
/// Password (will prompt if omitted)
72
+
#[arg(long)]
73
+
pub password: Option<String>,
74
+
/// PDS URL (default: https://bsky.social)
75
+
#[arg(long)]
76
+
pub pds: Option<String>,
77
+
}
78
+
79
+
#[derive(Subcommand, Debug, Clone)]
80
+
pub enum RepoCommand {
81
+
/// List repositories
82
+
List(RepoListArgs),
83
+
/// Create repository
84
+
Create(RepoCreateArgs),
85
+
/// Clone repository
86
+
Clone(RepoCloneArgs),
87
+
/// Show repository information
88
+
Info(RepoInfoArgs),
89
+
/// Delete a repository
90
+
Delete(RepoDeleteArgs),
91
+
/// Star a repository
92
+
Star(RepoRefArgs),
93
+
/// Unstar a repository
94
+
Unstar(RepoRefArgs),
95
+
}
96
+
97
+
#[derive(Args, Debug, Clone)]
98
+
pub struct RepoListArgs {
99
+
#[arg(long)]
100
+
pub knot: Option<String>,
101
+
#[arg(long)]
102
+
pub user: Option<String>,
103
+
#[arg(long, default_value_t = false)]
104
+
pub starred: bool,
105
+
}
106
+
107
+
#[derive(Args, Debug, Clone)]
108
+
pub struct RepoCreateArgs {
109
+
pub name: String,
110
+
#[arg(long)]
111
+
pub knot: Option<String>,
112
+
#[arg(long, default_value_t = false)]
113
+
pub private: bool,
114
+
#[arg(long)]
115
+
pub description: Option<String>,
116
+
#[arg(long, default_value_t = false)]
117
+
pub init: bool,
118
+
}
119
+
120
+
#[derive(Args, Debug, Clone)]
121
+
pub struct RepoCloneArgs {
122
+
pub repo: String,
123
+
#[arg(long, default_value_t = false)]
124
+
pub https: bool,
125
+
#[arg(long)]
126
+
pub depth: Option<usize>,
127
+
}
128
+
129
+
#[derive(Args, Debug, Clone)]
130
+
pub struct RepoInfoArgs {
131
+
pub repo: String,
132
+
#[arg(long, default_value_t = false)]
133
+
pub stats: bool,
134
+
#[arg(long, default_value_t = false)]
135
+
pub contributors: bool,
136
+
}
137
+
138
+
#[derive(Args, Debug, Clone)]
139
+
pub struct RepoDeleteArgs {
140
+
pub repo: String,
141
+
#[arg(long, default_value_t = false)]
142
+
pub force: bool,
143
+
}
144
+
145
+
#[derive(Args, Debug, Clone)]
146
+
pub struct RepoRefArgs {
147
+
pub repo: String,
148
+
}
149
+
150
+
#[derive(Subcommand, Debug, Clone)]
151
+
pub enum IssueCommand {
152
+
List(IssueListArgs),
153
+
Create(IssueCreateArgs),
154
+
Show(IssueShowArgs),
155
+
Edit(IssueEditArgs),
156
+
Comment(IssueCommentArgs),
157
+
}
158
+
159
+
#[derive(Args, Debug, Clone)]
160
+
pub struct IssueListArgs {
161
+
#[arg(long)]
162
+
pub repo: Option<String>,
163
+
#[arg(long)]
164
+
pub state: Option<String>,
165
+
#[arg(long)]
166
+
pub author: Option<String>,
167
+
#[arg(long)]
168
+
pub label: Option<String>,
169
+
#[arg(long)]
170
+
pub assigned: Option<String>,
171
+
}
172
+
173
+
#[derive(Args, Debug, Clone)]
174
+
pub struct IssueCreateArgs {
175
+
#[arg(long)]
176
+
pub repo: Option<String>,
177
+
#[arg(long)]
178
+
pub title: Option<String>,
179
+
#[arg(long)]
180
+
pub body: Option<String>,
181
+
#[arg(long)]
182
+
pub label: Option<Vec<String>>,
183
+
#[arg(long, value_name = "HANDLE")]
184
+
pub assign: Option<Vec<String>>,
185
+
}
186
+
187
+
#[derive(Args, Debug, Clone)]
188
+
pub struct IssueShowArgs {
189
+
pub id: String,
190
+
#[arg(long, default_value_t = false)]
191
+
pub comments: bool,
192
+
#[arg(long, default_value_t = false)]
193
+
pub json: bool,
194
+
}
195
+
196
+
#[derive(Args, Debug, Clone)]
197
+
pub struct IssueEditArgs {
198
+
pub id: String,
199
+
#[arg(long)]
200
+
pub title: Option<String>,
201
+
#[arg(long)]
202
+
pub body: Option<String>,
203
+
#[arg(long)]
204
+
pub state: Option<String>,
205
+
}
206
+
207
+
#[derive(Args, Debug, Clone)]
208
+
pub struct IssueCommentArgs {
209
+
pub id: String,
210
+
#[arg(long)]
211
+
pub body: Option<String>,
212
+
#[arg(long, default_value_t = false)]
213
+
pub close: bool,
214
+
}
215
+
216
+
#[derive(Subcommand, Debug, Clone)]
217
+
pub enum PrCommand {
218
+
List(PrListArgs),
219
+
Create(PrCreateArgs),
220
+
Show(PrShowArgs),
221
+
Review(PrReviewArgs),
222
+
Merge(PrMergeArgs),
223
+
}
224
+
225
+
#[derive(Args, Debug, Clone)]
226
+
pub struct PrListArgs {
227
+
#[arg(long)]
228
+
pub repo: Option<String>,
229
+
#[arg(long)]
230
+
pub state: Option<String>,
231
+
#[arg(long)]
232
+
pub author: Option<String>,
233
+
#[arg(long)]
234
+
pub reviewer: Option<String>,
235
+
}
236
+
237
+
#[derive(Args, Debug, Clone)]
238
+
pub struct PrCreateArgs {
239
+
#[arg(long)]
240
+
pub repo: Option<String>,
241
+
#[arg(long)]
242
+
pub base: Option<String>,
243
+
#[arg(long)]
244
+
pub head: Option<String>,
245
+
#[arg(long)]
246
+
pub title: Option<String>,
247
+
#[arg(long)]
248
+
pub body: Option<String>,
249
+
#[arg(long, default_value_t = false)]
250
+
pub draft: bool,
251
+
}
252
+
253
+
#[derive(Args, Debug, Clone)]
254
+
pub struct PrShowArgs {
255
+
pub id: String,
256
+
#[arg(long, default_value_t = false)]
257
+
pub diff: bool,
258
+
#[arg(long, default_value_t = false)]
259
+
pub comments: bool,
260
+
#[arg(long, default_value_t = false)]
261
+
pub checks: bool,
262
+
}
263
+
264
+
#[derive(Args, Debug, Clone)]
265
+
pub struct PrReviewArgs {
266
+
pub id: String,
267
+
#[arg(long, default_value_t = false)]
268
+
pub approve: bool,
269
+
#[arg(long, default_value_t = false)]
270
+
pub request_changes: bool,
271
+
#[arg(long)]
272
+
pub comment: Option<String>,
273
+
}
274
+
275
+
#[derive(Args, Debug, Clone)]
276
+
pub struct PrMergeArgs {
277
+
pub id: String,
278
+
#[arg(long, default_value_t = false)]
279
+
pub squash: bool,
280
+
#[arg(long, default_value_t = false)]
281
+
pub rebase: bool,
282
+
#[arg(long, default_value_t = false)]
283
+
pub no_ff: bool,
284
+
}
285
+
286
+
#[derive(Subcommand, Debug, Clone)]
287
+
pub enum KnotCommand {
288
+
List(KnotListArgs),
289
+
Add(KnotAddArgs),
290
+
Verify(KnotVerifyArgs),
291
+
SetDefault(KnotRefArgs),
292
+
Remove(KnotRefArgs),
293
+
}
294
+
295
+
#[derive(Args, Debug, Clone)]
296
+
pub struct KnotListArgs {
297
+
#[arg(long, default_value_t = false)]
298
+
pub public: bool,
299
+
#[arg(long, default_value_t = false)]
300
+
pub owned: bool,
301
+
}
302
+
303
+
#[derive(Args, Debug, Clone)]
304
+
pub struct KnotAddArgs {
305
+
pub url: String,
306
+
#[arg(long)]
307
+
pub did: Option<String>,
308
+
#[arg(long)]
309
+
pub name: Option<String>,
310
+
#[arg(long, default_value_t = false)]
311
+
pub verify: bool,
312
+
}
313
+
314
+
#[derive(Args, Debug, Clone)]
315
+
pub struct KnotVerifyArgs {
316
+
pub url: String,
317
+
}
318
+
319
+
#[derive(Args, Debug, Clone)]
320
+
pub struct KnotRefArgs {
321
+
pub url: String,
322
+
}
323
+
324
+
#[derive(Subcommand, Debug, Clone)]
325
+
pub enum SpindleCommand {
326
+
List(SpindleListArgs),
327
+
Config(SpindleConfigArgs),
328
+
Run(SpindleRunArgs),
329
+
Logs(SpindleLogsArgs),
330
+
}
331
+
332
+
#[derive(Args, Debug, Clone)]
333
+
pub struct SpindleListArgs {
334
+
#[arg(long)]
335
+
pub repo: Option<String>,
336
+
}
337
+
338
+
#[derive(Args, Debug, Clone)]
339
+
pub struct SpindleConfigArgs {
340
+
#[arg(long)]
341
+
pub repo: Option<String>,
342
+
#[arg(long)]
343
+
pub url: Option<String>,
344
+
#[arg(long, default_value_t = false)]
345
+
pub enable: bool,
346
+
#[arg(long, default_value_t = false)]
347
+
pub disable: bool,
348
+
}
349
+
350
+
#[derive(Args, Debug, Clone)]
351
+
pub struct SpindleRunArgs {
352
+
#[arg(long)]
353
+
pub repo: Option<String>,
354
+
#[arg(long)]
355
+
pub branch: Option<String>,
356
+
#[arg(long, default_value_t = false)]
357
+
pub wait: bool,
358
+
}
359
+
360
+
#[derive(Args, Debug, Clone)]
361
+
pub struct SpindleLogsArgs {
362
+
pub job_id: String,
363
+
#[arg(long, default_value_t = false)]
364
+
pub follow: bool,
365
+
#[arg(long)]
366
+
pub lines: Option<usize>,
367
+
}
368
+
+50
crates/tangled-cli/src/commands/auth.rs
+50
crates/tangled-cli/src/commands/auth.rs
···
1
+
use anyhow::Result;
2
+
use dialoguer::{Input, Password};
3
+
4
+
use crate::cli::{AuthCommand, AuthLoginArgs, Cli};
5
+
6
+
pub async fn run(cli: &Cli, cmd: AuthCommand) -> Result<()> {
7
+
match cmd {
8
+
AuthCommand::Login(args) => login(cli, args).await,
9
+
AuthCommand::Status => status(cli).await,
10
+
AuthCommand::Logout => logout(cli).await,
11
+
}
12
+
}
13
+
14
+
async fn login(_cli: &Cli, mut args: AuthLoginArgs) -> Result<()> {
15
+
let handle: String = match args.handle.take() {
16
+
Some(h) => h,
17
+
None => Input::new().with_prompt("Handle").interact_text()?,
18
+
};
19
+
let password: String = match args.password.take() {
20
+
Some(p) => p,
21
+
None => Password::new().with_prompt("Password").interact()?,
22
+
};
23
+
let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string());
24
+
25
+
// Placeholder: integrate tangled_api authentication here
26
+
println!(
27
+
"Logging in as '{}' against PDS '{}'... (stub)",
28
+
handle, pds
29
+
);
30
+
31
+
// Example future flow:
32
+
// let client = tangled_api::TangledClient::new(&pds);
33
+
// let session = client.login(&handle, &password).await?;
34
+
// tangled_config::session::SessionManager::default().save(&session)?;
35
+
36
+
Ok(())
37
+
}
38
+
39
+
async fn status(_cli: &Cli) -> Result<()> {
40
+
// Placeholder: read session from keyring/config
41
+
println!("Authentication status: (stub) not implemented");
42
+
Ok(())
43
+
}
44
+
45
+
async fn logout(_cli: &Cli) -> Result<()> {
46
+
// Placeholder: remove session from keyring/config
47
+
println!("Logged out (stub)");
48
+
Ok(())
49
+
}
50
+
+41
crates/tangled-cli/src/commands/issue.rs
+41
crates/tangled-cli/src/commands/issue.rs
···
1
+
use anyhow::Result;
2
+
use crate::cli::{Cli, IssueCommand, IssueListArgs, IssueCreateArgs, IssueShowArgs, IssueEditArgs, IssueCommentArgs};
3
+
4
+
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
5
+
match cmd {
6
+
IssueCommand::List(args) => list(args).await,
7
+
IssueCommand::Create(args) => create(args).await,
8
+
IssueCommand::Show(args) => show(args).await,
9
+
IssueCommand::Edit(args) => edit(args).await,
10
+
IssueCommand::Comment(args) => comment(args).await,
11
+
}
12
+
}
13
+
14
+
async fn list(args: IssueListArgs) -> Result<()> {
15
+
println!("Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}",
16
+
args.repo, args.state, args.author, args.label, args.assigned);
17
+
Ok(())
18
+
}
19
+
20
+
async fn create(args: IssueCreateArgs) -> Result<()> {
21
+
println!("Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}",
22
+
args.repo, args.title, args.body, args.label, args.assign);
23
+
Ok(())
24
+
}
25
+
26
+
async fn show(args: IssueShowArgs) -> Result<()> {
27
+
println!("Issue show (stub) id={} comments={} json={}", args.id, args.comments, args.json);
28
+
Ok(())
29
+
}
30
+
31
+
async fn edit(args: IssueEditArgs) -> Result<()> {
32
+
println!("Issue edit (stub) id={} title={:?} body={:?} state={:?}",
33
+
args.id, args.title, args.body, args.state);
34
+
Ok(())
35
+
}
36
+
37
+
async fn comment(args: IssueCommentArgs) -> Result<()> {
38
+
println!("Issue comment (stub) id={} close={} body={:?}", args.id, args.close, args.body);
39
+
Ok(())
40
+
}
41
+
+38
crates/tangled-cli/src/commands/knot.rs
+38
crates/tangled-cli/src/commands/knot.rs
···
1
+
use anyhow::Result;
2
+
use crate::cli::{Cli, KnotCommand, KnotListArgs, KnotAddArgs, KnotVerifyArgs, KnotRefArgs};
3
+
4
+
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
5
+
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,
11
+
}
12
+
}
13
+
14
+
async fn list(args: KnotListArgs) -> Result<()> {
15
+
println!("Knot list (stub) public={} owned={}", args.public, args.owned);
16
+
Ok(())
17
+
}
18
+
19
+
async fn add(args: KnotAddArgs) -> Result<()> {
20
+
println!("Knot add (stub) url={} did={:?} name={:?} verify={}", args.url, args.did, args.name, args.verify);
21
+
Ok(())
22
+
}
23
+
24
+
async fn verify(args: KnotVerifyArgs) -> Result<()> {
25
+
println!("Knot verify (stub) url={}", args.url);
26
+
Ok(())
27
+
}
28
+
29
+
async fn set_default(args: KnotRefArgs) -> Result<()> {
30
+
println!("Knot set-default (stub) url={}", args.url);
31
+
Ok(())
32
+
}
33
+
34
+
async fn remove(args: KnotRefArgs) -> Result<()> {
35
+
println!("Knot remove (stub) url={}", args.url);
36
+
Ok(())
37
+
}
38
+
+28
crates/tangled-cli/src/commands/mod.rs
+28
crates/tangled-cli/src/commands/mod.rs
···
1
+
pub mod auth;
2
+
pub mod repo;
3
+
pub mod issue;
4
+
pub mod pr;
5
+
pub mod knot;
6
+
pub mod spindle;
7
+
8
+
use anyhow::Result;
9
+
use colored::Colorize;
10
+
11
+
use crate::cli::{Cli, Command};
12
+
13
+
pub async fn dispatch(cli: Cli) -> Result<()> {
14
+
match cli.command {
15
+
Command::Auth(cmd) => auth::run(&cli, cmd).await,
16
+
Command::Repo(cmd) => repo::run(&cli, cmd).await,
17
+
Command::Issue(cmd) => issue::run(&cli, cmd).await,
18
+
Command::Pr(cmd) => pr::run(&cli, cmd).await,
19
+
Command::Knot(cmd) => knot::run(&cli, cmd).await,
20
+
Command::Spindle(cmd) => spindle::run(&cli, cmd).await,
21
+
}
22
+
}
23
+
24
+
fn not_implemented(feature: &str) -> Result<()> {
25
+
eprintln!("{} {}", "[todo]".yellow().bold(), feature);
26
+
Ok(())
27
+
}
28
+
+42
crates/tangled-cli/src/commands/pr.rs
+42
crates/tangled-cli/src/commands/pr.rs
···
1
+
use anyhow::Result;
2
+
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrShowArgs, PrReviewArgs, PrMergeArgs};
3
+
4
+
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
5
+
match cmd {
6
+
PrCommand::List(args) => list(args).await,
7
+
PrCommand::Create(args) => create(args).await,
8
+
PrCommand::Show(args) => show(args).await,
9
+
PrCommand::Review(args) => review(args).await,
10
+
PrCommand::Merge(args) => merge(args).await,
11
+
}
12
+
}
13
+
14
+
async fn list(args: PrListArgs) -> Result<()> {
15
+
println!("PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}",
16
+
args.repo, args.state, args.author, args.reviewer);
17
+
Ok(())
18
+
}
19
+
20
+
async fn create(args: PrCreateArgs) -> Result<()> {
21
+
println!("PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
22
+
args.repo, args.base, args.head, args.title, args.draft);
23
+
Ok(())
24
+
}
25
+
26
+
async fn show(args: PrShowArgs) -> Result<()> {
27
+
println!("PR show (stub) id={} diff={} comments={} checks={}", args.id, args.diff, args.comments, args.checks);
28
+
Ok(())
29
+
}
30
+
31
+
async fn review(args: PrReviewArgs) -> Result<()> {
32
+
println!("PR review (stub) id={} approve={} request_changes={} comment={:?}",
33
+
args.id, args.approve, args.request_changes, args.comment);
34
+
Ok(())
35
+
}
36
+
37
+
async fn merge(args: PrMergeArgs) -> Result<()> {
38
+
println!("PR merge (stub) id={} squash={} rebase={} no_ff={}",
39
+
args.id, args.squash, args.rebase, args.no_ff);
40
+
Ok(())
41
+
}
42
+
+57
crates/tangled-cli/src/commands/repo.rs
+57
crates/tangled-cli/src/commands/repo.rs
···
1
+
use anyhow::Result;
2
+
use crate::cli::{Cli, RepoCommand, RepoCreateArgs, RepoInfoArgs, RepoListArgs, RepoCloneArgs, RepoDeleteArgs, RepoRefArgs};
3
+
4
+
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
5
+
match cmd {
6
+
RepoCommand::List(args) => list(args).await,
7
+
RepoCommand::Create(args) => create(args).await,
8
+
RepoCommand::Clone(args) => clone(args).await,
9
+
RepoCommand::Info(args) => info(args).await,
10
+
RepoCommand::Delete(args) => delete(args).await,
11
+
RepoCommand::Star(args) => star(args).await,
12
+
RepoCommand::Unstar(args) => unstar(args).await,
13
+
}
14
+
}
15
+
16
+
async fn list(args: RepoListArgs) -> Result<()> {
17
+
println!("Listing repositories (stub) knot={:?} user={:?} starred={}",
18
+
args.knot, args.user, args.starred);
19
+
Ok(())
20
+
}
21
+
22
+
async fn create(args: RepoCreateArgs) -> Result<()> {
23
+
println!(
24
+
"Creating repo '{}' (stub) knot={:?} private={} init={} desc={:?}",
25
+
args.name, args.knot, args.private, args.init, args.description
26
+
);
27
+
Ok(())
28
+
}
29
+
30
+
async fn clone(args: RepoCloneArgs) -> Result<()> {
31
+
println!("Cloning repo '{}' (stub) https={} depth={:?}", args.repo, args.https, args.depth);
32
+
Ok(())
33
+
}
34
+
35
+
async fn info(args: RepoInfoArgs) -> Result<()> {
36
+
println!(
37
+
"Repository info '{}' (stub) stats={} contributors={}",
38
+
args.repo, args.stats, args.contributors
39
+
);
40
+
Ok(())
41
+
}
42
+
43
+
async fn delete(args: RepoDeleteArgs) -> Result<()> {
44
+
println!("Deleting repo '{}' (stub) force={}", args.repo, args.force);
45
+
Ok(())
46
+
}
47
+
48
+
async fn star(args: RepoRefArgs) -> Result<()> {
49
+
println!("Starring repo '{}' (stub)", args.repo);
50
+
Ok(())
51
+
}
52
+
53
+
async fn unstar(args: RepoRefArgs) -> Result<()> {
54
+
println!("Unstarring repo '{}' (stub)", args.repo);
55
+
Ok(())
56
+
}
57
+
+35
crates/tangled-cli/src/commands/spindle.rs
+35
crates/tangled-cli/src/commands/spindle.rs
···
1
+
use anyhow::Result;
2
+
use crate::cli::{Cli, SpindleCommand, SpindleListArgs, SpindleConfigArgs, SpindleRunArgs, SpindleLogsArgs};
3
+
4
+
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
5
+
match cmd {
6
+
SpindleCommand::List(args) => list(args).await,
7
+
SpindleCommand::Config(args) => config(args).await,
8
+
SpindleCommand::Run(args) => run_pipeline(args).await,
9
+
SpindleCommand::Logs(args) => logs(args).await,
10
+
}
11
+
}
12
+
13
+
async fn list(args: SpindleListArgs) -> Result<()> {
14
+
println!("Spindle list (stub) repo={:?}", args.repo);
15
+
Ok(())
16
+
}
17
+
18
+
async fn config(args: SpindleConfigArgs) -> Result<()> {
19
+
println!(
20
+
"Spindle config (stub) repo={:?} url={:?} enable={} disable={}",
21
+
args.repo, args.url, args.enable, args.disable
22
+
);
23
+
Ok(())
24
+
}
25
+
26
+
async fn run_pipeline(args: SpindleRunArgs) -> Result<()> {
27
+
println!("Spindle run (stub) repo={:?} branch={:?} wait={}", args.repo, args.branch, args.wait);
28
+
Ok(())
29
+
}
30
+
31
+
async fn logs(args: SpindleLogsArgs) -> Result<()> {
32
+
println!("Spindle logs (stub) job_id={} follow={} lines={:?}", args.job_id, args.follow, args.lines);
33
+
Ok(())
34
+
}
35
+
+12
crates/tangled-cli/src/main.rs
+12
crates/tangled-cli/src/main.rs
+16
crates/tangled-config/Cargo.toml
+16
crates/tangled-config/Cargo.toml
···
1
+
[package]
2
+
name = "tangled-config"
3
+
version = "0.1.0"
4
+
edition = "2021"
5
+
description = "Configuration and session management for Tangled CLI"
6
+
license = "MIT OR Apache-2.0"
7
+
8
+
[dependencies]
9
+
anyhow = { workspace = true }
10
+
dirs = { workspace = true }
11
+
keyring = { workspace = true }
12
+
serde = { workspace = true, features = ["derive"] }
13
+
serde_json = { workspace = true }
14
+
toml = { workspace = true }
15
+
chrono = { workspace = true }
16
+
+83
crates/tangled-config/src/config.rs
+83
crates/tangled-config/src/config.rs
···
1
+
use std::fs;
2
+
use std::path::{Path, PathBuf};
3
+
4
+
use anyhow::{Context, Result};
5
+
use dirs::config_dir;
6
+
use serde::{Deserialize, Serialize};
7
+
8
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9
+
pub struct RootConfig {
10
+
#[serde(default)]
11
+
pub default: DefaultSection,
12
+
#[serde(default)]
13
+
pub auth: AuthSection,
14
+
#[serde(default)]
15
+
pub knots: KnotsSection,
16
+
#[serde(default)]
17
+
pub ui: UiSection,
18
+
}
19
+
20
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21
+
pub struct DefaultSection {
22
+
pub knot: Option<String>,
23
+
pub editor: Option<String>,
24
+
pub pager: Option<String>,
25
+
#[serde(default = "default_format")]
26
+
pub format: String,
27
+
}
28
+
29
+
fn default_format() -> String { "table".to_string() }
30
+
31
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32
+
pub struct AuthSection {
33
+
pub handle: Option<String>,
34
+
pub did: Option<String>,
35
+
pub pds_url: Option<String>,
36
+
}
37
+
38
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39
+
pub struct KnotsSection {
40
+
pub default: Option<String>,
41
+
#[serde(default)]
42
+
pub custom: serde_json::Value,
43
+
}
44
+
45
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46
+
pub struct UiSection {
47
+
#[serde(default)]
48
+
pub color: bool,
49
+
#[serde(default)]
50
+
pub progress_bar: bool,
51
+
#[serde(default)]
52
+
pub confirm_destructive: bool,
53
+
}
54
+
55
+
pub fn default_config_path() -> Result<PathBuf> {
56
+
let base = config_dir().context("Could not determine platform config directory")?;
57
+
Ok(base.join("tangled").join("config.toml"))
58
+
}
59
+
60
+
pub fn load_config(path: Option<&Path>) -> Result<Option<RootConfig>> {
61
+
let path = path
62
+
.map(|p| p.to_path_buf())
63
+
.unwrap_or(default_config_path()?);
64
+
if !path.exists() {
65
+
return Ok(None);
66
+
}
67
+
let content = fs::read_to_string(&path)
68
+
.with_context(|| format!("Failed reading config file: {}", path.display()))?;
69
+
let cfg: RootConfig = toml::from_str(&content).context("Invalid TOML in config")?;
70
+
Ok(Some(cfg))
71
+
}
72
+
73
+
pub fn save_config(cfg: &RootConfig, path: Option<&Path>) -> Result<()> {
74
+
let path = path
75
+
.map(|p| p.to_path_buf())
76
+
.unwrap_or(default_config_path()?);
77
+
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
78
+
let toml = toml::to_string_pretty(cfg)?;
79
+
fs::write(&path, toml)
80
+
.with_context(|| format!("Failed writing config file: {}", path.display()))?;
81
+
Ok(())
82
+
}
83
+
+30
crates/tangled-config/src/keychain.rs
+30
crates/tangled-config/src/keychain.rs
···
1
+
use anyhow::{anyhow, Result};
2
+
use keyring::Entry;
3
+
4
+
pub struct Keychain {
5
+
service: String,
6
+
account: String,
7
+
}
8
+
9
+
impl Keychain {
10
+
pub fn new(service: &str, account: &str) -> Self {
11
+
Self { service: service.into(), account: account.into() }
12
+
}
13
+
14
+
fn entry(&self) -> Result<Entry> {
15
+
Entry::new(&self.service, &self.account).map_err(|e| anyhow!("keyring error: {e}"))
16
+
}
17
+
18
+
pub fn set_password(&self, secret: &str) -> Result<()> {
19
+
self.entry()?.set_password(secret).map_err(|e| anyhow!("keyring error: {e}"))
20
+
}
21
+
22
+
pub fn get_password(&self) -> Result<String> {
23
+
self.entry()?.get_password().map_err(|e| anyhow!("keyring error: {e}"))
24
+
}
25
+
26
+
pub fn delete_password(&self) -> Result<()> {
27
+
self.entry()?.delete_password().map_err(|e| anyhow!("keyring error: {e}"))
28
+
}
29
+
}
30
+
+4
crates/tangled-config/src/lib.rs
+4
crates/tangled-config/src/lib.rs
+62
crates/tangled-config/src/session.rs
+62
crates/tangled-config/src/session.rs
···
1
+
use anyhow::Result;
2
+
use chrono::{DateTime, Utc};
3
+
use serde::{Deserialize, Serialize};
4
+
5
+
use crate::keychain::Keychain;
6
+
7
+
#[derive(Debug, Clone, Serialize, Deserialize)]
8
+
pub struct Session {
9
+
pub access_jwt: String,
10
+
pub refresh_jwt: String,
11
+
pub did: String,
12
+
pub handle: String,
13
+
#[serde(default)]
14
+
pub created_at: DateTime<Utc>,
15
+
}
16
+
17
+
impl Default for Session {
18
+
fn default() -> Self {
19
+
Self {
20
+
access_jwt: String::new(),
21
+
refresh_jwt: String::new(),
22
+
did: String::new(),
23
+
handle: String::new(),
24
+
created_at: Utc::now(),
25
+
}
26
+
}
27
+
}
28
+
29
+
pub struct SessionManager {
30
+
service: String,
31
+
account: String,
32
+
}
33
+
34
+
impl Default for SessionManager {
35
+
fn default() -> Self {
36
+
Self { service: "tangled-cli".into(), account: "default".into() }
37
+
}
38
+
}
39
+
40
+
impl SessionManager {
41
+
pub fn new(service: &str, account: &str) -> Self { Self { service: service.into(), account: account.into() } }
42
+
43
+
pub fn save(&self, session: &Session) -> Result<()> {
44
+
let keychain = Keychain::new(&self.service, &self.account);
45
+
let json = serde_json::to_string(session)?;
46
+
keychain.set_password(&json)
47
+
}
48
+
49
+
pub fn load(&self) -> Result<Option<Session>> {
50
+
let keychain = Keychain::new(&self.service, &self.account);
51
+
match keychain.get_password() {
52
+
Ok(json) => Ok(Some(serde_json::from_str(&json)?)),
53
+
Err(_) => Ok(None),
54
+
}
55
+
}
56
+
57
+
pub fn clear(&self) -> Result<()> {
58
+
let keychain = Keychain::new(&self.service, &self.account);
59
+
keychain.delete_password()
60
+
}
61
+
}
62
+
+11
crates/tangled-git/Cargo.toml
+11
crates/tangled-git/Cargo.toml
+8
crates/tangled-git/src/operations.rs
+8
crates/tangled-git/src/operations.rs
+18
docs/getting-started.md
+18
docs/getting-started.md
···
1
+
# Getting Started
2
+
3
+
This project is a scaffold of a Tangled CLI in Rust. The commands are present as stubs and will be wired to XRPC endpoints iteratively.
4
+
5
+
## Build
6
+
7
+
Requires Rust toolchain and network access to fetch dependencies.
8
+
9
+
```
10
+
cargo build
11
+
```
12
+
13
+
## Run
14
+
15
+
```
16
+
cargo run -p tangled-cli -- --help
17
+
```
18
+
+8
lexicons/sh.tangled/issue.json
+8
lexicons/sh.tangled/issue.json
+6
lexicons/sh.tangled/knot.json
+6
lexicons/sh.tangled/knot.json
+37
lexicons/sh.tangled/repo.json
+37
lexicons/sh.tangled/repo.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.placeholder",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"properties": {
10
+
"user": {"type": "string"},
11
+
"knot": {"type": "string"},
12
+
"starred": {"type": "boolean"}
13
+
}
14
+
},
15
+
"output": {
16
+
"schema": {
17
+
"type": "object",
18
+
"properties": {
19
+
"repos": {
20
+
"type": "array",
21
+
"items": {
22
+
"type": "object",
23
+
"required": ["name"],
24
+
"properties": {
25
+
"name": {"type": "string"},
26
+
"knot": {"type": "string"},
27
+
"private": {"type": "boolean"}
28
+
}
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+