From 1b53a9cd1318ba2164943dd5e3a94d2b512aceb6 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 6 Mar 2026 06:19:57 -0800 Subject: [PATCH 1/4] Candid assist command --- crates/icp-cli/src/commands/candid/build.rs | 213 +++++++++++++++++++ crates/icp-cli/src/commands/candid/mod.rs | 9 + crates/icp-cli/src/commands/canister/call.rs | 69 ++---- crates/icp-cli/src/commands/mod.rs | 3 + crates/icp-cli/src/main.rs | 7 + docs/reference/cli.md | 44 ++++ 6 files changed, 290 insertions(+), 55 deletions(-) create mode 100644 crates/icp-cli/src/commands/candid/build.rs create mode 100644 crates/icp-cli/src/commands/candid/mod.rs diff --git a/crates/icp-cli/src/commands/candid/build.rs b/crates/icp-cli/src/commands/candid/build.rs new file mode 100644 index 00000000..a0ba6a72 --- /dev/null +++ b/crates/icp-cli/src/commands/candid/build.rs @@ -0,0 +1,213 @@ +use anyhow::{Context as _, bail}; +use candid::Principal; +use candid::types::{Type, TypeInner}; +use candid::{TypeEnv, types::Function}; +use candid_parser::assist; +use candid_parser::utils::CandidSource; +use clap::{ArgGroup, Args, ValueEnum}; +use ic_agent::Agent; +use icp::context::{Context, GetCanisterIdError, GetCanisterIdForEnvError}; +use icp::prelude::*; +use icp::store_id::LookupIdError; +use std::io::Write; + +use crate::commands::args; +use crate::operations::misc::fetch_canister_metadata; + +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +pub(crate) enum OutputFormat { + #[default] + Bin, + Hex, + Candid, +} + +/// Interactively build Candid arguments for a canister method +#[derive(Args, Debug)] +#[command(group(ArgGroup::new("input").required(true).args(["canister", "candid_file"])))] +pub(crate) struct BuildArgs { + #[command(flatten)] + pub(crate) cmd_args: args::OptionalCanisterCommandArgs, + + /// Name of canister method to build arguments for. + /// If not provided, an interactive prompt will be launched. + pub(crate) method: Option, + + /// Output file path. Pass `-` for stdout. + #[arg(short, long)] + pub(crate) output: PathBuf, + + /// Output format. + #[arg(long, default_value = "bin")] + pub(crate) format: OutputFormat, + + /// Optionally provide a local Candid file describing the canister interface, + /// instead of looking it up from canister metadata. + #[arg(short = 'c', long)] + pub(crate) candid_file: Option, +} + +pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow::Error> { + let selections = args.cmd_args.selections(); + + let interface = if let Some(path) = &args.candid_file { + // the canister is optional if the candid file is provided + load_candid_file(path)? + } else { + let agent = ctx + .get_agent( + &selections.identity, + &selections.network, + &selections.environment, + ) + .await?; + // otherwise, look up the canister + let cid = match ctx + .get_canister_id( + &selections.canister.expect("required by arg group"), + &selections.network, + &selections.environment, + ) + .await + { + Ok(cid) => cid, + Err(GetCanisterIdError::GetCanisterIdForEnv { + source: + GetCanisterIdForEnvError::CanisterIdLookup { + source, + canister_name, + environment_name, + }, + }) if matches!(*source, LookupIdError::IdNotFound { .. }) => { + bail!( + "Canister {canister_name} has not been deployed in environment {environment_name}. This command requires an active deployment to reference" + ); + } + Err(e) => return Err(e).context("failed to look up canister ID"), + }; + let Some(interface) = get_candid_type(&agent, cid).await else { + bail!("could not fetch Candid interface from canister {cid}"); + }; + interface + }; + + let method = if let Some(method) = &args.method { + method.clone() + } else { + pick_method(&interface, "Select a method")? + }; + + let Some(func) = interface.get_method(&method) else { + bail!("method `{method}` not found in the canister's Candid interface"); + }; + + let context = assist::Context::new(interface.env.clone()); + eprintln!("Build arguments for `{method}`:"); + let arguments = assist::input_args(&context, &func.args)?; + + let bytes = arguments + .to_bytes_with_types(&interface.env, &func.args) + .context("failed to serialize Candid arguments")?; + + if args.output.as_str() == "-" { + match args.format { + OutputFormat::Bin => { + std::io::stdout().write_all(&bytes)?; + } + OutputFormat::Hex => { + println!("{}", hex::encode(&bytes)); + } + OutputFormat::Candid => { + println!("{arguments}"); + } + } + } else { + let path = &args.output; + match args.format { + OutputFormat::Bin => { + icp::fs::write(path, &bytes)?; + } + OutputFormat::Hex => { + icp::fs::write_string(path, &hex::encode(&bytes))?; + } + OutputFormat::Candid => { + icp::fs::write_string(path, &format!("{arguments}\n"))?; + } + } + _ = ctx.term.write_line(&format!("Written to {path}")); + } + + Ok(()) +} + +/// Interactively pick a method from a canister's Candid interface. +pub(crate) fn pick_method( + interface: &CanisterInterface, + prompt: &str, +) -> Result { + let methods: Vec<&str> = interface.methods().collect(); + if methods.is_empty() { + bail!("the canister's Candid interface has no methods"); + } + let selection = dialoguer::Select::new() + .with_prompt(prompt) + .items(&methods) + .default(0) + .interact()?; + Ok(methods[selection].to_string()) +} + +/// Gets the Candid type of a method on a canister by fetching its Candid interface. +/// +/// This is a best effort function: it will succeed if +/// - the canister exposes its Candid interface in its metadata; +/// - the IDL file can be parsed and type checked in Rust parser; +/// - has an actor in the IDL file. If anything fails, it returns None. +pub(crate) async fn get_candid_type( + agent: &Agent, + canister_id: Principal, +) -> Option { + let candid_interface = fetch_canister_metadata(agent, canister_id, "candid:service").await?; + let candid_source = CandidSource::Text(&candid_interface); + let (type_env, ty) = candid_source.load().ok()?; + let actor = ty?; + Some(CanisterInterface { + env: type_env, + ty: actor, + }) +} + +/// Loads a canister's Candid interface from a local `.did` file. +pub(crate) fn load_candid_file(path: &Path) -> Result { + let candid_source = CandidSource::File(path.as_std_path()); + let (type_env, ty) = candid_source + .load() + .with_context(|| format!("failed to load Candid file {path}"))?; + let actor = ty.with_context(|| format!("Candid file {path} does not define a service"))?; + Ok(CanisterInterface { + env: type_env, + ty: actor, + }) +} + +pub(crate) struct CanisterInterface { + pub(crate) env: TypeEnv, + pub(crate) ty: Type, +} + +impl CanisterInterface { + pub(crate) fn methods(&self) -> impl Iterator { + let ty = if let TypeInner::Class(_, t) = &*self.ty.0 { + t + } else { + &self.ty + }; + let TypeInner::Service(methods) = &*ty.0 else { + unreachable!("check_prog should verify service type") + }; + methods.iter().map(|(name, _)| name.as_str()) + } + pub(crate) fn get_method<'a>(&'a self, method_name: &'a str) -> Option<&'a Function> { + self.env.get_method(&self.ty, method_name).ok() + } +} diff --git a/crates/icp-cli/src/commands/candid/mod.rs b/crates/icp-cli/src/commands/candid/mod.rs new file mode 100644 index 00000000..69be3b71 --- /dev/null +++ b/crates/icp-cli/src/commands/candid/mod.rs @@ -0,0 +1,9 @@ +use clap::Subcommand; + +pub(crate) mod build; + +/// Candid encoding utilities +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + Build(build::BuildArgs), +} diff --git a/crates/icp-cli/src/commands/canister/call.rs b/crates/icp-cli/src/commands/canister/call.rs index 1b6e8892..0b9547ae 100644 --- a/crates/icp-cli/src/commands/canister/call.rs +++ b/crates/icp-cli/src/commands/canister/call.rs @@ -1,12 +1,9 @@ use anyhow::{Context as _, anyhow, bail}; -use candid::types::{Type, TypeInner}; use candid::{Encode, IDLArgs, Nat, Principal, TypeEnv, types::Function}; use candid_parser::assist; use candid_parser::parse_idl_args; -use candid_parser::utils::CandidSource; use clap::{Args, ValueEnum}; use dialoguer::console::Term; -use ic_agent::Agent; use icp::context::Context; use icp::fs; use icp::manifest::InitArgsFormat; @@ -16,7 +13,8 @@ use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; use std::io::{self, Write}; use tracing::warn; -use crate::{commands::args, operations::misc::fetch_canister_metadata}; +use crate::commands::args; +use crate::commands::candid::build::{get_candid_type, load_candid_file, pick_method}; /// How to interpret and display the call response blob. #[derive(Debug, Clone, Copy, Default, ValueEnum)] @@ -79,6 +77,11 @@ pub(crate) struct CallArgs { /// How to interpret and display the response. #[arg(long, short, default_value = "auto")] pub(crate) output: CallOutputMode, + + /// Optionally provide a local Candid file describing the canister interface, + /// instead of looking it up from canister metadata. + #[arg(short = 'c', long)] + pub(crate) candid_file: Option, } pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::Error> { @@ -99,22 +102,16 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E ) .await?; - let candid_types = get_candid_type(&agent, cid).await; + let candid_types = if let Some(path) = &args.candid_file { + Some(load_candid_file(path)?) + } else { + get_candid_type(&agent, cid).await + }; let method = if let Some(method) = &args.method { method.clone() } else if let Some(interface) = &candid_types { - // Interactive method selection using candid assist - let methods: Vec<&str> = interface.methods().collect(); - if methods.is_empty() { - bail!("the canister's Candid interface has no methods"); - } - let selection = dialoguer::Select::new() - .with_prompt("Select a method to call") - .items(&methods) - .default(0) - .interact()?; - methods[selection].to_string() + pick_method(interface, "Select a method to call")? } else { bail!( "method name was not provided and could not fetch candid type to assist method selection" @@ -313,48 +310,10 @@ pub(crate) fn print_candid_for_term(term: &mut Term, args: &IDLArgs) -> io::Resu Ok(()) } -/// Gets the Candid type of a method on a canister by fetching its Candid interface. -/// -/// This is a best effort function: it will succeed if -/// - the canister exposes its Candid interface in its metadata; -/// - the IDL file can be parsed and type checked in Rust parser; -/// - has an actor in the IDL file. If anything fails, it returns None. -async fn get_candid_type(agent: &Agent, canister_id: Principal) -> Option { - let candid_interface = fetch_canister_metadata(agent, canister_id, "candid:service").await?; - let candid_source = CandidSource::Text(&candid_interface); - let (type_env, ty) = candid_source.load().ok()?; - let actor = ty?; - Some(CanisterInterface { - env: type_env, - ty: actor, - }) -} - -struct CanisterInterface { - env: TypeEnv, - ty: Type, -} - -impl CanisterInterface { - fn methods(&self) -> impl Iterator { - let ty = if let TypeInner::Class(_, t) = &*self.ty.0 { - t - } else { - &self.ty - }; - let TypeInner::Service(methods) = &*ty.0 else { - unreachable!("check_prog should verify service type") - }; - methods.iter().map(|(name, _)| name.as_str()) - } - fn get_method<'a>(&'a self, method_name: &'a str) -> Option<&'a Function> { - self.env.get_method(&self.ty, method_name).ok() - } -} - #[cfg(test)] mod tests { use super::*; + use candid_parser::utils::CandidSource; #[test] fn typed_decoding_preserves_record_field_names() { diff --git a/crates/icp-cli/src/commands/mod.rs b/crates/icp-cli/src/commands/mod.rs index dee9107b..423199fe 100644 --- a/crates/icp-cli/src/commands/mod.rs +++ b/crates/icp-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ use clap::Subcommand; pub(crate) mod args; pub(crate) mod build; +pub(crate) mod candid; pub(crate) mod canister; pub(crate) mod cycles; pub(crate) mod deploy; @@ -20,6 +21,8 @@ pub(crate) mod token; pub(crate) enum Command { Build(build::BuildArgs), #[command(subcommand)] + Candid(candid::Command), + #[command(subcommand)] Canister(canister::Command), #[command(subcommand)] Cycles(cycles::Command), diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 46b387a6..b22ea853 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -185,6 +185,13 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E // Build Command::Build(args) => commands::build::exec(ctx, &args).await?, + // Candid + Command::Candid(cmd) => match cmd { + commands::candid::Command::Build(args) => { + commands::candid::build::exec(ctx, &args).await? + } + }, + // Canister Command::Canister(cmd) => match cmd { commands::canister::Command::Call(args) => { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5ab7a519..6cf8c140 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6,6 +6,8 @@ This document contains the help content for the `icp` command-line program. * [`icp`↴](#icp) * [`icp build`↴](#icp-build) +* [`icp candid`↴](#icp-candid) +* [`icp candid build`↴](#icp-candid-build) * [`icp canister`↴](#icp-canister) * [`icp canister call`↴](#icp-canister-call) * [`icp canister create`↴](#icp-canister-create) @@ -74,6 +76,7 @@ This document contains the help content for the `icp` command-line program. ###### **Subcommands:** * `build` — Build canisters +* `candid` — Candid encoding utilities * `canister` — Perform canister operations against a network * `cycles` — Mint and manage cycles * `deploy` — Deploy a project to an environment @@ -112,6 +115,46 @@ Build canisters +## `icp candid` + +Candid encoding utilities + +**Usage:** `icp candid ` + +###### **Subcommands:** + +* `build` — Interactively build Candid arguments for a canister method + + + +## `icp candid build` + +Interactively build Candid arguments for a canister method + +**Usage:** `icp candid build [OPTIONS] --output > [METHOD]` + +###### **Arguments:** + +* `` — Name or principal of canister to target. When using a name an environment must be specified +* `` — Name of canister method to build arguments for. If not provided, an interactive prompt will be launched + +###### **Options:** + +* `-n`, `--network ` — Name or URL of the network to target, conflicts with environment argument +* `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--identity ` — The user identity to run this command as +* `-o`, `--output ` — Output file path. Pass `-` for stdout +* `--format ` — Output format + + Default value: `bin` + + Possible values: `bin`, `hex`, `candid` + +* `-c`, `--candid-file ` — Optionally provide a local Candid file describing the canister interface, instead of looking it up from canister metadata + + + ## `icp canister` Perform canister operations against a network @@ -193,6 +236,7 @@ Make a canister call - `hex`: Print raw response as hex +* `-c`, `--candid-file ` — Optionally provide a local Candid file describing the canister interface, instead of looking it up from canister metadata From 6a5a18052089814d48c2078db1825cbe9d545034 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 6 Mar 2026 13:33:09 -0800 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20245f23..10065b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: `icp candid build` allows using the Candid-assist wizard from `icp canister call` to construct a precompiled argument file * feat: Leaving off the method name parameter in `icp canister call` prompts you with an interactive list of methods * fix: Correct templating of special HTML characters in recipes From 80b07a069224d8c6acbba0e59e0277cde59daa0e Mon Sep 17 00:00:00 2001 From: Adam Spofford <93943719+adamspofford-dfinity@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:58:08 -0700 Subject: [PATCH 3/4] suggestion Co-authored-by: raymondk --- crates/icp-cli/src/commands/candid/build.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp-cli/src/commands/candid/build.rs b/crates/icp-cli/src/commands/candid/build.rs index a0ba6a72..960644e6 100644 --- a/crates/icp-cli/src/commands/candid/build.rs +++ b/crates/icp-cli/src/commands/candid/build.rs @@ -86,7 +86,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow:: Err(e) => return Err(e).context("failed to look up canister ID"), }; let Some(interface) = get_candid_type(&agent, cid).await else { - bail!("could not fetch Candid interface from canister {cid}"); + bail!("Could not fetch Candid interface from `candid:service` metadata section of canister {cid}"); }; interface }; From 68f2f7381af5d5378a6187365b8f9d3d418eb3f9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 9 Mar 2026 07:33:56 -0700 Subject: [PATCH 4/4] fmt --- crates/icp-cli/src/commands/candid/build.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/icp-cli/src/commands/candid/build.rs b/crates/icp-cli/src/commands/candid/build.rs index 960644e6..202935ad 100644 --- a/crates/icp-cli/src/commands/candid/build.rs +++ b/crates/icp-cli/src/commands/candid/build.rs @@ -86,7 +86,9 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow:: Err(e) => return Err(e).context("failed to look up canister ID"), }; let Some(interface) = get_candid_type(&agent, cid).await else { - bail!("Could not fetch Candid interface from `candid:service` metadata section of canister {cid}"); + bail!( + "Could not fetch Candid interface from `candid:service` metadata section of canister {cid}" + ); }; interface };