Packages
package-install is a tiny git-based package manager. Point it at a repository and Macros clones it into your config directory on first launch, optionally pins it to a commit or tag, and loads its entry file — so your init.scm can pull in extensions other people have written. Later launches skip the clone and just load, so startup stays fast and works offline.
It builds on existing primitives (git via call-process, the file predicates, and Steel's load), so there's nothing to enable — package-install is available in your init.scm.
Installing a package
The minimal form is just a URL:
;; Clone to <config-dir>/packages/macros-rainbow on first run, then load its
;; entry file — macros-rainbow.scm, the default (= the package name).
(package-install "https://github.com/user/macros-rainbow")
Packages live under your config directory — ~/.config/macros/packages/ (or $XDG_CONFIG_HOME/macros/packages, %APPDATA%/macros/packages on Windows). The exact path is available from Scheme as (packages-directory).
Pinning a version
Track a specific commit, tag, or branch with 'ref. Pinning is recommended — it makes your config reproducible and keeps an upstream change from silently altering your editor:
(package-install "https://github.com/user/macros-rainbow"
'ref "v0.3.1") ; commit, tag, or branch, checked out after clone
The clone happens only when the package directory is absent, so only the first launch touches the network. A pinned 'ref is re-checked-out on every launch, so editing it in init.scm re-pins on the next start.
Options
package-install takes a URL followed by quoted-symbol options (the same style as lsp-language):
| Option | Meaning |
|---|---|
'name |
Install name and clone-directory name. Defaults to the repo name derived from the URL (last path segment, minus .git). |
'ref |
Commit, tag, or branch to check out after cloning. |
'entry |
File to load, relative to the repo root. Defaults to "<name>.scm". |
'depends |
List of package names that must already be installed earlier in init.scm. |
'config |
A (lambda () …) run after the entry loads — put your settings here. |
A custom entry file
(package-install "https://github.com/user/macros-bigmode"
'ref "a1b2c3d"
'entry "init.scm") ; load this instead of <name>.scm
Configuring a package after it loads
The 'config thunk runs once the entry file has loaded, so any commands or
options the package defines are available:
(package-install "https://github.com/user/macros-snippets"
'ref "v1.0"
'config (lambda ()
(define-key "prog" "ctrl-c ctrl-s" 'snippet-expand)
(set-option "snippet-dir" "~/.snippets")))
Note that the entry file and 'config run on every launch, not just the
first install — only the git clone is once-only. They configure the live editor,
which starts fresh each session, so keep them idempotent (defining commands,
binding keys, and setting options all re-run cleanly).
Dependencies
'depends enforces ordering: each named package must have been installed by an earlier package-install in your init.scm. Nothing is auto-fetched — if a dependency is missing, the package is skipped with a message rather than installed implicitly. Declare dependencies first:
;; Declare the dependency first…
(package-install "https://github.com/user/macros-lib-async"
'name "async")
;; …then the package that needs it. `async` is guaranteed loaded before
;; macros-http's entry and config run.
(package-install "https://github.com/user/macros-http"
'ref "v2.1.0"
'depends '("async")
'config (lambda () (set-option "http-timeout" "30")))
Managing installed packages
| Command | What it does |
|---|---|
M-x package-list |
Echo the installed packages. |
M-x package-update |
Fast-forward every installed package to its upstream and report. |
package-update advances branch-tracking installs; a package pinned to a commit or tag is re-pinned from your init.scm on the next launch, and a pinned checkout that can't fast-forward is reported as failed and left untouched.
To remove a package, delete its directory under (packages-directory) and remove its package-install line from init.scm.
Creating a package
A package is just a git repository containing Steel that uses the editor's scripting API. There's no manifest and no build step.
Layout and the entry point
The default entry point is a file named after the package, at the repo root: <name>.scm, where <name> is the package name — the last path segment of the repo URL with .git removed, or the installer's 'name override. So a repo at github.com/you/macros-foo is loaded from macros-foo.scm:
macros-foo/
macros-foo.scm ← loaded by default
README.md
Name the entry file anything you like and have users point at it with 'entry "init.scm", but matching the repo name is the zero-config convention. A minimal entry:
;; macros-foo.scm — define a command and bind it. define-command defines the
;; command AND makes it M-x-discoverable in one form.
(define-command (foo-hello)
(message "Hello from macros-foo!"))
(define-key "global" "ctrl-c f" 'foo-hello) ; optional default binding
What you can do in the entry
The entry is loaded into the shared global engine, so it has the same API your init.scm does — define-key, set-option, set-face, add-hook, register-command, the buffer/point read bridges, call-process, tree-sitter and LSP requests, and everything else in the Scripting reference. Writing a package is writing config that happens to live in someone else's repo.
A few conventions that make a package well-behaved:
- Namespace your globals. Top-level
(define …)land in the one shared global scope, so prefix your identifiers (foo-…, andfoo--…for internals) to avoid colliding with the editor or other packages. - Register interactive commands with
register-commandso they appear inM-x. - Document with a
;;comment immediately above each(define …)— the editor scrapes it when your package loads, so your functions and variables get help text for free and become findable throughdescribe-function,apropos, and the livehelm-apropossearch (C-h C-a), whose Enter jumps straight to your source. See Getting help. - Stay idempotent. Your entry (and the user's
'configthunk) runs on every launch, not just install — see Configuring a package after it loads. Defining commands, binding keys, and setting options are all naturally re-runnable; avoid one-time side effects like appending to a file. - Don't bind keys in
"global"aggressively — prefer a mode keymap or leave bindings to the user's'config, so installing your package doesn't clobber their keys.
Multi-file packages
Steel's load resolves relative paths against the process working directory, not the entry file — so (load "helper.scm") from your entry will not reliably find a sibling. Use *package-directory*, which is bound to your package's own directory while the entry loads. Capture it at load time:
;; macros-foo.scm
(define foo-dir *package-directory*) ; capture immediately
(load (path-join foo-dir "helper.scm")) ; now resolves correctly
(load (path-join foo-dir "commands.scm"))
Read *package-directory* only during loading (capture it into a local like foo-dir above). Don't read it later from inside a command — by then it holds whichever package loaded last.
Publishing
Push to any git host the user's git can clone. Tag releases (v1.0.0, …) so users can pin a stable version with 'ref; without a tag they can still pin a commit SHA or track a branch. The macros- repo prefix is conventional but not required.
How loading is isolated
Each package's entry file (and its 'config thunk) is loaded inside an error
handler, so a single broken package reports its error to *Messages* rather
than aborting the rest of your init.scm. A package is only marked loaded — and
thus visible to a later 'depends — once its entry loads without error.
A note on trust
package-install runs code from a URL, exactly like any package manager. Install
from sources you trust, and prefer pinning a 'ref so you know exactly which
commit you're running.