Skip to content

Zero config CLI, HTTP, and REPL interface for Clojure.

Notifications You must be signed in to change notification settings

filipesilva/invoker

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

102 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Invoker

Zero config CLI, HTTP, and REPL interface for Clojure.

Invoked vars run in Clojure if there's a deps.edn, otherwise in Babashka.

Commands will automatically connect to an existing nREPL server if available using .nrepl-port. The nvk http and nvk repl commands start a nREPL server that can be connected to.

Invoker aims to make the following usecases easy:

  • making a simple webapp from scratch, possibly with Datomic
  • running functions and inspecting atoms inside an existing process
  • running targetted tests with reloaded code
  • allowing agents to interact with your clojure process
  • adding dependencies and reloading code without restarting the process

If you want to watch a video about Invoker, there's Vars are all you need hosted by the wonderful London Clojurians.

The invoker:

It's magic. I ain't gotta explain shit.

CLI

Given src/app.clj:

(ns app)

(defn my-fn
  "My doc"
  {:invoker/http true}
  [x y & {:as opts}]
  [x y opts])

You can invoke it with the nvk CLI, using the fully qualified name or separated by spaces, and passing opts using keywords or flags:

$ nvk app/my-fn 1 2
[1 2 nil]
$ nvk app my-fn 1 2
[1 2 nil]
$ nvk app my-fn 1 2 :a 3
[1 2 {:a 3}]
$ nvk app my-fn 1 2 --a 3
[1 2 {:a 3}]
$ nvk app my-fn 1 2 --a=3
[1 2 {:a 3}]

You must provide at least as the minimum number of arguments that the function takes. You can invoke a value, and atom values will be dereferenced.

Exceptions will return the exception map with no stack trace unless you use the --ex-trace option. The exit code for exceptions will be 1, and you can customize it with the :exit key: (throw (ex-info "my error" {:exit 3})).

HTTP

You can also serve it with nvk http, then invoke it with curl or by opening the address on your browser:

$ nvk http
Started nREPL server at localhost:51548
Started HTTP server at http://localhost
$ curl localhost/app/my-fn/1/2
[1 2 nil]
$ curl localhost/app/my-fn/1/2?a=3
[1 2 {:a 3}]
$ curl localhost/app/my-fn/1/2 -d a=3
[1 2 {:a "3"}]

Only vars with the {:invoker/http true} metadata will be served, unless you use nvk --http-all http.

Successful invocations return status 200, exceptions 400, internal server errors 500. The HTTP method will be ignored.

You can redirect on 200 to another var or string path using metadata: {:invoker/http {:redirect #'another-fn}}. Redirects will use the same arguments as the original request, but you can use the map form with a :args key as a function over the return: {:invoker/http {:redirect {:to #'another-fn, :args (fn [ret] (:id ret))}}}.

Static files in resources/public/ will be served directly.

REPL

You can also start a rebel-readline nREPL client with nvk repl:

$ nvk repl
Started nREPL server at localhost:51534
Connecting to nREPL server at localhost:51534
Quit REPL with ctrl+d, autocomplete with tab
More help at https://github.com/bhauman/rebel-readline
[Rebel readline] Type :repl/help for online help info
user=> (require 'app)
nil
user=> (app/my-fn 1 2 :a 3)
[1 2 {:a 3}]

Installation

To install Invoker you will need:

Then run bbin install io.github.filipesilva/invoker to install Invoker as nvk. Run the same command again to update. Uninstall with bbin uninstall nvk.

You can install a custom Invoker by cloning this repo and running bbin install ..

Now you should have a nvk binary in your CLI. Calling it with no arguments shows help.

Some nvk commands use git and ssh.

If you want to instead install it locally as a babashka task, add this to your bb.edn:

{:deps {io.github.filipesilva/invoker {:git/tag "v0.4.7" :git/sha "2d77d"}}
 :tasks
 {nvk {:requires ([invoker.nvk :as nvk])
       :task (apply nvk/-main *command-line-args*)}}}

Now you should be able to run bb nvk.

Content Negotiation

Invoker supports content negotiation via --content-type, --accept, and --ext options. --ext will set both --content-type and --accept based on a file extension.

In the CLI, --content-type applies to the last non-option argument. Piping in data through stdin will read it as the last non-option argument.

$ nvk --accept application/json app my-fn 1 2
[ 1, 2, null ]
$ nvk --content-type application/json app my-fn 1 2 '{"a":3}'
[1 2 {:a 3}]
$ nvk --ext .json app my-fn 1 2 '{"a":3}'
[ 1, 2, {
  "a" : 3
} ]
echo '{"a":3}' | nvk --ext .json app my-fn 1 2
[ 1, 2, {
  "a" : 3
} ]

In HTTP calls you append the extension to the URL:

$ curl localhost/app/my-fn/1/2 -H "Accept: application/json"
[ 1, 2, null ]
$ curl localhost/app/my-fn/1/2 -d '{"a": 3}' -H "Content-Type: application/json"
[1 2 {:a 3}]
$ curl localhost/app/my-fn/1/2.json -d '{"a": 3}'
[ 1, 2, {
  "a" : 3
} ]

Invoker supports the following MIME types out of the box:

  • application/edn as .edn
  • application/json as .json
  • application/yaml as .yaml
  • text/html as .html
  • text/plain as .txt

HTML will be rendered from Hiccup.

(defn index
  {:invoker/http true}
  []
  [:h1 "Hello World!"])
$ curl localhost/app/index
[:h1 "Hello World!"]
$ curl localhost/app/index.html
<h1>Hello World!</h1>

You can add a content-negotiated pre-render step to functions via metadata. This allows you to return objects for API consumers, and HTML for browser consumers.

(defn render-todo
  [{:keys [done content]}]
  [:div
   [:h1 content [:input {:type :checkbox, :checked done}]]])

(defn todo
  {:invoker/http true
   :invoker/pre-render {:text/html render-todo}}
  []
  {:id      42
   :done    false
   :content "foo the bar"})
$ curl localhost/app/todo
{:id 42, :done false, :content "foo the bar"}
$ curl localhost/app/todo.html
<div><h1>foo the bar<input type="checkbox" /></h1></div>

Cron

Call functions on a schedule using Cron expressions in :invoker/cron:

(require '[invoker.cron :as cron])

(defn still-alive
  ;; every minute
  {:invoker/cron "* * * * *"}
  []
  (println "hello at" (cron/t))

The scheduler starts with the nREPL server on nvk http and nvk repl.

$ nvk http
Started nREPL server at localhost:61621
Started HTTP server at http://localhost
hello at #inst "2026-02-14T20:40:00.000-00:00"
hello at #inst "2026-02-14T20:41:00.000-00:00"
hello at #inst "2026-02-14T20:42:00.000-00:00"

(cron/t) returns the cron inst that triggered this call. You can use it to synchronize single calls on multiple processes.

Tests

Run tests in test/app_test.clj using clojure+.test, reloading changed files:

(ns app-test
  (:require
   [clojure.test :refer [deftest is]]
   [app :as app]))

(deftest my-fn-test
  (is (= [1 2 {:a 3}] (app/my-fn 1 2 :a 3))))
$ nvk test
Reloading 0 namespaces...
Reloaded 0 namespaces in 1 ms
1/1 Testing app-test... 0 ms
╶───╴
Ran 1 tests containing 1 assertions in 0 ms.
0 failures, 0 errors.
{:test 1, :pass 1, :fail 0, :error 0}

You can target the namespace a single namespace (nvk test app-test), or a single test (nvk test app-test/my-fn-test), or select tests with ^:only metadata.

Devtools

Invoker will setup the following developer tools when creating a new process:

  • clojure+.print and clojure+.error improve printing of values and errors
  • clojure+.test installs the test runner
  • clojure+.hashp allows you to print pretty much anything by putting #p before the expression, including in threading macros
  • clj-reload tracks and reloads changed namespaces, use defonce instead of def to keep global state

Helper Commands

Invoker comes with a set of helper commands in invoker.cli, which is configured to be the default namespace:

nvk reload              # Reload changed namespaces
nvk reload :all         # Reload all namespaces
nvk routes              # List routes for vars with :invoker/http metadata
nvk crons               # List vars with :invoker/cron schedules
nvk dir app             # List public vars in ns
nvk source app/my-fn    # Source code for var
nvk doc app/my-fn       # Print var docstring
nvk find-doc My doc     # Find docs containing text
nvk apropos my-f        # Find vars containing text
nvk add-lib babashka/fs # Add dependency by name (Clojure only)
nvk sync-deps           # Sync process to deps.edn (Clojure only)
nvk devtools            # Call devtools var
nvk restart             # Call stop then start vars
nvk clojuredocs q       # Search ClojureDocs for q
nvk exit 1              # Exit the process with exit-code

Like all other nvk commands, they will connect to an existing nREPL if available.

Configuration

You can configure nvk commands by passing options before the command:

Usage: nvk <options>* <command> <args>*

        --help                                     Show doc for var
        --version                                  Show version
        --skill                                    Print README.md with Claude SKILL.md metadata
  -c,   --config          nvk.edn                  Invoker defaults config file
  -e,   --ext                                      Extension shorthand (.edn/.json/.yaml/.html/.txt) for content-type/accept MIME types
  -ct,  --content-type                             MIME type for body (last arg or piped input) on CLI content negotiation
  -ac,  --accept          application/edn          MIME types accepted on CLI content negotiation, use with :invoker/render metadata
        --extensions      invoker.utils/extensions Map of extension to MIME type
        --parse           invoker.utils/parse      Map of MIME type to parsing fn
        --render          invoker.utils/render     Map of MIME type to rendering fn
  -d,   --dialect         :clj                     Clojure (clj) or Babashka (bb), defaults to clj if there's a deps.edn
        --devtools        invoker.utils/devtools   Developer tools fn to call on process setup or nvk devtools
  -r,   --reload                                   Reload changed files before invoking fn via CLI
        --start           app/start                Start fn to call on nREPL server start  or nvk restart
        --stop                                     Stop fn to call on nREPL server start or nvk restart
  -nd,  --ns-default      invoker.cli              Default namespace for var resolution
  -na,  --ns-aliases                               Map of alias to namespace for var resolution
  -ha,  --http-all        false                    Expose vars without :invoker/http in the HTTP server
  -hp,  --http-port       80                       Port for HTTP server, written to .http-port
  -hh,  --http-handler    invoker.http/handler     Ring handler fn for HTTP server
  -rp,  --repl-port       0                        Port for nREPL server creation, 0 for random
  -rc,  --repl-connect                             nREPL server address to connect on, defaults to content of .nrepl-port file if present and port is taken
  -rgr, --repl-git-remote                          Git remote name to use for nREPL connection
  -a,   --aliases                                  Aliases to call Clojure with, does nothing with Babashka
  -et,  --ex-trace        false                    Include stack trace on exception

You can set custom defaults for options in nvk.edn:

{:http-port 8080
 :aliases   ":dev"}

The extensions, parse, render, devtools, start, stop, ns-default, ns-aliases, http-handler options take symbols that will be resolved at in your codebase, allowing you to customize nvk behaviour with your own code.

Datomic

You can use the --start option together with dpm to launch a Datomic database and wait for it to be up on startup.

First add these dependencies:

$ nvk add-lib io.github.filipesilva/datomic-pro-manager
$ nvk add-lib com.datomic/peer
$ nvk add-lib org.xerial/sqlite-jdbc
$ nvk add-lib org.slf4j/slf4j-nop

In your src/app.clj add:

(ns app
  (:require
   [datomic.api :as d]
   [filipesilva.datomic-pro-manager :as dpm]))

(def db-uri "datomic:sql://app?jdbc:sqlite:./storage/sqlite.db")
(defonce *conn (atom nil))

;; https://docs.datomic.com/schema/schema-reference.html
(def schema
  [,,,])

(defn start []
  (future (dpm/up))
  (dpm/wait-for-up)
  (d/create-database db-uri)
  (reset! *conn (d/connect db-uri))
  @(d/transact @*conn schema))

(defn db-stats
  {:invoker/http true}
  []
  (d/db-stats (d/db @*conn)))

Then set the :start option on nvk.edn so you don't have to write nvk --start app/start http all the time:

{:start app/start}

Now when you start a nREPL server via nvk http or nvk repl you should see datomic starting up too:

$ nvk http
Started nREPL server at localhost:60250
Started HTTP server at http://localhost
SLF4J(I): Connected with provider of type [org.slf4j.nop.NOPServiceProvider]
info  Waiting for datomic to be up...
info  Starting Datomic
run   ./datomic-pro/1.0.7482/bin/transactor ./config/transactor.properties
Launching with Java options -server -Xms1g -Xmx1g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
System started
info  Datomic is up!
$ curl localhost/app/db-stats
{:datoms 268,
 :attrs
 {:db/index {:count 2},
  :db/unique {:count 1},
  :db/valueType {:count 33},
  :db/txInstant {:count 6},
  :db/tupleType {:count 2},
  :db/lang {:count 2},
  :db/fulltext {:count 2},
  :db/cardinality {:count 33},
  :db/doc {:count 48},
  :db/ident {:count 67},
  :db/code {:count 2},
  :db.install/valueType {:count 16},
  :db.install/function {:count 2},
  :db.install/partition {:count 3},
  :db.install/attribute {:count 33},
  :fressian/tag {:count 16}}}

If you're running Datomic as a separate process (you should, for any serious stuff), remove (future (dpm/up)) from start to just wait. You can use dpm up to start it if you have dpm installed globally.

Deploy

You can deploy a nvk app to a remote server and interact with it with very little fuss, using little more than git and ssh.

Setup a git repository on user@server with receive.denyCurrentBranch set to updateInstead. This will cause pushes to the checked out branch to update the files unless there's any uncommited changes.

$ ssh user@server
$ mkdir -p ~/repos/app
$ cd ~/repos/app
$ git init
$ git config receive.denyCurrentBranch updateInstead

Add the the ssh server and path to your local repository and push it. Whenever you push the server branch the files will be updated.

$ git remote add server user@server:~/repos/app
$ git push server

Back on the server, use nohup to start a persistent background nvk http process that logs output to nohup.out.

$ ssh user@server
$ cd ~/repos/app
$ nohup nvk http &
appending output to nohup.out
$ exit

Invoker's --repl-git-remote/-rgr option takes a git remote name and uses it to look up the nREPL port on that server, create a ssh forward tunnel to it, connect nREPL to your app, and run commands.

You can use it to run commands on the server or inspect its state:

$ nvk --repl-git-remote server app state
Forwarding localhost:61754 to user@server:53663...
{:foo 42}

To redeploy with zero downtime push, sync-deps (if deps changed), and reload:

$ git push server
$ nvk --repl-git-remote server sync-deps
Forwarding localhost:61504 to user@server:53663...
nil
$ nvk --repl-git-remote server reload
Forwarding localhost:61522 to user@server:53663...
Unloading app
Loading app
{:unloaded [app], :loaded [app]}

It is possible for sync-deps to fail if dependencies conflict. In that case you will have kill the nvk http server process with nvk exit (or any other way) then restart it like before with nohup.

You can also make your own ssh tunnel and set the port on --nrepl-connect. You don't even need to have started the app with nvk, since it installs itself on the process when connecting.

Claude SKILL.md

You can output this README.md with Claude skill metadata using nvk --skill.

Then you can output it to your global skills directory to teach Claude Code how to use nvk:

$ mkdir -p ~/.claude/skills/nvk
$ nvk --skill > ~/.claude/skills/nvk/SKILL.md

Now you should be able to prompt something like make a basic blog using nvk and datomic and it should just work.

.gitignore

Below are all the temporary files used by nvk or dpm in the examples so you can add them to your .gitignore:

# nvk
.rebel_readline_history
.nrepl-port
.http-port
.cpcache
.clj-kondo
nohup.out
# dpm
datomic-pro
storage
backups

About

Zero config CLI, HTTP, and REPL interface for Clojure.

Resources

Stars

Watchers

Forks

Packages

No packages published