diff --git a/Cargo.lock b/Cargo.lock
index 08e1d9c..25324a6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4
 
 [[package]]
 name = "addr2line"
@@ -233,9 +233,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "clap"
-version = "4.5.15"
+version = "4.5.28"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d8838454fda655dafd3accb2b6e2bea645b9e4078abe84a22ceb947235c5cc"
+checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -243,9 +243,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.15"
+version = "4.5.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6"
+checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7"
 dependencies = [
  "anstream",
  "anstyle",
@@ -255,10 +255,19 @@ dependencies = [
 ]
 
 [[package]]
-name = "clap_derive"
-version = "4.5.13"
+name = "clap_complete"
+version = "4.5.44"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
+checksum = "375f9d8255adeeedd51053574fd8d4ba875ea5fa558e86617b07f09f1680c8b6"
+dependencies = [
+ "clap",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed"
 dependencies = [
  "heck",
  "proc-macro2",
@@ -268,9 +277,9 @@ dependencies = [
 
 [[package]]
 name = "clap_lex"
-version = "0.7.2"
+version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97"
+checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
 
 [[package]]
 name = "colorchoice"
@@ -605,6 +614,7 @@ dependencies = [
  "base64ct",
  "cfg-if",
  "clap",
+ "clap_complete",
  "comrak",
  "crossterm",
  "directories",
@@ -1864,12 +1874,12 @@ dependencies = [
 
 [[package]]
 name = "terminal_size"
-version = "0.3.0"
+version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
+checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9"
 dependencies = [
  "rustix",
- "windows-sys 0.48.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 15532e3..b68460d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ auth-git2 = "0.5.4"
 base64ct = { version = "1.6.0", features = ["std"] }
 cfg-if = "1.0.0"
 clap = { version = "4.5.11", features = ["derive"] }
+clap_complete = "4.5.44"
 comrak = "0.26.0"
 crossterm = "0.27.0"
 directories = "5.0.1"
diff --git a/src/completion.rs b/src/completion.rs
new file mode 100644
index 0000000..f096413
--- /dev/null
+++ b/src/completion.rs
@@ -0,0 +1,183 @@
+use std::io::Write;
+
+use clap::{ArgAction, Args, CommandFactory, ValueEnum};
+use eyre::OptionExt;
+
+#[derive(Args, Clone, Debug)]
+pub struct CompletionCommand {
+    shell: Shell,
+    #[clap(long)]
+    bin_name: Option<String>,
+}
+
+#[derive(ValueEnum, Clone, Debug)]
+pub enum Shell {
+    Bash,
+    Elvish,
+    Fish,
+    PowerShell,
+    Zsh,
+    Nushell,
+}
+
+impl CompletionCommand {
+    pub fn run(self) {
+        use clap_complete::Shell as CCShell;
+        use Shell::*;
+        let mut cmd = crate::App::command();
+        let app_name = self.bin_name.as_deref().unwrap_or("fj");
+        let mut writer = std::io::stdout();
+        match self.shell {
+            Bash => clap_complete::generate(CCShell::Bash, &mut cmd, app_name, &mut writer),
+            Elvish => clap_complete::generate(CCShell::Elvish, &mut cmd, app_name, &mut writer),
+            Fish => clap_complete::generate(CCShell::Fish, &mut cmd, app_name, &mut writer),
+            PowerShell => clap_complete::generate(CCShell::PowerShell, &mut cmd, app_name, &mut writer),
+            Zsh => clap_complete::generate(CCShell::Zsh, &mut cmd, app_name, &mut writer),
+            Nushell => clap_complete::generate(NushellCompletion, &mut cmd, app_name, &mut writer),
+        }
+    }
+}
+
+// Heavily inspired by clap_complete_nushell
+// but rewritten/modified since I'm not a fan of its completions
+
+struct NushellCompletion;
+
+impl clap_complete::Generator for NushellCompletion {
+    fn file_name(&self, name: &str) -> String {
+        format!("{name}.nu")
+    }
+
+    fn generate(&self, cmd: &clap::Command, buf: &mut dyn Write) {
+        generate_command(cmd, buf).expect("failed to generate nushell completions");
+    }
+}
+
+fn generate_command(cmd: &clap::Command, buf: &mut dyn Write) -> eyre::Result<()> {
+    writeln!(buf, "module completions {{")?;
+    generate_subcommand(cmd, buf)?;
+    writeln!(buf, "}}")?;
+    writeln!(buf, "export use completions *")?;
+    Ok(())
+}
+
+fn generate_subcommand(cmd: &clap::Command, buf: &mut dyn Write) -> eyre::Result<()> {
+    let name = cmd.get_bin_name().ok_or_eyre("no bin name")?;
+    writeln!(buf, "  export extern \"{name}\" [")?;
+    let mut args = cmd.get_arguments().collect::<Vec<_>>();
+    args.sort_by_key(|arg| arg.is_positional());
+
+    // positional arguments
+    for arg in cmd.get_arguments() {
+        if !arg.is_positional() {
+            continue;
+        }
+
+        write!(buf, "    ")?;
+        let id = arg.get_id().as_str();
+
+        if matches!(arg.get_action(), ArgAction::Append) {
+            write!(buf, "...{id}")?;
+        } else {
+            write!(buf, "{id}")?;
+            if !arg.is_required_set() {
+                write!(buf, "?")?;
+            }
+        }
+
+        arg_type(name, arg, buf)?;
+        writeln!(buf)?;
+    }
+
+    // subcommand completion
+    if cmd.get_subcommands().next().is_some() { // basically `!is_empty`
+        writeln!(buf, "    rest?: string@\"complete-subcommand {name}\",")?;
+    }
+
+    // flag arguments
+    for arg in cmd.get_arguments() {
+        match (arg.get_long(), arg.get_short()) {
+            (Some(long), Some(short)) => write!(buf, "    --{long}(-{short})")?,
+            (Some(long), None) => write!(buf, "    --{long}")?,
+            (None, Some(short)) => write!(buf, "    -{short}")?,
+            (None, None) => continue,
+        }
+        arg_type(name, arg, buf)?;
+        writeln!(buf)?;
+    }
+    writeln!(buf, "  ]")?;
+    writeln!(buf)?;
+
+    // argument completions
+    for arg in cmd.get_arguments() {
+        let possible_values = arg.get_possible_values();
+        if possible_values.is_empty() {
+            continue;
+        }
+        writeln!(buf, "  def \"complete-value {name} {}\" [] {{", arg.get_id().as_str())?;
+        writeln!(buf, "    [")?;
+        for possible_value in &possible_values {
+            write!(buf, "      {{ value: \"{}\"", possible_value.get_name())?;
+            if let Some(help) = possible_value.get_help() {
+                write!(buf, ", description: \"{help}\"")?;
+            }
+            writeln!(buf, " }},")?;
+        }
+        writeln!(buf, "    ]")?;
+        writeln!(buf, "  }}")?;
+        writeln!(buf)?;
+    }
+
+    // subcommand completion
+    if cmd.get_subcommands().count() != 0 {
+        writeln!(buf, "  def \"complete-subcommand {name}\" [] {{")?;
+        writeln!(buf, "    [")?;
+        for subcommand in cmd.get_subcommands() {
+            write!(buf, "      {{ value: \"{}\"", subcommand.get_name())?;
+            if let Some(about) = subcommand.get_about() {
+                write!(buf, ", description: \"{about}\"")?;
+            }
+            writeln!(buf, " }},")?;
+        }
+        writeln!(buf, "    ]")?;
+        writeln!(buf, "  }}")?;
+        writeln!(buf)?;
+    }
+
+    for subcommand in cmd.get_subcommands() {
+        generate_subcommand(subcommand, buf)?;
+    }
+    Ok(())
+}
+
+fn arg_type(cmd_name: &str, arg: &clap::Arg, buf: &mut dyn Write) -> eyre::Result<()> {
+    use clap::ValueHint;
+    let takes_values = arg.get_num_args().map(|r| r.takes_values()).unwrap_or_default();
+    if takes_values {
+        let type_name = match arg.get_value_hint() {
+            ValueHint::Unknown => "string",
+            ValueHint::Other => "string",
+            ValueHint::AnyPath => "path",
+            ValueHint::FilePath => "path",
+            ValueHint::DirPath => "path",
+            ValueHint::ExecutablePath => "path",
+            ValueHint::CommandName => "string",
+            ValueHint::CommandString => "path",
+            ValueHint::CommandWithArguments => "string",
+            ValueHint::Username => "string",
+            ValueHint::Hostname => "string",
+            ValueHint::Url => "string",
+            ValueHint::EmailAddress => "string",
+            _ => "string",
+        };
+
+        write!(buf, ": {type_name}")?;
+    }
+
+    let possible_values = arg.get_possible_values();
+    if !possible_values.is_empty() {
+        write!(buf, "@\"complete-value {cmd_name} {}\"", arg.get_id().as_str())?;
+    }
+
+    Ok(())
+}
diff --git a/src/issues.rs b/src/issues.rs
index b82f21e..196c243 100644
--- a/src/issues.rs
+++ b/src/issues.rs
@@ -25,27 +25,24 @@ pub enum IssueSubcommand {
         title: Option<String>,
         #[clap(long)]
         body: Option<String>,
-        #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+        #[clap(long, short)]
         repo: Option<RepoArg>,
         #[clap(long)]
         web: bool,
     },
     /// Edit an issue
     Edit {
-        #[clap(id = "[REPO#]ID")]
         issue: IssueId,
         #[clap(subcommand)]
         command: EditCommand,
     },
     /// Add a comment on an issue
     Comment {
-        #[clap(id = "[REPO#]ID")]
         issue: IssueId,
         body: Option<String>,
     },
     /// Close an issue
     Close {
-        #[clap(id = "[REPO#]ID")]
         issue: IssueId,
         /// A comment to leave on the issue before closing it
         #[clap(long, short)]
@@ -53,7 +50,7 @@ pub enum IssueSubcommand {
     },
     /// Search for an issue in a repo
     Search {
-        #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+        #[clap(long, short)]
         repo: Option<RepoArg>,
         query: Option<String>,
         #[clap(long, short)]
@@ -67,14 +64,12 @@ pub enum IssueSubcommand {
     },
     /// View an issue's info
     View {
-        #[clap(id = "[REPO#]ID")]
         id: IssueId,
         #[clap(subcommand)]
         command: Option<ViewCommand>,
     },
     /// Open an issue in your browser
     Browse {
-        #[clap(id = "[REPO#]ID")]
         id: IssueId,
     },
 }
diff --git a/src/main.rs b/src/main.rs
index 9e52800..e3936c9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16,6 +16,7 @@ mod user;
 mod version;
 mod whoami;
 mod wiki;
+mod completion;
 
 pub const USER_AGENT: &str = concat!(
     env!("CARGO_PKG_NAME"),
@@ -50,6 +51,7 @@ pub enum Command {
     Release(release::ReleaseCommand),
     User(user::UserCommand),
     Version(version::VersionCommand),
+    Completion(completion::CompletionCommand),
 }
 
 #[tokio::main]
@@ -72,6 +74,7 @@ async fn main() -> eyre::Result<()> {
         Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?,
         Command::User(subcommand) => subcommand.run(&mut keys, host_name).await?,
         Command::Version(command) => command.run().await?,
+        Command::Completion(subcommand) => subcommand.run(),
     }
 
     keys.save().await?;
diff --git a/src/prs.rs b/src/prs.rs
index ecce467..383c34c 100644
--- a/src/prs.rs
+++ b/src/prs.rs
@@ -61,7 +61,7 @@ pub enum PrSubcommand {
         #[clap(long)]
         body: Option<String>,
         /// The repo to create this issue on
-        #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+        #[clap(long, short)]
         repo: Option<RepoArg>,
         /// Open the PR creation menu in your web browser
         #[clap(short, long, group = "web-or-cmd", group = "web-or-agit")]
@@ -73,7 +73,6 @@ pub enum PrSubcommand {
     /// View the contents of a pull request
     View {
         /// The pull request to view.
-        #[clap(id = "[REPO#]ID")]
         id: Option<IssueId>,
         #[clap(subcommand)]
         command: Option<ViewCommand>,
@@ -81,7 +80,6 @@ pub enum PrSubcommand {
     /// View the mergability and CI status of a pull request
     Status {
         /// The pull request to view.
-        #[clap(id = "[REPO#]ID")]
         id: Option<IssueId>,
         /// Wait for all checks to finish before exiting
         #[clap(long)]
@@ -103,7 +101,6 @@ pub enum PrSubcommand {
     /// Add a comment on a pull request
     Comment {
         /// The pull request to comment on.
-        #[clap(id = "[REPO#]ID")]
         pr: Option<IssueId>,
         /// The text content of the comment.
         ///
@@ -113,7 +110,6 @@ pub enum PrSubcommand {
     /// Edit the contents of a pull request
     Edit {
         /// The pull request to edit.
-        #[clap(id = "[REPO#]ID")]
         pr: Option<IssueId>,
         #[clap(subcommand)]
         command: EditCommand,
@@ -121,7 +117,6 @@ pub enum PrSubcommand {
     /// Close a pull request, without merging.
     Close {
         /// The pull request to close.
-        #[clap(id = "[REPO#]ID")]
         pr: Option<IssueId>,
         /// A comment to add before closing.
         ///
@@ -132,7 +127,6 @@ pub enum PrSubcommand {
     /// Merge a pull request
     Merge {
         /// The pull request to merge.
-        #[clap(id = "[REPO#]ID")]
         pr: Option<IssueId>,
         /// The merge style to use.
         #[clap(long, short = 'M')]
@@ -150,7 +144,6 @@ pub enum PrSubcommand {
     /// Open a pull request in your browser
     Browse {
         /// The pull request to open in your browser.
-        #[clap(id = "[REPO#]ID")]
         id: Option<IssueId>,
     },
 }
diff --git a/src/release.rs b/src/release.rs
index 60a1abf..c7d235b 100644
--- a/src/release.rs
+++ b/src/release.rs
@@ -18,7 +18,7 @@ pub struct ReleaseCommand {
     #[clap(long, short = 'R')]
     remote: Option<String>,
     /// The name of the repository to operate on.
-    #[clap(long, short, id = "[HOST/]OWNER/REPO")]
+    #[clap(long, short)]
     repo: Option<RepoArg>,
     #[clap(subcommand)]
     command: ReleaseSubcommand,
diff --git a/src/repo.rs b/src/repo.rs
index adfde12..0c60015 100644
--- a/src/repo.rs
+++ b/src/repo.rs
@@ -330,7 +330,6 @@ pub enum RepoCommand {
     },
     /// Fork a repository onto your account
     Fork {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         repo: RepoArg,
         #[clap(long)]
         name: Option<String>,
@@ -339,7 +338,6 @@ pub enum RepoCommand {
     },
     Migrate {
         /// URL of the repo to migrate
-        #[clap(id = "HOST/OWNER/REPO")]
         repo: String,
         /// Name of the new mirror
         name: String,
@@ -376,44 +374,37 @@ pub enum RepoCommand {
     },
     /// View a repo's info
     View {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         name: Option<RepoArg>,
         #[clap(long, short = 'R')]
         remote: Option<String>,
     },
     /// View a repo's README
     Readme {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         name: Option<RepoArg>,
         #[clap(long, short = 'R')]
         remote: Option<String>,
     },
     /// Clone a repo's code locally
     Clone {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         repo: RepoArg,
         path: Option<PathBuf>,
     },
     /// Add a star to a repo
     Star {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         repo: RepoArg,
     },
     /// Take away a star from a repo
     Unstar {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         repo: RepoArg,
     },
     /// Delete a repository
     ///
     /// This cannot be undone!
     Delete {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         repo: RepoArg,
     },
     /// Open a repository's page in your browser
     Browse {
-        #[clap(id = "[HOST/]OWNER/REPO")]
         name: Option<RepoArg>,
         #[clap(long, short = 'R')]
         remote: Option<String>,