// Command-line parsing.
//
// DEVIATION FROM THE PLAN: this is hand-rolled rather than built on
// `moonbitlang/core/argparse`. Three properties of the reference CLI's contract drove
// that:
//
// * commander's `.allowUnknownOption()` silently drops unrecognised options;
// * its variadic `` options take several values per occurrence;
// * the leading run of non-option tokens is a `[target...]` positional that is
// itself a command line (`node build/index.js --method ...`).
//
// Normalising all three would take a pre-pass as long as this file, after which
// argparse would only be contributing conflict checks. Worse, its `ArgError` is `priv`,
// so parse failures could not carry the reference's exact messages -- and those
// messages are part of the contract we are matching (see docs/reference-behaviour.md
// section 3). Hand-rolling keeps the error text under our control.
///|
/// Options that take a value.
let value_options : Array[String] = [
"--catalog", "--config", "--server", "--method", "--tool-name", "--tool-arg", "--tool-args-json",
"--uri", "--prompt-name", "--prompt-args", "--log-level", "--cwd", "--transport",
"--server-url", "--header", "--metadata", "--tool-metadata", "--connect-timeout",
"--format", "--client-config", "--client-id", "--client-secret", "--client-metadata-url",
"--callback-url", "--wait-for-auth", "-e",
// subscriptions/listen
"--watch-resource", "--max-events", "--listen-timeout",
// tasks extension
"--task-id", "--input-responses", "--max-polls",
]
///|
/// Options that take several values per occurrence, mirroring commander's
/// ``: `--tool-arg a=1 b=2` sets both.
let variadic_options : Array[String] = [
"--tool-arg", "--prompt-args", "--header", "--metadata", "--tool-metadata", "--watch-resource",
]
///|
/// Boolean flags.
let flag_options : Array[String] = [
"--app-info", "--content-info", "--use-stored-auth", "--stored-auth-only", "--relogin",
"--list-stored-auth", "--print-handoff", "--help", "-h", "--version", "-V",
// subscriptions/listen
"--watch-tools", "--watch-prompts", "--watch-resources", "--follow-task",
]
///|
/// Everything the CLI was asked to do.
pub struct Args {
/// The ad-hoc target: a command line, or a URL.
target : Array[String]
/// Repeatable options, in order of appearance.
multi : Map[String, Array[String]]
/// Single-valued options; last occurrence wins, as commander does.
single : Map[String, String]
flags : Map[String, Bool]
}
///|
pub fn Args::flag(self : Self, name : String) -> Bool {
self.flags.get(name).unwrap_or(false)
}
///|
pub fn Args::opt(self : Self, name : String) -> String? {
self.single.get(name)
}
///|
pub fn Args::list(self : Self, name : String) -> Array[String] {
self.multi.get(name).unwrap_or([])
}
///|
/// Raised for anything that makes the command line unusable. Maps to exit 1.
pub(all) suberror UsageError {
UsageError(String)
} derive(Debug)
///|
/// See the note on `CatalogError`: `Show` is what makes the message survive being
/// caught as a generic `Error`.
pub impl Show for UsageError with fn output(self, logger) {
let UsageError(m) = self
logger.write_string(m)
}
///|
/// Parse argv (without the program name).
///
/// The target/option split follows the reference: with an explicit `--`, everything
/// BEFORE it is the target and everything after is options; otherwise the target is the
/// leading run of tokens that do not start with `-`. That ordering is what lets
/// `inspector-cli node server.js --method tools/list` work without quoting.
pub fn parse_args(argv : Array[String]) -> Args raise UsageError {
let (target, rest) = split_target(argv)
let multi : Map[String, Array[String]] = Map([])
let single : Map[String, String] = Map([])
let flags : Map[String, Bool] = Map([])
let mut i = 0
while i < rest.length() {
let tok = rest[i]
if !tok.has_prefix("-") {
// A stray positional after the options began. commander would treat it as an
// excess argument; we ignore it, as `.allowExcessArguments` does.
i = i + 1
continue
}
// Support --name=value as well as --name value.
let (name, inline_value) = match tok.find("=") {
Some(eq) if tok.has_prefix("--") =>
(tok[:eq].to_owned(), Some(tok[eq + 1:].to_owned()))
_ => (tok, None)
}
if flag_options.contains(name) {
flags[name] = true
i = i + 1
continue
}
if !value_options.contains(name) {
// Unknown option: drop it, and drop a following value-looking token, exactly
// as commander's allowUnknownOption does.
i = i + 1
if inline_value is None &&
i < rest.length() &&
!rest[i].has_prefix("-") &&
!is_known(rest[i]) {
i = i + 1
}
continue
}
// Collect this option's value(s).
let values : Array[String] = []
match inline_value {
Some(v) => {
values.push(v)
i = i + 1
}
None => {
i = i + 1
if variadic_options.contains(name) {
// Consume until the next option token.
while i < rest.length() && !rest[i].has_prefix("-") {
values.push(rest[i])
i = i + 1
}
} else if i < rest.length() {
values.push(rest[i])
i = i + 1
}
}
}
if values.is_empty() {
raise UsageError("option '\{name}' requires a value")
}
if variadic_options.contains(name) || name == "-e" {
let existing = multi.get(name).unwrap_or([])
for v in values {
existing.push(v)
}
multi[name] = existing
} else {
single[name] = values[values.length() - 1]
}
}
{ target, multi, single, flags }
}
///|
fn is_known(tok : String) -> Bool {
value_options.contains(tok) || flag_options.contains(tok)
}
///|
/// Split argv into the ad-hoc target and the option tokens.
fn split_target(argv : Array[String]) -> (Array[String], Array[String]) {
// An explicit `--` means: target first, options after. Note this is the reverse of
// the usual shell convention, and matches the reference CLI.
for i, tok in argv {
if tok == "--" {
return (argv[:i].to_owned(), argv[i + 1:].to_owned())
}
}
let mut n = 0
while n < argv.length() && !argv[n].has_prefix("-") {
n = n + 1
}
(argv[:n].to_owned(), argv[n:].to_owned())
}
// --- Value coercion ----------------------------------------------------------
///|
/// Parse a `key=value` pair, JSON-coercing the value.
///
/// This is what makes `--tool-arg count=1` a number and `--tool-arg name=hello` a
/// string: the value is offered to the JSON parser first and falls back to a string.
pub fn parse_kv(pair : String) -> (String, Json) raise UsageError {
guard pair.find("=") is Some(eq) else {
raise UsageError("Invalid parameter format: \{pair}. Use key=value format.")
}
let key = pair[:eq].to_owned()
let value = pair[eq + 1:].to_owned()
if key == "" || value == "" {
raise UsageError("Invalid parameter format: \{pair}. Use key=value format.")
}
let coerced = @json.parse(value) catch { _ => Json::string(value) }
(key, coerced)
}
///|
/// Parse a `Name: Value` header pair. Split on the FIRST colon so a URL in the value
/// survives.
pub fn parse_header(pair : String) -> (String, String) raise UsageError {
guard pair.find(":") is Some(colon) else {
raise UsageError(
"Invalid header format: \{pair}. Use \"HeaderName: Value\" format.",
)
}
let name = pair[:colon].trim(chars=" ").to_owned()
let value = pair[colon + 1:].trim(chars=" ").to_owned()
if name == "" {
raise UsageError(
"Invalid header format: \{pair}. Use \"HeaderName: Value\" format.",
)
}
(name, value)
}
///|
/// Collect repeated `key=value` options into a JSON object.
pub fn collect_kv(pairs : Array[String]) -> Map[String, Json] raise UsageError {
let out : Map[String, Json] = Map([])
for p in pairs {
let (k, v) = parse_kv(p)
out[k] = v
}
out
}
///|
/// Metadata values are carried as strings: a non-string coerced value is re-encoded
/// rather than stringified structurally, so `{"a":1}` stays JSON and not "[object ...]".
pub fn collect_meta(
pairs : Array[String],
) -> Map[String, Json] raise UsageError {
let out : Map[String, Json] = Map([])
for p in pairs {
let (k, v) = parse_kv(p)
out[k] = match v {
String(_) => v
_ => Json::string(v.stringify())
}
}
out
}