Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

clime

clime is a declarative command framework for Emacs Lisp. Define your commands, options, and arguments once with a structured DSL — then surface them however you want: as a CLI script, interactively from Emacs via clime-invoke, or over a network via clime-serve.

clime handles the plumbing: two-pass argument parsing with type coercion, env var resolution, and validation, auto-generated help, structured output formats, a polyglot shebang for direct shell execution, and a bundler that produces single-file distributions usable as both standalone CLIs and Emacs libraries.

Here’s clime’s own build tool, clime-make (built with clime):

.github/assets/invoke-menu.png

And from the command line — same definition, two surfaces:

.github/assets/cli-help.png

Getting Started

Write an Elisp file using the clime DSL:

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

;;; Code:

(require 'clime)

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

  (clime-opt shout ("--shout" "-s") :bool
    :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

Now surface it. From the shell (run quickstart once to add the 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

Or interactively from Emacs:

(require 'clime-invoke)
(clime-invoke greet)

Installation

Git clone / submodule

Pull the code locally.

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

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

Then set up your app file.

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

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

Download release (single file)

Download clime.el from the latest release. The dist is self-executable — it includes clime-make:

curl -Lo clime.el https://github.com/cosmicz/clime/releases/latest/download/clime.el
chmod +x clime.el
./clime.el quickstart myapp.el

Or use it as a library from Emacs:

(require 'clime)

package-vc (Emacs 29+)

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

Vendoring (build from source)

Build a single-file bundle from a clone:

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

Concepts

App tree

A clime app is a tree of nodes. The root is a clime-app. Interior nodes are clime-group (containing subcommands or nested groups). Leaves are clime-command (with a handler). Options and args attach to any node.

clime-app (root)
  +-- clime-opt (--verbose)          app-level option
  +-- clime-command (install)       leaf command
  |     +-- clime-arg (package)
  |     +-- clime-handler
  +-- clime-group (repo)            branch node
        +-- clime-opt (--registry)      group-level option
        +-- clime-command (add)
        +-- clime-command (remove)

Surfaces

A clime app is surface-agnostic — the same definition drives CLI parsing, interactive menus, and network servers. Each surface translates user input into the values map, validates it through the same pipeline, and calls the handler.

CLI (clime-run-batch)

The default surface. clime-run-batch parses argv, runs the two-pass pipeline, and calls the handler. See Getting Started and Bundling & Distribution for setup.

Interactive (clime-invoke)

Any clime-app can be invoked interactively from Emacs via clime-invoke, which auto-generates a lightweight read-key menu from the app tree. No external dependencies.

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

Groups become nested menus (press a key to drill down, DEL to go back). Options are cycled with -X (prefix dash + key) or set directly with =X (completing-read for choices, read-string for counts). Leaf commands show a “Run” action (RET) that collects values, calls clime-run, and displays output.

Inline validation highlights invalid parameters as you set them. Prefix keys (- / =) re-render the buffer with dimmed non-option keys for visual feedback.

Use :key to override auto-assigned keys:

(clime-command deploy :key "d"
  :help "Deploy to production"
  (clime-opt target ("--target" "-t") :key "t" :required
    :choices '("staging" "production"))
  (clime-handler (ctx) ...))

clime-invoke returns a structured plist for programmatic use:

(let ((result (clime-invoke my-app)))
  (plist-get result :params)  ;; final parameter values
  (plist-get result :exit)    ;; handler exit code (nil if user quit)
  (plist-get result :output)) ;; handler output string (nil if user quit)

Programmatic invocation (:ask, :immediate)

:ask prompts for parameters via minibuffer before showing the menu. :immediate runs the handler directly once all required params are satisfied, without entering the menu loop:

;; Prompt for all required params, then run without menu
(clime-invoke my-app '("deploy") nil :immediate t)

;; Prompt only for specific params
(clime-invoke my-app '("deploy") '(target "staging") :ask '(env))

;; Skip y-or-n-p confirmation when all params are pre-filled
(clime-invoke my-app '("deploy") '(target "prod" env "us-east")
              :immediate 'no-confirm)
:ask valueBehavior
nil (default)No pre-prompting, enters menu directly
tPrompt for all required params
'(param ...)Prompt for listed params only
:immediate valueBehavior
nil (default)Normal menu loop
tRun after ask if satisfied; y-or-n-p if nothing was asked
'no-confirmLike t but skip y-or-n-p confirmation

Server (clime-serve)

Any clime-app can be exposed over HTTP via clime-serve. The app tree IS the router — URL path segments resolve commands via the same dispatch used by the CLI.

(require 'clime-serve)
(clime-serve my-app :port 8080 :host "127.0.0.1")

Returns a server process object. Stop with clime-serve-stop (pass the process, or nil to stop all clime servers).

Mirrors the clime-invoke dispatch shape: :setup and :config hooks run once at startup (two-pass), the tree is deep-copied per request, and values from the request are seeded directly into the values map. No per-request re-initialization.

Routing

URL path segments walk the command tree. Segments matching a child name descend into that child. Non-matching segments are consumed as positional args on the current node, enabling path params:

(clime-app my-app
  (clime-group agent
    (clime-arg id :help "Agent ID" :required t)
    (clime-command start (clime-handler (ctx) ...))
    (clime-command stop  (clime-handler (ctx) ...))))
RequestCommandValues
GET /agent/abc/startagent/startid=abc
GET /agent/xyz/stopagent/stopid=xyz

Parameters

Query string params (GET) and JSON body params (POST) seed option and arg values by name. String values are coerced to the option’s declared :type using the same pipeline as CLI argv. Bare flags (e.g. ?dry-run) normalize to t.

GET /db/migrate?dry-run&limit=100
POST /db/seed  { "file": "data.sql" }

Response mapping

Handler output is captured (same as clime-invoke) and returned as the response body. Exit codes map to HTTP status:

Exit codeStatusMeaning
0200Success
2400Usage error (bad input)
1500Handler error
unknown path404Route not found

Path suffix format selection

When the app declares output formats, URL paths ending with a format extension (e.g. .json, .html) activate that format for the request. The suffix is stripped before routing:

GET /db/migrate.json?dry-run    →  command: db/migrate, format: json
GET /report/summary.html        →  command: report/summary, format: html

Apps without output formats are unaffected — suffixes are treated as literal path segments. The _api introspection endpoints auto-declare their own JSON format, so they return application/json regardless of whether the app itself registers a JSON output format.

Introspection

GET /_routes returns a plain-text listing of available commands with their help strings.

Structured API endpoints are injected as hidden commands at startup:

EndpointReturns
GET /_api/metaApp name, version, and help text
GET /_api/commandsAll visible top-level commands
GET /_api/commands/PATHDetail for a specific command (e.g. db/migrate)

Output uses clime-out, so the active output format drives encoding.

Dependencies

clime-serve requires web-server (Eric Schulte, GPLv3) for the network layer. It is included as a git submodule under lib/web-server/. After cloning, initialize it with:

git submodule update --init

Or use make submodules. clime-serve auto-adds lib/web-server/ to the load-path at require time, so no manual configuration is needed beyond initializing the submodule.

Hot-reload (clime-reload)

clime-reload force-reloads all clime modules in dependency order, including clime.el itself and optional modules (clime-invoke, clime-make, clime-serve) if already loaded. Clears and reapplies face specs so defface changes take effect. Useful during development to pick up changes without restarting Emacs.

Features

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-opt prompt ("--prompt") :help "Initial prompt")
    (clime-handler (ctx) (do-start ctx))))

(clime-group shortcuts :inline :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 to promote its children to the parent level. Both the group prefix and the short form work:

(clime-group repo
  :inline
  :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 to omit it from --help while keeping it callable:

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

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

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-opt old-flag ("--old") :bool
  :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 with :deprecated for silent deprecation: hidden from help but still warns when used.

Options

Options declared at any level are available to all descendants. A --verbose flag on the app can be accessed from any child command. In --help, inherited options appear in a “Global Options:” section.

Boolean flags

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

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

Count flags

Count flags increment on each occurrence:

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

Repeatable options

Repeatable options collect values into a list:

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

A :multiple option also accepts several values after a single flag, space-separated:

./myapp.el run --tag dev ci prod    # → ("dev" "ci" "prod")

This greedy consumption is deliberately conservative: it fires only where it is unambiguous — at a resolving node that has no positional args and no subcommands (a leaf command, or a single-command app/group). It stops at the next option-like token (e.g. --verbose), at --, or at the end of the arguments, and each value still honors :separator, :coerce, and :choices. When the node has positional args, a :rest arg, or subcommands, the single-token form is kept (so --tag dev takes only dev) and you repeat the flag instead. The --tag=dev form is never greedy.

Default values

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

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

Value separator

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

(clime-opt 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 to enforce that it must be provided. Unlike positional args (which are required by default), options are optional by default — :required makes them mandatory. The inverse keyword :optional is also available: on args it means :required nil, on options it’s the default but can be used for clarity:

(clime-opt token ("--token") :required
  :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 to auto-generate --no-X variants for boolean flags. This implies boolean (no :bool needed):

(clime-opt color ("--color") :negatable :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.

A negatable flag with :default t declares that env and config may override the default in both directions. Unlike a plain boolean (where env/config can only enable), a negatable flag can be explicitly disabled without the CLI:

# env (with :env / :env-prefix): 0, false, or no disables
MYAPP_COLOR=0    →  color = nil
MYAPP_COLOR=1    →  color = t
# (unset)        →  color = t   (the :default)
// config file: explicit false disables; JSON null is "not configured"
{ "color": false }   //  color = nil
{ "color": true }    //  color = t
// key absent       //  color = t   (the :default)

Precedence is unchanged: CLI > env > config > default. A plain (non negatable) boolean keeps the “env/config can only enable” semantics — a falsy env or config value leaves it at its default.

Mutually exclusive options

Use clime-mutex to declare options that cannot be used together. The group enforces at-most-one exclusivity and injects the winner’s name as a derived key in ctx:

(clime-mutex output-format
  (clime-opt format-json ("--json") :bool)
  (clime-opt format-csv  ("--csv")  :bool)
  (clime-opt format-text ("--text") :bool))

Using more than one signals an error:

Error: Options json, csv are mutually exclusive

The derived key is available in the handler:

(clime-ctx-get ctx 'output-format) ;; => 'format-json, 'format-csv, etc.

Pass :default to set a fallback when no member is chosen:

(clime-mutex output-format :default 'format-text ...)

Validation runs after env vars are applied, so conflicts from environment variables are caught too. When a truthy member wins, defaults on sibling options are suppressed. Negatable flags (--no-color) count for conflict detection but don’t become winners — they disable themselves without suppressing siblings.

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-opt skip ("--skip") :requires '(reason))
(clime-opt reason ("--reason") :requires '(skip))

;; One-way: --output-file needs --format, but --format works alone
(clime-opt output-file ("--output-file") :requires '(format))
(clime-opt 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 clime-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:

(clime-zip skip-reason
  (clime-opt skip ("--skip"))
  (clime-opt reason ("--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 on any member to require at least one pair.

--flag=value and -- (CLI)

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

Collapsed short flags (CLI)

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

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 :optional (or :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 -

Parsing & Validation

The value pipeline and validation stages apply uniformly across all surfaces. The features below describe CLI-specific parsing behavior and the shared validation pipeline.

Value pipeline

Each option and arg value flows through a multi-stage pipeline split across two passes. CLI args and env vars enter through the same per-value stages; they differ only in when they enter:

                       per-value transform
                  ┌──────────────────────────┐
raw string ──────┤  :type → :choices → :coerce  ├──→ values map
                  └──────────────────────────┘
                             ▲
Pass 1: CLI tokens ──────────┘
                  :setup hook runs here
Pass 2: env vars ────────────┘
        then: :conform (per-param) → node :conform → defaults → required check

Both CLI and env var strings flow through :type:choices:coerce. The difference is timing: CLI values are transformed immediately during pass 1, while env vars are applied in pass 2 (after the :setup hook). :conform runs last, seeing the final value regardless of source.

  • :type — convert the raw string to a typed value. Accepts a type spec: a symbol ('integer, 'number, 'string, 'boolean, 'json, 'file, 'directory, 'path; short aliases 'int, 'num, 'str, 'bool, 'dir), a parameterized form ('(integer :min 1 :max 65535), '(file :must-exist t)), or a composite type ('(member "json" "csv"), '(choice ...)). Runs first so that :choices can compare typed values:
    ;; Simple type
    (clime-opt level ("--level") :type 'integer :choices '(1 2 3))
    
    ;; Parameterized — bounded port number
    (clime-opt port ("--port" "-p") :type '(integer :min 1 :max 65535)
      :help "Listen port")
    
    ;; Member — string enum with invoke completion
    (clime-opt format ("--format" "-f") :type '(member "json" "csv" "table")
      :help "Output format")
    
    ;; Choice — mixed types, first match wins
    (clime-opt timeout ("--timeout") :type '(choice (integer :min 0) (const "off"))
      :help "Timeout in seconds or \"off\"")
        

    Types provide both help text and invoke completion automatically. (member ...) and (const ...) supply :choices for cycling and completing-read. CLI help appends the resolved type description after help text (suppressed when it duplicates choices):

    Options:
      --port VALUE      Listen port (integer 1–65535)
      --format VALUE    Output format [choices: json, csv, table]
      --timeout VALUE   Timeout in seconds or "off" (integer ≥0|"off")
        

    In clime-invoke, the type description is prepended to help text in the desc column. Cycling through a choice type’s member values prompts for free-form input after exhausting the enum. Direct input (=) uses non-strict completion, allowing values from any branch.

    Errors from type parsing are wrapped as usage errors automatically. Define custom types with clime-deftype:

    (clime-deftype port ()
      "TCP port number."
      (clime-resolve-type '(integer :min 1 :max 65535)))
    
    (clime-opt listen ("--listen") :type 'port :help "Listen port")
        
  • :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-opt 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-opt 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-opt 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. Receives (value, param) or just (value) — arity is detected automatically. Must return the conformed value or signal an error:
    (clime-opt id ("--id")
      :conform (lambda (val _param)
                 (unless (string-match-p "^[a-z0-9]\\{7\\}$" val)
                   (error "Invalid ID format: %s" val))
                 (upcase val))
      :help "Resource ID (7-char alphanumeric)")
        

    Node-level :conform (on commands, groups, apps) receives (values, node) where values is the values map, and returns the updated values map. clime-mutex and clime-zip use this mechanism internally.

The key distinction between :coerce and :conform is when they run: :coerce runs immediately when a value is set (per-token in CLI, per-keystroke in invoke), while :conform runs during finalization (after all values are collected, env vars applied, and :setup has run). Use :coerce for simple transforms, :conform for cross-source or cross-param validation.

All four slots chain in order — :type:choices:coerce:conform:

(clime-opt port ("--port" "-p")
  :type 'integer                          ; "8080" → 8080
  :choices '(80 443 8080 8443)            ; validate allowed ports
  :coerce (lambda (n) (format ":%d" n))   ; 8080 → ":8080"
  :conform (lambda (v _p)                 ; cross-check with env
             (when (and (getenv "PROD") (equal v ":80"))
               (error "Port 80 not allowed in PROD"))
             v)
  :help "Listen port")

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-opt config-dir ("--config-dir")
    :help "Config directory")

  (clime-opt 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.

Config file providers

The :config slot accepts a factory function (app result) → provider called between pass-1 and pass-2 (after :setup). The returned provider is a lookup function (command-path param-name) → value that supplies defaults from external config files. Config values sit between env vars and hard defaults in the precedence chain: user > app > env > config > default > conform.

clime ships two built-in provider creators:

  • clime-config-json — reads a JSON file with hierarchical inheritance
  • clime-config-sexp — reads a plist file with the same semantics
(clime-app myapp
  :version "1.0"
  :env-prefix "MYAPP"
  :config (lambda (_app result)
            (clime-config-json
             (clime-parse-result-param result 'config "~/.myapp.json")))

  (clime-opt config ("--config" "-c")
    :help "Path to config file")

  (clime-command serve
    :help "Start the server"
    (clime-opt port ("--port" "-p") :type 'integer
      :help "Listen port")
    (clime-handler (ctx)
      (clime-let ctx (port)
        (message "Serving on %s" (or port 8080))))))

With ~/.myapp.json:

{
  "port": 3000,
  "serve": {
    "port": 8080
  }
}

The JSON provider walks from the most specific path to the root: serve.port (8080) wins over root port (3000) for the serve command. A different command would inherit root port (3000) unless it defines its own override.

Custom providers just need to return the right shape:

:config (lambda (_app _result)
          (lambda (command-path param-name)
            (my-lookup command-path param-name)))

Environment variables

Options opt in to env var resolution with :env (or :env t) for an auto-derived name, or :env "SUFFIX" for an explicit suffix. Set :env-prefix on the app to provide the prefix:

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

  (clime-opt shout ("--shout") :bool :env
    :help "SHOUT THE GREETING"))

Now --shout can also be set via GREET_SHOUT=1. Options without :env are not affected by :env-prefix.

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

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

For option-backed configuration, the canonical pattern is to declare the command-line flag and its env fallback on the same clime-opt. Handlers should read the resolved value from the clime context, not re-read the same setting from getenv after parsing:

(clime-app embead
  :version "1.0"
  :env-prefix "EMBEAD"

  (clime-opt embead-dir ("--embead-dir") :env "DIR"
    :help "Embead directory")
  (clime-opt embead-file ("--embead-file") :env "FILE"
    :help "Embead data file"))

With this shape, ordinary option precedence is flag > env > default. When every source is enabled, the full precedence is CLI/user value > app vals > real process env > .env > config provider > default.

Do not bolt on a later env-first config layer for option-backed settings. A post-parse resolver that calls getenv and overwrites clime’s resolved params can make env values clobber explicit flags. The motivating downstream regression was embead’s --embead-dir / --embead-file settings: they were plain options followed by a later env-first resolver instead of using clime’s :env / :env-prefix machinery.

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

Loading .env files

Apps can load KEY=VALUE pairs from a .env file (dotenv format) via the :dotenv app option. Loaded values populate the process environment for the duration of a single run, so they feed the same :env / :env-prefix machinery described above without any per-option wiring.

(clime-app greet
  :env-prefix "GREET"
  :dotenv t                                  ;; load .env from CWD
  ;; or :dotenv ".env.local"
  ;; or :dotenv '(".env.local" ".env")       ;; earlier file wins per key
  ...)

Precedence (high → low): CLI > app vals > real process env > .env > config provider > default. Real process-environment entries always win over .env values — .env is a fallback, not an override.

Grammar accepted (v1): bare KEY=value, double-quoted (\n~/\r~/~\t~ escapes), single-quoted (literal), leading export tolerated, # comments full-line or trailing on bare values. Missing files are silently skipped; malformed files signal a usage error with file + line number.

Values map

Every surface feeds parameter values into a shared values map — an alist of (NAME . (:value V :source S)) entries that tracks each parameter’s resolved value and provenance (user, app, env, config, default, conform).

For CLI, the pipeline runs in two passes with an optional :setup hook and/or :config factory between them. Pass 1 extracts values from argv (type coercion, static :choices, :coerce) into the values map with source user. Between passes, :setup runs first, then :config creates a provider. Pass 2 merges env vars, applies config values, runs :conform hooks (accumulating errors as :error entries), applies defaults, and checks required params.

Invoke builds the values map interactively (key-by-key), then runs the same pass-2 finalization before calling the handler.

Handlers receive a flat params plist derived from the values map (via clime-values-plist) — they don’t know which surface provided the values.

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.

Option help lines include inline annotations:

  • (type desc) prepended type hint — e.g., (integer 1–65535); suppressed when redundant with choices
  • [$VAR] for options with env var resolution (or [$VAR=value] when the variable is set)
  • (required) for required options with no default
  • [choices: a|b|c] for options with :choices
  • (DEPRECATED) for deprecated options

Examples

Add structured example invocations with :examples. They render as a two-column “Examples:” section in --help (between Global Options and Epilog):

(clime-command show
  :help "Show a resource"
  :examples '(("app show 123" . "Show resource by ID")
              ("app show --all" . "Show all resources")
              "app show --json 123")
  ...)

Each element can be:

  • a cons pair ("invocation" . "description") — two-column
  • a bare string "invocation" — left column only
  • a single-element list ("invocation") — same as bare string

Commands, groups, and apps all support :examples.

Epilog

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

(clime-app myapp
  :version "1.0"
  :help "My application."
  :epilog "See https://example.com for full docs."
  ...)

Commands and groups support :epilog too. For example invocations, prefer :examples over putting them in :epilog — it gives you structured formatting and consistent placement.

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-opt author ("--author" "-a")
  :help "Filter by author"
  :category "Filter")

(clime-opt 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 :category "Admin"
  :help "Administrative commands"

  (clime-opt 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 handler output is encoded:

(clime-app myapp
  :version "1.0"

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

  ...)
./myapp.el show 123          # text output (default)
./myapp.el show --json 123   # JSON output

Handlers use clime-out to emit data without knowing the active format:

(clime-handler (ctx)
  (clime-out '((name . "foo") (status . "ok")) :text "foo  ok")
  (clime-out '((name . "bar") (status . "err")) :text "bar  err"))

In text mode, the :text string is emitted (with newline). In JSON mode, items accumulate — multiple items become a JSON array, a single item is a bare object, wrapped in a {success, data} envelope. Errors reported via clime-out-error take priority over items at finalize time.

The output API:

FunctionBehavior
clime-outEmit one item (accumulated or streamed, per format)
clime-out-errorReport an error (accumulated or handled immediately)
clime-out-emitEncode and emit immediately, bypassing the accumulator
clime-out-formatReturns the active format name ('json, 'text, etc.)
clime-out-textEmit text-only decoration (no-op for structured formats)

clime-output-format derives from clime-option, so it supports :hidden, :category, etc.

Group-level formats

clime-output-format may be declared on a clime-group as well as on the app, so a subtree can offer its own formats. Two or more formats on the same node are automatically made mutually exclusive, exactly as at the app level:

(clime-group reports
  :help "Reporting commands"
  (clime-output-format json ("--json") :help "JSON output")
  (clime-output-format csv ("--csv")  :help "CSV output")
  ...)

A format flag declared on a group is honored for any command in that group’s subtree; app-level formats keep precedence when the same flag is declared at both levels.

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-out 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 on the format. This bypasses the accumulator — each clime-out call emits one JSON object per line immediately:

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

Handlers can also call clime-out-emit 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.

Handlers

Context accessors

Every clime-handler receives a clime-context struct. Use these accessors to extract param values:

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)))

The (tags tag) form binds the local variable tags to the param named tag — useful when the option name differs from the variable you want.

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

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)))

Void handlers (clime-nil-handler)

Handlers that perform side effects but don’t produce output data must return nil to prevent the runner from encoding their return value. clime-nil-handler eliminates the trailing nil boilerplate:

;; Before: explicit nil to suppress output
(clime-handler (ctx)
  (my-side-effectful-fn ctx)
  nil)

;; After: wraps a function reference
(clime-nil-handler #'my-side-effectful-fn)

;; Or with inline body
(clime-nil-handler (ctx)
  (do-stuff ctx)
  (do-more ctx))

After-execute hooks

The :after-execute slot on clime-app registers hooks that fire after every handler invocation — CLI, interactive, and HTTP surfaces all go through clime-run--execute.

Each hook receives three arguments: the context, the integer exit code, and the wall-clock duration in seconds:

(clime-app myapp
  :version "1.0"
  :after-execute (lambda (ctx exit-code duration)
                   (message "Finished in %.2fs (exit %d)"
                            duration exit-code))
  ...)

Multiple hooks are supported (pass a list). Errors in hooks are caught and reported via message — they never propagate or alter the exit code. The clime-context struct includes a start-time slot (set automatically before handler invocation) available to handlers for timeout or progress logic.

Because :after-execute fires from inside clime-run--execute, it only observes invocations that reach handler dispatch. Parse/usage failures, --help, and --version never build a context and so never fire it. When you need to log every attempted invocation, use the unified hook below.

Unified invocation hooks (audit logging)

The :on-invocation slot registers hooks that fire exactly once per invocation across every exit path — successful dispatch, runtime handler errors, parse/usage failures, and --help / --version requests — on all surfaces (CLI clime-run, clime-run-from-values, HTTP clime-serve dispatch, and interactive clime-invoke).

Each hook receives a single clime-invocation-event struct. Fields (read with clime-invocation-event-* accessors):

FieldMeaning
appthe root clime-app
surfacecli, serve, invoke, or run-from-values
phasecompleted, no-handler, help, version, usage-error, runtime-error, not-found (serve 404)
argvraw argv list (CLI surface; nil elsewhere)
pathnode-name path from root to command, when known (requested URL segments for serve not-found)
display-pathuser-facing path (excludes inline groups), when known
paramsresolved params plist, when a context was built
commandresolved clime-command node, when known
contextthe clime-context, or nil for pre-context failures
exit-code0 success / 1 runtime error / 2 usage error
error-typeerror signal symbol, when failed
error-messageerror message string, when failed
start-timeinvocation start (float-time)
durationwhole-invocation seconds (parse → done)
formatactive clime-output-format, or nil for text mode

As with :after-execute, a bare function is normalized to a one-element list, multiple hooks are supported, and hook errors are caught and reported via message — they never propagate or alter the outcome.

The two hooks are independent and may be used together. Key differences: :after-execute fires only for handler dispatch with a handler-scoped duration and does not fire for help/version, while :on-invocation fires for all outcomes (including help/version) with a whole-invocation duration.

Privacy and redaction

The event exposes raw user input — argv, params, and values derived from the environment (env vars, .env files, config providers). These routinely contain secrets: passwords, API tokens, access keys, personal data. Redaction is the logger’s responsibility. clime does not scrub the event; it hands you everything so you can decide what is safe to persist. Before writing params (or argv) to durable storage, mask values whose parameter name looks sensitive, and prefer logging the structured path~/~command over the raw argv when the argv may carry inline credentials.

Recipe: JSONL audit logging

Append one JSON object per invocation to a log file — capturing successes, failures, usage errors, and serve 404s alike — with sensitive parameter values redacted:

(defvar my-app--secret-param-re
  (rx (or "pass" "secret" "token" "key" "auth" "cred"))
  "Parameter names matching this are redacted before logging.")

(defun my-app--redact-params (plist)
  "Return PLIST with sensitive values replaced by \"<redacted>\".
PLIST is the event's `params' — keys are symbols, values are parsed."
  (let (out)
    (while plist
      (let ((k (pop plist)) (v (pop plist)))
        (push (cons k (if (string-match-p my-app--secret-param-re
                                          (symbol-name k))
                          "<redacted>"
                        v))
              out)))
    (nreverse out)))

(defun my-app--log-invocation (ev)
  "Append EV as one redacted JSON line to ~/my-app.log.jsonl."
  (let* ((st (clime-invocation-event-start-time ev))
         (record
          `((time      . ,(format-time-string "%FT%T%z" (seconds-to-time st)))
            (app       . ,(clime-node-name (clime-invocation-event-app ev)))
            (surface   . ,(clime-invocation-event-surface ev))
            (phase     . ,(clime-invocation-event-phase ev))
            ;; structured path is safer to log than raw argv
            (path      . ,(or (clime-invocation-event-display-path ev)
                              (clime-invocation-event-path ev)))
            (command   . ,(string-join
                           (or (clime-invocation-event-display-path ev)
                               (clime-invocation-event-path ev))
                           " "))
            ;; argv may carry inline credentials — redact or omit in
            ;; high-sensitivity deployments
            (argv      . ,(clime-invocation-event-argv ev))
            (params    . ,(my-app--redact-params
                           (clime-invocation-event-params ev)))
            (exit      . ,(clime-invocation-event-exit-code ev))
            (duration  . ,(clime-invocation-event-duration ev))
            ;; nil json-encodes to bare `null'
            (error_type    . ,(clime-invocation-event-error-type ev))
            (error_message . ,(clime-invocation-event-error-message ev)))))
    (with-temp-buffer
      (insert (json-encode record) "\n")
      (append-to-file (point-min) (point-max)
                      (expand-file-name "~/my-app.log.jsonl")))))

(clime-app myapp
  :version "1.0"
  :on-invocation #'my-app--log-invocation
  ...)

Every CLI run, HTTP request (including 404s), and interactive dispatch now leaves one durable JSONL record — successful or not, parsed or not — with secret-looking params masked. Fields absent for a given outcome (e.g. argv on the serve surface, params before a context is built) serialize as JSON null.

Reusable Definitions

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 (lambda (v _p) (my-ensure-id v))
  :help "A verified resource ID")

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

(clime-command move
  :help "Move a resource"
  (clime-opt src ("--src") :from verified-id)
  (clime-opt 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, :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) ...))

Form composition

Define reusable command and group forms that can be composed into an app from separate files or modules:

;; commands.el
(clime-defcommand my-show
  :help "Show a resource"
  (clime-arg id :help "Resource ID")
  (clime-handler (ctx)
    (clime-let ctx (id) (format "Showing %s" id))))

(clime-defgroup my-admin
  :help "Admin commands"
  (clime-command status
    :help "Show system status"
    (clime-handler (_ctx) "OK")))

The macros expand to defvar forms holding the struct value.

Container macros (clime-app, clime-group, clime-command) accept keyword arguments :options, :args, :children, and :output-formats holding runtime list expressions. Keyword-provided items merge with inline DSL forms (keyword items first, then inline):

;; app.el
(require 'commands)  ; provides my-show, my-admin

(clime-app myapp
  :version "1.0"
  :help "My application"
  :children (list my-show my-admin)

  ;; Inline children are appended after keyword children
  (clime-command version
    :help "Show version"
    (clime-handler (_ctx) "1.0")))

This enables cross-file app composition without dropping to raw struct constructors.

Because clime-command accepts :children too, a single inline group defined once with clime-defgroup can be shared verbatim across several commands (and groups) — the canonical way to reuse a common option set:

;; A reusable inline option group, defined once.
(clime-defgroup section-write-opts
  :inline t
  (clime-opt design ("--design") :help "Design section body")
  (clime-opt plan   ("--plan")   :help "Plan section body"))

(clime-app tracker
  :version "1.0"
  ;; A leaf command consumes the shared group via :children.
  (clime-command create
    :help "Create an item"
    :children (list section-write-opts)
    (clime-arg title :help "Item title")
    (clime-handler (ctx) ...))
  ;; A group consumes the SAME value — no redefinition.
  (clime-group mutate
    :inline t
    :children (list section-write-opts)
    (clime-command update
      :help "Update an item"
      (clime-handler (ctx) ...))))

Options in the shared inline group are promoted into each host’s option namespace, so parsing, help, and :requires checking all see them.

DSL Reference

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

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

MacroAliasPurposeDistinctive arguments
clime-appRoot application definition:version, :env-prefix, :dotenv, :setup, :config
clime-commandLeaf command (has handler)All shared command slots
clime-groupBranch node (has children):inline
clime-optclime-optionNamed option (flag or value):bool, :count, :multiple, :separator, :negatable, :required, :optional, :requires, :env, :category
clime-mutexExclusive option groupGroup name, :default, contains clime-opt forms
clime-zipPaired option groupGroup name, contains clime-opt forms
clime-argclime-argumentPositional argument:nargs, :required, :optional
clime-defoptclime-defoptionReusable option templateAny option slot except :name, :flags
clime-defargclime-defargumentReusable arg templateAny arg slot except :name
clime-defcommandComposable command (defvar)Same as clime-command
clime-defgroupComposable group (defvar)Same as clime-group
clime-alias-forAlias for a nested commandTarget path, :defaults, :vals
clime-output-formatOutput format declaration:finalize, :streaming, :encoder, :error-handler, plus all option slots
clime-handlerCommand implementation(ctx) — receives clime-context
clime-nil-handlerHandler that returns nilSame as clime-handler; suppresses output encoding
clime-letDestructure context paramsctx (var1 var2 (alias param) ...)

:bool (or :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 six 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
stripRemove a clime shebang from an Elisp file
serveStart an HTTP server for a clime app file

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.

Pre-load eval injection (--eval, --no-jit)

The launcher runs emacs --batch -Q and then loads your app. Some settings must be in effect before the app’s own source is loaded or compiled, so they cannot live in app code or a runtime hook — they have to be set on the launcher’s command line. init injects --eval forms immediately after -Q and before the load paths and load loop:

# Disable native-comp JIT for the ephemeral process (built-in shorthand).
# Cold runs of an uncompiled app otherwise spawn background libgccjit
# subprocesses; --no-jit suppresses that CPU storm.
./clime-make.el init --no-jit myapp.el

# Inject an arbitrary pre-load form (repeatable, runs left-to-right).
./clime-make.el init --eval '(setq gc-cons-threshold 80000000)' myapp.el

# Combine: --no-jit's form is emitted first, then each --eval in order.
./clime-make.el init --no-jit --eval '(setq foo t)' myapp.el

--no-jit is shorthand for --eval '(setq native-comp-jit-compilation nil)'. Each --eval form is read-validated at init time; an unbalanced or unreadable form is rejected before the file is written. A form must be a single physical line — the launcher is a two-line construct, so a form containing a newline (LF) or carriage return (CR) — which would split line 2 and orphan the format tag — is rejected before the file is written. Put multi-form logic on one line with (progn ...).

With no --eval~/–no-jit~ flags the emitted shebang is byte-identical to before, so existing scripts are unaffected. Injected forms are launcher content (like --env vars and -L paths): they do not change the shebang format version, and strip removes them with the rest of the header.

App-specific hardening that depends on a library the app loads (for example org-persist tuning for an org-backed app) belongs in the app’s own load path, guarded by noninteractive — not in the generic launcher.

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, --eval, --no-jit):

./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 is a single file that provides one feature ('myapp). Use (require 'myapp) to load it — individual module requires (e.g. (require 'myapp-core)) do not work against the bundle.

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/
  lib/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 := lib/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 $@

Stripping a shebang (strip)

strip removes a clime shebang from an Elisp file and clears the executable bit:

./clime-make.el strip myapp.el

Useful before committing tracked .el files that should not carry shebangs in version control.

Serving an app over HTTP (serve)

serve loads a clime app file and starts an HTTP server via clime-serve:

./clime-make.el serve myapp.el --port 8080 --host 127.0.0.1

When the file has a clime shebang, load paths and CLIME_MAIN_APP are parsed from it automatically. Pass --no-auto-paths to disable this. Use --app SYMBOL to override the auto-detected app symbol. Use -L DIR to add extra load paths.

The server blocks until interrupted (Ctrl-C). Requires web-server at runtime — see Dependencies under the Server section. In the dist bundle, clime-serve is included but web-server must be on the load-path separately.

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 err \
      (while t(eval(read(current-buffer))t)) \
      (end-of-file nil) \
      (error(princ(format \"clime-sh!: load error: %S\\n\" err) \
        #'external-debugging-output)(kill-emacs 1))))" \
  -- "$@" # clime-sh!:v2 -*- 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.

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
  • transient — Emacs command dispatching with infix arguments and interactive menus
  • 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

Declarative command framework for Emacs Lisp. Define commands once — run them as CLI scripts or interactively via clime-invoke. Parsing, types, help, env vars, and dispatch included.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages