Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
pull_request:
push:
branches:
- main

jobs:
lint-test:
name: Lint and test (Linux)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Check formatting
run: cargo fmt --check

- name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets -- -D warnings

- name: Run tests
run: cargo test --workspace

# The release workflow already builds Windows binaries, so keep Windows
# compiling on every PR. Tests are only run on Linux for now.
build-windows:
name: Build (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable

- name: Build workspace (including tests)
run: cargo build --workspace --all-targets
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ On first run, JuCode creates its configuration under the user profile directory.
- default API base URL: `https://api.jucode.cn/v1`
- default API key environment variable: `OPENAI_API_KEY`

Sign in with `/login` to use the JuCode gateway, or set an API key and point the config at any OpenAI-compatible endpoint (`openai` and `deepseek` are built in; Anthropic-protocol models are supported via the `protocol` setting):
Sign in with `/login` to use the JuCode gateway, or set an API key and point the config at any OpenAI-compatible endpoint. The built-in provider templates are `jucode` and `deepseek`; other vendors (for example OpenAI) work by setting `base_url`, `model`, and `api_key_env` manually — dedicated vendor templates are separate future work. Anthropic-protocol models are supported via the `protocol` setting:

```bash
export OPENAI_API_KEY="..."
Expand All @@ -62,6 +62,18 @@ You can switch model and reasoning effort inside the TUI:

The config also supports custom OpenAI-compatible base URLs, retry settings, model metadata, project-instruction discovery, and optional extensions.

### Edit tools (`edit_tools`)

The default edit tool is `hashline_edit`; the other edit tools are off unless you enable them. The `edit_tools` array in `config.json` controls which edit tools the model sees (and may execute):

```json
"edit_tools": ["hashline_edit", "str_replace", "write", "apply_patch"]
```

Valid names are `hashline_edit`, `str_replace` (alias `edit`), `write`, and `apply_patch`. Omitting the field enables only `hashline_edit`; an empty array disables all edit tools. Disabled edit tools are not sent to the model and return a clear error if called anyway. Non-edit tools (`read`, `bash`, `ls`, `ripgrep`, `outline`, `checkpoint`, and so on) are not affected by this field. The desktop-only `browser_open` tool can be switched off with `"enable_browser_open": false`.

File tools (read/write/edit/ls/outline/checkpoint/apply_patch) only operate on paths inside the working directory: absolute paths, `..`, and symlinks that resolve outside the workspace are rejected with a clear error. This is a path policy, not an OS sandbox — shell commands are not restricted by it.

## Usage

### Interactive mode
Expand Down Expand Up @@ -97,8 +109,16 @@ Useful commands:

Headless mode emits JSONL events and finishes with a `final_result` event containing status, usage, context, tool-call counts, and elapsed time.

Headless runs default to the `read-only` approval mode: tool calls that would need interactive approval (edits, shell commands) are auto-denied with a clear message instead of hanging. Pass `--approval-mode` explicitly for tasks that change files or run commands:

```bash
jucode --headless --approval-mode full-auto "Fix the failing test and verify the focused suite"
```

Read-only tasks work without a flag:

```bash
jucode --headless "Fix the failing test and verify the focused suite"
jucode --headless "List the repository structure and stop."
```

You can also pipe the task through stdin:
Expand All @@ -116,10 +136,10 @@ JuCode exposes a small set of direct tools to the model:
| Tool | Purpose |
| --- | --- |
| `read` | Read text, image metadata/payload, or binary metadata. Supports `offset` and `limit`. |
| `str_replace` | Apply exact targeted replacements after reading a file. |
| `hashline_edit` | Patch lines using stable `LINE#HASH` anchors from `read`. |
| `write` | Create new files or overwrite previously read files. |
| `apply_patch` | Apply a unified patch when targeted edits are awkward. |
| `hashline_edit` | Patch lines using stable `LINE#HASH` anchors from `read`. The only edit tool enabled by default. |
| `str_replace` | Apply exact targeted replacements after reading a file. Off by default; enable via `edit_tools`. |
| `write` | Create new files or overwrite previously read files. Off by default; enable via `edit_tools`. |
| `apply_patch` | Apply a unified patch when targeted edits are awkward. Off by default; enable via `edit_tools`. |
| `bash` / `exec_command` | Run shell commands with timeout, sessions, output truncation, and progress updates. |
| `write_stdin` | Poll or send input to a running shell session. |
| `ls` | List directory entries. |
Expand Down
123 changes: 119 additions & 4 deletions crates/agent-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,26 @@ fn is_shell_tool(name: &str) -> bool {
}

fn is_edit_tool(name: &str) -> bool {
matches!(
name,
"write" | "edit" | "str_replace" | "hashline_edit" | "apply_patch"
)
canonical_edit_tool_name(name).is_some()
}

/// Canonical name for an edit tool, accepting the `edit` alias for
/// `str_replace`. Returns None for anything that is not an edit tool.
pub fn canonical_edit_tool_name(name: &str) -> Option<&'static str> {
match name {
"hashline_edit" => Some("hashline_edit"),
"str_replace" | "edit" => Some("str_replace"),
"write" => Some("write"),
"apply_patch" => Some("apply_patch"),
_ => None,
}
}

/// Edit tools exposed to the model when config.json has no `edit_tools`
/// field. Only hashline_edit is on by default; users opt in to the others
/// with e.g. `"edit_tools": ["hashline_edit", "str_replace", "write"]`.
pub fn default_edit_tools() -> Vec<String> {
vec!["hashline_edit".to_string()]
}

fn is_network_tool(name: &str) -> bool {
Expand Down Expand Up @@ -168,6 +184,16 @@ pub struct Config {
pub compaction_threshold_percent: u64,
pub include_project_instructions: bool,
pub approval_mode: ApprovalMode,
/// Edit tools offered to the model (`edit_tools` in config.json).
/// Canonical names: hashline_edit, str_replace (alias edit), write,
/// apply_patch. Defaults to hashline_edit only; an explicit empty array
/// disables all edit tools. Tools not listed here are neither sent to the
/// model nor executed if called anyway.
pub edit_tools: Vec<String>,
/// Whether the desktop-only browser_open tool may be offered at all
/// (`enable_browser_open` in config.json, default true). It is only ever
/// exposed when running under JuCode Desktop (JUCODE_DESKTOP set).
pub enable_browser_open: bool,
pub extensions: Vec<ExtensionConfig>,
pub mcp_servers: Vec<McpServerConfig>,
path: PathBuf,
Expand Down Expand Up @@ -286,6 +312,8 @@ impl Config {
compaction_threshold_percent: DEFAULT_COMPACTION_THRESHOLD_PERCENT,
include_project_instructions: true,
approval_mode: ApprovalMode::default(),
edit_tools: default_edit_tools(),
enable_browser_open: true,
extensions: Vec::new(),
mcp_servers: Vec::new(),
path,
Expand Down Expand Up @@ -365,6 +393,8 @@ impl Config {
.clamp(10, 95),
include_project_instructions: read_bool(&value, "include_project_instructions", true),
approval_mode: read_approval_mode(&value)?,
edit_tools: read_edit_tools(&value)?,
enable_browser_open: read_bool(&value, "enable_browser_open", true),
extensions: read_extensions(&value),
mcp_servers: read_mcp_servers(&value),
path,
Expand Down Expand Up @@ -396,6 +426,8 @@ impl Config {
"compaction_threshold_percent": self.compaction_threshold_percent,
"include_project_instructions": self.include_project_instructions,
"approval_mode": self.approval_mode.as_str(),
"edit_tools": self.edit_tools,
"enable_browser_open": self.enable_browser_open,
"extensions": self.extensions.iter().map(extension_config_value).collect::<Vec<_>>(),
"mcp_servers": self.mcp_servers.iter().map(mcp_server_config_value).collect::<Vec<_>>()
});
Expand Down Expand Up @@ -556,6 +588,38 @@ fn read_approval_mode(value: &Value) -> io::Result<ApprovalMode> {
}
}

/// Optional `edit_tools` in config.json. Absent defaults to hashline_edit
/// only; an explicit empty array disables all edit tools. Unknown names are a
/// hard load error rather than a silent fallback. The `edit` alias is stored
/// canonically as `str_replace` and duplicates collapse.
fn read_edit_tools(value: &Value) -> io::Result<Vec<String>> {
let Some(raw) = value.get("edit_tools") else {
return Ok(default_edit_tools());
};
let Some(items) = raw.as_array() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid edit_tools in config.json: expected an array of tool names",
));
};
let mut tools = Vec::new();
for item in items {
let name = item.as_str().map(str::trim).unwrap_or_default();
let Some(canonical) = canonical_edit_tool_name(name) else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"invalid edit_tools entry '{name}' in config.json: use hashline_edit, str_replace (alias edit), write, or apply_patch"
),
));
};
if !tools.iter().any(|tool| tool == canonical) {
tools.push(canonical.to_string());
}
}
Ok(tools)
}

fn read_bool(value: &Value, key: &str, default: bool) -> bool {
value.get(key).and_then(Value::as_bool).unwrap_or(default)
}
Expand Down Expand Up @@ -1175,6 +1239,8 @@ mod tests {
compaction_threshold_percent: DEFAULT_COMPACTION_THRESHOLD_PERCENT,
include_project_instructions: true,
approval_mode: ApprovalMode::default(),
edit_tools: default_edit_tools(),
enable_browser_open: true,
extensions: Vec::new(),
mcp_servers: Vec::new(),
path: PathBuf::from("config.json"),
Expand Down Expand Up @@ -1348,6 +1414,55 @@ mod tests {
assert_eq!(parsed[0].command, "run");
}

#[test]
fn edit_tools_default_to_hashline_only() {
assert_eq!(read_edit_tools(&json!({})).unwrap(), vec!["hashline_edit"]);
assert_eq!(default_edit_tools(), vec!["hashline_edit"]);
}

#[test]
fn edit_tools_accept_known_names_and_canonicalize_the_edit_alias() {
let tools = read_edit_tools(&json!({
"edit_tools": ["hashline_edit", "edit", "write", "str_replace", "apply_patch"]
}))
.unwrap();
assert_eq!(
tools,
vec!["hashline_edit", "str_replace", "write", "apply_patch"]
);

// An explicit empty array disables all edit tools.
assert_eq!(
read_edit_tools(&json!({ "edit_tools": [] })).unwrap(),
Vec::<String>::new()
);
}

#[test]
fn edit_tools_reject_unknown_names_and_non_arrays() {
let error = read_edit_tools(&json!({ "edit_tools": ["bash"] })).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("bash"));

let error = read_edit_tools(&json!({ "edit_tools": "write" })).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("array"));
}

#[test]
fn canonical_edit_tool_name_covers_aliases_and_rejects_others() {
assert_eq!(canonical_edit_tool_name("edit"), Some("str_replace"));
assert_eq!(canonical_edit_tool_name("str_replace"), Some("str_replace"));
assert_eq!(
canonical_edit_tool_name("hashline_edit"),
Some("hashline_edit")
);
assert_eq!(canonical_edit_tool_name("write"), Some("write"));
assert_eq!(canonical_edit_tool_name("apply_patch"), Some("apply_patch"));
assert_eq!(canonical_edit_tool_name("read"), None);
assert_eq!(canonical_edit_tool_name("bash"), None);
}

#[test]
fn read_approval_mode_defaults_and_validates() {
assert_eq!(
Expand Down
8 changes: 8 additions & 0 deletions crates/agent-core/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,8 @@ impl AgentCore {
goal_tool_tx: Some(goal_tool_tx),
approval_tx: Some(approval_tx),
approval_mode: self.approval_mode,
edit_tools: self.config.edit_tools.clone(),
enable_browser_open: self.config.enable_browser_open,
subagent_manager: Some(self.subagent_manager.clone()),
hooks: self.hooks.clone(),
}) else {
Expand Down Expand Up @@ -1519,6 +1521,9 @@ impl AgentCore {
goal_tool_tx: None,
approval_tx: None,
approval_mode: self.approval_mode,
// Summarization clients never expose or execute tools.
edit_tools: Vec::new(),
enable_browser_open: false,
subagent_manager: None,
hooks: Hooks::default(),
})
Expand Down Expand Up @@ -1558,6 +1563,9 @@ impl AgentCore {
goal_tool_tx: None,
approval_tx: None,
approval_mode: self.approval_mode,
// Summarization clients never expose or execute tools.
edit_tools: Vec::new(),
enable_browser_open: false,
subagent_manager: None,
hooks: Hooks::default(),
})
Expand Down
9 changes: 6 additions & 3 deletions crates/agent-core/src/hunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ fn filter_patch_call(arguments: &str, approved: &HashSet<&str>) -> Result<Filter
fn plan_write(args: &Value, cwd: &Path) -> Option<Vec<HunkView>> {
let path = args.get("path").and_then(Value::as_str)?;
let content = args.get("content").and_then(Value::as_str)?;
let path = tools::resolve_path(cwd, path);
// Paths outside the workspace get no preview; the tool itself rejects them.
let path = tools::workspace_path(cwd, path).ok()?;
let original = fs::read_to_string(&path).unwrap_or_default();
if original == content {
return None;
Expand All @@ -247,7 +248,8 @@ fn plan_str_replace(args: &Value, cwd: &Path) -> Option<Vec<HunkView>> {
if edits.is_empty() {
return None;
}
let path = tools::resolve_path(cwd, path);
// Paths outside the workspace get no preview; the tool itself rejects them.
let path = tools::workspace_path(cwd, path).ok()?;
let original = fs::read_to_string(&path).ok()?;

let mut views = Vec::new();
Expand Down Expand Up @@ -281,7 +283,8 @@ fn plan_hashline_edit(args: &Value, cwd: &Path) -> Option<Vec<HunkView>> {
if edits.is_empty() {
return None;
}
let path = tools::resolve_path(cwd, path);
// Paths outside the workspace get no preview; the tool itself rejects them.
let path = tools::workspace_path(cwd, path).ok()?;
let original = fs::read_to_string(&path).ok()?;

let mut views = Vec::new();
Expand Down
Loading
Loading