Initial commit

Vitor Py Braga b60e6c54

+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
···
··· 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
···
··· 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
···
··· 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 +
+4
crates/tangled-api/src/lib.rs
···
··· 1 + pub mod client; 2 + 3 + pub use client::TangledClient; 4 +
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 1 + mod cli; 2 + mod commands; 3 + 4 + use anyhow::Result; 5 + use cli::Cli; 6 + use clap::Parser; 7 + 8 + #[tokio::main] 9 + async fn main() -> Result<()> { 10 + let cli = Cli::parse(); 11 + commands::dispatch(cli).await 12 + }
+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
···
··· 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
···
··· 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
···
··· 1 + pub mod config; 2 + pub mod session; 3 + pub mod keychain; 4 +
+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
···
··· 1 + [package] 2 + name = "tangled-git" 3 + version = "0.1.0" 4 + edition = "2021" 5 + description = "Git integration helpers for Tangled CLI" 6 + license = "MIT OR Apache-2.0" 7 + 8 + [dependencies] 9 + anyhow = { workspace = true } 10 + git2 = { workspace = true } 11 +
+2
crates/tangled-git/src/lib.rs
···
··· 1 + pub mod operations; 2 +
+8
crates/tangled-git/src/operations.rs
···
··· 1 + use anyhow::{bail, Result}; 2 + use git2::Repository; 3 + 4 + pub fn clone_repo(_url: &str, _path: &std::path::Path) -> Result<Repository> { 5 + // TODO: support ssh/https and depth 6 + bail!("clone_repo not implemented") 7 + } 8 +
+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
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.issue.placeholder", 4 + "defs": { 5 + "main": { "type": "record", "record": { "type": "object", "required": ["title"], "properties": { "title": {"type": "string"}, "body": {"type": "string"} } } } 6 + } 7 + } 8 +
+6
lexicons/sh.tangled/knot.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.placeholder", 4 + "defs": {"main": {"type": "query"}} 5 + } 6 +
+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 +