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()`).
18
+`sigit logout`, `sigit whoami` (see `crates/cli/src/main.rs` `main()`).
19
20
## Working in this repo
21
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
70
+ Claude Code, and sigit itself (via `crates/cli/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
75
+ which is how both Claude Code and sigit itself (via `crates/cli/src/skills.rs`) discover
76
them. Add new skills under `.agents/skills/<name>/SKILL.md`, never as real
77
files under `.claude/`.
78
79
+## Workspace layout
80
+
81
+The repo root is a Cargo workspace (`members = ["crates/cli"]`, with `crates/sigit-*` added as
82
+they land). The `sigit` binary crate lives in `crates/cli`: its `src/` and `tests/` are what the
83
+architecture section below calls `src/` and `tests/`, and it publishes as `sigit` from there.
84
+Shared package fields and every dependency version are declared once in the root
85
+`Cargo.toml` (`[workspace.package]`, `[workspace.dependencies]`) and inherited with
86
+`workspace = true`. Build outputs are unchanged: `target/<triple>/release/sigit`. Release
87
+workflows build and publish with `-p sigit`.
88
+
89
## Build / test / lint
90
91
```sh
92
cargo build # debug build
93
cargo build --release # release binary at target/release/sigit
84
-cargo run # launches interactive TUI (stdin is a TTY)
94
+cargo run -p sigit # launches interactive TUI (stdin is a TTY)
95
cargo test # CI runs: cargo test --locked --target <target>
96
cargo clippy --tests -- -D warnings # CI gate: clippy is -D warnings on all 4 targets
97
cargo fmt -- --check # CI gate (edition 2024)
120
`InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
121
feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
122
113
-- **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
123
+- **`crates/cli/src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
124
new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
125
ACP session state owns its roots, conversation, and selected model even though the process has
126
one working directory and one live backend slot; activating a thread parks and restores all
156
`session_store::delete`, and succeeds for an id that is already gone. The
157
`SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
158
smbCloud, and stay general otherwise.
149
-- **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
159
+- **`crates/cli/src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
160
`ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
161
`OpenAiBackend` (any OpenAI-compatible HTTP endpoint). A `BackendError` is user-facing: the
162
ACP prompt handler passes it to the client verbatim and the editor puts it in an error banner,
181
`round_cap_note` is appended to that round's last tool result, and the turn always closes
182
with `round_cap_stop_message`. Without both, the thread stops on the model announcing a
183
step that never runs (issue #120).
174
- Some models write tool calls into content as text; `src/inline_tool_calls.rs` recovers the
184
+ Some models write tool calls into content as text; `crates/cli/src/inline_tool_calls.rs` recovers the
185
well-formed ones. A block that doesn't parse (or never closes) is dropped from both the reply
186
and history, and `OpenAiBackend::complete` retries once with a note telling the model the call
187
didn't run. Leaving the raw block in history makes the model invent `<function_results>` later.
178
- What that scanner lets through then goes through `src/harness_markup.rs`, which removes
188
+ What that scanner lets through then goes through `crates/cli/src/harness_markup.rs`, which removes
189
markup the model makes up in the harness's shape (issue #122): a `<system_warning>`-style
190
block (any `system_*`/`system-*` tag) is dropped whole, from the reply and the history, and
191
other underscore-named tags like `<Option_Picker>` lose the tags but keep their text. Code is
192
never touched, and an opening tag only counts at the start of a line, since prose writes
193
placeholders like `<repo_url>` the same way. It has to run after the inline-call scanner,
194
because `<tool_call>` matches its underscore rule.
185
- `src/repetition.rs` guards the same stream against a reply that degenerates into a loop over a
195
+ `crates/cli/src/repetition.rs` guards the same stream against a reply that degenerates into a loop over a
196
tiny vocabulary (issue #123): a window of 200 words with at most 40 distinct ones, fenced code
197
excepted, makes `consume_stream` stop reading, truncate the text to where the loop began (so
198
history never holds the junk), tell the user, and finish with `FinishReason::Repetition`
214
takes audio, because onde-cloud drops `input_audio` parts when it parses a request, so
215
`provider::model_accepts_audio` says no for every `onde-*` id and yes for a user's own
216
endpoint. If onde-cloud ever routes audio, give it a tier table like `IMAGE_TIERS`.
207
-- **`src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
217
+- **`crates/cli/src/provider.rs`** — decides *which* backend serves inference. Resolution order, first match
218
wins: (1) override via `OPENAI_BASE_URL`+`OPENAI_API_KEY` or active profile in
219
`~/.config/sigit/providers.toml`; (2) siGit Code Cloud when logged in; (3) on-device.
210
-- **`src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
220
+- **`crates/cli/src/tools.rs`** — agent tool schemas + execution: `read_file`, `create_directory`,
221
`list_directory`, `search_files`, `glob`, `read_website`, `create_file`, `edit_file`,
222
`multi_edit`, `delete_file`, `run_command`, `write_todos`, `remember`. Add a tool in both the
223
spec list (`all_tools`) and the execute `match` (`execute_tool`). `run_command` also enforces
235
(a subagent factory is registered — see `register_subagent_factory_for` in `main.rs`; on-device
236
registers a `None`-returning factory since onde has a single shared history). A subagent's
237
toolset is a hard-gated read-only allow-list (`SUBAGENT_TOOL_NAMES`) that is never expanded by a
228
- configurable subagent type (see `src/subagents.rs`) — only ever narrowed — so a `.sigit/agents/*.md`
238
+ configurable subagent type (see `crates/cli/src/subagents.rs`) — only ever narrowed — so a `.sigit/agents/*.md`
239
file can't grant itself `edit_file`/`run_command` and bypass the permission system. Also owns
240
`web_search`: a thin native wrapper around the official MCP server's Brave-Search-backed
241
`web_search` tool (`mcp__sigit__web_search`, implemented server-side in `sigit-si`'s
247
of the assembled tool list (`is_web_search_delegate`) so the model sees one clean option, not
248
two names for the same tool. Execution still forwards verbatim to `mcp::call_tool` — no second
249
HTTP/JSON-RPC implementation.
240
-- **`src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
250
+- **`crates/cli/src/skills.rs`** — [Agent Skills](https://agentskills.io) support. Discovers skill
251
folders (each with a `SKILL.md`: YAML frontmatter `name` + `description`, then Markdown
242
- instructions) from `.sigit/skills/` and `.claude/skills/` in every project root (see `src/workspace.rs`),
252
+ instructions) from `.sigit/skills/` and `.claude/skills/` in every project root (see `crates/cli/src/workspace.rs`),
253
`$SIGIT_CONFIG_DIR/skills/`,
254
and `~/.claude/skills/`. Progressive disclosure: the discovery list (name + description) is
255
baked into the dynamically-built `skill` tool's description, and activating a skill (the model
256
calls `skill` with a name) loads the full `SKILL.md` body. The `skill` tool is appended in the
257
`*_as_specs`/`build_tool_specs` layer (not in `all_tools()`) so its description can be dynamic,
258
and only when at least one skill exists.
249
-- **`src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
259
+- **`crates/cli/src/commands.rs`** — user-defined slash commands. Discovers Markdown files (each an
260
optional YAML frontmatter block — `description`, `argument-hint` — followed by a prompt-template
261
body) from `.sigit/commands/` and `.claude/commands/` in every project root, `$SIGIT_CONFIG_DIR/commands/`,
262
and `~/.claude/commands/`. A subdirectory namespaces the command with `:`
269
before falling back to "unknown command". A custom command sharing a name with a built-in is
270
unreachable (built-ins match first in `parse_slash`) and is skipped when advertised to ACP
271
clients, with a warning logged.
262
-- **`src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
272
+- **`crates/cli/src/subagents.rs`** — configurable subagent types for the `task` tool. Discovers Markdown
273
files (YAML frontmatter `name` + `description`, optional comma-separated `tools:` allow-list,
274
then a Markdown body that becomes the subagent's system prompt) from `.sigit/agents/` and
275
`.claude/agents/` in every project root, `$SIGIT_CONFIG_DIR/agents/`, and `~/.claude/agents/`. Passing a
279
module only discovers and parses files. `SubagentFactory` (in `tools.rs`) takes the resolved
280
system prompt per call rather than baking one in at registration, so a single registered
281
factory serves both the default research subagent and every configured type.
272
-- **`src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
273
- `src/skills.rs` (`SKILL.md`) and `src/commands.rs` (`.sigit/commands/*.md`).
274
-- **`src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
282
+- **`crates/cli/src/frontmatter.rs`** — shared "YAML frontmatter + Markdown body" parsing used by both
283
+ `crates/cli/src/skills.rs` (`SKILL.md`) and `crates/cli/src/commands.rs` (`.sigit/commands/*.md`).
284
+- **`crates/cli/src/mcp.rs`** — [Model Context Protocol](https://modelcontextprotocol.io) *client*. Two
285
transports: **Streamable HTTP** (one JSON-RPC POST endpoint, `url` in `mcp.toml`; replies are
286
`application/json` or SSE) and **stdio** (`command` + optional `args`/`[server.env]` in
287
`mcp.toml`; sigit spawns the server and speaks newline-delimited JSON-RPC over its
300
`SIGIT_MCP_SMBCLOUD=off`). A user-defined entry named `sigit` or `smbcloud` overrides the
301
corresponding baked-in one. Extra servers live in `mcp.toml` (global
302
`$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
293
- `tests/mcp_stdio.rs`, driven by the test-only `src/bin/mcp_stdio_stub.rs` helper binary
303
+ `crates/cli/tests/mcp_stdio.rs`, driven by the test-only `crates/cli/src/bin/mcp_stdio_stub.rs` helper binary
304
(excluded from the published crate via `exclude` in `Cargo.toml`). The baked-in official
305
server is *also* listed in the public MCP Registry as `si.sigit/sigit` — a **remote**
306
Streamable-HTTP listing owned by the [`getsigit/si`](https://github.com/getsigit/si) repo
324
servers are never offered elicitation, and `rmcp` answers `ping` and refuses other server
325
requests itself. Forwarding needs that hook in `ed-mcp` first
326
(tracked upstream at `ondeinference/ed#33`).
317
-- **`src/permissions.rs`** — tool permission policy. Every tool call passes through
327
+- **`crates/cli/src/permissions.rs`** — tool permission policy. Every tool call passes through
328
`decision_for` before executing: read-only tools always run; mutating tools (and all
329
`mcp__*`/unknown tools) are governed by, in order: per-session plan mode (`/plan` — deny all
330
mutating tools with a present-a-plan message), session "always deny" and "always allow"
366
that session's own model (`config_options_for_session`), never `activate_session`. Unknown session ids are `-32002` (resource not found) everywhere,
367
and `authenticate` accepts only `AUTH_METHOD_ID`. Tool
368
execution, subagents included, still runs one session at a time.
359
-- **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
369
+- **`crates/cli/src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
370
Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
371
walking from the session cwd up to the repo root (nearest ancestor with `.git`, never above it),
372
plus a global file under `$SIGIT_CONFIG_DIR`. Files are ordered outermost-first so the deepest
374
(`load_workspace_instructions`). The combined block is injected via `session_context_message`
375
in `main.rs` — pushed as a system message at every ACP session entry point (new/load/fork +
376
model switch) and appended to the system prompt on the cloud and TUI-startup paths.
367
-- **`src/workspace.rs`** — the directories the session treats as project roots. An editor can
377
+- **`crates/cli/src/workspace.rs`** — the directories the session treats as project roots. An editor can
378
open several at once (Zed calls it a multi-root project) and ACP carries the extras as
379
`additional_directories` on every session request; the headless CLI takes them as repeatable
380
`--add-dir` flags. A client only sends those extras to an agent that advertises
392
discovery reads it: skills, slash commands, subagent types, and instruction files all scan
393
every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
394
any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
385
- client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
386
-- **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
395
+ client names in `mcpServers` are a different thing and are per session; see `crates/cli/src/mcp.rs`.)
396
+- **`crates/cli/src/client_fs.rs`** — file reads and writes through the ACP client. A client that
397
advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
398
and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
399
`edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
413
seconds is retried on disk with a warning in the log. The client is only ever asked from
414
inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
415
permission requests). Existence checks and `create_file`'s parent directories still use the
406
- disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
407
-- **`src/client_terminal.rs`** — `run_command` in the ACP client's terminal. A client that
416
+ disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `crates/cli/tests/acp_client_fs.rs`.
417
+- **`crates/cli/src/client_terminal.rs`** — `run_command` in the ACP client's terminal. A client that
418
advertises `terminal` in `initialize` runs a foreground command itself (`terminal/create`, with
419
the platform shell as `command` and the whole command line as one argument), and
420
`exec_run_command_via_client` embeds the terminal in the tool call as `terminal` content, so the
430
after that is reported as the tool's error instead, because running it again could repeat its
431
effects. The finished card keeps the terminal and drops the result text, which still goes out
432
as `raw_output`. `SIGIT_CLIENT_TERMINAL=off` turns the routing off. Covered by
423
- `tests/acp_client_terminal.rs`.
424
-- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
433
+ `crates/cli/tests/acp_client_terminal.rs`.
434
+- **`crates/cli/src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
435
`tokio::select!` to multiplex terminal events with streaming tokens.
426
-- **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
436
+- **`crates/cli/src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
437
clients. The legacy `-p` form reaches the same parser. Every new run gets a UUID session id;
438
`--resume <id>` restores that session through `session_store`, and `--output jsonl` emits a
439
structured session/delta/tool/result stream while logs stay on stderr. Headless permission
440
prompts collapse to denial unless the tool was pre-approved with `--allow-tool`.
431
-- **`src/session_store.rs`** — durable conversations: one JSON-lines history file per session at
441
+- **`crates/cli/src/session_store.rs`** — durable conversations: one JSON-lines history file per session at
442
`$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`, written atomically, restorable into either backend.
443
Each save also writes a `<id>.meta.json` sidecar naming the session's `cwd` and the extra roots
444
of a multi-root project. That sidecar is what makes a thread *listable*: ACP's `session/list`
452
the client as a `session_info_update` carrying a fresh `updatedAt`, plus the title when it
453
differs from the last one sent (`announced_titles`), so a new thread gets its title in the
454
sidebar without waiting for the next `session/list`.
445
-- **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
455
+- **`crates/cli/src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
456
Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
457
those read env vars once at init.
448
-- **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
458
+- **`crates/cli/src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
459
against the account API and stores a session token. Performs no console I/O.
460
ACP `logout` (the editor's sign out button, shown only because `handle_initialize` advertises
461
`agentCapabilities.auth.logout`) and `/logout` both go through `SiGitAgent::sign_out` in
462
`main.rs`. The request names no session, so `leave_cloud` moves the parked threads off their
463
cloud tier along with the live one and each of them gets a fresh model picker.
454
-- **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
464
+- **`crates/cli/src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
465
button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
466
S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
467
back: a loopback listener on `127.0.0.1:0` (the registration lists `http://127.0.0.1/callback`
477
so that answer can be routed), a bare `/login`
478
in either chat surface, and `sigit login` (which adds the paste fallback, plus `--paste` to
479
force it and `--password` for the old email/password prompt).
470
-- **`src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
471
-- **`src/models.rs`** — model-picker types shared across platforms.
480
+- **`crates/cli/src/credentials.rs`** — local session-token store (TOML, `0600` on Unix).
481
+- **`crates/cli/src/models.rs`** — model-picker types shared across platforms.
482
483
Slash commands (`/help`, `/models`, `/skills`, `/agents`, `/commands`, `/mcp`, `/login`, `/logout`,
484
`/whoami`, `/reload`, `/plan`, `/permissions`, `/init`, `/clear`, `/status`) are advertised via
485
`advertise_commands` in `main.rs` and handled in both the TUI and ACP sessions. `/init` is
486
special: instead of replying directly it substitutes `instructions::INIT_PROMPT` for the user
487
text and runs a normal agent turn that explores the repo and writes (or improves) `AGENTS.md`
478
-through the ordinary tools and permission checks. User-defined commands (see `src/commands.rs`)
488
+through the ordinary tools and permission checks. User-defined commands (see `crates/cli/src/commands.rs`)
489
get the same treatment via the `SlashCommand::Unknown` fallback path, so anyone can add their own
490
`/name` commands without touching the built-in command list.
491
518
519
## Releasing
520
511
-Version lives in `Cargo.toml`. Update `CHANGELOG.md` for releases.
521
+Version lives in `[workspace.package]` in the root `Cargo.toml`. Update `CHANGELOG.md` for releases.
522
523
Publishing splits into two groups. **Language registries** each get their own tag-triggered
524
workflow: `release-crates`, `release-npm`, `release-nuget`, `release-pypi`. The `npm/`, `nuget/`,