Commit

Author:

Hash:

Timestamp:

+348 -192 +/-19 browse

Kevin Schoon [me@kevinschoon.com]

72ba4353d1f6c846355f0a2883aedc587d80f102

Sat, 23 Aug 2025 16:00:46 +0000 (2 months ago)

add man page generation support with xtask pattern
add man page generation support with xtask pattern

This implements the xtask pattern [1] and moves all of the command line
definitions into a single crates/cmd package. Each command is then imported
into their respective binaries. Having a shared command package allows the
generation of man pages in a single task.

1. https://github.com/matklad/cargo-xtask
1diff --git a/Cargo.lock b/Cargo.lock
2index 912aec5..9c43931 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -322,12 +322,12 @@ dependencies = [
6 "axum",
7 "axum-extra",
8 "ayllu_api",
9+ "ayllu_cmd",
10 "ayllu_config",
11 "ayllu_git",
12 "ayllu_identity",
13 "bytes",
14 "cc",
15- "clap 4.5.41",
16 "comrak",
17 "file-mode",
18 "futures",
19 @@ -372,9 +372,9 @@ dependencies = [
20 name = "ayllu-keys"
21 version = "0.5.1"
22 dependencies = [
23+ "ayllu_cmd",
24 "ayllu_config",
25 "ayllu_identity",
26- "clap 4.5.41",
27 "serde",
28 ]
29
30 @@ -382,10 +382,10 @@ dependencies = [
31 name = "ayllu-shell"
32 version = "0.5.1"
33 dependencies = [
34+ "ayllu_cmd",
35 "ayllu_config",
36 "ayllu_git",
37 "ayllu_identity",
38- "clap 4.5.41",
39 "dialoguer",
40 "nix",
41 "serde",
42 @@ -402,6 +402,16 @@ dependencies = [
43 ]
44
45 [[package]]
46+ name = "ayllu_cmd"
47+ version = "0.1.0"
48+ dependencies = [
49+ "ayllu_config",
50+ "clap 4.5.41",
51+ "tracing",
52+ "url",
53+ ]
54+
55+ [[package]]
56 name = "ayllu_config"
57 version = "0.2.1"
58 dependencies = [
59 @@ -660,6 +670,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
60 checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
61
62 [[package]]
63+ name = "clap_mangen"
64+ version = "0.2.29"
65+ source = "registry+https://github.com/rust-lang/crates.io-index"
66+ checksum = "27b4c3c54b30f0d9adcb47f25f61fcce35c4dd8916638c6b82fbd5f4fb4179e2"
67+ dependencies = [
68+ "clap 4.5.41",
69+ "roff",
70+ ]
71+
72+ [[package]]
73 name = "colorchoice"
74 version = "1.0.4"
75 source = "registry+https://github.com/rust-lang/crates.io-index"
76 @@ -2342,6 +2362,7 @@ name = "quipu"
77 version = "0.5.1"
78 dependencies = [
79 "ayllu_api",
80+ "ayllu_cmd",
81 "ayllu_config",
82 "ayllu_git",
83 "clap 4.5.41",
84 @@ -2598,6 +2619,12 @@ dependencies = [
85 ]
86
87 [[package]]
88+ name = "roff"
89+ version = "0.2.2"
90+ source = "registry+https://github.com/rust-lang/crates.io-index"
91+ checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3"
92+
93+ [[package]]
94 name = "rss"
95 version = "2.0.12"
96 source = "registry+https://github.com/rust-lang/crates.io-index"
97 @@ -4138,6 +4165,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
98 checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
99
100 [[package]]
101+ name = "xtask"
102+ version = "0.1.0"
103+ dependencies = [
104+ "ayllu_cmd",
105+ "clap 4.5.41",
106+ "clap_mangen",
107+ ]
108+
109+ [[package]]
110 name = "yoke"
111 version = "0.8.0"
112 source = "registry+https://github.com/rust-lang/crates.io-index"
113 diff --git a/Cargo.toml b/Cargo.toml
114index 717542d..8f2214d 100644
115--- a/Cargo.toml
116+++ b/Cargo.toml
117 @@ -16,7 +16,7 @@ members = [
118 "ayllu-keys",
119 # "ayllu-jobs",
120 # "ayllu-xmpp",
121- "quipu", "crates/identity",
122+ "quipu", "crates/identity", "crates/cmd", "xtask",
123 ]
124
125 [workspace.dependencies]
126 @@ -31,8 +31,6 @@ thiserror = "2.0.12"
127 tracing = { version = "0.1.41", features=["log"] }
128 toml = "0.8.23"
129 futures = "0.3.31"
130- futures-util = "0.3.31"
131- sqlx = { version = "0.8.6", features = [ "runtime-tokio-rustls", "sqlite", "macros", "time" ] }
132 tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
133 openssh-keys = "0.6.4"
134 url = { version = "2.5.4", features = ["serde"]}
135 diff --git a/ayllu-keys/Cargo.toml b/ayllu-keys/Cargo.toml
136index 3d7e822..fca312b 100644
137--- a/ayllu-keys/Cargo.toml
138+++ b/ayllu-keys/Cargo.toml
139 @@ -5,8 +5,8 @@ edition = "2021"
140
141 [dependencies]
142
143+ ayllu_cmd = { path = "../crates/cmd"}
144 ayllu_config = { path = "../crates/config" }
145 ayllu_identity = { path = "../crates/identity" }
146
147- clap = { workspace = true }
148 serde = { workspace = true }
149 diff --git a/ayllu-keys/src/main.rs b/ayllu-keys/src/main.rs
150index 281cbfb..c0afb0a 100644
151--- a/ayllu-keys/src/main.rs
152+++ b/ayllu-keys/src/main.rs
153 @@ -1,18 +1,11 @@
154- /// ayllu-keys handles authentication for remote users in a shared environment.
155- /// There are a few scenarios which it needs to account for:
156- ///
157- /// * A system user that is not defined in the global ayllu-config is
158- /// requesting shell access via a regular ssh channel.
159- /// * An Ayllu managed user is requesting shell access.
160- /// * An Ayllu managed user is running git operations as the git service.
161 use std::{
162 path::{Path, PathBuf},
163 process::ExitCode,
164 };
165
166+ use ayllu_cmd::keys::Command;
167 use ayllu_config::Configurable;
168 use ayllu_identity::Identity;
169- use clap::Parser;
170
171 use serde::{Deserialize, Serialize};
172
173 @@ -26,19 +19,6 @@ pub struct Config {
174
175 impl Configurable for Config {}
176
177- #[derive(Parser, Debug)]
178- #[clap(version, about, long_about = "Ayllu Keys")]
179- struct Arguments {
180- username: String,
181- home_dir: PathBuf,
182- ca_key_type: String,
183- certificate: String,
184- #[clap(short, long, value_name = "FILE")]
185- config: Option<PathBuf>,
186- #[clap(long, value_name = "ayllu-shell")]
187- ayllu_shell: Option<PathBuf>,
188- }
189-
190 fn authorized_keys(home_dir: &Path) -> ExitCode {
191 let authorized_keys_path = home_dir.join(".ssh/authorized_keys");
192 if let Ok(mut fp) = std::fs::File::open(authorized_keys_path) {
193 @@ -56,14 +36,13 @@ fn authorized_keys(home_dir: &Path) -> ExitCode {
194 }
195
196 fn identify<'a>(config: &'a Config, ca_key_type: &str, certificate: &str) -> Option<&'a Identity> {
197- let user_key =
198- match ayllu_identity::PublicKey::parse(&format!("{ca_key_type} {certificate}")) {
199- Ok(key) => key,
200- Err(e) => {
201- eprintln!("Cannot parse SSH key from sshd: {e:?}");
202- return None;
203- }
204- };
205+ let user_key = match ayllu_identity::PublicKey::parse(&format!("{ca_key_type} {certificate}")) {
206+ Ok(key) => key,
207+ Err(e) => {
208+ eprintln!("Cannot parse SSH key from sshd: {e:?}");
209+ return None;
210+ }
211+ };
212
213 match config.identities.iter().find(|identity| {
214 identity
215 @@ -80,7 +59,7 @@ fn identify<'a>(config: &'a Config, ca_key_type: &str, certificate: &str) -> Opt
216 }
217
218 fn main() -> ExitCode {
219- let args = Arguments::parse();
220+ let args = ayllu_cmd::parse::<Command>();
221
222 if args.username != "ayllu" {
223 return authorized_keys(&args.home_dir);
224 diff --git a/ayllu-shell/Cargo.toml b/ayllu-shell/Cargo.toml
225index 5c8965f..97cf2cd 100644
226--- a/ayllu-shell/Cargo.toml
227+++ b/ayllu-shell/Cargo.toml
228 @@ -5,11 +5,11 @@ edition = "2021"
229
230 [dependencies]
231
232+ ayllu_cmd = { path = "../crates/cmd" }
233 ayllu_config = { path = "../crates/config" }
234 ayllu_git = { path = "../crates/git"}
235 ayllu_identity = { path = "../crates/identity" }
236
237- clap = { workspace = true }
238 dialoguer = { version = "0.11.0", default-features = false }
239 nix = { version = "0.30.1", default-features = false, features = ["user"] }
240 serde = { workspace = true }
241 diff --git a/ayllu-shell/src/main.rs b/ayllu-shell/src/main.rs
242index 7a23919..11ad2e7 100644
243--- a/ayllu-shell/src/main.rs
244+++ b/ayllu-shell/src/main.rs
245 @@ -1,54 +1,11 @@
246- use std::path::PathBuf;
247-
248+ use ayllu_cmd::shell::{Command, Subcommand};
249 use ayllu_git::Wrapper;
250- use clap::{Parser, Subcommand};
251
252 mod config;
253 mod error;
254 mod ui;
255
256- #[derive(Subcommand, Debug)]
257- enum Commands {
258- /// Invokes git-receive-pack
259- GitReceivePack {
260- /// Path to the git repository
261- path: PathBuf,
262- },
263- /// Invokes git-upload-pack
264- GitUploadPack {
265- /// Path to the git repository
266- path: PathBuf,
267- },
268- }
269-
270- #[derive(Parser, Debug)]
271- #[clap(
272- version,
273- about,
274- long_about = r#"
275-
276- Ayllu Shell
277-
278- ayllu-shell provides a restricted shell for performing operations against
279- an Ayllu server. Although it may be invoked directly, typically it is
280- expected to be paired with ayllu-keys and an OpenSSH server.
281- "#
282- )]
283- struct Arguments {
284- /// sh compatibility
285- #[clap(short, action)]
286- c: Option<String>,
287- /// Path to a configuration file
288- #[clap(long)]
289- config: Option<PathBuf>,
290- /// Specify the user identity to assume
291- #[clap(short, long)]
292- username: Option<String>,
293- #[command(subcommand)]
294- command: Option<Commands>,
295- }
296-
297- fn execute(args: Arguments) -> Result<(), Box<dyn std::error::Error>> {
298+ fn execute(args: Command) -> Result<(), Box<dyn std::error::Error>> {
299 let config: config::Config = ayllu_config::Reader::load(args.config.as_deref())?;
300 let username = args
301 .username
302 @@ -58,7 +15,7 @@ fn execute(args: Arguments) -> Result<(), Box<dyn std::error::Error>> {
303 .iter()
304 .find(|identity| identity.username == username);
305 match args.command {
306- Some(Commands::GitReceivePack { path }) => {
307+ Some(Subcommand::GitReceivePack { path }) => {
308 let resolved = if let Some(base_dir) = config.base_dir {
309 let trimmed = if path.is_absolute() {
310 path.strip_prefix("/").unwrap()
311 @@ -88,7 +45,7 @@ fn execute(args: Arguments) -> Result<(), Box<dyn std::error::Error>> {
312 }
313 }
314 }
315- Some(Commands::GitUploadPack { ref path }) => {
316+ Some(Subcommand::GitUploadPack { ref path }) => {
317 let resolved = if path.is_relative() {
318 if let Some(base_dir) = config.base_dir {
319 &base_dir.join(path)
320 @@ -129,17 +86,16 @@ fn execute(args: Arguments) -> Result<(), Box<dyn std::error::Error>> {
321 // TODO: Add RBAC style permissions per identity
322 fn main() -> Result<(), Box<dyn std::error::Error>> {
323 eprintln!("Args: {:?}", std::env::args());
324- let args = Arguments::parse();
325+ let args = ayllu_cmd::parse::<Command>();
326 if let Some(cmd) = args.c {
327 let unescaped = cmd.replace("\'", "").replace("\"", "");
328- let parts = unescaped.split(" ").fold(
329- vec!["ayllu-shell".to_string()],
330- |mut accm, part| {
331+ let parts = unescaped
332+ .split(" ")
333+ .fold(vec!["ayllu-shell".to_string()], |mut accm, part| {
334 accm.push(part.to_string());
335 accm
336- },
337- );
338- return execute(Arguments::parse_from(parts));
339+ });
340+ return execute(ayllu_cmd::parse_from(parts.as_slice()));
341 };
342 execute(args)
343 }
344 diff --git a/ayllu/Cargo.toml b/ayllu/Cargo.toml
345index 2cee0a7..770dcf8 100644
346--- a/ayllu/Cargo.toml
347+++ b/ayllu/Cargo.toml
348 @@ -9,6 +9,7 @@ name = "ayllu"
349
350 [dependencies]
351 ayllu_api = { path = "../crates/api" }
352+ ayllu_cmd = { path = "../crates/cmd" }
353 ayllu_git = { path = "../crates/git" }
354 ayllu_identity = {path = "../crates/identity"}
355 ayllu_config = { path = "../crates/config" }
356 @@ -16,7 +17,6 @@ timeutil = {path = "../crates/timeutil"}
357
358 async-trait = { workspace = true }
359 bytes = { workspace = true }
360- clap = { workspace = true }
361 futures = { workspace = true }
362 thiserror = { workspace = true }
363 tracing = { workspace = true }
364 diff --git a/ayllu/src/main.rs b/ayllu/src/main.rs
365index 61aa9bd..7c3d758 100644
366--- a/ayllu/src/main.rs
367+++ b/ayllu/src/main.rs
368 @@ -1,11 +1,10 @@
369 use std::error::Error;
370- use std::path::PathBuf;
371 use std::str::FromStr;
372
373- use clap::{Args, Parser, Subcommand};
374 use config::Collection;
375 use tracing::Level;
376
377+ use ayllu_cmd::ayllu::{Command, Subcommand};
378 use ayllu_config::Reader;
379
380 mod web2;
381 @@ -16,42 +15,6 @@ mod languages;
382 mod license;
383 mod readme;
384
385- #[derive(Parser, Debug)]
386- #[clap(version, about, long_about = "hyper performant code forge")]
387- struct Arguments {
388- #[clap(short, long, value_name = "FILE")]
389- config: Option<PathBuf>,
390- // FIXME: Parse to Level enum directly
391- /// logging level [ERROR,WARN,INFO,DEBUG,TRACE]
392- #[clap(short, long)]
393- level: Option<String>,
394- #[clap(subcommand)]
395- subcommand: Command,
396- }
397-
398- #[derive(Args, Debug)]
399- struct JobArguments {
400- #[clap(short, long)]
401- /// Maximum amount of commits to traverse
402- depth: Option<i64>,
403- /// Path to a git repository
404- path: Option<PathBuf>,
405- /// Job kind specifier.
406- #[clap(short, long)]
407- kind: Option<String>,
408- }
409-
410- #[derive(Subcommand, Debug)]
411- enum Command {
412- /// Configuration options.
413- #[clap(subcommand)]
414- Config(ayllu_config::Command),
415- /// Launch the main web server. If no configuration file is loaded or the configuration does
416- /// not contain any collections Ayllu will attempt to serve a repository at the current working
417- /// path. If none are found Ayllu will serve an empty Git interface.
418- Serve {},
419- }
420-
421 fn init_logger(level: Level) {
422 tracing_subscriber::fmt()
423 .compact()
424 @@ -64,14 +27,14 @@ fn init_logger(level: Level) {
425
426 #[tokio::main]
427 async fn main() -> Result<(), Box<dyn Error>> {
428- let args: Arguments = Arguments::parse();
429- match args.subcommand {
430- Command::Config(subcommand) => {
431- subcommand.execute::<config::Config>(config::EXAMPLE_CONFIG, args.config)?;
432+ let cli = ayllu_cmd::parse::<Command>();
433+ match cli.subcommand {
434+ Subcommand::Config(subcommand) => {
435+ subcommand.execute::<config::Config>(config::EXAMPLE_CONFIG, cli.config)?;
436 Ok(())
437 }
438- Command::Serve {} => {
439- let mut cfg: config::Config = Reader::load(args.config.as_deref())?;
440+ Subcommand::Serve {} => {
441+ let mut cfg: config::Config = Reader::load(cli.config.as_deref())?;
442 if cfg.collections.is_empty() {
443 if let Ok(path) = std::env::current_dir() {
444 if ayllu_git::git_dir(path.as_path()).is_ok_and(|is_git_dir| is_git_dir) {
445 @@ -85,7 +48,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
446 }
447 }
448 init_logger(
449- args.level
450+ cli.level
451 .as_ref()
452 .map(|level| Level::from_str(level))
453 .unwrap_or(Level::from_str(&cfg.log_level))?,
454 diff --git a/crates/cmd/Cargo.toml b/crates/cmd/Cargo.toml
455new file mode 100644
456index 0000000..6a94e68
457--- /dev/null
458+++ b/crates/cmd/Cargo.toml
459 @@ -0,0 +1,11 @@
460+ [package]
461+ name = "ayllu_cmd"
462+ version = "0.1.0"
463+ edition = "2024"
464+
465+ [dependencies]
466+ ayllu_config = {path = "../config"}
467+
468+ tracing = {workspace = true}
469+ url = {workspace = true}
470+ clap = {workspace = true}
471 diff --git a/crates/cmd/src/ayllu.rs b/crates/cmd/src/ayllu.rs
472new file mode 100644
473index 0000000..4ce3eb0
474--- /dev/null
475+++ b/crates/cmd/src/ayllu.rs
476 @@ -0,0 +1,42 @@
477+ use std::path::PathBuf;
478+
479+ use clap::Parser;
480+
481+ const LONG_ABOUT_DESCRIPTION: &str = r#"
482+
483+ ayllu is the main binary interface for running the Ayllu web server.
484+
485+ When *ayllu serve* is run and no global configuration file is loaded or no
486+ collections are configured Ayllu will attempt to serve a repository at the
487+ current working path. If no repositories are found an empty git interface will
488+ be served.
489+ "#;
490+
491+ /// Hyper Performant & Hackable Code Forge
492+ #[derive(Parser, Debug)]
493+ #[clap(
494+ version,
495+ about,
496+ name = "ayllu",
497+ long_about = LONG_ABOUT_DESCRIPTION,
498+
499+ )]
500+ pub struct Command {
501+ #[clap(short, long, value_name = "FILE")]
502+ pub config: Option<PathBuf>,
503+ // FIXME: Parse to Level enum directly
504+ /// logging level [ERROR,WARN,INFO,DEBUG,TRACE]
505+ #[clap(short, long)]
506+ pub level: Option<String>,
507+ #[clap(subcommand)]
508+ pub subcommand: Subcommand,
509+ }
510+
511+ #[derive(clap::Subcommand, Debug)]
512+ pub enum Subcommand {
513+ /// Configuration options.
514+ #[clap(subcommand)]
515+ Config(ayllu_config::Command),
516+ /// Launch the web interface
517+ Serve {},
518+ }
519 diff --git a/crates/cmd/src/keys.rs b/crates/cmd/src/keys.rs
520new file mode 100644
521index 0000000..95b5ca2
522--- /dev/null
523+++ b/crates/cmd/src/keys.rs
524 @@ -0,0 +1,40 @@
525+ use std::path::PathBuf;
526+
527+ use clap::Parser;
528+
529+ const LONG_ABOUT_DESCRIPTION: &str = r#"
530+
531+ ayllu-keys implements a shim for authorizing users via OpenSSH's AuthorizedKeysCommand
532+ option. When using this in a shared environment external users can be authorized by
533+ their public keys defined in the global Ayllu configuration file. This command is
534+ expected to be paired with the ayllu-shell.
535+
536+ This command handles several authentication scenarios:
537+
538+ * A system user that is not defined in the global ayllu-config is
539+ requesting shell access via a regular ssh channel.
540+
541+ * An Ayllu managed user is requesting shell access.
542+
543+ * An Ayllu managed user is running git operations as the git service.
544+ "#;
545+
546+ /// Ayllu shim for OpenSSH's AuthorizedKeysCommand
547+ #[derive(Parser, Debug)]
548+ #[clap(version, about, name = "ayllu-keys", long_about = LONG_ABOUT_DESCRIPTION)]
549+ pub struct Command {
550+ /// The username the caller is attempting to impersonate
551+ pub username: String,
552+ /// The home directory of the caller specified user
553+ pub home_dir: PathBuf,
554+ /// Type of SSH key
555+ pub ca_key_type: String,
556+ /// Public key body content.
557+ pub certificate: String,
558+ /// Path to the Ayllu configuration file
559+ #[clap(short, long, value_name = "FILE")]
560+ pub config: Option<PathBuf>,
561+ /// Path to the ayllu-shell binary
562+ #[clap(long, value_name = "ayllu-shell")]
563+ pub ayllu_shell: Option<PathBuf>,
564+ }
565 diff --git a/crates/cmd/src/lib.rs b/crates/cmd/src/lib.rs
566new file mode 100644
567index 0000000..473f986
568--- /dev/null
569+++ b/crates/cmd/src/lib.rs
570 @@ -0,0 +1,18 @@
571+ pub mod ayllu;
572+ pub mod keys;
573+ pub mod quipu;
574+ pub mod shell;
575+
576+ pub fn parse<T>() -> T
577+ where
578+ T: clap::Parser,
579+ {
580+ T::parse()
581+ }
582+
583+ pub fn parse_from<T>(parts: &[String]) -> T
584+ where
585+ T: clap::Parser,
586+ {
587+ T::parse_from(parts)
588+ }
589 diff --git a/crates/cmd/src/quipu.rs b/crates/cmd/src/quipu.rs
590new file mode 100644
591index 0000000..891b352
592--- /dev/null
593+++ b/crates/cmd/src/quipu.rs
594 @@ -0,0 +1,47 @@
595+ use std::path::PathBuf;
596+
597+ use clap::Parser;
598+ // use clap_complete::Shell;
599+ use tracing::Level;
600+ use url::Url;
601+
602+ const LONG_ABOUT_DESCRIPTION: &str = r#"
603+
604+ quipu is a client interface for inspecting and interacting with remote Ayllu instances.
605+ "#;
606+
607+ /// Quipu - Ayllu Client
608+ #[derive(Parser)]
609+ #[command(author, version, about, name = "quipu", long_about = LONG_ABOUT_DESCRIPTION)]
610+ pub struct Command {
611+ /// Path to your configuration file
612+ #[arg(short, long, value_name = "FILE")]
613+ pub config: Option<PathBuf>,
614+
615+ /// Sets the logging level
616+ #[arg(short, long, value_name = "LEVEL")]
617+ pub level: Option<Level>,
618+
619+ #[arg(short, long)]
620+ /// instance name from your quipu configuration
621+ pub instance: Option<String>,
622+ #[arg(short, long)]
623+ /// alternative url to contact
624+ pub url: Option<Url>,
625+
626+ #[command(subcommand)]
627+ pub command: Subcommand,
628+ }
629+
630+ #[derive(clap::Subcommand, Debug, PartialEq)]
631+ pub enum Subcommand {
632+ /// Generate autocomplete commands for common shells
633+ // Complete {
634+ // #[arg(long)]
635+ // shell: Shell,
636+ // },
637+ /// Verify the remote server is functional
638+ Ping,
639+ /// Perform a webfinger query against the Ayllu instance
640+ Finger { resource: String },
641+ }
642 diff --git a/crates/cmd/src/shell.rs b/crates/cmd/src/shell.rs
643new file mode 100644
644index 0000000..1006100
645--- /dev/null
646+++ b/crates/cmd/src/shell.rs
647 @@ -0,0 +1,46 @@
648+ use std::path::PathBuf;
649+
650+ use clap::Parser;
651+
652+ const LONG_ABOUT_DESCRIPTION: &str = r#"
653+
654+ ayllu-shell provides a restricted shell for performing operations against
655+ an Ayllu server. Although it may be invoked directly, typically it is
656+ expected to be paired with ayllu-keys and an OpenSSH server.
657+ "#;
658+
659+ #[derive(clap::Subcommand, Debug)]
660+ pub enum Subcommand {
661+ /// Invokes git-receive-pack
662+ GitReceivePack {
663+ /// Path to the git repository
664+ path: PathBuf,
665+ },
666+ /// Invokes git-upload-pack
667+ GitUploadPack {
668+ /// Path to the git repository
669+ path: PathBuf,
670+ },
671+ }
672+
673+ /// Interactive & restricted shell for remote Ayllu users
674+ #[derive(Parser, Debug)]
675+ #[clap(
676+ version,
677+ about,
678+ name = "ayllu-shell",
679+ long_about=LONG_ABOUT_DESCRIPTION,
680+ )]
681+ pub struct Command {
682+ /// sh compatibility
683+ #[clap(short, action)]
684+ pub c: Option<String>,
685+ /// Path to a configuration file
686+ #[clap(long)]
687+ pub config: Option<PathBuf>,
688+ /// Specify the user identity to assume
689+ #[clap(short, long)]
690+ pub username: Option<String>,
691+ #[command(subcommand)]
692+ pub command: Option<Subcommand>,
693+ }
694 diff --git a/quipu/Cargo.toml b/quipu/Cargo.toml
695index d1c5b8a..2a29996 100644
696--- a/quipu/Cargo.toml
697+++ b/quipu/Cargo.toml
698 @@ -10,6 +10,7 @@ name = "quipu"
699 ayllu_api = { path = "../crates/api" }
700 ayllu_config = { path = "../crates/config" }
701 ayllu_git = { path = "../crates/git" }
702+ ayllu_cmd = {path = "../crates/cmd"}
703
704 clap = { workspace = true }
705 tokio = { workspace = true }
706 @@ -19,5 +20,5 @@ tracing-subscriber = { workspace = true }
707 clap_complete = { workspace = true }
708 thiserror = { workspace = true }
709 serde = { workspace = true }
710- url = { version = "2.5.4", features = ["serde"] }
711+ url = { workspace = true }
712 webfinger-rs = { version = "0.0.18", features = ["reqwest"] }
713 diff --git a/quipu/src/main.rs b/quipu/src/main.rs
714index e2d6b2a..1b293e2 100644
715--- a/quipu/src/main.rs
716+++ b/quipu/src/main.rs
717 @@ -1,12 +1,9 @@
718- use std::io::stdout;
719- use std::path::PathBuf;
720 use std::str::FromStr;
721
722- use clap::{arg, Command, CommandFactory, Parser, Subcommand};
723- use clap_complete::{generate, Generator, Shell};
724 use tracing::Level;
725 use url::Url;
726
727+ use ayllu_cmd::quipu::{Command, Subcommand};
728 use ayllu_config::Reader;
729
730 mod client;
731 @@ -14,47 +11,6 @@ mod config;
732 mod error;
733 mod output;
734
735- #[derive(Parser)]
736- #[command(author, version, about, long_about = None)]
737- #[command(name = "quipu")]
738- #[command(about = "Ayllu Client")]
739- struct Cli {
740- /// Path to your configuration file
741- #[arg(short, long, value_name = "FILE")]
742- config: Option<PathBuf>,
743-
744- /// Sets the logging level
745- #[arg(short, long, value_name = "LEVEL")]
746- level: Option<Level>,
747-
748- #[arg(short, long)]
749- /// instance name from your quipu configuration
750- instance: Option<String>,
751- #[arg(short, long)]
752- /// alternative url to contact
753- url: Option<Url>,
754-
755- #[command(subcommand)]
756- command: Commands,
757- }
758-
759- #[derive(Subcommand, Debug, PartialEq)]
760- enum Commands {
761- /// Generate autocomplete commands for common shells
762- Complete {
763- #[arg(long)]
764- shell: Shell,
765- },
766- /// Verify the remote server is functional
767- Ping,
768- /// Perform a webfinger query against the Ayllu instance
769- Finger { resource: String },
770- }
771-
772- fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
773- generate(gen, cmd, cmd.get_name().to_string(), &mut stdout());
774- }
775-
776 fn get_instance(
777 cfg: &config::Config,
778 url: Option<Url>,
779 @@ -84,7 +40,7 @@ fn get_instance(
780
781 #[tokio::main(flavor = "current_thread")]
782 async fn main() -> Result<(), error::QuipuError> {
783- let cli = Cli::parse();
784+ let cli = ayllu_cmd::parse::<Command>();
785 let cfg: config::Config = Reader::load(cli.config.as_deref())?;
786 let log_level = Level::from_str(&cfg.log_level)?;
787 tracing_subscriber::fmt()
788 @@ -95,18 +51,18 @@ async fn main() -> Result<(), error::QuipuError> {
789 .init();
790 tracing::info!("logger initialized");
791 match cli.command {
792- Commands::Complete { shell } => {
793- let mut cmd = Cli::command();
794- print_completions(shell, &mut cmd);
795- Ok(())
796- }
797- Commands::Finger { resource } => {
798+ // Commands::Complete { shell } => {
799+ // let mut cmd = Cli::command();
800+ // print_completions(shell, &mut cmd);
801+ // Ok(())
802+ // }
803+ Subcommand::Finger { resource } => {
804 let instance = get_instance(&cfg, cli.url, cli.instance)?;
805 let client = client::Quipu::new(instance.url);
806 client.finger(&resource).await?;
807 Ok(())
808 }
809- Commands::Ping => {
810+ Subcommand::Ping => {
811 let instance = get_instance(&cfg, cli.url, cli.instance)?;
812 let client = client::Quipu::new(instance.url);
813 let status = client.ping().await?;
814 diff --git a/scripts/task.sh b/scripts/task.sh
815new file mode 100755
816index 0000000..92f935b
817--- /dev/null
818+++ b/scripts/task.sh
819 @@ -0,0 +1,3 @@
820+ #!/bin/sh
821+
822+ cargo run --package xtask -- "$1"
823 diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
824new file mode 100644
825index 0000000..4f3a76a
826--- /dev/null
827+++ b/xtask/Cargo.toml
828 @@ -0,0 +1,9 @@
829+ [package]
830+ name = "xtask"
831+ version = "0.1.0"
832+ edition = "2024"
833+
834+ [dependencies]
835+ ayllu_cmd = { path = "../crates/cmd" }
836+ clap = {workspace = true}
837+ clap_mangen = "0.2.29"
838 diff --git a/xtask/src/main.rs b/xtask/src/main.rs
839new file mode 100644
840index 0000000..2fe548e
841--- /dev/null
842+++ b/xtask/src/main.rs
843 @@ -0,0 +1,51 @@
844+ use std::{
845+ env,
846+ fs::OpenOptions,
847+ io::BufWriter,
848+ path::{Path, PathBuf},
849+ };
850+
851+ const ARTIFACT_DIR: &str = "target/dist";
852+
853+ fn target_dir() -> PathBuf {
854+ Path::new(&env!("CARGO_MANIFEST_DIR"))
855+ .ancestors()
856+ .nth(1)
857+ .unwrap()
858+ .join(ARTIFACT_DIR)
859+ .to_path_buf()
860+ }
861+
862+ fn man_gen<T>(target: &Path) -> std::io::Result<()>
863+ where
864+ T: clap::Parser,
865+ {
866+ let as_cmd = T::command();
867+ let man = clap_mangen::Man::new(as_cmd);
868+ if let Some(parent) = target.parent() {
869+ std::fs::create_dir_all(parent)?;
870+ };
871+ let fp = OpenOptions::new()
872+ .create(true)
873+ .write(true)
874+ .truncate(true)
875+ .open(target)?;
876+ let mut buf = BufWriter::new(&fp);
877+ man.render(&mut buf)?;
878+ fp.sync_data()?;
879+
880+ Ok(())
881+ }
882+
883+ fn main() -> Result<(), Box<dyn std::error::Error>> {
884+ match env::args().nth(1).as_deref() {
885+ Some("man_gen") => {
886+ man_gen::<ayllu_cmd::ayllu::Command>(&target_dir().join("ayllu.1"))?;
887+ man_gen::<ayllu_cmd::quipu::Command>(&target_dir().join("quipu.1"))?;
888+ man_gen::<ayllu_cmd::shell::Command>(&target_dir().join("ayllu-shell.1"))?;
889+ man_gen::<ayllu_cmd::keys::Command>(&target_dir().join("ayllu-keys.1"))?;
890+ Ok(())
891+ }
892+ _ => unimplemented!(),
893+ }
894+ }