claude/github-issue-40-0au5ua
AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
What this is
sigit ("siGit Code") is a single Rust binary: a local-first AI coding agent that runs LLM
inference on-device (via the onde crate / GGUF models) or against a hosted/OpenAI-compatible
endpoint. It exposes itself two ways from the same binary, chosen at startup by whether stdin
is a TTY:
- ACP mode (stdin not a TTY): speaks the Agent Client Protocol over stdio for editor integration (Zed, VS Code ACP Client). Cross-platform.
- Interactive terminal mode (stdin is a TTY): a full-screen ratatui chat UI. Unix-only — it relies on fd redirection to keep logs out of the TUI, so Windows gets ACP mode only.
Before the TTY/ACP split, main also dispatches the account subcommands sigit login,
sigit logout, sigit whoami (see src/main.rs main()).
Working in this repo
IMPORTANT — branch naming: Prefix every working branch with feature/ (new functionality)
or fix/ (bug fixes) — never a tool- or agent-name prefix like claude/. Name the branch after
the changes it contains, as a short, descriptive, kebab-case slug that is self-explanatory
from the name alone (e.g. feature/tool-permission-system, fix/glob-mtime-sort), never after
a task, ticket, or session id (not feature/task-q003hm).
IMPORTANT — pull request target: Always open pull requests against the development branch,
never main. main is release-only; development is where day-to-day work integrates.
IMPORTANT — branch off development: Start every working branch from the latest
origin/development. Exception: when new work depends on a feature branch that has not merged
yet (e.g. it builds on tools or APIs that branch introduces), it may be stacked on top of that
branch instead. When stacking: merge the base PR into development first, then rebase the
stacked branch onto development before opening its pull request, so each PR shows only its own
commits.
IMPORTANT — run CI before pushing: Run the full CI gate locally and confirm it is green before pushing a branch or opening a pull request — never push work that fails these:
cargo fmt -- --check # formatting
cargo clippy --tests -- -D warnings # lint (warnings are errors)
cargo test --locked # tests
Agent assets layout
.agents/ is the canonical home for agent assets; every other agent path in
the repo is a symlink into it, never a copy:
.agents/AGENTS.md— this file, the project instructions. The rootAGENTS.mdandCLAUDE.mdare symlinks to it, so agents.md-standard tools, Claude Code, and sigit itself (viasrc/instructions.rs, which prefersAGENTS.mdoverCLAUDE.mdin the same directory and therefore reads it once) all load the same content. Edit this file; never replace the root symlinks with real files..agents/skills/— all project skills..claude/skillsis a symlink to it, which is how both Claude Code and sigit itself (viasrc/skills.rs) discover them. Add new skills under.agents/skills/<name>/SKILL.md, never as real files under.claude/.
Build / test / lint
cargo build # debug build
cargo build --release # release binary at target/release/sigit
cargo run # launches interactive TUI (stdin is a TTY)
cargo test # CI runs: cargo test --locked --target <target>
cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
cargo fmt -- --check # CI gate (edition 2024)
CI (.github/workflows/ci.yml) runs fmt + clippy + test across four targets:
aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu,
x86_64-pc-windows-msvc. Clippy is -D warnings, so warnings fail the build.
Run a single test: cargo test <test_name>.
Critical platform constraint: #[cfg(unix)] dead code
The interactive client is #[cfg(unix)]-only. The InferenceBackend seam (backend.rs) and
provider resolution (provider.rs) are consumed by both the interactive client and the ACP
server, but several of their items are reached only through the Unix-only interactive paths, so
the dead-code lint is suppressed on non-Unix targets only.
Consequence: code can pass clippy on macOS/Linux but fail on the Windows target (or vice versa).
When touching backend.rs, provider.rs, or the interactive path, keep the cfg gates intact —
don't "fix" an unused-warning by deleting code that's live on Unix.
Architecture
The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goes to an
InferenceBackend, which returns assistant text and/or tool calls; the loop executes tools and
feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
src/main.rs— entry point, mode dispatch, the full ACPAgentimpl (session lifecycle: new/load/fork/prompt/cancel, config options, slash-command advertisement), and theSYSTEM_PROMPT(note: it bakes in smbCloud-specific context the agent should use when the repo is clearly smbCloud, and stay general otherwise).src/backend.rs— theInferenceBackendtrait and neutral types (ToolSpec,ToolCall,ToolResult,TurnResult). Two impls:LocalBackend(on-device viaonde::ChatEngine) andOpenAiBackend(any OpenAI-compatible HTTP endpoint).src/provider.rs— decides which backend serves inference. Resolution order, first match wins: (1) override viaOPENAI_BASE_URL+OPENAI_API_KEYor active profile in~/.config/sigit/providers.toml; (2) siGit Code Cloud when logged in; (3) on-device.src/tools.rs— agent tool schemas + execution:read_file,create_directory,list_directory,search_files,glob,read_website,create_file,edit_file,multi_edit,delete_file,run_command,write_todos,remember. Add a tool in both the spec list (all_tools) and the executematch(execute_tool).run_commandalso enforces commit attribution: when a command creates a new commit that lacks theCo-Authored-By: siGit Codetrailer (COMMIT_CO_AUTHOR_TRAILER), it amends the trailer in — unless the commit already exists on a remote, which is never rewritten. Also owns thetasktool: a nested agent loop in a fresh conversation, offered only whensubagent_available()(a subagent factory is registered — seeregister_subagent_factory_forinmain.rs; on-device registers aNone-returning factory since onde has a single shared history). A subagent's toolset is a hard-gated read-only allow-list (SUBAGENT_TOOL_NAMES) that is never expanded by a configurable subagent type (seesrc/subagents.rs) — only ever narrowed — so a.sigit/agents/*.mdfile can't grant itselfedit_file/run_commandand bypass the permission system. Also ownsweb_search: a thin native wrapper around the official MCP server's Brave-Search-backedweb_searchtool (mcp__sigit__web_search, implemented server-side insigit-si'sWebSearchService+Mcp::Tools::WebSearch), offered only when that MCP tool was actually discovered (i.e. the user is signed in to siGit Code Cloud — seeweb_search_available). Wrapping it natively rather than leaving it as a rawmcp__*tool matters for two reasons:permissions::classifytreats everymcp__*tool as mutating (an "ask" prompt on every call), whileweb_searchis read-only likeread_website; and the raw delegate name is filtered out of the assembled tool list (is_web_search_delegate) so the model sees one clean option, not two names for the same tool. Execution still forwards verbatim tomcp::call_tool— no second HTTP/JSON-RPC implementation.src/skills.rs— Agent Skills support. Discovers skill folders (each with aSKILL.md: YAML frontmattername+description, then Markdown instructions) from.sigit/skills/and.claude/skills/in the cwd,$SIGIT_CONFIG_DIR/skills/, and~/.claude/skills/. Progressive disclosure: the discovery list (name + description) is baked into the dynamically-builtskilltool's description, and activating a skill (the model callsskillwith a name) loads the fullSKILL.mdbody. Theskilltool is appended in the*_as_specs/build_tool_specslayer (not inall_tools()) so its description can be dynamic, and only when at least one skill exists.src/commands.rs— user-defined slash commands. Discovers Markdown files (each an optional YAML frontmatter block —description,argument-hint— followed by a prompt-template body) from.sigit/commands/and.claude/commands/in the cwd,$SIGIT_CONFIG_DIR/commands/, and~/.claude/commands/. A subdirectory namespaces the command with:(.sigit/commands/git/commit.md→/git:commit). Unlike skills there's no tool-call indirection: invoking one works exactly like the built-in/init—commands::rendersubstitutes$ARGUMENTS/$1..$9in the body against whatever followed the command on the input line, and the result is fed to the model as a normal turn through the ordinary tools and permission checks. Resolution happens where each surface (main.rs/chat.rs) already special-cases/init: an unrecognized slash command is tried againstcommands::resolve_commandbefore falling back to "unknown command". A custom command sharing a name with a built-in is unreachable (built-ins match first inparse_slash) and is skipped when advertised to ACP clients, with a warning logged.src/subagents.rs— configurable subagent types for thetasktool. Discovers Markdown files (YAML frontmattername+description, optional comma-separatedtools:allow-list, then a Markdown body that becomes the subagent's system prompt) from.sigit/agents/and.claude/agents/in the cwd,$SIGIT_CONFIG_DIR/agents/, and~/.claude/agents/. Passing a type'snameastask'ssubagent_typeargument swaps in that system prompt and, iftools:is set, narrows the offered toolset to its intersection withSUBAGENT_TOOL_NAMES— the security-relevant narrowing logic lives intools.rsnext to that constant, not here; this module only discovers and parses files.SubagentFactory(intools.rs) takes the resolved system prompt per call rather than baking one in at registration, so a single registered factory serves both the default research subagent and every configured type.src/frontmatter.rs— shared "YAML frontmatter + Markdown body" parsing used by bothsrc/skills.rs(SKILL.md) andsrc/commands.rs(.sigit/commands/*.md).src/mcp.rs— Model Context Protocol client. Two transports: Streamable HTTP (one JSON-RPC POST endpoint,urlinmcp.toml; replies areapplication/jsonor SSE) and stdio (command+ optionalargs/[server.env]inmcp.toml; sigit spawns the server and speaks newline-delimited JSON-RPC over its stdin/stdout, stderr inherited into sigit's log).urlandcommandare mutually exclusive — both or neither is a config error, logged and skipped. Both transports run the sameinitialize/tools/listhandshake and forwardtools/call. Discovery is best-effort at startup (mcp::init, called from both branches ofmain()) and cached in a process-global so the synchronous spec builders (mcp::tool_specs) and the async dispatch (mcp::call_tool) can both read it;/reloaddoes not re-run it, so config changes need a restart. stdio children live for the process; a dead child fails calls with an in-band error string (no auto-restart). Tools are namespacedmcp__<server>__<tool>, appended in the*_as_specs/build_tool_specslayer and routed intools::execute_toolviamcp::is_mcp_tool. The official server (<cloud>/mcp, defaulthttps://sigit.si/api/v1/mcp) is baked in (always HTTP) and authed with the cloud session token; extra servers live inmcp.toml(global$SIGIT_CONFIG_DIR/mcp.tomland project-local.sigit/mcp.toml). The stdio path is covered bytests/mcp_stdio.rs, driven by the test-onlysrc/bin/mcp_stdio_stub.rshelper binary (excluded from the published crate viaexcludeinCargo.toml).src/permissions.rs— tool permission policy. Every tool call passes throughdecision_forbefore executing: read-only tools always run; mutating tools (and allmcp__*/unknown tools) are governed by, in order: per-session plan mode (/plan— deny all mutating tools with a present-a-plan message), session "always allow" grants, per-tool overrides and the default mode from[permissions]insettings.toml(allow/ask/deny, defaultask;SIGIT_PERMISSIONSenv overrides the default). Onask, the ACP path sendssession/request_permission(allow once / allow for session / deny) and the TUI pauses the inference task on a y/a/n prompt. Note: ACP turn-affecting handlers run incx.spawned tasks serialized bySiGitAgent::turn_lockso the dispatch loop can route the client's permission answer mid-turn — don't move them back inline, and don't await client requests from inline handlers (deadlock).src/instructions.rs— project instruction files, the always-on counterpart to skills. ReadsAGENTS.md(the cross-tool agents.md standard) andCLAUDE.md, walking from the session cwd up to the repo root (nearest ancestor with.git, never above it), plus a global file under$SIGIT_CONFIG_DIR. Files are ordered outermost-first so the deepest (most specific) wins. The combined block is injected viasession_context_messageinmain.rs— pushed as a system message at every ACP session entry point (new/load/fork + model switch) and appended to the system prompt on the cloud and TUI-startup paths.src/chat.rs— the Unix-only ratatui TUI. Loading-spinner phase then chat; usestokio::select!to multiplex terminal events with streaming tokens.src/setup.rs— model cache location, local model discovery, selected-model persistence. Must run (setup_shared_model_cache) before anything touchesChatEngine/hf-hub, since those read env vars once at init.src/account.rs— siGit Code Cloud auth (/login,/logout,/whoami); authenticates against the account API and stores a session token. Performs no console I/O.src/credentials.rs— local session-token store (TOML,0600on Unix).src/models.rs— model-picker types shared across platforms.
Slash commands (/help, /models, /skills, /agents, /commands, /mcp, /login, /logout,
/whoami, /reload, /plan, /permissions, /init, /clear, /status) are advertised via
advertise_commands in main.rs and handled in both the TUI and ACP sessions. /init is
special: instead of replying directly it substitutes instructions::INIT_PROMPT for the user
text and runs a normal agent turn that explores the repo and writes (or improves) AGENTS.md
through the ordinary tools and permission checks. User-defined commands (see src/commands.rs)
get the same treatment via the SlashCommand::Unknown fallback path, so anyone can add their own
/name commands without touching the built-in command list.
Model cache (macOS)
On macOS the HF model cache lives in an App Group container shared with the siGit desktop app:
~/Library/Group Containers/group.com.ondeinference.apps/models/. Other platforms fall back to
~/.cache/huggingface/. The CLI reuses a model the desktop app already downloaded. First run
downloads a GGUF model (~1–2 GB) from Hugging Face.
Logging
In TTY (interactive) mode, all output — log, tracing, stray println! — is redirected to
$TMPDIR/sigit.log so the ratatui surface stays clean; the TUI holds a separate fd to the real
terminal. In ACP mode, stdout is reserved for protocol JSON and logs go to stderr. Control
verbosity with RUST_LOG.
Relevant env vars
OPENAI_BASE_URL / OPENAI_API_KEY (provider override), SIGIT_API_URL (account API base,
default https://sigit.si), SIGIT_CLOUD_URL, SIGIT_CONFIG_DIR (default ~/.config/sigit),
SIGIT_MODEL, SIGIT_MCP (off disables MCP), SIGIT_MCP_OFFICIAL (off drops the baked-in
server), SIGIT_PERMISSIONS (allow/ask/deny — overrides the default permission mode for
mutating tools; the escape hatch for clients without permission-request support),
HF_HOME / HF_HUB_CACHE, RUST_LOG.
Releasing
Version lives in Cargo.toml. The binary is published to five registries via separate workflows
(release-crates, release-github, release-homebrew, release-npm, release-pypi); the
npm/ and pypi/ dirs hold the wrapper-package templates. Update CHANGELOG.md for releases.