Contributing guide
Recipes for the most common changes, and how the code is tested. Read architecture and the agent loop first.
Build and test
go build -o larik ./cmd/larik
go test ./...
Prepare a GitHub release
The release script packages binaries locally. It does not create a tag, push code, or publish a GitHub Release. v0.2.0 is the next planned version; the website continues to show the last published version and disabled download cards until the assets are uploaded.
- Add this repository as
originif it is not configured yet (git remote add origin <GitHub repository URL>). Review and commit the changes you intend to publish; the script refuses a dirty checkout. - Run
scripts/package-release.sh v0.2.0. It runsgo test ./..., builds the website to check links, and creates five archives plusSHA256SUMSindist/release/v0.2.0/. The archives cover macOS and Linux on arm64 and amd64, plus Windows on amd64. Check the archives and checksums before publishing. - Create and push the tag:
git tag -a v0.2.0 -m "Larik v0.2.0", thengit push origin main v0.2.0. Upload the files indist/release/v0.2.0/to a GitHub Release for that tag (for example, withgh release create v0.2.0 dist/release/v0.2.0/* --verify-tag --generate-notes). - After every asset is available, change
website/site.jsontov0.2.0and fill each download URL withhttps://github.com/<owner>/<repo>/releases/download/v0.2.0/<file>, using the owner and repository fromorigin. Rename the Unreleased changelog entry tov0.2.0, add the publication date, rebuild the website, and commit and push those site changes. Until then, keep URLs empty so visitors do not get broken downloads.
The script embeds 0.2.0 into each binary; a release binary prints larik 0.2.0 with --version.
No test needs an API key or network access:
- The agent loop is tested with
fakeProviderin agent_test.go, which replays scripted assistant messages and records every request, so tests can assert on exactly what was sent. - Provider adapters are tested against canned SSE from llmtest.NewServer.
- MCP and LSP have tiny fake servers under
testdata/(mcp/testdata/server, lsp/testdata/fakels). - Server mode tests drive the real HTTP handler with a scripted provider (server_test.go).
- Subagents and worktrees create real git repositories in temp dirs.
After changing code, refresh the knowledge graph used by the project's agent instructions:
graphify update .
Add a built-in tool
-
Implement
tools.Toolininternal/tools(or in its own package if it has dependencies, likeweborlsp):type Count struct{} func (Count) ReadOnly() bool { return true } func (Count) Spec() llm.ToolSpec { return llm.ToolSpec{ Name: "count", Description: "Count lines in a file.", Schema: schema(`{"type":"object","properties":{ "path":{"type":"string"}},"required":["path"]}`), } } func (Count) Run(ctx context.Context, env *Env, input json.RawMessage) Result { in, err := decode[struct{ Path string `json:"path"` }](input) if err != nil { return errorf("%v", err) } data, err := os.ReadFile(env.Abs(in.Path)) if err != nil { return errorf("%v", err) } return Result{Content: strconv.Itoa(bytes.Count(data, []byte("\n")))} } -
Register it: add it to
tools.Builtin()(tool.go:136), or append it tobaseToolsinapp.Setup(app.go:54) if it needs setup. -
Decide its safety properties:
ReadOnly() == trueonly if it never changes anything. Read-only tools skip prompts and run in plan mode.- If it changes nothing locally but still needs approval (network), return
falseand implementConcurrencySafe() boolto allow parallel runs. - If it takes a path or a command, make sure
permission.Subject(permission.go:132) extracts it (field namespath,commandorurl), so rules can match it. - If it writes files, call
env.checkFresh(path)andenv.beforeWrite(path)first, and returnenv.afterWrite(ctx, path, res)so undo and diagnostics work, asWriteandEditdo in fs.go.
-
Keep the description and schema stable. They're part of the cached prompt prefix.
-
Add a test in tools_test.go.
Add a provider
If it speaks OpenAI Chat Completions, you probably need no code: users can configure "type": "openai-compatible", or you add a preset to openaicompat.Presets (compat.go) with its base URL and key variable.
Otherwise, write an adapter:
- Create
internal/llm/<name>/with aProviderimplementingName()andStream(ctx, llm.Request) iter.Seq2[llm.StreamEvent, error]. - In
Stream:- Build the vendor request from
req.System,req.Toolsand, for each message,llm.ReplayableBlocks(m, Name, req.Model). Never send another provider's thinking or opaque blocks. - Yield
EventTextDelta,EventThinkingDeltaandEventToolUseStartfor display as they arrive. - Accumulate the full response, convert it to an
llm.MessagewithModel: req.Model, setProvider: Name(andSignature/Raw) on any block the vendor needs back, and yield exactly oneEventDonewithUsageand aStopReason. - Map errors: HTTP status →
llm.ClassifyStatus; prompt-too-long → wrapllm.ErrContextOverflow. - Stop promptly when
yieldreturns false orctxis cancelled.
- Build the vendor request from
- If the API has no built-in retries, wrap it with
llm.WithRetry(p, 4)where it's built. - Wire it into
providers.buildandisBuiltin(providers.go), and add catalog entries in catalog.go for its models (context window, max output, prices). - Optionally implement
llm.ModelProberif the server can report its real context window or tool support. - Test against
llmtest.NewServerwith recorded SSE: streaming text, a tool call, thinking replay, an error status. Assert onLastBody()to check what was sent.
Add a slash command
- Add an entry to
commandsin palette.go (name, args, description, section). This feeds/helpand the palette. - Handle it in
model.commandin commands.go. If it would race with a running turn (it changes the model, the context or files), add it to the "unavailable while a turn is running" list. - Put the logic on
AgentorAppif the server should offer it too, then add a route inServer.Handler(server.go) that runs it throughlive.idleDo. - Document it in the README's command table.
Add a hook event
- Add the constant to hooks.go and to
hooks.Events(lifecycle order). - Add any payload fields to
hooks.Input. - Fire it from the agent with
a.runHook(ctx, emit, hooks.Input{HookEventName: …}, target)at the right point in agent.go or runtools.go, and decide whatBlock,Halt,ContextandPermissionmean for it. Keep the Claude Code semantics if the event exists there. - Test it in hooks_test.go with a small shell script.
Add a front end
Consume Agent.Run and Agent.Background() and answer every EvPermission exactly once. Nothing in the loop needs to change. headless/print.go is the smallest complete example, including how to wait for background tasks with RunNotifications.
Invariants to keep
These are easy to break by accident and expensive to debug:
- Don't change the prompt prefix mid-context. System prompt, tool list and earlier messages must stay byte-identical between requests. New tools, prompt text or settings that affect the prompt apply from the next fresh context: build them in
App.SystemPromptorApp.loadTools, which run at session start and onClear. - Don't rewrite the transcript. Append entries; express corrections as notes on the next user message.
- Every
tool_usegets exactly onetool_result, in order, even on error or interrupt. - Branch only at turn boundaries (
session.IsPrompt). - Shared config may only tighten. Anything that widens access must be read only from trusted files in
config.merge. - The loop stays UI-agnostic. No front-end imports in
internal/agent; new information travels as anEvent. - Never hold
Agent.muacross I/O (model requests, tool runs, hooks).