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 ACP session state owns its roots, conversation, and selected model even though the process has
116 one live backend; activating a thread parks and restores all three. Unknown session ids are
117 rejected instead of silently borrowing the active thread's cwd. Prompt cancellation is routed
118 outside `turn_lock`, which lets a client cancel the turn currently holding that lock. The
119 `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
120 smbCloud, and stay general otherwise.
121 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
122 `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
123 `OpenAiBackend` (any OpenAI-compatible HTTP endpoint). A `BackendError` is user-facing: the
124 ACP prompt handler passes it to the client verbatim and the editor puts it in an error banner,
125 so `describe_api_error` unwraps the OpenAI `{"error":{"message":…}}` envelope and shows that
126 message on its own, falling back to the status only when there is nothing to unwrap. An
127 endpoint can also fail *after* the response is open, reporting it as a `data:` frame holding
128 the same envelope; that frame has no `choices`, so `consume_stream` has to check for it
129 explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.
130 `TurnResult::finish` carries the endpoint's `finish_reason` up to the loop, and
131 `stop_reason_for` in `main.rs` turns it into the ACP stop reason: the tool-round cap is
132 `max_turn_requests`, `length` is `max_tokens`, `content_filter` is `refusal`. ACP defines a
133 refusal as a turn the next prompt will not include, so `handle_prompt` restores the history
134 it snapshotted before the turn instead of only relabelling the response.
135 The round that reaches the cap (24, or `SIGIT_MAX_TOOL_ROUNDS`) is followed by one forced
136 text reply with no tools. The model cannot see that the tools are gone, so
137 `round_cap_note` is appended to that round's last tool result, and the turn always closes
138 with `round_cap_stop_message`. Without both, the thread stops on the model announcing a
139 step that never runs (issue #120).
140 Some models write tool calls into content as text; `src/inline_tool_calls.rs` recovers the
141 well-formed ones. A block that doesn't parse (or never closes) is dropped from both the reply
142 and history, and `OpenAiBackend::complete` retries once with a note telling the model the call
143 didn't run. Leaving the raw block in history makes the model invent `<function_results>` later.
144 Image attachments: ACP fixes `promptCapabilities.image` for the whole connection, while the
145 model can change on any turn, so the capability is always advertised and the decision is made
146 per prompt. `InferenceBackend::accepts_images` answers for the active model (on-device: no;
147 remote: `provider::model_accepts_images`, which reads the hard-coded `IMAGE_TIERS` for
148 `onde-*` ids and says yes for a user's own endpoint). `images_for_turn` in `main.rs` either
149 passes the images to `send_message_with_images` or drops them with a note to the user and a
150 bracketed note to the model. A message with images is stored in history as OpenAI content
151 parts and stays that way; `OpenAiBackend` strips the images per request (`without_images`)
152 when its model cannot read them, which is what makes a mid-thread switch to a text-only tier
153 safe. Read history text through `backend::message_text`, never `content.as_str()`.
154 `IMAGE_TIERS` mirrors onde-cloud's `IMAGE_CANDIDATES`; update both together.
155 - **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
156 wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
157 `~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
158 - **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
159 `list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
160 `multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
161 spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
162 commit attribution: when a command creates a new commit that lacks the
163 `Co-Authored-By: siGit Code` trailer (`commit_co_author_trailer()`, which reads
164 `siGit Code v<version>-<acp|tui|headless>` from the surface `main` sets via `set_surface`), it amends the trailer in —
165 unless the commit already exists on a remote, which is never rewritten. Every child process it
166 spawns (`spawn_shell`, the `git` helpers, and `hooks.rs`) sets `stdin` to null and never
167 inherits it: in ACP mode sigit's stdin is the JSON-RPC pipe from the editor, so a command that
168 reads stdin both blocks forever and eats the client's next request, wedging the session past
169 any timeout. On Unix the shell also leads its own process group so the timeout kills the whole
170 tree, and stdout/stderr are drained on threads while the command runs — waiting first and
171 reading after deadlocks as soon as the output outgrows the pipe buffer. Also owns the `task`
172 tool: a nested agent loop in a fresh conversation, offered only when `subagent_available()`
173 (a subagent factory is registered — see `register_subagent_factory_for` in `main.rs`; on-device
174 registers a `None`-returning factory since onde has a single shared history). A subagent's
175 toolset is a hard-gated read-only allow-list (`SUBAGENT_TOOL_NAMES`) that is never expanded by a
176 configurable subagent type (see `src/subagents.rs`) — only ever narrowed — so a `.sigit/agents/*.md`
177 file can't grant itself `edit_file`/`run_command` and bypass the permission system. Also owns
178 `web_search`: a thin native wrapper around the official MCP server's Brave-Search-backed
179 `web_search` tool (`mcp__sigit__web_search`, implemented server-side in `sigit-si`'s
180 `WebSearchService` + `Mcp::Tools::WebSearch`), offered only when that MCP tool was actually
181 discovered (i.e. the user is signed in to siGit Code Cloud — see `web_search_available`).
182 Wrapping it natively rather than leaving it as a raw `mcp__*` tool matters for two reasons:
183 `permissions::classify` treats every `mcp__*` tool as mutating (an "ask" prompt on every call),
184 while `web_search` is read-only like `read_website`; and the raw delegate name is filtered out
185 of the assembled tool list (`is_web_search_delegate`) so the model sees one clean option, not
186 two names for the same tool. Execution still forwards verbatim to `mcp::call_tool` — no second
187 HTTP/JSON-RPC implementation.
188 - **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
189 folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
190 instructions) from `.sigit/skills/` and `.claude/skills/` in every project root (see `src/workspace.rs`),
191 `$SIGIT_CONFIG_DIR/skills/`,
192 and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
193 baked into the dynamically-built `skill` tool's description, and activating a skill (the model
194 calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
195 `*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
196 and only when at least one skill exists.
197 - **`src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
198 optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
199 body) from `.sigit/commands/` and `.claude/commands/` in every project root, `$SIGIT_CONFIG_DIR/commands/`,
200 and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
201 (`.sigit/commands/git/commit.md` → `/git:commit`). Unlike skills there's no tool-call
202 indirection: invoking one works exactly like the built-in `/init` — `commands::render`
203 substitutes `$ARGUMENTS`/`$1..$9` in the body against whatever followed the command on the
204 input line, and the result is fed to the model as a normal turn through the ordinary tools and
205 permission checks. Resolution happens where each surface (`main.rs`/`chat.rs`) already
206 special-cases `/init`: an unrecognized slash command is tried against `commands::resolve_command`
207 before falling back to "unknown command". A custom command sharing a name with a built-in is
208 unreachable (built-ins match first in `parse_slash`) and is skipped when advertised to ACP
209 clients, with a warning logged.
210 - **`src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
211 files (YAML frontmatter `name` + `description`, optional comma-separated `tools:` allow-list,
212 then a Markdown body that becomes the subagent's system prompt) from `.sigit/agents/` and
213 `.claude/agents/` in every project root, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
214 type's `name` as `task`'s `subagent_type` argument swaps in that system prompt and, if `tools:`
215 is set, narrows the offered toolset to its *intersection* with `SUBAGENT_TOOL_NAMES` — the
216 security-relevant narrowing logic lives in `tools.rs` next to that constant, not here; this
217 module only discovers and parses files. `SubagentFactory` (in `tools.rs`) takes the resolved
218 system prompt per call rather than baking one in at registration, so a single registered
219 factory serves both the default research subagent and every configured type.
220 - **`src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
221 `src/skills.rs` (`SKILL.md`) and `src/commands.rs` (`.sigit/commands/*.md`).
222 - **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
223 transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
224 `application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
225 `mcp.toml`; sigit spawns the server and speaks newline-delimited JSON-RPC over its
226 stdin/stdout, stderr inherited into sigit's log). `url` and `command` are mutually exclusive —
227 both or neither is a config error, logged and skipped. Both transports run the same
228 `initialize`/`tools/list` handshake and forward `tools/call`. Discovery is best-effort at
229 startup (`mcp::init`, called from both branches of `main()`) and cached in a process-global so
230 the synchronous spec builders (`mcp::tool_specs`) and the async dispatch (`mcp::call_tool`) can
231 both read it; `/reload` does *not* re-run it, so config changes need a restart. stdio children
232 live for the process; a dead child fails calls with an in-band error string (no auto-restart).
233 Tools are namespaced `mcp__<server>__<tool>`, appended in the `*_as_specs`/`build_tool_specs`
234 layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. Two servers are baked in:
235 the official server (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`, always HTTP, authed
236 with the cloud session token) and the smbCloud CLI server (`smb --mcp`, stdio, added only when
237 the `smb` binary is on `PATH`; opt out with `smbcloud = false` in `mcp.toml` or
238 `SIGIT_MCP_SMBCLOUD=off`). A user-defined entry named `sigit` or `smbcloud` overrides the
239 corresponding baked-in one. Extra servers live in `mcp.toml` (global
240 `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
241 `tests/mcp_stdio.rs`, driven by the test-only `src/bin/mcp_stdio_stub.rs` helper binary
242 (excluded from the published crate via `exclude` in `Cargo.toml`). The baked-in official
243 server is *also* listed in the public MCP Registry as `si.sigit/sigit` — a **remote**
244 Streamable-HTTP listing owned by the [`getsigit/si`](https://github.com/getsigit/si) repo
245 (`server.json` at its root, published by its `release-mcp-registry.yml`), not this one. Because
246 it's a remote server, the registry's URL-match rule forces a domain namespace (`si.sigit` ↔
247 `sigit.si`) verified by a DNS TXT record, not the GitHub-OIDC scheme `smbcloud-cli` uses for its
248 package listing.
249 Separate from all of the above are the servers an ACP client passes in `mcpServers` on
250 `session/new`, `session/load` and `session/fork`. ACP requires agents to connect to the stdio
251 ones, and they belong to the session that named them, so they cannot live in the startup
252 global: `connect_session_servers` returns a `SessionServers` that `main.rs` keeps on
253 `SessionState` and installs with `set_session_servers` whenever a session becomes live, the
254 same way it swaps the roots. `tool_specs`, `call_tool` and `/mcp` read the startup servers and
255 then the live session's. A client-supplied server whose name is already taken by a startup
256 server is not connected, since both would claim the same `mcp__<server>__` prefix. Streamable
257 HTTP entries are connected too, with the URL and headers the client sent, because
258 `handle_initialize` advertises `mcpCapabilities.http`. SSE is not advertised (the MCP spec
259 deprecated it), so a conforming client never sends one and a stray entry is skipped.
260 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
261 `decision_for` before executing: read-only tools always run; mutating tools (and all
262 `mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
263 mutating tools with a present-a-plan message), session "always allow" grants, per-tool
264 overrides and the default mode from `[permissions]` in `settings.toml` (`allow`/`ask`/`deny`,
265 default `ask`; `SIGIT_PERMISSIONS` env overrides the default). On `ask`, the ACP path sends
266 `session/request_permission` (allow once / allow for session / deny) and the TUI pauses the
267 inference task on a y/a/n prompt. On the ACP path the decision is taken *before* the call is
268 announced, because it sets the announced status: a call that will ask starts `pending`, the
269 permission request carries that call's own id, and an `in_progress` update follows approval.
270 That update also puts the card's title back, since the permission request overwrites it
271 with the full arguments. Note: ACP turn-affecting handlers run in `cx.spawn`ed tasks
272 serialized by `SiGitAgent::turn_lock` so the dispatch loop can route the client's permission
273 answer mid-turn — don't move them back inline, and don't await client requests from inline
274 handlers (deadlock).
275 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
276 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
277 walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
278 plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
279 (most specific) wins. In a multi-root project that walk is repeated for every root
280 (`load_workspace_instructions`). The combined block is injected via `session_context_message`
281 in `main.rs` — pushed as a system message at every ACP session entry point (new/load/fork +
282 model switch) and appended to the system prompt on the cloud and TUI-startup paths.
283 - **`src/workspace.rs`** — the directories the session treats as project roots. An editor can
284 open several at once (Zed calls it a multi-root project) and ACP carries the extras as
285 `additional_directories` on every session request; the headless CLI takes them as repeatable
286 `--add-dir` flags. A client only sends those extras to an agent that advertises
287 `sessionCapabilities.additionalDirectories` in its `initialize` reply, so that capability in
288 `handle_initialize` is what makes the rest of this reachable — without it Zed keeps the first
289 root, drops the others, and shows "This agent doesn't currently support multi-root workspaces".
290 The process still has one working directory, so the extra roots live in a
291 process-global here and `project_dirs()` returns cwd-first, extras after.
292 One process also serves every thread the editor has open, so that global
293 (with the cwd, the backend conversation, and the background-task owner in
294 `tools.rs`) always belongs to the *live* session: `main.rs` keeps a
295 `SessionState` per session id and `activate_session` parks the live one and
296 installs the requested one before any prompt or config change runs. Don't
297 read another session's roots from the global. Project-local
298 discovery reads it: skills, slash commands, subagent types, and instruction files all scan
299 every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
300 any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
301 client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
302 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
303 `tokio::select!` to multiplex terminal events with streaming tokens.
304 - **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
305 clients. The legacy `-p` form reaches the same parser. Every new run gets a UUID session id;
306 `--resume <id>` restores that session through `session_store`, and `--output jsonl` emits a
307 structured session/delta/tool/result stream while logs stay on stderr. Headless permission
308 prompts collapse to denial unless the tool was pre-approved with `--allow-tool`.
309 - **`src/session_store.rs`** — durable conversations: one JSON-lines history file per session at
310 `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`, written atomically, restorable into either backend.
311 Each save also writes a `<id>.meta.json` sidecar naming the session's `cwd` and the extra roots
312 of a multi-root project. That sidecar is what makes a thread *listable*: ACP's `session/list`
313 (advertised as `sessionCapabilities.list`, handled by `handle_list_sessions` in `main.rs` — the
314 editor's "Import Threads" picker) must report an absolute `cwd` per session and may filter on
315 it, so a session without one is skipped there while still reopening by id through
316 `session/load`. Sidecars are written at save time, not at session start, so a thread nobody
317 spoke in leaves nothing behind.
318 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
319 Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
320 those read env vars once at init.
321 - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
322 against the account API and stores a session token. Performs no console I/O.
323 - **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
324 button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
325 S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
326 back: a loopback listener on `127.0.0.1:0` (the registration lists `http://127.0.0.1/callback`
327 with no port, and RFC 8252 §7.3 matching ignores the port, so one registration covers every
328 run), or the out-of-band URN, where the server renders the code and the user pastes it. The
329 resulting token goes into `credentials` like any other, and is served by `/api/v1/user` rather
330 than the deprecated `/api/v1/me` — OAuth tokens are rejected there. The scopes requested are
331 `user:read code:agent`; `code:agent` is what reaches `chat/completions` and the official MCP
332 server. Surfaces: ACP `authenticate` (loopback only — nowhere to show a code), a bare `/login`
333 in either chat surface, and `sigit login` (which adds the paste fallback, plus `--paste` to
334 force it and `--password` for the old email/password prompt).
335 - **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
336 - **`src/models.rs`** — model-picker types shared across platforms.
337
338 Slash commands (`/help`, `/models`, `/skills`, `/agents`, `/commands`, `/mcp`, `/login`, `/logout`,
339 `/whoami`, `/reload`, `/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via
340 `advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. `/init` is
341 special: instead of replying directly it substitutes `instructions::INIT_PROMPT` for the user
342 text and runs a normal agent turn that explores the repo and writes (or improves) `AGENTS.md`
343 through the ordinary tools and permission checks. User-defined commands (see `src/commands.rs`)
344 get the same treatment via the `SlashCommand::Unknown` fallback path, so anyone can add their own
345 `/name` commands without touching the built-in command list.
346
347 ## Model cache (macOS)
348
349 On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
350 `~/Library/Group Containers/group.com.ondeinference.apps/models/`. Other platforms fall back to
351 `~/.cache/huggingface/`. The CLI reuses a model the desktop app already downloaded. First run
352 downloads a GGUF model (~1–2 GB) from Hugging Face.
353
354 ## Logging
355
356 In TTY (interactive) mode, *all* output — `log`, `tracing`, stray `println!` — is redirected to
357 `$TMPDIR/sigit.log` so the ratatui surface stays clean; the TUI holds a separate fd to the real
358 terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
359 verbosity with `RUST_LOG`.
360
361 ## Relevant env vars
362
363 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
364 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
365 `SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
366 headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
367 smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
368 server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
369 mutating tools; the escape hatch for clients without permission-request support),
370 `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
371
372 ## Releasing
373
374 Version lives in `Cargo.toml`. Update `CHANGELOG.md` for releases.
375
376 Publishing splits into two groups. **Language registries** each get their own tag-triggered
377 workflow: `release-crates`, `release-npm`, `release-nuget`, `release-pypi`. The `npm/`, `nuget/`,
378 and `pypi/` dirs hold their wrapper-package templates.
379
380 npm and NuGet authenticate with OIDC (Trusted Publishing), so neither needs a stored token. On
381 npm that is configured per package, not per org: all seven `@getsigit/*` names carry a trusted
382 publisher pointing at `release-npm.yml`, and a package added later needs the same setup or its
383 publish step will fail. OIDC also requires npm >= 11.5.1 and Node >= 22.14.0, which is why the
384 workflow upgrades npm and why `.nvmrc` cannot drop below 22.
385
386 **OS package managers** all publish somewhere outside this repo and all need checksums from the
387 GitHub release, so `release-github` builds the binaries, attaches the assets, and then dispatches
388 `release-homebrew`, `release-scoop`, `release-winget`, and `release-aur`. A dispatch failure in one
389 is logged as a warning rather than failing the others, so an unconfigured channel does not block a
390 release. Their inputs live in `packaging/`.
391
392 The Windows binaries link the MSVC C runtime statically (`+crt-static` in `.cargo/config.toml`).
393 winget, Scoop, npm, PyPI and NuGet all ship the bare exe with no installer, so a dynamic build
394 that imports `VCRUNTIME140.dll` fails to load on a machine without the VC++ Redistributable
395 (`0xC0000135`, which is what failed winget's first validation).
396 `packaging/windows/check-static-crt.ps1` runs in CI and in `release-github` and fails the build if
397 a Windows exe imports the runtime again. Don't set a `RUSTFLAGS` env var in those workflows: it
398 replaces the config rustflags instead of adding to them.
399
400 Every release asset now carries a `.sha256` sidecar, not just the macOS Homebrew tarball. Scoop,
401 winget, and the AUR PKGBUILD each need one, and they consume the raw binaries rather than the
402 tarball. `release-github` also builds a `.deb` and `.rpm` per Linux target with nfpm
403 (`packaging/nfpm.yaml`), packaging the already-built binary rather than re-invoking cargo.
404
405 Re-pushing a tag fires every release workflow a second time, so each one is grouped by tag in a
406 `concurrency` block with `cancel-in-progress: false` — the duplicate queues behind the original
407 rather than interrupting a publish that is halfway through uploading. What keeps the queued run
408 from going red is the "is this version already published?" check each publishing workflow does
409 before it uploads. Those checks are load-bearing, not belt-and-braces: v1.5.6 published twice
410 because the crates.io one asked the API with curl's default User-Agent, which crates.io answers
411 with a 403, which read as "not published yet".
412
413 Three of these need credentials or a one-time manual step before they work:
414
415 - Scoop needs a `getsigit/scoop-bucket` repo and a `SCOOP_BUCKET_TOKEN` secret, mirroring the
416 Homebrew tap setup.
417 - winget needs a `WINGET_TOKEN` (PAT with `public_repo` scope) so `wingetcreate` can fork
418 `microsoft/winget-pkgs` and open the manifest PR. It is passed as
419 `WINGET_CREATE_GITHUB_TOKEN` rather than `--token`, which wingetcreate warns can leak the
420 token into logs. `wingetcreate update` only works on a package that already exists in
421 `microsoft/winget-pkgs`, so the workflow checks the `manifests/g/getSigit/siGitCode` path
422 first and falls back to rendering `packaging/winget/*.yaml.in` and running
423 `wingetcreate submit` for a first submission. That fallback runs once, then every later
424 release takes the `update` path. Two non-obvious things in the update path: the `--urls`
425 arguments carry a trailing `|x64` / `|arm64` suffix that tells wingetcreate which installer
426 entry each URL replaces — without it, wingetcreate guesses from the file name, and
427 "sigit-win-amd64.exe" is not a spelling it recognises; and the first-submission render
428 uppercases the SHA256 (`tr '[:lower:]' '[:upper:]'`) to match the community validation
429 pipeline, which writes 64-hex checksums uppercase. The update path also passes
430 `--release-date`, `--release-notes-url`, `--submit`, and `--no-open` explicitly.
431 - The AUR needs `AUR_USERNAME`, `AUR_EMAIL`, and `AUR_SSH_PRIVATE_KEY`. It publishes `sigit-bin`
432 (a prebuilt binary) so Arch users are not compiling the on-device inference stack to install a
433 CLI.
434
435 `[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not
436 just a nicety. See the NuGet note below.
437
438 The NuGet package (`SiGit.Code`, installed with `dotnet tool install --global SiGit.Code`) is the
439 odd one out among the language registries: npm and PyPI publish one artifact per platform, but a
440 .NET tool is a single package, so `nuget/sigit/` bundles all six binaries under
441 `native/<os>-<arch>/` and a small managed shim (`Program.cs`) execs the right one. That shim leaves
442 stdin/stdout/stderr unredirected on purpose, since siGit Code chooses TUI or ACP mode by testing
443 whether stdin is a TTY. It also sets `RollForward=Major`: it targets `net8.0`, and without that a
444 machine carrying only the .NET 10 runtime installs a tool that refuses to start.
445
446 Bundling every target means the package is large, so `release-nuget.yml` fails the pack job if the
447 `.nupkg` crosses nuget.org's 250 MB limit. At v1.5.1 it lands around 160 MB. If a future release
448 trips that check, the fix is not to drop targets but to split into RID-specific tool packages
449 (.NET 10's `DotnetToolRidPackage`), which ship one binary per platform the way npm already does.
450 That also needs a fallback package for pre-.NET-10 SDKs.