Thank you for your interest in contributing to Tusk! This document provides guidelines and information to help you get started.
- Go 1.26+
- golangci-lint (for linting)
- lefthook is installed automatically by
make setup-hooks
git clone https://github.com/germanamz/tusk.git
cd tusk
make setup-hooks
make build
make testIf you'd rather develop in a hermetic, sandboxed environment with all tooling pre-installed, see the Dev Container section below.
A .devcontainer/ is provided that builds a hermetic Ubuntu 24.04 image with the Go SDK, Node, and all project tooling pre-installed. Outbound network access is gated by an in-container egress proxy (tinyproxy) plus iptables rules, making it safe to run AI agents like Claude Code inside.
- Docker Desktop (or another Docker engine)
- The dev container CLI:
npm install -g @devcontainers/cli
From the repository root:
make devcontainer-upThe first build downloads the Go SDK, Node.js, and Go tools and might take several minutes. Subsequent starts complete in well under a second — the entrypoint just applies iptables and starts the egress proxy.
The container has two distinct users; pick the one that matches what you're doing.
| User | Sudo | Egress | Use for |
|---|---|---|---|
claude |
no | only allowlisted hosts (via tinyproxy) | day-to-day editing, running agents, make build, make test |
dev |
yes (NOPASSWD) | unrestricted | installing extra packages, fetching from non-allowlisted hosts, ops tasks |
# Default shell as claude (sandboxed) — IDE/devcontainer CLI attach here
devcontainer exec --workspace-folder . bash
# Shell as dev (unrestricted) via the container ID
CID=$(docker ps --filter "label=devcontainer.local_folder=$PWD" -q)
docker exec -u dev -it "$CID" bashOr use the Makefile shortcuts, which handle the container ID lookup for you:
make devcontainer-shell # exec in as claude (sandboxed)
make devcontainer-shell-ops # exec in as dev (unrestricted)gopls, dlv, golangci-lint, lefthook, claude (Claude Code CLI), fd, ripgrep, git, gh-style tooling. All on PATH for both users — make build, make test, and make lint work without further setup.
Allowed hostnames live in .devcontainer/tinyproxy-filter:
api.anthropic.comproxy.golang.org,sum.golang.orggithub.com,codeload.github.com,*.githubusercontent.comregistry.npmjs.org,*.npmjs.org
Anything else is rejected by tinyproxy with a 403, and direct (non-proxied) connections are dropped at the kernel by iptables. The dev user is exempt from both.
To allow a new host, append a regex to .devcontainer/tinyproxy-filter and rebuild (see below).
The claude CLI is pre-installed on PATH. Auth and configuration come from the host via the bind-mounted ~/.claude directory, so a session you've signed into on the host carries over with no extra setup.
# Open a sandboxed shell and start claude in the workspace
devcontainer exec --workspace-folder . bash -lc 'cd /workspaces/tusk && claude'Or, from an existing shell inside the container:
cd /workspaces/tusk
claudeWhat this means in practice:
- The agent runs as the
claudeuser — it inherits the same egress posture as your shell. It can reachapi.anthropic.comand the other allowlisted hosts; everything else returns a tinyproxy 403 or is dropped at the kernel. - The agent has no
sudo, so it cannot disable the firewall, restart tinyproxy, or alter the iptables rules — even if it tried. - File edits land in
/workspaces/tusk, which is the bind-mounted host repo, so changes show up on the host immediately. - Local tooling (
go test,golangci-lint, your locally-builttuskbinary) is available for the agent to invoke during its workflow. - If the agent needs a host that isn't on the allowlist, the request fails. Add it to
tinyproxy-filterand rebuild (see Egress allowlist).
If you want to run claude without the sandbox restrictions (e.g., for an interactive session that needs broader network access), exec in as dev:
CID=$(docker ps --filter "label=devcontainer.local_folder=$PWD" -q)
docker exec -u dev -it "$CID" bash -lc 'cd /workspaces/tusk && claude'CID=$(docker ps --filter "label=devcontainer.local_folder=$PWD" -q)
# claude has no sudo:
docker exec -u claude "$CID" sudo -n true # expect failure
# Allowlisted host via proxy — succeeds:
docker exec -u claude "$CID" curl -sI https://api.anthropic.com | head -1
# Non-allowlisted host — tinyproxy 403:
docker exec -u claude "$CID" curl -sI https://example.com | head -1
# Direct connection (proxy bypassed) — iptables drops:
docker exec -u claude "$CID" curl -sI --noproxy '*' --max-time 3 https://1.1.1.1
# dev is unrestricted:
docker exec -u dev "$CID" curl -sI https://example.com | head -1# Stop and remove the current container
make devcontainer-down
# Rebuild from scratch (e.g., after editing the Dockerfile or filter list)
make devcontainer-down && devcontainer up --workspace-folder . --build-no-cache- The workspace is bind-mounted from the host, so file ownership inside the container reflects host UIDs (not the in-container
claude/devUIDs). ~/.claudeis bind-mounted between host and container, so Claude Code auth/config is shared.- The Neovim config is cloned into the image at build time (from
germanamz/simple-nvim), not bind-mounted. That means lazy.nvim can manage its lockfile freely, but config edits made on the host don't appear inside the container until you change thegit clonesource in the Dockerfile and rebuild. - Egress filter changes in
tinyproxy-filterrequire a container rebuild to take effect.
- Fork the repository and create a feature branch from
main. - Make your changes following the conventions below.
- Run tests and linting before submitting.
- Open a pull request against
main.
make test # Unit + e2e tests
make test-race # Tests with race detector
make test-e2e # E2e tests only
# Single unit test
go test -v ./service -run TestTaskCreate
# Single e2e scenario
go test -v ./tests/e2e -run TestErrorHandlingmake vet
make lintTusk follows a layered architecture. Dependencies flow downward only:
Interface Layer (CLI, MCP server)
|
Service Layer (business logic)
|
Repository Layer (interfaces)
|
Storage Implementations (SQLite)
When contributing, respect these boundaries:
- Interface layer (
internal/tui/,internal/mcp/) translates external protocols into service calls. No business logic here. - Service layer (
service/) contains all business logic. Services accept repository interfaces via constructor injection. - Repository layer (
repository/) defines Go interfaces only. - Storage layer (
sqlite/) implements repository interfaces.
For code style, see STYLE.md.
Use conventional commits with scope:
feat(cli): add tree view command
fix(sqlite): handle null parent_id in query
test(e2e): add filter syntax scenarios
docs: update README with MCP examples
- Use sentinel errors from
domain/errors.go. - Check errors with
errors.Is(). - Available sentinels:
ErrNotFound,ErrConflict,ErrCyclicBlock,ErrInvalidTransition,ErrDuplicateRelation.
- Optimistic locking: every mutable entity has a
versionfield. Updates must useWHERE id = ? AND version = ?. - Double-pointer updates:
TaskUpdateuses**string/**uuid.UUIDfor nullable fields (nil= don't change,*nil= set NULL,*"value"= set value). - Soft delete: tasks transition to
deletedstatus via workflow, not removed from DB.
End-to-end tests live in tests/e2e/ and use a custom harness. Each scenario runs 4 times (2 DB config modes x 2 output formats). Reference prior step results with $0.short_id:
scenarios := []Scenario{
{
Name: "create_and_get",
Steps: []Step{
{Args: []string{"task", "create", "My task"}},
{Args: []string{"task", "get", "$0.short_id"}},
},
},
}When reporting bugs, please include:
- Steps to reproduce
- Expected vs actual behavior
- Go version and OS
- Tusk version or commit hash
By contributing to Tusk, you agree that your contributions will be licensed under the Apache 2.0 License.