1 # AGENTS.md
2
3 This file provides guidance to AI coding agents when working with code in this repository.
4
5 ## What this is
6
7 `sigit` ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM
8 inference on-device (via the `onde` crate / GGUF models) or against a hosted/OpenAI-compatible
9 endpoint. It exposes itself two ways from the *same* binary, chosen at startup by whether stdin
10 is a TTY:
11
12 - **ACP mode** (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor
13 integration (Zed, VS Code ACP Client). Cross-platform.
14 - **Interactive terminal mode** (stdin is a TTY): a full-screen ratatui chat UI. **Unix-only** —
15 it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.
16
17 Before the TTY/ACP split, `main` also dispatches the account subcommands `sigit login`,
18 `sigit logout`, `sigit whoami` (see `src/main.rs` `main()`).
19
20 ## Working in this repo
21
22 **IMPORTANT — branch naming:** Prefix every working branch with `feature/` (new functionality)
23 or `fix/` (bug fixes) — never a tool- or agent-name prefix like `claude/`. Name the branch after
24 the *changes it contains*, as a short, descriptive, kebab-case slug that is self-explanatory
25 from the name alone (e.g. `feature/tool-permission-system`, `fix/glob-mtime-sort`), never after
26 a task, ticket, or session id (not `feature/task-q003hm`).
27
28 **IMPORTANT — pull request target:** Always open pull requests against the `development` branch,
29 never `main`. `main` is release-only; `development` is where day-to-day work integrates, and the
30 release merge (see the `sigit-code-release` skill) is the one thing that ever puts commits on
31 `main`.
32
33 This rule needs help to hold, because the repository's default branch is `main`: both
34 `gh pr create` and the GitHub web form pre-select it, so a PR lands on the wrong base unless the
35 base is passed explicitly. Pass it every time, and check that it took:
36
37 ```sh
38 gh pr create --base development --head <branch>
39 gh pr view <number> --json baseRefName
40 ```
41
42 Retargeting a PR that was opened against `main` costs nothing before it merges:
43 `gh pr edit <number> --base development`. After it merges it costs real work — the change sits on
44 `main` while `development`, which the next release is cut from, does not have it, and someone has
45 to carry it back by hand. PR #65 went in that way.
46
47 **IMPORTANT — branch off `development`:** Start every working branch from the latest
48 `origin/development`. Exception: when new work *depends on* a feature branch that has not merged
49 yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
50 branch instead. When stacking: merge the base PR into `development` first, then rebase the
51 stacked branch onto `development` before opening its pull request, so each PR shows only its own
52 commits.
53
54 **IMPORTANT — run CI before pushing:** Run the full CI gate locally and confirm it is green
55 *before* pushing a branch or opening a pull request — never push work that fails these:
56
57 ```sh
58 cargo fmt -- --check # formatting
59 cargo clippy --tests -- -D warnings # lint (warnings are errors)
60 cargo test --locked # tests
61 ```
62
63 ## Agent assets layout
64
65 `.agents/` is the canonical home for agent assets; every other agent path in
66 the repo is a symlink into it, never a copy:
67
68 - `.agents/AGENTS.md` — this file, the project instructions. The root
69 `AGENTS.md` and `CLAUDE.md` are symlinks to it, so agents.md-standard tools,
70 Claude Code, and sigit itself (via `src/instructions.rs`, which prefers
71 `AGENTS.md` over `CLAUDE.md` in the same directory and therefore reads it
72 once) all load the same content. Edit this file; never replace the root
73 symlinks with real files.
74 - `.agents/skills/` — all project skills. `.claude/skills` is a symlink to it,
75 which is how both Claude Code and sigit itself (via `src/skills.rs`) discover
76 them. Add new skills under `.agents/skills/<name>/SKILL.md`, never as real
77 files under `.claude/`.
78
79 ## Build / test / lint
80
81 ```sh
82 cargo build # debug build
83 cargo build --release # release binary at target/release/sigit
84 cargo run # launches interactive TUI (stdin is a TTY)
85 cargo test # CI runs: cargo test --locked --target <target>
86 cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
87 cargo fmt -- --check # CI gate (edition 2024)
88 ```
89
90 CI (`.github/workflows/ci.yml`) runs fmt + clippy + test across four targets:
91 `aarch64-apple-darwin`, `x86_64-apple-darwin`, `x86_64-unknown-linux-gnu`,
92 `x86_64-pc-windows-msvc`. Clippy is `-D warnings`, so warnings fail the build.
93
94 Run a single test: `cargo test <test_name>`.
95
96 ## Critical platform constraint: `#[cfg(unix)]` dead code
97
98 The interactive client is `#[cfg(unix)]`-only. The `InferenceBackend` seam (`backend.rs`) and
99 provider resolution (`provider.rs`) are consumed by both the interactive client and the ACP
100 server, but several of their items are reached only through the Unix-only interactive paths, so
101 the dead-code lint is suppressed *on non-Unix targets only*.
102
103 Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
104 When touching `backend.rs`, `provider.rs`, or the interactive path, keep the `cfg` gates intact —
105 don't "fix" an unused-warning by deleting code that's live on Unix.
106
107 ## Architecture
108
109 The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an
110 `InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
111 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
112
113 - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
114 new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`
115 (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly
116 smbCloud, and stay general otherwise).
117 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
118 `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
119 `OpenAiBackend` (any OpenAI-compatible HTTP endpoint).
120 - **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
121 wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
122 `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
123 - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
124 `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
125 `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
126 spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
127 commit attribution: when a command creates a new commit that lacks the
128 `Co-Authored-By: siGit Code` trailer (`COMMIT_CO_AUTHOR_TRAILER`), it amends the trailer in —
129 unless the commit already exists on a remote, which is never rewritten. Also owns the `task`
130 tool: a nested agent loop in a fresh conversation, offered only when `subagent_available()`
131 (a subagent factory is registered — see `register_subagent_factory_for` in `main.rs`; on-device
132 registers a `None`-returning factory since onde has a single shared history). A subagent's
133 toolset is a hard-gated read-only allow-list (`SUBAGENT_TOOL_NAMES`) that is never expanded by a
134 configurable subagent type (see `src/subagents.rs`) — only ever narrowed — so a `.sigit/agents/*.md`
135 file can't grant itself `edit_file`/`run_command` and bypass the permission system. Also owns
136 `web_search`: a thin native wrapper around the official MCP server's Brave-Search-backed
137 `web_search` tool (`mcp__sigit__web_search`, implemented server-side in `sigit-si`'s
138 `WebSearchService` + `Mcp::Tools::WebSearch`), offered only when that MCP tool was actually
139 discovered (i.e. the user is signed in to siGit Code Cloud — see `web_search_available`).
140 Wrapping it natively rather than leaving it as a raw `mcp__*` tool matters for two reasons:
141 `permissions::classify` treats every `mcp__*` tool as mutating (an "ask" prompt on every call),
142 while `web_search` is read-only like `read_website`; and the raw delegate name is filtered out
143 of the assembled tool list (`is_web_search_delegate`) so the model sees one clean option, not
144 two names for the same tool. Execution still forwards verbatim to `mcp::call_tool` — no second
145 HTTP/JSON-RPC implementation.
146 - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
147 folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
148 instructions) from `.sigit/skills/` and `.claude/skills/` in the cwd, `$SIGIT_CONFIG_DIR/skills/`,
149 and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
150 baked into the dynamically-built `skill` tool's description, and activating a skill (the model
151 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
152 `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
153 and only when at least one skill exists.
154 - **`src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
155 optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
156 body) from `.sigit/commands/` and `.claude/commands/` in the cwd, `$SIGIT_CONFIG_DIR/commands/`,
157 and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
158 (`.sigit/commands/git/commit.md` → `/git:commit`). Unlike skills there's no tool-call
159 indirection: invoking one works exactly like the built-in `/init` — `commands::render`
160 substitutes `$ARGUMENTS`/`$1..$9` in the body against whatever followed the command on the
161 input line, and the result is fed to the model as a normal turn through the ordinary tools and
162 permission checks. Resolution happens where each surface (`main.rs`/`chat.rs`) already
163 special-cases `/init`: an unrecognized slash command is tried against `commands::resolve_command`
164 before falling back to "unknown command". A custom command sharing a name with a built-in is
165 unreachable (built-ins match first in `parse_slash`) and is skipped when advertised to ACP
166 clients, with a warning logged.
167 - **`src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
168 files (YAML frontmatter `name` + `description`, optional comma-separated `tools:` allow-list,
169 then a Markdown body that becomes the subagent's system prompt) from `.sigit/agents/` and
170 `.claude/agents/` in the cwd, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
171 type's `name` as `task`'s `subagent_type` argument swaps in that system prompt and, if `tools:`
172 is set, narrows the offered toolset to its *intersection* with `SUBAGENT_TOOL_NAMES` — the
173 security-relevant narrowing logic lives in `tools.rs` next to that constant, not here; this
174 module only discovers and parses files. `SubagentFactory` (in `tools.rs`) takes the resolved
175 system prompt per call rather than baking one in at registration, so a single registered
176 factory serves both the default research subagent and every configured type.
177 - **`src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
178 `src/skills.rs` (`SKILL.md`) and `src/commands.rs` (`.sigit/commands/*.md`).
179 - **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
180 transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
181 `application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
182 `mcp.toml`; sigit spawns the server and speaks newline-delimited JSON-RPC over its
183 stdin/stdout, stderr inherited into sigit's log). `url` and `command` are mutually exclusive —
184 both or neither is a config error, logged and skipped. Both transports run the same
185 `initialize`/`tools/list` handshake and forward `tools/call`. Discovery is best-effort at
186 startup (`mcp::init`, called from both branches of `main()`) and cached in a process-global so
187 the synchronous spec builders (`mcp::tool_specs`) and the async dispatch (`mcp::call_tool`) can
188 both read it; `/reload` does *not* re-run it, so config changes need a restart. stdio children
189 live for the process; a dead child fails calls with an in-band error string (no auto-restart).
190 Tools are namespaced `mcp__<server>__<tool>`, appended in the `*_as_specs`/`build_tool_specs`
191 layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. Two servers are baked in:
192 the official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`, always HTTP, authed
193 with the cloud session token) and the smbCloud CLI server (`smb --mcp`, stdio, added only when
194 the `smb` binary is on `PATH`; opt out with `smbcloud = false` in `mcp.toml` or
195 `SIGIT_MCP_SMBCLOUD=off`). A user-defined entry named `sigit` or `smbcloud` overrides the
196 corresponding baked-in one. Extra servers live in `mcp.toml` (global
197 `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
198 `tests/mcp_stdio.rs`, driven by the test-only `src/bin/mcp_stdio_stub.rs` helper binary
199 (excluded from the published crate via `exclude` in `Cargo.toml`). The baked-in official
200 server is *also* listed in the public MCP Registry as `si.sigit/sigit` — a **remote**
201 Streamable-HTTP listing (`server.json` at the repo root, published by
202 `release-mcp-registry.yml`). Because it's a remote server, the registry's URL-match rule
203 forces a domain namespace (`si.sigit` ↔ `sigit.si`) verified by a DNS TXT record, not the
204 GitHub-OIDC scheme `smbcloud-cli` uses for its package listing.
205 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
206 `decision_for` before executing: read-only tools always run; mutating tools (and all
207 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
208 mutating tools with a present-a-plan message), session "always allow" grants, per-tool
209 overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
210 default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
211 `session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
212 inference task on a y/a/n prompt. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
213 serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
214 answer mid-turn — don't move them back inline, and don't await client requests from inline
215 handlers (deadlock).
216 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
217 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
218 walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
219 plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
220 (most specific) wins. The combined block is injected via `session_context_message` in `main.rs`
221 — pushed as a system message at every ACP session entry point (new/load/fork + model switch)
222 and appended to the system prompt on the cloud and TUI-startup paths.
223 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
224 `tokio::select!` to multiplex terminal events with streaming tokens.
225 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
226 Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
227 those read env vars once at init.
228 - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
229 against the account API and stores a session token. Performs no console I/O.
230 - **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
231 button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
232 S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
233 back: a loopback listener on `127.0.0.1:0` (the registration lists `http://127.0.0.1/callback`
234 with no port, and RFC 8252 §7.3 matching ignores the port, so one registration covers every
235 run), or the out-of-band URN, where the server renders the code and the user pastes it. The
236 resulting token goes into `credentials` like any other, and is served by `/api/v1/user` rather
237 than the deprecated `/api/v1/me` — OAuth tokens are rejected there. The scopes requested are
238 `user:read code:agent`; `code:agent` is what reaches `chat/completions` and the official MCP
239 server. Surfaces: ACP `authenticate` (loopback only — nowhere to show a code), a bare `/login`
240 in either chat surface, and `sigit login` (which adds the paste fallback, plus `--paste` to
241 force it and `--password` for the old email/password prompt).
242 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
243 - **`src/models.rs`** — model-picker types shared across platforms.
244
245 Slash commands (`/help`, `/models`, `/skills`, `/agents`, `/commands`, `/mcp`, `/login`, `/logout`,
246 `/whoami`, `/reload`, `/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via
247 `advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. `/init` is
248 special: instead of replying directly it substitutes `instructions::INIT_PROMPT` for the user
249 text and runs a normal agent turn that explores the repo and writes (or improves) `AGENTS.md`
250 through the ordinary tools and permission checks. User-defined commands (see `src/commands.rs`)
251 get the same treatment via the `SlashCommand::Unknown` fallback path, so anyone can add their own
252 `/name` commands without touching the built-in command list.
253
254 ## Model cache (macOS)
255
256 On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
257 `~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to
258 `~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run
259 downloads a GGUF model (~1–2 GB) from Hugging Face.
260
261 ## Logging
262
263 In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to
264 `$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real
265 terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
266 verbosity with `RUST_LOG`.
267
268 ## Relevant env vars
269
270 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
271 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
272 `SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
273 smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
274 server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
275 mutating tools; the escape hatch for clients without permission-request support),
276 `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
277
278 ## Releasing
279
280 Version lives in `Cargo.toml`. Update `CHANGELOG.md` for releases.
281
282 Publishing splits into two groups. **Language registries** each get their own tag-triggered
283 workflow: `release-crates`, `release-npm`, `release-nuget`, `release-pypi`. The `npm/`, `nuget/`,
284 and `pypi/` dirs hold their wrapper-package templates.
285
286 npm and NuGet authenticate with OIDC (Trusted Publishing), so neither needs a stored token. On
287 npm that is configured per package, not per org: all seven `@getsigit/*` names carry a trusted
288 publisher pointing at `release-npm.yml`, and a package added later needs the same setup or its
289 publish step will fail. OIDC also requires npm >= 11.5.1 and Node >= 22.14.0, which is why the
290 workflow upgrades npm and why `.nvmrc` cannot drop below 22.
291
292 **OS package managers** all publish somewhere outside this repo and all need checksums from the
293 GitHub release, so `release-github` builds the binaries, attaches the assets, and then dispatches
294 `release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in one
295 is logged as a warning rather than failing the others, so an unconfigured channel does not block a
296 release. Their inputs live in `packaging/`.
297
298 Every release asset now carries a `.sha256` sidecar, not just the macOS Homebrew tarball. Scoop,
299 winget, and the AUR PKGBUILD each need one, and they consume the raw binaries rather than the
300 tarball. `release-github` also builds a `.deb` and `.rpm` per Linux target with nfpm
301 (`packaging/nfpm.yaml`), packaging the already-built binary rather than re-invoking cargo.
302
303 Three of these need credentials or a one-time manual step before they work:
304
305 - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
306 Homebrew tap setup.
307 - winget needs a `WINGET_TOKEN` (PAT with `public_repo`) so `wingetcreate` can fork
308 `microsoft/winget-pkgs`. It is passed as `WINGET_CREATE_GITHUB_TOKEN` rather than `--token`,
309 which wingetcreate warns can leak the token into logs. `wingetcreate update` only works on a
310 package that already exists in `microsoft/winget-pkgs`, so the workflow checks the
311 `manifests/g/getSigit/siGitCode` path first and falls back to rendering
312 `packaging/winget/*.yaml.in` and running `wingetcreate submit` for a first submission. That
313 fallback runs once, then every later release takes the `update` path.
314 - The AUR needs `AUR_USERNAME`, `AUR_EMAIL`, and `AUR_SSH_PRIVATE_KEY`. It publishes `sigit-bin`
315 (a prebuilt binary) so Arch users are not compiling the on-device inference stack to install a
316 CLI.
317
318 `[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not
319 just a nicety. See the NuGet note below.
320
321 The NuGet package (`SiGit.Code`, installed with `dotnet tool install --global SiGit.Code`) is the
322 odd one out among the language registries: npm and PyPI publish one artifact per platform, but a
323 .NET tool is a single package, so `nuget/sigit/` bundles all six binaries under
324 `native/<os>-<arch>/` and a small managed shim (`Program.cs`) execs the right one. That shim leaves
325 stdin/stdout/stderr unredirected on purpose, since siGit Code chooses TUI or ACP mode by testing
326 whether stdin is a TTY. It also sets `RollForward=Major`: it targets `net8.0`, and without that a
327 machine carrying only the .NET 10 runtime installs a tool that refuses to start.
328
329 Bundling every target means the package is large, so `release-nuget.yml` fails the pack job if the
330 `.nupkg` crosses nuget.org's 250 MB limit. At v1.5.1 it lands around 160 MB. If a future release
331 trips that check, the fix is not to drop targets but to split into RID-specific tool packages
332 (.NET 10's `DotnetToolRidPackage`), which ship one binary per platform the way npm already does.
333 That also needs a fallback package for pre-.NET-10 SDKs.