From b3f2864f6a0e3c32dd7a25e259d3ac4f7e93b929 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Thu, 10 Sep 2026 10:38:36 +0300 Subject: [PATCH] skills support --- README.md | 19 ++++++++- main.go | 95 +++++++++++++++++++++++++++++++++++++++++++- main_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 405f65d..e569585 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if - `/cfg [val]` — inspect or update a configuration parameter live (writes to `.bantam.cfg`) - `/model [val]` — alias for `/cfg model` (inspect or set the model) - `/endpoint [val]` — alias for `/cfg endpoint` (inspect or set the API endpoint) + - `/skill` — with no argument, list every skill under the configured skills directory (or report that none are configured); with ` [prompt]`, load `//SKILL.md` (or an absolute path when no skills dir is set) and send its contents prefixed to the optional `prompt` as the next user turn - `/models` — query the `/models` path on the current inference endpoint and print a plain list of supported model IDs, marking the currently configured model with a leading `* ` (Go port) - `!` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port) - `/help` — show all supported commands @@ -117,7 +118,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l 2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size. 3. Prepare a new message list with the system prompt (`role: "system"`). 4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit. -5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/.json` (or `.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg `) or update the config live by writing to `.bantam.cfg` (`/cfg `) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/.json`. +5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/.json` (or `.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg `) or update the config live by writing to `.bantam.cfg` (`/cfg `) and return to step 5. `/model` and `/endpoint` are aliases for `/cfg model` and `/cfg endpoint` respectively and behave the same way. If equal to `/skill` (no argument), list every skill under the configured skills directory (or report that none is configured) and return to step 5. If starting with `/skill`, resolve the skill (a `//SKILL.md` file, or an absolute path to a skill directory or `SKILL.md` file when no skills directory is configured), prefix its contents to the optional remaining text, and send the result as the next user turn (returning to step 5 after the response). If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/.json`. 6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5. ### Agentic loop (`AL(cfg, messages)`) function @@ -156,6 +157,7 @@ If the API rejects the request with an `Invalid assistant message: content or to - `reasoning_effort` (reasoning effort level, forwarded to chat completions API, default `high`) - `bantam_tools_dir` (optional path to a directory of extra shell tools; the Go port appends `"Extra shell tools can be found at "` to the system prompt at startup when set. The `BANTAM_TOOLS_DIR` environment variable overrides this and is checked first; if neither is set, nothing is appended. Note: this is an agent-internal hint, not forwarded to the API.) +- `bantam_skills_dir` (optional path to a directory of reusable *skills*; each skill lives in its own subdirectory as `/SKILL.md`. When set, the Go port appends `"Skills may be discovered and invoked from "` to the system prompt at startup, and the `/skill [prompt]` command loads a skill's `SKILL.md` and runs it as a prompt. The `BANTAM_SKILLS_DIR` environment variable overrides this and is checked first; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead. Note: this is an agent-internal hint, not forwarded to the API.) The Go port also supports SOCKS5 proxying via the `SOCKS_PROXY` (or `socks_proxy`) environment variable (e.g. `SOCKS_PROXY=socks5://127.0.0.1:1080` or `SOCKS_PROXY=127.0.0.1:1080`), falling back to standard `HTTP_PROXY` / `HTTPS_PROXY` environment variables. @@ -223,6 +225,17 @@ The `extras/` directory contains small, dependency-light shell scripts that exte If you keep your own collection of helper scripts, point Bantam at them with the `BANTAM_TOOLS_DIR` environment variable or the `bantam_tools_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`). When either is defined (environment variable taking precedence over the config key), the Go port appends the line `Extra shell tools can be found at ` to the system prompt at startup, so the agent is aware of where to look for them. The `extras/` scripts shipped here are just examples of what such a directory can contain. +## Skills + +A *skill* is a reusable, self-contained instruction bundle the agent can load on demand. Each skill is a directory containing a `SKILL.md` file with the skill's prompt/instructions. Point Bantam at a skills directory with the `BANTAM_SKILLS_DIR` environment variable or the `bantam_skills_dir` key in the config file (`.bantam.cfg` if present, else `model.cfg`); the environment variable takes precedence. When either is set, the Go port appends `Skills may be discovered and invoked from ` to the system prompt at startup, and the agent can run a skill with: + +```text +/skill # list every skill under the configured skills directory +/skill [prompt] # loads //SKILL.md and runs it as a prompt +``` + +A bare `/skill` (no name) lists all skills found under the configured skills directory — a subdirectory is treated as a skill only if it contains a `SKILL.md` file — or reports that no skills directory is configured. The contents of `SKILL.md` are prefixed to the optional `prompt` and sent as the next user turn. If neither `BANTAM_SKILLS_DIR` nor `bantam_skills_dir` is set, `/skill ` accepts an absolute path instead: either a skill directory (e.g. `/abs/path/to/skill`, which resolves to `/abs/path/to/skill/SKILL.md`) or a direct path to a `SKILL.md` file. + ### `extras/websearch` @@ -309,6 +322,10 @@ The default system prompt instructs the agent to respect `AGENTS.md` contents in Set the `BANTAM_TOOLS_DIR` environment variable (or the `bantam_tools_dir` key in the config file — `.bantam.cfg` if present, else `model.cfg`) to a directory containing your helper scripts. When defined, the Go port appends `Extra shell tools can be found at ` to the system prompt at startup, making the agent aware of them. The environment variable takes precedence over the config key; if neither is set, nothing is appended. +### How do I give Bantam reusable skills? + +Set the `BANTAM_SKILLS_DIR` environment variable (or the `bantam_skills_dir` key in the config file — `.bantam.cfg` if present, else `model.cfg`) to a directory where each subdirectory is a skill containing a `SKILL.md` file. When defined, the Go port appends `Skills may be discovered and invoked from ` to the system prompt at startup, and you (or the agent) can run a skill with `/skill [prompt]`. The environment variable takes precedence over the config key; if neither is set, `/skill` accepts an absolute path to a skill directory or `SKILL.md` file instead. + ### Is there any common config place for Bantam? No, loading the config file (`.bantam.cfg` if present, else `model.cfg`) is deliberately only supported from the current working directory. This allows natural separation of configs per project. In case there's no config file inside the project, Bantam will use the `openrouter/free` model from Kilo Code with the temperature 0.7. diff --git a/main.go b/main.go index 3579a4b..275bb95 100644 --- a/main.go +++ b/main.go @@ -56,7 +56,8 @@ type Cfg struct { func internalKey(k string) bool { switch k { case "endpoint", "model", "temperature", "stream", "api_key", "timeout", - "shell_timeout", "max_al_iterations", "color", "context_window": + "shell_timeout", "max_al_iterations", "color", "context_window", + "bantam_tools_dir", "bantam_skills_dir": return true } return false @@ -306,6 +307,18 @@ func toolsDir(cfg *Cfg) string { return "" } +// skillsDir returns the directory Bantam should scan for skills, preferring the +// BANTAM_SKILLS_DIR environment variable, then the bantam_skills_dir config key. +func skillsDir(cfg *Cfg) string { + if v := strings.TrimSpace(os.Getenv("BANTAM_SKILLS_DIR")); v != "" { + return v + } + if v := strings.TrimSpace(cfg.Raw["bantam_skills_dir"]); v != "" { + return v + } + return "" +} + func c(t string, cs ...int) string { if !COL || len(cs) == 0 { return t } s := make([]string, len(cs)) @@ -1801,6 +1814,71 @@ func readLine(prompt string) (string, bool) { } } +// skillPrompt resolves and composes the prompt for the /skill command: it reads +// //SKILL.md (or an absolute path when no skills directory is set) and +// prefixes its contents to the optional user prompt. +func skillPrompt(u string, cfg *Cfg) (string, error) { + rest := strings.TrimSpace(strings.TrimPrefix(u, "/skill")) + if rest == "" { + return "", fmt.Errorf("usage: /skill [prompt]") + } + fields := strings.SplitN(rest, " ", 2) + name := fields[0] + prompt := "" + if len(fields) == 2 { + prompt = strings.TrimSpace(fields[1]) + } + sd := skillsDir(cfg) + var path string + if sd != "" { + path = filepath.Join(sd, name, "SKILL.md") + } else if strings.HasSuffix(name, "SKILL.md") { + path = name // absolute path to the SKILL.md file itself + } else { + path = filepath.Join(name, "SKILL.md") // absolute path to the skill directory + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read skill %q: %v", name, err) + } + content := strings.TrimRight(string(data), "\r\n") + if prompt != "" { + return content + "\n\n" + prompt, nil + } + return content, nil +} + +// listSkills prints every skill found under the configured skills directory, or a +// clear notice when none is configured / none are present. +func listSkills(cfg *Cfg) { + sd := skillsDir(cfg) + if sd == "" { + fmt.Println(c("No skills directory configured (set BANTAM_SKILLS_DIR or bantam_skills_dir).", 33)) + return + } + entries, err := os.ReadDir(sd) + if err != nil { + fmt.Println(c("[skill error: cannot read skills dir: "+err.Error()+"]", 31)) + return + } + var names []string + for _, e := range entries { + if e.IsDir() { + if _, err := os.Stat(filepath.Join(sd, e.Name(), "SKILL.md")); err == nil { + names = append(names, e.Name()) + } + } + } + if len(names) == 0 { + fmt.Println(c("No skills found in "+sd, 33)) + return + } + fmt.Println(c("Available skills in "+sd+":", 1, 36)) + for _, n := range names { + fmt.Println(c(" "+n, 32)) + } +} + func runDirectShell(cmd string, timeout int) { cmd = filterText(strings.TrimSpace(cmd)) if cmd == "" { return } @@ -1844,6 +1922,9 @@ func main() { if td := toolsDir(&cfg); td != "" { sp += "\n\nExtra shell tools can be found at " + td } + if sd := skillsDir(&cfg); sd != "" { + sp += "\n\nSkills may be discovered and invoked from " + sd + } COL = col(cfg) stdin = bufio.NewReader(os.Stdin) msgs := []Message{{Role: "system", Content: strp(sp)}} @@ -1987,6 +2068,17 @@ func main() { fmt.Println(c("Usage: /cfg [val]", 31)) } continue + case strings.HasPrefix(u, "/skill"): + if strings.TrimSpace(strings.TrimPrefix(u, "/skill")) == "" { + listSkills(&cfg) // bare /skill lists available skills + continue + } + composed, err := skillPrompt(u, &cfg) + if err != nil { + fmt.Println(c("[skill error: "+err.Error()+"]", 31)) + continue + } + u = composed // fall through to a normal LLM turn with the composed prompt case u == "/models": ml, err := listModels(&cfg) if err != nil { @@ -2014,6 +2106,7 @@ func main() { {"/endpoint [e]", "alias for /cfg endpoint"}, {"!", "run shell command directly"}, {"/models", "list models at endpoint"}, + {"/skill [p]", "load SKILL.md and run as prompt"}, {"/help", "show help"}, } { fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1]) diff --git a/main_test.go b/main_test.go index 617c732..1638dca 100644 --- a/main_test.go +++ b/main_test.go @@ -2525,3 +2525,111 @@ func TestWriteFileOmittedOverwritesWholeFile(t *testing.T) { } + +// #18: skillsDir mirrors toolsDir, and skillPrompt composes SKILL.md + prompt. +func TestSkills(t *testing.T) { + base := t.TempDir() + skillDir := filepath.Join(base, "skills") + if err := os.MkdirAll(filepath.Join(skillDir, "greet"), 0755); err != nil { + t.Fatal(err) + } + skillMd := "You are a friendly greeter.\n" + if err := os.WriteFile(filepath.Join(skillDir, "greet", "SKILL.md"), []byte(skillMd), 0644); err != nil { + t.Fatal(err) + } + + // skillsDir prefers the env var, then the config key. + t.Setenv("BANTAM_SKILLS_DIR", skillDir) + if got := skillsDir(&Cfg{}); got != skillDir { + t.Errorf("skillsDir() with env = %q, want %q", got, skillDir) + } + t.Setenv("BANTAM_SKILLS_DIR", "") + if got := skillsDir(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); got != skillDir { + t.Errorf("skillsDir() with config = %q, want %q", got, skillDir) + } + + // With a skills dir set, the name is resolved relative to it. + got, err := skillPrompt("/skill greet hello there", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}) + if err != nil { + t.Fatalf("skillPrompt(relative): %v", err) + } + want := skillMd + "\nhello there" + if got != want { + t.Errorf("skillPrompt(relative) = %q, want %q", got, want) + } + + // With no skills dir, the name is an absolute path to the skill directory. + got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet")+" just hi", &Cfg{}) + if err != nil { + t.Fatalf("skillPrompt(absolute): %v", err) + } + if got != skillMd+"\njust hi" { + t.Errorf("skillPrompt(absolute) = %q, want %q", got, skillMd+"\njust hi") + } + + // Absolute path directly to a SKILL.md file also works. + got, err = skillPrompt("/skill "+filepath.Join(skillDir, "greet", "SKILL.md"), &Cfg{}) + if err != nil { + t.Fatalf("skillPrompt(file): %v", err) + } + if got != strings.TrimRight(skillMd, "\n") { + t.Errorf("skillPrompt(file) = %q, want %q", got, strings.TrimRight(skillMd, "\n")) + } + + // Missing skill reports an error; bare /skill reports usage. + if _, err := skillPrompt("/skill nope", &Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}); err == nil { + t.Error("skillPrompt(missing) expected error, got nil") + } + if _, err := skillPrompt("/skill", &Cfg{}); err == nil { + t.Error("skillPrompt(bare) expected error, got nil") + } +} + +// #19: bare /skill lists skills under the configured dir, or reports none. +func TestListSkills(t *testing.T) { + base := t.TempDir() + skillDir := filepath.Join(base, "skills") + if err := os.MkdirAll(filepath.Join(skillDir, "alpha"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(skillDir, "beta"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "alpha", "SKILL.md"), []byte("a"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "beta", "SKILL.md"), []byte("b"), 0644); err != nil { + t.Fatal(err) + } + // a directory without SKILL.md must NOT be reported as a skill + if err := os.MkdirAll(filepath.Join(skillDir, "notaskill"), 0755); err != nil { + t.Fatal(err) + } + + capture := func() string { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + listSkills(&Cfg{Raw: map[string]string{"bantam_skills_dir": skillDir}}) + w.Close() + os.Stdout = old + data, _ := io.ReadAll(r) + return string(data) + } + + out := capture() + if !strings.Contains(out, "alpha") || !strings.Contains(out, "beta") { + t.Errorf("listSkills missing skills, got:\n%s", out) + } + if strings.Contains(out, "notaskill") { + t.Errorf("listSkills reported a non-skill directory, got:\n%s", out) + } + + // No skills dir configured -> clear notice, no panic. + os.Stdout, _ = os.Open(os.DevNull) + listSkills(&Cfg{}) + os.Stdout.Close() +}