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):
And from the command line — same definition, two surfaces:
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 hereNow 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 --helpOr interactively from Emacs:
(require 'clime-invoke)
(clime-invoke greet)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/climeThen set up your app file.
lib/clime/clime-make.el quickstart myapp.elThis inserts the entrypoint boilerplate, prepends a polyglot shebang, and makes the file executable.
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.elOr use it as a library from Emacs:
(require 'clime)(package-vc-install "https://github.com/cosmicz/clime")Build a single-file bundle from a clone:
cd clime/
make dist
cp dist/clime.el /path/to/myapp/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)
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.
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.
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):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 value | Behavior |
|---|---|
nil (default) | No pre-prompting, enters menu directly |
t | Prompt for all required params |
'(param ...) | Prompt for listed params only |
:immediate value | Behavior |
nil (default) | Normal menu loop |
t | Run after ask if satisfied; y-or-n-p if nothing was asked |
'no-confirm | Like t but skip y-or-n-p confirmation |
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.
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) ...))))| Request | Command | Values |
|---|---|---|
GET /agent/abc/start | agent/start | id=abc |
GET /agent/xyz/stop | agent/stop | id=xyz |
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" }
Handler output is captured (same as clime-invoke) and returned as
the response body. Exit codes map to HTTP status:
| Exit code | Status | Meaning |
|---|---|---|
| 0 | 200 | Success |
| 2 | 400 | Usage error (bad input) |
| 1 | 500 | Handler error |
| unknown path | 404 | Route not found |
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.
GET /_routes returns a plain-text listing of available commands with
their help strings.
Structured API endpoints are injected as hidden commands at startup:
| Endpoint | Returns |
|---|---|
GET /_api/meta | App name, version, and help text |
GET /_api/commands | All visible top-level commands |
GET /_api/commands/PATH | Detail for a specific command (e.g. db/migrate) |
Output uses clime-out, so the active output format drives encoding.
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 --initOr 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.
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.
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))))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.
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")))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)))))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.
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")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 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 consume no value. Use :bool (shorthand for :nargs 0):
(clime-opt force ("--force" "-f") :bool
:help "Skip confirmation")Count flags increment on each occurrence:
(clime-opt verbose ("-v" "--verbose") :count
:help "Increase verbosity")
;; -v → 1, -vv → 2, -vvv → 3Repeatable 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 are used when an option isn’t provided:
(clime-opt registry ("--registry" "-r")
:help "Registry to use"
:default "default")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.
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 envUse :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-colorAll 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.
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.
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.
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.
Options accept --flag=value syntax:
./myapp.el show --format=json 123Use -- to stop option parsing — everything after is treated as
positional arguments:
./myapp.el show -- --not-a-flagSingle-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 123Declare 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.
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))))Use - as an argument value to read from stdin:
echo "World" | ./greet.el hello -The value pipeline and validation stages apply uniformly across all surfaces. The features below describe CLI-specific parsing behavior and the shared validation 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:choicescan 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:choicesfor cycling andcompleting-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 achoicetype’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
:typeso 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
:choicesso 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)wherevaluesis the values map, and returns the updated values map.clime-mutexandclime-zipuse 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")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.
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 inheritanceclime-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)))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").
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.
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 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 helpUsage 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
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.
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.
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.
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 outputHandlers 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:
| Function | Behavior |
|---|---|
clime-out | Emit one item (accumulated or streamed, per format) |
clime-out-error | Report an error (accumulated or handled immediately) |
clime-out-emit | Encode and emit immediately, bypassing the accumulator |
clime-out-format | Returns the active format name ('json, 'text, etc.) |
clime-out-text | Emit text-only decoration (no-op for structured formats) |
clime-output-format derives from clime-option, so it supports
:hidden, :category, etc.
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.
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).
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.
: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.
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 passedclime-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)))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))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.
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):
| Field | Meaning |
|---|---|
app | the root clime-app |
surface | cli, serve, invoke, or run-from-values |
phase | completed, no-handler, help, version, usage-error, runtime-error, not-found (serve 404) |
argv | raw argv list (CLI surface; nil elsewhere) |
path | node-name path from root to command, when known (requested URL segments for serve not-found) |
display-path | user-facing path (excludes inline groups), when known |
params | resolved params plist, when a context was built |
command | resolved clime-command node, when known |
context | the clime-context, or nil for pre-context failures |
exit-code | 0 success / 1 runtime error / 2 usage error |
error-type | error signal symbol, when failed |
error-message | error message string, when failed |
start-time | invocation start (float-time) |
duration | whole-invocation seconds (parse → done) |
format | active 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.
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.
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.
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) ...))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.
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.
| Macro | Alias | Purpose | Distinctive arguments |
|---|---|---|---|
clime-app | Root application definition | :version, :env-prefix, :dotenv, :setup, :config | |
clime-command | Leaf command (has handler) | All shared command slots | |
clime-group | Branch node (has children) | :inline | |
clime-opt | clime-option | Named option (flag or value) | :bool, :count, :multiple, :separator, :negatable, :required, :optional, :requires, :env, :category |
clime-mutex | Exclusive option group | Group name, :default, contains clime-opt forms | |
clime-zip | Paired option group | Group name, contains clime-opt forms | |
clime-arg | clime-argument | Positional argument | :nargs, :required, :optional |
clime-defopt | clime-defoption | Reusable option template | Any option slot except :name, :flags |
clime-defarg | clime-defargument | Reusable arg template | Any arg slot except :name |
clime-defcommand | Composable command (defvar) | Same as clime-command | |
clime-defgroup | Composable group (defvar) | Same as clime-group | |
clime-alias-for | Alias for a nested command | Target path, :defaults, :vals | |
clime-output-format | Output format declaration | :finalize, :streaming, :encoder, :error-handler, plus all option slots | |
clime-handler | Command implementation | (ctx) — receives clime-context | |
clime-nil-handler | Handler that returns nil | Same as clime-handler; suppresses output encoding | |
clime-let | Destructure context params | ctx (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.
clime-make.el provides six commands:
| Command | Purpose |
|---|---|
init | Add polyglot shebang to make a script executable |
scaffold | Insert ;;; Entrypoint: boilerplate |
quickstart | scaffold + init in one shot (auto env vars) |
bundle | Combine multiple source files into one |
strip | Remove a clime shebang from an Elisp file |
serve | Start an HTTP server for a clime app file |
For a single-file app that lives alongside clime:
./clime-make.el init myapp.el
./myapp.el --helpinit 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.elRe-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.
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.
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.elBefore:
(clime-app myapp :version "1.0" ...)
(provide 'myapp)
;;; myapp.el ends hereAfter:
(clime-app myapp :version "1.0" ...)
(provide 'myapp)
;;; Entrypoint:
(when (clime-main-script-p 'myapp)
(clime-run-batch myapp))
;;; myapp.el ends hereThe 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.
quickstart composes scaffold + init and auto-detects
CLIME_MAIN_APP:
./clime-make.el quickstart myapp.el
./myapp.el --helpThis 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.elAn explicit -e CLIME_MAIN_APP=custom overrides the auto-detected
value.
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.elThe 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.
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.elThe 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 hereIts 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.
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 hereThe 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:.
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 $@strip removes a clime shebang from an Elisp file and clears the
executable bit:
./clime-make.el strip myapp.elUseful before committing tracked .el files that should not carry
shebangs in version control.
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.1When 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.
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.
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
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 artifactsMIT

