Usability pass: tool registry, TUI polish, saved keys and settings
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:
+296
@@ -0,0 +1,296 @@
|
||||
/* Per-turn file snapshots under <ws>/.agent/undo/<session>/<tag>/ so the
|
||||
* file changes of the latest turn can be rolled back (/undo). */
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <dirent.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_undo.h"
|
||||
#include "cst_util.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#define UNDO_FILE_MAX (8 * 1024 * 1024)
|
||||
|
||||
static int undo_root(const char *ws, const char *sid, char *out, size_t n) {
|
||||
int k = snprintf(out, n, "%s/.agent/undo/%s", ws, sid && sid[0] ? sid : "_");
|
||||
return k < 0 || (size_t)k >= n ? -1 : 0;
|
||||
}
|
||||
|
||||
/* Workspace-relative, no "..", not absolute: undo may delete these paths. */
|
||||
static int rel_ok(const char *rel) {
|
||||
if (!rel || !rel[0] || rel[0] == '/' || rel[0] == '\\' ||
|
||||
(rel[0] && rel[1] == ':'))
|
||||
return 0;
|
||||
const char *p = rel;
|
||||
while (*p) {
|
||||
size_t n = strcspn(p, "/\\");
|
||||
if (n == 2 && p[0] == '.' && p[1] == '.')
|
||||
return 0;
|
||||
p += n;
|
||||
while (*p == '/' || *p == '\\')
|
||||
p++;
|
||||
}
|
||||
return strchr(rel, '\n') == NULL && strchr(rel, '\t') == NULL;
|
||||
}
|
||||
|
||||
static char *slurp(const char *p, size_t *len, size_t max) {
|
||||
*len = 0;
|
||||
FILE *fp = fopen(p, "rb");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
fseek(fp, 0, SEEK_END);
|
||||
long sz = ftell(fp);
|
||||
if (sz < 0 || (size_t)sz > max) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
rewind(fp);
|
||||
char *b = malloc((size_t)sz + 1);
|
||||
if (!b) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
*len = fread(b, 1, (size_t)sz, fp);
|
||||
b[*len] = 0;
|
||||
fclose(fp);
|
||||
return b;
|
||||
}
|
||||
|
||||
static int put(const char *p, const char *d, size_t n) {
|
||||
FILE *fp = fopen(p, "wb");
|
||||
if (!fp)
|
||||
return -1;
|
||||
int ok = n == 0 || fwrite(d, 1, n, fp) == n;
|
||||
return fclose(fp) == 0 && ok ? 0 : -1;
|
||||
}
|
||||
|
||||
static int manifest_has(const char *mf, const char *rel) {
|
||||
FILE *fp = fopen(mf, "r");
|
||||
if (!fp)
|
||||
return 0;
|
||||
char line[1400];
|
||||
int found = 0;
|
||||
while (!found && fgets(line, sizeof(line), fp)) {
|
||||
char *t2 = strchr(line, '\t');
|
||||
char *t3 = t2 ? strchr(t2 + 1, '\t') : NULL;
|
||||
if (!t3)
|
||||
continue;
|
||||
t3++;
|
||||
size_t n = strlen(t3);
|
||||
if (n && t3[n - 1] == '\n')
|
||||
t3[--n] = 0;
|
||||
found = strcmp(t3, rel) == 0;
|
||||
}
|
||||
fclose(fp);
|
||||
return found;
|
||||
}
|
||||
|
||||
int cst_undo_snapshot(const char *ws, const char *sid, const char *tag,
|
||||
const char *rel) {
|
||||
if (!ws || !tag || !rel_ok(rel))
|
||||
return -1;
|
||||
char full[OS_PATH_MAX];
|
||||
if (cst_sandbox_resolve(ws, rel, full, sizeof(full)) != 0) {
|
||||
/* parent missing: the file is new; record it as absent */
|
||||
snprintf(full, sizeof(full), "%s", "");
|
||||
}
|
||||
char dir[OS_PATH_MAX], mf[OS_PATH_MAX];
|
||||
if (undo_root(ws, sid, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
size_t dl = strlen(dir);
|
||||
int k = snprintf(dir + dl, sizeof(dir) - dl, "/%s", tag);
|
||||
if (k < 0 || (size_t)k >= sizeof(dir) - dl)
|
||||
return -1;
|
||||
if (os_mkdir_p(dir, 0755) != 0)
|
||||
return -1;
|
||||
k = snprintf(mf, sizeof(mf), "%s/MANIFEST", dir);
|
||||
if (k < 0 || (size_t)k >= sizeof(mf))
|
||||
return -1;
|
||||
if (manifest_has(mf, rel))
|
||||
return 0;
|
||||
int idx = 0;
|
||||
{
|
||||
FILE *fp = fopen(mf, "r");
|
||||
if (fp) {
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF)
|
||||
idx += ch == '\n';
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
const char *state = "absent";
|
||||
struct stat st;
|
||||
if (full[0] && stat(full, &st) == 0 && S_ISREG(st.st_mode)) {
|
||||
size_t n = 0;
|
||||
char *data = slurp(full, &n, UNDO_FILE_MAX);
|
||||
if (!data) {
|
||||
state = "toolarge";
|
||||
} else {
|
||||
char bak[OS_PATH_MAX + 16];
|
||||
int bl = snprintf(bak, sizeof(bak), "%s/%d.bak", dir, idx);
|
||||
state = bl > 0 && (size_t)bl < sizeof(bak) && put(bak, data, n) == 0
|
||||
? "file"
|
||||
: "toolarge";
|
||||
free(data);
|
||||
}
|
||||
}
|
||||
FILE *fp = fopen(mf, "a");
|
||||
if (!fp)
|
||||
return -1;
|
||||
fprintf(fp, "%d\t%s\t%s\n", idx, state, rel);
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
static int latest_tag(const char *root, char *tag, size_t n) {
|
||||
DIR *d = opendir(root);
|
||||
if (!d)
|
||||
return -1;
|
||||
struct dirent *de;
|
||||
tag[0] = 0;
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
if (de->d_name[0] == '.')
|
||||
continue;
|
||||
if (strcmp(de->d_name, tag) > 0)
|
||||
snprintf(tag, n, "%s", de->d_name);
|
||||
}
|
||||
closedir(d);
|
||||
return tag[0] ? 0 : -1;
|
||||
}
|
||||
|
||||
static void rm_dir(const char *dir) {
|
||||
DIR *d = opendir(dir);
|
||||
if (d) {
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
|
||||
continue;
|
||||
char p[OS_PATH_MAX];
|
||||
if (snprintf(p, sizeof(p), "%s/%s", dir, de->d_name) < (int)sizeof(p))
|
||||
unlink(p);
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
int cst_undo_count(const char *ws, const char *sid) {
|
||||
char root[OS_PATH_MAX];
|
||||
if (undo_root(ws, sid, root, sizeof(root)) != 0)
|
||||
return 0;
|
||||
DIR *d = opendir(root);
|
||||
if (!d)
|
||||
return 0;
|
||||
int n = 0;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(d)) != NULL)
|
||||
n += de->d_name[0] != '.';
|
||||
closedir(d);
|
||||
return n;
|
||||
}
|
||||
|
||||
int cst_undo_restore_last(const char *ws, const char *sid, char *report,
|
||||
size_t report_sz) {
|
||||
report[0] = 0;
|
||||
char root[OS_PATH_MAX], tag[256], dir[OS_PATH_MAX], mf[OS_PATH_MAX];
|
||||
if (undo_root(ws, sid, root, sizeof(root)) != 0 ||
|
||||
latest_tag(root, tag, sizeof(tag)) != 0) {
|
||||
snprintf(report, report_sz, "nothing to undo");
|
||||
return -1;
|
||||
}
|
||||
if (snprintf(dir, sizeof(dir), "%s/%s", root, tag) >= (int)sizeof(dir) ||
|
||||
snprintf(mf, sizeof(mf), "%s/MANIFEST", dir) >= (int)sizeof(mf)) {
|
||||
snprintf(report, report_sz, "undo path too long");
|
||||
return -1;
|
||||
}
|
||||
FILE *fp = fopen(mf, "r");
|
||||
cst_buf_t rep = {0};
|
||||
int restored = 0, failed = 0;
|
||||
if (fp) {
|
||||
char line[1400];
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
size_t n = strlen(line);
|
||||
if (n && line[n - 1] == '\n')
|
||||
line[--n] = 0;
|
||||
char *t2 = strchr(line, '\t');
|
||||
char *t3 = t2 ? strchr(t2 + 1, '\t') : NULL;
|
||||
if (!t3)
|
||||
continue;
|
||||
*t2++ = 0;
|
||||
*t3++ = 0;
|
||||
const char *rel = t3, *state = t2;
|
||||
char full[OS_PATH_MAX];
|
||||
struct stat lst;
|
||||
if (!rel_ok(rel) || os_path_join(full, sizeof(full), ws, rel) != 0)
|
||||
continue;
|
||||
if (lstat(full, &lst) == 0 && S_ISLNK(lst.st_mode)) {
|
||||
cst_buf_printf(&rep, "skipped %s (now a symlink)\n", rel);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(state, "file") == 0) {
|
||||
char bak[OS_PATH_MAX];
|
||||
if (snprintf(bak, sizeof(bak), "%s/%s.bak", dir, line) >=
|
||||
(int)sizeof(bak))
|
||||
bak[0] = 0;
|
||||
size_t len = 0;
|
||||
char *data = slurp(bak, &len, UNDO_FILE_MAX);
|
||||
char parent[OS_PATH_MAX];
|
||||
snprintf(parent, sizeof(parent), "%s", full);
|
||||
char *sl = strrchr(parent, '/');
|
||||
if (sl) {
|
||||
*sl = 0;
|
||||
os_mkdir_p(parent, 0755);
|
||||
}
|
||||
if (data && put(full, data, len) == 0) {
|
||||
cst_buf_printf(&rep, "restored %s\n", rel);
|
||||
restored++;
|
||||
} else {
|
||||
cst_buf_printf(&rep, "FAILED to restore %s\n", rel);
|
||||
failed++;
|
||||
}
|
||||
free(data);
|
||||
} else if (strcmp(state, "absent") == 0) {
|
||||
if (unlink(full) == 0) {
|
||||
cst_buf_printf(&rep, "removed %s (did not exist before)\n", rel);
|
||||
restored++;
|
||||
}
|
||||
} else {
|
||||
cst_buf_printf(&rep, "skipped %s (too large to snapshot)\n", rel);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
rm_dir(dir);
|
||||
if (!rep.buf)
|
||||
cst_buf_puts(&rep, "nothing changed\n");
|
||||
snprintf(report, report_sz, "%s", rep.buf);
|
||||
cst_buf_free(&rep);
|
||||
return failed && !restored ? -1 : 0;
|
||||
}
|
||||
#else
|
||||
int cst_undo_count(const char *ws, const char *sid) {
|
||||
(void)ws;
|
||||
(void)sid;
|
||||
return 0;
|
||||
}
|
||||
int cst_undo_restore_last(const char *ws, const char *sid, char *report,
|
||||
size_t report_sz) {
|
||||
(void)ws;
|
||||
(void)sid;
|
||||
snprintf(report, report_sz, "undo is not available on Windows");
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user