mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
feat(skills): built-in extension-author skill, hidden from /skills
The model now ships with a `write-zot-extension` skill compiled
into the binary. When the user asks for help authoring a zot
extension (slash command, LLM tool, audit hook, permission gate)
the model sees the skill in its system-prompt manifest, calls
the `skill` tool to load the body on demand, and walks the user
through the right answer with the wire format, manifest shape,
SDK examples (Go + TS + Python), and dev workflow already in
context. No need for the user to be in the zot repo or to ask
the model to read docs/extensions.md first.
Built-in skills:
- shipped via //go:embed at internal/skills/builtin/
- merged into Discover()'s output AFTER user skills, so a
user-installed skill with the same name shadows the built-in
(drop your own SKILL.md at $ZOT_HOME/skills/write-zot-extension/
to customise)
- marked Builtin: true on the Skill struct
- hidden from user-facing surfaces: VisibleSkills() filters them
so /skills only shows skills the user actually installed or
shipped in their project
The model side stays unchanged: system-prompt manifest still lists
built-ins (so the model knows they exist), the `skill` tool still
loads them on demand. Only the picker is filtered.
Verified live:
prompt: "List the names of the skills you have available"
-> code-review, test-fix, write-zot-extension
prompt: "I want to write a zot extension that adds a slash
command /pwd which inserts the current directory path
into the editor. What language should I use, and what
files do I need to create?"
-> [tool_call] skill({"name":"write-zot-extension"})
-> body returned
-> the model produces a complete extension with the right
manifest, the right hello/register/ready frames, action:
insert correctly chosen, and a remark about cwd capture.
The picker filter has its own unit test
(TestVisibleSkillsHidesBuiltins) and the existing Discover test
was updated to expect the built-in count without hardcoding it.
This commit is contained in:
parent
e425dbba59
commit
2cffe048c9
5 changed files with 474 additions and 4 deletions
|
|
@ -409,10 +409,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
|
|||
Extensions: extMgr,
|
||||
SkillSnapshot: func() []*skills.Skill {
|
||||
// Re-discover so the picker reflects edits made during
|
||||
// the session. Cheap; SKILL.md files are small.
|
||||
// the session. Cheap; SKILL.md files are small. Filter
|
||||
// out built-in skills — they're hidden from user-facing
|
||||
// surfaces because they're implementation detail; the
|
||||
// model still sees them through the system-prompt
|
||||
// manifest and the skill tool.
|
||||
userHome, _ := os.UserHomeDir()
|
||||
list, _ := skills.Discover(ZotHome(), r.CWD, userHome)
|
||||
return list
|
||||
return skills.VisibleSkills(list)
|
||||
},
|
||||
PersistModel: func(providerName, model string) {
|
||||
// Update config.json so next launch uses the same pick.
|
||||
|
|
|
|||
51
internal/skills/builtin.go
Normal file
51
internal/skills/builtin.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// builtinFS holds the SKILL.md files zot ships with the binary.
|
||||
// They appear in the catalogue as ordinary skills — same on-demand
|
||||
// load via the `skill` tool, same /skills picker — but never need
|
||||
// to be installed by the user. A user-installed skill with the same
|
||||
// name shadows the built-in one (Discover's first-match-wins).
|
||||
//
|
||||
//go:embed all:builtin
|
||||
var builtinFS embed.FS
|
||||
|
||||
// loadBuiltins returns every SKILL.md compiled into the binary.
|
||||
// Errors per file are silently dropped: built-ins are part of the
|
||||
// release; if one is malformed it's a release bug we want to surface
|
||||
// in tests, not panic in front of the user.
|
||||
func loadBuiltins() []*Skill {
|
||||
entries, err := fs.ReadDir(builtinFS, "builtin")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []*Skill
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
raw, err := fs.ReadFile(builtinFS, path.Join("builtin", e.Name(), "SKILL.md"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
front, body := splitFrontmatter(string(raw))
|
||||
s := &Skill{
|
||||
Path: "builtin:" + e.Name(),
|
||||
Source: "built-in",
|
||||
Body: strings.TrimSpace(body),
|
||||
Builtin: true,
|
||||
}
|
||||
parseFrontmatter(front, s)
|
||||
if s.Name == "" {
|
||||
s.Name = e.Name()
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
353
internal/skills/builtin/write-zot-extension/SKILL.md
Normal file
353
internal/skills/builtin/write-zot-extension/SKILL.md
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
---
|
||||
name: write-zot-extension
|
||||
description: Help the user create a new zot extension (slash command, LLM tool, or guard) in any language.
|
||||
---
|
||||
|
||||
# Writing a zot extension
|
||||
|
||||
Use this skill when the user asks for help building a zot extension —
|
||||
a new slash command, a new tool the LLM can call, an audit hook, or
|
||||
a permission gate. Skim this whole skill first, then collaborate
|
||||
with the user on the specific extension they want.
|
||||
|
||||
## What an extension is
|
||||
|
||||
A zot extension is **an external executable** that zot launches as a
|
||||
subprocess and talks to over its stdin/stdout in newline-delimited
|
||||
JSON. It can be written in any language that can read/write JSON
|
||||
lines from stdio: Go, TypeScript (via tsx), Python, Rust, shell with
|
||||
jq, anything. Crash isolation is automatic; one bad extension never
|
||||
takes down zot.
|
||||
|
||||
Three things an extension can do (any combination):
|
||||
|
||||
1. **Slash commands** — register `/foo` so the user can run it from
|
||||
the input. The handler returns a "prompt" (submitted to the
|
||||
agent), an "insert" (text dropped into the editor), a "display"
|
||||
(one-shot styled note in the chat), or a "noop".
|
||||
|
||||
2. **Tools** — register tools the LLM itself calls. Schema is
|
||||
JSON Schema; zot routes the model's `tool_call` to the
|
||||
extension's `tool_result`. Same lifecycle as built-in tools
|
||||
(read/write/edit/bash/skill).
|
||||
|
||||
3. **Lifecycle hooks** — subscribe to events
|
||||
(session_start, turn_start, tool_call, turn_end,
|
||||
assistant_message) for telemetry / audit / custom UI, or
|
||||
intercept tool calls before execution to refuse dangerous
|
||||
patterns.
|
||||
|
||||
## On-disk layout
|
||||
|
||||
Each extension lives in its own directory:
|
||||
|
||||
```
|
||||
~/Library/Application Support/zot/extensions/<name>/
|
||||
├── extension.json # manifest (required)
|
||||
└── <executable> # whatever exec points at
|
||||
```
|
||||
|
||||
Or project-local: `<project>/.zot/extensions/<name>/`. Project-local
|
||||
wins on name conflict.
|
||||
|
||||
For ad-hoc use during development, skip the install step entirely
|
||||
and run `zot --ext PATH` (repeatable: `-e PATH -e PATH`).
|
||||
|
||||
### Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "weather",
|
||||
"version": "1.0.0",
|
||||
"exec": "./weather",
|
||||
"args": [],
|
||||
"language": "go",
|
||||
"description": "current weather lookups for any city",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
- `name` (required, unique) — id zot uses internally; matches the
|
||||
hello frame. Slash commands & tools live in the same name space
|
||||
as built-ins; conflicts are silently shadowed by built-ins.
|
||||
- `exec` (required) — the executable path. Resolution:
|
||||
- absolute: as-is
|
||||
- starts with `./` or `../`: relative to the manifest's directory
|
||||
- bare name (no separator): looked up via `$PATH` (e.g. `node`,
|
||||
`python3`, `npx`, `tsx`)
|
||||
- `args` — extra argv passed to `exec` (e.g. `["index.js"]`)
|
||||
- `language` — informational only (`"go"`, `"typescript"`,
|
||||
`"python"`, etc.)
|
||||
- `enabled` — defaults to true; set false to keep installed but skip
|
||||
|
||||
## Wire format
|
||||
|
||||
Newline-delimited JSON in both directions. Top-level `type` is the
|
||||
discriminator. Optional `id` correlates command/tool requests with
|
||||
their responses.
|
||||
|
||||
### Required handshake
|
||||
|
||||
The very first frame the extension sends is `hello`:
|
||||
|
||||
```json
|
||||
{"type":"hello","name":"weather","version":"1.0.0",
|
||||
"capabilities":["commands","tools"]}
|
||||
```
|
||||
|
||||
Capabilities are advisory; current values are `commands`, `tools`,
|
||||
`events`. Send all that apply.
|
||||
|
||||
zot replies with `hello_ack`:
|
||||
|
||||
```json
|
||||
{"type":"hello_ack","protocol_version":1,"zot_version":"0.0.x",
|
||||
"provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
|
||||
```
|
||||
|
||||
### Registration (immediately after hello)
|
||||
|
||||
Send registration frames in any order, then a single `ready`
|
||||
sentinel so zot can finalize the agent's tool registry:
|
||||
|
||||
```json
|
||||
{"type":"register_command","name":"weather","description":"current weather"}
|
||||
{"type":"register_tool","name":"weather","description":"Get current weather for a city.",
|
||||
"schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}
|
||||
{"type":"subscribe","events":["tool_call"],"intercept":["tool_call"]}
|
||||
{"type":"ready"}
|
||||
```
|
||||
|
||||
If you don't send `ready`, zot's idle watchdog auto-treats you as
|
||||
ready after 250ms of no frames, but always send it explicitly when
|
||||
you can — newer extensions on faster hosts shave that 250ms off.
|
||||
|
||||
### Runtime frames
|
||||
|
||||
**zot → extension:**
|
||||
|
||||
```json
|
||||
{"type":"command_invoked","id":"abc","name":"weather","args":"berlin"}
|
||||
{"type":"tool_call","id":"def","name":"weather","args":{"city":"Berlin"}}
|
||||
{"type":"event","event":"turn_start","step":1}
|
||||
{"type":"event_intercept","id":"ghi","event":"tool_call",
|
||||
"tool_name":"bash","tool_args":{"command":"rm -rf /tmp/foo"}}
|
||||
{"type":"shutdown"}
|
||||
```
|
||||
|
||||
**extension → zot (replies + spontaneous notifications):**
|
||||
|
||||
```json
|
||||
{"type":"command_response","id":"abc","action":"prompt",
|
||||
"prompt":"Show today's weather for Berlin in one line."}
|
||||
{"type":"tool_result","id":"def","content":[{"type":"text","text":"Berlin: 16°C, fog"}]}
|
||||
{"type":"event_intercept_response","id":"ghi","block":true,
|
||||
"reason":"refused: command matches the danger pattern \"rm -rf\""}
|
||||
{"type":"notify","level":"info","message":"refreshed cache"}
|
||||
{"type":"shutdown_ack"}
|
||||
```
|
||||
|
||||
`command_response.action` values:
|
||||
- `"prompt"` — submit `prompt` as a fresh user message
|
||||
- `"insert"` — drop `insert` into the editor at the cursor
|
||||
- `"display"` — append `display` to chat as a one-shot note (no
|
||||
model call, not in transcript)
|
||||
- `"noop"` — handled internally; zot doesn't change the UI
|
||||
|
||||
`tool_result.content[]` blocks: `{"type":"text","text":"..."}` or
|
||||
`{"type":"image","mime_type":"image/png","data":"<base64>"}`.
|
||||
|
||||
Per-tool timeout: 60s. Per-intercept timeout: 5s. Missing the
|
||||
intercept timeout is treated as "allow" so an unresponsive guard
|
||||
never stalls the agent.
|
||||
|
||||
## Important rules
|
||||
|
||||
- **stdout is reserved for the protocol.** Anything you print to
|
||||
stdout that isn't a JSON frame breaks the wire. Use stderr for
|
||||
logs / debug output (zot captures stderr to
|
||||
`$ZOT_HOME/logs/ext-<name>.log`).
|
||||
- **One JSON object per line.** No multi-line JSON. Always end
|
||||
every frame with `\n`.
|
||||
- **Flush after writing.** Most stdout writes are line-buffered when
|
||||
piped, which is fine, but explicitly flushing avoids surprise
|
||||
buffering on slow handlers.
|
||||
- Extension processes inherit the user's permissions. A bad
|
||||
extension can do anything the user can.
|
||||
|
||||
## Recommended layout per language
|
||||
|
||||
### Go (use the built-in SDK at pkg/zotext)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/patriceckhart/zot/pkg/zotext"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ext := zotext.New("weather", "1.0.0")
|
||||
|
||||
ext.Command("weather", "current weather for a city",
|
||||
func(args string) zotext.Response {
|
||||
return zotext.Prompt("Tell me the weather for " + args)
|
||||
})
|
||||
|
||||
ext.Tool("weather", "Get current weather for a city.",
|
||||
json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`),
|
||||
func(args json.RawMessage) zotext.ToolResult {
|
||||
var in struct{ City string `json:"city"` }
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return zotext.TextErrorResult("invalid args")
|
||||
}
|
||||
return zotext.TextResult(in.City + ": sunny, 21°C (fake)")
|
||||
})
|
||||
|
||||
if err := ext.Run(); err != nil {
|
||||
ext.Logf("fatal: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Build: `go build -o weather .`
|
||||
|
||||
`extension.json`:
|
||||
```json
|
||||
{"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
|
||||
```
|
||||
|
||||
### TypeScript (no SDK; handles the protocol directly)
|
||||
|
||||
Run via `tsx`, which executes `.ts` files without a build step.
|
||||
|
||||
```json
|
||||
{"name":"scratchpad","version":"1.0.0","exec":"tsx","args":["index.ts"],"language":"typescript","enabled":true}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// index.ts (excerpt; see examples/extensions/scratchpad/index.ts for the full version)
|
||||
import { createInterface } from "node:readline";
|
||||
import { stderr, stdin, stdout } from "node:process";
|
||||
|
||||
function send(o: object) { stdout.write(JSON.stringify(o) + "\n"); }
|
||||
function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
|
||||
|
||||
send({ type: "hello", name: "scratchpad", version: "1.0.0",
|
||||
capabilities: ["commands", "tools"] });
|
||||
send({ type: "register_command", name: "note", description: "append a note" });
|
||||
send({ type: "register_tool", name: "read_notes",
|
||||
description: "Read the user's scratchpad notes.",
|
||||
schema: { type: "object", properties: {} } });
|
||||
send({ type: "ready" });
|
||||
|
||||
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
||||
rl.on("line", (line) => {
|
||||
const f = JSON.parse(line);
|
||||
if (f.type === "command_invoked" && f.name === "note") {
|
||||
send({ type: "command_response", id: f.id, action: "display",
|
||||
display: `noted: ${f.args}` });
|
||||
} else if (f.type === "tool_call" && f.name === "read_notes") {
|
||||
send({ type: "tool_result", id: f.id,
|
||||
content: [{ type: "text", text: "(notes go here)" }] });
|
||||
} else if (f.type === "shutdown") {
|
||||
send({ type: "shutdown_ack" });
|
||||
rl.close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`tsx` install: `npm install -g tsx`. Without global tsx, fall back
|
||||
to `"exec":"npx","args":["--yes","tsx","index.ts"]` (slower
|
||||
startup; npx checks the registry every launch).
|
||||
|
||||
### Python
|
||||
|
||||
```json
|
||||
{"name":"hello-py","version":"1.0.0","exec":"./hello.py","language":"python","enabled":true}
|
||||
```
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json, sys
|
||||
|
||||
def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
|
||||
|
||||
emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
|
||||
emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
|
||||
emit({"type": "ready"})
|
||||
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if msg["type"] == "command_invoked":
|
||||
emit({"type": "command_response", "id": msg["id"],
|
||||
"action": "prompt", "prompt": "Say hi briefly."})
|
||||
elif msg["type"] == "shutdown":
|
||||
emit({"type": "shutdown_ack"})
|
||||
break
|
||||
```
|
||||
|
||||
`chmod +x hello.py`.
|
||||
|
||||
## Install / dev workflow
|
||||
|
||||
```bash
|
||||
zot ext install ./weather # copy into $ZOT_HOME/extensions/
|
||||
zot --ext ./weather # run from disk for one zot session (no install)
|
||||
zot --ext . # cwd is the extension dir
|
||||
zot ext list # show installed extensions
|
||||
zot ext logs weather # cat the extension's stderr
|
||||
zot ext logs weather -f # tail it
|
||||
zot ext disable weather # keep installed but skip on launch
|
||||
zot ext enable weather
|
||||
zot ext remove weather
|
||||
```
|
||||
|
||||
For TS / Python extensions, no build step is needed — edit the source
|
||||
in place and relaunch zot.
|
||||
|
||||
For Go, run `go build -o <name> .` in the extension directory after
|
||||
edits, then `zot ext install` (which copies the manifest + binary)
|
||||
or `zot --ext .` to test from the working tree.
|
||||
|
||||
## Manual debug
|
||||
|
||||
The extension is just a process. Drive it directly with shell pipes
|
||||
to see exactly what's happening on the wire:
|
||||
|
||||
```bash
|
||||
{
|
||||
printf '%s\n' '{"type":"hello_ack","protocol_version":1,"zot_version":"x","provider":"a","model":"o","cwd":"/tmp"}'
|
||||
sleep 0.2
|
||||
printf '%s\n' '{"type":"command_invoked","id":"1","name":"weather","args":"Berlin"}'
|
||||
sleep 0.5
|
||||
printf '%s\n' '{"type":"shutdown"}'
|
||||
} | ./weather
|
||||
```
|
||||
|
||||
Compare what comes out of stdout to the expected wire format. If a
|
||||
frame doesn't match what zot expects, it's discarded silently and
|
||||
logged to `ext-<name>.log`.
|
||||
|
||||
## Process to follow with the user
|
||||
|
||||
1. Ask what the extension should DO. One sentence.
|
||||
2. Pick the right capability:
|
||||
- "I want a slash command that triggers a prompt" → `command` only
|
||||
- "I want the model to be able to do X" → `tool`
|
||||
- "I want to gate / log every bash command" → `event` + `intercept`
|
||||
3. Pick a language. Default to **Go via pkg/zotext** for new
|
||||
extensions if the user has Go installed; **TypeScript via tsx**
|
||||
if they prefer JS-flavored ergonomics; **Python** for one-off
|
||||
scripts.
|
||||
4. Write the extension dir (manifest + source).
|
||||
5. For Go, build it. For TS / Python, mark the script executable.
|
||||
6. Suggest `zot --ext <path>` for testing without committing to an
|
||||
install.
|
||||
7. When happy, `zot ext install <path>`.
|
||||
|
||||
Don't try to write a full SDK or framework on top of the protocol
|
||||
unless the user asked for one — the wire format is small enough
|
||||
that a 30-line raw script is the right answer for most extensions.
|
||||
|
|
@ -54,6 +54,13 @@ type Skill struct {
|
|||
// Shown in the /skills picker.
|
||||
Source string
|
||||
|
||||
// Builtin marks skills that ship inside the zot binary. They are
|
||||
// fully active for the model (system-prompt manifest + skill
|
||||
// tool) but hidden from user-facing surfaces like the /skills
|
||||
// picker so users only see skills they actually installed or
|
||||
// shipped in their project.
|
||||
Builtin bool
|
||||
|
||||
// AllowedTools and Permissions are parsed for forward-
|
||||
// compatibility but NOT enforced in this version. They appear
|
||||
// in the skill body so the model can self-regulate.
|
||||
|
|
@ -61,11 +68,31 @@ type Skill struct {
|
|||
Permissions map[string][]string
|
||||
}
|
||||
|
||||
// VisibleSkills returns the subset of skills users should see in
|
||||
// pickers, /skills, and other interactive surfaces. Built-ins are
|
||||
// hidden because they're implementation detail; the model still
|
||||
// loads them through the system-prompt manifest + the skill tool.
|
||||
func VisibleSkills(in []*Skill) []*Skill {
|
||||
out := make([]*Skill, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s == nil || s.Builtin {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Discover walks every supported location, parses each SKILL.md, and
|
||||
// returns the merged skill set. First-match-wins per name; the order
|
||||
// matches the priority list in the package doc. Errors per skill are
|
||||
// returned alongside the partial result so a single broken file
|
||||
// doesn't suppress the rest.
|
||||
//
|
||||
// Built-in skills (compiled into the zot binary) are added LAST so
|
||||
// any user-installed skill with the same name shadows the built-in.
|
||||
// That lets users customise the help text by dropping their own
|
||||
// SKILL.md with the same name into $ZOT_HOME/skills/<name>/.
|
||||
func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
|
||||
var errs []error
|
||||
seen := map[string]*Skill{}
|
||||
|
|
@ -95,6 +122,14 @@ func Discover(zotHome, cwd, userHome string) ([]*Skill, []error) {
|
|||
seen[s.Name] = s
|
||||
}
|
||||
}
|
||||
// Built-ins fill in any name the user didn't already provide.
|
||||
for _, s := range loadBuiltins() {
|
||||
if _, dup := seen[s.Name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[s.Name] = s
|
||||
}
|
||||
|
||||
out := make([]*Skill, 0, len(seen))
|
||||
for _, s := range seen {
|
||||
out = append(out, s)
|
||||
|
|
|
|||
|
|
@ -67,8 +67,12 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
|
|||
if len(errs) > 0 {
|
||||
t.Fatalf("errs: %v", errs)
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("expected 2 skills, got %d (%v)", len(skills), skills)
|
||||
// Expect the two user skills + every built-in shipped with the
|
||||
// binary (currently the write-zot-extension authoring guide).
|
||||
builtins := loadBuiltins()
|
||||
want := 2 + len(builtins)
|
||||
if len(skills) != want {
|
||||
t.Fatalf("expected %d skills (2 user + %d built-in), got %d (%v)", want, len(builtins), len(skills), skills)
|
||||
}
|
||||
shared := FindByName(skills, "shared")
|
||||
if shared == nil || shared.Description != "project version" {
|
||||
|
|
@ -77,6 +81,29 @@ func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
|
|||
if FindByName(skills, "global-only") == nil {
|
||||
t.Errorf("global-only skill missing")
|
||||
}
|
||||
// At least one built-in should have made it through.
|
||||
for _, b := range builtins {
|
||||
if FindByName(skills, b.Name) == nil {
|
||||
t.Errorf("built-in skill %q missing from Discover output", b.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleSkillsHidesBuiltins(t *testing.T) {
|
||||
in := []*Skill{
|
||||
{Name: "user-one"},
|
||||
{Name: "built-one", Builtin: true},
|
||||
{Name: "user-two"},
|
||||
}
|
||||
out := VisibleSkills(in)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2 visible skills, got %d (%v)", len(out), out)
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.Builtin {
|
||||
t.Errorf("built-in %q leaked into visible set", s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptAddendum(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue