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()
+}