diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..35049cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/Cargo.lock b/Cargo.lock index 2af175c..84ae351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,6 +652,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -670,6 +679,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -2107,6 +2126,7 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "clap_complete", "inkfinite-core", "resvg", "schemars 1.2.1", @@ -3380,6 +3400,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "roxmltree" version = "0.20.0" @@ -5781,6 +5807,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "clap_complete", + "clap_mangen", + "inkfinite-cli", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 84da365..a40ab2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/inkfinite-cli", "crates/inkfinite-core", "crates/inkfinite-wasm", + "xtask", ] resolver = "2" @@ -18,6 +19,8 @@ anyhow = "1.0.103" automerge = "=0.10.0" base64 = "0.22.1" clap = { version = "4.6.1", features = ["derive"] } +clap_complete = "4.6.9" +clap_mangen = "0.2.33" inkfinite-core = { path = "crates/inkfinite-core" } perfect_freehand = "=0.1.1" resvg = { version = "=0.48.1", default-features = false, features = ["raster-images", "system-fonts", "text"] } diff --git a/README.md b/README.md index 3e4e6d7..d0bb4b8 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,16 @@ pnpm tauri dev To build the CLI used by scripts and live desktop sessions: ```sh -cargo build -p inkfinite-cli --bin inkfinite # you can optionally cargo install -./target/debug/inkfinite --help # just inkfinite if cargo installed +cargo build -p inkfinite-cli --bin inkfinite +./target/debug/inkfinite --help + +# Build an installable release tree with the binary, man page, and completions. +cargo xtask dist ``` +See the [CLI documentation](apps/web/src/content/docs/reference/cli.md) for +installation paths and shell completion setup. + ## Documentation - [Getting started](apps/web/src/content/docs/getting-started.md) diff --git a/apps/web/src/content/docs/reference/cli.md b/apps/web/src/content/docs/reference/cli.md index d3dec60..e93d5f3 100644 --- a/apps/web/src/content/docs/reference/cli.md +++ b/apps/web/src/content/docs/reference/cli.md @@ -24,6 +24,7 @@ commands are: | `import svg` | Import static SVG content into native shapes | | `render` | Write an SVG or PNG of a document or filtered view | | `app` | Inspect or work with a running desktop session | +| `completions` | Print a Bash, Fish, or Zsh completion script | | `schema`, `capabilities` | Print machine-readable contracts for integrations | During development, build the binary with: @@ -32,6 +33,56 @@ During development, build the binary with: cargo build -p inkfinite-cli --bin inkfinite ``` +## Build and install the CLI + +The workspace includes an `xtask` helper for building the CLI and generating its +man page and shell completions: + +```sh +cargo xtask man +cargo xtask completions +cargo xtask dist +``` + +The first two commands write to `target/man/inkfinite.1` and +`target/completions/`. `cargo xtask dist` builds the release binary and writes a +source-built distribution under `target/dist/`: + +```text +target/dist/bin/inkfinite +target/dist/share/man/man1/inkfinite.1 +target/dist/share/bash-completion/completions/inkfinite +target/dist/share/fish/vendor_completions.d/inkfinite.fish +target/dist/share/zsh/site-functions/_inkfinite +``` + +On a Unix system, install those files with: + +```sh +sudo mkdir -p /usr/local/bin /usr/local/share/man/man1 \ + /usr/local/share/bash-completion/completions \ + /usr/local/share/fish/vendor_completions.d \ + /usr/local/share/zsh/site-functions + +sudo install -m 755 target/dist/bin/inkfinite /usr/local/bin/inkfinite + +sudo install -m 644 target/dist/share/man/man1/inkfinite.1 \ + /usr/local/share/man/man1/inkfinite.1 + +sudo install -m 644 target/dist/share/bash-completion/completions/inkfinite \ + /usr/local/share/bash-completion/completions/inkfinite + +sudo install -m 644 target/dist/share/fish/vendor_completions.d/inkfinite.fish \ + /usr/local/share/fish/vendor_completions.d/inkfinite.fish + +sudo install -m 644 target/dist/share/zsh/site-functions/_inkfinite \ + /usr/local/share/zsh/site-functions/_inkfinite +``` + +Use `inkfinite completions bash`, `inkfinite completions fish`, or +`inkfinite completions zsh` to print one script directly. `comp` is an alias for +`completions`. + ## File mode File-mode commands operate on a closed `.inkfinite` file. Start by inspecting its heads and querying diff --git a/crates/inkfinite-cli/Cargo.toml b/crates/inkfinite-cli/Cargo.toml index 263beaa..8c55055 100644 --- a/crates/inkfinite-cli/Cargo.toml +++ b/crates/inkfinite-cli/Cargo.toml @@ -17,6 +17,7 @@ path = "src/bin/generate-bindings.rs" [dependencies] anyhow.workspace = true clap.workspace = true +clap_complete.workspace = true inkfinite-core.workspace = true resvg.workspace = true schemars.workspace = true diff --git a/crates/inkfinite-cli/src/bin/inkfinite-cli.rs b/crates/inkfinite-cli/src/bin/inkfinite-cli.rs index 628b7ed..2c9e092 100644 --- a/crates/inkfinite-cli/src/bin/inkfinite-cli.rs +++ b/crates/inkfinite-cli/src/bin/inkfinite-cli.rs @@ -3,9 +3,7 @@ use std::io; use clap::Parser; - -#[path = "../cli/mod.rs"] -mod cli; +use inkfinite_cli::cli; fn main() { let arguments = std::env::args_os().collect::>(); diff --git a/crates/inkfinite-cli/src/cli/args.rs b/crates/inkfinite-cli/src/cli/args.rs index 1579443..6c86e3c 100644 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -108,6 +108,16 @@ pub enum Command { inkfinite schema protocol ")] Schema(SchemaArgs), + /// Print a shell completion script. + #[command( + visible_alias = "comp", + after_help = "Examples: + + inkfinite completions bash > inkfinite.bash + inkfinite comp zsh > _inkfinite +" + )] + Completions(CompletionsArgs), /// Report the stable file-mode command contract. #[command(after_help = "Examples: @@ -715,3 +725,21 @@ pub struct SchemaArgs { #[arg(value_enum, value_name = "KIND")] pub kind: SchemaKind, } + +#[derive(Debug, Args)] +pub struct CompletionsArgs { + /// Shell to generate completions for. + #[arg(value_enum, value_name = "SHELL")] + pub shell: CompletionShell, +} + +/// Shells supported by the completion command and source distribution. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum CompletionShell { + /// Bourne Again Shell. + Bash, + /// Friendly Interactive Shell. + Fish, + /// Z Shell. + Zsh, +} diff --git a/crates/inkfinite-cli/src/cli/completion.rs b/crates/inkfinite-cli/src/cli/completion.rs new file mode 100644 index 0000000..8d9b5ed --- /dev/null +++ b/crates/inkfinite-cli/src/cli/completion.rs @@ -0,0 +1,16 @@ +use clap::CommandFactory; +use clap_complete::aot::{Bash, Fish, Zsh, generate}; + +use super::support::map_output_error; +use super::{Cli, CompletionShell, Result, Write}; + +pub fn print_completions(shell: CompletionShell, stdout: &mut dyn Write) -> Result<()> { + let mut command = Cli::command(); + let mut output = Vec::new(); + match shell { + CompletionShell::Bash => generate(Bash, &mut command, "inkfinite", &mut output), + CompletionShell::Fish => generate(Fish, &mut command, "inkfinite", &mut output), + CompletionShell::Zsh => generate(Zsh, &mut command, "inkfinite", &mut output), + } + stdout.write_all(&output).map_err(map_output_error) +} diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs index c884edf..a2a7b3b 100644 --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -36,7 +36,7 @@ pub fn print_schema(kind: SchemaKind, stdout: &mut dyn Write) -> Result<(), CliE pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<(), CliError> { let capabilities = json!({ - "commands": ["new", "inspect", "query", "app", "validate", "apply", "import", "shape", "connect", "layout", "render", "schema", "capabilities"], + "commands": ["new", "inspect", "query", "app", "validate", "apply", "import", "shape", "connect", "layout", "render", "schema", "completions", "capabilities"], "exit_codes": { "conflict": EXIT_CONFLICT, "input": EXIT_INPUT, @@ -72,6 +72,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "query_options": ["detail", "limit"], "render_filters": ["page", "layer", "shape", "role", "region"], "schemas": ["document", "transaction", "protocol", "protocol-request", "protocol-response", "protocol-error"], + "completions": { "shells": ["bash", "fish", "zsh"], "alias": "comp" }, "shape_kinds": builtin_shape_kinds() }); if json_output { @@ -86,7 +87,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( .map_err(map_output_error)?; writeln!( stdout, - "Commands: new, inspect, query, app, validate, apply, import, shape, connect, layout, render, schema, capabilities" + "Commands: new, inspect, query, app, validate, apply, import, shape, connect, layout, render, schema, completions, capabilities" ) .map_err(map_output_error)?; writeln!( diff --git a/crates/inkfinite-cli/src/cli/mod.rs b/crates/inkfinite-cli/src/cli/mod.rs index 6b43254..dab0a5d 100644 --- a/crates/inkfinite-cli/src/cli/mod.rs +++ b/crates/inkfinite-cli/src/cli/mod.rs @@ -105,6 +105,7 @@ impl CliError { mod app; mod apply; mod args; +mod completion; mod connect; mod contract; mod document; @@ -122,7 +123,7 @@ use args::{ }; use support::parse_bounds; -pub use args::{Cli, Command}; +pub use args::{Cli, Command, CompletionShell}; pub fn run(command: Command, json_output: bool, stdout: &mut dyn Write) -> Result<()> { match command { @@ -138,6 +139,7 @@ pub fn run(command: Command, json_output: bool, stdout: &mut dyn Write) -> Resul Command::Layout(command) => layout::run_layout_command(command, json_output, stdout), Command::Render(args) => render::render_document(args, json_output, stdout), Command::Schema(args) => contract::print_schema(args.kind, stdout), + Command::Completions(args) => completion::print_completions(args.shell, stdout), Command::Capabilities => contract::print_capabilities(json_output, stdout), } } diff --git a/crates/inkfinite-cli/src/lib.rs b/crates/inkfinite-cli/src/lib.rs new file mode 100644 index 0000000..b9e9551 --- /dev/null +++ b/crates/inkfinite-cli/src/lib.rs @@ -0,0 +1,3 @@ +//! Public library surface for the Inkfinite command-line interface. + +pub mod cli; diff --git a/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs index d0c44e8..dc34eac 100644 --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -61,6 +61,24 @@ fn help_makes_common_tasks_and_support_paths_discoverable() { assert_eq!(parse_stderr(&agent_review_attempt)["error"]["code"], "invalid_usage"); } +#[test] +fn completion_command_accepts_long_name_and_alias() { + let bash = run(["completions", "bash"]); + assert_success(&bash); + assert!(String::from_utf8_lossy(&bash.stdout).contains("inkfinite")); + + let fish = run(["comp", "fish"]); + assert_success(&fish); + assert!(String::from_utf8_lossy(&fish.stdout).contains("inkfinite")); + + let zsh = run(["completions", "zsh"]); + assert_success(&zsh); + assert!(String::from_utf8_lossy(&zsh.stdout).contains("inkfinite")); + + let invalid = run(["completions", "powershell"]); + assert_eq!(invalid.status.code(), Some(2)); +} + #[test] fn closed_file_workflow_has_stable_human_and_json_output() { let temporary = TestDirectory::new("workflow"); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..abbc5a0 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +clap.workspace = true +clap_complete.workspace = true +clap_mangen.workspace = true +inkfinite-cli = { path = "../crates/inkfinite-cli" } + +[lints] +workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..706f615 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,152 @@ +use std::env; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; + +use anyhow::{Context, Result, bail}; +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::aot::{Bash, Fish, Generator, Zsh}; +use clap_mangen::Man; +use inkfinite_cli::cli::{Cli, CompletionShell}; + +const BINARY_NAME: &str = "inkfinite"; + +#[derive(Debug, Parser)] +#[command( + name = "cargo xtask", + bin_name = "cargo xtask", + about = "Inkfinite source distribution tasks" +)] +struct Xtask { + #[command(subcommand)] + command: Task, +} + +#[derive(Debug, Subcommand)] +enum Task { + /// Generate the Inkfinite man page. + Man, + /// Generate Bash, Fish, and Zsh completion scripts. + Completions, + /// Build a release binary with its man page and shell completions. + Dist, +} + +fn main() { + if let Err(error) = run() { + eprintln!("cargo xtask: {error:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + match Xtask::parse().command { + Task::Man => generate_man(&target_dir().join("man")), + Task::Completions => generate_completions(&target_dir().join("completions")), + Task::Dist => generate_distribution(), + } +} + +fn generate_man(output_dir: &Path) -> Result<()> { + fs::create_dir_all(output_dir).with_context(|| format!("could not create {}", output_dir.display()))?; + let output = output_dir.join("inkfinite.1"); + let mut file = File::create(&output).with_context(|| format!("could not create {}", output.display()))?; + Man::new(Cli::command()) + .render(&mut file) + .with_context(|| format!("could not write {}", output.display()))?; + println!("generated {}", output.display()); + Ok(()) +} + +fn generate_completions(output_dir: &Path) -> Result<()> { + fs::create_dir_all(output_dir).with_context(|| format!("could not create {}", output_dir.display()))?; + for (shell, filename) in [ + (CompletionShell::Bash, "inkfinite.bash"), + (CompletionShell::Fish, "inkfinite.fish"), + (CompletionShell::Zsh, "_inkfinite"), + ] { + write_completion(shell, &output_dir.join(filename))?; + } + println!("generated completions in {}", output_dir.display()); + Ok(()) +} + +fn generate_distribution() -> Result<()> { + let target = target_dir(); + build_release_binary(&workspace_root())?; + + let distribution = target.join("dist"); + if distribution.exists() { + fs::remove_dir_all(&distribution).with_context(|| format!("could not remove {}", distribution.display()))?; + } + fs::create_dir_all(distribution.join("bin")) + .with_context(|| format!("could not create {}", distribution.display()))?; + + let binary_name = if cfg!(windows) { "inkfinite.exe" } else { BINARY_NAME }; + let binary = target.join("release").join(binary_name); + let installed_binary = distribution.join("bin").join(binary_name); + fs::copy(&binary, &installed_binary).with_context(|| format!("could not copy {}", binary.display()))?; + + generate_man(&distribution.join("share/man/man1"))?; + write_completion( + CompletionShell::Bash, + &distribution.join("share/bash-completion/completions/inkfinite"), + )?; + write_completion( + CompletionShell::Fish, + &distribution.join("share/fish/vendor_completions.d/inkfinite.fish"), + )?; + write_completion( + CompletionShell::Zsh, + &distribution.join("share/zsh/site-functions/_inkfinite"), + )?; + + println!("distribution written to {}", distribution.display()); + Ok(()) +} + +fn build_release_binary(root: &Path) -> Result<()> { + let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let status = ProcessCommand::new(cargo) + .args(["build", "--release", "-p", "inkfinite-cli", "--bin", BINARY_NAME]) + .current_dir(root) + .status() + .context("could not start the release build")?; + if !status.success() { + bail!("release build failed with {status}"); + } + Ok(()) +} + +fn write_completion(shell: CompletionShell, output: &Path) -> Result<()> { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?; + } + let mut command = Cli::command(); + command.set_bin_name(BINARY_NAME); + command.build(); + let mut file = File::create(output).with_context(|| format!("could not create {}", output.display()))?; + let result = match shell { + CompletionShell::Bash => Bash.try_generate(&command, &mut file), + CompletionShell::Fish => Fish.try_generate(&command, &mut file), + CompletionShell::Zsh => Zsh.try_generate(&command, &mut file), + }; + result.with_context(|| format!("could not write {}", output.display()))?; + println!("generated {}", output.display()); + Ok(()) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask is inside the workspace") + .to_path_buf() +} + +fn target_dir() -> PathBuf { + let root = workspace_root(); + let target = env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target")); + if target.is_absolute() { target } else { root.join(target) } +}