Usability pass: tool registry, TUI polish, saved keys and settings
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s

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.
This commit is contained in:
WHC
2026-10-05 01:47:01 +08:00
parent 9877ff48b6
commit 2fde898da0
105 changed files with 19348 additions and 3786 deletions
+372
View File
@@ -0,0 +1,372 @@
/* 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;
}