Skip to main content

AD-010: Bowrain CLI and Project Model

Summary

Bowrain ships as a manifest-driven kapi plugin, not as part of the kapi binary. The bowrain commands (push, pull, auth, the kapi bowrain … group, the server-up/server-status plumbing behind the built-in up/status, …), the source connector that implements push/pull against bowrain-server, the bowrain MCP tools, and the recipe-schema decoders that validate server:/hooks:/automations: all live in bowrain/plugin/ and compile into a standalone plugin binary, kapi-bowrain (built from bowrain/plugin/cmd/kapi-bowrain/).

The kapi binary contains zero bowrain code. It discovers the installed plugin at runtime by reading its manifest.json, then dispatches kapi push, kapi pull, … to kapi-bowrain as a subprocess. The discovery and dispatch mechanics are the framework's unified plugin model — see AD-framework-007: Plugin System. There is one user-facing CLI, kapi, and bowrain is something you install into it (kapi plugins install bowrain, or brew install neokapi/tap/bowrain-cli).

A bowrain project is just a kapi project with a server: block on its recipe — same kapi.yaml recipe file, same .kapi/ state directory, same discovery rules as the framework. Commands walk upward from the current working directory looking for a file named kapi.yaml, in the same style as git and terraform.

This is the only project model. Bowrain does not maintain a parallel project schema; the recipe loader, validator, layout discovery, sync cache, and content iteration all live in the framework's core/project package. Bowrain registers its extension schema, commands, MCP tools, and source connector via process-global registries at init() time inside the kapi-bowrain binary.

Context

Developer-facing content pipelines live inside source repositories. They need a first-class command surface that:

  • tracks which files feed a bowrain-server project,
  • pushes source changes and pulls translations reliably,
  • composes with git, CI, and Makefile-driven workflows, and
  • stores its own configuration alongside the code it describes.

The framework's kapi.yaml recipe model (AD-framework-008: Kapi Project Model) already provides a portable, gitignore-aware project layout with stateless recipe + sibling state directory. Bowrain extends it with a server: block and a few top-level lifecycle/governance fields.

Decision

The bowrain plugin

Bowrain's behavior lives in Go packages under bowrain/plugin/:

bowrain/plugin/
├── plugin.go ← anchor; blank-imports the three sub-packages
├── schema/ ← recipe schema decoders (no CLI deps)
│ ├── server.go, hooks.go, assets.go, brand_voice.go, stream.go
│ └── extension.go ← init() RegisterExtensionGroup("bowrain", ...)
├── commands/ ← bowrain CLI commands (push, pull, init, auth, ...)
├── connector/ ← BowrainSourceConnector implementation
└── mcp/ ← bowrain MCP tools

Each sub-package's init() registers its features with the framework / shared CLI registries:

  • schema/extension.go calls coreproj.RegisterExtensionGroup("bowrain", ...)
  • commands/*.go each call cli.RegisterCommandFactory(...)
  • commands/register.go calls cli.RegisterAppInitializer(...) to install app.FallbackRunE (project flow resolution) and app.ExtraFlows (project flow listing)
  • mcp/tools.go calls cli.RegisterMCPToolFactory(...)

These registries are the framework's in-process plugin mechanism. The only binary that links them is kapi-bowrain, whose main.go blank-imports the anchor:

import _ "github.com/neokapi/neokapi/bowrain/plugin"

The kapi binary does not import bowrain/plugin. It loads bowrain at runtime as a separate process (next section), so the registries above populate inside kapi-bowrain, never inside kapi.

Distribution and discovery

Two binaries are produced from this source tree:

BinaryBuilt fromRole
kapikapi/cmd/kapiThe single user-facing CLI. Framework-only; no bowrain code.
kapi-bowrainbowrain/plugin/cmd/kapi-bowrainThe bowrain plugin binary. Carries a manifest.json declaring its commands, MCP tools, schema extensions, and source connector.

kapi-bowrain is installed into a plugin directory rather than placed on PATH. kapi discovers it at runtime by scanning, in order, $KAPI_PLUGINS_DIR, $XDG_DATA_HOME/kapi/plugins (default ~/.local/share/kapi/plugins), and the system plugin roots, looking for <dir>/bowrain/{manifest.json, kapi-bowrain}. Two paths install it there:

kapi plugins install bowrain # via the plugin registry
brew install neokapi/tap/bowrain-cli # Homebrew (depends on the kapi formula)

Discovery and dispatch are not bowrain-specific — they are the framework's unified plugin model, shared with okapi-bridge and any other plugin. See AD-framework-007: Plugin System for the manifest schema, discovery precedence, and the A/B/C transport modes referenced below.

The Wails desktop apps (kapi-desktop, bowrain-desktop) blank-import bowrain/plugin/schema directly so they validate bowrain recipes when opened; push/pull is invoked by spawning kapi-bowrain.

Available commands

With the plugin installed, kapi exposes:

  • Framework commands (built into kapi): up, run, extract, merge, add, rm, ls, flows, exec, tools, formats, plugin, terms, memory, credentials, mcp, version. The local project-content commands add/rm/ls are core — they edit and list the kapi.yaml recipe's content, which is local configuration, not a server concern (a server-connected project just declares requires: bowrain).
  • Bowrain commands (contributed by the kapi-bowrain manifest) follow the no-shadowing rule: installing the plugin never changes what an existing kapi verb means. New verbs attach top-level: push, pull, diff (local-vs-server block diff; the format-aware file differ's kapi-side spelling is kdiff only, so the name is free), auth, stream, ui, workspace. The plugin ships no config command at all: there is one kapi config verb, and the plugin extends it by claiming the bowrain.* key namespace in its manifest (capabilities.config_namespaces), which kapi routes to bowrain/bowrain.yaml under the user config directory. Recipe keys stay positional (kapi config server.url …). Participation in core verbs happens through a command contribution (kapi init --server …) and hidden dispatch plumbing (server-status, merged into kapi status; server-up, the server venue behind kapi up; server-ls, the SYNC column on kapi ls) — never a replacement command. Sync state lives on the built-ins: aggregate standing in kapi status, per-file pending counts in kapi ls.

These commands separate three concerns. Transport is push and pull: pure data movement that makes the local checkout and the server replica consistent (Merkle diff, conflicts, terminology hand-off), never producing translations. Convergence is up: the built-in owns the verb in every install and resolves the venue itself — when the recipe declares server: and the plugin is installed, kapi up prints the resolved venue and dispatches the run to the hidden server-up plumbing, which pushes drift, converges on the server (org keys, shared assets), streams progress back live, and pulls the results down; kapi up --local runs the loop on this machine and pushes the results. Venue — where up's compute executes — is therefore a property of the project (server: presence plus the server.converge policy below), not a separate verb. There is no sync verb: pushing and then waiting for the server to translate is exactly what kapi up expresses in a connected project.

Each bowrain capability is dispatched according to its manifest entry:

  • Commands run one-shot — kapi push forks kapi-bowrain command push …, inheriting stdin/stdout/stderr and propagating the exit code (Mode A).
  • MCP tools (project_status, project_pull, project_push) are served by a kapi-bowrain mcp-server session that the shared kapi mcp command launches and proxies (Mode B).
  • The source connector (bowrain-source) runs inside a long-lived kapi-bowrain daemon reached over a Unix-domain socket; kapi routes push/pull/status through it so recipe parsing and sync state stay in the plugin process (Mode C).

Without the plugin, kapi is framework-only — a bowrain recipe still loads, but kapi push/kapi pull are unavailable and a recipe that declares requires: [bowrain] fails validation.

All bowrain server-sync commands require a .kapi project whose kapi.yaml recipe declares a server: block. Discovery is identical to kapi: walk upward looking for a kapi.yaml recipe; the recipe must declare server: for push/pull/status to be meaningful.

Project layout

my-app/
├── kapi.yaml # the recipe (committed) — fixed, conventional filename
├── .kapi/ # state (gitignored)
│ ├── manifest.yaml
│ ├── memory.db # authoritative project content memory
│ ├── terms.db # authoritative project terms store
│ ├── flows/ # optional file-per-flow definitions
│ │ └── pseudo.yaml
│ └── cache/ # all regenerable caches under one roof
│ ├── blocks.db # block store
│ ├── sync-cache.json # kapi push/pull state (only with server: block)
│ ├── extractions/
│ └── collections/
└── src/
└── locales/
├── en.json
└── fr.json

Ownership:

  • kapi.yaml — hand-edited, committed to git. The single source of truth for project configuration. The recipe is a fixed, conventional YAML config filename, so every editor and every code host (GitHub, GitLab) applies YAML syntax highlighting to diffs and previews with no configuration. See AD-framework-008 for the full rationale.
  • .kapi/cache/ — CLI-owned, gitignored. Contains everything that's cheaply regenerable: the block store, the kapi sync cache, extraction intermediates, overlay layers.
  • .kapi/memory.db, .kapi/terms.db, .kapi/manifest.yaml — kapi-owned, authoritative. The first two are the project's content memory and terms store. Gitignored by default; opt in to commit them when cross-clone reproducibility matters.
  • .kapi/flows/*.yaml — optional file-per-flow definitions, hand-edited, committed. Bowrain reads these in addition to inline flows: declared on the recipe.

Recipe with server connection

version: v1
name: My App

defaults:
source_language: en
target_languages: [fr, de, ja]
collection: ui/strings
exclude:
- "**/*.test.json"
- "node_modules/**"

content:
- path: src/locales/**/*.json
format: json
- path: content/docs/**/*.md
format: markdown
target: i18n/{lang}/docs/{path}/{filename}
- path: src/es/**/*.json
format: json
source_language: es # per-entry source language override
collection: spanish-ui # per-entry collection routing override

plugins:
okapi-bridge: "^1.47.0" # map form, not list

flows:
pseudo:
steps:
- tool: pseudo-translate
config: { method: extended }

# Optional bowrain-server connection — presence enables push/pull and makes
# the server the default venue for `kapi up`.
server:
url: https://bowrain.example.com/my-team/abc123
stream: $auto # auto-detect from git branch / CI
converge: on-push # on-push (default) | manual

# Top-level lifecycle policy:
hooks:
pre-push: [qa]
post-pull: [update-stats]

automations:
- name: notify-on-parked
trigger: run-parked
actions:
- type: slack
config: { channel: "#translation" }

# Top-level governance / asset policy:
assets:
enabled: true
max_size: 100MB

brand_voice:
profile: company-profile
channel: marketing

The recipe schema is owned by the framework. Field shape, validation, loading, saving, and walk-up discovery all live in core/project. Bowrain imports them; it does not redefine them.

server: block

Only the connection coordinates sit under server::

  • url — compound URL encoding <server>/<workspace>/<project-id> for workspace projects, or <server>/projects/<project-id> for direct (anonymous) projects.
  • stream — which server-side content stream to sync against. The sentinel $auto (default when unset) auto-detects the stream name from CI environment variables (GitHub Actions, GitLab CI, CircleCI, Azure DevOps, Jenkins, Travis CI, Buildkite) or from the local git branch. master normalizes to main. The chain --stream flag → BOWRAIN_STREAM env var → recipe field → auto-detect → main decides the active stream per command.
  • converge — the server-side convergence policy: when the server runs up on the project's behalf. on-push (the default for connected projects) converges after every push; manual converges only when kapi up is invoked. This makes server-side translation an explicit, visible policy rather than a hidden side effect of push — the way a repository configures push to trigger CI. See AD-022: Convergence as a Service.

Lifecycle (hooks, automations) and content/governance (assets, brand_voice) are top-level on the recipe — they describe project-owned policy, not server identity.

Sync cache

.kapi/cache/sync-cache.json tracks the last known server state for incremental sync. Per-file block hashes, per-stream cursors, claim tokens for anonymous projects, and cached server metadata. Stored as JSON, always gitignored (it lives under .kapi/cache/ which the user typically gitignores wholesale).

Deleting the file is safe — push and pull repopulate it from server state. The cache is regenerable.

Auth

Bowrain access tokens live in the OS keychain — the same store kapi uses for LLM provider keys (keyringService = "kapi"). Tokens are keyed by server URL (bowrain-auth:<server-url> for the access token, bowrain-refresh:<server-url> for the refresh token), so multiple bowrain instances coexist without collision.

Non-secret metadata (server URL, user info, expiry) lives in auth.json in the bowrain config directory (~/.config/bowrain on Linux, ~/Library/Application Support/bowrain on macOS). The file format is JSON; the access / refresh token fields are intentionally absent on disk — they're loaded from the keychain by bowrain.auth.LoadAuth().

For CI, the BOWRAIN_AUTH_TOKEN environment variable bypasses both the file and the keychain (paired with BOWRAIN_SERVER_URL).

Anonymous projects use a claim token stored in .kapi/cache/sync-cache.json (gitignored) rather than the keychain — the token is project-scoped rather than user-scoped.

Workflow

kapi init # create kapi.yaml + .kapi/, populate server: block
kapi auth login # OAuth login → tokens to keychain, metadata to the bowrain config dir
kapi status # show standing: coverage, gates, and what's pending push / pull
kapi push [--dry-run] # transport: scan local files, diff against cache, upload changed blocks
kapi pull [--locale fr] # transport: fetch translations from server, write to local files
kapi up # convergence: run the loop on the server (push → converge → stream → pull)
kapi up --local # converge on this machine, then push the results
kapi ls # list tracked files (--stats adds block/word counts)
kapi add <path> # append a content entry to the recipe
kapi rm <path> # remove or exclude a content entry
kapi mcp # stdio MCP server exposing project tools

kapi init writes a kapi.yaml recipe, defaulting the recipe's name: label to the current directory's basename. The recipe lands at the project root; the sibling .kapi/ state dir is created empty (caches populate as commands run). No .bowrain/ directory is ever created.

Consequences

Positive:

  • One project model, one schema, one loader. Bowrain consumes the framework directly — no parallel Config struct, no duplicate validation, no separate walk-up routine.
  • Users with both kapi and bowrain workflows on a project see one recipe, one state directory, and one keychain prompt for credentials.
  • .kapi/cache/ consolidates all regenerable state under a single predictable path that can be safely deleted.

Negative:

  • Bowrain features that aren't yet expressed on the framework recipe (plugin registries, per-flow config maps, LocalFormatPreset.Description) are deferred or dropped. Future work re-adds them as framework recipe fields when needed.
  • A directory holds at most one kapi.yaml, so the recipe with its server: block is unambiguous by construction — discovery never has to choose between competing recipes.