From 8768ad1c7d0236031d612a9012e9df8f3412a103 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 7 Aug 2026 06:26:41 -0700 Subject: [PATCH 1/6] Implement shell completions --- CHANGELOG.md | 1 + Cargo.lock | 12 ++- Cargo.toml | 1 + crates/icp-cli/Cargo.toml | 1 + crates/icp-cli/src/commands/completions.rs | 36 +++++++ crates/icp-cli/src/commands/mod.rs | 2 + crates/icp-cli/src/main.rs | 11 +++ docs/guides/installation.md | 10 ++ docs/reference/cli.md | 34 +++++++ npm/icp-cli/completions.js | 107 +++++++++++++++++++++ npm/icp-cli/postinstall.js | 9 ++ 11 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 crates/icp-cli/src/commands/completions.rs create mode 100644 npm/icp-cli/completions.js diff --git a/CHANGELOG.md b/CHANGELOG.md index da58dc477..76dd77d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ bump. Currently experimental: project bundling, project dependencies # Unreleased +* feat: `icp completions ` prints a shell completion script for `bash`, `zsh`, `fish`, `powershell`, or `elvish` to stdout. See the [installation guide](docs/guides/installation.md#shell-completions) for where to put it. * feat: a canister environment variable's value can now be read from a file, by writing `var: { path: }` in place of `var: value`. The path resolves against the canister's directory — including in an environment override, matching `init_args` — and surrounding whitespace is trimmed off the file's contents. The file is read when the project is loaded, so a missing file fails before anything is deployed. `icp project bundle` writes the value into the bundled manifest inline, rejecting a file outside the project as it does for other manifest file references. * fix: `icp network start` now explains why a Docker-based network failed to come up. A container that exited before the network was ready was reported as `failed to watch docker container for exit` with an empty cause, discarding the actual reason (e.g. the gateway port already being taken); the container's output is now attached to the error. * feat: Docker-based networks now show the launcher's output like non-containerized ones do. In the foreground the container's stdout and stderr are streamed to your terminal as it runs; in background mode `icp network start` prints the `docker logs -f ` command to follow it. Previously container output was never shown at all — which on Windows, where the launcher always runs in a container, meant `icp network start` was silent. diff --git a/Cargo.lock b/Cargo.lock index 2bfd1b4b7..5eb074667 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,6 +1227,15 @@ dependencies = [ "terminal_size", ] +[[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" @@ -3709,6 +3718,7 @@ dependencies = [ "cargo-generate", "clap", "clap-markdown", + "clap_complete", "cryptoki", "dialoguer 0.12.0", "dunce", @@ -7118,7 +7128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 3aa77ac1c..e3b10f966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ candid = "0.10.19" candid_parser = "0.3.0" clap = { version = "4.5.3", features = ["derive", "env"] } clap-markdown = "0.1.5" +clap_complete = "4.5.3" cryptoki = "0.12.0" console = "0.16.3" dialoguer = "0.12.0" diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index bddc01ea8..b05d0d14f 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -28,6 +28,7 @@ candid.workspace = true cargo-generate.workspace = true clap-markdown.workspace = true clap.workspace = true +clap_complete.workspace = true dialoguer.workspace = true dunce.workspace = true elliptic-curve.workspace = true diff --git a/crates/icp-cli/src/commands/completions.rs b/crates/icp-cli/src/commands/completions.rs new file mode 100644 index 000000000..f6536ab3a --- /dev/null +++ b/crates/icp-cli/src/commands/completions.rs @@ -0,0 +1,36 @@ +use std::io; + +use clap::{Args, CommandFactory}; +use clap_complete::{Shell, generate}; + +use crate::Cli; + +/// Generate a shell completion script +/// +/// The script is written to stdout; redirect it to the location your shell +/// loads completions from, or source it directly from your shell profile. +#[derive(Debug, Args)] +#[command(after_long_help = "\ +Examples: + + # Bash + icp completions bash > /etc/bash_completion.d/icp + + # Zsh, into a directory on your $fpath + icp completions zsh > ~/.zfunc/_icp + + # Fish + icp completions fish > ~/.config/fish/completions/icp.fish + + # PowerShell, appended to your profile + icp completions powershell >> $PROFILE +")] +pub(crate) struct CompletionsArgs { + /// The shell to generate a completion script for + shell: Shell, +} + +pub(crate) fn exec(args: &CompletionsArgs) { + let mut command = Cli::command(); + generate(args.shell, &mut command, "icp", &mut io::stdout()); +} diff --git a/crates/icp-cli/src/commands/mod.rs b/crates/icp-cli/src/commands/mod.rs index dee9107b0..dcd64626a 100644 --- a/crates/icp-cli/src/commands/mod.rs +++ b/crates/icp-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ use clap::Subcommand; pub(crate) mod args; pub(crate) mod build; pub(crate) mod canister; +pub(crate) mod completions; pub(crate) mod cycles; pub(crate) mod deploy; pub(crate) mod environment; @@ -21,6 +22,7 @@ pub(crate) enum Command { Build(build::BuildArgs), #[command(subcommand)] Canister(canister::Command), + Completions(completions::CompletionsArgs), #[command(subcommand)] Cycles(cycles::Command), Deploy(deploy::DeployArgs), diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index ce26a9cab..f2dac0cb6 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -122,6 +122,14 @@ async fn main() -> Result<(), Error> { } }; + // Completion scripts are derived from the clap definition alone. Handle them + // before any other setup: they are generated at package-install time, where + // the telemetry notice and the update check have no business firing. + if let Command::Completions(args) = &command { + commands::completions::exec(args); + return Ok(()); + } + // Logging: --debug gets the detailed tracing layer; otherwise plain user-facing output let debug = cli.debug; let reg = Registry::default() @@ -305,6 +313,9 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E } }, + // Completions: handled in `main` before the context exists + Command::Completions(_) => unreachable!(), + // Cycles Command::Cycles(cmd) => match cmd { commands::cycles::Command::Balance(args) => { diff --git a/docs/guides/installation.md b/docs/guides/installation.md index 5af13fcda..cb1ba82d1 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -135,6 +135,16 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup target add wasm32-unknown-unknown ``` +## Shell Completions + +The npm and Homebrew distributions install shell completions automatically. Otherwise, `icp completions ` writes a completion script to stdout — `bash`, `zsh`, `fish`, `powershell`, and `elvish` are supported. Redirect it to wherever your shell loads completions from: + +```bash +mkdir -p ~/.local/share/bash-completion/completions +icp completions bash > ~/.local/share/bash-completion/completions/icp +``` + +Restart your shell afterwards, and regenerate the script after upgrading `icp` so completions cover newly added commands and flags. ## Troubleshooting diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8fd377ce0..31aeff0a1 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -34,6 +34,7 @@ This document contains the help content for the `icp` command-line program. * [`icp canister status`↴](#icp-canister-status) * [`icp canister stop`↴](#icp-canister-stop) * [`icp canister top-up`↴](#icp-canister-top-up) +* [`icp completions`↴](#icp-completions) * [`icp cycles`↴](#icp-cycles) * [`icp cycles balance`↴](#icp-cycles-balance) * [`icp cycles mint`↴](#icp-cycles-mint) @@ -89,6 +90,7 @@ This document contains the help content for the `icp` command-line program. * `build` — Build canisters * `canister` — Perform canister operations against a network +* `completions` — Generate a shell completion script * `cycles` — Mint and manage cycles * `deploy` — Deploy a project to an environment * `environment` — Show information about the current project environments @@ -802,6 +804,38 @@ Top up a canister with cycles +## `icp completions` + +Generate a shell completion script + +The script is written to stdout; redirect it to the location your shell loads completions from, or source it directly from your shell profile. + +**Usage:** `icp completions ` + +Examples: + + # Bash + icp completions bash > /etc/bash_completion.d/icp + + # Zsh, into a directory on your $fpath + icp completions zsh > ~/.zfunc/_icp + + # Fish + icp completions fish > ~/.config/fish/completions/icp.fish + + # PowerShell, appended to your profile + icp completions powershell >> $PROFILE + + +###### **Arguments:** + +* `` — The shell to generate a completion script for + + Possible values: `bash`, `elvish`, `fish`, `powershell`, `zsh` + + + + ## `icp cycles` Mint and manage cycles diff --git a/npm/icp-cli/completions.js b/npm/icp-cli/completions.js new file mode 100644 index 000000000..dfdec8299 --- /dev/null +++ b/npm/icp-cli/completions.js @@ -0,0 +1,107 @@ +/** + * Shell completion installation, run from postinstall.js. + * + * Only directories that a shell reads on its own are written to; hooking up a + * shell that needs a profile edit is left to the user, who can generate the + * script with `icp completions `. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function completionTargets(home) { + return [ + { + shell: 'bash', + // XDG user directory read by bash-completion v2. + dir: path.join(home, '.local', 'share', 'bash-completion', 'completions'), + file: 'icp' + }, + { + shell: 'fish', + dir: path.join(home, '.config', 'fish', 'completions'), + file: 'icp.fish', + // Only if fish is actually configured; fish creates this itself otherwise. + requires: path.join(home, '.config', 'fish') + }, + { + shell: 'zsh', + // Not on zsh's default $fpath, so only useful if the user set it up. + dir: path.join(home, '.zfunc'), + file: '_icp', + requires: path.join(home, '.zfunc') + } + ]; +} + +/** Generate a completion script, or throw with what the binary reported. */ +function generate(binaryPath, shell) { + const result = spawnSync(binaryPath, ['completions', shell], { + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024 + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const detail = (result.stderr || '').trim(); + throw new Error( + `\`icp completions ${shell}\` exited with status ${result.status}` + + (detail ? `: ${detail}` : '') + ); + } + if (!result.stdout) { + throw new Error(`\`icp completions ${shell}\` produced no output`); + } + return result.stdout; +} + +/** + * Install completion scripts for the shells that can pick them up automatically. + * + * A failure is reported but does not fail the install: completions are a + * convenience, and an unwritable home directory is not an installation error. + * + * @returns {string[]} the shells whose completions were installed + */ +function installCompletions(binaryPath) { + if (process.env.ICP_CLI_SKIP_COMPLETIONS || process.platform === 'win32') { + return []; + } + + const home = os.homedir(); + if (!home) { + console.error( + 'WARNING: skipping shell completions: no home directory found\n' + + ' Once that is resolved, run `icp completions ` and save the output ' + + 'where your shell loads completions from.' + ); + return []; + } + + const installed = []; + for (const target of completionTargets(home)) { + if (target.requires && !fs.existsSync(target.requires)) { + continue; + } + const destination = path.join(target.dir, target.file); + try { + const script = generate(binaryPath, target.shell); + fs.mkdirSync(target.dir, { recursive: true }); + fs.writeFileSync(destination, script, { mode: 0o644 }); + installed.push(target.shell); + } catch (err) { + console.error( + `WARNING: could not install ${target.shell} completions at ${destination}: ${err.message}\n` + + ` Once that is resolved, run: icp completions ${target.shell} > ${destination}` + ); + } + } + + return installed; +} + +module.exports = { installCompletions }; diff --git a/npm/icp-cli/postinstall.js b/npm/icp-cli/postinstall.js index 7a94a9fdd..480be1b33 100644 --- a/npm/icp-cli/postinstall.js +++ b/npm/icp-cli/postinstall.js @@ -7,6 +7,8 @@ const fs = require('fs'); const path = require('path'); +const { installCompletions } = require('./completions'); + const platform = process.platform; const arch = process.arch; @@ -52,6 +54,8 @@ try { // Ignore permission errors - might not have rights to chmod } + const completions = installCompletions(binaryPath); + console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ ║ @@ -64,6 +68,11 @@ try { ║ ║ ╚═══════════════════════════════════════════════════════════╝ `); + + if (completions.length > 0) { + console.log(`Installed shell completions for: ${completions.join(', ')}. Restart your shell to use them.`); + } + console.log('Completions for other shells: see `icp completions --help`.'); } else { console.log(` ╔═══════════════════════════════════════════════════════════╗ From 79ef93c4cbcfab8c378cc453a9ea269b5bd486fb Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 7 Aug 2026 08:01:47 -0700 Subject: [PATCH 2/6] . From 0f5161f93bbf02d82b81477ce40129169c642a21 Mon Sep 17 00:00:00 2001 From: Adam Spofford <93943719+adamspofford-dfinity@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:03:21 -0700 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- npm/icp-cli/postinstall.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/npm/icp-cli/postinstall.js b/npm/icp-cli/postinstall.js index 480be1b33..83365d75c 100644 --- a/npm/icp-cli/postinstall.js +++ b/npm/icp-cli/postinstall.js @@ -54,7 +54,9 @@ try { // Ignore permission errors - might not have rights to chmod } - const completions = installCompletions(binaryPath); + const completions = process.env.npm_config_global === 'true' + ? installCompletions(binaryPath) + : []; console.log(` ╔═══════════════════════════════════════════════════════════╗ From a29d834e18b098a27f4fcf16e89389ed70a1d950 Mon Sep 17 00:00:00 2001 From: Adam Spofford <93943719+adamspofford-dfinity@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:03:53 -0700 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- npm/icp-cli/completions.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/npm/icp-cli/completions.js b/npm/icp-cli/completions.js index dfdec8299..3185f21dc 100644 --- a/npm/icp-cli/completions.js +++ b/npm/icp-cli/completions.js @@ -16,7 +16,14 @@ function completionTargets(home) { { shell: 'bash', // XDG user directory read by bash-completion v2. - dir: path.join(home, '.local', 'share', 'bash-completion', 'completions'), + dir: path.join( + process.env.BASH_COMPLETION_USER_DIR || + path.join( + process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), + 'bash-completion' + ), + 'completions' + ), file: 'icp' }, { From ae9e1f085d07c23e5e1e3604a77f45b98dfe8435 Mon Sep 17 00:00:00 2001 From: Adam Spofford <93943719+adamspofford-dfinity@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:04:33 -0700 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- npm/icp-cli/completions.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/npm/icp-cli/completions.js b/npm/icp-cli/completions.js index 3185f21dc..ff3ed9117 100644 --- a/npm/icp-cli/completions.js +++ b/npm/icp-cli/completions.js @@ -28,10 +28,17 @@ function completionTargets(home) { }, { shell: 'fish', - dir: path.join(home, '.config', 'fish', 'completions'), + dir: path.join( + process.env.XDG_CONFIG_HOME || path.join(home, '.config'), + 'fish', + 'completions' + ), file: 'icp.fish', // Only if fish is actually configured; fish creates this itself otherwise. - requires: path.join(home, '.config', 'fish') + requires: path.join( + process.env.XDG_CONFIG_HOME || path.join(home, '.config'), + 'fish' + ) }, { shell: 'zsh', From 807f2b9f50e0850b6a582d05986041d3576000c9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 7 Aug 2026 08:44:35 -0700 Subject: [PATCH 6/6] fix --- npm/icp-cli/completions.js | 42 ++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/npm/icp-cli/completions.js b/npm/icp-cli/completions.js index ff3ed9117..f11f4cc52 100644 --- a/npm/icp-cli/completions.js +++ b/npm/icp-cli/completions.js @@ -11,34 +11,40 @@ const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); +/** + * The user location bash-completion v2 loads from. `BASH_COMPLETION_USER_DIR` + * is a colon-separated list searched in order, so its first entry is the one to + * write to. + */ +function bashCompletionUserDir(home) { + const configured = (process.env.BASH_COMPLETION_USER_DIR || '') + .split(':') + .find((dir) => dir !== ''); + if (configured) { + return configured; + } + const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'); + return path.join(dataHome, 'bash-completion'); +} + function completionTargets(home) { + const fishConfig = path.join( + process.env.XDG_CONFIG_HOME || path.join(home, '.config'), + 'fish' + ); + return [ { shell: 'bash', - // XDG user directory read by bash-completion v2. - dir: path.join( - process.env.BASH_COMPLETION_USER_DIR || - path.join( - process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), - 'bash-completion' - ), - 'completions' - ), + dir: path.join(bashCompletionUserDir(home), 'completions'), file: 'icp' }, { shell: 'fish', - dir: path.join( - process.env.XDG_CONFIG_HOME || path.join(home, '.config'), - 'fish', - 'completions' - ), + dir: path.join(fishConfig, 'completions'), file: 'icp.fish', // Only if fish is actually configured; fish creates this itself otherwise. - requires: path.join( - process.env.XDG_CONFIG_HOME || path.join(home, '.config'), - 'fish' - ) + requires: fishConfig }, { shell: 'zsh',