Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in <config>/credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in <config>/settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
373 lines
13 KiB
C
373 lines
13 KiB
C
/* Built-in workspace tools: read ranges, list_dir, glob, grep, edit_file,
|
|
* delete/move, todo_write, ask_user, previews and the sandbox. */
|
|
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "cst_agent.h"
|
|
#include "cst_tools.h"
|
|
|
|
#include <stdarg.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
static int nfail;
|
|
|
|
#define EXPECT(cond, msg) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
|
nfail++; \
|
|
} \
|
|
} while (0)
|
|
|
|
static char g_ws[256];
|
|
static char g_todo[512];
|
|
|
|
static void wfile(const char *rel, const char *s) {
|
|
char p[512];
|
|
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
|
|
FILE *fp = fopen(p, "wb");
|
|
if (fp) {
|
|
fputs(s, fp);
|
|
fclose(fp);
|
|
}
|
|
}
|
|
|
|
static char *rfile(const char *rel) {
|
|
char p[512];
|
|
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
|
|
FILE *fp = fopen(p, "rb");
|
|
if (!fp)
|
|
return NULL;
|
|
static char buf[65536];
|
|
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
|
|
buf[n] = 0;
|
|
fclose(fp);
|
|
return buf;
|
|
}
|
|
|
|
static int exists(const char *rel) {
|
|
char p[512];
|
|
struct stat st;
|
|
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
|
|
return stat(p, &st) == 0;
|
|
}
|
|
|
|
static void set_todo(const char *text, void *ud) {
|
|
(void)ud;
|
|
snprintf(g_todo, sizeof(g_todo), "%s", text);
|
|
}
|
|
|
|
static int ask_yes(const char *q, const char *o, char *ans, size_t n,
|
|
void *ud) {
|
|
(void)q;
|
|
(void)o;
|
|
(void)ud;
|
|
snprintf(ans, n, "yes please");
|
|
return 0;
|
|
}
|
|
|
|
/* name, then key/value pairs, NULL-terminated. */
|
|
static ctl_tool_call_t mk(const char *name, ...) {
|
|
ctl_tool_call_t c;
|
|
memset(&c, 0, sizeof(c));
|
|
snprintf(c.id, sizeof(c.id), "t");
|
|
snprintf(c.name, sizeof(c.name), "%s", name);
|
|
va_list ap;
|
|
va_start(ap, name);
|
|
const char *k;
|
|
while ((k = va_arg(ap, const char *)) != NULL) {
|
|
const char *v = va_arg(ap, const char *);
|
|
ctl_arg_t *a = &c.args[c.nargs++];
|
|
snprintf(a->key, sizeof(a->key), "%s", k);
|
|
a->value = (char *)v;
|
|
a->value_len = strlen(v);
|
|
}
|
|
va_end(ap);
|
|
return c;
|
|
}
|
|
|
|
static int run(cst_tool_ctx_t *ctx, ctl_tool_call_t c, cst_tool_result_t *r) {
|
|
memset(r, 0, sizeof(*r));
|
|
cst_tool_run(ctx, &c, r);
|
|
return r->exit_code;
|
|
}
|
|
|
|
static void reset(cst_tool_result_t *r) { cst_tool_result_free(r); }
|
|
|
|
int main(void) {
|
|
snprintf(g_ws, sizeof(g_ws), "/tmp/cst-tools-test-%d", (int)getpid());
|
|
mkdir(g_ws, 0755);
|
|
char p[512];
|
|
snprintf(p, sizeof(p), "%s/src", g_ws);
|
|
mkdir(p, 0755);
|
|
snprintf(p, sizeof(p), "%s/src/deep", g_ws);
|
|
mkdir(p, 0755);
|
|
snprintf(p, sizeof(p), "%s/node_modules", g_ws);
|
|
mkdir(p, 0755);
|
|
snprintf(p, sizeof(p), "%s/build", g_ws);
|
|
mkdir(p, 0755);
|
|
wfile("src/main.c", "#include <stdio.h>\nint main(void) {\n int x = 42;\n"
|
|
" printf(\"%d\\n\", x);\n return 0;\n}\n");
|
|
wfile("src/deep/util.c", "int add(int a, int b) { return a + b; }\n"
|
|
"int sub(int a, int b) { return a - b; }\n");
|
|
wfile("src/notes.txt", "TODO: x\n");
|
|
wfile("node_modules/junk.c", "int add(int a, int b);\n");
|
|
wfile("build/out.c", "int add_generated;\n");
|
|
wfile(".gitignore", "build/\n*.log\n");
|
|
wfile("run.log", "int add in log\n");
|
|
{
|
|
FILE *fp = fopen(strcat(strcpy(p, g_ws), "/lines.txt"), "w");
|
|
for (int i = 1; i <= 100; i++)
|
|
fprintf(fp, "line %d\n", i);
|
|
fclose(fp);
|
|
}
|
|
|
|
cst_tool_ctx_t ctx;
|
|
memset(&ctx, 0, sizeof(ctx));
|
|
ctx.workspace_root = g_ws;
|
|
ctx.set_todo = set_todo;
|
|
cst_tool_result_t r;
|
|
|
|
/* read_file: whole, slices, numbers */
|
|
EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", NULL), &r) == 0 &&
|
|
strcmp(r.out, "TODO: x\n") == 0,
|
|
"read whole file unchanged");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "10",
|
|
"limit", "3", "line_numbers", "true", NULL),
|
|
&r) == 0,
|
|
"read slice ok");
|
|
EXPECT(r.out && strstr(r.out, " 10|line 10\n 11|line 11\n 12|line 12\n"),
|
|
"slice numbered lines");
|
|
EXPECT(r.out && strstr(r.out, "next offset=13"), "slice continuation hint");
|
|
EXPECT(r.out && !strstr(r.out, "line 13\n"), "slice limit respected");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "\"99\"",
|
|
NULL),
|
|
&r) == 0 &&
|
|
strstr(r.out, "line 100\n") && !strstr(r.out, "next offset"),
|
|
"quoted offset (ctl style) reads to the end");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "abc", NULL),
|
|
&r) == 1 && strstr(r.out, "integer"),
|
|
"non-integer offset rejected");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("read_file", "path", "src", NULL), &r) == 1 &&
|
|
strstr(r.out, "list_dir"),
|
|
"directory read suggests list_dir");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("read_file", "path", "../../etc/passwd", NULL), &r) == 1,
|
|
"read outside workspace denied");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("read_file", NULL), &r) == 1 &&
|
|
strstr(r.out, "needs argument \"path\""),
|
|
"missing required arg");
|
|
reset(&r);
|
|
|
|
/* list_dir */
|
|
EXPECT(run(&ctx, mk("list_dir", NULL), &r) == 0, "list_dir ok");
|
|
EXPECT(r.out && strstr(r.out, "src/") && strstr(r.out, " main.c"),
|
|
"list_dir tree");
|
|
EXPECT(r.out && strstr(r.out, "node_modules/ (skipped)"),
|
|
"node_modules shown but not walked");
|
|
EXPECT(r.out && !strstr(r.out, "junk.c"), "skipped dir contents hidden");
|
|
EXPECT(r.out && !strstr(r.out, "build/") && !strstr(r.out, "run.log"),
|
|
".gitignore respected");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("list_dir", "path", "src", "depth", "1", NULL), &r) == 0 &&
|
|
strstr(r.out, "deep/") && !strstr(r.out, "util.c"),
|
|
"list_dir depth");
|
|
reset(&r);
|
|
|
|
/* glob */
|
|
EXPECT(run(&ctx, mk("glob", "pattern", "*.c", NULL), &r) == 0, "glob ok");
|
|
EXPECT(r.out && strstr(r.out, "src/main.c") &&
|
|
strstr(r.out, "src/deep/util.c"),
|
|
"basename glob matches at any depth");
|
|
EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "out.c"),
|
|
"glob skips node_modules and ignored dirs");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("glob", "pattern", "src/*.c", NULL), &r) == 0 &&
|
|
strstr(r.out, "src/main.c") && !strstr(r.out, "util.c"),
|
|
"single-segment glob");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("glob", "pattern", "src/**/*.c", NULL), &r) == 0 &&
|
|
strstr(r.out, "src/main.c") && strstr(r.out, "util.c"),
|
|
"** glob");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("glob", "pattern", "*.rs", NULL), &r) == 0 &&
|
|
strstr(r.out, "no files match"),
|
|
"glob no match");
|
|
reset(&r);
|
|
|
|
/* grep */
|
|
EXPECT(run(&ctx, mk("grep", "pattern", "int add", NULL), &r) == 0, "grep ok");
|
|
EXPECT(r.out && strstr(r.out, "src/deep/util.c:1:int add"), "grep hit");
|
|
EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "run.log"),
|
|
"grep skips node_modules and ignored files");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("grep", "pattern", "x = \\d+", "glob", "*.c", NULL),
|
|
&r) == 0 &&
|
|
strstr(r.out, "src/main.c:3:"),
|
|
"grep translates \\d");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("grep", "pattern", "RETURN", "ignore_case", "true",
|
|
"path", "src/main.c", "context", "1", NULL),
|
|
&r) == 0 &&
|
|
strstr(r.out, "src/main.c:5: return 0;") &&
|
|
strstr(r.out, "src/main.c-4-") && strstr(r.out, "src/main.c-6-"),
|
|
"grep single file, icase, context");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("grep", "pattern", "(", NULL), &r) == 1 &&
|
|
strstr(r.out, "bad regex"),
|
|
"grep bad regex");
|
|
reset(&r);
|
|
|
|
/* edit_file */
|
|
EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string",
|
|
"int x = 42;", "new_string", "int x = 7;", NULL),
|
|
&r) == 0,
|
|
"edit ok");
|
|
EXPECT(r.out && strstr(r.out, "- int x = 42;") && strstr(r.out, "+ int x = 7;"),
|
|
"edit diff");
|
|
EXPECT(strstr(rfile("src/main.c"), "int x = 7;") != NULL, "edit written");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string",
|
|
"nothing like this", "new_string", "y", NULL),
|
|
&r) == 1 &&
|
|
strstr(r.out, "not found"),
|
|
"edit not found");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string",
|
|
"int a, int b", "new_string", "long a, long b", NULL),
|
|
&r) == 1 &&
|
|
strstr(r.out, "matches 2 places"),
|
|
"edit ambiguous");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string",
|
|
"int a, int b", "new_string", "long a, long b",
|
|
"replace_all", "true", NULL),
|
|
&r) == 0 &&
|
|
strstr(r.out, "2 replacements"),
|
|
"edit replace_all");
|
|
reset(&r);
|
|
wfile("crlf.txt", "one\r\ntwo\r\nthree\r\n");
|
|
EXPECT(run(&ctx, mk("edit_file", "path", "crlf.txt", "old_string",
|
|
"one\ntwo\n", "new_string", "1\n2\n", NULL),
|
|
&r) == 0 &&
|
|
strcmp(rfile("crlf.txt"), "1\r\n2\r\nthree\r\n") == 0,
|
|
"edit keeps CRLF line endings");
|
|
reset(&r);
|
|
|
|
/* previews never touch the disk */
|
|
{
|
|
ctl_tool_call_t c = mk("edit_file", "path", "src/main.c", "old_string",
|
|
"return 0;", "new_string", "return 1;", NULL);
|
|
char *pv = NULL;
|
|
EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv &&
|
|
strstr(pv, "+ return 1;"),
|
|
"edit preview diff");
|
|
EXPECT(strstr(rfile("src/main.c"), "return 0;") != NULL,
|
|
"preview did not write");
|
|
free(pv);
|
|
c = mk("write_file", "path", "new/dir/x.txt", "content", "hi\n", NULL);
|
|
EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv &&
|
|
strstr(pv, "create new/dir/x.txt") && strstr(pv, "+hi"),
|
|
"write preview for a new file");
|
|
EXPECT(!exists("new"), "write preview did not mkdir");
|
|
free(pv);
|
|
c = mk("apply_patch", "patch",
|
|
"--- a/src/notes.txt\n+++ b/src/notes.txt\n@@ -1 +1 @@\n-TODO: x\n"
|
|
"+DONE: x\n",
|
|
NULL);
|
|
EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv &&
|
|
strstr(pv, "+DONE: x"),
|
|
"patch preview");
|
|
EXPECT(strcmp(rfile("src/notes.txt"), "TODO: x\n") == 0,
|
|
"patch preview did not write");
|
|
free(pv);
|
|
}
|
|
|
|
/* move / delete */
|
|
EXPECT(run(&ctx, mk("move_file", "from", "src/notes.txt", "to",
|
|
"docs/notes.md", NULL),
|
|
&r) == 0 &&
|
|
exists("docs/notes.md") && !exists("src/notes.txt"),
|
|
"move creates parent and renames");
|
|
reset(&r);
|
|
wfile("other.txt", "o\n");
|
|
EXPECT(run(&ctx, mk("move_file", "from", "other.txt", "to", "docs/notes.md",
|
|
NULL),
|
|
&r) == 1 &&
|
|
strstr(r.out, "exists"),
|
|
"move refuses to overwrite");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("move_file", "from", "other.txt", "to", "../escape.txt",
|
|
NULL),
|
|
&r) == 1,
|
|
"move outside workspace denied");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("delete_file", "path", "other.txt", NULL), &r) == 0 &&
|
|
!exists("other.txt") && strstr(r.out, "-o"),
|
|
"delete with diff");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("delete_file", "path", "src", NULL), &r) == 1 &&
|
|
strstr(r.out, "directory"),
|
|
"delete refuses directories");
|
|
reset(&r);
|
|
|
|
/* todo_write / ask_user */
|
|
EXPECT(run(&ctx, mk("todo_write", "todos",
|
|
"- [x] read code\n[~] write fix\nrun tests\n", NULL),
|
|
&r) == 0 &&
|
|
strstr(r.out, "1/3 done"),
|
|
"todo_write counts");
|
|
EXPECT(strcmp(g_todo, "[x] read code\n[~] write fix\n[ ] run tests\n") == 0,
|
|
"todo normalized and handed to the agent");
|
|
reset(&r);
|
|
EXPECT(run(&ctx, mk("ask_user", "question", "Proceed?", NULL), &r) == 1 &&
|
|
strstr(r.out, "no interactive user"),
|
|
"ask_user headless");
|
|
reset(&r);
|
|
ctx.ask = ask_yes;
|
|
EXPECT(run(&ctx, mk("ask_user", "question", "Proceed?", NULL), &r) == 0 &&
|
|
strstr(r.out, "yes please"),
|
|
"ask_user answered");
|
|
reset(&r);
|
|
|
|
/* web_fetch refuses non-http schemes without touching the network */
|
|
EXPECT(run(&ctx, mk("web_fetch", "url", "file:///etc/passwd", NULL), &r) == 1,
|
|
"web_fetch rejects file://");
|
|
reset(&r);
|
|
|
|
/* undo targets */
|
|
{
|
|
char paths[CST_TOOL_MAX_TARGETS][1024];
|
|
ctl_tool_call_t c = mk("move_file", "from", "a.txt", "to", "b.txt", NULL);
|
|
EXPECT(cst_tool_targets(&c, paths, CST_TOOL_MAX_TARGETS) == 2 &&
|
|
strcmp(paths[0], "a.txt") == 0 && strcmp(paths[1], "b.txt") == 0,
|
|
"move targets");
|
|
c = mk("apply_patch", "patch",
|
|
"--- a/x.c\n+++ b/x.c\n@@ -1 +1 @@\n-a\n+b\n--- /dev/null\n+++ "
|
|
"b/y.c\n@@ -0,0 +1 @@\n+y\n",
|
|
NULL);
|
|
EXPECT(cst_tool_targets(&c, paths, CST_TOOL_MAX_TARGETS) == 2 &&
|
|
strcmp(paths[0], "x.c") == 0 && strcmp(paths[1], "y.c") == 0,
|
|
"patch targets");
|
|
}
|
|
|
|
char cmd[400];
|
|
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_ws);
|
|
if (system(cmd) != 0)
|
|
fprintf(stderr, "note: cleanup failed\n");
|
|
if (nfail) {
|
|
fprintf(stderr, "test_tools_fs: %d failure(s)\n", nfail);
|
|
return 1;
|
|
}
|
|
printf("test_tools_fs: OK\n");
|
|
return 0;
|
|
}
|