A CLI for managing Open Knowledge Format (OKF) bundles.
See docs/PRD.md for the tool spec,
docs/adr/ for the Architecture Decision Records (why the
architecture is the way it is), and
docs/plans/2026-07-22-roadmap.md
for the full plan. The walking skeleton (increment 1) covered bundle,
node, validate, and config; this increment adds lifecycle management for
the reserved index.md and log.md files.
Install the latest tagged release with go install:
go install github.com/cwest/okfctl@latestOr download a prebuilt binary for your platform from the
releases page (darwin and
linux, amd64 and arm64). Each archive bundles both okfctl and the
okfctl-search plugin — extract them onto your PATH:
tar -xzf okfctl_<version>_<os>_<arch>.tar.gz
sudo mv okfctl okfctl-search /usr/local/bin/Verify the install and the reported version:
okfctl version # e.g. okfctl v1.2.3 (commit abc1234, built 2026-...)
okfctl --version # same stringA binary built straight from source with a plain go build (no release
metadata injected) reports dev.
go build -o okfctl .For a static binary with no cgo dependency:
CGO_ENABLED=0 go build -o okfctl .okfctl bundle init mykb
okfctl node new concepts/tannin.md --type Reference --title "Tannin" --bundle mykb
okfctl node list --bundle mykb
okfctl index build mykb
okfctl index check mykb
okfctl log append mykb --message "added tannin node"
okfctl log show mykb
okfctl validate mykb
okfctl bundle info mykbbundle init [dir]— scaffold a minimal conformant OKF bundlebundle info [dir]— summarize a bundle: node count, reserved-file count, spec versionnode new <path> --type <type> [--title <title>] --bundle <dir>— create a new concept node. When a type template governs--type, the node is scaffolded from it: required fields are stubbed (TODOplaceholders), recommended fields are stubbed empty, and the template's body sections are laid down as empty##headings, so the new node starts conformant to the team's convention.node show <path> --bundle <dir>— print a node's front matter and bodynode list --bundle <dir>— list the concept nodes in a bundlenode edit <path> --bundle <dir>— open a node in$EDITOR(or$OKFCTL_EDITOR/$VISUAL), then re-validate the bundle on returnnode mv <old> <new> --bundle <dir> [--dry-run]— move/rename a node and rewrite every inbound link, preserving each author's relative link form (path is identity, so a move is a graph operation)node refresh <bundle> [path] [--dry-run] [--yes]— bulk-fix stalemodifiedtimestamps: rewrite every drifting node'smodifiedto its git last-commit day (the remediation for the git driftvalidatereports).createdis immutable and never touched, the body is preserved verbatim, andlog.md/index.mdare maintained. A trailing path fixes a single node;--dry-runlists what would change and writes nothing. Degrades to a no-op outside a git repo, and exits non-zero only on real failure. Guardrail: a plan dominated by a single commit — the signature of a bulk mechanical commit, whose remediation would collapse the real authoring dates it touched into that one migration date — is refused unless--yesis given. The recommended fix in that case is to list the mechanical commit in.okf-drift-ignore-revs(see below), not to force the rewrite.node promote <bundle> [--name <basename>] [--dry-run]— bulk-remediate the directory-as-concept shape (a corpus authored on the intuition that a directory is a concept andindex.mdis what it says, as Obsidian folder notes / Hugo_index.md/ Jekyll collections do). Every non-rootindex.mdthat carries frontmatter is moved to a sibling concept file (foo/index.md→foo/foo.md;--name overviewapplies one basename convention uniformly), preserving the body verbatim and keepingcreatedimmutable. Inbound links to the old directory-concept are rewritten — both thefoo/andfoo/index.mdspellings — the realindex.mdis regenerated with no frontmatter, andlog.mdis appended. The bundle-root index is left alone (its §12okf_versionmarker is legal).--dry-runlists every move and rewrite and writes nothing.node rm <path> --bundle <dir> [--dry-run]— remove a node and report any nodes orphaned as a resultindex build [dir]— regenerate the reservedindex.mdfiles from the current bundle. Per OKF SPEC §8, oneindex.mdis emitted in each directory that holds concepts or subdirectories, enumerating only that directory's own contents with dir-relative links (concept.mdfor a sibling concept,subdir/for a child directory) and each concept'sdescription. Only the bundle-root index carries frontmatter (the §12okf_versionmarker); every nested index carries none. Orphaned indexes left in a now-empty directory are pruned.index check [dir]— verify every directory'sindex.mdis current; nonzero exit if any nested index is stale, missing, or orphanedlog append [dir] --message <text>— append a dated entry tolog.mdlog show [dir]— print the change historyvalidate <dir>— validate a bundle against the OKF spec floor. It also reports git drift — any node whose frontmattermodifieddisagrees with its git last-commit date — as advisory warnings (read-only; runnode refreshto fix them; degrades to nothing outside a git repo). Commits listed in.okf-drift-ignore-revs(see below) are walked past, so a bulk mechanical commit does not manufacture drift against every node it touched. With--templatesit additionally runs the opt-in type-template overlay (§9.4), reporting template drift (a node missing a required field or body section its governing template declares) as warnings — advisory by default (exit 0),--strictexits non-zero on any drift. Spec-floor violations always fail regardless of--templates/--strict; the overlay never leaks into the floor (unknown type values still pass, §7.4).
Git drift infers a node's freshness from its last-touching commit's date. That is
right for an incremental edit, but a bulk mechanical commit — a one-time
migration that rewrites frontmatter across the whole corpus on day one — has no
authoring intent, and treating its date as the node's modified collapses the
real authoring history into the migration date. Git records when a commit
landed, not why, so the tool cannot tell the two apart on its own.
Declare the intent with a checked-in .okf-drift-ignore-revs at the bundle root,
mirroring git blame --ignore-revs-file — a convention users already understand:
# Mechanical migration commits — opt these out of git drift.
# One commit SHA per line; blank lines and #-comments are ignored.
3f9a1c2e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d # v0.2 frontmatter key sweep
When a node's last-touching commit is on the list, the drift comparison walks
back to the prior real commit for that file. Incremental edits (commits not
on the list) still drift normally — the check is not narrowed into uselessness.
Full or abbreviated (≥7-char) SHAs both match. This is the recommended cure when
node refresh refuses a bulk-dominated plan.
template list [dir]— list the type templates a bundle declares (target type, required-field and body-section counts). Templates are authored as ordinary OKF nodes (type: Type Template); nothing lives in tool config.template show <target-type> [dir]— show one template's required/recommended fields and body sections.plugin list [--path <PATH>]— listokfctl-<name>plugin executables discovered onPATH(sorted by name, first-on-PATH wins). Plugins extend okfctlgit/kubectl-style: an unknown subcommandokfctl foo barexecs anokfctl-foobinary found onPATH, passing throughbarplus the remaining flags and environment (withOKFCTLset to the core binary's path so a plugin can call back), and propagates the plugin's exit code. Built-in subcommands always take precedence; an unknown subcommand with no matching plugin produces the usual error plus a did-you-mean suggestion. Executable detection uses Unix permission bits (macOS/Linux); Windows is not yet supported.plugin install <source> [--dir <dir>]— copy anokfctl-<name>executable into the managed plugins dir soplugin listand dispatch discover it. The default destination is$OKFCTL_CONFIG_HOME/plugins(or<user config dir>/okfctl/plugins), the same config-home conventionconfiguses; override with--dir. Put that directory on yourPATH. The source's base name must followokfctl-<name>; the copy is written with execute bits. If the destination is not on yourPATH, install prints a note to stderr so the plugin is not silently undiscoverable.
okfctl-search is a bundled plugin (a separate static binary; invoke as okfctl search … via plugin dispatch, or directly). It adds semantic search over a bundle's concept nodes, fully offline, with zero runtime dependencies — no Python, no ONNX, no sqlite-vec, no model server. It shares the exact embedding protocol with cwest/knowledge-base so vectors are cross-verifiable.
okfctl-search index build [bundle-dir]— embed every concept node into.okfctl/index.db, recording the embedder model + dimension. Content-hash keyed: an unchanged node is not re-embedded; deterministic for a fixed embedder.okfctl-search --semantic "query" [bundle-dir]— rank nodes by cosine similarity to the query (top---k, default 5). Refuses an index built under a different model (rebuild withindex build).okfctl-search related <node-path> [bundle-dir]— a node's nearest neighbors (self excluded); the neighbor setlint --semanticconsumes for its similarity-driven checks (§8.6).--embedder hash(default) is the offline, dependency-free embedder. It is deterministic and needs no model, but it is lexical — it matches tokens, not meaning.--lexical-gate(off by default) gates the semantic results by a term-wise lexical match and preserves lexical recall. It runs the semantic query wide, keeps the results whose node also contains a query term (stopwords dropped, plurals/inflections stemmed sohashandhashesmatch the same nodes), in semantic order, then appends the lexical hits the semantic band missed so a correct exact match outside the embedding's top band is never discarded. It is useful for exact-identifier-shaped queries where the embedding blurs a rare token. It degrades to pure semantic (a no-op, byte-identical to gate-off) when the query has no content terms (an all-stopword question like"how should the") or a term matches more than 60% of the bundle (a term that broad carries no discriminating signal — e.g.agentmatches 73% of the reference corpus). Composes with--path/--type/--tag(which constrain both the semantic band and the appended lexical tail) and with--half-life.
--embedder model2vec runs a genuine static embedding model (for example minishlab/potion-base-8M) in pure Go — the BERT WordPiece tokenizer and the Model2Vec inference math are both ported into internal/search, so there is still no CGO, no Python, and no ONNX runtime. Vectors match the upstream model2vec library's own output to within 1e-5, which is verified against the real model in the test suite.
okfctl never downloads a model at runtime. Point it at a directory you already have on disk:
# once — persisted in okfctl's JSON config
okfctl config set model_path ~/models/potion-base-8M
okfctl-search --embedder model2vec index build ./my-bundle
okfctl-search --embedder model2vec --semantic "tannin structure" ./my-bundle
# or per-invocation, overriding the config
okfctl-search --embedder model2vec --model-path ~/models/potion-base-8M --semantic "…" ./my-bundleThe directory needs the standard model2vec layout: config.json, model.safetensors, and tokenizer.json (or vocab.txt). If no path is configured, model2vec fails with an actionable error rather than silently falling back to hash — a query answered by the wrong embedder is worse than one that refuses to run. An index records the model it was built with, so switching embedders requires a rebuild.
lint <dir>— report curation health findings (orphans, missing cross-references, broken internal links, coverage gaps, type-value hygiene). Advisory by default (exits 0 even with findings);--strictexits non-zero on any finding,--coverage-threshold Ntunes the coverage-gap check (default 3).lintnever mutates the bundle.lint <dir> --semantic— add the two similarity-driven checks (see below). Requires an index built byokfctl-search index build; no embedding model is needed to lint, because core only ever reads an index.
Structural lint asks "is anything linked to this?". Semantic lint asks "is anything even about the same thing?" — the curation question the graph alone can't answer:
| check | finding | reads as |
|---|---|---|
similar-unlinked |
two nodes scoring ≥ --similarity-threshold (default 0.80) with no link in either direction |
"these cover the same ground and don't reference each other — missing cross-reference?" |
no-semantic-neighbors |
a node whose best neighbor falls below --isolation-floor (default 0.20) |
"nothing in the corpus is close to this — dead concept, or missing context?" |
okfctl-search index build ./my-bundle # the plugin builds (needs a model)
okfctl lint ./my-bundle --semantic # core reads (needs none)Three deliberate behaviors:
- Opt-in. Without
--semantic, output is unchanged and the index is never read. - A missing index is an error, not a silent skip — it names
okfctl-search index build. A quiet structural-only fallback would let CI believe semantic checks ran when they did not. - Index drift is surfaced. Nodes added since the last
index buildproduce onestale-indexfinding listing them, so a partial pass never reads as a clean one.
Findings are only as meaningful as the embedder that built the index. With the default hash embedder, similar-unlinked effectively means "shares vocabulary"; with --embedder model2vec it means genuinely related subject matter. Build the index with model2vec if you intend to act on these findings.
search "query" [dir]— core lexical + graph-structural search, stdlib-only, no model or index. Matches concept nodes case-insensitively by title, tag, type, or body substring; restrict the surface with--field title|tag|type|body(defaultany). Reserved files (index.md/log.md) are never results. A zero-result query is not an error. Add--jsonfor a deterministic, CI-diffable array (path/title/type/neighborhood/matched_on).search --neighbors <node-path> [dir]— graph-structural query: the concept nodes within--depthhops (default 1) of a node in the link graph. Edges are treated as undirected (a node is a neighbor whether it links to the start or the start links to it), so a reader's traversal is symmetric. Results are ordered by (depth, path);--jsonemits the same fields plusdepth. This is the core, always-available baseline; the semantic side of search is the separateokfctl-searchplugin above (okfctl-search --semantic …).graph export <dir> --format json|dot— export the concept-node link graph in a machine format (deterministic, CI-diffable).json(default) emits nodes (path/title/type/neighborhood/orphan) + edges;dotemits Graphviz. For SVG, pipe DOT to Graphviz:okfctl graph export --format dot | dot -Tsvg > graph.svg.serve <dir> --addr 127.0.0.1:8080— start a local web server rendering the bundle as an interactive knowledge graph (click a node to inspect, follow edges, orphans highlighted, filter by type/neighborhood). The viewer is embedded in the binary — no separate install. Binds loopback by default; override with--addr.config set <key> <value>— set a config valueconfig get <key>— read a config valueconfig list— list all config valuesregistry add <name> <git-url>— register (or re-point) a named remote bundle source. Named remotes are plain git URLs — this isgit remotefor OKF bundles, not a hosted service, account system, or schema registry. They live in the one okfctl config store (keyedregistry.<name>), so there is no second config file.registry list— list the registeredname→urlsources, sorted by name.registry show <name>— print a source's git URL (nonzero exit on an unknown name).registry remove <name>(aliasrm) — unregister a source.connect <name|git-url> [dir]— materialize a remote bundle source into a local directory over git. A registered name resolves to its URL; an ad-hoc git URL is used directly. A fresh destination isgit cloned; an existing checkout of the same source is fast-forwarded (git pull --ff-only, never a history-rewriting merge); a non-empty directory that is not that checkout is left untouched.okfctlshells out togit(no new dependency) and does no authentication of its own — reaching a private URL is git's concern (ssh agent, credential helper). Defaultdiris a directory named after the source (trailing.gitstripped), matchinggit clone.completion <bash|zsh|fish>— generate a shell completion scriptversion— print the okfctl version (alsookfctl --version); reports the release tag injected at build time, ordevfor a plain source build
Every command that walks a bundle (validate, lint, analyze, search,
graph export, index build/check) prunes vendored and derived directories
by default — a Python virtualenv (.venv), node_modules, a vendor/ tree, or
a build-output dir (dist, build, target, …) sitting under the bundle root
holds .md files nobody authored as knowledge, and walking them pollutes every
report. The prune is by directory base name at any depth (see
DefaultSkipDirs), applied once in the loader so all commands share identical
scope, and it never touches the bundle root itself.
Two guardrails keep this from silently eating your work:
--no-ignorerestores the full walk on any of those commands, so a directory whose name happens to match the skip list (real content you deliberately authored there) is always recoverable.- The skip is never silent: when the walk prunes anything, the command
prints a note to stderr naming the skipped directories and pointing at
--no-ignore.
This is a built-in default, not a policy read from config — okfctl deliberately
does not consult .gitignore (curation scope and version-control scope are
different questions) and needs no .okfctlignore to be usable on a real tree.
validate enforces the OKF spec floor only: every node must carry a
non-empty type (OKF §7). Unknown type values are allowed — the
walking skeleton does not enforce a taxonomy, so --type Reference,
--type Concept, or any other string all pass.
Apache-2.0. See LICENSE.