Skip to content

cosmicz/clime

Repository files navigation

clime

Build real CLI tools in pure Emacs Lisp. Declare commands, options, and args in a single clime-app form — clime handles parsing, --help generation, error messages, and dispatch.

Getting Started

Write an Elisp file using the clime DSL:

;;; greet.el --- A greeting CLI  -*- lexical-binding: t; -*-

;;; Code:

(require 'clime)

(clime-app greet
  :version "1.0.0"
  :help "A friendly greeter."

  (clime-option shout ("--shout" "-s") :bool t
    :help "SHOUT THE GREETING")

  (clime-command hello
    :help "Say hello to someone"
    (clime-arg name :help "Person to greet")
    (clime-handler (ctx)
      (clime-let ctx (name shout)
        (let ((msg (format "Hello, %s!" name)))
          (if shout (upcase msg) msg)))))

  (clime-command goodbye
    :help "Say goodbye"
    (clime-arg name :help "Person to bid farewell")
    (clime-handler (ctx)
      (clime-let ctx (name)
        (format "Goodbye, %s!" name)))))

(provide 'greet)
;;; greet.el ends here

Run quickstart to add the entrypoint boilerplate and shebang:

path/to/clime-make.el quickstart greet.el

./greet.el hello world        # => Hello, world!
./greet.el --shout hello world # => HELLO, WORLD!
./greet.el --help
./greet.el hello --help

How the shebang works

clime-make.el init prepends a two-line polyglot header that’s valid as both shell script and Elisp:

#!/bin/sh
":"; CLIME_ARGV0="$0" exec emacs --batch -Q \
  -L "$(dirname "$0")" \
  --eval "(setq load-file-name \"$0\")" \
  --eval "(with-temp-buffer \
    (insert-file-contents load-file-name) \
    (setq lexical-binding t) \
    (goto-char(point-min)) \
    (condition-case nil \
      (while t(eval(read(current-buffer))t)) \
      (end-of-file nil)))" \
  -- "$@" # clime-sh!:v1 -*- mode: emacs-lisp; lexical-binding: t; -*-

Line 2 is both a shell no-op (":") and the Emacs invocation. It uses a read/eval loop that explicitly sets lexical-binding to t, since the ":" prefix on line 2 prevents Emacs from recognizing the -*- cookie during normal file loading. The cookie is still useful: editors use it to detect the major mode when opening the file for editing.

The # clime-sh!:vN tag lets init detect and update existing shebangs without clobbering newer formats. CLIME_ARGV0 preserves the original script name for usage output.

So ./myapp.el hello world just works from your shell.

Features

  • Declarative DSL — one form defines your entire CLI
  • Subcommands and nested groups (myapp repo add)
  • Auto-generated --help and --version at every level
  • Boolean flags, count flags (-vvv), repeatable options (--tag a --tag b)
  • Negatable flags (--color / --no-color) with ternary state
  • Mutually exclusive options (:mutex)
  • Option dependencies (:requires) — coupled or one-way
  • Paired option groups (:zip) — matched cardinality with zipped alist output
  • Collapsed short flags (-abc expands to -a -b -c)
  • --flag=value syntax
  • Choices validation (:choices '("json" "csv")), including lazy :choices functions
  • Value separator (:separator "," splits --tags=a,b,c into a list)
  • Custom value transforms (:coerce) and conformers (:conform)
  • Type coercion (string, integer, number)
  • Default values
  • Command aliases (:aliases (i))
  • Command aliases for nested paths (clime-alias-for start (agents start))
  • Hidden commands and options (:hidden t)
  • Deprecation warnings (:deprecated) with migration hints
  • Environment variables with auto-derived or per-option names
  • Stdin via - sentinel
  • Output formats with accumulation, custom envelopes, and streaming (clime-output-format)
  • Epilog text for extra help content
  • Category sections in help output (options, commands, inline groups)
  • Terminal-width-aware help wrapping
  • Rest args (:nargs :rest)
  • Invocable groups (group with its own handler)
  • Inline groups (:inline t) — promote children to parent level
  • Ancestor option propagation — group options available to all descendants
  • Setup hook (:setup) with two-pass parse for config loading
  • Parameter templates (clime-defopt, clime-defarg) with :from inheritance
  • clime-params-plist and clime-param — context param accessors
  • Interactive transient UI (clime-invoke) — auto-generated menus from app definitions
  • clime-reload — hot-reload all modules during development
  • Single-file distribution via bundle command

Options

Boolean flags

Boolean flags consume no value. Use :bool t (shorthand for :nargs 0):

(clime-option force ("--force" "-f") :bool t
  :help "Skip confirmation")

Count flags

Count flags increment on each occurrence:

(clime-option verbose ("-v" "--verbose") :count t
  :help "Increase verbosity")
;; -v → 1, -vv → 2, -vvv → 3

Repeatable options

Repeatable options collect values into a list:

(clime-option tag ("--tag" "-t") :multiple t
  :help "Add tag")
;; --tag dev --tag ci → ("dev" "ci")

Default values

Default values are used when an option isn’t provided:

(clime-option registry ("--registry" "-r")
  :help "Registry to use"
  :default "default")

Value separator

Split a single value into a list by a delimiter. Implies :multiple t:

(clime-option tags ("--tags" "-t") :separator ","
  :help "Comma-separated tags")
;; --tags=dev,ci → ("dev" "ci")

This also affects env var parsing: MYAPP_TAGS=dev,ci produces the same result.

Required options

Mark an option as :required t to enforce that it must be provided. Unlike positional args (which are required by default), options are optional by default — :required makes them mandatory:

(clime-option token ("--token") :required t
  :help "API authentication token")
Error: Missing required option --token for myapp deploy

This is especially useful with :env-prefix — the option can be satisfied via an environment variable instead of the command line:

MYAPP_TOKEN=secret ./myapp.el deploy   # --token satisfied via env

Negatable flags

Use :negatable t to auto-generate --no-X variants for boolean flags. This implies boolean (no :bool t needed):

(clime-option color ("--color") :negatable t :default t
  :help "Colorize output")

Help output:

Options:
  --color / --no-color  Colorize output

The --no-X form explicitly sets the param to nil, enabling handlers to distinguish three states — unset, enabled, and disabled:

(clime-handler (ctx)
  (pcase (clime-param ctx 'color 'auto)
    ('auto (if (isatty) "color" "plain"))  ; unset → auto-detect
    ('t    "color")                         ; --color
    (_     "plain")))                       ; --no-color

All long flags on the option get negated variants. Short flags (-c) and flags already starting with --no- are not double-negated.

Mutually exclusive options

Use :mutex to declare options that cannot be used together. Options sharing the same :mutex symbol are mutually exclusive — at most one may be set per invocation:

(clime-option format-json ("--json") :bool t :mutex 'output-format)
(clime-option format-csv  ("--csv")  :bool t :mutex 'output-format)
(clime-option format-text ("--text") :bool t :mutex 'output-format)

Using more than one signals an error:

Error: Options --json, --csv are mutually exclusive

Mutex validation runs after env vars are applied, so conflicts from environment variables are caught too. When any member of a mutex group is set (via CLI or env), defaults on sibling options are suppressed.

Option dependencies

Use :requires to declare that an option depends on other options. When the option is set, all required options must also be present:

;; Bidirectional: both must be used together
(clime-option skip ("--skip") :requires '(reason))
(clime-option reason ("--reason") :requires '(skip))

;; One-way: --output-file needs --format, but --format works alone
(clime-option output-file ("--output-file") :requires '(format))
(clime-option format ("--format"))

Using --skip without --reason signals an error:

Error: --skip requires --reason

An option can require multiple others: :requires '(a b c) means all three must be set. Env-var-set values satisfy the requirement; defaults do not.

Paired option groups

Use :zip to declare options that must be used the same number of times. Values are paired by position and stored as a list of alists in ctx under the group name. Implies :multiple t:

(clime-option skip ("--skip") :zip 'skip-reason)
(clime-option reason ("--reason") :zip 'skip-reason)
myapp --skip SPEC --reason "no spec" --skip TEST --reason "no test"
(clime-handler (ctx)
  (clime-ctx-get ctx 'skip-reason)
  ;; => (((skip . "SPEC") (reason . "no spec"))
  ;;     ((skip . "TEST") (reason . "no test")))

  ;; Individual lists still available:
  (clime-ctx-get ctx 'skip)    ;; => ("SPEC" "TEST")
  (clime-ctx-get ctx 'reason)) ;; => ("no spec" "no test")

Unequal counts signal an error:

Error: Zip group options must be used the same number of times: --skip (2), --reason (1)

Combine with :required t on any member to require at least one pair.

Parsing

Collapsed short flags

Single-character flags can be collapsed: -abc expands to -a -b -c. This works when all flags in the group are boolean. A trailing value-taking flag consumes the next argument:

./myapp.el -vvf show 123   # -v -v -f show 123

--flag=value and --

Options accept --flag=value syntax:

./myapp.el show --format=json 123

Use -- to stop option parsing — everything after is treated as positional arguments:

./myapp.el show -- --not-a-flag

Value pipeline

Each option and arg value flows through a multi-stage pipeline. The stages are ordered so that each step receives the right kind of input:

raw string → :type → :choices → :coerce → env vars → :conform → :mutex → defaults
   pass 1 (immediate, per-token)       │     pass 2 (after all input collected)
                                       │
                              :setup hook runs here
  • :type — convert the raw string to a typed value. Accepts 'integer, 'number, or 'string (default). Runs first so that :choices can compare typed values:
    (clime-option level ("--level") :type 'integer :choices '(1 2 3))
        
  • :choices — validate against an allowed set. Runs after :type so the comparison uses typed values. Accepts a literal list or a function (lazy; deferred to pass 2):
    (clime-option format ("--format") :choices '("json" "table" "csv")
      :help "Output format")
        

    Invalid values produce a clear error:

    Error: Invalid value "xml" for --format (choose from: json, table, csv)
        
  • :coerce — arbitrary transform after validation. Runs after :choices so you validate against user-friendly values, then transform for the handler:
    (clime-option env ("--env") :choices '("dev" "prod") :coerce #'upcase
      :help "Deploy target")
    ;; user types "dev", choices validates "dev", handler gets "DEV"
        

    Also useful for standalone transforms:

    (clime-option path ("--path") :coerce #'expand-file-name
      :help "File path (expanded)")
        
  • :conform — pass-2 validation and normalization. Runs after env vars are applied, so it catches values from both CLI and environment. Defaults skip conforming — they’re developer-authored and should already be valid. Must return the conformed value or signal an error:
    (clime-option id ("--id")
      :conform (lambda (val)
                 (unless (string-match-p "^[a-z0-9]\\{7\\}$" val)
                   (error "Invalid ID format: %s" val))
                 (upcase val))
      :help "Resource ID (7-char alphanumeric)")
        

The key distinction between :coerce and :conform is when they run: :coerce runs immediately in pass 1 (CLI input only), while :conform runs in pass 2 (CLI + env var input, after the :setup hook). Use :coerce for simple transforms, :conform for cross-source validation.

Environment variables

Set :env-prefix on the app to auto-derive env var names from options:

(clime-app greet
  :version "1.0.0"
  :help "A friendly greeter."
  :env-prefix "GREET"
  ...)

Now --shout can also be set via GREET_SHOUT=1.

For individual options, override the env var name with :env:

(clime-option token ("--token") :env "API_TOKEN"
  :help "Auth token")

Boolean flags accept 1~/~true~/~yes and 0~/~false~/~no. Multiple options split on comma: MYAPP_TAGS=dev,ci becomes ("dev" "ci").

Arguments

Positional arguments

Declare positional arguments with clime-arg:

(clime-command show
  :help "Show a resource"
  (clime-arg id :help "Resource ID")
  (clime-handler (ctx)
    (clime-let ctx (id)
      (format "Showing %s" id))))

Arguments are required by default. Use :required nil for optional args.

Rest args

Collect all remaining positional arguments into a list:

(clime-command run
  :help "Run a script"
  (clime-arg script :help "Script name")
  (clime-arg args :nargs :rest :required nil
    :help "Arguments passed to the script")
  (clime-handler (ctx)
    (clime-let ctx (script args)
      (format "Running %s with %s" script args))))

Stdin

Use - as an argument value to read from stdin:

echo "World" | ./greet.el hello -

Commands & Groups

Subcommands

Define subcommands with clime-command:

(clime-command install
  :help "Install a package"
  (clime-arg name :help "Package name")
  (clime-handler (ctx)
    (clime-let ctx (name) (format "Installing %s" name))))

Aliases

Commands can have aliases:

(clime-command install
  :help "Install a package"
  :aliases (i)
  ...)

Now both myapp install foo and myapp i foo work. Aliases are symbols or strings.

Command aliases for nested paths

Use clime-alias-for to expose a nested command at a higher level without duplicating its definition:

(clime-group agents
  :help "Manage agents"
  (clime-command start
    :help "Start an agent"
    (clime-arg name :help "Agent name")
    (clime-option prompt ("--prompt") :help "Initial prompt")
    (clime-handler (ctx) (do-start ctx))))

(clime-group shortcuts :inline t :category "Shortcuts"
  (clime-alias-for start (agents start)
    :help "Start an agent (shortcut)"))

Now myapp start foo works identically to myapp agents start foo. The alias copies args, options, and handler from the target at init time. You can override :help, :aliases, :hidden, and :category on the alias. When :help is omitted, it inherits from the target.

Use :defaults to preset option values (user can still override):

(clime-alias-for show-csv (report show)
  :help "Show report as CSV"
  :defaults '((format . "csv")))

Use :vals to lock option values (removed from CLI and help):

(clime-alias-for deploy-ci (deploy run)
  :help "Deploy to CI"
  :vals '((target . "ci")))

Nested groups

Organize related commands under a group:

(clime-group repo
  :help "Manage repositories"

  (clime-command add
    :help "Add a repository"
    (clime-arg name :help "Repository name")
    (clime-arg url :help "Repository URL")
    (clime-handler (ctx)
      (clime-let ctx (name url)
        (format "Added %s%s" name url))))

  (clime-command remove
    :help "Remove a repository"
    :aliases (rm)
    (clime-arg name :help "Repository name")
    (clime-handler (ctx)
      (clime-let ctx (name) (format "Removed %s" name)))))

Inline groups

Mark a group with :inline t to promote its children to the parent level. Both the group prefix and the short form work:

(clime-group repo
  :inline t
  :help "Manage repositories"

  (clime-command add
    :help "Add a repository"
    (clime-arg name :help "Repository name")
    (clime-handler (ctx)
      (clime-let ctx (name) (format "Added %s" name))))

  (clime-command remove
    :help "Remove a repository"
    (clime-arg name :help "Repository name")
    (clime-handler (ctx)
      (clime-let ctx (name) (format "Removed %s" name)))))
./myapp.el add foo       # works (promoted)
./myapp.el repo add foo  # also works (direct)

In --help, inline group children appear at the parent level. The group name itself is hidden from the command listing. If a parent has its own child with the same name, the parent’s child takes priority.

Invocable groups

A group can have its own :handler that runs when no subcommand is given:

(clime-group config
  :help "View or modify configuration"

  (clime-handler (_ctx)
    "Configuration:\n  registry = default\n  timeout = 30s")

  (clime-command set
    :help "Set a config value"
    (clime-arg key :help "Config key")
    (clime-arg value :help "New value")
    (clime-handler (ctx)
      (clime-let ctx (key value)
        (format "Set %s = %s" key value)))))

myapp config shows the overview; myapp config set key val sets a value.

Hidden commands and options

Mark a command or option as :hidden t to omit it from --help while keeping it callable:

(clime-command debug
  :help "Dump internal state"
  :hidden t
  (clime-handler (ctx) ...))

(clime-option trace ("--internal-trace") :bool t
  :help "Enable tracing" :hidden t)

Deprecation warnings

Mark commands or options as :deprecated to emit a warning when used. The value can be t (generic) or a string (migration hint):

(clime-option old-flag ("--old") :bool t
  :deprecated "Use --new instead"
  :help "Legacy option")

(clime-command migrate
  :deprecated "Use 'upgrade' instead"
  :help "Old migration path"
  (clime-handler (ctx) ...))

Help output shows the deprecation annotation:

Options:
  --old             Legacy option (deprecated: Use --new instead)

Commands:
  migrate           Old migration path (deprecated: Use 'upgrade' instead)

At runtime, using a deprecated item prints a warning to stderr:

Warning: --old is deprecated. Use --new instead
Warning: migrate is deprecated. Use 'upgrade' instead

Combine :hidden t with :deprecated for silent deprecation: hidden from help but still warns when used.

Help & Output

Help

--help and -h are recognized at every level. Both orderings work:

./myapp.el install --help   # help for install
./myapp.el --help install   # same thing
./myapp.el --help           # app-level help

Usage errors include a hint:

Error: Missing required argument <name>
Try 'myapp install --help' for more information.

Multiline :help strings are truncated to the first line in command listings, and shown in full in detailed command help.

Help text wraps to the terminal width, auto-detected from the COLUMNS environment variable (fallback 80, minimum 40). Override with the clime-help-width variable.

Epilog

Add free-form text after the auto-generated help with :epilog:

(clime-app myapp
  :version "1.0"
  :help "My application."
  :epilog "Examples:
  myapp install foo --tag dev
  myapp config set timeout 60"
  ...)

Commands and groups support :epilog too.

Categories in help

Use :category on options, commands, or inline groups to organize --help into named sections. Items sharing a category appear under one heading; uncategorized items fall back to “Options:” or “Commands:”.

Options with :category:

(clime-option author ("--author" "-a")
  :help "Filter by author"
  :category "Filter")

(clime-option since ("--since")
  :help "Only after DATE"
  :category "Filter")

These appear under a “Filter:” section instead of “Options:”.

Commands with :category:

(clime-command search
  :help "Search items" :category "Filter")
(clime-command list
  :help "List items" :category "Filter")
(clime-command show
  :help "Show details")   ;; uncategorized → "Commands:"

Options and commands with the same :category share a section.

Inline groups with :category apply it to all their children and options:

(clime-group admin
  :inline t :category "Admin"
  :help "Administrative commands"

  (clime-option admin-token ("--admin-token")
    :help "Admin API token")

  (clime-command status
    :help "Show system status"))

This renders as:

Admin:
  Administrative commands

  --admin-token VALUE    Admin API token
  status                 Show system status

Nested inline groups compose category paths — an inner group with :category "Config" inside an outer :category "Admin" produces “Admin / Config:” in help.

Output formats

Declare output formats with clime-output-format. Each format is a CLI flag that controls how output is encoded. The form derives from clime-option, so it supports :mutex, :hidden, :category, etc.

(clime-app myapp
  :version "1.0"

  (clime-output-format json ("--json")
    :help "Output as JSON")

  ...)
./myapp.el show --json 123

In JSON mode, clime--active-output-format is bound to the format struct and handler return values are JSON-encoded in a {success, data} envelope. Handlers calling clime-output per-item get automatic accumulation — multiple items become a JSON array, a single item is a bare object. Errors reported via clime-output-error accumulate and take priority over items and retval at finalize time.

Custom envelopes

Use :finalize to control the JSON envelope shape:

(clime-output-format json ("--json")
  :finalize (lambda (items retval errors)
              (cond
               (errors `((error . ,(car errors))))
               (t (let ((data (or (and items (vconcat items)) retval)))
                    (when data `((result . ,data) (status . "ok"))))))))

The finalize function receives (items retval errors) where items is the list of accumulated clime-output calls, retval is the handler’s return value, and errors is a list of error strings (may be nil).

Streaming (NDJSON)

For streaming output, set :streaming t on the format. This bypasses the accumulator — each clime-output call emits one JSON object per line immediately:

(clime-output-format json ("--json") :streaming t)

Handlers can also call clime-output-stream explicitly to bypass the accumulator in a non-streaming format.

Legacy: :json-mode

:json-mode t on clime-app still works as shorthand for (clime-output-format json ("--json") :help "Output as JSON"). Prefer the explicit form for new code.

Advanced

Ancestor option propagation

Options declared at any level are automatically available to all descendants. A --verbose flag on the app or on a group can be used from any child command:

(clime-app myapp
  :version "1.0"

  (clime-option verbose ("-v" "--verbose") :count t
    :help "Increase verbosity")

  (clime-group repo
    :help "Manage repositories"
    (clime-option registry ("--registry") :help "Registry URL")

    (clime-command add
      :help "Add a repo"
      (clime-arg name :help "Repo name")
      ;; Handler can access verbose (from app) and registry (from group)
      (clime-handler (ctx)
        (clime-let ctx (name verbose registry)
          (format "Adding %s (verbose=%s, registry=%s)" name verbose registry))))))

In --help, inherited options appear in a separate “Global Options:” section.

Setup hook

The :setup hook runs after pass-1 parsing (all values extracted from argv) but before pass-2 finalization (dynamic :choices validation, env vars, defaults). This lets apps load configuration that influences dynamic option values:

(clime-app myapp
  :version "1.0"
  :env-prefix "MYAPP"
  :setup (lambda (app result)
           (let ((dir (plist-get (clime-parse-result-params result) 'config-dir)))
             (my-load-config (or dir "~/.myapp"))))

  (clime-option config-dir ("--config-dir")
    :help "Config directory")

  (clime-option level ("--level")
    :help "Log level"
    :choices #'my-configured-levels)  ; resolved after setup

  (clime-command run
    :help "Run the app"
    (clime-handler (ctx) "running")))

Static :choices (literal lists) are validated immediately in pass 1. Dynamic :choices (functions) are deferred to pass 2 after the setup hook has run.

Parameter templates

Define reusable option or arg specs with clime-defopt and clime-defarg, then reference them with :from:

;; Define a template once
(clime-defopt verified-id
  :type 'string
  :conform #'my-ensure-id
  :help "A verified resource ID")

;; Reuse across commands — explicit values override the template
(clime-command copy
  :help "Copy a resource"
  (clime-option src ("--src") :from verified-id :multiple t)
  (clime-option dst ("--dst") :from verified-id)
  (clime-handler (ctx) ...))

(clime-command move
  :help "Move a resource"
  (clime-option src ("--src") :from verified-id)
  (clime-option dst ("--dst") :from verified-id)
  (clime-handler (ctx) ...))

clime-defopt expands to a defvar with a clime--opt- prefix (e.g. clime--opt-verified-id). The :from keyword handles the prefixing transparently — you write the bare name in both places.

Templates cannot contain :name or :flags (those are per-instance). All DSL shorthands (:bool t, :separator) work in templates.

clime-defarg works the same way for positional args:

(clime-defarg file-path
  :type 'string
  :coerce #'expand-file-name
  :help "Path to a file")

(clime-command cat
  :help "Print file contents"
  (clime-arg path :from file-path)
  (clime-handler (ctx) ...))

Context accessors

clime-let destructures context params into let-bindings:

(clime-handler (ctx)
  (clime-let ctx (name verbose (tags tag))
    (format "name=%s verbose=%s tags=%s" name verbose tags)))

clime-params-plist converts context params to a keyword plist:

(clime-handler (ctx)
  ;; All params as keyword plist
  (my-function (clime-params-plist ctx))
  ;; => (:verbose 3 :package "foo" :force t)

  ;; Selected params only (nil values omitted)
  (my-other-function (clime-params-plist ctx 'package 'force)))

clime-param gets a param value with a default for absent params. Unlike clime-ctx-get, it distinguishes between explicitly nil and absent — useful for negatable flags:

(clime-param ctx 'color 'auto)
;; => t       when --color was passed
;; => nil     when --no-color was passed
;; => 'auto   when neither was passed

Interactive

Interactive UI (clime-invoke)

Any clime-app can be invoked interactively from Emacs via clime-invoke, which auto-generates a transient.el menu:

(require 'clime-invoke)
(clime-invoke my-app)

Groups become nested transient prefixes (press a key to drill down, q to go back). Options become infixes (switches and value inputs). Leaf commands show a “Run” action that collects infix values, prompts for positional args, calls clime-run, and displays output in the *clime-output* buffer.

Requires the transient package (ships with Emacs 29+).

Hot-reload (clime-reload)

clime-reload force-reloads all clime modules in dependency order, including optional modules (clime-invoke, clime-make) if already loaded. Useful during development to pick up changes without restarting Emacs.

DSL Reference

All command-like forms (clime-command, clime-group, clime-app) accept: :help (:doc is an alias), :aliases, :hidden, :deprecated, :epilog, :category.

All parameter forms (clime-option, clime-arg) accept: :type, :choices, :coerce, :conform, :default, :help, :deprecated, :from.

MacroPurposeDistinctive arguments
clime-appDefine a CLI application:version, :env-prefix, :setup
clime-commandDefine a subcommand(uses shared command slots)
clime-groupNest related commands:inline
clime-optionDeclare a named option:bool, :count, :multiple, :separator, :negatable, :required, :requires, :mutex, :zip, :env, :category
clime-argDeclare a positional argument:nargs, :required
clime-defoptReusable option templateAny option slot except :name, :flags
clime-defargReusable arg templateAny arg slot except :name
clime-alias-forAlias for a nested commandPath to target command, :defaults, :vals
clime-output-formatDeclare an output format:finalize, :streaming, :encoder, :error-handler, plus all option slots
clime-handlerAttach a handler(ctx) — receives the parse context
clime-letDestructure context in a handlerctx (var1 var2 ...) — binds option/arg values

:bool t is shorthand for :nargs 0 (boolean flag, DSL only).

See examples/pkm.el for a comprehensive demo covering all major features, and examples/cloq.el for an org-ql CLI showcasing output formats, aliases, and JSON mode.

Bundling & Distribution

clime-make.el provides four commands:

CommandPurpose
initAdd polyglot shebang to make a script executable
scaffoldInsert ;;; Entrypoint: boilerplate
quickstartscaffold + init in one shot (auto env vars)
bundleCombine multiple source files into one

Making a script executable (init)

For a single-file app that lives alongside clime:

./clime-make.el init myapp.el
./myapp.el --help

init prepends a polyglot shebang, sets the executable bit, and adds -L flags so Emacs can find clime at runtime.

Common options:

# Script in a subdirectory that needs the parent on the load path
./clime-make.el init -R .. examples/myapp.el

# Script's own dir on the load path (for local requires)
./clime-make.el init --self-dir myapp.el

# Bundled/vendored app that doesn't need the clime load path
./clime-make.el init --standalone --self-dir dist/myapp.el

# Set env vars in the shebang
./clime-make.el init --env CLIME_MAIN_APP=myapp dist/myapp.el

Re-running init on a file that already has a clime shebang updates it in place. init refuses to overwrite a non-clime shebang (use --force) or to downgrade a newer shebang format version.

Inserting entrypoint boilerplate (scaffold)

scaffold detects the clime-app symbol in a file and inserts an ;;; Entrypoint: section with the clime-main-script-p guard:

./clime-make.el scaffold myapp.el

Before:

(clime-app myapp :version "1.0" ...)
(provide 'myapp)
;;; myapp.el ends here

After:

(clime-app myapp :version "1.0" ...)
(provide 'myapp)
;;; Entrypoint:
(when (clime-main-script-p 'myapp)
  (clime-run-batch myapp))
;;; myapp.el ends here

The guard symbol comes from (provide 'FEATURE) if present, falling back to the clime-app symbol. If ;;; Entrypoint: already exists, scaffold skips without error. If there is no ;;; ... ends here marker, the entrypoint is appended at the end.

One-command quickstart (quickstart)

quickstart composes scaffold + init and auto-detects CLIME_MAIN_APP:

./clime-make.el quickstart myapp.el
./myapp.el --help

This is the fastest way to go from a source file to a runnable script. quickstart accepts all of init’s flags (--self-dir, -R, -L, --standalone, --force, -e):

./clime-make.el quickstart --self-dir -e MY_VAR=hello myapp.el

An explicit -e CLIME_MAIN_APP=custom overrides the auto-detected value.

Building a single-file distribution (bundle)

For multi-file projects, bundle concatenates source files into one:

./clime-make.el bundle -o dist/myapp.el \
    --provide myapp \
    src/myapp-core.el src/myapp-commands.el src/myapp.el

The output provides the feature ('myapp) and can be used as a library via (require 'myapp).

Adding an entrypoint (--main)

To make the bundle also work as a standalone CLI, pass --main:

./clime-make.el bundle -o dist/myapp.el \
    --provide myapp --main myapp-main.el \
    src/myapp-core.el src/myapp-commands.el src/myapp.el

./clime-make.el init --standalone --self-dir \
    --env CLIME_MAIN_APP=myapp dist/myapp.el

The main file is a short script — it requires the app and runs it:

;;; myapp-main.el --- Entrypoint for myapp  -*- lexical-binding: t; -*-
;;; Code:
(require 'myapp)
(clime-run-batch myapp)
;;; myapp-main.el ends here

Its code is wrapped in a clime-main-script-p guard inside the bundle, so it only executes when CLIME_MAIN_APP matches the feature name — not when loaded as a library. The --main file must not contain an ;;; Entrypoint: marker.

Entrypoint markers in source files

Source files can contain an ;;; Entrypoint: section that separates library code from standalone entrypoint code:

;;; myapp.el --- My app  -*- lexical-binding: t; -*-
;;; Code:
(require 'clime)
(clime-app myapp :version "1.0" ...)
(provide 'myapp)
;;; Entrypoint:
(clime-run-batch myapp)
;;; myapp.el ends here

The bundler extracts only the library section (;;; Code: to ;;; Entrypoint:), stripping the entrypoint automatically. This lets each source file run standalone during development while bundling cleanly. Files without the marker are extracted up to ;;; ... ends here as usual.

clime-run-batch is a no-op in interactive Emacs (emits a warning instead of calling kill-emacs), so loading a script with (require 'myapp) during development is safe.

If (clime-run-batch ...) appears in a source file’s library section (above the marker or without one), bundle signals an error — move it below ;;; Entrypoint:.

Typical project layout

myapp/
  vendor/clime/          # clime source (git clone or submodule)
  src/
    myapp-core.el        # library code
    myapp-commands.el    # commands
    myapp.el             # app definition + (provide 'myapp)
  myapp-main.el          # standalone entrypoint
  dist/
    myapp.el             # bundled output (single file)
  Makefile

A minimal Makefile:

CLIME := vendor/clime/clime-make.el
SRCS  := src/myapp-core.el src/myapp-commands.el src/myapp.el

dist/myapp.el: $(SRCS) myapp-main.el
	$(CLIME) bundle -o $@ --provide myapp --main myapp-main.el $(SRCS)
	$(CLIME) init --standalone --self-dir --env CLIME_MAIN_APP=myapp $@

Installation

Git clone / submodule

# Clone directly
git clone https://github.com/cosmicz/clime vendor/clime

# Or as a submodule
git submodule add https://github.com/cosmicz/clime vendor/clime

Then set up your app file:

vendor/clime/clime-make.el quickstart myapp.el

This inserts the entrypoint boilerplate, prepends a polyglot shebang, and makes the file executable.

package-vc (Emacs 29+)

(package-vc-install "https://github.com/cosmicz/clime")

Vendoring (single file)

Build a single-file bundle and copy it into your project:

cd clime/
make dist
cp dist/clime.el /path/to/myapp/

Your app just needs (require 'clime) and -L pointing to the directory containing the bundled file.

Prior Art

clime draws inspiration from several projects:

  • commander.el — command-line parsing for Emacs Lisp with a declarative API
  • clingon — a feature-rich Common Lisp command-line parser with subcommands, options, and completions
  • orgstrap — self-bootstrapping Org files with embedded Elisp, a different take on making Emacs code executable

Development

make test              # run tests (SELECT=pattern to filter)
make lint              # byte-compile warnings + checkdoc
make compile           # byte-compile
make dist              # build single-file dist/clime.el
make clean             # remove build artifacts

License

MIT

About

Build real CLI tools in pure Emacs Lisp. Declare commands, options, and args in a single form — clime handles parsing, --help, error messages, and dispatch. ./myapp.el just works.

Resources

License

Contributing

Stars

Watchers

Forks

Packages