@setoelkahfi / sigit / commits / 9f0e844

Add smbCloud-specific context and tool heuristics

- Expand README with what siGit knows about smbCloud repos - Update SYSTEM_PROMPT to include detailed smbCloud platform knowledge, context-aware advice, and tool-use heuristics - Add create_directory tool and prefer absolute paths in tool descriptions - Improve tool descriptions and argument handling for clarity and platform awareness - Ensure all file and directory operations use absolute paths when possible

paydii committed Apr 24, 2026 at 13:52 UTC 9f0e84431a580ed3937cb40efc5cf510d84b5c26
3 files changed +285 -79
README.md
+13
index b791a33..c9bb918 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A coding agent for [smbCloud](https://smbcloud.xyz/) that runs entirely on your machine. No API keys. No cloud round-trips. The model lives in your local HuggingFace cache. +siGit is meant to be a general coding agent, but it is especially good in smbCloud codebases. It already knows the rough shape of the platform: Rust workspaces with focused crates, Rails services, deploy flows, auth boundaries, and platform-managed services like GresIQ. In smbCloud repos, that means it can usually give more grounded answers with less back-and-forth. + siGit has two modes: - ACP mode, where Zed or another ACP-compatible editor starts it over stdio @@ -15,6 +17,17 @@ Current platform support: - Linux: ACP mode and interactive terminal mode - Windows: ACP mode only for now +## What siGit knows about smbCloud + +When siGit is working in an smbCloud repo, it should lean on platform context instead of treating everything like a generic cloud app. That includes things like: + +- the difference between platform user flows and tenant app auth flows +- the fact that `Project` is the umbrella workspace, while app-like resources such as `FrontendApp`, `AuthApp`, and GresIQ are separate deployable units +- the fact that Next.js SSR deploys are not the same as the generic git-push path +- the fact that smbCloud repos usually prefer existing workspace patterns and crate boundaries over new abstractions + +Outside smbCloud, it should still behave like a normal coding agent and not force platform-specific advice where it does not belong. + ## Install ```sh
src/main.rs
+63 -7
index 66c0d51..703515d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,18 +73,74 @@ const SYSTEM_PROMPT: &str = "\ Your name is siGit — lowercase 's', uppercase 'G', no spaces. \ Not 'SiGit', not 'Sigit'. Only say your name if the user asks who you are. -You are the official coding agent for smbCloud (https://smbcloud.xyz), \ -a cloud platform for deploying and managing projects. \ -You help developers build, debug, and ship software on the smbCloud platform. +You are a strong general-purpose coding agent. smbCloud is your home turf, \ +but you should still be useful in any codebase. When the project is clearly \ +about smbCloud, use that context directly instead of falling back to vague \ +cloud-platform advice. + +smbCloud context you should know and use when it helps: +- smbCloud is a platform for deploying and managing projects +- the main CLI is a Rust workspace with focused crates rather than one giant crate +- common areas include auth, project management, deploy flows, networking, \ + shared models, release tooling, and managed services +- deploy branches usually follow `release/service-{name}` +- Next.js SSR deploys on smbCloud are not the same as generic git-push deploys; \ + they often use a local build plus rsync/PM2 style flow +- auth has a hard boundary between smbCloud platform users and tenant app users; \ + platform flows use `/v1/users*`, tenant app flows use `/v1/client/*`, and \ + you should not casually mix `User`, `TenantMembership`, `AuthApp`, and `AuthUser` +- smbCloud authorization is layered; do not flatten platform accounts, tenant \ + memberships, auth-app collaborators, and tenant end users into one model +- `Project` is the umbrella workspace, while app-like resources such as \ + `FrontendApp`, `AuthApp`, and GresIQ are the deployable units with their own \ + ownership, sharing, and collaboration rules +- `FrontendApp` is many-per-project, while `AuthApp` is intentionally one-per-project; \ + preserve those cardinality rules unless the code clearly changes them +- GresIQ is smbCloud's managed PostgreSQL offering; treat it as a platform \ + service with its own credentials and boundaries, not as a generic local DB helper +- when debugging smbCloud Rails APIs, first classify the request: first-party \ + smbCloud app or tenant app, then check which endpoint family and validator \ + should be involved before changing code +- when working in smbCloud repos, prefer existing workspace patterns, existing \ + crate boundaries, existing Rails conventions, and existing command flows over \ + inventing new abstractions Never introduce yourself unless asked. Jump straight into the answer. \ Keep answers short. Write idiomatic code. \ Fix root causes, not symptoms. -You have access to tools that let you read files, list directories, search \ -code, create new files, edit existing files, delete files, and run shell \ -commands. Use them proactively — read the code before answering, run builds \ -and tests after making changes. Always ground your answers in the actual code. +You have access to tools that let you read files, create directories, list \ +directories, search code, create new files, edit existing files, delete files, \ +and run shell commands. You can also use git directly through shell commands, \ +including `git init` and normal git workflows. Use them proactively. Read the \ +code before answering. Prefer absolute paths when referring to files and \ +directories, especially in protocol-facing output and tool arguments. Create \ +directories when needed. Run builds, tests, and git commands after making \ +changes. Ground your answers in the actual code, not in guesses. + +Tool-use heuristics: +- prefer absolute paths over relative paths when you mention, return, or pass \ + file and directory paths +- if a path does not exist yet, create the directory before creating files in it +- if the user asks for a new repo, scaffold, or scratch project, create the \ + directory, create the first files, and run `git init` without waiting unless \ + the request says otherwise +- if the repo looks like smbCloud CLI code, respect workspace crate boundaries, \ + shared models, and existing command handlers before adding new abstractions +- if the repo looks like smbCloud Rails code, check routes, controllers, \ + validators, and model boundaries before changing business logic +- if the task touches smbCloud auth, first decide whether it is a platform-user \ + flow or a tenant-app flow, then follow the right endpoint family and model layer +- if the task touches smbCloud deploy code, check whether it is the generic \ + deploy path or the Next.js SSR path before proposing changes +- after edits, prefer running the smallest useful verification step first, then \ + widen to broader checks if needed +- use git commands naturally for status checks, repo setup, diffs, and normal \ + developer workflows when they help move the task forward + +When the repo is not about smbCloud, act like a normal coding agent and do not \ +force smbCloud-specific advice into the answer. When it is about smbCloud, be \ +specific and practical. Be direct and brief. Write clean, idiomatic code. When debugging, go for the \ root cause, not the symptom. Correct beats clever.";
src/tools.rs
+209 -72
index a3d581e..789749d 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -14,18 +14,19 @@ //! //! # Write Tools //! +//! - `create_directory` — create a directory and any missing parent directories //! - `create_file` — create a new file (fails if it already exists) //! - `edit_file` — replace an exact old-text span with new text in an existing file //! - `delete_file` — delete a file or empty directory at the given path //! //! # Shell Tools //! -//! - `run_command` — run a shell command and return its combined stdout/stderr output +//! - `run_command` — run shell commands, including git porcelain and plumbing commands use regex::Regex; use serde_json::{Value, json}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; /// Maximum characters returned from `read_file` before truncation. @@ -52,8 +53,9 @@ pub fn all_tools() -> Vec<AgentTool> { AgentTool { name: "read_file", description: "Read the contents of a file at the given path. \ - Returns the file text, or an error message if the file cannot be read. \ - Output is truncated to 10 000 characters.", + Prefer an absolute path when possible. Returns the file text, \ + or an error message if the file cannot be read. Output is \ + truncated to 10 000 characters.", parameters_schema: json!({ "type": "object", "properties": { @@ -66,11 +68,31 @@ pub fn all_tools() -> Vec<AgentTool> { "additionalProperties": false }), }, + AgentTool { + name: "create_directory", + description: "Create a directory at the given path. \ + Prefer an absolute path when possible. Missing parent \ + directories are created automatically. Use this before \ + create_file when the parent path does not exist. Succeeds \ + if the directory already exists.", + parameters_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative path to the directory to create." + } + }, + "required": ["path"], + "additionalProperties": false + }), + }, AgentTool { name: "list_directory", description: "List files and directories at the given path. \ - Each entry is prefixed with [DIR] or [FILE]. \ - Directories are listed first, sorted alphabetically.", + Prefer an absolute path when possible. Each entry is \ + prefixed with [DIR] or [FILE]. Directories are listed \ + first, sorted alphabetically.", parameters_schema: json!({ "type": "object", "properties": { @@ -86,9 +108,9 @@ pub fn all_tools() -> Vec<AgentTool> { AgentTool { name: "search_files", description: "Search for a regex pattern across files in a directory tree. \ - Returns matching lines in `file:line_number: content` format. \ - Skips binary files and hidden directories. \ - Limited to the first 50 matches.", + Prefer an absolute root path when possible. Returns matching \ + lines in `file:line_number: content` format. Skips binary \ + files and hidden directories. Limited to the first 50 matches.", parameters_schema: json!({ "type": "object", "properties": { @@ -108,8 +130,9 @@ pub fn all_tools() -> Vec<AgentTool> { AgentTool { name: "create_file", description: "Create a new file at the given path with the provided content. \ - Parent directories are created automatically if they do not exist. \ - Fails if the file already exists — use edit_file to modify existing files.", + Prefer an absolute path when possible. Parent directories are \ + created automatically if they do not exist. Fails if the file \ + already exists — use edit_file to modify existing files.", parameters_schema: json!({ "type": "object", "properties": { @@ -129,10 +152,11 @@ pub fn all_tools() -> Vec<AgentTool> { AgentTool { name: "edit_file", description: "Edit an existing file by replacing an exact substring (old_text) with \ - new text (new_text). The old_text must appear exactly once in the file. \ - Use read_file first to see the current content and identify the exact \ - text to replace. To append to a file, match the last few lines as \ - old_text and include them plus the new content as new_text.", + new text (new_text). Prefer an absolute path when possible. The \ + old_text must appear exactly once in the file. Use read_file first \ + to see the current content and identify the exact text to replace. \ + To append to a file, match the last few lines as old_text and \ + include them plus the new content as new_text.", parameters_schema: json!({ "type": "object", "properties": { @@ -156,7 +180,8 @@ pub fn all_tools() -> Vec<AgentTool> { AgentTool { name: "delete_file", description: "Delete a file or empty directory at the given path. \ - Refuses to delete non-empty directories to prevent accidental data loss. \ + Prefer an absolute path when possible. Refuses to delete \ + non-empty directories to prevent accidental data loss. \ Use read_file or list_directory first to confirm the target.", parameters_schema: json!({ "type": "object", @@ -174,16 +199,22 @@ pub fn all_tools() -> Vec<AgentTool> { name: "run_command", description: "Run a shell command and return its combined stdout and stderr output. \ The command runs in the given working directory (defaults to \".\"). \ - Use this for build tools (cargo, npm, make), version control (git), \ - package managers, linters, test runners, and other CLI tasks. \ - Commands that run indefinitely (servers, watchers) will be killed \ - after 120 seconds.", + Prefer an absolute working directory when possible. Use this for \ + build tools (cargo, npm, make), package managers, linters, test \ + runners, and git commands, including git init, porcelain commands \ + like status/add/commit/checkout, and plumbing commands like \ + rev-parse, hash-object, update-ref, and cat-file. If the user asks \ + for a new repo or scaffold, it is fine to use this for `git init` \ + and normal repo setup steps. In smbCloud repos, prefer existing \ + workspace commands, Rails conventions, and deploy flows over \ + inventing new command sequences. Commands that run indefinitely \ + (servers, watchers) will be killed after 120 seconds.", parameters_schema: json!({ "type": "object", "properties": { "command": { "type": "string", - "description": "The shell command to execute (e.g. \"cargo update\", \"git status\")." + "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")." }, "cwd": { "type": "string", @@ -208,6 +239,7 @@ pub fn execute_tool(name: &str, arguments: &str) -> String { "read_file" => exec_read_file(arguments), "list_directory" => exec_list_directory(arguments), "search_files" => exec_search_files(arguments), + "create_directory" => exec_create_directory(arguments), "create_file" => exec_create_file(arguments), "edit_file" => exec_edit_file(arguments), "delete_file" => exec_delete_file(arguments), @@ -216,6 +248,20 @@ pub fn execute_tool(name: &str, arguments: &str) -> String { } } +fn absolute_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} + +fn absolute_path_string(path: &Path) -> String { + absolute_path(path).display().to_string() +} + // ── read_file ──────────────────────────────────────────────────────────────── /// Read the contents of a single file, truncating at [`READ_FILE_CHAR_LIMIT`]. @@ -231,16 +277,18 @@ fn exec_read_file(arguments: &str) -> String { }; let path = Path::new(path_str); + let absolute_path = absolute_path(path); + let absolute_path_str = absolute_path.display().to_string(); - if !path.exists() { - return format!("Error: path does not exist: {path_str}"); + if !absolute_path.exists() { + return format!("Error: path does not exist: {absolute_path_str}"); } - if !path.is_file() { - return format!("Error: path is not a file: {path_str}"); + if !absolute_path.is_file() { + return format!("Error: path is not a file: {absolute_path_str}"); } - match fs::read_to_string(path) { + match fs::read_to_string(&absolute_path) { Ok(contents) => { if contents.len() > READ_FILE_CHAR_LIMIT { let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect(); @@ -271,16 +319,18 @@ fn exec_list_directory(arguments: &str) -> String { }; let path = Path::new(path_str); + let absolute_path = absolute_path(path); + let absolute_path_str = absolute_path.display().to_string(); - if !path.exists() { - return format!("Error: path does not exist: {path_str}"); + if !absolute_path.exists() { + return format!("Error: path does not exist: {absolute_path_str}"); } - if !path.is_dir() { - return format!("Error: path is not a directory: {path_str}"); + if !absolute_path.is_dir() { + return format!("Error: path is not a directory: {absolute_path_str}"); } - let entries = match fs::read_dir(path) { + let entries = match fs::read_dir(&absolute_path) { Ok(rd) => rd, Err(err) => return format!("Error: could not read directory: {err}"), }; @@ -318,7 +368,7 @@ fn exec_list_directory(arguments: &str) -> String { dirs.extend(files); if dirs.is_empty() { - return format!("(empty directory: {path_str})"); + return format!("(empty directory: {absolute_path_str})"); } dirs.join("\n") @@ -346,17 +396,19 @@ fn exec_search_files(arguments: &str) -> String { }; let root = Path::new(root_str); + let absolute_root = absolute_path(root); + let absolute_root_str = absolute_root.display().to_string(); - if !root.exists() { - return format!("Error: path does not exist: {root_str}"); + if !absolute_root.exists() { + return format!("Error: path does not exist: {absolute_root_str}"); } - if !root.is_dir() { - return format!("Error: path is not a directory: {root_str}"); + if !absolute_root.is_dir() { + return format!("Error: path is not a directory: {absolute_root_str}"); } let mut matches: Vec<String> = Vec::new(); - walk_and_search(root, &re, &mut matches); + walk_and_search(&absolute_root, &re, &mut matches); if matches.is_empty() { return format!("No matches found for pattern: {pattern_str}"); @@ -423,7 +475,7 @@ fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) { Err(_) => return, }; - let display_path = path.display(); + let display_path = absolute_path_string(path); for (line_idx, line) in contents.lines().enumerate() { if re.is_match(line) { @@ -433,7 +485,38 @@ fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) { } } -// ── create_file ────────────────────────────────────────────────────────────── +/// ── create_directory ───────────────────────────────────────────────────────── + +/// Create a directory and any missing parent directories. +fn exec_create_directory(arguments: &str) -> String { + let args: Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(err) => return format!("Error: failed to parse arguments: {err}"), + }; + + let path_str = match args.get("path").and_then(Value::as_str) { + Some(p) => p, + None => return "Error: missing required parameter \"path\"".to_string(), + }; + + let path = Path::new(path_str); + let absolute_path = absolute_path(path); + let absolute_path_str = absolute_path.display().to_string(); + + if absolute_path.exists() { + if absolute_path.is_dir() { + return format!("Directory already exists: {absolute_path_str}"); + } + return format!("Error: path exists and is not a directory: {absolute_path_str}"); + } + + match fs::create_dir_all(&absolute_path) { + Ok(()) => format!("Created directory: {absolute_path_str}"), + Err(err) => format!("Error: could not create directory: {err}"), + } +} + +/// ── create_file ────────────────────────────────────────────────────────────── /// Create a new file with the provided content. /// @@ -457,15 +540,17 @@ fn exec_create_file(arguments: &str) -> String { }; let path = Path::new(path_str); + let absolute_path = absolute_path(path); + let absolute_path_str = absolute_path.display().to_string(); - if path.exists() { + if absolute_path.exists() { return format!( - "Error: file already exists: {path_str} — use edit_file to modify existing files" + "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files" ); } // Create parent directories if needed. - if let Some(parent) = path.parent() + if let Some(parent) = absolute_path.parent() && !parent.as_os_str().is_empty() && !parent.exists() && let Err(err) = fs::create_dir_all(parent) @@ -473,8 +558,11 @@ fn exec_create_file(arguments: &str) -> String { return format!("Error: could not create parent directories: {err}"); } - match fs::write(path, content) { - Ok(()) => format!("Created file: {path_str} ({} bytes)", content.len()), + match fs::write(&absolute_path, content) { + Ok(()) => format!( + "Created file: {absolute_path_str} ({} bytes)", + content.len() + ), Err(err) => format!("Error: could not write file: {err}"), } } @@ -509,16 +597,20 @@ fn exec_edit_file(arguments: &str) -> String { }; let path = Path::new(path_str); + let absolute_path = absolute_path(path); + let absolute_path_str = absolute_path.display().to_string(); - if !path.exists() { - return format!("Error: file does not exist: {path_str} — use create_file for new files"); + if !absolute_path.exists() { + return format!( + "Error: file does not exist: {absolute_path_str} — use create_file for new files" + ); } - if !path.is_file() { - return format!("Error: path is not a file: {path_str}"); + if !absolute_path.is_file() { + return format!("Error: path is not a file: {absolute_path_str}"); } - let contents = match fs::read_to_string(path) { + let contents = match fs::read_to_string(&absolute_path) { Ok(c) => c, Err(err) => return format!("Error: could not read file: {err}"), }; @@ -528,22 +620,25 @@ fn exec_edit_file(arguments: &str) -> String { if occurrences == 0 { return format!( - "Error: old_text not found in {path_str}. \ + "Error: old_text not found in {absolute_path_str}. \ Use read_file to see the current content and copy the exact text to replace." ); } if occurrences > 1 { return format!( - "Error: old_text appears {occurrences} times in {path_str}. \ + "Error: old_text appears {occurrences} times in {absolute_path_str}. \ Include more surrounding context in old_text so it matches exactly once." ); } let updated = contents.replacen(old_text, new_text, 1); - match fs::write(path, &updated) { - Ok(()) => format!("Edited file: {path_str} ({} bytes written)", updated.len()), + match fs::write(&absolute_path, &updated) { + Ok(()) => format!( + "Edited file: {absolute_path_str} ({} bytes written)", + updated.len() + ), Err(err) => format!("Error: could not write file: {err}"), } } @@ -566,22 +661,24 @@ fn exec_delete_file(arguments: &str) -> String { }; let path = Path::new(path_str); + let absolute_path = absolute_path(path); + let absolute_path_str = absolute_path.display().to_string(); - if !path.exists() { - return format!("Error: path does not exist: {path_str}"); + if !absolute_path.exists() { + return format!("Error: path does not exist: {absolute_path_str}"); } - if path.is_dir() { - match fs::remove_dir(path) { - Ok(()) => format!("Deleted empty directory: {path_str}"), + if absolute_path.is_dir() { + match fs::remove_dir(&absolute_path) { + Ok(()) => format!("Deleted empty directory: {absolute_path_str}"), Err(err) => format!( "Error: could not delete directory: {err}. \ Only empty directories can be deleted." ), } } else { - match fs::remove_file(path) { - Ok(()) => format!("Deleted file: {path_str}"), + match fs::remove_file(&absolute_path) { + Ok(()) => format!("Deleted file: {absolute_path_str}"), Err(err) => format!("Error: could not delete file: {err}"), } } @@ -613,19 +710,20 @@ fn exec_run_command(arguments: &str) -> String { }; let cwd = args.get("cwd").and_then(Value::as_str).unwrap_or("."); + let cwd_path = absolute_path(Path::new(cwd)); + let cwd_str = cwd_path.display().to_string(); - let cwd_path = Path::new(cwd); if !cwd_path.exists() { - return format!("Error: working directory does not exist: {cwd}"); + return format!("Error: working directory does not exist: {cwd_str}"); } - log::info!("run_command: `{command_str}` in `{cwd}`"); + log::info!("run_command: `{command_str}` in `{cwd_str}`"); #[cfg(unix)] let mut child = match Command::new("sh") .arg("-c") .arg(command_str) - .current_dir(cwd_path) + .current_dir(&cwd_path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() @@ -638,7 +736,7 @@ fn exec_run_command(arguments: &str) -> String { let mut child = match Command::new("cmd") .arg("/C") .arg(command_str) - .current_dir(cwd_path) + .current_dir(&cwd_path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() @@ -814,14 +912,15 @@ mod tests { #[test] fn test_all_tools_count() { let tools = all_tools(); - assert_eq!(tools.len(), 7); + assert_eq!(tools.len(), 8); assert_eq!(tools[0].name, "read_file"); - assert_eq!(tools[1].name, "list_directory"); - assert_eq!(tools[2].name, "search_files"); - assert_eq!(tools[3].name, "create_file"); - assert_eq!(tools[4].name, "edit_file"); - assert_eq!(tools[5].name, "delete_file"); - assert_eq!(tools[6].name, "run_command"); + assert_eq!(tools[1].name, "create_directory"); + assert_eq!(tools[2].name, "list_directory"); + assert_eq!(tools[3].name, "search_files"); + assert_eq!(tools[4].name, "create_file"); + assert_eq!(tools[5].name, "edit_file"); + assert_eq!(tools[6].name, "delete_file"); + assert_eq!(tools[7].name, "run_command"); } #[test] @@ -839,6 +938,44 @@ mod tests { } } + // ── create_directory tests ─────────────────────────────────────────── + + #[test] + fn test_create_directory_missing_path() { + let result = exec_create_directory("{}"); + assert!(result.contains("missing required parameter")); + } + + #[test] + fn test_create_directory_success() { + let dir = std::env::temp_dir() + .join("sigit_test_create_directory") + .join("nested") + .join("child"); + let _ = fs::remove_dir_all(dir.parent().unwrap()); + + let args = serde_json::json!({ "path": dir }).to_string(); + let result = exec_create_directory(&args); + assert!(result.starts_with("Created directory:"), "got: {result}"); + assert!(dir.exists()); + assert!(dir.is_dir()); + + let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap()); + } + + #[test] + fn test_create_directory_already_exists() { + let dir = std::env::temp_dir().join("sigit_test_create_directory_exists"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let args = serde_json::json!({ "path": dir }).to_string(); + let result = exec_create_directory(&args); + assert!(result.contains("Directory already exists"), "got: {result}"); + + let _ = fs::remove_dir_all(&dir); + } + // ── create_file tests ──────────────────────────────────────────────── #[test]