Files
zero/internal/tools/skill.go
Vasanth T 164150633a feat(skills): install a skill's bundled files and point the skill tool at them (#1167)
* feat(skills): install a skill's bundled files and point the skill tool at them

A skill was only its SKILL.md: install copied that one file, and the skill
tool returned only the markdown body, so a skill whose instructions run its
own scripts was broken by installing it, and a hand-placed skill directory
gave the model no way to find the files beside it.

Install now copies the whole skill directory, leaving out hidden entries
(.git, .env) and symlinks, which it neither follows nor copies but reports.
A script stays executable; everything else is installed 0644. A tree over
1,000 files or 32 MiB is refused. skills.lock records a treeHash beside the
SKILL.md hash; skill info reports drift in bundled files, and an entry from
before this is still checked on SKILL.md alone. A reinstall that changed
only a bundled file is reported as an update.

Both skill tools end their output with the skill's directory and a list of
up to 50 bundled files when there are any; a SKILL.md-only skill is
returned unchanged.

Closes #584

* fix(skills): compare a reinstall over an old lock entry with SKILL.md alone

A lock entry written before tree hashes has no treeHash, so the reinstall
compared the new tree hash with an empty string and always counted as an
update. Re-adding an unchanged skill that is only a SKILL.md printed
"Updated" and "the files bundled with it changed" when there were none.

That entry comes from an install that copied SKILL.md alone, so compare
with that: it is an update when SKILL.md changed or the reinstall now
brings bundled files. InstallResult gains previousTreeHash, and the CLI
uses it to say the bundled files are now installed rather than changed
on the first reinstall over such an entry.

* fix(skills): match the tree hash when recovering an interrupted install

lockReconciler matched both trees of an interrupted workspace on the
SKILL.md hash alone. A reinstall that changes only a bundled file keeps
SKILL.md, so a kill between the tree swap and the lockfile publish left
two trees carrying the recorded hash, and recovery kept the new one and
retired the backup the lockfile still described.

A tree now matches an entry with a tree hash only when both hashes
match. An entry from before tree hashes is still matched on SKILL.md
alone. A tree hash that cannot be computed is returned as an error, the
way an unreadable SKILL.md already is, so recovery decides nothing about
a tree it could not read.

Also bring the package comment up to date: install copies the skill
directory, not SKILL.md alone.
2026-10-25 07:24:59 -07:00

82 lines
2.8 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"github.com/Twigpine/zero/internal/skills"
)
// skillTool lets the model pull a reusable instruction "skill" into context on
// demand (PRD F15). It reads the skills directory itself via the internal/skills
// loader and returns the named skill's markdown body as its Output, so the model
// can opt into reusable guidance only when relevant. It is read-only.
type skillTool struct {
baseTool
dir string
}
// NewSkillTool builds the skill tool. An empty dir resolves to the standard
// skills data directory (skills.DefaultDir); pass an explicit dir in tests.
func NewSkillTool(dir string) *skillTool {
if strings.TrimSpace(dir) == "" {
dir = skills.DefaultDir(nil)
}
return &skillTool{
dir: dir,
baseTool: baseTool{
name: "skill",
description: "Load a named Zero skill and return its instructions as the tool output. " +
"Skills are reusable, on-demand instruction sets (e.g. project conventions or confirmation policies). " +
"Call this when a relevant skill exists; an unknown name returns the list of available skills.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"name": {
Type: "string",
Description: "The name of the skill to load.",
},
"skill": {
Type: "string",
Description: "Alias for name; supply either name or skill.",
},
},
// Intentionally no strict Required: the tool needs exactly one of
// name/skill, which Run enforces via aliasedStringArg. Declaring both
// here keeps the alias usable under schema validators that reject
// unknown keys (AdditionalProperties:false).
AdditionalProperties: false,
},
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
},
}
}
// Run loads the named skill and returns its Content. Unknown names return a
// clear error listing the available skill names so the model can self-correct.
func (tool *skillTool) Run(_ context.Context, args map[string]any) Result {
name, err := aliasedStringArg(args, []string{"name", "skill"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for skill: " + err.Error())
}
loaded, err := skills.Load(tool.dir)
if err != nil {
return errorResult("Error: failed to load skills: " + err.Error())
}
if len(loaded) == 0 {
return errorResult(fmt.Sprintf("Error: no skills are available (looked in %s).", tool.dir))
}
names := make([]string, 0, len(loaded))
for _, skill := range loaded {
if skill.Name == name {
return okResult(skills.ToolOutput(skill))
}
names = append(names, skill.Name)
}
return errorResult(fmt.Sprintf("Error: unknown skill %q. Available skills: %s.", name, strings.Join(names, ", ")))
}