Initial commit

Vitor Py Braga b60e6c54

+61
Cargo.toml
···
+
[workspace]
+
members = [
+
"crates/*",
+
]
+
resolver = "2"
+
+
[workspace.package]
+
edition = "2021"
+
+
[workspace.dependencies]
+
# AT Protocol
+
atrium-api = "0.24"
+
atrium-xrpc-client = "0.5"
+
atrium-identity = "0.1"
+
atrium-oauth = "0.1"
+
+
# CLI
+
clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
+
clap_complete = "4.5"
+
+
# Async
+
tokio = { version = "1.40", features = ["full"] }
+
futures = "0.3"
+
+
# HTTP & Serialization
+
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
+
serde = { version = "1.0", features = ["derive"] }
+
serde_json = "1.0"
+
toml = "0.8"
+
+
# Git
+
git2 = "0.19"
+
git2-credentials = "0.13"
+
+
# Terminal UI
+
indicatif = "0.17"
+
colored = "2.1"
+
tabled = "0.16"
+
dialoguer = "0.11"
+
console = "0.15"
+
+
# Storage
+
dirs = "5.0"
+
keyring = "3.0"
+
+
# Error Handling
+
anyhow = "1.0"
+
thiserror = "2.0"
+
+
# Utilities
+
chrono = "0.4"
+
url = "2.5"
+
base64 = "0.22"
+
regex = "1.10"
+
+
# Testing
+
mockito = "1.4"
+
tempfile = "3.10"
+
assert_cmd = "2.0"
+
predicates = "3.1"
+
+29
README.md
···
+
# Tangled CLI (Rust)
+
+
A Rust CLI for Tangled, a decentralized git collaboration platform built on the AT Protocol.
+
+
Status: project scaffold with CLI, config, API and git crates. Commands are stubs pending endpoint wiring.
+
+
## Workspace
+
+
- `crates/tangled-cli`: CLI binary (clap-based)
+
- `crates/tangled-config`: Config + session management
+
- `crates/tangled-api`: XRPC client wrapper (stubs)
+
- `crates/tangled-git`: Git helpers (stubs)
+
- `lexicons/sh.tangled`: Placeholder lexicons
+
+
## Quick start
+
+
```
+
cargo run -p tangled-cli -- --help
+
```
+
+
Building requires network to fetch crates.
+
+
## Next steps
+
+
- Implement `com.atproto.server.createSession` for auth
+
- Wire repo list/create endpoints under `sh.tangled.*`
+
- Persist sessions via keyring and load in CLI
+
- Add output formatting (table/json)
+
+24
crates/tangled-api/Cargo.toml
···
+
[package]
+
name = "tangled-api"
+
version = "0.1.0"
+
edition = "2021"
+
description = "XRPC client wrapper for Tangled operations"
+
license = "MIT OR Apache-2.0"
+
+
[dependencies]
+
anyhow = { workspace = true }
+
serde = { workspace = true, features = ["derive"] }
+
serde_json = { workspace = true }
+
reqwest = { workspace = true }
+
tokio = { workspace = true, features = ["full"] }
+
+
# Optionally depend on ATrium (wired later as endpoints solidify)
+
atrium-api = { workspace = true, optional = true }
+
atrium-xrpc-client = { workspace = true, optional = true }
+
+
tangled-config = { path = "../tangled-config" }
+
+
[features]
+
default = []
+
atrium = ["dep:atrium-api", "dep:atrium-xrpc-client"]
+
+39
crates/tangled-api/src/client.rs
···
+
use anyhow::{bail, Result};
+
use serde::{Deserialize, Serialize};
+
use tangled_config::session::Session;
+
+
#[derive(Clone, Debug)]
+
pub struct TangledClient {
+
base_url: String,
+
}
+
+
impl TangledClient {
+
pub fn new(base_url: impl Into<String>) -> Self {
+
Self { base_url: base_url.into() }
+
}
+
+
pub fn default() -> Self {
+
Self::new("https://tangled.org")
+
}
+
+
pub async fn login_with_password(&self, _handle: &str, _password: &str, _pds: &str) -> Result<Session> {
+
// TODO: implement via com.atproto.server.createSession
+
bail!("login_with_password not implemented")
+
}
+
+
pub async fn list_repos(&self, _user: Option<&str>, _knot: Option<&str>, _starred: bool) -> Result<Vec<Repository>> {
+
// TODO: implement XRPC sh.tangled.repo.list
+
Ok(vec![])
+
}
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+
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,
+
}
+
+4
crates/tangled-api/src/lib.rs
···
+
pub mod client;
+
+
pub use client::TangledClient;
+
+22
crates/tangled-cli/Cargo.toml
···
+
[package]
+
name = "tangled-cli"
+
version = "0.1.0"
+
edition = "2021"
+
description = "CLI for interacting with Tangled (AT Protocol-based git collaboration)."
+
license = "MIT OR Apache-2.0"
+
+
[dependencies]
+
anyhow = { workspace = true }
+
clap = { workspace = true, features = ["derive", "env", "unicode", "wrap_help"] }
+
colored = { workspace = true }
+
dialoguer = { workspace = true }
+
indicatif = { workspace = true }
+
serde = { workspace = true, features = ["derive"] }
+
serde_json = { workspace = true }
+
tokio = { workspace = true, features = ["full"] }
+
+
# Internal crates
+
tangled-config = { path = "../tangled-config" }
+
tangled-api = { path = "../tangled-api" }
+
tangled-git = { path = "../tangled-git" }
+
+368
crates/tangled-cli/src/cli.rs
···
+
use clap::{Args, Parser, Subcommand, ValueEnum};
+
+
#[derive(Parser, Debug, Clone)]
+
#[command(name = "tangled", author, version, about = "Tangled CLI", long_about = None)]
+
pub struct Cli {
+
/// Config file path override
+
#[arg(long, global = true)]
+
pub config: Option<String>,
+
+
/// Use named profile
+
#[arg(long, global = true)]
+
pub profile: Option<String>,
+
+
/// Output format
+
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
+
pub format: OutputFormat,
+
+
/// Verbose output
+
#[arg(long, global = true, action = clap::ArgAction::Count)]
+
pub verbose: u8,
+
+
/// Quiet output
+
#[arg(long, global = true, default_value_t = false)]
+
pub quiet: bool,
+
+
/// Disable colors
+
#[arg(long, global = true, default_value_t = false)]
+
pub no_color: bool,
+
+
#[command(subcommand)]
+
pub command: Command,
+
}
+
+
#[derive(Copy, Clone, Debug, ValueEnum)]
+
pub enum OutputFormat {
+
Json,
+
Table,
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum Command {
+
/// Authentication commands
+
Auth(AuthCommand),
+
/// Repository commands
+
Repo(RepoCommand),
+
/// Issue commands
+
Issue(IssueCommand),
+
/// Pull request commands
+
Pr(PrCommand),
+
/// Knot management commands
+
Knot(KnotCommand),
+
/// Spindle integration commands
+
Spindle(SpindleCommand),
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum AuthCommand {
+
/// Login with Bluesky credentials
+
Login(AuthLoginArgs),
+
/// Show authentication status
+
Status,
+
/// Logout and clear session
+
Logout,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct AuthLoginArgs {
+
/// Bluesky handle (e.g. user.bsky.social)
+
#[arg(long)]
+
pub handle: Option<String>,
+
/// Password (will prompt if omitted)
+
#[arg(long)]
+
pub password: Option<String>,
+
/// PDS URL (default: https://bsky.social)
+
#[arg(long)]
+
pub pds: Option<String>,
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum RepoCommand {
+
/// List repositories
+
List(RepoListArgs),
+
/// Create repository
+
Create(RepoCreateArgs),
+
/// Clone repository
+
Clone(RepoCloneArgs),
+
/// Show repository information
+
Info(RepoInfoArgs),
+
/// Delete a repository
+
Delete(RepoDeleteArgs),
+
/// Star a repository
+
Star(RepoRefArgs),
+
/// Unstar a repository
+
Unstar(RepoRefArgs),
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct RepoListArgs {
+
#[arg(long)]
+
pub knot: Option<String>,
+
#[arg(long)]
+
pub user: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub starred: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct RepoCreateArgs {
+
pub name: String,
+
#[arg(long)]
+
pub knot: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub private: bool,
+
#[arg(long)]
+
pub description: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub init: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct RepoCloneArgs {
+
pub repo: String,
+
#[arg(long, default_value_t = false)]
+
pub https: bool,
+
#[arg(long)]
+
pub depth: Option<usize>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct RepoInfoArgs {
+
pub repo: String,
+
#[arg(long, default_value_t = false)]
+
pub stats: bool,
+
#[arg(long, default_value_t = false)]
+
pub contributors: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct RepoDeleteArgs {
+
pub repo: String,
+
#[arg(long, default_value_t = false)]
+
pub force: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct RepoRefArgs {
+
pub repo: String,
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum IssueCommand {
+
List(IssueListArgs),
+
Create(IssueCreateArgs),
+
Show(IssueShowArgs),
+
Edit(IssueEditArgs),
+
Comment(IssueCommentArgs),
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct IssueListArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
#[arg(long)]
+
pub state: Option<String>,
+
#[arg(long)]
+
pub author: Option<String>,
+
#[arg(long)]
+
pub label: Option<String>,
+
#[arg(long)]
+
pub assigned: Option<String>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct IssueCreateArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
#[arg(long)]
+
pub title: Option<String>,
+
#[arg(long)]
+
pub body: Option<String>,
+
#[arg(long)]
+
pub label: Option<Vec<String>>,
+
#[arg(long, value_name = "HANDLE")]
+
pub assign: Option<Vec<String>>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct IssueShowArgs {
+
pub id: String,
+
#[arg(long, default_value_t = false)]
+
pub comments: bool,
+
#[arg(long, default_value_t = false)]
+
pub json: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct IssueEditArgs {
+
pub id: String,
+
#[arg(long)]
+
pub title: Option<String>,
+
#[arg(long)]
+
pub body: Option<String>,
+
#[arg(long)]
+
pub state: Option<String>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct IssueCommentArgs {
+
pub id: String,
+
#[arg(long)]
+
pub body: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub close: bool,
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum PrCommand {
+
List(PrListArgs),
+
Create(PrCreateArgs),
+
Show(PrShowArgs),
+
Review(PrReviewArgs),
+
Merge(PrMergeArgs),
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct PrListArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
#[arg(long)]
+
pub state: Option<String>,
+
#[arg(long)]
+
pub author: Option<String>,
+
#[arg(long)]
+
pub reviewer: Option<String>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct PrCreateArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
#[arg(long)]
+
pub base: Option<String>,
+
#[arg(long)]
+
pub head: Option<String>,
+
#[arg(long)]
+
pub title: Option<String>,
+
#[arg(long)]
+
pub body: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub draft: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct PrShowArgs {
+
pub id: String,
+
#[arg(long, default_value_t = false)]
+
pub diff: bool,
+
#[arg(long, default_value_t = false)]
+
pub comments: bool,
+
#[arg(long, default_value_t = false)]
+
pub checks: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct PrReviewArgs {
+
pub id: String,
+
#[arg(long, default_value_t = false)]
+
pub approve: bool,
+
#[arg(long, default_value_t = false)]
+
pub request_changes: bool,
+
#[arg(long)]
+
pub comment: Option<String>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct PrMergeArgs {
+
pub id: String,
+
#[arg(long, default_value_t = false)]
+
pub squash: bool,
+
#[arg(long, default_value_t = false)]
+
pub rebase: bool,
+
#[arg(long, default_value_t = false)]
+
pub no_ff: bool,
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum KnotCommand {
+
List(KnotListArgs),
+
Add(KnotAddArgs),
+
Verify(KnotVerifyArgs),
+
SetDefault(KnotRefArgs),
+
Remove(KnotRefArgs),
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct KnotListArgs {
+
#[arg(long, default_value_t = false)]
+
pub public: bool,
+
#[arg(long, default_value_t = false)]
+
pub owned: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct KnotAddArgs {
+
pub url: String,
+
#[arg(long)]
+
pub did: Option<String>,
+
#[arg(long)]
+
pub name: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub verify: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct KnotVerifyArgs {
+
pub url: String,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct KnotRefArgs {
+
pub url: String,
+
}
+
+
#[derive(Subcommand, Debug, Clone)]
+
pub enum SpindleCommand {
+
List(SpindleListArgs),
+
Config(SpindleConfigArgs),
+
Run(SpindleRunArgs),
+
Logs(SpindleLogsArgs),
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleListArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleConfigArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
#[arg(long)]
+
pub url: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub enable: bool,
+
#[arg(long, default_value_t = false)]
+
pub disable: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleRunArgs {
+
#[arg(long)]
+
pub repo: Option<String>,
+
#[arg(long)]
+
pub branch: Option<String>,
+
#[arg(long, default_value_t = false)]
+
pub wait: bool,
+
}
+
+
#[derive(Args, Debug, Clone)]
+
pub struct SpindleLogsArgs {
+
pub job_id: String,
+
#[arg(long, default_value_t = false)]
+
pub follow: bool,
+
#[arg(long)]
+
pub lines: Option<usize>,
+
}
+
+50
crates/tangled-cli/src/commands/auth.rs
···
+
use anyhow::Result;
+
use dialoguer::{Input, Password};
+
+
use crate::cli::{AuthCommand, AuthLoginArgs, Cli};
+
+
pub async fn run(cli: &Cli, cmd: AuthCommand) -> Result<()> {
+
match cmd {
+
AuthCommand::Login(args) => login(cli, args).await,
+
AuthCommand::Status => status(cli).await,
+
AuthCommand::Logout => logout(cli).await,
+
}
+
}
+
+
async fn login(_cli: &Cli, mut args: AuthLoginArgs) -> Result<()> {
+
let handle: String = match args.handle.take() {
+
Some(h) => h,
+
None => Input::new().with_prompt("Handle").interact_text()?,
+
};
+
let password: String = match args.password.take() {
+
Some(p) => p,
+
None => Password::new().with_prompt("Password").interact()?,
+
};
+
let pds = args.pds.unwrap_or_else(|| "https://bsky.social".to_string());
+
+
// Placeholder: integrate tangled_api authentication here
+
println!(
+
"Logging in as '{}' against PDS '{}'... (stub)",
+
handle, pds
+
);
+
+
// Example future flow:
+
// let client = tangled_api::TangledClient::new(&pds);
+
// let session = client.login(&handle, &password).await?;
+
// tangled_config::session::SessionManager::default().save(&session)?;
+
+
Ok(())
+
}
+
+
async fn status(_cli: &Cli) -> Result<()> {
+
// Placeholder: read session from keyring/config
+
println!("Authentication status: (stub) not implemented");
+
Ok(())
+
}
+
+
async fn logout(_cli: &Cli) -> Result<()> {
+
// Placeholder: remove session from keyring/config
+
println!("Logged out (stub)");
+
Ok(())
+
}
+
+41
crates/tangled-cli/src/commands/issue.rs
···
+
use anyhow::Result;
+
use crate::cli::{Cli, IssueCommand, IssueListArgs, IssueCreateArgs, IssueShowArgs, IssueEditArgs, IssueCommentArgs};
+
+
pub async fn run(_cli: &Cli, cmd: IssueCommand) -> Result<()> {
+
match cmd {
+
IssueCommand::List(args) => list(args).await,
+
IssueCommand::Create(args) => create(args).await,
+
IssueCommand::Show(args) => show(args).await,
+
IssueCommand::Edit(args) => edit(args).await,
+
IssueCommand::Comment(args) => comment(args).await,
+
}
+
}
+
+
async fn list(args: IssueListArgs) -> Result<()> {
+
println!("Issue list (stub) repo={:?} state={:?} author={:?} label={:?} assigned={:?}",
+
args.repo, args.state, args.author, args.label, args.assigned);
+
Ok(())
+
}
+
+
async fn create(args: IssueCreateArgs) -> Result<()> {
+
println!("Issue create (stub) repo={:?} title={:?} body={:?} labels={:?} assign={:?}",
+
args.repo, args.title, args.body, args.label, args.assign);
+
Ok(())
+
}
+
+
async fn show(args: IssueShowArgs) -> Result<()> {
+
println!("Issue show (stub) id={} comments={} json={}", args.id, args.comments, args.json);
+
Ok(())
+
}
+
+
async fn edit(args: IssueEditArgs) -> Result<()> {
+
println!("Issue edit (stub) id={} title={:?} body={:?} state={:?}",
+
args.id, args.title, args.body, args.state);
+
Ok(())
+
}
+
+
async fn comment(args: IssueCommentArgs) -> Result<()> {
+
println!("Issue comment (stub) id={} close={} body={:?}", args.id, args.close, args.body);
+
Ok(())
+
}
+
+38
crates/tangled-cli/src/commands/knot.rs
···
+
use anyhow::Result;
+
use crate::cli::{Cli, KnotCommand, KnotListArgs, KnotAddArgs, KnotVerifyArgs, KnotRefArgs};
+
+
pub async fn run(_cli: &Cli, cmd: KnotCommand) -> Result<()> {
+
match cmd {
+
KnotCommand::List(args) => list(args).await,
+
KnotCommand::Add(args) => add(args).await,
+
KnotCommand::Verify(args) => verify(args).await,
+
KnotCommand::SetDefault(args) => set_default(args).await,
+
KnotCommand::Remove(args) => remove(args).await,
+
}
+
}
+
+
async fn list(args: KnotListArgs) -> Result<()> {
+
println!("Knot list (stub) public={} owned={}", args.public, args.owned);
+
Ok(())
+
}
+
+
async fn add(args: KnotAddArgs) -> Result<()> {
+
println!("Knot add (stub) url={} did={:?} name={:?} verify={}", args.url, args.did, args.name, args.verify);
+
Ok(())
+
}
+
+
async fn verify(args: KnotVerifyArgs) -> Result<()> {
+
println!("Knot verify (stub) url={}", args.url);
+
Ok(())
+
}
+
+
async fn set_default(args: KnotRefArgs) -> Result<()> {
+
println!("Knot set-default (stub) url={}", args.url);
+
Ok(())
+
}
+
+
async fn remove(args: KnotRefArgs) -> Result<()> {
+
println!("Knot remove (stub) url={}", args.url);
+
Ok(())
+
}
+
+28
crates/tangled-cli/src/commands/mod.rs
···
+
pub mod auth;
+
pub mod repo;
+
pub mod issue;
+
pub mod pr;
+
pub mod knot;
+
pub mod spindle;
+
+
use anyhow::Result;
+
use colored::Colorize;
+
+
use crate::cli::{Cli, Command};
+
+
pub async fn dispatch(cli: Cli) -> Result<()> {
+
match cli.command {
+
Command::Auth(cmd) => auth::run(&cli, cmd).await,
+
Command::Repo(cmd) => repo::run(&cli, cmd).await,
+
Command::Issue(cmd) => issue::run(&cli, cmd).await,
+
Command::Pr(cmd) => pr::run(&cli, cmd).await,
+
Command::Knot(cmd) => knot::run(&cli, cmd).await,
+
Command::Spindle(cmd) => spindle::run(&cli, cmd).await,
+
}
+
}
+
+
fn not_implemented(feature: &str) -> Result<()> {
+
eprintln!("{} {}", "[todo]".yellow().bold(), feature);
+
Ok(())
+
}
+
+42
crates/tangled-cli/src/commands/pr.rs
···
+
use anyhow::Result;
+
use crate::cli::{Cli, PrCommand, PrCreateArgs, PrListArgs, PrShowArgs, PrReviewArgs, PrMergeArgs};
+
+
pub async fn run(_cli: &Cli, cmd: PrCommand) -> Result<()> {
+
match cmd {
+
PrCommand::List(args) => list(args).await,
+
PrCommand::Create(args) => create(args).await,
+
PrCommand::Show(args) => show(args).await,
+
PrCommand::Review(args) => review(args).await,
+
PrCommand::Merge(args) => merge(args).await,
+
}
+
}
+
+
async fn list(args: PrListArgs) -> Result<()> {
+
println!("PR list (stub) repo={:?} state={:?} author={:?} reviewer={:?}",
+
args.repo, args.state, args.author, args.reviewer);
+
Ok(())
+
}
+
+
async fn create(args: PrCreateArgs) -> Result<()> {
+
println!("PR create (stub) repo={:?} base={:?} head={:?} title={:?} draft={}",
+
args.repo, args.base, args.head, args.title, args.draft);
+
Ok(())
+
}
+
+
async fn show(args: PrShowArgs) -> Result<()> {
+
println!("PR show (stub) id={} diff={} comments={} checks={}", args.id, args.diff, args.comments, args.checks);
+
Ok(())
+
}
+
+
async fn review(args: PrReviewArgs) -> Result<()> {
+
println!("PR review (stub) id={} approve={} request_changes={} comment={:?}",
+
args.id, args.approve, args.request_changes, args.comment);
+
Ok(())
+
}
+
+
async fn merge(args: PrMergeArgs) -> Result<()> {
+
println!("PR merge (stub) id={} squash={} rebase={} no_ff={}",
+
args.id, args.squash, args.rebase, args.no_ff);
+
Ok(())
+
}
+
+57
crates/tangled-cli/src/commands/repo.rs
···
+
use anyhow::Result;
+
use crate::cli::{Cli, RepoCommand, RepoCreateArgs, RepoInfoArgs, RepoListArgs, RepoCloneArgs, RepoDeleteArgs, RepoRefArgs};
+
+
pub async fn run(_cli: &Cli, cmd: RepoCommand) -> Result<()> {
+
match cmd {
+
RepoCommand::List(args) => list(args).await,
+
RepoCommand::Create(args) => create(args).await,
+
RepoCommand::Clone(args) => clone(args).await,
+
RepoCommand::Info(args) => info(args).await,
+
RepoCommand::Delete(args) => delete(args).await,
+
RepoCommand::Star(args) => star(args).await,
+
RepoCommand::Unstar(args) => unstar(args).await,
+
}
+
}
+
+
async fn list(args: RepoListArgs) -> Result<()> {
+
println!("Listing repositories (stub) knot={:?} user={:?} starred={}",
+
args.knot, args.user, args.starred);
+
Ok(())
+
}
+
+
async fn create(args: RepoCreateArgs) -> Result<()> {
+
println!(
+
"Creating repo '{}' (stub) knot={:?} private={} init={} desc={:?}",
+
args.name, args.knot, args.private, args.init, args.description
+
);
+
Ok(())
+
}
+
+
async fn clone(args: RepoCloneArgs) -> Result<()> {
+
println!("Cloning repo '{}' (stub) https={} depth={:?}", args.repo, args.https, args.depth);
+
Ok(())
+
}
+
+
async fn info(args: RepoInfoArgs) -> Result<()> {
+
println!(
+
"Repository info '{}' (stub) stats={} contributors={}",
+
args.repo, args.stats, args.contributors
+
);
+
Ok(())
+
}
+
+
async fn delete(args: RepoDeleteArgs) -> Result<()> {
+
println!("Deleting repo '{}' (stub) force={}", args.repo, args.force);
+
Ok(())
+
}
+
+
async fn star(args: RepoRefArgs) -> Result<()> {
+
println!("Starring repo '{}' (stub)", args.repo);
+
Ok(())
+
}
+
+
async fn unstar(args: RepoRefArgs) -> Result<()> {
+
println!("Unstarring repo '{}' (stub)", args.repo);
+
Ok(())
+
}
+
+35
crates/tangled-cli/src/commands/spindle.rs
···
+
use anyhow::Result;
+
use crate::cli::{Cli, SpindleCommand, SpindleListArgs, SpindleConfigArgs, SpindleRunArgs, SpindleLogsArgs};
+
+
pub async fn run(_cli: &Cli, cmd: SpindleCommand) -> Result<()> {
+
match cmd {
+
SpindleCommand::List(args) => list(args).await,
+
SpindleCommand::Config(args) => config(args).await,
+
SpindleCommand::Run(args) => run_pipeline(args).await,
+
SpindleCommand::Logs(args) => logs(args).await,
+
}
+
}
+
+
async fn list(args: SpindleListArgs) -> Result<()> {
+
println!("Spindle list (stub) repo={:?}", args.repo);
+
Ok(())
+
}
+
+
async fn config(args: SpindleConfigArgs) -> Result<()> {
+
println!(
+
"Spindle config (stub) repo={:?} url={:?} enable={} disable={}",
+
args.repo, args.url, args.enable, args.disable
+
);
+
Ok(())
+
}
+
+
async fn run_pipeline(args: SpindleRunArgs) -> Result<()> {
+
println!("Spindle run (stub) repo={:?} branch={:?} wait={}", args.repo, args.branch, args.wait);
+
Ok(())
+
}
+
+
async fn logs(args: SpindleLogsArgs) -> Result<()> {
+
println!("Spindle logs (stub) job_id={} follow={} lines={:?}", args.job_id, args.follow, args.lines);
+
Ok(())
+
}
+
+12
crates/tangled-cli/src/main.rs
···
+
mod cli;
+
mod commands;
+
+
use anyhow::Result;
+
use cli::Cli;
+
use clap::Parser;
+
+
#[tokio::main]
+
async fn main() -> Result<()> {
+
let cli = Cli::parse();
+
commands::dispatch(cli).await
+
}
+16
crates/tangled-config/Cargo.toml
···
+
[package]
+
name = "tangled-config"
+
version = "0.1.0"
+
edition = "2021"
+
description = "Configuration and session management for Tangled CLI"
+
license = "MIT OR Apache-2.0"
+
+
[dependencies]
+
anyhow = { workspace = true }
+
dirs = { workspace = true }
+
keyring = { workspace = true }
+
serde = { workspace = true, features = ["derive"] }
+
serde_json = { workspace = true }
+
toml = { workspace = true }
+
chrono = { workspace = true }
+
+83
crates/tangled-config/src/config.rs
···
+
use std::fs;
+
use std::path::{Path, PathBuf};
+
+
use anyhow::{Context, Result};
+
use dirs::config_dir;
+
use serde::{Deserialize, Serialize};
+
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+
pub struct RootConfig {
+
#[serde(default)]
+
pub default: DefaultSection,
+
#[serde(default)]
+
pub auth: AuthSection,
+
#[serde(default)]
+
pub knots: KnotsSection,
+
#[serde(default)]
+
pub ui: UiSection,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+
pub struct DefaultSection {
+
pub knot: Option<String>,
+
pub editor: Option<String>,
+
pub pager: Option<String>,
+
#[serde(default = "default_format")]
+
pub format: String,
+
}
+
+
fn default_format() -> String { "table".to_string() }
+
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+
pub struct AuthSection {
+
pub handle: Option<String>,
+
pub did: Option<String>,
+
pub pds_url: Option<String>,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+
pub struct KnotsSection {
+
pub default: Option<String>,
+
#[serde(default)]
+
pub custom: serde_json::Value,
+
}
+
+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+
pub struct UiSection {
+
#[serde(default)]
+
pub color: bool,
+
#[serde(default)]
+
pub progress_bar: bool,
+
#[serde(default)]
+
pub confirm_destructive: bool,
+
}
+
+
pub fn default_config_path() -> Result<PathBuf> {
+
let base = config_dir().context("Could not determine platform config directory")?;
+
Ok(base.join("tangled").join("config.toml"))
+
}
+
+
pub fn load_config(path: Option<&Path>) -> Result<Option<RootConfig>> {
+
let path = path
+
.map(|p| p.to_path_buf())
+
.unwrap_or(default_config_path()?);
+
if !path.exists() {
+
return Ok(None);
+
}
+
let content = fs::read_to_string(&path)
+
.with_context(|| format!("Failed reading config file: {}", path.display()))?;
+
let cfg: RootConfig = toml::from_str(&content).context("Invalid TOML in config")?;
+
Ok(Some(cfg))
+
}
+
+
pub fn save_config(cfg: &RootConfig, path: Option<&Path>) -> Result<()> {
+
let path = path
+
.map(|p| p.to_path_buf())
+
.unwrap_or(default_config_path()?);
+
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
+
let toml = toml::to_string_pretty(cfg)?;
+
fs::write(&path, toml)
+
.with_context(|| format!("Failed writing config file: {}", path.display()))?;
+
Ok(())
+
}
+
+30
crates/tangled-config/src/keychain.rs
···
+
use anyhow::{anyhow, Result};
+
use keyring::Entry;
+
+
pub struct Keychain {
+
service: String,
+
account: String,
+
}
+
+
impl Keychain {
+
pub fn new(service: &str, account: &str) -> Self {
+
Self { service: service.into(), account: account.into() }
+
}
+
+
fn entry(&self) -> Result<Entry> {
+
Entry::new(&self.service, &self.account).map_err(|e| anyhow!("keyring error: {e}"))
+
}
+
+
pub fn set_password(&self, secret: &str) -> Result<()> {
+
self.entry()?.set_password(secret).map_err(|e| anyhow!("keyring error: {e}"))
+
}
+
+
pub fn get_password(&self) -> Result<String> {
+
self.entry()?.get_password().map_err(|e| anyhow!("keyring error: {e}"))
+
}
+
+
pub fn delete_password(&self) -> Result<()> {
+
self.entry()?.delete_password().map_err(|e| anyhow!("keyring error: {e}"))
+
}
+
}
+
+4
crates/tangled-config/src/lib.rs
···
+
pub mod config;
+
pub mod session;
+
pub mod keychain;
+
+62
crates/tangled-config/src/session.rs
···
+
use anyhow::Result;
+
use chrono::{DateTime, Utc};
+
use serde::{Deserialize, Serialize};
+
+
use crate::keychain::Keychain;
+
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct Session {
+
pub access_jwt: String,
+
pub refresh_jwt: String,
+
pub did: String,
+
pub handle: String,
+
#[serde(default)]
+
pub created_at: DateTime<Utc>,
+
}
+
+
impl Default for Session {
+
fn default() -> Self {
+
Self {
+
access_jwt: String::new(),
+
refresh_jwt: String::new(),
+
did: String::new(),
+
handle: String::new(),
+
created_at: Utc::now(),
+
}
+
}
+
}
+
+
pub struct SessionManager {
+
service: String,
+
account: String,
+
}
+
+
impl Default for SessionManager {
+
fn default() -> Self {
+
Self { service: "tangled-cli".into(), account: "default".into() }
+
}
+
}
+
+
impl SessionManager {
+
pub fn new(service: &str, account: &str) -> Self { Self { service: service.into(), account: account.into() } }
+
+
pub fn save(&self, session: &Session) -> Result<()> {
+
let keychain = Keychain::new(&self.service, &self.account);
+
let json = serde_json::to_string(session)?;
+
keychain.set_password(&json)
+
}
+
+
pub fn load(&self) -> Result<Option<Session>> {
+
let keychain = Keychain::new(&self.service, &self.account);
+
match keychain.get_password() {
+
Ok(json) => Ok(Some(serde_json::from_str(&json)?)),
+
Err(_) => Ok(None),
+
}
+
}
+
+
pub fn clear(&self) -> Result<()> {
+
let keychain = Keychain::new(&self.service, &self.account);
+
keychain.delete_password()
+
}
+
}
+
+11
crates/tangled-git/Cargo.toml
···
+
[package]
+
name = "tangled-git"
+
version = "0.1.0"
+
edition = "2021"
+
description = "Git integration helpers for Tangled CLI"
+
license = "MIT OR Apache-2.0"
+
+
[dependencies]
+
anyhow = { workspace = true }
+
git2 = { workspace = true }
+
+2
crates/tangled-git/src/lib.rs
···
+
pub mod operations;
+
+8
crates/tangled-git/src/operations.rs
···
+
use anyhow::{bail, Result};
+
use git2::Repository;
+
+
pub fn clone_repo(_url: &str, _path: &std::path::Path) -> Result<Repository> {
+
// TODO: support ssh/https and depth
+
bail!("clone_repo not implemented")
+
}
+
+18
docs/getting-started.md
···
+
# Getting Started
+
+
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.
+
+
## Build
+
+
Requires Rust toolchain and network access to fetch dependencies.
+
+
```
+
cargo build
+
```
+
+
## Run
+
+
```
+
cargo run -p tangled-cli -- --help
+
```
+
+8
lexicons/sh.tangled/issue.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.issue.placeholder",
+
"defs": {
+
"main": { "type": "record", "record": { "type": "object", "required": ["title"], "properties": { "title": {"type": "string"}, "body": {"type": "string"} } } }
+
}
+
}
+
+6
lexicons/sh.tangled/knot.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.knot.placeholder",
+
"defs": {"main": {"type": "query"}}
+
}
+
+37
lexicons/sh.tangled/repo.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.repo.placeholder",
+
"defs": {
+
"main": {
+
"type": "query",
+
"parameters": {
+
"type": "params",
+
"properties": {
+
"user": {"type": "string"},
+
"knot": {"type": "string"},
+
"starred": {"type": "boolean"}
+
}
+
},
+
"output": {
+
"schema": {
+
"type": "object",
+
"properties": {
+
"repos": {
+
"type": "array",
+
"items": {
+
"type": "object",
+
"required": ["name"],
+
"properties": {
+
"name": {"type": "string"},
+
"knot": {"type": "string"},
+
"private": {"type": "boolean"}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+