A lightweight, configurable dotfiles manager.
Knot is a CLI tool for managing dotfiles via symlinks. Like GNU Stow, it centralizes your configuration files in a single repository. Unlike Stow, Knot is fully configurable β you explicitly define where files go, ignore specific files, and apply OS-specific rules without a rigid directory structure.
- Intuitive CLI: Simple commands like
knot tieandknot untie - Configurable Routing: Map any file in your dotfiles repo to any location on your system
- Ignore Rules: Exclude specific files (e.g.
README.md,.DS_Store) per package - OS Conditions: Conditionally tie packages based on the operating system (macOS vs Linux)
- Tags: Group packages with
tags: [work, linux]and bulk-operate with--tag <name> - Safe by Default:
knot planpreviews every change before anything is written - Validation:
knot validatechecks your Knotfile for errors before you run anything - Interactive TUI: Run
knotwith no arguments for a live package/tags dashboard - App Install: Declare
installmetadata per package to check versions and install apps from the TUI - File Templating:
.tmplfiles are rendered with Gotext/templateusing runtime variables like.os,.hostname,.home
brew install oxGrad/tap/knotInstalls a pre-built binary for macOS (Intel + Apple Silicon) and Linux via the oxGrad/homebrew-tap tap.
Pre-built binaries for Linux, macOS, and Windows are available on the Releases page.
# Example for Linux amd64
curl -L https://github.com/oxGrad/knot/releases/latest/download/knot_linux_amd64.tar.gz | tar xz
sudo mv knot /usr/local/bin/go install github.com/oxgrad/knot@latestCreate a file named exactly Knotfile (no extension) at the root of your dotfiles repository.
Knot searches upward from the current directory to find it automatically.
packages:
# source defaults to ./nvim when omitted
nvim:
target: ~/.config/nvim
tags: [work]
ignore:
- "README.md"
- ".DS_Store"
install:
bin: nvim
brew: neovim
apt: neovim
dnf: neovim
# Map zsh files directly into the home directory
zsh:
source: ./zsh
tags: [home]
# OS-specific package β only tied on macOS; belongs to two tags
yabai:
target: ~/.config/yabai
tags: [home, macos]
condition:
os: darwin
# Untagged β still usable by name
secrets:
target: ~/.ssh| Field | Required | Description |
|---|---|---|
source |
β | Path to source directory (relative to Knotfile, or absolute; ~ supported). Defaults to ./<package-name> |
target |
β | Destination path for symlinks (~ supported). See linking modes below. |
ignore |
β | List of glob patterns matched against file basenames |
tags |
β | List of tag names; enables --tag flag and Tags tab in TUI |
condition.os |
β | Only tie on this OS (darwin, linux, windows, freebsd) |
install |
β | Optional app-install metadata β see App Install below |
Adding an install: block to a package enables version checking and, in the
TUI, an i key that installs the app itself.
packages:
nvim:
target: ~/.config/nvim
install:
bin: nvim
deps: [git]
secrets:
target: ~/.ssh
install:
bin: age
script: curl -fsSL https://age-encryption.org/install.sh | sh
autoResolve: false| Field | Required | Description |
|---|---|---|
install.bin |
β | Binary name checked with which and --version to detect an existing install (e.g. nvim) |
install.brew |
β | Homebrew formula name |
install.apt |
β | apt-get package name |
install.dnf |
β | dnf package name |
install.script |
β | URL for a curl install script, piped to bash |
install.deps |
β | Other knot package names to install first, via the same package manager |
install.autoResolve |
β | Fill empty brew/apt/dnf with the package name. Defaults to true |
bin β the binary knot looks for on $PATH to decide whether the
package is already installed, and to read its version for the Packages tab.
If omitted, it falls back to the package's Knotfile name.
brew / apt / dnf β the package name passed to each manager's
install command. Leave these unset and let autoResolve fill them in when
the name matches the package's Knotfile key (the common case); set one
explicitly when a manager's package name differs (e.g. apt: neovim where
the Knotfile package is named nvim).
| Manager | Binary checked | OS |
|---|---|---|
brew |
brew |
macOS, Linux (Homebrew on Linux) |
apt |
apt-get |
Linux (Debian/Ubuntu) |
dnf |
dnf |
Linux (Fedora/RHEL) |
Pressing i in the TUI only lists managers configured on the package and
present on $PATH β e.g. on Fedora, apt never shows up even if
install.apt resolves to a name, since apt-get isn't installed.
script β a shell one-liner (typically a curl | sh) run when there's
no package-manager entry to use, or as an option alongside them. Checked
against curl on $PATH.
deps β other package names (from this same Knotfile) to install first,
using the same manager the user picks for this package. Useful when an app
needs a build dependency (e.g. tmux depending on deps: [git]).
autoResolve β when true (the default), any of brew/apt/dnf left
empty are filled in with the package's Knotfile name, so a package whose name
matches across all three managers needs no repetition. Set it to false to
opt a package out entirely β e.g. a script-only install like secrets
above, where "secrets" isn't a real package in any manager.
The target value controls how knot places symlinks:
Directory symlink (default) β knot creates a single symlink at target pointing to the entire source directory. Use this when the target path does not yet exist and you want the whole directory to be managed as one unit.
nvim:
target: ~/.config/nvim # creates ~/.config/nvim -> /dotfiles/nvimPer-file mode β add a trailing / to target. Knot links each file in the source directory individually into target. This is required when target is a directory that must already exist (like ~/ or ~/.config/), and also respects ignore patterns.
zsh:
target: ~/ # links ~/dotfiles/zsh/.zshrc -> ~/.zshrc, etc.
ignore:
- "README.md"In per-file mode, any source file ending with .tmpl is rendered with Go text/template before being linked. The .tmpl suffix is stripped in the target name (e.g. config.tmpl β config).
Available template variables:
| Variable | Description |
|---|---|
.os |
Runtime OS (darwin, linux, windows, β¦) |
.arch |
CPU architecture (amd64, arm64, β¦) |
.hostname |
Machine hostname |
.username |
Current user name |
.home |
Home directory path |
.env |
Map of all environment variables |
Example β write a config that adapts to the current OS:
# zsh/.zshrc.tmpl
{{ if eq .os "darwin" }}
eval "$(/opt/homebrew/bin/brew shellenv)"
{{ end }}
export PATH="$HOME/.local/bin:$PATH"
A JSON Schema is available for editor validation and auto-complete β see Editor Integration.
knot tie [package...] [--all] [--tag <name>] Create symlinks
knot untie [package...] [--all] [--tag <name>] Remove symlinks
knot status Show symlink state
knot plan [package...] [--all] [--tag <name>] Dry-run preview
knot validate Validate Knotfile
knot init [git-url] Create or clone a Knotfile
knot version Print the knot version
Global flags available on every command:
--config string Path to Knotfile (default: auto-discover upward from cwd)
--dry-run Print actions without executing them
Creates symlinks for the specified packages. Skips packages that are already correctly linked. Warns on conflicts (target exists but is not the expected symlink) without overwriting.
knot tie nvim zsh # tie specific packages
knot tie --all # tie every package in the Knotfile
knot tie --tag work # tie all packages tagged "work"
knot tie nvim --dry-run # preview without writingRemoves symlinks previously created by knot tie.
knot untie nvim
knot untie --tag home # untie all packages tagged "home"Shows the current state of every managed symlink:
[OK] ~/.config/nvim
[MISSING] ~/.zshrc
[CONFLICT] ~/.config/karabiner: target exists and is not a symlink
Dry-run that shows exactly what tie would do:
+ ~/.config/nvim -> /dotfiles/nvim
= ~/.zshrc (already linked)
Plan: 1 to create, 0 to remove, 1 already linked, 0 conflicts
Creates a starter Knotfile in the current directory, or clones an existing dotfiles repository:
knot init # scaffold a Knotfile in the current directory
knot init <git-url> # clone a dotfiles repo and set it upPrints the current knot version and exits.
Validates the Knotfile without touching the filesystem:
knot validate
# Validating Knotfile: /home/user/dotfiles/Knotfile
#
# ERROR [yabai]: source directory "/home/user/dotfiles/yabai" does not exist
#
# Validation failed: 1 error(s), 0 warning(s)Exit codes: 0 = valid Β· 1 = errors Β· 2 = warnings only
Run knot with no arguments to launch the interactive TUI. It shows a live view of all packages and lets you toggle, apply, and reload without typing individual commands.
The TUI has two tabs: Packages (the default) and Tags. Switch between them with [ and ]. The Tags tab shows packages grouped by tag in a collapsible tree view β press enter to collapse or expand a tag, and space to bulk-toggle all packages in a tag.
Key bindings:
| Key | Action |
|---|---|
β/β or j/k |
Navigate |
space |
Toggle package / bulk-toggle tag |
enter |
Collapse/expand tag (Tags tab) |
[ / ] |
Switch tabs |
a |
Apply pending changes |
i |
Install selected package (Packages tab) |
r |
git pull and reload |
b |
Switch branch |
e |
Open dotfiles dir in $EDITOR |
m |
Cycle mascot character |
q |
Quit |
Knot ships a JSON Schema for the Knotfile format, enabling inline validation, hover documentation, and auto-completions in any editor that supports yaml-language-server.
Schema URL:
https://raw.githubusercontent.com/oxGrad/knot/main/schema/knotfile.schema.json
Add this comment as the first line of any Knotfile. yaml-language-server picks it up
automatically regardless of which editor you use β no editor configuration required.
# yaml-language-server: $schema=https://raw.githubusercontent.com/oxGrad/knot/main/schema/knotfile.schema.json
packages:
nvim:
source: ./nvim
target: ~/.config/nvimCopy into your workspace .vscode/settings.json. Requires the
YAML extension by Red Hat.
{
"yaml.schemas": {
"https://raw.githubusercontent.com/oxGrad/knot/main/schema/knotfile.schema.json": "**/Knotfile"
},
"yaml.validate": true,
"yaml.completion": true,
"yaml.hover": true
}Add the schema to your yamlls setup:
require("lspconfig").yamlls.setup({
settings = {
yaml = {
schemas = {
["https://raw.githubusercontent.com/oxGrad/knot/main/schema/knotfile.schema.json"] = "**/Knotfile",
},
},
},
})Add the schema to whichever config file your editor reads for yaml-language-server:
{
"schemas": {
"https://raw.githubusercontent.com/oxGrad/knot/main/schema/knotfile.schema.json": ["**/Knotfile", "Knotfile"]
}
}A full Neovim plugin lives in editors/neovim/. It provides:
- Filetype detection for files named
Knotfile - YAML syntax highlighting with Knotfile-specific keyword groups
- Treesitter YAML parser override (Neovim 0.9+)
- πͺ’ devicon registration for nvim-web-devicons
- Automatic yaml-language-server schema configuration at runtime (no manual lspconfig setup needed)
See editors/neovim/README.md for installation instructions
(lazy.nvim, packer.nvim, and manual).
Releases are automated via GoReleaser. Push a semver tag to main:
git tag v1.2.3
git push origin v1.2.3This triggers the release workflow which:
- Builds binaries for Linux, macOS (Intel + Apple Silicon), and Windows
- Creates a GitHub Release with archives and
checksums.txt - Pushes an updated
knot.rbformula to oxGrad/homebrew-tap
Prerequisite: A
HOMEBREW_TAP_GITHUB_TOKENrepository secret must be set β a GitHub PAT withcontents: writepermission on theoxGrad/homebrew-taprepository.
Pull requests are welcome. The CI pipeline runs on every PR to main:
| Check | Tool |
|---|---|
| Tests | go test ./... |
| Build | go build ./... |
| Lint | golangci-lint |
All three checks must pass before a PR can be merged.