Add the sigit-agent crate: siGit Code as an ed-acp profile

An app that cannot spawn the sigit binary needs the agent as a library. sigit-agent is that: SiGitCode implements ed_acp::Profile with the workspace tools (or without run_command for hosts that cannot start processes), /init, and the same system prompt the CLI uses. SiGitAuth is the AuthProvider that carries over handle_authenticate: an existing session passes, otherwise the loopback browser flow runs, through the client's URL elicitation when it offers one. llm_config reads the stored token and points at siGit Code Cloud. browser_auth and credentials move here from the CLI, which imports them, so both keep one OAuth client and one session store. A host can point the store at its own directory with credentials::use_config_dir. The system and /init prompts move too, and the CLI imports them. The official MCP server is offered per session through official_mcp_servers rather than as a built-in shared server, because a shared server would keep its first bearer across a sign-out and in. ed-acp resolves from the onde-ed checkout next to this repo until the release with Profile::auth is on crates.io. release-crates.yml publishes sigit-agent before sigit, each behind its own already-published check. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.7 <noreply@sigit.si>

Seto Elkahfi committed Oct 12, 2026 at 08:49 UTC 9874d18351cbacaffde6514481967b7eacfe64dc
15 files changed +1050 -200
.agents/AGENTS.md
+17 -1
@@ -79,7 +79,8 @@ the repo is a symlink into it, never a copy:
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
82 +they land). `crates/sigit-agent` is siGit Code as an `ed-acp` agent profile for apps that embed it (see
83 +below). The `sigit` binary crate lives in `crates/cli`: its `src/` and `tests/` are what the
84 architecture section below calls `src/` and `tests/`, and it publishes as `sigit` from there.
85 Shared package fields and every dependency version are declared once in the root
86 `Cargo.toml` (`[workspace.package]`, `[workspace.dependencies]`) and inherited with
@@ -120,6 +121,21 @@ The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goe
121 `InferenceBackend`, which returns assistant text and/or tool calls; the loop executes tools and
122 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
123
124 +- **`crates/sigit-agent`** — siGit Code as a library for hosts that embed the agent (sigit-app
125 + serves it in-process with `ed_acp::serve` over `Channel::duplex`, through `ed-acp-tauri`).
126 + `SiGitCode` is the `ed_acp::Profile`: the workspace tools (or `SiGitCode::without_shell()` for
127 + a host that cannot start processes), `/init`, and `SiGitAuth` as its `AuthProvider`.
128 + `SiGitAuth` is the browser sign-in `sigit` runs in an editor (`browser_auth`, public OAuth
129 + client `sigit-code-cli`, PKCE) over the same stored session (`credentials`); the CLI imports
130 + both from here so there is one OAuth client. A host calls `credentials::use_config_dir` with
131 + its own data directory, which is inside the container in a sandboxed app, so the app and the
132 + CLI never share or overwrite a session. `llm_config()` is siGit Code Cloud
133 + (`SIGIT_CLOUD_URL`, default `https://sigit.si/api/v1`) with the stored token as bearer.
134 + `prompt.rs` holds `SYSTEM_PROMPT` and `INIT_PROMPT`; the CLI imports them. The official MCP
135 + server reaches a session through `official_mcp_servers()` in the host's `mcpServers`, not as
136 + a process-wide built-in: a shared server would keep its first bearer across a sign-out. It
137 + depends on `ed-acp` by path to the `onde-ed` checkout next to this repo until `ed-acp` 1.3.0
138 + is published (see the root `Cargo.toml`). `release-crates.yml` publishes it before `sigit`.
139 - **`crates/cli/src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
140 new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
141 ACP session state owns its roots, conversation, and selected model even though the process has
.github/workflows/release-crates.yml
+46 -2
@@ -55,7 +55,51 @@ jobs:
55 with:
56 key: crates-publish
57
58 - - name: Check whether version already exists on crates.io
58 + # sigit depends on sigit-agent, so the library goes first, each behind its own
59 + # "already published" check: a re-run publishes only what is missing.
60 + - name: Check whether sigit-agent already exists on crates.io
61 + id: agent-check
62 + shell: bash
63 + run: |
64 + VERSION=$(cargo metadata --no-deps --format-version 1 | \
65 + python3 -c "import sys,json; pkgs=json.load(sys.stdin)['packages']; \
66 + print(next(p['version'] for p in pkgs if p['name']=='sigit-agent'))")
67 + echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
68 +
69 + # crates.io answers 403 to requests without a descriptive
70 + # User-Agent, so curl's default one makes this look like a 404 and
71 + # the publish below runs into "crate already exists on the index".
72 + # Branch on the status code rather than on curl's exit status, so an
73 + # answer we do not understand stops the job instead of quietly
74 + # degrading into that same failure.
75 + # Retry first, so a timeout or a 5xx costs a few seconds instead of
76 + # the whole release.
77 + STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
78 + --retry 3 --retry-delay 2 --retry-all-errors \
79 + -H "User-Agent: ${GITHUB_REPOSITORY} release workflow (https://github.com/${GITHUB_REPOSITORY})" \
80 + "https://crates.io/api/v1/crates/sigit-agent/${VERSION}")
81 +
82 + case "$STATUS" in
83 + 200)
84 + echo "sigit-agent ${VERSION} already exists on crates.io, skipping publish"
85 + echo "exists=true" >> "$GITHUB_OUTPUT"
86 + ;;
87 + 404)
88 + echo "exists=false" >> "$GITHUB_OUTPUT"
89 + ;;
90 + *)
91 + echo "Unexpected ${STATUS} from crates.io while checking whether sigit-agent ${VERSION} is published" >&2
92 + exit 1
93 + ;;
94 + esac
95 +
96 + - name: Publish sigit-agent to crates.io
97 + if: steps.agent-check.outputs.exists != 'true'
98 + env:
99 + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
100 + run: cargo publish --locked -p sigit-agent
101 +
102 + - name: Check whether sigit already exists on crates.io
103 id: crates-check
104 shell: bash
105 run: |
@@ -91,7 +135,7 @@ jobs:
135 ;;
136 esac
137
94 - - name: Publish to crates.io
138 + - name: Publish sigit to crates.io
139 if: steps.crates-check.outputs.exists != 'true'
140 env:
141 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
Cargo.lock
+77 -1
@@ -1507,6 +1507,15 @@ dependencies = [
1507 "crypto-common",
1508 ]
1509
1510 +[[package]]
1511 +name = "directories"
1512 +version = "6.0.0"
1513 +source = "registry+https://github.com/rust-lang/crates.io-index"
1514 +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
1515 +dependencies = [
1516 + "dirs-sys",
1517 +]
1518 +
1519 [[package]]
1520 name = "dirs"
1521 version = "6.0.0"
@@ -1614,6 +1623,36 @@ version = "0.1.3"
1623 source = "registry+https://github.com/rust-lang/crates.io-index"
1624 checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
1625
1626 +[[package]]
1627 +name = "ed-acp"
1628 +version = "1.2.2"
1629 +dependencies = [
1630 + "agent-client-protocol",
1631 + "anyhow",
1632 + "async-trait",
1633 + "base64 0.23.1",
1634 + "directories",
1635 + "ed-mcp 1.2.2",
1636 + "futures",
1637 + "reqwest 0.13.4",
1638 + "serde",
1639 + "serde_json",
1640 + "tokio",
1641 + "tokio-util",
1642 + "tracing",
1643 + "tracing-subscriber",
1644 + "uuid 1.26.0",
1645 +]
1646 +
1647 +[[package]]
1648 +name = "ed-acp-testkit"
1649 +version = "1.2.2"
1650 +dependencies = [
1651 + "serde_json",
1652 + "tokio",
1653 + "uuid 1.26.0",
1654 +]
1655 +
1656 [[package]]
1657 name = "ed-mcp"
1658 version = "1.2.0"
@@ -1630,6 +1669,21 @@ dependencies = [
1669 "tracing",
1670 ]
1671
1672 +[[package]]
1673 +name = "ed-mcp"
1674 +version = "1.2.2"
1675 +dependencies = [
1676 + "agent-client-protocol",
1677 + "anyhow",
1678 + "futures",
1679 + "http",
1680 + "rmcp",
1681 + "serde_json",
1682 + "tokio",
1683 + "tokio-util",
1684 + "tracing",
1685 +]
1686 +
1687 [[package]]
1688 name = "ego-tree"
1689 version = "0.10.0"
@@ -5908,7 +5962,7 @@ dependencies = [
5962 "async-trait",
5963 "base64 0.22.1",
5964 "crossterm",
5911 - "ed-mcp",
5965 + "ed-mcp 1.2.0",
5966 "futures",
5967 "libc",
5968 "log",
@@ -5921,6 +5975,7 @@ dependencies = [
5975 "serde",
5976 "serde_json",
5977 "sha2",
5978 + "sigit-agent",
5979 "tokio",
5980 "tokio-util",
5981 "toml 0.8.23",
@@ -5929,6 +5984,27 @@ dependencies = [
5984 "uuid 1.26.0",
5985 ]
5986
5987 +[[package]]
5988 +name = "sigit-agent"
5989 +version = "1.6.7"
5990 +dependencies = [
5991 + "agent-client-protocol",
5992 + "anyhow",
5993 + "async-trait",
5994 + "base64 0.22.1",
5995 + "ed-acp",
5996 + "ed-acp-testkit",
5997 + "log",
5998 + "rand 0.9.5",
5999 + "reqwest 0.13.4",
6000 + "serde",
6001 + "serde_json",
6002 + "sha2",
6003 + "tokio",
6004 + "toml 0.8.23",
6005 + "uuid 1.26.0",
6006 +]
6007 +
6008 [[package]]
6009 name = "signal-hook"
6010 version = "0.3.18"
Cargo.toml
+8 -1
@@ -1,6 +1,6 @@
1 [workspace]
2 resolver = "3"
3 -members = ["crates/cli"]
3 +members = ["crates/cli", "crates/sigit-*"]
4
5 [workspace.package]
6 version = "1.6.7"
@@ -12,6 +12,13 @@ documentation = "https://github.com/getsigit/sigit"
12 authors = ["Seto Elkahfi <seto@sigit.si>", "paydii <193906237+paydii@users.noreply.github.com>", "keypair34 <216233964+keypair34@users.noreply.github.com>"]
13
14 [workspace.dependencies]
15 +# The ACP server `sigit-agent` is a profile for. Resolved from the onde-ed checkout next to this
16 +# one until the ed-acp release with `Profile::auth` (1.3.0) is on crates.io; then the path goes
17 +# away and the version becomes "1.3.0". Until then the version below is the checkout's own.
18 +ed-acp = { version = "1.2.2", default-features = false, path = "../onde-ed/crates/ed-acp" }
19 +ed-acp-testkit = { version = "1.2.2", path = "../onde-ed/crates/ed-acp-testkit" }
20 +sigit-agent = { version = "1.6.7", default-features = false, path = "crates/sigit-agent" }
21 +
22 # ACP protocol SDK
23 agent-client-protocol = { version = "2.2", features = ["unstable_session_fork"] }
24
crates/cli/Cargo.toml
+1
@@ -22,6 +22,7 @@ name = "sigit"
22 path = "src/main.rs"
23
24 [dependencies]
25 +sigit-agent.workspace = true
26 agent-client-protocol.workspace = true
27 ed-mcp.workspace = true
28 onde.workspace = true
crates/cli/src/instructions.rs
+1 -26
@@ -28,32 +28,7 @@ use crate::workspace;
28 /// Only the first match in a given directory is loaded.
29 const INSTRUCTION_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];
30
31 -/// The prompt behind the `/init` slash command. Both the TUI and the ACP
32 -/// server substitute it for the user's input and run a normal agent turn, so
33 -/// generation goes through the ordinary tools and permission checks. It lives
34 -/// here, next to the loader that consumes the file it produces.
35 -pub const INIT_PROMPT: &str = "\
36 -Analyze this repository and write an AGENTS.md instruction file for AI coding \
37 -agents working in it.
38 -
39 -First explore, then write. Read the manifest/build files (Cargo.toml, \
40 -package.json, pyproject.toml, Makefile, or equivalents), the CI configuration, \
41 -the README, and the top one or two levels of the directory tree. Read broadly \
42 -but shallowly; do not descend into every subdirectory.
43 -
44 -Then create AGENTS.md at the repository root covering, briefly:
45 -- what the project is and does (a sentence or two)
46 -- how to build, test, and lint it (the exact commands)
47 -- the architecture: main modules/directories and what each owns
48 -- conventions an agent must follow (branch naming, commit rules, code style, \
49 -platform constraints) that the repository itself shows evidence of
50 -
51 -Keep it compact — aim for under 60 lines. State only what this repository \
52 -actually supports; no generic boilerplate.
53 -
54 -If AGENTS.md or CLAUDE.md already exists, read it first and improve it in \
55 -place (fix what is stale, add what is missing) instead of replacing it. \
56 -Always target AGENTS.md, never CLAUDE.md.";
31 +pub use sigit_agent::prompt::INIT_PROMPT;
32
33 /// Per-file and total caps so an oversized file can't blow up the context window.
34 const MAX_FILE_BYTES: usize = 32 * 1024;
crates/cli/src/main.rs
+6 -165
@@ -30,12 +30,10 @@
30
31 mod account;
32 mod backend;
33 -mod browser_auth;
33 mod chat;
34 mod client_fs;
35 mod client_terminal;
36 mod commands;
38 -mod credentials;
37 mod frontmatter;
38 mod harness_markup;
39 mod headless;
@@ -55,6 +53,10 @@ mod subagents;
53 mod tools;
54 mod workspace;
55
56 +// Sign-in and the stored session live in `sigit-agent`, where an embedding host reaches them
57 +// too; the rest of this crate keeps its `crate::` paths.
58 +pub(crate) use sigit_agent::{browser_auth, credentials};
59 +
60 /// Serializes tests that mutate process-global state: env vars
61 /// (`SIGIT_CONFIG_DIR` etc.) and the current directory. `cargo test` runs
62 /// tests in parallel within a binary, so without this the credentials and
@@ -100,6 +102,8 @@ use agent_client_protocol::schema::v1::{
102 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
103 use onde::inference::{ChatEngine, GgufModelConfig};
104
105 +use sigit_agent::prompt::{CO_AUTHOR_TRAILER_PLACEHOLDER, SYSTEM_PROMPT};
106 +
107 use crate::backend::{
108 AudioInput, ImageInput, InferenceBackend, LocalBackend, OpenAiBackend, TokenChunk,
109 ToolResult as BackendToolResult, ToolSpec, TurnResult,
@@ -112,169 +116,6 @@ use tracing_subscriber::{EnvFilter, fmt as tracing_fmt};
116 #[cfg(unix)]
117 use std::os::unix::io::{AsRawFd, FromRawFd};
118
115 -/// Stands in for the commit trailer inside [`SYSTEM_PROMPT`]; see
116 -/// [`system_prompt_for_model`].
117 -const CO_AUTHOR_TRAILER_PLACEHOLDER: &str = "{commit_co_author_trailer}";
118 -
119 -const SYSTEM_PROMPT: &str = concat!(
120 - "\
121 -Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
122 -Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
123 -
124 -You are a strong general-purpose coding agent. smbCloud is your home turf, \
125 -but you should still be useful in any codebase. When the project is clearly \
126 -about smbCloud, use that context directly instead of falling back to vague \
127 -cloud-platform advice.
128 -
129 -smbCloud context you should know and use when it helps:
130 -- smbCloud is a platform for deploying and managing projects
131 -- the main CLI is a Rust workspace with focused crates rather than one giant crate
132 -- common areas include auth, project management, deploy flows, networking, \
133 - shared models, release tooling, and managed services
134 -- deploy branches usually follow `release/service-{name}`
135 -- Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
136 - they often use a local build plus rsync/PM2 style flow
137 -- auth has a hard boundary between smbCloud platform users and tenant app users; \
138 - platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
139 - you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
140 -- smbCloud authorization is layered; do not flatten platform accounts, tenant \
141 - memberships, auth-app collaborators, and tenant end users into one model
142 -- `Project` is the umbrella workspace, while app-like resources such as \
143 - `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
144 - ownership, sharing, and collaboration rules
145 -- `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
146 - preserve those cardinality rules unless the code clearly changes them
147 -- GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
148 - service with its own credentials and boundaries, not as a generic local DB helper
149 -- when debugging smbCloud Rails APIs, first classify the request: first-party \
150 - smbCloud app or tenant app, then check which endpoint family and validator \
151 - should be involved before changing code
152 -- when working in smbCloud repos, prefer existing workspace patterns, existing \
153 - crate boundaries, existing Rails conventions, and existing command flows over \
154 - inventing new abstractions
155 -
156 -CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
157 -When the user asks you to clone a repo, run a build, check git status, or do \
158 -anything that involves a shell command, you MUST call the run_command tool and \
159 -execute it yourself. Do not print shell commands for the user to copy-paste. \
160 -Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
161 -If a command fails, try to fix the problem and re-run it. If you cannot fix it \
162 -after two attempts, explain what went wrong and what you tried.
163 -
164 -Git operations — always use run_command:
165 -- git clone: always pass the full absolute destination path as the last argument \
166 - and set cwd to an existing writable parent directory. Example: \
167 - run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
168 - \"cwd\": \"/Users/me/Repositories\"})
169 -- git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
170 - stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
171 - the repo root
172 -- never run git clone without an explicit absolute destination path
173 -- if a clone or init fails, check the error, fix the cause (wrong path, missing \
174 - directory, permissions), and retry
175 -- when you create a commit, always end the commit message with a blank line and \
176 - then this trailer on its own line: ",
177 - // CO_AUTHOR_TRAILER_PLACEHOLDER (concat! takes only literals), filled in
178 - // by `system_prompt_for_model` with tools::commit_co_author_trailer(),
179 - // which depends on the surface picked at startup.
180 - "{commit_co_author_trailer}",
181 - " \
182 - — that exact format is the standard co-author trailer git forges detect, so \
183 - siGit is credited as co-author. If a commit lands without it, siGit Code \
184 - amends the trailer in automatically and the tool output says so; do not amend \
185 - again yourself.
186 -
187 -CRITICAL — forge boundaries: git is not the forge. Plain git commands (clone, \
188 -commit, push, pull, branch, log, diff, tag, ...) work on any repository, but \
189 -issues, pull requests, reviews, releases, and other forge features are NOT git \
190 -features and there is no forge-agnostic way to reach them. Detect the forge \
191 -from the repo's remote URL (run `git remote get-url origin`) before doing any \
192 -issue or PR work:
193 -- sigit.si: issue and pull request workflows go through the official MCP \
194 - tools: mcp__sigit__list_issues, mcp__sigit__get_issue, \
195 - mcp__sigit__create_issue, mcp__sigit__list_pull_requests, and \
196 - mcp__sigit__get_pull_request. These tools are valid ONLY for repositories \
197 - hosted on sigit.si. Prefer them over shelling out to git or fetching web \
198 - pages for issue and PR queries on sigit.si repos.
199 -- any other host (github.com, gitlab.com, bitbucket.org, self-hosted forges, \
200 - ...): the mcp__sigit__* tools do NOT work there. Use the forge's own CLI via \
201 - run_command when it is installed (`gh` for GitHub, `glab` for GitLab, `tea` \
202 - for Gitea/Forgejo), its web API via read_website when that is practical, or \
203 - ask the user how they want to interact with the forge.
204 -Never assume a repo is on GitHub, and never use the mcp__sigit__* tools \
205 -against a repo whose remote is not sigit.si.
206 -
207 -Never introduce yourself unless asked. Jump straight into the answer. \
208 -Keep answers short. Write idiomatic code. \
209 -Fix root causes, not symptoms.
210 -
211 -You have access to tools that let you read files, read websites directly from \
212 -http and https URLs, create directories, list directories, search code, create \
213 -new files, edit existing files, delete files, and run shell commands. You can \
214 -also use git directly through shell commands, including `git init` and normal \
215 -git workflows. Use them proactively. Read the code or website before answering. \
216 -Prefer absolute paths when referring to files and directories, especially in \
217 -protocol-facing output and tool arguments. Create directories when needed. Run \
218 -builds, tests, and git commands after making changes. Ground your answers in \
219 -the actual code or fetched page content, not in guesses.
220 -
221 -CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
222 -access. You have a read_website tool that fetches any http or https URL and \
223 -returns the page text. When the user gives you a URL or asks you to read, \
224 -summarize, or inspect a web page, you MUST call the read_website tool with that \
225 -URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
226 -You can. Use the tool.
227 -
228 -CRITICAL — before every edit_file call, you MUST call read_file on the target \
229 -file first (or the specific line range if one was given). Never rely on file \
230 -content you saw in a previous turn — the user may have reverted, edited, or \
231 -changed the file externally since then. Always re-read to get the current state \
232 -before constructing old_text. \
233 -When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
234 -treat it as a fresh task: re-read the file, identify the current content, and \
235 -plan the edit from scratch. Do not assume the file still reflects your last edit.
236 -
237 -Tool-use heuristics:
238 -- when the user provides a URL or asks about a web page, ALWAYS call \
239 - read_website — never refuse or claim you lack internet access
240 -- prefer absolute paths over relative paths when you mention, return, or pass \
241 - file and directory paths
242 -- if a path does not exist yet, create the directory before creating files in it
243 -- if the user asks to clone a repo, immediately call run_command with git clone \
244 - and an absolute destination path — do not ask where to put it unless the \
245 - request is ambiguous; default to the user's home Repositories directory
246 -- if the user asks for a new repo, scaffold, or scratch project, create the \
247 - directory, create the first files, and run `git init` without waiting unless \
248 - the request says otherwise
249 -- if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
250 - shared models, and existing command handlers before adding new abstractions
251 -- if the repo looks like smbCloud Rails code, check routes, controllers, \
252 - validators, and model boundaries before changing business logic
253 -- if the task touches smbCloud auth, first decide whether it is a platform-user \
254 - flow or a tenant-app flow, then follow the right endpoint family and model layer
255 -- if the task touches smbCloud deploy code, check whether it is the generic \
256 - deploy path or the Next.js SSR path before proposing changes
257 -- after edits, prefer running the smallest useful verification step first, then \
258 - widen to broader checks if needed
259 -- use git commands naturally for status checks, repo setup, diffs, and normal \
260 - developer workflows when they help move the task forward
261 -- if a tool call fails, read the error, try to fix it, and retry — do not \
262 - fall back to telling the user what to type
263 -
264 -Replies are plain Markdown. Never wrap a reply, or any part of it, in XML-style \
265 -tags you made up, and never write a system message, warning, or notice as if it \
266 -came from siGit Code or the system: only siGit Code speaks for itself, and \
267 -nothing renders those tags. To offer the user a choice, list the options as \
268 -plain text.
269 -
270 -When the repo is not about smbCloud, act like a normal coding agent and do not \
271 -force smbCloud-specific advice into the answer. When it is about smbCloud, be \
272 -specific and practical.
273 -
274 -Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
275 -root cause, not the symptom. Correct beats clever."
276 -);
277 -
119 /// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1).
120 /// the full [`SYSTEM_PROMPT`] wastes context and confuses them.
121 const SIMPLE_SYSTEM_PROMPT: &str = "\
crates/sigit-agent/Cargo.toml new
+33
@@ -0,0 +1,33 @@
1 +[package]
2 +name = "sigit-agent"
3 +version.workspace = true
4 +edition.workspace = true
5 +description = "siGit Code as an ed-acp agent profile: the prompt, the sign-in and the cloud session, for apps that embed it."
6 +documentation.workspace = true
7 +license.workspace = true
8 +repository.workspace = true
9 +homepage.workspace = true
10 +keywords = ["sigit", "acp", "agent", "llm", "coding-agent"]
11 +categories = ["development-tools"]
12 +authors.workspace = true
13 +
14 +[dependencies]
15 +ed-acp.workspace = true
16 +agent-client-protocol.workspace = true
17 +async-trait.workspace = true
18 +anyhow.workspace = true
19 +base64.workspace = true
20 +log.workspace = true
21 +rand.workspace = true
22 +reqwest.workspace = true
23 +serde.workspace = true
24 +serde_json.workspace = true
25 +sha2.workspace = true
26 +tokio.workspace = true
27 +toml.workspace = true
28 +uuid.workspace = true
29 +
30 +[dev-dependencies]
31 +ed-acp-testkit.workspace = true
32 +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
33 +serde_json.workspace = true
crates/sigit-agent/src/auth.rs new
+219
@@ -0,0 +1,219 @@
1 +//! [`SiGitAuth`]: siGit Code's sign-in as an `ed-acp` [`AuthProvider`].
2 +//!
3 +//! The same browser flow `sigit` runs in an editor (OAuth with PKCE against `sigit.si/oauth`,
4 +//! public client `sigit-code-cli`), and the same stored session, so there is one OAuth client
5 +//! to register and one place a token lives. The loopback listener receives the code; the
6 +//! authorize URL goes to the client as a URL-mode elicitation when it offers one, so the page
7 +//! opens where the user is looking.
8 +
9 +use agent_client_protocol::schema::v1::{
10 + AuthMethod, AuthMethodAgent, CompleteElicitationNotification, CreateElicitationRequest,
11 + CreateElicitationResponse, ElicitationAction, ElicitationRequestScope, ElicitationUrlMode,
12 + ErrorCode, RequestId,
13 +};
14 +use agent_client_protocol::{Client, ConnectionTo};
15 +use async_trait::async_trait;
16 +use ed_acp::{AuthCtx, AuthProvider, LlmConfig, Provider};
17 +
18 +use crate::browser_auth::{self, Flow};
19 +use crate::credentials;
20 +use crate::profile::INFO;
21 +
22 +/// The id of the auth method, as the client sends it in `authenticate`.
23 +pub const METHOD_ID: &str = "sigit";
24 +
25 +/// Default API root of siGit Code Cloud; `SIGIT_CLOUD_URL` overrides it.
26 +const DEFAULT_CLOUD_URL: &str = "https://sigit.si/api/v1";
27 +
28 +/// The inference endpoint: `SIGIT_CLOUD_URL` or `https://sigit.si/api/v1`.
29 +pub fn cloud_base_url() -> String {
30 + std::env::var("SIGIT_CLOUD_URL")
31 + .ok()
32 + .filter(|url| !url.trim().is_empty())
33 + .unwrap_or_else(|| DEFAULT_CLOUD_URL.to_string())
34 + .trim_end_matches('/')
35 + .to_string()
36 +}
37 +
38 +/// The model a session starts on: `SIGIT_AGENT_MODEL`, else the profile's default tier.
39 +fn default_model() -> String {
40 + std::env::var(INFO.env.var("MODEL"))
41 + .ok()
42 + .filter(|model| !model.trim().is_empty())
43 + .unwrap_or_else(|| INFO.env.default_model.to_string())
44 +}
45 +
46 +pub struct SiGitAuth;
47 +
48 +#[async_trait]
49 +impl AuthProvider for SiGitAuth {
50 + fn method(&self) -> AuthMethod {
51 + AuthMethod::Agent(
52 + AuthMethodAgent::new(METHOD_ID, "Sign in to siGit Code")
53 + .description("Opens sigit.si in your browser to sign in to siGit Code Cloud."),
54 + )
55 + }
56 +
57 + fn llm_config(&self) -> Option<LlmConfig> {
58 + let token = credentials::load_token()?;
59 + Some(LlmConfig {
60 + provider: Provider::OpenAi,
61 + base_url: cloud_base_url(),
62 + api_key: Some(token),
63 + model: default_model(),
64 + })
65 + }
66 +
67 + async fn authenticate(&self, ctx: AuthCtx<'_>) -> agent_client_protocol::Result<()> {
68 + // An existing session clears the gate without sending anyone to a browser they
69 + // didn't ask for.
70 + if let Some(token) = credentials::load_token()
71 + && session_is_valid(&token).await
72 + {
73 + log::info!("authenticate: the stored session is still valid");
74 + return Ok(());
75 + }
76 +
77 + // There is no way to take a pasted code back from here, so this is the loopback
78 + // variant or nothing.
79 + let flow = browser_auth::begin();
80 + if !flow.is_loopback() {
81 + return Err(error(
82 + "Could not open a local port to receive the sign-in. Run `sigit login` in a \
83 + terminal instead.",
84 + ));
85 + }
86 +
87 + let outcome = if ctx.client_offers_url_elicitation() {
88 + sign_in_via_client(ctx.connection, ctx.request_id, flow).await
89 + } else {
90 + log::info!(
91 + "authenticate: opening the browser at {}",
92 + flow.authorize_url()
93 + );
94 + if !browser_auth::open_browser(flow.authorize_url()) {
95 + log::warn!("authenticate: no browser could be launched");
96 + }
97 + browser_auth::complete_loopback(flow).await
98 + };
99 +
100 + match outcome {
101 + Ok(email) => {
102 + log::info!("authenticate: signed in as {email}");
103 + Ok(())
104 + }
105 + Err(reason) => Err(error(format!(
106 + "Sign-in did not complete ({reason}). Try again, or create an account at \
107 + https://sigit.si."
108 + ))),
109 + }
110 + }
111 +
112 + async fn logout(&self) -> anyhow::Result<()> {
113 + credentials::clear();
114 + Ok(())
115 + }
116 +}
117 +
118 +fn error(message: impl Into<String>) -> agent_client_protocol::Error {
119 + agent_client_protocol::Error::new(ErrorCode::InternalError.into(), message.into())
120 +}
121 +
122 +/// Whether `token` is still accepted by the account API.
123 +async fn session_is_valid(token: &str) -> bool {
124 + let base = std::env::var("SIGIT_API_URL")
125 + .ok()
126 + .filter(|url| !url.trim().is_empty())
127 + .unwrap_or_else(|| "https://sigit.si".to_string());
128 + let url = format!("{}/api/v1/user", base.trim_end_matches('/'));
129 + match reqwest::Client::new()
130 + .get(url)
131 + .bearer_auth(token)
132 + .timeout(std::time::Duration::from_secs(15))
133 + .send()
134 + .await
135 + {
136 + Ok(response) => response.status().is_success(),
137 + Err(error) => {
138 + log::warn!("authenticate: could not verify the stored session ({error})");
139 + false
140 + }
141 + }
142 +}
143 +
144 +/// The loopback sign-in with the client showing the page: the authorize URL goes out as a
145 +/// URL-mode elicitation, so the user sees where they are being sent and the client opens it,
146 +/// which also works where this process cannot launch a browser. A user who declines ends the
147 +/// sign-in; a client that fails the request gets a browser opened from here. Once the code is
148 +/// redeemed the client is told the elicitation is complete.
149 +async fn sign_in_via_client(
150 + connection: &ConnectionTo<Client>,
151 + request_id: RequestId,
152 + flow: Flow,
153 +) -> Result<String, String> {
154 + let url = flow.authorize_url().to_string();
155 + let elicitation_id = uuid::Uuid::new_v4().to_string();
156 + log::info!("authenticate: asking the client to open {url}");
157 + let ask = connection
158 + .send_request(CreateElicitationRequest::new(
159 + ElicitationUrlMode::new(
160 + ElicitationRequestScope::new(request_id),
161 + elicitation_id.clone(),
162 + url.clone(),
163 + ),
164 + "Sign in to siGit Code in your browser to use siGit Code Cloud.",
165 + ))
166 + .block_task();
167 + let sign_in = browser_auth::complete_loopback(flow);
168 + tokio::pin!(sign_in);
169 +
170 + // The page can be finished before the client gets round to answering.
171 + enum First {
172 + Answer(agent_client_protocol::Result<CreateElicitationResponse>),
173 + SignedIn(Result<String, String>),
174 + }
175 + let first = tokio::select! {
176 + answer = ask => First::Answer(answer),
177 + result = &mut sign_in => First::SignedIn(result),
178 + };
179 + let result = match first {
180 + First::SignedIn(result) => result,
181 + First::Answer(Ok(response)) => match response.action {
182 + ElicitationAction::Accept(_) => sign_in.await,
183 + _ => Err("the sign-in page was not opened".to_string()),
184 + },
185 + First::Answer(Err(error)) => {
186 + log::warn!("authenticate: the client could not show the sign-in page ({error})");
187 + if !browser_auth::open_browser(&url) {
188 + log::warn!("authenticate: no browser could be launched");
189 + }
190 + sign_in.await
191 + }
192 + };
193 + if result.is_ok() {
194 + connection
195 + .send_notification(CompleteElicitationNotification::new(elicitation_id))
196 + .ok();
197 + }
198 + result
199 +}
200 +
201 +#[cfg(test)]
202 +mod tests {
203 + use super::*;
204 +
205 + #[test]
206 + fn the_method_is_an_agent_method_named_sigit() {
207 + let method = SiGitAuth.method();
208 + assert_eq!(&*method.id().0, METHOD_ID);
209 + assert!(matches!(method, AuthMethod::Agent(_)));
210 + }
211 +
212 + #[test]
213 + fn the_cloud_url_defaults_to_the_api_root_and_drops_a_trailing_slash() {
214 + // Not set in a test run; the override is covered by `cloud_base_url`'s own branch.
215 + if std::env::var_os("SIGIT_CLOUD_URL").is_none() {
216 + assert_eq!(cloud_base_url(), "https://sigit.si/api/v1");
217 + }
218 + }
219 +}
crates/sigit-agent/src/browser_auth.rs renamed
crates/sigit-agent/src/credentials.rs renamed
+20 -4
@@ -5,6 +5,7 @@
5 //! `~/.config/sigit/credentials.toml`, with `0600` permissions on Unix.
6
7 use std::path::PathBuf;
8 +use std::sync::OnceLock;
9
10 use serde::{Deserialize, Serialize};
11
@@ -18,8 +19,24 @@ pub struct Credentials {
19 pub email: Option<String>,
20 }
21
21 -/// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
22 +/// Where an embedding host keeps the session, set once at startup by
23 +/// [`use_config_dir`]. Unset for the command-line agent.
24 +static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
25 +
26 +/// Keep the session in `dir` instead of `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
27 +///
28 +/// An app that embeds the agent calls this with its own data directory, which is inside the
29 +/// container in a sandboxed app, so the app and the CLI never share or overwrite a session.
30 +/// The first call wins; later calls are ignored.
31 +pub fn use_config_dir(dir: PathBuf) {
32 + let _ = CONFIG_DIR.set(dir);
33 +}
34 +
35 +/// Config directory: the host's, else `$SIGIT_CONFIG_DIR`, else `~/.config/sigit`.
36 fn config_dir() -> Option<PathBuf> {
37 + if let Some(dir) = CONFIG_DIR.get() {
38 + return Some(dir.clone());
39 + }
40 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
41 return Some(PathBuf::from(dir));
42 }
@@ -81,9 +98,8 @@ mod tests {
98
99 #[test]
100 fn round_trips_credentials_via_temp_dir() {
84 - let _guard = crate::ENV_TEST_LOCK
85 - .lock()
86 - .unwrap_or_else(|poisoned| poisoned.into_inner());
101 + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
102 + let _guard = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
103 let dir = std::env::temp_dir().join(format!("sigit_creds_test_{}", std::process::id()));
104 let _ = std::fs::remove_dir_all(&dir);
105 // SAFETY: single-threaded test; restores below.
crates/sigit-agent/src/lib.rs new
+18
@@ -0,0 +1,18 @@
1 +//! siGit Code as a library: its [`Profile`](ed_acp::Profile) for `ed-acp`, the browser sign-in
2 +//! and the stored session, for hosts that embed the agent instead of launching the `sigit`
3 +//! binary.
4 +//!
5 +//! A host serves [`SiGitCode`] with `ed_acp::serve` over an in-memory channel and runs an ACP
6 +//! client on the other end, which keeps the agent inside an app sandbox that can't spawn it as
7 +//! a subprocess. Inference goes to siGit Code Cloud with the session [`SiGitAuth`] holds; the
8 +//! command-line agent uses the same sign-in and the same prompts.
9 +
10 +pub mod auth;
11 +pub mod browser_auth;
12 +pub mod credentials;
13 +pub mod profile;
14 +pub mod prompt;
15 +
16 +pub use auth::{METHOD_ID, SiGitAuth};
17 +pub use ed_acp;
18 +pub use profile::{INFO, SiGitCode, official_mcp_servers};
crates/sigit-agent/src/profile.rs new
+289
@@ -0,0 +1,289 @@
1 +//! siGit Code's [`Profile`] for `ed-acp`: who the agent is, its prompt and tools, its slash
2 +//! commands, its sign-in, and how a host reaches the official MCP server.
3 +
4 +use std::sync::Arc;
5 +
6 +use agent_client_protocol::schema::v1::{
7 + AvailableCommand, AvailableCommandInput, HttpHeader, McpServer, McpServerHttp,
8 + UnstructuredCommandInput,
9 +};
10 +use async_trait::async_trait;
11 +use ed_acp::{
12 + AgentInfo, AuthProvider, DescribeCtx, LlmEnv, Profile, PromptCtx, SessionCtx, ToolCtx,
13 + ToolOutcome, Toolset, WorkspaceTools,
14 +};
15 +use serde_json::Value;
16 +
17 +use crate::auth::{SiGitAuth, cloud_base_url};
18 +use crate::credentials;
19 +use crate::prompt::{CO_AUTHOR_TRAILER_PLACEHOLDER, INIT_PROMPT, SYSTEM_PROMPT};
20 +
21 +pub const INFO: AgentInfo = AgentInfo {
22 + name: "sigit-agent",
23 + display_name: "siGit Code",
24 + version: env!("CARGO_PKG_VERSION"),
25 + env: LlmEnv {
26 + prefix: "SIGIT_AGENT",
27 + dir_name: "sigit-agent",
28 + default_model: "onde-nova",
29 + },
30 +};
31 +
32 +/// Name of the official server in a session, so its tools are `mcp__sigit__*`, which is what
33 +/// [`SYSTEM_PROMPT`] tells the model to call.
34 +const OFFICIAL_MCP: &str = "sigit";
35 +
36 +/// The official siGit MCP server (issues, pull requests) as a session server for the signed-in
37 +/// user, `None` when signed out.
38 +///
39 +/// This is passed per session rather than offered as a process-wide built-in server: a shared
40 +/// server would keep the bearer it started with, and a user who signed out and in as someone
41 +/// else would keep reaching the first account's issues. A host calls this when it opens a
42 +/// session and passes the result in `mcpServers`.
43 +pub fn official_mcp_servers() -> Vec<McpServer> {
44 + if matches!(
45 + std::env::var("SIGIT_MCP_OFFICIAL").as_deref(),
46 + Ok("off" | "0" | "false")
47 + ) {
48 + return Vec::new();
49 + }
50 + let Some(token) = credentials::load_token() else {
51 + return Vec::new();
52 + };
53 + let url = std::env::var("SIGIT_MCP_URL")
54 + .ok()
55 + .filter(|url| !url.trim().is_empty())
56 + .unwrap_or_else(|| format!("{}/mcp", cloud_base_url()));
57 + vec![McpServer::Http(
58 + McpServerHttp::new(OFFICIAL_MCP, url).headers(vec![HttpHeader::new(
59 + "Authorization",
60 + format!("Bearer {token}"),
61 + )]),
62 + )]
63 +}
64 +
65 +/// What the model is told when the host cannot start processes.
66 +const NO_SHELL_NOTE: &str = "\n\nThis host cannot run commands: there is no run_command tool, and no git or shell. The rule \
67 +above about running commands yourself does not apply here. When something needs a command, \
68 +say which one and let the user run it.";
69 +
70 +/// siGit Code as an `ed-acp` agent.
71 +#[derive(Debug, Clone, Copy)]
72 +pub struct SiGitCode {
73 + shell: bool,
74 +}
75 +
76 +impl SiGitCode {
77 + /// With the workspace tools, `run_command` included.
78 + pub fn new() -> Self {
79 + Self { shell: true }
80 + }
81 +
82 + /// For hosts that cannot start processes (an iOS app, a sandboxed Mac app): the workspace
83 + /// tools without `run_command`, and a prompt that says so.
84 + pub fn without_shell() -> Self {
85 + Self { shell: false }
86 + }
87 +
88 + /// The model-facing tool list for this host, in the system prompt.
89 + fn host_note(&self) -> String {
90 + let tools = if self.shell {
91 + "read_file, write_file, edit_file, list_directory and run_command"
92 + } else {
93 + "read_file, write_file, edit_file and list_directory"
94 + };
95 + format!(
96 + "\n\nIn this host your file tools are {tools}, plus the tools of connected MCP \
97 + servers. Tools named above that are not in this list (such as read_website or \
98 + create_file) are not available; use write_file to create files."
99 + )
100 + }
101 +}
102 +
103 +impl Default for SiGitCode {
104 + fn default() -> Self {
105 + Self::new()
106 + }
107 +}
108 +
109 +/// `WorkspaceTools` minus `run_command`.
110 +struct FileTools;
111 +
112 +fn is_run_command(definition: &Value) -> bool {
113 + definition["function"]["name"] == "run_command"
114 +}
115 +
116 +#[async_trait]
117 +impl Toolset for FileTools {
118 + fn definitions(&self) -> Vec<Value> {
119 + WorkspaceTools
120 + .definitions()
121 + .into_iter()
122 + .filter(|definition| !is_run_command(definition))
123 + .collect()
124 + }
125 +
126 + fn handles(&self, name: &str) -> bool {
127 + name != "run_command" && WorkspaceTools.handles(name)
128 + }
129 +
130 + fn describe(
131 + &self,
132 + name: &str,
133 + args: &Value,
134 + ctx: &DescribeCtx<'_>,
135 + ) -> (
136 + String,
137 + agent_client_protocol::schema::v1::ToolKind,
138 + Vec<agent_client_protocol::schema::v1::ToolCallLocation>,
139 + ) {
140 + WorkspaceTools.describe(name, args, ctx)
141 + }
142 +
143 + async fn execute(
144 + &self,
145 + ctx: &ToolCtx,
146 + tool_call_id: &str,
147 + name: &str,
148 + args: Value,
149 + ) -> anyhow::Result<ToolOutcome> {
150 + if name == "run_command" {
151 + anyhow::bail!("This host cannot run commands.");
152 + }
153 + WorkspaceTools.execute(ctx, tool_call_id, name, args).await
154 + }
155 +}
156 +
157 +/// The co-author trailer for a commit made from `surface`, in the format `sigit` uses.
158 +fn co_author_trailer(surface: &str) -> String {
159 + format!(
160 + "Co-Authored-By: siGit Code v{}-{surface} <noreply@sigit.si>",
161 + INFO.version
162 + )
163 +}
164 +
165 +/// `/init`'s name and what it does, advertised to the client.
166 +fn init_command() -> AvailableCommand {
167 + AvailableCommand::new("init", "Create or improve AGENTS.md for this repository").input(
168 + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new("")),
169 + )
170 +}
171 +
172 +#[async_trait]
173 +impl Profile for SiGitCode {
174 + fn info(&self) -> AgentInfo {
175 + INFO
176 + }
177 +
178 + fn system_prompt(&self, ctx: &PromptCtx<'_>) -> String {
179 + let mut prompt = SYSTEM_PROMPT.replace(
180 + CO_AUTHOR_TRAILER_PLACEHOLDER,
181 + &co_author_trailer(ctx.surface),
182 + );
183 + prompt.push_str(&self.host_note());
184 + if !self.shell {
185 + prompt.push_str(NO_SHELL_NOTE);
186 + }
187 + prompt.push_str(&format!("\n\nWorking directory: {}", ctx.cwd.display()));
188 + if ctx.roots.len() > 1 {
189 + let roots: Vec<String> = ctx.roots.iter().map(|r| r.display().to_string()).collect();
190 + prompt.push_str(&format!("\nProject roots: {}", roots.join(", ")));
191 + }
192 + prompt
193 + }
194 +
195 + fn toolsets(&self) -> Vec<Arc<dyn Toolset>> {
196 + if self.shell {
197 + vec![Arc::new(WorkspaceTools)]
198 + } else {
199 + vec![Arc::new(FileTools)]
200 + }
201 + }
202 +
203 + fn slash_commands(&self, _ctx: &SessionCtx<'_>) -> Vec<AvailableCommand> {
204 + vec![init_command()]
205 + }
206 +
207 + fn expand_slash(&self, text: &str, _ctx: &SessionCtx<'_>) -> Option<String> {
208 + let rest = text.trim_start().strip_prefix("/init")?;
209 + // `/initialize` is not `/init`.
210 + if rest.is_empty() || rest.starts_with(char::is_whitespace) {
211 + Some(INIT_PROMPT.to_string())
212 + } else {
213 + None
214 + }
215 + }
216 +
217 + fn auth(&self) -> Option<Arc<dyn AuthProvider>> {
218 + Some(Arc::new(SiGitAuth))
219 + }
220 +}
221 +
222 +#[cfg(test)]
223 +mod tests {
224 + use std::path::Path;
225 +
226 + use super::*;
227 +
228 + fn names(toolsets: &[Arc<dyn Toolset>]) -> Vec<String> {
229 + toolsets
230 + .iter()
231 + .flat_map(|t| t.definitions())
232 + .map(|d| d["function"]["name"].as_str().unwrap().to_string())
233 + .collect()
234 + }
235 +
236 + #[test]
237 + fn the_shell_variant_offers_run_command_and_the_other_does_not() {
238 + let with = names(&SiGitCode::new().toolsets());
239 + let without = names(&SiGitCode::without_shell().toolsets());
240 + assert!(with.contains(&"run_command".to_string()));
241 + assert!(!without.contains(&"run_command".to_string()));
242 + assert!(without.contains(&"edit_file".to_string()));
243 + assert!(!FileTools.handles("run_command"));
244 + assert!(FileTools.handles("read_file"));
245 + }
246 +
247 + #[test]
248 + fn the_prompt_carries_the_surface_trailer_and_names_the_host() {
249 + let mcp = ed_acp::ed_mcp::McpToolset::empty(ed_acp::ed_mcp::ToolsetConfig::new(
250 + ed_acp::ed_mcp::ClientInfo::new("t", "0"),
251 + ));
252 + let roots = vec![Path::new("/work/a").to_path_buf()];
253 + let ctx = PromptCtx {
254 + surface: "app",
255 + cwd: Path::new("/work/a"),
256 + roots: &roots,
257 + mcp: &mcp,
258 + };
259 + let prompt = SiGitCode::without_shell().system_prompt(&ctx);
260 + assert!(prompt.contains("siGit Code v"), "{prompt}");
261 + assert!(prompt.contains("-app <noreply@sigit.si>"));
262 + assert!(!prompt.contains(CO_AUTHOR_TRAILER_PLACEHOLDER));
263 + assert!(prompt.contains("cannot run commands"));
264 + assert!(prompt.contains("Working directory: /work/a"));
265 + let with_shell = SiGitCode::new().system_prompt(&ctx);
266 + assert!(!with_shell.contains("cannot run commands"));
267 + }
268 +
269 + #[test]
270 + fn init_expands_to_the_init_prompt_and_only_init() {
271 + let mcp = ed_acp::ed_mcp::McpToolset::empty(ed_acp::ed_mcp::ToolsetConfig::new(
272 + ed_acp::ed_mcp::ClientInfo::new("t", "0"),
273 + ));
274 + let ctx = SessionCtx { mcp: &mcp };
275 + let profile = SiGitCode::new();
276 + assert_eq!(
277 + profile.expand_slash("/init", &ctx).as_deref(),
278 + Some(INIT_PROMPT)
279 + );
280 + assert!(profile.expand_slash(" /init now", &ctx).is_some());
281 + assert!(profile.expand_slash("/initialize", &ctx).is_none());
282 + assert!(profile.expand_slash("hello", &ctx).is_none());
283 + }
284 +
285 + #[test]
286 + fn the_profile_brings_its_own_sign_in() {
287 + assert!(SiGitCode::new().auth().is_some());
288 + }
289 +}
crates/sigit-agent/src/prompt.rs new
+194
@@ -0,0 +1,194 @@
1 +//! The prompts siGit Code speaks with, shared by the command-line agent and the hosts that
2 +//! embed it.
3 +
4 +/// Stands in for the commit trailer inside [`SYSTEM_PROMPT`]. A host fills it in with the
5 +/// trailer for its own surface: the CLI in `system_prompt_for_model`, [`crate::SiGitCode`] for
6 +/// an embedding app.
7 +pub const CO_AUTHOR_TRAILER_PLACEHOLDER: &str = "{commit_co_author_trailer}";
8 +
9 +/// The system prompt for a model that calls tools.
10 +pub const SYSTEM_PROMPT: &str = concat!(
11 + "\
12 +Your name is siGit — lowercase 's', uppercase 'G', no spaces. \
13 +Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are.
14 +
15 +You are a strong general-purpose coding agent. smbCloud is your home turf, \
16 +but you should still be useful in any codebase. When the project is clearly \
17 +about smbCloud, use that context directly instead of falling back to vague \
18 +cloud-platform advice.
19 +
20 +smbCloud context you should know and use when it helps:
21 +- smbCloud is a platform for deploying and managing projects
22 +- the main CLI is a Rust workspace with focused crates rather than one giant crate
23 +- common areas include auth, project management, deploy flows, networking, \
24 + shared models, release tooling, and managed services
25 +- deploy branches usually follow `release/service-{name}`
26 +- Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \
27 + they often use a local build plus rsync/PM2 style flow
28 +- auth has a hard boundary between smbCloud platform users and tenant app users; \
29 + platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \
30 + you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser`
31 +- smbCloud authorization is layered; do not flatten platform accounts, tenant \
32 + memberships, auth-app collaborators, and tenant end users into one model
33 +- `Project` is the umbrella workspace, while app-like resources such as \
34 + `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \
35 + ownership, sharing, and collaboration rules
36 +- `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \
37 + preserve those cardinality rules unless the code clearly changes them
38 +- GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \
39 + service with its own credentials and boundaries, not as a generic local DB helper
40 +- when debugging smbCloud Rails APIs, first classify the request: first-party \
41 + smbCloud app or tenant app, then check which endpoint family and validator \
42 + should be involved before changing code
43 +- when working in smbCloud repos, prefer existing workspace patterns, existing \
44 + crate boundaries, existing Rails conventions, and existing command flows over \
45 + inventing new abstractions
46 +
47 +CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
48 +When the user asks you to clone a repo, run a build, check git status, or do \
49 +anything that involves a shell command, you MUST call the run_command tool and \
50 +execute it yourself. Do not print shell commands for the user to copy-paste. \
51 +Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
52 +If a command fails, try to fix the problem and re-run it. If you cannot fix it \
53 +after two attempts, explain what went wrong and what you tried.
54 +
55 +Git operations — always use run_command:
56 +- git clone: always pass the full absolute destination path as the last argument \
57 + and set cwd to an existing writable parent directory. Example: \
58 + run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
59 + \"cwd\": \"/Users/me/Repositories\"})
60 +- git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
61 + stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
62 + the repo root
63 +- never run git clone without an explicit absolute destination path
64 +- if a clone or init fails, check the error, fix the cause (wrong path, missing \
65 + directory, permissions), and retry
66 +- when you create a commit, always end the commit message with a blank line and \
67 + then this trailer on its own line: ",
68 + // CO_AUTHOR_TRAILER_PLACEHOLDER (concat! takes only literals), filled in
69 + // by `system_prompt_for_model` with tools::commit_co_author_trailer(),
70 + // which depends on the surface picked at startup.
71 + "{commit_co_author_trailer}",
72 + " \
73 + — that exact format is the standard co-author trailer git forges detect, so \
74 + siGit is credited as co-author. If a commit lands without it, siGit Code \
75 + amends the trailer in automatically and the tool output says so; do not amend \
76 + again yourself.
77 +
78 +CRITICAL — forge boundaries: git is not the forge. Plain git commands (clone, \
79 +commit, push, pull, branch, log, diff, tag, ...) work on any repository, but \
80 +issues, pull requests, reviews, releases, and other forge features are NOT git \
81 +features and there is no forge-agnostic way to reach them. Detect the forge \
82 +from the repo's remote URL (run `git remote get-url origin`) before doing any \
83 +issue or PR work:
84 +- sigit.si: issue and pull request workflows go through the official MCP \
85 + tools: mcp__sigit__list_issues, mcp__sigit__get_issue, \
86 + mcp__sigit__create_issue, mcp__sigit__list_pull_requests, and \
87 + mcp__sigit__get_pull_request. These tools are valid ONLY for repositories \
88 + hosted on sigit.si. Prefer them over shelling out to git or fetching web \
89 + pages for issue and PR queries on sigit.si repos.
90 +- any other host (github.com, gitlab.com, bitbucket.org, self-hosted forges, \
91 + ...): the mcp__sigit__* tools do NOT work there. Use the forge's own CLI via \
92 + run_command when it is installed (`gh` for GitHub, `glab` for GitLab, `tea` \
93 + for Gitea/Forgejo), its web API via read_website when that is practical, or \
94 + ask the user how they want to interact with the forge.
95 +Never assume a repo is on GitHub, and never use the mcp__sigit__* tools \
96 +against a repo whose remote is not sigit.si.
97 +
98 +Never introduce yourself unless asked. Jump straight into the answer. \
99 +Keep answers short. Write idiomatic code. \
100 +Fix root causes, not symptoms.
101 +
102 +You have access to tools that let you read files, read websites directly from \
103 +http and https URLs, create directories, list directories, search code, create \
104 +new files, edit existing files, delete files, and run shell commands. You can \
105 +also use git directly through shell commands, including `git init` and normal \
106 +git workflows. Use them proactively. Read the code or website before answering. \
107 +Prefer absolute paths when referring to files and directories, especially in \
108 +protocol-facing output and tool arguments. Create directories when needed. Run \
109 +builds, tests, and git commands after making changes. Ground your answers in \
110 +the actual code or fetched page content, not in guesses.
111 +
112 +CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
113 +access. You have a read_website tool that fetches any http or https URL and \
114 +returns the page text. When the user gives you a URL or asks you to read, \
115 +summarize, or inspect a web page, you MUST call the read_website tool with that \
116 +URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
117 +You can. Use the tool.
118 +
119 +CRITICAL — before every edit_file call, you MUST call read_file on the target \
120 +file first (or the specific line range if one was given). Never rely on file \
121 +content you saw in a previous turn — the user may have reverted, edited, or \
122 +changed the file externally since then. Always re-read to get the current state \
123 +before constructing old_text. \
124 +When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
125 +treat it as a fresh task: re-read the file, identify the current content, and \
126 +plan the edit from scratch. Do not assume the file still reflects your last edit.
127 +
128 +Tool-use heuristics:
129 +- when the user provides a URL or asks about a web page, ALWAYS call \
130 + read_website — never refuse or claim you lack internet access
131 +- prefer absolute paths over relative paths when you mention, return, or pass \
132 + file and directory paths
133 +- if a path does not exist yet, create the directory before creating files in it
134 +- if the user asks to clone a repo, immediately call run_command with git clone \
135 + and an absolute destination path — do not ask where to put it unless the \
136 + request is ambiguous; default to the user's home Repositories directory
137 +- if the user asks for a new repo, scaffold, or scratch project, create the \
138 + directory, create the first files, and run `git init` without waiting unless \
139 + the request says otherwise
140 +- if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \
141 + shared models, and existing command handlers before adding new abstractions
142 +- if the repo looks like smbCloud Rails code, check routes, controllers, \
143 + validators, and model boundaries before changing business logic
144 +- if the task touches smbCloud auth, first decide whether it is a platform-user \
145 + flow or a tenant-app flow, then follow the right endpoint family and model layer
146 +- if the task touches smbCloud deploy code, check whether it is the generic \
147 + deploy path or the Next.js SSR path before proposing changes
148 +- after edits, prefer running the smallest useful verification step first, then \
149 + widen to broader checks if needed
150 +- use git commands naturally for status checks, repo setup, diffs, and normal \
151 + developer workflows when they help move the task forward
152 +- if a tool call fails, read the error, try to fix it, and retry — do not \
153 + fall back to telling the user what to type
154 +
155 +Replies are plain Markdown. Never wrap a reply, or any part of it, in XML-style \
156 +tags you made up, and never write a system message, warning, or notice as if it \
157 +came from siGit Code or the system: only siGit Code speaks for itself, and \
158 +nothing renders those tags. To offer the user a choice, list the options as \
159 +plain text.
160 +
161 +When the repo is not about smbCloud, act like a normal coding agent and do not \
162 +force smbCloud-specific advice into the answer. When it is about smbCloud, be \
163 +specific and practical.
164 +
165 +Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
166 +root cause, not the symptom. Correct beats clever."
167 +);
168 +
169 +/// The prompt behind the `/init` slash command. Both the TUI and the ACP
170 +/// server substitute it for the user's input and run a normal agent turn, so
171 +/// generation goes through the ordinary tools and permission checks. It lives
172 +/// here, next to the loader that consumes the file it produces.
173 +pub const INIT_PROMPT: &str = "\
174 +Analyze this repository and write an AGENTS.md instruction file for AI coding \
175 +agents working in it.
176 +
177 +First explore, then write. Read the manifest/build files (Cargo.toml, \
178 +package.json, pyproject.toml, Makefile, or equivalents), the CI configuration, \
179 +the README, and the top one or two levels of the directory tree. Read broadly \
180 +but shallowly; do not descend into every subdirectory.
181 +
182 +Then create AGENTS.md at the repository root covering, briefly:
183 +- what the project is and does (a sentence or two)
184 +- how to build, test, and lint it (the exact commands)
185 +- the architecture: main modules/directories and what each owns
186 +- conventions an agent must follow (branch naming, commit rules, code style, \
187 +platform constraints) that the repository itself shows evidence of
188 +
189 +Keep it compact — aim for under 60 lines. State only what this repository \
190 +actually supports; no generic boilerplate.
191 +
192 +If AGENTS.md or CLAUDE.md already exists, read it first and improve it in \
193 +place (fix what is stale, add what is missing) instead of replacing it. \
194 +Always target AGENTS.md, never CLAUDE.md.";
crates/sigit-agent/tests/embedded.rs new
+121
@@ -0,0 +1,121 @@
1 +//! `SiGitCode` served in-process, the way an app hosts it, against the testkit's mock endpoint:
2 +//! the method is advertised, a signed-out session is refused, a stored session reaches the
3 +//! endpoint as the bearer, and signing out takes it away again.
4 +//!
5 +//! One test function: the sign-in reads process-wide environment variables.
6 +
7 +use std::sync::Arc;
8 +use std::sync::atomic::Ordering;
9 +
10 +use agent_client_protocol::schema::ProtocolVersion;
11 +use agent_client_protocol::schema::v1::{
12 + ContentBlock, InitializeRequest, LogoutRequest, NewSessionRequest, PromptRequest, StopReason,
13 + TextContent,
14 +};
15 +use agent_client_protocol::{Agent, Channel, Client, ConnectionTo};
16 +use ed_acp::ServeOptions;
17 +use ed_acp_testkit::{MockStats, Script, Step, start_mock_llm};
18 +use sigit_agent::{METHOD_ID, SiGitCode, credentials};
19 +
20 +#[tokio::test]
21 +async fn the_profile_signs_in_through_its_provider_and_talks_to_the_cloud() {
22 + let dir = std::env::temp_dir().join(format!("sigit_agent_embedded_{}", std::process::id()));
23 + let _ = std::fs::remove_dir_all(&dir);
24 + std::fs::create_dir_all(&dir).unwrap();
25 +
26 + let stats = Arc::new(MockStats::default());
27 + let url = start_mock_llm(
28 + Script(vec![Step::Final("hello from the cloud")]),
29 + stats.clone(),
30 + )
31 + .await;
32 + // SAFETY: this is the only test in the binary, so nothing else reads the environment.
33 + unsafe {
34 + std::env::set_var("SIGIT_CONFIG_DIR", dir.join("config"));
35 + std::env::set_var("SIGIT_AGENT_DATA_DIR", dir.join("data"));
36 + std::env::set_var("SIGIT_CLOUD_URL", &url);
37 + std::env::set_var("SIGIT_AGENT_MODEL", ed_acp_testkit::MODEL);
38 + // The mock is the only server in play.
39 + std::env::set_var("SIGIT_MCP_OFFICIAL", "off");
40 + }
41 + assert!(credentials::load().is_none());
42 +
43 + let (agent_end, client_end) = Channel::duplex();
44 + let opts = ServeOptions {
45 + yolo: false,
46 + surface: "app",
47 + };
48 + tokio::spawn(ed_acp::serve(
49 + Arc::new(SiGitCode::without_shell()),
50 + opts,
51 + agent_end,
52 + ));
53 + let cwd = dir.clone();
54 + let seen = stats.clone();
55 +
56 + Client
57 + .builder()
58 + .connect_with(client_end, |conn: ConnectionTo<Agent>| async move {
59 + let init = conn
60 + .send_request(InitializeRequest::new(ProtocolVersion::V1))
61 + .block_task()
62 + .await?;
63 + assert_eq!(init.auth_methods.len(), 1);
64 + assert_eq!(&*init.auth_methods[0].id().0, METHOD_ID);
65 + assert_eq!(init.agent_info.unwrap().name, "sigit-agent");
66 +
67 + let refused = conn
68 + .send_request(NewSessionRequest::new(cwd.clone()))
69 + .block_task()
70 + .await;
71 + assert!(refused.is_err(), "signed out, so no session");
72 +
73 + // What a finished browser sign-in leaves behind.
74 + credentials::store(&credentials::Credentials {
75 + // The mock accepts one bearer.
76 + access_token: ed_acp_testkit::TEST_KEY.into(),
77 + email: Some("dev@sigit.si".into()),
78 + })
79 + .unwrap();
80 +
81 + let session = conn
82 + .send_request(NewSessionRequest::new(cwd.clone()))
83 + .block_task()
84 + .await?;
85 + let reply = conn
86 + .send_request(PromptRequest::new(
87 + session.session_id.clone(),
88 + vec![ContentBlock::Text(TextContent::new("hi"))],
89 + ))
90 + .block_task()
91 + .await?;
92 + assert_eq!(reply.stop_reason, StopReason::EndTurn);
93 + assert!(*seen.saw_bearer.lock().unwrap());
94 + assert_eq!(seen.requests.load(Ordering::SeqCst), 1);
95 +
96 + // The model saw siGit Code's prompt and no run_command.
97 + let payload = seen.payloads.lock().unwrap()[0].clone();
98 + let system = payload["messages"][0]["content"]
99 + .as_str()
100 + .unwrap()
101 + .to_string();
102 + assert!(system.contains("siGit"));
103 + assert!(system.contains("cannot run commands"));
104 + let tools = payload["tools"].to_string();
105 + assert!(tools.contains("edit_file"));
106 + assert!(!tools.contains("run_command"));
107 +
108 + conn.send_request(LogoutRequest::new()).block_task().await?;
109 + assert!(credentials::load().is_none());
110 + let after = conn
111 + .send_request(NewSessionRequest::new(cwd.clone()))
112 + .block_task()
113 + .await;
114 + assert!(after.is_err(), "signed out again");
115 + Ok(())
116 + })
117 + .await
118 + .expect("conversation");
119 +
120 + let _ = std::fs::remove_dir_all(&dir);
121 +}