sigit-code 1.6.1__tar.gz → 1.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/AGENTS.md +19 -1
- {sigit_code-1.6.1 → sigit_code-1.6.2}/AGENTS.md +19 -1
- {sigit_code-1.6.1 → sigit_code-1.6.2}/CHANGELOG.md +28 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/CLAUDE.md +19 -1
- {sigit_code-1.6.1 → sigit_code-1.6.2}/Cargo.lock +2 -1
- {sigit_code-1.6.1 → sigit_code-1.6.2}/Cargo.toml +6 -1
- {sigit_code-1.6.1 → sigit_code-1.6.2}/PKG-INFO +1 -1
- sigit_code-1.6.2/src/client_fs.rs +350 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/main.rs +178 -31
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/tools.rs +327 -180
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/workspace.rs +1 -1
- sigit_code-1.6.2/tests/acp_client_fs.rs +549 -0
- sigit_code-1.6.2/tests/version_flag.rs +32 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/agent-client-protocol/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/ai-assisted-coding/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/branding/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/run-sigit/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/run-sigit/driver.mjs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/run-sigit/tui-smoke.sh +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/sigit-code-release/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.agents/skills/tool-calling/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.cargo/config.toml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/agent-client-protocol/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/ai-assisted-coding/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/branding/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/run-sigit/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/run-sigit/driver.mjs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/run-sigit/tui-smoke.sh +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/sigit-code-release/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.claude/skills/tool-calling/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/ci.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-aur.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-crates.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-github.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-homebrew.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-npm.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-nuget.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-pypi.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-scoop.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.github/workflows/release-winget.yml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.gitignore +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.nvmrc +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/.sigit/factory/config.toml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/LICENSE +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/README.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/docs/headless.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/docs/hooks.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/docs/mcp.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/examples/settings-with-hooks.toml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/examples/skills/README.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/examples/skills/commit-message/SKILL.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/README.md.tmpl +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/package-compat.json.tmpl +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/package-main.json.tmpl +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/package.json.tmpl +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/scripts/render-main-package.cjs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/scripts/render-platform-package.cjs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/sigit/.gitignore +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/sigit/README.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/sigit/package.json +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/sigit/src/index.ts +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/npm/sigit/tsconfig.json +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/nuget/.gitignore +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/nuget/sigit/Program.cs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/nuget/sigit/README.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/nuget/sigit/SiGit.Code.csproj +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/packaging/aur/PKGBUILD.in +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/packaging/nfpm.yaml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/packaging/windows/check-static-crt.ps1 +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/packaging/winget/getSigit.siGitCode.installer.yaml.in +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/packaging/winget/getSigit.siGitCode.locale.en-US.yaml.in +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/packaging/winget/getSigit.siGitCode.yaml.in +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/pypi/README.md +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/pypi/pyproject.toml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/pyproject.toml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/rust-toolchain.toml +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/account.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/backend.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/browser_auth.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/chat.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/commands.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/credentials.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/frontmatter.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/headless.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/hooks.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/inline_tool_calls.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/instructions.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/mcp.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/models.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/permissions.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/provider.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/session_store.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/settings.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/setup.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/skills.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/src/subagents.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_endpoint_errors.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_images.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_multi_root.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_permissions.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_session_list.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_session_load.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_stop_reasons.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/acp_tool_stdin.rs +0 -0
- {sigit_code-1.6.1 → sigit_code-1.6.2}/tests/headless_mode.rs +0 -0
|
@@ -299,6 +299,23 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
|
|
|
299
299
|
every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
|
|
300
300
|
any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
|
|
301
301
|
client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
|
|
302
|
+
- **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
|
|
303
|
+
advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
|
|
304
|
+
and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
|
|
305
|
+
`edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
|
|
306
|
+
connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
|
|
307
|
+
registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
|
|
308
|
+
`execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
|
|
309
|
+
`edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
|
|
310
|
+
render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
|
|
311
|
+
client path share, so the model gets the same result text either way. The two capabilities are
|
|
312
|
+
independent: a client that only reads still gets its edits written to disk. The disk is the
|
|
313
|
+
fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
|
|
314
|
+
session's roots is not routed, and a request the client fails or leaves unanswered for 30
|
|
315
|
+
seconds is retried on disk with a warning in the log. The client is only ever asked from
|
|
316
|
+
inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
|
|
317
|
+
permission requests). Existence checks and `create_file`'s parent directories still use the
|
|
318
|
+
disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
|
|
302
319
|
- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
|
|
303
320
|
`tokio::select!` to multiplex terminal events with streaming tokens.
|
|
304
321
|
- **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
|
|
@@ -363,7 +380,8 @@ verbosity with `RUST_LOG`.
|
|
|
363
380
|
`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
|
|
364
381
|
default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
|
|
365
382
|
`SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
|
|
366
|
-
headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `
|
|
383
|
+
headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_CLIENT_FS` (`off` keeps the file tools on disk even when the
|
|
384
|
+
ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
|
|
367
385
|
smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
|
|
368
386
|
server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
|
|
369
387
|
mutating tools; the escape hatch for clients without permission-request support),
|
|
@@ -299,6 +299,23 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
|
|
|
299
299
|
every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
|
|
300
300
|
any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
|
|
301
301
|
client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
|
|
302
|
+
- **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
|
|
303
|
+
advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
|
|
304
|
+
and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
|
|
305
|
+
`edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
|
|
306
|
+
connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
|
|
307
|
+
registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
|
|
308
|
+
`execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
|
|
309
|
+
`edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
|
|
310
|
+
render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
|
|
311
|
+
client path share, so the model gets the same result text either way. The two capabilities are
|
|
312
|
+
independent: a client that only reads still gets its edits written to disk. The disk is the
|
|
313
|
+
fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
|
|
314
|
+
session's roots is not routed, and a request the client fails or leaves unanswered for 30
|
|
315
|
+
seconds is retried on disk with a warning in the log. The client is only ever asked from
|
|
316
|
+
inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
|
|
317
|
+
permission requests). Existence checks and `create_file`'s parent directories still use the
|
|
318
|
+
disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
|
|
302
319
|
- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
|
|
303
320
|
`tokio::select!` to multiplex terminal events with streaming tokens.
|
|
304
321
|
- **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
|
|
@@ -363,7 +380,8 @@ verbosity with `RUST_LOG`.
|
|
|
363
380
|
`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
|
|
364
381
|
default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
|
|
365
382
|
`SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
|
|
366
|
-
headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `
|
|
383
|
+
headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_CLIENT_FS` (`off` keeps the file tools on disk even when the
|
|
384
|
+
ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
|
|
367
385
|
smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
|
|
368
386
|
server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
|
|
369
387
|
mutating tools; the escape hatch for clients without permission-request support),
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.6.2
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`sigit --version` prints the release.** `sigit --version` and `sigit -V`
|
|
8
|
+
print `sigit <version>` on stdout and exit 0. Before, the flag was not
|
|
9
|
+
recognized and siGit Code started a session, which left a script that asked
|
|
10
|
+
for the version waiting.
|
|
11
|
+
|
|
12
|
+
- **File tools read and write through the editor when it offers to.** An ACP
|
|
13
|
+
client that advertises `fs.readTextFile` or `fs.writeTextFile` serves those
|
|
14
|
+
requests from its open buffers. siGit Code always used the disk, so
|
|
15
|
+
`read_file` missed unsaved changes and `edit_file` wrote underneath a buffer
|
|
16
|
+
the user might have modified. `read_file`, `create_file`, `edit_file` and
|
|
17
|
+
`multi_edit` now go through the client for paths inside the session's roots.
|
|
18
|
+
The two capabilities are independent, and the disk is still the fallback:
|
|
19
|
+
for a path outside the roots, and for a request the client fails or leaves
|
|
20
|
+
unanswered for 30 seconds. `SIGIT_CLIENT_FS=off` keeps the file tools on
|
|
21
|
+
disk (#147).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **A file attached from the editor is found when its path needs escaping.**
|
|
26
|
+
A `resource_link` was read by stripping `file://` and using the rest as the
|
|
27
|
+
path, so a space arrived as `%20`, a non-ASCII name failed, and a Windows
|
|
28
|
+
URI kept its leading slash. A `#` in a file name was also taken for the
|
|
29
|
+
line-range fragment. The URI is now parsed properly (#170).
|
|
30
|
+
|
|
3
31
|
## 1.6.1
|
|
4
32
|
|
|
5
33
|
### Added
|
|
@@ -299,6 +299,23 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
|
|
|
299
299
|
every root. MCP is deliberately not on that list — `mcp::init` runs once at startup, before
|
|
300
300
|
any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell. (The servers a
|
|
301
301
|
client names in `mcpServers` are a different thing and are per session; see `src/mcp.rs`.)
|
|
302
|
+
- **`src/client_fs.rs`** — file reads and writes through the ACP client. A client that
|
|
303
|
+
advertises `fs.readTextFile` / `fs.writeTextFile` in `initialize` serves `fs/read_text_file`
|
|
304
|
+
and `fs/write_text_file` from its buffers, so `read_file` sees unsaved changes and
|
|
305
|
+
`edit_file` lands in the open buffer instead of underneath it. `tools.rs` has no access to the
|
|
306
|
+
connection, so this is a seam like the one `mcp::call_tool` gives MCP: `handle_initialize`
|
|
307
|
+
registers a `ClientFileSystem` (`AcpClientFs` in `main.rs`, holding the connection), and
|
|
308
|
+
`execute_tool_impl` asks `route_for` before it dispatches `read_file`, `create_file`,
|
|
309
|
+
`edit_file` or `multi_edit`. Each of those tools is split into a parse step and a pure
|
|
310
|
+
render/apply step (`ReadFileCall`, `CreateFileCall`, `EditCall`) that the disk path and the
|
|
311
|
+
client path share, so the model gets the same result text either way. The two capabilities are
|
|
312
|
+
independent: a client that only reads still gets its edits written to disk. The disk is the
|
|
313
|
+
fallback throughout: nothing is registered in the TUI or headless modes, a path outside the
|
|
314
|
+
session's roots is not routed, and a request the client fails or leaves unanswered for 30
|
|
315
|
+
seconds is retried on disk with a warning in the log. The client is only ever asked from
|
|
316
|
+
inside a spawned prompt turn, never from `handle_initialize` itself (deadlock, same as
|
|
317
|
+
permission requests). Existence checks and `create_file`'s parent directories still use the
|
|
318
|
+
disk. `SIGIT_CLIENT_FS=off` turns the routing off. Covered by `tests/acp_client_fs.rs`.
|
|
302
319
|
- **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
|
|
303
320
|
`tokio::select!` to multiplex terminal events with streaming tokens.
|
|
304
321
|
- **`src/headless.rs`** — non-interactive `sigit run` execution for scripts, CI, and Factory
|
|
@@ -363,7 +380,8 @@ verbosity with `RUST_LOG`.
|
|
|
363
380
|
`OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
|
|
364
381
|
default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
|
|
365
382
|
`SIGIT_MODEL`, `SIGIT_MAX_TOOL_ROUNDS` (1 to 500, default 24; the tool-round cap of a
|
|
366
|
-
headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `
|
|
383
|
+
headless run or an ACP prompt turn), `SIGIT_MCP` (`off` disables MCP), `SIGIT_CLIENT_FS` (`off` keeps the file tools on disk even when the
|
|
384
|
+
ACP client offers `fs/read_text_file` / `fs/write_text_file`), `SIGIT_MCP_SMBCLOUD` (`off` drops the baked-in
|
|
367
385
|
smbCloud CLI server), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
|
|
368
386
|
server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
|
|
369
387
|
mutating tools; the escape hatch for clients without permission-request support),
|
|
@@ -5778,7 +5778,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|
|
5778
5778
|
|
|
5779
5779
|
[[package]]
|
|
5780
5780
|
name = "sigit"
|
|
5781
|
-
version = "1.6.
|
|
5781
|
+
version = "1.6.2"
|
|
5782
5782
|
dependencies = [
|
|
5783
5783
|
"agent-client-protocol",
|
|
5784
5784
|
"anyhow",
|
|
@@ -5801,6 +5801,7 @@ dependencies = [
|
|
|
5801
5801
|
"tokio-util",
|
|
5802
5802
|
"toml 0.8.23",
|
|
5803
5803
|
"tracing-subscriber",
|
|
5804
|
+
"url",
|
|
5804
5805
|
"uuid 1.26.0",
|
|
5805
5806
|
]
|
|
5806
5807
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "sigit"
|
|
3
|
-
version = "1.6.
|
|
3
|
+
version = "1.6.2"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
|
|
6
6
|
documentation = "https://github.com/getsigit/sigit"
|
|
@@ -60,6 +60,11 @@ base64 = "0.22"
|
|
|
60
60
|
rand = "0.9"
|
|
61
61
|
sha2 = "0.10"
|
|
62
62
|
|
|
63
|
+
# ACP `resource_link` blocks carry file URIs. Parsing them with `url` decodes
|
|
64
|
+
# percent escapes and maps Windows drive paths, which hand-stripping `file://`
|
|
65
|
+
# did not. Already in the graph through reqwest.
|
|
66
|
+
url = "2"
|
|
67
|
+
|
|
63
68
|
# Release binaries ship to five registries, and the NuGet package bundles all
|
|
64
69
|
# six targets in a single archive, so binary size is a distribution constraint
|
|
65
70
|
# rather than a nicety. Stripping symbols takes the macOS arm64 binary from
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
//! Reading and writing files through the ACP client instead of the disk.
|
|
2
|
+
//!
|
|
3
|
+
//! An editor that advertises `fs.readTextFile` / `fs.writeTextFile` in its
|
|
4
|
+
//! `initialize` request will serve `fs/read_text_file` and `fs/write_text_file`
|
|
5
|
+
//! from its own buffers. Going through it is what lets `read_file` see changes
|
|
6
|
+
//! the user has not saved yet, and lets `edit_file` land in the open buffer
|
|
7
|
+
//! rather than underneath it, where the editor would have to reconcile the
|
|
8
|
+
//! file on disk with a buffer the user may have modified.
|
|
9
|
+
//!
|
|
10
|
+
//! The tool code in `tools.rs` has no access to the connection, so this module
|
|
11
|
+
//! is the seam, the same shape as the one `mcp::call_tool` gives MCP: `main.rs`
|
|
12
|
+
//! registers a [`ClientFileSystem`] once the client has said what it supports,
|
|
13
|
+
//! and the file tools ask [`route_for`] whether a path should go through it.
|
|
14
|
+
//!
|
|
15
|
+
//! The disk stays the fallback everywhere. Nothing is registered in the TUI or
|
|
16
|
+
//! headless modes, a path outside the session's roots is not the editor's to
|
|
17
|
+
//! serve, and a request the client fails or never answers is retried on disk,
|
|
18
|
+
//! so a client with a partial or broken implementation costs a log line rather
|
|
19
|
+
//! than the tool call.
|
|
20
|
+
|
|
21
|
+
use std::path::{Path, PathBuf};
|
|
22
|
+
use std::sync::{Arc, RwLock};
|
|
23
|
+
use std::time::Duration;
|
|
24
|
+
|
|
25
|
+
use async_trait::async_trait;
|
|
26
|
+
|
|
27
|
+
/// How long the client gets to answer one file request before the disk is used
|
|
28
|
+
/// instead. A buffer read or write is local work for an editor; a client that
|
|
29
|
+
/// takes longer than this has most likely dropped the request.
|
|
30
|
+
const CLIENT_FS_TIMEOUT: Duration = Duration::from_secs(30);
|
|
31
|
+
|
|
32
|
+
/// The client's side of ACP's file-system methods. Errors are plain strings:
|
|
33
|
+
/// they only ever reach the log, since the caller falls back to the disk.
|
|
34
|
+
#[async_trait]
|
|
35
|
+
pub trait ClientFileSystem: Send + Sync {
|
|
36
|
+
async fn read_text_file(&self, session_id: &str, path: &Path) -> Result<String, String>;
|
|
37
|
+
async fn write_text_file(
|
|
38
|
+
&self,
|
|
39
|
+
session_id: &str,
|
|
40
|
+
path: &Path,
|
|
41
|
+
content: &str,
|
|
42
|
+
) -> Result<(), String>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Clone)]
|
|
46
|
+
struct Registered {
|
|
47
|
+
fs: Arc<dyn ClientFileSystem>,
|
|
48
|
+
read: bool,
|
|
49
|
+
write: bool,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// The connected client's file system, with the two capabilities it
|
|
53
|
+
/// advertised. Capabilities are fixed for the connection, and the process
|
|
54
|
+
/// serves one connection, so this is set once from `initialize`.
|
|
55
|
+
static CLIENT: RwLock<Option<Registered>> = RwLock::new(None);
|
|
56
|
+
|
|
57
|
+
/// Record what the client offered in `clientCapabilities.fs`. A client that
|
|
58
|
+
/// offered neither method leaves the disk as the only path.
|
|
59
|
+
pub fn register(fs: Arc<dyn ClientFileSystem>, read: bool, write: bool) {
|
|
60
|
+
let registered = (read || write).then_some(Registered { fs, read, write });
|
|
61
|
+
if let Ok(mut guard) = CLIENT.write() {
|
|
62
|
+
*guard = registered;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// `SIGIT_CLIENT_FS=off` keeps every file tool on the disk, for a client whose
|
|
67
|
+
/// file-system methods misbehave in a way the fallback cannot see.
|
|
68
|
+
pub fn disabled_by_env() -> bool {
|
|
69
|
+
std::env::var("SIGIT_CLIENT_FS")
|
|
70
|
+
.map(|value| {
|
|
71
|
+
matches!(
|
|
72
|
+
value.trim().to_ascii_lowercase().as_str(),
|
|
73
|
+
"off" | "0" | "false"
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
.unwrap_or(false)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// One file's way through the client, for the session that asked.
|
|
80
|
+
pub struct Route {
|
|
81
|
+
fs: Arc<dyn ClientFileSystem>,
|
|
82
|
+
session_id: String,
|
|
83
|
+
read: bool,
|
|
84
|
+
write: bool,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// The client route for `path`, or `None` when the tool should use the disk:
|
|
88
|
+
/// no client file system is registered, no session is live, or the path is
|
|
89
|
+
/// outside the session's roots. `path` must be absolute, which is also what
|
|
90
|
+
/// the protocol requires of the paths it carries.
|
|
91
|
+
pub fn route_for(session_id: Option<&str>, path: &Path) -> Option<Route> {
|
|
92
|
+
let registered = CLIENT.read().ok()?.clone()?;
|
|
93
|
+
let session_id = session_id?;
|
|
94
|
+
if !path.is_absolute() || !in_roots(path, &crate::workspace::project_dirs()) {
|
|
95
|
+
return None;
|
|
96
|
+
}
|
|
97
|
+
Some(Route {
|
|
98
|
+
fs: registered.fs,
|
|
99
|
+
session_id: session_id.to_string(),
|
|
100
|
+
read: registered.read,
|
|
101
|
+
write: registered.write,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl Route {
|
|
106
|
+
/// The file's text: the client's copy when it can read, the disk's when it
|
|
107
|
+
/// cannot or the request failed.
|
|
108
|
+
pub async fn read(&self, path: &Path) -> std::io::Result<String> {
|
|
109
|
+
if self.read {
|
|
110
|
+
let request = self.fs.read_text_file(&self.session_id, path);
|
|
111
|
+
match tokio::time::timeout(CLIENT_FS_TIMEOUT, request).await {
|
|
112
|
+
Ok(Ok(content)) => return Ok(content),
|
|
113
|
+
Ok(Err(error)) => log::warn!(
|
|
114
|
+
"fs/read_text_file failed for {}: {error}; reading from disk",
|
|
115
|
+
path.display()
|
|
116
|
+
),
|
|
117
|
+
Err(_) => log::warn!(
|
|
118
|
+
"fs/read_text_file timed out for {}; reading from disk",
|
|
119
|
+
path.display()
|
|
120
|
+
),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
std::fs::read_to_string(path)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Write the file's text through the client when it can write, and to the
|
|
127
|
+
/// disk when it cannot or the request failed.
|
|
128
|
+
pub async fn write(&self, path: &Path, content: &str) -> std::io::Result<()> {
|
|
129
|
+
if self.write {
|
|
130
|
+
let request = self.fs.write_text_file(&self.session_id, path, content);
|
|
131
|
+
match tokio::time::timeout(CLIENT_FS_TIMEOUT, request).await {
|
|
132
|
+
Ok(Ok(())) => return Ok(()),
|
|
133
|
+
Ok(Err(error)) => log::warn!(
|
|
134
|
+
"fs/write_text_file failed for {}: {error}; writing to disk",
|
|
135
|
+
path.display()
|
|
136
|
+
),
|
|
137
|
+
Err(_) => log::warn!(
|
|
138
|
+
"fs/write_text_file timed out for {}; writing to disk",
|
|
139
|
+
path.display()
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
std::fs::write(path, content)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Whether `path` sits inside one of `roots`.
|
|
148
|
+
///
|
|
149
|
+
/// Both sides are resolved first, so a root reached through a symlink (macOS
|
|
150
|
+
/// spells `/tmp` as `/private/tmp`) still contains its files, and a `..` that
|
|
151
|
+
/// climbs out of a root does not pass as being inside it.
|
|
152
|
+
fn in_roots(path: &Path, roots: &[PathBuf]) -> bool {
|
|
153
|
+
let path = resolve(path);
|
|
154
|
+
roots
|
|
155
|
+
.iter()
|
|
156
|
+
.any(|root| path.starts_with(crate::workspace::canonical_key(root)))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// `path` with its deepest existing ancestor canonicalized. A file that is
|
|
160
|
+
/// about to be created cannot be canonicalized itself, but its directory can.
|
|
161
|
+
fn resolve(path: &Path) -> PathBuf {
|
|
162
|
+
let normalized = crate::workspace::normalize(path);
|
|
163
|
+
let mut missing: Vec<&std::ffi::OsStr> = Vec::new();
|
|
164
|
+
let mut existing = normalized.as_path();
|
|
165
|
+
loop {
|
|
166
|
+
if let Ok(canonical) = existing.canonicalize() {
|
|
167
|
+
return missing
|
|
168
|
+
.iter()
|
|
169
|
+
.rev()
|
|
170
|
+
.fold(canonical, |joined, part| joined.join(part));
|
|
171
|
+
}
|
|
172
|
+
match (existing.parent(), existing.file_name()) {
|
|
173
|
+
(Some(parent), Some(name)) => {
|
|
174
|
+
missing.push(name);
|
|
175
|
+
existing = parent;
|
|
176
|
+
}
|
|
177
|
+
_ => return normalized,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[cfg(test)]
|
|
183
|
+
mod tests {
|
|
184
|
+
use super::*;
|
|
185
|
+
|
|
186
|
+
/// A client holding one buffer's text, which can be told to fail.
|
|
187
|
+
struct FakeClient {
|
|
188
|
+
buffer: &'static str,
|
|
189
|
+
fail: bool,
|
|
190
|
+
written: std::sync::Mutex<Vec<(String, PathBuf, String)>>,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
impl FakeClient {
|
|
194
|
+
fn new(buffer: &'static str, fail: bool) -> Arc<Self> {
|
|
195
|
+
Arc::new(Self {
|
|
196
|
+
buffer,
|
|
197
|
+
fail,
|
|
198
|
+
written: std::sync::Mutex::default(),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[async_trait]
|
|
204
|
+
impl ClientFileSystem for FakeClient {
|
|
205
|
+
async fn read_text_file(&self, _session_id: &str, _path: &Path) -> Result<String, String> {
|
|
206
|
+
if self.fail {
|
|
207
|
+
return Err("no such buffer".to_string());
|
|
208
|
+
}
|
|
209
|
+
Ok(self.buffer.to_string())
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async fn write_text_file(
|
|
213
|
+
&self,
|
|
214
|
+
session_id: &str,
|
|
215
|
+
path: &Path,
|
|
216
|
+
content: &str,
|
|
217
|
+
) -> Result<(), String> {
|
|
218
|
+
if self.fail {
|
|
219
|
+
return Err("read-only buffer".to_string());
|
|
220
|
+
}
|
|
221
|
+
self.written.lock().unwrap().push((
|
|
222
|
+
session_id.to_string(),
|
|
223
|
+
path.to_path_buf(),
|
|
224
|
+
content.to_string(),
|
|
225
|
+
));
|
|
226
|
+
Ok(())
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
fn route(client: &Arc<FakeClient>, read: bool, write: bool) -> Route {
|
|
231
|
+
Route {
|
|
232
|
+
fs: Arc::clone(client) as Arc<dyn ClientFileSystem>,
|
|
233
|
+
session_id: "session-1".to_string(),
|
|
234
|
+
read,
|
|
235
|
+
write,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[tokio::test]
|
|
240
|
+
async fn reads_and_writes_go_to_the_client_when_it_offers_them() {
|
|
241
|
+
let dir = scratch("client");
|
|
242
|
+
let file = dir.join("a.txt");
|
|
243
|
+
std::fs::write(&file, "on disk").unwrap();
|
|
244
|
+
let client = FakeClient::new("in the buffer", false);
|
|
245
|
+
let route = route(&client, true, true);
|
|
246
|
+
|
|
247
|
+
assert_eq!(route.read(&file).await.unwrap(), "in the buffer");
|
|
248
|
+
route.write(&file, "edited").await.unwrap();
|
|
249
|
+
|
|
250
|
+
// The client owns the write, so the disk is left for it to update.
|
|
251
|
+
assert_eq!(std::fs::read_to_string(&file).unwrap(), "on disk");
|
|
252
|
+
assert_eq!(
|
|
253
|
+
*client.written.lock().unwrap(),
|
|
254
|
+
vec![("session-1".to_string(), file.clone(), "edited".to_string())]
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#[tokio::test]
|
|
261
|
+
async fn a_capability_the_client_did_not_offer_stays_on_disk() {
|
|
262
|
+
let dir = scratch("partial");
|
|
263
|
+
let file = dir.join("a.txt");
|
|
264
|
+
std::fs::write(&file, "on disk").unwrap();
|
|
265
|
+
let client = FakeClient::new("in the buffer", false);
|
|
266
|
+
let route = route(&client, false, false);
|
|
267
|
+
|
|
268
|
+
assert_eq!(route.read(&file).await.unwrap(), "on disk");
|
|
269
|
+
route.write(&file, "edited").await.unwrap();
|
|
270
|
+
|
|
271
|
+
assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited");
|
|
272
|
+
assert!(client.written.lock().unwrap().is_empty());
|
|
273
|
+
|
|
274
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#[tokio::test]
|
|
278
|
+
async fn a_request_the_client_fails_falls_back_to_disk() {
|
|
279
|
+
let dir = scratch("failing");
|
|
280
|
+
let file = dir.join("a.txt");
|
|
281
|
+
std::fs::write(&file, "on disk").unwrap();
|
|
282
|
+
let client = FakeClient::new("in the buffer", true);
|
|
283
|
+
let route = route(&client, true, true);
|
|
284
|
+
|
|
285
|
+
assert_eq!(route.read(&file).await.unwrap(), "on disk");
|
|
286
|
+
route.write(&file, "edited").await.unwrap();
|
|
287
|
+
|
|
288
|
+
assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited");
|
|
289
|
+
|
|
290
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fn scratch(name: &str) -> PathBuf {
|
|
294
|
+
let dir =
|
|
295
|
+
std::env::temp_dir().join(format!("sigit-client-fs-{name}-{}", uuid::Uuid::new_v4()));
|
|
296
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
297
|
+
dir
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn a_file_inside_a_root_is_in_roots() {
|
|
302
|
+
let root = scratch("inside");
|
|
303
|
+
std::fs::write(root.join("a.txt"), "a").unwrap();
|
|
304
|
+
|
|
305
|
+
assert!(in_roots(&root.join("a.txt"), std::slice::from_ref(&root)));
|
|
306
|
+
// Not created yet, so only its directory can be resolved.
|
|
307
|
+
assert!(in_roots(
|
|
308
|
+
&root.join("new").join("b.txt"),
|
|
309
|
+
std::slice::from_ref(&root)
|
|
310
|
+
));
|
|
311
|
+
|
|
312
|
+
std::fs::remove_dir_all(&root).ok();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn a_file_outside_every_root_is_not() {
|
|
317
|
+
let root = scratch("root");
|
|
318
|
+
let other = scratch("other");
|
|
319
|
+
std::fs::write(other.join("a.txt"), "a").unwrap();
|
|
320
|
+
|
|
321
|
+
assert!(!in_roots(&other.join("a.txt"), std::slice::from_ref(&root)));
|
|
322
|
+
// `..` out of the root names the sibling, however the path starts.
|
|
323
|
+
let climbing = root
|
|
324
|
+
.join("..")
|
|
325
|
+
.join(other.file_name().unwrap())
|
|
326
|
+
.join("a.txt");
|
|
327
|
+
assert!(!in_roots(&climbing, std::slice::from_ref(&root)));
|
|
328
|
+
assert!(!in_roots(&root.join("a.txt"), &[]));
|
|
329
|
+
|
|
330
|
+
std::fs::remove_dir_all(&root).ok();
|
|
331
|
+
std::fs::remove_dir_all(&other).ok();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#[cfg(unix)]
|
|
335
|
+
#[test]
|
|
336
|
+
fn a_symlink_out_of_the_root_is_not_inside_it() {
|
|
337
|
+
let root = scratch("link-root");
|
|
338
|
+
let outside = scratch("link-outside");
|
|
339
|
+
std::fs::write(outside.join("secret.txt"), "s").unwrap();
|
|
340
|
+
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
|
341
|
+
|
|
342
|
+
assert!(!in_roots(
|
|
343
|
+
&root.join("link").join("secret.txt"),
|
|
344
|
+
std::slice::from_ref(&root)
|
|
345
|
+
));
|
|
346
|
+
|
|
347
|
+
std::fs::remove_dir_all(&root).ok();
|
|
348
|
+
std::fs::remove_dir_all(&outside).ok();
|
|
349
|
+
}
|
|
350
|
+
}
|