From 5cf6661957d26237653edb48f414f4a5c54c6030 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 08:37:17 +0800 Subject: [PATCH 1/3] feat: add OpenSpec proposal for mergiraf native support Adds the full spec-driven planning artifacts for the mergiraf native tool change: proposal, design, specs (mergiraf-tool + tools delta), and tasks. Tracked in GitHub issue #36. Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: multica-agent --- .../mergiraf-native-support/.openspec.yaml | 2 + .../changes/mergiraf-native-support/design.md | 63 +++++++++++ .../mergiraf-native-support/proposal.md | 30 +++++ .../specs/mergiraf-tool/spec.md | 106 ++++++++++++++++++ .../specs/tools/spec.md | 15 +++ .../changes/mergiraf-native-support/tasks.md | 31 +++++ 6 files changed, 247 insertions(+) create mode 100644 openspec/changes/mergiraf-native-support/.openspec.yaml create mode 100644 openspec/changes/mergiraf-native-support/design.md create mode 100644 openspec/changes/mergiraf-native-support/proposal.md create mode 100644 openspec/changes/mergiraf-native-support/specs/mergiraf-tool/spec.md create mode 100644 openspec/changes/mergiraf-native-support/specs/tools/spec.md create mode 100644 openspec/changes/mergiraf-native-support/tasks.md diff --git a/openspec/changes/mergiraf-native-support/.openspec.yaml b/openspec/changes/mergiraf-native-support/.openspec.yaml new file mode 100644 index 0000000..db47328 --- /dev/null +++ b/openspec/changes/mergiraf-native-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-02 diff --git a/openspec/changes/mergiraf-native-support/design.md b/openspec/changes/mergiraf-native-support/design.md new file mode 100644 index 0000000..d72050e --- /dev/null +++ b/openspec/changes/mergiraf-native-support/design.md @@ -0,0 +1,63 @@ +# Design + +## Context + +Ra has an established pattern for native CLI wrapper tools: `git`, `gh`, `jq`, `mise`, `just`, and `wrkflw` all share the same shape — argv-safe process spawning, a bounded JSON result envelope, and structured missing-binary guidance. The `bash` tool handles arbitrary shell work but bypasses the catalog's structured schema and safety guarantees. + +Mergiraf is a syntax-aware git merge driver. Agents working on git-heavy tasks (merge, rebase, cherry-pick) need to invoke `mergiraf merge`, `mergiraf solve`, and `mergiraf languages`. Today that works only through `bash`, which provides no structure and gives no actionable message when `mergiraf` is absent from PATH. + +The `mergiraf` tool should follow exactly the same conventions as `jq` and `wrkflw`, staying narrow and making no attempt to replicate mergiraf's internal logic in Rust. + +## Goals / Non-Goals + +**Goals:** + +- Wrap the `mergiraf` binary with argv-safe process spawning and no shell interpolation. +- Expose `merge` (git merge-driver invocation), `solve` (resolve conflicts in a file), and `languages` (list supported extensions) as structured sub-actions. +- Return a consistent JSON envelope: `ok`, `tool`, `action`, `exit_code`, `stdout`, `stderr`, `truncated`, and optional `error`. +- Return `error.kind: "missing_mergiraf"` with install guidance when the binary is absent. +- Bound all process output via `max_output_bytes`. +- Register via `default_builtins` so the tool obeys `[tools] builtin` allow-list semantics. +- Document in `README.md` and `spec/tools.md`; add focused integration tests. + +**Non-Goals:** + +- Do not implement mergiraf's tree-sitter parsing or conflict resolution logic in Rust. +- Do not expose every mergiraf flag in v1 — keep the schema narrow (the key flags for the merge-driver and solve paths). +- Do not remove `bash` as the fallback for advanced or experimental mergiraf invocations. +- Do not configure `.gitconfig` or `.gitattributes` on the user's behalf. + +## Decisions + +### Use the system `mergiraf` binary + +Invoke `mergiraf` from PATH rather than vendoring or reimplementing it. This matches every other Ra native CLI wrapper and keeps the implementation small and correct. If the binary is missing, return `ok: false` with `error.kind: "missing_mergiraf"` and installation guidance (`cargo install mergiraf` or distro package). + +Alternative considered: a Rust crate embedding mergiraf logic. Ruled out — broadens scope enormously and risks drift from upstream behavior. + +### Three actions: `merge`, `solve`, `languages` + +- `merge`: maps to `mergiraf merge [flags]` — the git merge-driver path. Accepts the three required file paths plus optional `--language`, `--compact`, and `--allow-parse-errors`. +- `solve`: maps to `mergiraf solve ` — resolves conflict markers in a file that already has them. Simpler invocation, single path argument. +- `languages`: maps to `mergiraf languages --gitattributes` — read-only, no file args. + +Alternative considered: exposing arbitrary `args` to cover all mergiraf subcommands. Ruled out — reintroduces the shell-escape surface this tool is meant to avoid; advanced use stays in `bash`. + +### JSON envelope consistent with existing wrappers + +Fields: `ok: bool`, `tool: "mergiraf"`, `action: string`, `exit_code: int | null`, `stdout: string`, `stderr: string`, `truncated: bool`, `error?: {kind, message, install_hint}`. The `truncated` flag clips combined output before returning, following the `jq` and `webfetch` patterns. + +### ACP hosts: spawn locally like `mise`/`just`/`wrkflw` + +The tool spawns the local `mergiraf` binary directly even when an ACP host is attached, matching the pattern established by the other native CLIs. ACP terminal permission prompts do not wrap the spawn; `[tools].builtin` and PreToolUse/PostToolUse hooks remain the governance mechanism. + +## Risks / Trade-offs + +- Missing `mergiraf` on PATH → return structured install guidance; this is expected behavior for optional native tools. +- `merge` action writes to the `ours` file in-place (mergiraf's standard behavior) → callers must be aware the file is mutated; tests should use temp dirs. +- Narrow v1 flag surface may not cover `--compact` or `--allow-parse-errors` edge cases → these can be added in a follow-up change without breaking the envelope. +- mergiraf exits non-zero when conflicts remain unresolved → `ok: false` is correct here; callers should inspect `exit_code` to distinguish "conflicts remain" from "binary missing". + +## Open Questions + +None blocking implementation. The install guidance string (cargo vs. binary release) can be finalized during implementation by checking the upstream release method. diff --git a/openspec/changes/mergiraf-native-support/proposal.md b/openspec/changes/mergiraf-native-support/proposal.md new file mode 100644 index 0000000..c31ffb1 --- /dev/null +++ b/openspec/changes/mergiraf-native-support/proposal.md @@ -0,0 +1,30 @@ +# Add Native mergiraf Support + +## Why + +Ra agents frequently trigger git operations (merge, rebase, cherry-pick) that produce conflict markers in files. Today agents fall back to generic `bash` to invoke `mergiraf` — forfeiting structured error handling, missing-binary guidance, and argv safety. Adding a native `mergiraf` tool brings syntax-aware merge conflict resolution into Ra's built-in tool catalog with the same reliability guarantees as `jq`, `git`, and other native wrappers. + +## What Changes + +- Add a native built-in `mergiraf` tool that exposes the core mergiraf subcommands (`merge`, `solve`, `languages`) as structured operations. +- Execute mergiraf through argv-safe process spawning — no shell interpolation. +- Return a stable JSON envelope with exit status, stdout, stderr, truncation state, and structured missing-binary guidance. +- Bound all returned output through `max_output_bytes`. +- Register the tool in `default_builtins`, document it in `README.md` and `spec/tools.md`, add focused integration tests. + +## Capabilities + +### New Capabilities + +- `mergiraf-tool`: Native Ra built-in that wraps the `mergiraf` CLI for syntax-aware merge conflict resolution. Covers `merge` (git merge-driver invocation), `solve` (resolve conflicts in a file with existing markers), and `languages` (list supported extensions in gitattributes format). Returns a bounded JSON envelope consistent with existing native CLI wrapper conventions. + +### Modified Capabilities + +- `tools`: Add the `mergiraf` built-in tool entry to the existing tool catalog requirements. + +## Impact + +- Affected code: `src/tools/` (new `mergiraf.rs`), tool registration in `src/tools/mod.rs`, `src/config.rs` if tool metadata is config-driven. +- Affected docs: `README.md` built-in tools table, `spec/tools.md`. +- Runtime dependency: `mergiraf` binary must be on `PATH`; missing binary returns structured guidance, not an opaque spawn error. +- No breaking changes. `bash` remains available as the general fallback; existing allow-list semantics are unchanged. diff --git a/openspec/changes/mergiraf-native-support/specs/mergiraf-tool/spec.md b/openspec/changes/mergiraf-native-support/specs/mergiraf-tool/spec.md new file mode 100644 index 0000000..894f75a --- /dev/null +++ b/openspec/changes/mergiraf-native-support/specs/mergiraf-tool/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: mergiraf Tool Catalog Registration + +Ra SHALL include `mergiraf` in the default built-in catalog when `[tools].builtin` is empty. + +#### Scenario: Empty allow-list exposes mergiraf tool + +- **WHEN** Ra builds the default built-in tool catalog with an empty `[tools].builtin` allow-list +- **THEN** the catalog includes `mergiraf` + +#### Scenario: Non-empty allow-list remains exact + +- **WHEN** Ra builds the default built-in tool catalog with `[tools].builtin` containing only `mergiraf` +- **THEN** the catalog contains `mergiraf` and omits unspecified tools + +### Requirement: mergiraf merge Action + +Ra SHALL provide a `mergiraf` tool with a `merge` action that invokes `mergiraf merge ` with argv-safe process spawning and no shell interpolation. + +#### Scenario: merge preserves argv boundaries for required files + +- **WHEN** the agent calls `mergiraf` with `action: "merge"`, `base`, `ours`, and `theirs` paths +- **THEN** Ra invokes the `mergiraf` binary with `merge`, `base`, `ours`, and `theirs` as separate argv entries + +#### Scenario: merge accepts optional language override + +- **WHEN** the agent calls `mergiraf` with `action: "merge"` and `language: "java"` +- **THEN** Ra appends `--language` and `java` as separate argv entries + +#### Scenario: merge accepts compact flag + +- **WHEN** the agent calls `mergiraf` with `action: "merge"` and `compact: true` +- **THEN** Ra appends `--compact` to the argv + +#### Scenario: merge result envelope on clean merge + +- **WHEN** mergiraf exits with code 0 +- **THEN** Ra returns JSON with `ok: true`, `action: "merge"`, `exit_code: 0`, `stdout`, `stderr`, and `truncated` + +#### Scenario: merge result envelope on unresolved conflicts + +- **WHEN** mergiraf exits with a non-zero code indicating remaining conflicts +- **THEN** Ra returns JSON with `ok: false`, the non-zero `exit_code`, `stdout`, `stderr`, and `truncated` + +### Requirement: mergiraf solve Action + +Ra SHALL provide a `mergiraf` tool with a `solve` action that invokes `mergiraf solve ` with argv-safe process spawning. + +#### Scenario: solve passes file path as single argv entry + +- **WHEN** the agent calls `mergiraf` with `action: "solve"` and a `file` path +- **THEN** Ra invokes the `mergiraf` binary with `solve` and the file path as separate argv entries + +#### Scenario: solve result envelope on success + +- **WHEN** `mergiraf solve` exits with code 0 +- **THEN** Ra returns JSON with `ok: true`, `action: "solve"`, `exit_code: 0`, `stdout`, `stderr`, and `truncated` + +### Requirement: mergiraf languages Action + +Ra SHALL provide a `mergiraf` tool with a `languages` action that invokes `mergiraf languages --gitattributes` and returns the supported extension list. + +#### Scenario: languages returns gitattributes format + +- **WHEN** the agent calls `mergiraf` with `action: "languages"` +- **THEN** Ra invokes the `mergiraf` binary with `languages` and `--gitattributes` as separate argv entries +- **AND** Ra returns the output in the standard JSON envelope + +### Requirement: mergiraf Missing Binary Guidance + +Ra SHALL return a structured error when the `mergiraf` binary is not found on PATH, without breaking agent startup. + +#### Scenario: Missing binary returns structured guidance + +- **WHEN** the `mergiraf` binary is not found on PATH +- **THEN** Ra returns JSON with `ok: false`, no `exit_code`, and `error.kind: "missing_mergiraf"` with an `install_hint` + +#### Scenario: Missing binary does not block catalog startup + +- **WHEN** `mergiraf` is absent from PATH and Ra starts +- **THEN** Ra still registers the tool in the catalog and the startup succeeds + +### Requirement: mergiraf Output Bounding + +Ra SHALL bound all process output returned from `mergiraf` invocations via `max_output_bytes`. + +#### Scenario: Output within bounds is returned in full + +- **WHEN** combined stdout and stderr are within `max_output_bytes` +- **THEN** Ra returns the full output with `truncated: false` + +#### Scenario: Output exceeding bounds is truncated + +- **WHEN** combined stdout and stderr exceed `max_output_bytes` +- **THEN** Ra clips the output and returns `truncated: true` + +### Requirement: mergiraf ACP Host Isolation + +Ra SHALL spawn the `mergiraf` binary locally even when an ACP host is attached, matching the behavior of other native CLI wrappers. + +#### Scenario: ACP host does not wrap mergiraf spawns + +- **WHEN** an ACP host is attached +- **THEN** `mergiraf` still spawns the local binary directly +- **AND** ACP terminal permission prompts do not wrap those local spawns diff --git a/openspec/changes/mergiraf-native-support/specs/tools/spec.md b/openspec/changes/mergiraf-native-support/specs/tools/spec.md new file mode 100644 index 0000000..77d873f --- /dev/null +++ b/openspec/changes/mergiraf-native-support/specs/tools/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: Native Task Workflow Tool Catalog + +Ra SHALL include `mise`, `just`, `wrkflw`, and `mergiraf` in the default built-in catalog when `[tools].builtin` is empty. + +#### Scenario: Empty allow-list exposes task workflow tools + +- **WHEN** Ra builds the default built-in tool catalog with an empty `[tools].builtin` allow-list +- **THEN** the catalog includes `mise`, `just`, `wrkflw`, and `mergiraf` + +#### Scenario: Non-empty allow-list remains exact + +- **WHEN** Ra builds the default built-in tool catalog with `[tools].builtin` containing only `mise` +- **THEN** the catalog contains `mise` and omits unspecified tools diff --git a/openspec/changes/mergiraf-native-support/tasks.md b/openspec/changes/mergiraf-native-support/tasks.md new file mode 100644 index 0000000..19218c9 --- /dev/null +++ b/openspec/changes/mergiraf-native-support/tasks.md @@ -0,0 +1,31 @@ +# Tasks + +## 1. Tool Contract + +- [ ] 1.1 Define the `mergiraf` tool input schema with an `action` discriminant (`merge` | `solve` | `languages`), action-specific required fields (`base`/`ours`/`theirs` for merge, `file` for solve), optional flags (`language`, `compact`, `allow_parse_errors`), and `max_output_bytes`. +- [ ] 1.2 Define the stable result envelope: `ok`, `tool`, `action`, `exit_code`, `stdout`, `stderr`, `truncated`, and optional `error` (`kind`, `message`, `install_hint`). + +## 2. Implementation + +- [ ] 2.1 Add `MergirafTool` under `src/tools/` using argv-safe process spawning (no shell interpolation) and action-specific argument construction. +- [ ] 2.2 Implement the `merge` action: spawn `mergiraf merge ` with optional `--language`, `--compact`, and `--allow-parse-errors` argv entries. +- [ ] 2.3 Implement the `solve` action: spawn `mergiraf solve `. +- [ ] 2.4 Implement the `languages` action: spawn `mergiraf languages --gitattributes`. +- [ ] 2.5 Implement stdout/stderr budgeting using `max_output_bytes`, setting `truncated: true` when output is clipped. +- [ ] 2.6 Return `error.kind: "missing_mergiraf"` with an `install_hint` when the binary is absent; do not panic or break catalog startup. +- [ ] 2.7 Register `mergiraf` in `tools::default_builtins` and verify it obeys `[tools].builtin` allow-list semantics. + +## 3. Documentation + +- [ ] 3.1 Add `mergiraf` row to the built-in tools table in `README.md`. +- [ ] 3.2 Document the `mergiraf` schema, all three actions, result envelope, and error cases in `spec/tools.md`. +- [ ] 3.3 Update `spec/ra.toml.example` and any init templates that enumerate built-in tools. + +## 4. Tests + +- [ ] 4.1 Add catalog registration tests: default (empty allow-list includes `mergiraf`) and exact allow-list behavior. +- [ ] 4.2 Add `merge` action tests: required argv construction, `--language` and `--compact` flag mapping, and argv boundary preservation. +- [ ] 4.3 Add `solve` action tests: file path as single argv entry, success and non-zero exit envelopes. +- [ ] 4.4 Add `languages` action tests: `--gitattributes` flag appended, output in envelope. +- [ ] 4.5 Add tests for missing-binary guidance and output truncation. +- [ ] 4.6 Run focused tool tests and the full Rust test suite (`cargo test`). From e1edce6999f91ff28cdd31c261169de364b18352 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 09:00:43 +0800 Subject: [PATCH 2/3] feat: add native mergiraf tool Co-authored-by: multica-agent --- .../changes/mergiraf-native-support/tasks.md | 30 +- src/lib.rs | 7 +- src/tools/mergiraf.rs | 592 ++++++++++++++++++ src/tools/mod.rs | 9 +- tests/mergiraf_tool.rs | 272 ++++++++ tests/task_workflow_tools.rs | 4 +- 6 files changed, 893 insertions(+), 21 deletions(-) create mode 100644 src/tools/mergiraf.rs create mode 100644 tests/mergiraf_tool.rs diff --git a/openspec/changes/mergiraf-native-support/tasks.md b/openspec/changes/mergiraf-native-support/tasks.md index 19218c9..3385726 100644 --- a/openspec/changes/mergiraf-native-support/tasks.md +++ b/openspec/changes/mergiraf-native-support/tasks.md @@ -2,18 +2,18 @@ ## 1. Tool Contract -- [ ] 1.1 Define the `mergiraf` tool input schema with an `action` discriminant (`merge` | `solve` | `languages`), action-specific required fields (`base`/`ours`/`theirs` for merge, `file` for solve), optional flags (`language`, `compact`, `allow_parse_errors`), and `max_output_bytes`. -- [ ] 1.2 Define the stable result envelope: `ok`, `tool`, `action`, `exit_code`, `stdout`, `stderr`, `truncated`, and optional `error` (`kind`, `message`, `install_hint`). +- [x] 1.1 Define the `mergiraf` tool input schema with an `action` discriminant (`merge` | `solve` | `languages`), action-specific required fields (`base`/`ours`/`theirs` for merge, `file` for solve), optional flags (`language`, `compact`, `allow_parse_errors`), and `max_output_bytes`. +- [x] 1.2 Define the stable result envelope: `ok`, `tool`, `action`, `exit_code`, `stdout`, `stderr`, `truncated`, and optional `error` (`kind`, `message`, `install_hint`). ## 2. Implementation -- [ ] 2.1 Add `MergirafTool` under `src/tools/` using argv-safe process spawning (no shell interpolation) and action-specific argument construction. -- [ ] 2.2 Implement the `merge` action: spawn `mergiraf merge ` with optional `--language`, `--compact`, and `--allow-parse-errors` argv entries. -- [ ] 2.3 Implement the `solve` action: spawn `mergiraf solve `. -- [ ] 2.4 Implement the `languages` action: spawn `mergiraf languages --gitattributes`. -- [ ] 2.5 Implement stdout/stderr budgeting using `max_output_bytes`, setting `truncated: true` when output is clipped. -- [ ] 2.6 Return `error.kind: "missing_mergiraf"` with an `install_hint` when the binary is absent; do not panic or break catalog startup. -- [ ] 2.7 Register `mergiraf` in `tools::default_builtins` and verify it obeys `[tools].builtin` allow-list semantics. +- [x] 2.1 Add `MergirafTool` under `src/tools/` using argv-safe process spawning (no shell interpolation) and action-specific argument construction. +- [x] 2.2 Implement the `merge` action: spawn `mergiraf merge ` with optional `--language`, `--compact`, and `--allow-parse-errors` argv entries. +- [x] 2.3 Implement the `solve` action: spawn `mergiraf solve `. +- [x] 2.4 Implement the `languages` action: spawn `mergiraf languages --gitattributes`. +- [x] 2.5 Implement stdout/stderr budgeting using `max_output_bytes`, setting `truncated: true` when output is clipped. +- [x] 2.6 Return `error.kind: "missing_mergiraf"` with an `install_hint` when the binary is absent; do not panic or break catalog startup. +- [x] 2.7 Register `mergiraf` in `tools::default_builtins` and verify it obeys `[tools].builtin` allow-list semantics. ## 3. Documentation @@ -23,9 +23,9 @@ ## 4. Tests -- [ ] 4.1 Add catalog registration tests: default (empty allow-list includes `mergiraf`) and exact allow-list behavior. -- [ ] 4.2 Add `merge` action tests: required argv construction, `--language` and `--compact` flag mapping, and argv boundary preservation. -- [ ] 4.3 Add `solve` action tests: file path as single argv entry, success and non-zero exit envelopes. -- [ ] 4.4 Add `languages` action tests: `--gitattributes` flag appended, output in envelope. -- [ ] 4.5 Add tests for missing-binary guidance and output truncation. -- [ ] 4.6 Run focused tool tests and the full Rust test suite (`cargo test`). +- [x] 4.1 Add catalog registration tests: default (empty allow-list includes `mergiraf`) and exact allow-list behavior. +- [x] 4.2 Add `merge` action tests: required argv construction, `--language` and `--compact` flag mapping, and argv boundary preservation. +- [x] 4.3 Add `solve` action tests: file path as single argv entry, success and non-zero exit envelopes. +- [x] 4.4 Add `languages` action tests: `--gitattributes` flag appended, output in envelope. +- [x] 4.5 Add tests for missing-binary guidance and output truncation. +- [x] 4.6 Run focused tool tests and the full Rust test suite (`cargo test`). diff --git a/src/lib.rs b/src/lib.rs index 9288ff9..9471dd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,7 +34,8 @@ pub use tool_ctx::{ }; pub use tools::{ default_builtins, default_builtins_with_cfg, ApplyPatchTool, AstGrepTool, BashTool, EditTool, - FuzzyTool, GhTool, GitTool, GlobTool, GrepTool, JqTool, JustTool, LsTool, LspTool, MiseTool, - ReadTool, RtkRewriter, TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, - TmuxSendTool, TmuxWaitTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, WrkflwTool, + FuzzyTool, GhTool, GitTool, GlobTool, GrepTool, JqTool, JustTool, LsTool, LspTool, + MergirafTool, MiseTool, ReadTool, RtkRewriter, TmuxCaptureTool, TmuxKillTool, TmuxListenTool, + TmuxRunTool, TmuxSendTool, TmuxWaitTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, + WrkflwTool, }; diff --git a/src/tools/mergiraf.rs b/src/tools/mergiraf.rs new file mode 100644 index 0000000..47e8f05 --- /dev/null +++ b/src/tools/mergiraf.rs @@ -0,0 +1,592 @@ +//! Native mergiraf wrapper. +//! +//! This tool exposes the common syntax-aware merge operations without routing +//! path arguments through a shell. + +use crate::events::Event; +use crate::tool_ctx::ToolCtx; +use crate::tools::core::Tool; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::process::Command; + +const DEFAULT_MAX_OUTPUT_BYTES: usize = 100_000; +const DEFAULT_STDERR_BYTES: usize = 32_000; + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MergirafAction { + Merge, + Solve, + Languages, +} + +impl MergirafAction { + fn as_str(&self) -> &'static str { + match self { + Self::Merge => "merge", + Self::Solve => "solve", + Self::Languages => "languages", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MergirafParams { + /// Action to run: `merge`, `solve`, or `languages`. + pub action: MergirafAction, + /// Base file for `merge`. + #[serde(default)] + pub base: Option, + /// Ours/current file for `merge`; mergiraf may update this file in place. + #[serde(default)] + pub ours: Option, + /// Theirs/other file for `merge`. + #[serde(default)] + pub theirs: Option, + /// File with existing conflict markers for `solve`. + #[serde(default)] + pub file: Option, + /// Optional language override passed as `--language ` for `merge`. + #[serde(default)] + pub language: Option, + /// Map to `--compact` for `merge`. + #[serde(default)] + pub compact: bool, + /// Map to `--allow-parse-errors` for `merge`. + #[serde(default)] + pub allow_parse_errors: bool, + /// Working directory for the command. Relative paths resolve against the + /// session cwd. + #[serde(default)] + pub cwd: Option, + /// Maximum bytes in Ra's returned JSON envelope. + #[serde(default = "default_max_output_bytes")] + pub max_output_bytes: usize, +} + +pub struct MergirafTool; + +#[async_trait] +impl Tool for MergirafTool { + fn name(&self) -> &str { + "mergiraf" + } + + fn description(&self) -> &str { + "Run native mergiraf actions with argv-safe arguments: merge three files, \ + solve conflict markers in one file, or list supported languages. Returns \ + a bounded JSON envelope and structured missing-mergiraf guidance." + } + + fn schema(&self) -> serde_json::Value { + mergiraf_schema() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("mergiraf"); + let params: MergirafParams = + serde_json::from_value(input).context("invalid params for mergiraf")?; + let submitted_action = params.action.as_str(); + let invocation = match MergirafInvocation::from_params(params, &ctx.cwd).await { + Ok(invocation) => invocation, + Err(error) => return Ok(invalid_request_json(submitted_action, error)), + }; + + execute_mergiraf(call_id, invocation, ctx).await + } +} + +fn mergiraf_schema() -> serde_json::Value { + let mut schema = serde_json::to_value(schema_for!(MergirafParams)).unwrap(); + if let Some(object) = schema.as_object_mut() { + object.insert( + "oneOf".to_string(), + json!([ + { + "properties": { "action": { "const": "merge" } }, + "required": ["action", "base", "ours", "theirs"] + }, + { + "properties": { "action": { "const": "solve" } }, + "required": ["action", "file"] + }, + { + "properties": { "action": { "const": "languages" } }, + "required": ["action"] + } + ]), + ); + } + schema +} + +#[derive(Debug, Clone)] +struct MergirafInvocation { + action: &'static str, + args: Vec, + cwd: PathBuf, + max_output_bytes: usize, +} + +impl MergirafInvocation { + async fn from_params(params: MergirafParams, session_cwd: &Path) -> Result { + let action = params.action.as_str(); + let max_output_bytes = params.max_output_bytes; + let cwd = resolve_cwd(params.cwd.clone(), session_cwd); + validate_cwd(&cwd).await?; + + let args = match params.action { + MergirafAction::Merge => merge_args(¶ms)?, + MergirafAction::Solve => solve_args(¶ms)?, + MergirafAction::Languages => languages_args(), + }; + + Ok(Self { + action, + args, + cwd, + max_output_bytes, + }) + } + + fn command_json(&self) -> serde_json::Value { + json!({ + "program": "mergiraf", + "args": self.args, + "cwd": self.cwd, + }) + } + + fn summary(&self) -> String { + shell_words("mergiraf", &self.args) + } +} + +fn merge_args(params: &MergirafParams) -> Result> { + let base = required_path(¶ms.base, "base", "merge")?; + let ours = required_path(¶ms.ours, "ours", "merge")?; + let theirs = required_path(¶ms.theirs, "theirs", "merge")?; + + let mut args = vec!["merge".to_string(), base, ours, theirs]; + if let Some(language) = non_empty(params.language.clone()) { + args.push("--language".to_string()); + args.push(language); + } + if params.compact { + args.push("--compact".to_string()); + } + if params.allow_parse_errors { + args.push("--allow-parse-errors".to_string()); + } + Ok(args) +} + +fn solve_args(params: &MergirafParams) -> Result> { + Ok(vec![ + "solve".to_string(), + required_path(¶ms.file, "file", "solve")?, + ]) +} + +fn languages_args() -> Vec { + vec!["languages".to_string(), "--gitattributes".to_string()] +} + +fn required_path(value: &Option, field: &str, action: &str) -> Result { + non_empty(value.clone()).ok_or_else(|| anyhow!("mergiraf {action} requires `{field}`")) +} + +async fn execute_mergiraf( + call_id: &str, + invocation: MergirafInvocation, + ctx: &ToolCtx, +) -> Result { + let binary = match which::which("mergiraf") { + Ok(path) => path, + Err(_) => return Ok(missing_mergiraf_json(&invocation)), + }; + + let _ = ctx.events.send(Event::ToolCallUpdate { + id: call_id.to_string(), + chunk: format!("[mergiraf] {}", invocation.summary()), + }); + + let output = run_mergiraf(&binary, &invocation).await?; + let exit_code = output.exit_code; + let _ = ctx.events.send(Event::ToolCallUpdate { + id: call_id.to_string(), + chunk: format!("[exit={exit_code}]"), + }); + + format_output(&invocation, output) +} + +#[derive(Debug, Clone)] +struct MergirafOutput { + exit_code: i32, + stdout: String, + stderr: String, +} + +async fn run_mergiraf(binary: &Path, invocation: &MergirafInvocation) -> Result { + let output = Command::new(binary) + .args(&invocation.args) + .current_dir(&invocation.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .output() + .await + .with_context(|| { + format!( + "spawn {} in {}", + invocation.summary(), + invocation.cwd.display() + ) + })?; + + Ok(MergirafOutput { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn format_output(invocation: &MergirafInvocation, output: MergirafOutput) -> Result { + let stdout = output.stdout; + let mut stderr = output.stderr; + let ok = output.exit_code == 0; + let stderr_truncated = trim_to_char_budget(&mut stderr, DEFAULT_STDERR_BYTES); + + let mut base = json!({ + "ok": ok, + "tool": "mergiraf", + "action": invocation.action, + "command": invocation.command_json(), + "exit_code": output.exit_code, + "stdout": stdout.clone(), + "stderr": nullable_string(&stderr), + "truncated": stderr_truncated, + }); + + if !ok { + base["error"] = json!({ + "kind": "mergiraf_error", + "message": "mergiraf exited with a non-zero status" + }); + } + + bounded_output_json(base, &stdout, stderr, invocation.max_output_bytes) +} + +fn invalid_request_json(action: &str, error: anyhow::Error) -> String { + serde_json::to_string_pretty(&json!({ + "ok": false, + "tool": "mergiraf", + "action": action, + "command": { + "program": "mergiraf", + "args": [], + "cwd": null + }, + "exit_code": null, + "stdout": "", + "stderr": null, + "truncated": false, + "error": { + "kind": "invalid_request", + "message": error.to_string() + } + })) + .expect("invalid request JSON is serializable") +} + +fn missing_mergiraf_json(invocation: &MergirafInvocation) -> String { + serde_json::to_string_pretty(&json!({ + "ok": false, + "tool": "mergiraf", + "action": invocation.action, + "command": invocation.command_json(), + "exit_code": null, + "stdout": "", + "stderr": null, + "truncated": false, + "error": { + "kind": "missing_mergiraf", + "message": "mergiraf was not found on PATH, so the mergiraf tool could not be run.", + "install_hint": "Install mergiraf, for example with `cargo install mergiraf`, and ensure it is available on PATH." + } + })) + .expect("missing mergiraf JSON is serializable") +} + +fn bounded_output_json( + mut value: serde_json::Value, + stdout: &str, + stderr: String, + max_output_bytes: usize, +) -> Result { + let mut rendered = serde_json::to_string_pretty(&value)?; + if max_output_bytes == 0 || rendered.len() <= max_output_bytes { + return Ok(rendered); + } + + let mut low = 0; + let mut high = stdout.chars().count(); + let mut best = 0; + + while low <= high { + let mid = low + (high - low) / 2; + value["stdout"] = json!(with_truncation_marker(&take_chars(stdout, mid))); + value["stderr"] = nullable_string(&stderr); + value["truncated"] = json!(true); + let probe = serde_json::to_string_pretty(&value)?; + if probe.len() <= max_output_bytes { + best = mid; + low = mid + 1; + } else if mid == 0 { + break; + } else { + high = mid - 1; + } + } + + value["stdout"] = json!(with_truncation_marker(&take_chars(stdout, best))); + rendered = serde_json::to_string_pretty(&value)?; + if rendered.len() <= max_output_bytes || stderr.is_empty() { + return Ok(rendered); + } + + let mut stderr_trimmed = stderr; + trim_to_char_budget(&mut stderr_trimmed, DEFAULT_STDERR_BYTES.min(1024)); + value["stderr"] = json!(stderr_trimmed); + rendered = serde_json::to_string_pretty(&value)?; + if rendered.len() <= max_output_bytes || best == 0 { + return Ok(rendered); + } + + value["stdout"] = json!(with_truncation_marker("")); + value["truncated"] = json!(true); + serde_json::to_string_pretty(&value).map_err(Into::into) +} + +fn nullable_string(text: &str) -> serde_json::Value { + if text.is_empty() { + serde_json::Value::Null + } else { + json!(text) + } +} + +fn resolve_cwd(cwd: Option, session_cwd: &Path) -> PathBuf { + match non_empty(cwd) { + Some(cwd) => { + let path = PathBuf::from(cwd); + if path.is_absolute() { + path + } else { + session_cwd.join(path) + } + } + None => session_cwd.to_path_buf(), + } +} + +async fn validate_cwd(cwd: &Path) -> Result<()> { + let metadata = tokio::fs::metadata(cwd) + .await + .with_context(|| format!("read cwd {}", cwd.display()))?; + if !metadata.is_dir() { + return Err(anyhow!("cwd is not a directory: {}", cwd.display())); + } + Ok(()) +} + +fn non_empty(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn trim_to_char_budget(text: &mut String, max_bytes: usize) -> bool { + if text.len() <= max_bytes { + return false; + } + + let mut end = 0; + for (idx, ch) in text.char_indices() { + let next = idx + ch.len_utf8(); + if next > max_bytes { + break; + } + end = next; + } + + text.truncate(end); + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str("[truncated]\n"); + true +} + +fn take_chars(text: &str, count: usize) -> String { + text.chars().take(count).collect() +} + +fn with_truncation_marker(text: &str) -> String { + if text.is_empty() { + "[truncated]\n".to_string() + } else if text.ends_with('\n') { + format!("{text}[truncated]\n") + } else { + format!("{text}\n[truncated]\n") + } +} + +fn shell_words(binary: &str, args: &[String]) -> String { + std::iter::once(shell_word(binary)) + .chain(args.iter().map(|arg| shell_word(arg))) + .collect::>() + .join(" ") +} + +fn shell_word(s: &str) -> String { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_' | '-' | '=' | ':')) + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +fn default_max_output_bytes() -> usize { + DEFAULT_MAX_OUTPUT_BYTES +} + +#[cfg(test)] +mod tests { + use super::*; + + fn merge_params() -> MergirafParams { + MergirafParams { + action: MergirafAction::Merge, + base: Some("base.rs".into()), + ours: Some("ours.rs".into()), + theirs: Some("theirs.rs".into()), + file: None, + language: None, + compact: false, + allow_parse_errors: false, + cwd: None, + max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, + } + } + + #[tokio::test] + async fn merge_invocation_maps_optional_flags_to_argv() { + let dir = tempfile::tempdir().unwrap(); + let mut params = merge_params(); + params.language = Some("rust".into()); + params.compact = true; + params.allow_parse_errors = true; + + let invocation = MergirafInvocation::from_params(params, dir.path()) + .await + .unwrap(); + + assert_eq!( + invocation.args, + vec![ + "merge", + "base.rs", + "ours.rs", + "theirs.rs", + "--language", + "rust", + "--compact", + "--allow-parse-errors" + ] + ); + } + + #[tokio::test] + async fn merge_invocation_requires_three_paths() { + let dir = tempfile::tempdir().unwrap(); + let mut params = merge_params(); + params.theirs = None; + + assert!(MergirafInvocation::from_params(params, dir.path()) + .await + .unwrap_err() + .to_string() + .contains("requires `theirs`")); + } + + #[test] + fn output_budget_preserves_valid_json_and_marks_truncated() { + let invocation = MergirafInvocation { + action: "languages", + args: vec!["languages".into(), "--gitattributes".into()], + cwd: PathBuf::from("."), + max_output_bytes: 450, + }; + let rendered = format_output( + &invocation, + MergirafOutput { + exit_code: 0, + stdout: "x".repeat(2_000), + stderr: String::new(), + }, + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["truncated"], true); + assert!(value["stdout"].as_str().unwrap().contains("[truncated]")); + } + + #[test] + fn schema_declares_action_specific_required_fields() { + let schema = mergiraf_schema(); + let one_of = schema["oneOf"].as_array().unwrap(); + + assert!(one_of.iter().any(|entry| { + entry["properties"]["action"]["const"] == "merge" + && entry["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "theirs") + })); + assert!(one_of.iter().any(|entry| { + entry["properties"]["action"]["const"] == "solve" + && entry["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "file") + })); + assert!(one_of + .iter() + .any(|entry| entry["properties"]["action"]["const"] == "languages")); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index cc8bd3f..3d4f633 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -15,6 +15,7 @@ //! - `mise` — run mise tasks and tests with argv-safe arguments //! - `just` — run just recipes with argv-safe arguments //! - `wrkflw` — validate/run GitHub Actions workflows locally +//! - `mergiraf` — syntax-aware merge conflict resolution //! - `grep` — structured text search //! - `glob` — structured file discovery //! - `ls` — structured directory listing @@ -37,6 +38,7 @@ mod extended; mod fs; mod jq; mod lsp; +mod mergiraf; mod openspec; mod rtk; mod search; @@ -50,6 +52,7 @@ pub use extended::{ApplyPatchTool, FuzzyTool, GlobTool, GrepTool, LsTool}; pub use fs::{EditTool, WriteTool}; pub use jq::JqTool; pub use lsp::{resolve_openlsp_binary, LspTool}; +pub use mergiraf::MergirafTool; pub use openspec::OpenSpecTool; pub use rtk::RtkRewriter; pub use search::AstGrepTool; @@ -115,6 +118,9 @@ pub fn default_builtins_with_cfg( if want("wrkflw") { out.push(Arc::new(WrkflwTool)); } + if want("mergiraf") { + out.push(Arc::new(MergirafTool)); + } if want("grep") { out.push(Arc::new(GrepTool)); } @@ -219,7 +225,7 @@ mod tests { #[test] fn default_catalog_includes_task_workflow_tools() { let names = builtin_names(&[]); - for name in ["mise", "just", "wrkflw"] { + for name in ["mise", "just", "wrkflw", "mergiraf"] { assert!( names.contains(&name.to_string()), "missing {name}: {names:?}" @@ -232,6 +238,7 @@ mod tests { assert_eq!(builtin_names(&["mise"]), vec!["mise"]); assert_eq!(builtin_names(&["just"]), vec!["just"]); assert_eq!(builtin_names(&["wrkflw"]), vec!["wrkflw"]); + assert_eq!(builtin_names(&["mergiraf"]), vec!["mergiraf"]); } #[test] diff --git a/tests/mergiraf_tool.rs b/tests/mergiraf_tool.rs new file mode 100644 index 0000000..3478afa --- /dev/null +++ b/tests/mergiraf_tool.rs @@ -0,0 +1,272 @@ +//! Integration tests for the native mergiraf tool. These use a fake +//! `mergiraf` binary so the tests verify Ra's argv/envelope behavior without +//! depending on the host mergiraf installation. + +use ra::{ + tools::{MergirafTool, Tool}, + ToolCtx, +}; +use serde_json::Value; +use std::{ffi::OsString, fs, os::unix::fs::PermissionsExt, path::Path, sync::OnceLock}; +use tokio::sync::Mutex; + +struct EnvRestore { + key: &'static str, + old_value: Option, +} + +impl EnvRestore { + fn set>(key: &'static str, value: K) -> Self { + let old_value = std::env::var_os(key); + std::env::set_var(key, value.into()); + Self { key, old_value } + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } +} + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn make_ctx() -> ToolCtx { + let (events, _) = tokio::sync::broadcast::channel(16); + ToolCtx::local(events) +} + +fn json_output(output: &str) -> Value { + serde_json::from_str(output).unwrap_or_else(|err| panic!("invalid json: {err}: {output}")) +} + +fn write_fake_mergiraf(dir: &Path, body: &str) { + let path = dir.join("mergiraf"); + fs::write( + &path, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$MERGIRAF_ARGS_FILE\"\n{body}\n"), + ) + .unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); +} + +#[test] +fn default_catalog_contains_mergiraf_and_allowlist_is_exact() { + let names = ra::default_builtins(&[]) + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + assert!( + names.contains(&"mergiraf".to_string()), + "missing mergiraf: {names:?}" + ); + + let filtered = ra::default_builtins(&["mergiraf".to_string()]) + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + assert_eq!(filtered, vec!["mergiraf"]); +} + +#[tokio::test] +async fn merge_executes_fake_mergiraf_with_expected_argv_and_envelope() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + write_fake_mergiraf(dir.path(), "printf 'merged\\n'\n"); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("MERGIRAF_ARGS_FILE", args_file.as_os_str()); + + let output = MergirafTool + .execute( + "mergiraf", + serde_json::json!({ + "action": "merge", + "base": "base file.rs", + "ours": "ours;still-one-arg.rs", + "theirs": "theirs.rs", + "language": "rust", + "compact": true, + "allow_parse_errors": true + }), + &make_ctx(), + ) + .await + .unwrap(); + + assert_eq!( + fs::read_to_string(args_file) + .unwrap() + .lines() + .collect::>(), + vec![ + "merge", + "base file.rs", + "ours;still-one-arg.rs", + "theirs.rs", + "--language", + "rust", + "--compact", + "--allow-parse-errors" + ] + ); + + let output = json_output(&output); + assert_eq!(output["ok"], true); + assert_eq!(output["tool"], "mergiraf"); + assert_eq!(output["action"], "merge"); + assert_eq!(output["exit_code"], 0); + assert_eq!(output["stdout"], "merged\n"); + assert_eq!(output["stderr"], Value::Null); + assert_eq!(output["truncated"], false); +} + +#[tokio::test] +async fn solve_passes_file_path_as_single_argv_and_reports_non_zero_exit() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + write_fake_mergiraf( + dir.path(), + "printf 'conflicts remain\\n' >&2\nprintf 'partial\\n'\nexit 2\n", + ); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("MERGIRAF_ARGS_FILE", args_file.as_os_str()); + + let output = MergirafTool + .execute( + "mergiraf", + serde_json::json!({ + "action": "solve", + "file": "path with spaces/conflicted.rs" + }), + &make_ctx(), + ) + .await + .unwrap(); + + assert_eq!( + fs::read_to_string(args_file) + .unwrap() + .lines() + .collect::>(), + vec!["solve", "path with spaces/conflicted.rs"] + ); + + let output = json_output(&output); + assert_eq!(output["ok"], false); + assert_eq!(output["action"], "solve"); + assert_eq!(output["exit_code"], 2); + assert_eq!(output["stdout"], "partial\n"); + assert_eq!(output["stderr"], "conflicts remain\n"); + assert_eq!(output["error"]["kind"], "mergiraf_error"); +} + +#[tokio::test] +async fn languages_adds_gitattributes_flag_and_returns_output() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + write_fake_mergiraf(dir.path(), "printf '*.rs merge=mergiraf\\n'\n"); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("MERGIRAF_ARGS_FILE", args_file.as_os_str()); + + let output = MergirafTool + .execute( + "mergiraf", + serde_json::json!({ "action": "languages" }), + &make_ctx(), + ) + .await + .unwrap(); + + assert_eq!( + fs::read_to_string(args_file) + .unwrap() + .lines() + .collect::>(), + vec!["languages", "--gitattributes"] + ); + + let output = json_output(&output); + assert_eq!(output["ok"], true); + assert_eq!(output["action"], "languages"); + assert_eq!(output["stdout"], "*.rs merge=mergiraf\n"); +} + +#[tokio::test] +async fn missing_binary_returns_structured_guidance() { + let _guard = env_lock().lock().await; + let empty_path = tempfile::tempdir().unwrap(); + let _path = EnvRestore::set("PATH", empty_path.path().as_os_str()); + + let output = MergirafTool + .execute( + "mergiraf", + serde_json::json!({ + "action": "merge", + "base": "base.rs", + "ours": "ours.rs", + "theirs": "theirs.rs" + }), + &make_ctx(), + ) + .await + .unwrap(); + let output = json_output(&output); + + assert_eq!(output["ok"], false); + assert_eq!(output["tool"], "mergiraf"); + assert_eq!(output["action"], "merge"); + assert_eq!(output["exit_code"], Value::Null); + assert_eq!(output["error"]["kind"], "missing_mergiraf"); + assert!(output["error"]["install_hint"] + .as_str() + .unwrap() + .contains("cargo install mergiraf")); +} + +#[tokio::test] +async fn output_truncation_preserves_valid_json() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + write_fake_mergiraf( + dir.path(), + "i=0\nwhile [ \"$i\" -lt 2000 ]; do printf x; i=$((i + 1)); done\nprintf '\\n'\n", + ); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("MERGIRAF_ARGS_FILE", args_file.as_os_str()); + + let rendered = MergirafTool + .execute( + "mergiraf", + serde_json::json!({ + "action": "languages", + "max_output_bytes": 700 + }), + &make_ctx(), + ) + .await + .unwrap(); + let output = json_output(&rendered); + + assert_eq!(output["ok"], true); + assert_eq!(output["truncated"], true); + assert!(output["stdout"].as_str().unwrap().contains("[truncated]")); + assert!( + rendered.len() <= 700, + "len={} output={rendered}", + rendered.len() + ); +} diff --git a/tests/task_workflow_tools.rs b/tests/task_workflow_tools.rs index 9264d63..c4f92f9 100644 --- a/tests/task_workflow_tools.rs +++ b/tests/task_workflow_tools.rs @@ -68,14 +68,14 @@ fn default_catalog_contains_task_workflow_tools_and_allowlist_is_exact() { .map(|tool| tool.name().to_string()) .collect::>(); - for name in ["mise", "just", "wrkflw"] { + for name in ["mise", "just", "wrkflw", "mergiraf"] { assert!( names.contains(&name.to_string()), "missing {name}: {names:?}" ); } - for name in ["mise", "just", "wrkflw"] { + for name in ["mise", "just", "wrkflw", "mergiraf"] { let filtered = ra::default_builtins(&[name.to_string()]) .into_iter() .map(|tool| tool.name().to_string()) From a9ecc3f56970f1fe4d7c6d95463a8266ba113310 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 16:26:28 +0800 Subject: [PATCH 3/3] Document native mergiraf tool --- README.md | 19 ++-- .../changes/mergiraf-native-support/tasks.md | 6 +- spec/ra.toml.example | 3 +- spec/tools.md | 89 +++++++++++++++++-- src/init.rs | 4 +- 5 files changed, 100 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 15f6dae..7c6f681 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ Resolution order: `--config ` → `$RA_CONFIG` → `./ra.toml` → | `git` | Runs native `git` with argv-safe arguments; ACP hosts use the terminal reverse-call with shell quoting. This path does not use RTK. | | `gh` | Runs native GitHub CLI (`gh`) with argv-safe arguments; ACP hosts use the terminal reverse-call with shell quoting. This path does not use RTK. | | `jq` | Runs jq filters against inline JSON or a JSON file with argv-safe stdin and a bounded JSON envelope. | +| `mergiraf` | Runs mergiraf merge, solve, and languages actions with argv-safe arguments and bounded JSON output. | | `mise` | Runs mise tasks/tests with argv-safe arguments and bounded JSON output. | | `just` | Runs just recipes with argv-safe arguments and bounded JSON output. | | `wrkflw` | Runs wrkflw local GitHub Actions validation/execution with argv-safe arguments and bounded JSON output. | @@ -230,18 +231,20 @@ native because it is structured code search rather than a plain shell command. `bash` remains the fallback for project scripts, tests, and one-off command pipelines. -Native `git` / `gh` / `jq` prioritize argv safety over RTK rewriting. If a +Native `git` / `gh` / `jq` / `mergiraf` prioritize argv safety over RTK rewriting. If a high-volume native CLI command needs RTK output compression, run it through `bash` instead so the existing RTK rewrite path can apply. `jq` requires the system `jq` binary on `PATH`; missing jq returns structured install guidance instead of an opaque spawn error. -Native `mise` / `just` / `wrkflw` are intended for test-first task and -workflow loops such as `mise run test`, `just test`, and local GitHub -Actions validation. They run local binaries with argv-safe arguments, -optional `cwd`/timeout controls, bounded JSON output, and structured -missing-binary guidance. These tools spawn locally even when an ACP host is -attached, so ACP terminal permission prompts do not wrap them; use -`[tools].builtin` and PreToolUse/PostToolUse hooks to govern availability. +`mergiraf` requires the system `mergiraf` binary on `PATH`; missing +mergiraf returns structured install guidance. Native `mise` / `just` / +`wrkflw` are intended for test-first task and workflow loops such as +`mise run test`, `just test`, and local GitHub Actions validation. They +run local binaries with argv-safe arguments, optional `cwd`/timeout +controls, bounded JSON output, and structured missing-binary guidance. +These tools spawn locally even when an ACP host is attached, so ACP +terminal permission prompts do not wrap them; use `[tools].builtin` and +PreToolUse/PostToolUse hooks to govern availability. `webfetch_fetch` and `webfetch_crawl` run `npm exec --yes --package=github:trotsky1997/webfetch-cli -- webfetch-cli` under the hood; if `npm` is missing, the tools return structured install diff --git a/openspec/changes/mergiraf-native-support/tasks.md b/openspec/changes/mergiraf-native-support/tasks.md index 3385726..3d3a62d 100644 --- a/openspec/changes/mergiraf-native-support/tasks.md +++ b/openspec/changes/mergiraf-native-support/tasks.md @@ -17,9 +17,9 @@ ## 3. Documentation -- [ ] 3.1 Add `mergiraf` row to the built-in tools table in `README.md`. -- [ ] 3.2 Document the `mergiraf` schema, all three actions, result envelope, and error cases in `spec/tools.md`. -- [ ] 3.3 Update `spec/ra.toml.example` and any init templates that enumerate built-in tools. +- [x] 3.1 Add `mergiraf` row to the built-in tools table in `README.md`. +- [x] 3.2 Document the `mergiraf` schema, all three actions, result envelope, and error cases in `spec/tools.md`. +- [x] 3.3 Update `spec/ra.toml.example` and any init templates that enumerate built-in tools. ## 4. Tests diff --git a/spec/ra.toml.example b/spec/ra.toml.example index e81dd60..f7d2797 100644 --- a/spec/ra.toml.example +++ b/spec/ra.toml.example @@ -40,6 +40,7 @@ api_key_env = "ANTHROPIC_API_KEY" # git — run native git with argv-safe arguments # gh — run native GitHub CLI with argv-safe arguments # jq — run jq filters against inline JSON or a JSON file +# mergiraf — syntax-aware merge conflict resolution # grep — structured text search # glob — structured file discovery # ls — structured directory listing @@ -56,7 +57,7 @@ api_key_env = "ANTHROPIC_API_KEY" # tmux_wait — block until a tmux event, hook expression, program result, or sleep timeout resolves # An empty list (or omitted section) ships every built-in tool. [tools] -# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen", "tmux_wait"] +# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "mergiraf", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen", "tmux_wait"] builtin = [] # ─── Skills (Claude Code / agentskills.io) ────────────────────────── diff --git a/spec/tools.md b/spec/tools.md index c407455..d602bf4 100644 --- a/spec/tools.md +++ b/spec/tools.md @@ -131,10 +131,11 @@ compose a shell string. In local CLI / A2A / TUI mode Ra spawns the binary directly with `Command::args`, preserving argv boundaries. With an ACP host attached, `git` and `gh` reuse the same permission-gated `terminal/*` reverse-call path as `bash`, rendering the argv array as a shell-quoted command -line for the host terminal. `jq`, `mise`, `just`, and `wrkflw` spawn local -binaries directly so they can preserve stdin/output-envelope behavior; ACP -terminal permission prompts do not wrap those local spawns. The central -`[tools].builtin` allow-list and PreToolUse/PostToolUse hooks still apply. +line for the host terminal. `jq`, `mergiraf`, `mise`, `just`, and `wrkflw` +spawn local binaries directly so they can preserve stdin/output-envelope +behavior; ACP terminal permission prompts do not wrap those local spawns. The +central `[tools].builtin` allow-list and PreToolUse/PostToolUse hooks still +apply. These native wrappers do not route through RTK. That tradeoff preserves argv semantics in the local process path instead of converting the call @@ -142,9 +143,9 @@ back into a shell command for compression. Use the `bash` tool for verbose commands when RTK compression is more important than argv-safe process execution. -`git` and `gh` return combined stdout+stderr. `jq`, `mise`, `just`, and -`wrkflw` return bounded JSON envelopes. Tool calls emit progress and -`[exit=N]` event chunks on the broadcast bus. +`git` and `gh` return combined stdout+stderr. `jq`, `mergiraf`, `mise`, +`just`, and `wrkflw` return bounded JSON envelopes. Tool calls emit progress +and `[exit=N]` event chunks on the broadcast bus. ### `git` @@ -230,6 +231,80 @@ and happen before jq is spawned. Non-zero jq exits use `error.kind:"missing_jq"` with installation guidance. Output exceeding `max_output_bytes` is clipped with `truncated:true`. +### `mergiraf` + +Run native [`mergiraf`](https://mergiraf.org/) for syntax-aware merge +workflows. The `action` field selects one of three command shapes: + +| Action | CLI mapping | Mutation behavior | +|--------|-------------|-------------------| +| `merge` | `mergiraf merge [--language language] [--compact] [--allow-parse-errors]` | Prints the merge result to stdout unless mergiraf itself decides otherwise; this wrapper does not pass `--git` or `--output`. | +| `solve` | `mergiraf solve ` | Lets mergiraf update the conflicted file according to its normal `solve` behavior. | +| `languages` | `mergiraf languages --gitattributes` | Read-only supported-language listing. | + +```json +{ + "action": "merge", + "base": "base.rs", + "ours": "ours.rs", + "theirs": "theirs.rs", + "language": "rust", + "compact": true +} +``` + +```json +{ + "action": "solve", + "file": "src/conflicted.rs", + "cwd": "." +} +``` + +```json +{ "action": "languages" } +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| action | string | yes | | `merge`, `solve`, or `languages` | +| base | string | for `merge` | | Base file path passed as one argv entry | +| ours | string | for `merge` | | Ours/current file path passed as one argv entry | +| theirs | string | for `merge` | | Theirs/other file path passed as one argv entry | +| file | string | for `solve` | | Conflict-marker file passed as one argv entry | +| language | string | no | | Maps to `--language ` for `merge` | +| compact | boolean | no | `false` | Maps to `--compact` for `merge` | +| allow_parse_errors | boolean | no | `false` | Maps to `--allow-parse-errors` for `merge` | +| cwd | string | no | session cwd | Working directory; relative paths resolve there | +| max_output_bytes | number | no | `100000` | Bounds Ra's returned JSON envelope; `0` means unbounded | + +Returned envelope: + +```json +{ + "ok": true, + "tool": "mergiraf", + "action": "merge", + "command": { + "program": "mergiraf", + "args": ["merge", "base.rs", "ours.rs", "theirs.rs"], + "cwd": "/repo" + }, + "exit_code": 0, + "stdout": "merged contents\n", + "stderr": null, + "truncated": false +} +``` + +Error cases are returned as valid JSON with `ok:false`. Missing required +action fields or an invalid `cwd` use `error.kind:"invalid_request"` before +spawning mergiraf. Non-zero exits use `error.kind:"mergiraf_error"` with +stdout, stderr, and exit code. Missing mergiraf uses +`error.kind:"missing_mergiraf"` with installation guidance including +`cargo install mergiraf`. Output exceeding `max_output_bytes` is clipped with +`truncated:true`. + ### `mise` Run native `mise` for project task/test loops. Pass only arguments after the diff --git a/src/init.rs b/src/init.rs index 0a1f9ed..2021d37 100644 --- a/src/init.rs +++ b/src/init.rs @@ -35,12 +35,12 @@ banner = true # Empty means: enable every built-in tool. # Basic tools: read, write, edit, bash. # Structured search tools: ast_grep. -# Native CLI tools: git, gh, jq. +# Native CLI tools: git, gh, jq, mergiraf. # Extended tools: grep, glob, ls, fuzzy, apply_patch. # Web docs tools: webfetch_fetch, webfetch_crawl. # OpenSpec tool: openspec. # Tmux tools: tmux_run, tmux_send, tmux_capture, tmux_kill, tmux_listen, tmux_wait. -# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen", "tmux_wait"] +# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "mergiraf", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen", "tmux_wait"] builtin = [] [skills]