{"id":18319855,"url":"https://github.com/bitwalker/artificery","last_synced_at":"2025-04-04T23:08:26.678Z","repository":{"id":50245183,"uuid":"126110464","full_name":"bitwalker/artificery","owner":"bitwalker","description":"A toolkit for creating terminal user interfaces in Elixir","archived":false,"fork":false,"pushed_at":"2021-05-31T15:40:54.000Z","size":1142,"stargazers_count":127,"open_issues_count":4,"forks_count":15,"subscribers_count":7,"default_branch":"master","last_synced_at":"2024-10-30T02:03:51.055Z","etag":null,"topics":["command-line","elixir-lang"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bitwalker.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2018-03-21T02:17:36.000Z","updated_at":"2024-07-31T15:05:47.000Z","dependencies_parsed_at":"2022-09-15T21:10:55.691Z","dependency_job_id":null,"html_url":"https://github.com/bitwalker/artificery","commit_stats":null,"previous_names":[],"tags_count":10,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bitwalker%2Fartificery","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bitwalker%2Fartificery/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bitwalker%2Fartificery/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bitwalker%2Fartificery/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bitwalker","download_url":"https://codeload.github.com/bitwalker/artificery/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246913297,"owners_count":20854007,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["command-line","elixir-lang"],"created_at":"2024-11-05T18:14:30.302Z","updated_at":"2025-04-04T23:08:26.657Z","avatar_url":"https://github.com/bitwalker.png","language":"Elixir","readme":"# Artificery\n\n[![Module Version](https://img.shields.io/hexpm/v/artificery.svg)](https://hex.pm/packages/artificery)\n[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/artificery/)\n[![Total Download](https://img.shields.io/hexpm/dt/artificery.svg)](https://hex.pm/packages/artificery)\n[![License](https://img.shields.io/hexpm/l/artificery.svg)](https://github.com/bitwalker/artificery/blob/master/LICENSE)\n[![Last Updated](https://img.shields.io/github/last-commit/bitwalker/artificery.svg)](https://github.com/bitwalker/artificery/commits/master)\n\nArtificery is a toolkit for generating command line applications. It handles\nargument parsing, validation/transformation, generating help, and provides an\neasy way to define commands, their arguments, and options.\n\n## Installation\n\nJust add Artificery to your deps:\n\n```elixir\ndefp deps do\n  [\n    # You can get the latest version information via `mix hex.info artificery`\n    {:artificery, \"~\u003e x.x\"}\n  ]\nend\n```\n\nThen run `mix deps.get` and you are ready to get started!\n\n## Defining a CLI\n\nLet's assume you have an application named `:myapp`, let's define a module,\n`MyCliModule` which will be the entry point for the command line interface:\n\n```elixir\ndefmodule MyCliModule do\n  use Artificery\nend\n```\n\nThe above will setup the Artificery internals for your CLI, namely it defines\nan entry point for the command line, argument parsing, and imports the macros\nfor defining commands, options, and arguments.\n\n### Commands\n\nLet's add a simple \"hello\" command, which will greet the caller:\n\n```elixir\ndefmodule MyCliModule do\n  use Artificery\n\n  command :hello, \"Says hello\" do\n    argument :name, :string, \"The name of the person to greet\", required: true\n  end\nend\n```\n\nWe've introduced two of the macros Aritificery imports: `command`, for defining\ntop-level and nested commands; and `argument` for defining positional arguments\nfor the current command. **Note**: `argument` can only be used inside of\n`command`, as it applies to the current command being defined, and has no\nmeaning globally.\n\nThis command could be invoked (via escript) like so: `./myapp hello bitwalker`.\nRight now this will print an error stating that the command is defined, but no\nmatching implementation was exported. We define that like so:\n\n```elixir\ndef hello(_argv, %{name: name}) do\n  Artificery.Console.notice \"Hello #{name}!\"\nend\n```\n\n**Note**: Command handlers are expected to have an arity of 2, where the first\nargument is a list of unhandled arguments/options passed on the command line,\nand the second is a map containing all of the formally defined\narguments/options.\n\nThis goes in the same module as the command definition, but you can use\n`defdelegate` to put the implementation elsewhere. The thing to note is that\nthe function needs to be named the same as the command. You can change this\nhowever using an extra parameter to `command`, like so:\n\n```elixir\ncommand :hello, [callback: :say_hello], \"Says hello\" do\n  argument :name, :string, \"The name of the person to greet\", required: true\nend\n```\n\nThe above will invoke `say_hello/2` rather than `hello/2`.\n\n### Command Flags\n\nThere are two command flags you can set currently to alter some of Artificery's\nbehaviour: `callback: atom` and `hidden: boolean`. The former will change the\ncallback function invoked when dispatching a command, as shown above, and the\nlatter, when true, will hide the command from display in the `help` output. You\nmay also apply `:hidden` to options (but not arguments).\n\n### Options\n\nLet's add a `--greeting=string` option to the `hello` command:\n\n```elixir\ncommand :hello, \"Says hello\" do\n  argument :name, :string, \"The name of the person to greet\", required: true\n  option :greeting, :string, \"Sets a different greeting than \\\"Hello \u003cname\u003e\\!\"\"\nend\n```\n\nAnd adjust our implementation:\n\n```elixir\ndef hello(_argv, %{name: name} = opts) do\n  greeting = Map.get(opts, :greeting, \"Hello\")\n  greet(greeting, name)\nend\ndefp greet(greeting, name), do: Artificery.Console.notice(\"#{greeting} #{name}!\")\n```\n\nAnd we're done!\n\n### Subcommands\n\nWhen you have more complex command line interfaces, it is common to divide up\n\"topics\" or top-level commands into subcommands, you see this in things like\nHeroku's CLI, e.g. `heroku keys:add`. Artificery supports this by allowing you\nto nest `command` within another `command`. Artificery is smart about how it\nparses arguments, so you can have options/arguments at the top-level as well as\nin subcommands, e.g. `./myapp info --format=json processes`. The options map\nreceived by the `processes` command will contain all of the options for\ncommands above it.\n\n```elixir\ndefmodule MyCliModule do\n  use Artificery\n\n  command :info, \"Get info about :myapp\" do\n    option :format, :string, \"Sets the output format\"\n\n    command :processes, \"Prints information about processes running in :myapp\"\n  end\n```\n\n**Note**: As you may have noticed above, the `processes` command doesn't have a\n`do` block, because it doesn't define any arguments or options, this form is\nsupported for convenience.\n\n### Global Options\n\nYou may define global options which apply to all commands by defining them\noutside `command`:\n\n```elixir\ndefmodule MyCliModule do\n  use Artificery\n\n  option :debug, :boolean, \"When set, produces debugging output\"\n\n  ...\nend\n```\n\nNow all commands defined in this module will receive `debug: true | false` in their options map,\nand can act accordingly.\n\n### Reusing Options\n\nYou can define reusable options via `defoption/3` or `defoption/4`. These are\neffectively the same as `option/3` and `option/4`, except they do not define an\noption in any context, they are defined abstractly and intended to be used via\n`option/1` or `option/2`, as shown below:\n\n```elixir\ndefoption :host, :string, \"The hostname of the server to connect to\",\n  alias: :h\n\ncommand :ping, \"Pings the host to verify connectivity\" do\n  # With no overridden flags\n  # option :host\n\n  # With overrides\n  option :host, help: \"The host to ping\", default: \"localhost\"\nend\n\ncommand :query, \"Queries the host\" do\n  # Can be shared across commands, even used globally\n  option :host, required: true\n  argument :query, :string, required: true\nend\n```\n\n### Option/Argument Transforms\n\nYou can provide transforms for options or arguments to convert them to the data\ntypes your commands desire as part of the option definition, like so:\n\n```elixir\n# Options\noption :ip, :string, \"The IP address of the host to connect to\",\n  transform: fn raw -\u003e\n    case :inet.parse_address(String.to_charlist(raw)) do\n      {:ok, ip} -\u003e\n        ip\n      {:error, reason} -\u003e\n        raise \"invalid value for --ip, got: #{raw}, error: #{inspect reason}\"\n    end\n  end\n\n# Arguments\nargument :ip, :string, \"The IP address of the host to connect to\",\n  transform: ...\n```\n\nNow the command (and any subcommands) where this option is defined will get a\nparsed IP address, rather than a raw string, allowing you to do the conversion\nin one place, rather than in each command handler.\n\nCurrently this macro supports functions in anonymous form (like in the example\nabove), or one of the following forms:\n\n```elixir\n# Function capture, must have arity 1\ntransform: \u0026String.to_atom/1\n\n# Local function as an atom, must have arity 1\ntransform: :to_ip_address\n\n# Module/function/args tuple, where the raw value is passed as the first argument\n# This form is invoked via `apply/3`\ntransform: {String, :to_char_list, []}\n```\n\n### Pre-Dispatch Handling\n\nFor those cases where you need to perform some action before command handlers\nare invoked, perhaps to apply global behaviour to all commands, start\napplications, or whatever else you may need, Artificery provides a hook for\nthat, `pre_dispatch/3`.\n\nThis is actually a callback defined as part of the `Artificery` behaviour, but\nis given a default implementation. You can override this implementation though\nto provide your own pre-dispatch step.\n\nThe default implementation is basically the following:\n\n```elixir\ndef pre_dispatch(%Artificery.Command{}, _argv, %{} = options) do\n  {:ok, options}\nend\n```\n\nYou can either return `{:ok, options}` or raise an error, there are no other\nchoices permitted. This allows you to extend or filter `options`, handle\nadditional arguments in `argv`, or take action based on the current command.\n\n## Writing Output / Logging\n\nArtificery provides a `Console` module which contains a number of functions for\nlogging or writing output to standard out/standard error. A list of basic\nfunctions it provides is below:\n\n- `configure/1`, takes a list of options which configures the logger, currently the only option is `:verbosity`\n- `debug/1`, writes a debug message to stderr (colored cyan if terminal supports color)\n- `info/1`, writes an info message to stdout (no color)\n- `notice/1`, writes an informational notice to stdout (bright blue)\n- `success/1`, writes a success message to stdout (bright green)\n- `warn/1`, writes a warning to stderr (yellow)\n- `error/1`, writes an error to stderr (red), and also halts/terminates the process with a non-zero exit code\n\nIn addition to writing messages to the terminal, `Console` also provides a way\nto provide a spinner/loading animation while some long-running work is being\nperformed, also supporting the ability to update the message with progress\ninformation.\n\nThe following example shows a trivial example of progress, by simply reading\nfrom a file in a loop, updating the status of the spinner while it reads. There\nare obviously cleaner ways of writing this, but hopefully it is clear what the\ncapabilities are.\n\n```elixir\ndef load_data(_argv, %{path: path}) do\n  alias Artificery.Console\n\n  unless File.exists?(path) do\n    Console.error \"No such file: #{path}\"\n  end\n\n  # A state machine defined as a recursive anonymous function\n  # Each state updates the spinner status and is reflected in the console\n  loader = fn\n    :opening, _size, _bytes_read, _file, loader -\u003e\n      Console.update_spinner(\"opening #{path}\")\n      %{size: size} = File.stat!(path)\n      loader.(:reading, size, 0, File.open!(path), loader)\n\n    :reading, size, bytes_read, file, loader -\u003e\n      progress = Float.round((size / bytes_read) * 100)\n      Console.update_spinner(\"reading..#{progress}%\")\n      case IO.read(file) do\n        :eof -\u003e\n          loader.(:done, size, bytes_read, file, loader)\n\n        {:error, _reason} = err -\u003e\n          Console.update_spinner(\"read error!\")\n          File.close!(file)\n          err\n\n        new_data -\u003e\n          loader.(:reading, size, byte_size(new_data), file, loader)\n      end\n\n    :done, size, bytes_read, file, loader -\u003e\n      Console.update_spinner(\"done! (total bytes read #{bytes_read})\")\n      File.close!(file)\n      :ok\n  end\n\n  results =\n    Console.spinner \"Loading data..\" do\n      loader.(:opening, 0, 0, nil, loader)\n    end\n\n  case results do\n    {:error, reason} -\u003e\n      Console.error \"Failed to load data from #{path}: #{inspect reason}\"\n\n    :ok -\u003e\n      Console.success \"Load complete!\"\n  end\nend\n```\n\n## Handling Input\n\nArtificery exposes some functions for working with interactive user sessions:\n\n- `yes?/1`, asks the user a question and expects a yes/no response, returns a boolean\n- `ask/2`, queries the user for information they need to provide\n\n### Example\n\nLet's shoot for a slightly more amped up `hello` command:\n\n```elixir\ndef hello(_argv, _opts) do\n  name = Console.ask \"What is your name?\", validator: \u0026is_valid_name/1\n  Console.success \"Hello #{name}!\"\nend\n\ndefp is_valid_name(name) when byte_size(name) \u003e 1, do: :ok\ndefp is_valid_name(_), do: {:error, \"You must tell me your name or I can't greet you!\"}\n```\n\nThe above will accept any name more than one character in length, obviously not\nsuper robust, but the general idea is shown here.\n\nThe `ask` function also supports transforming responses, and providing defaults\nin the case where you want to accept blank answers.\n\nCheck the docs for more information!\n\n## Producing An Escript\n\nTo use your newly created CLI as an escript, simply add the following to your\n`mix.exs`:\n\n```elixir\ndefp project do\n  [\n    ...\n    escript: escript()\n  ]\nend\n\n...\n\ndefp escript do\n  [main_module: MyCliModule]\nend\n```\n\nThe `main_module` to use is the module in which you added `use Artificery`,\ni.e. the module in which you defined the commands your application exposes.\n\nFinally, run `mix escript.build` to generate the escript executable. You can\nthen run `./yourapp help` to test it out.\n\n## Using In Releases\n\nIf you want to define the CLI as part of a larger application, and consume it\nvia custom commands in Distillery, it is very straightforward to do. You'll\nneed to define a custom command and add it to your release configuration:\n\n```elixir\n\n# rel/config.exs\n\nrelease :myapp do\n  set commands: [\n    mycli: \"rel/commands/mycli.sh\"\n  ]\nend\n```\n\nThen in `rel/commands/mycli.sh` add the following:\n\n```shell\n#!/usr/bin/env bash\n\nelixir -e \"MyCliModule.main\" -- \"$@\"\n```\n\nSince the code for your application will already be on the path in a release,\nwe simply need to invoke the CLI module and pass in arguments.  We add `--`\nbetween the `elixir` arguments and those provided from the command line to\nensure that they are not treated like arguments to our CLI. Artificery handles\nthis, so you simply need to ensure that you add `--` when invoking via `elixir`\nlike this.\n\nYou can then invoke your CLI via the custom command, for example, `bin/myapp\nmycli help` to print the help text.\n\n## Roadmap\n\n- [ ] Support validators\n\nI'm open to suggestions, just open an issue titled `RFC: \u003cfeature you are requesting\u003e`.\n\n## License\n\nCopyright (c) 2018 Paul Schoenfelder\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbitwalker%2Fartificery","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbitwalker%2Fartificery","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbitwalker%2Fartificery/lists"}