ollie/toolsrv/meta.go

217 lines
5.7 KiB
Go

package toolsrv
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// MetaFile is the JSON structure of a .meta sidecar file.
type MetaFile struct {
Description string `json:"description"`
Prompt string `json:"prompt,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
Tier string `json:"tier,omitempty"`
Scope string `json:"scope,omitempty"`
OutputFormat string `json:"outputFormat,omitempty"`
Cmd string `json:"cmd,omitempty"`
ResetsCounter bool `json:"resetsCounter,omitempty"`
Variants []Variant `json:"variants,omitempty"`
}
// Variant is a conditional tool definition. The first variant whose
// match conditions are all satisfied determines the tool's schema,
// documentation, and executable.
type Variant struct {
Match map[string]any `json:"match"`
Description string `json:"description,omitempty"`
Prompt string `json:"prompt,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
Tier string `json:"tier,omitempty"`
Scope string `json:"scope,omitempty"`
OutputFormat string `json:"outputFormat,omitempty"`
Cmd string `json:"cmd,omitempty"`
ResetsCounter bool `json:"resetsCounter,omitempty"`
}
// LoadMetaFile reads and parses a .meta sidecar file for the given tool name.
// Returns nil if the .meta file does not exist.
func LoadMetaFile(name string) (*MetaFile, error) {
path := filepath.Join(ToolsPath(), name+".meta")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var m MetaFile
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
// Resolve applies variant matching to a MetaFile. If variants are present,
// the first matching variant's fields override the top-level fields.
// Returns nil if variants are present but none match (tool unavailable).
func (m *MetaFile) Resolve() *MetaFile {
if len(m.Variants) == 0 {
return m
}
for _, v := range m.Variants {
if matchesMet(v.Match) {
// Merge: variant fields override top-level where set.
resolved := *m
resolved.Variants = nil
if v.Description != "" {
resolved.Description = v.Description
}
if v.Prompt != "" {
resolved.Prompt = v.Prompt
}
if v.Args != nil {
resolved.Args = v.Args
}
if v.Tier != "" {
resolved.Tier = v.Tier
}
if v.OutputFormat != "" {
resolved.OutputFormat = v.OutputFormat
}
if v.Scope != "" {
resolved.Scope = v.Scope
}
if v.ResetsCounter {
resolved.ResetsCounter = true
}
if v.Cmd != "" {
resolved.Cmd = v.Cmd
}
return &resolved
}
}
// No variant matched — tool not available on this host.
return nil
}
// matchesMet returns true if all conditions in the match map are satisfied.
// Supported keys: binary, file, os, arch, env, nenv.
// Values can be a string (single check) or []string (any must pass = OR).
func matchesMet(m map[string]any) bool {
for key, raw := range m {
vals := toStrings(raw)
if !anyMatch(key, vals) {
return false
}
}
return true
}
func toStrings(v any) []string {
switch val := v.(type) {
case string:
return []string{val}
case []any:
out := make([]string, 0, len(val))
for _, item := range val {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
func anyMatch(key string, vals []string) bool {
for _, val := range vals {
if checkCondition(key, val) {
return true
}
}
return false
}
func checkCondition(key, val string) bool {
switch key {
case "binary":
if filepath.IsAbs(val) {
_, err := os.Stat(val)
return err == nil
}
_, err := exec.LookPath(val)
return err == nil
case "file":
_, err := os.Stat(val)
return err == nil
case "os":
return runtime.GOOS == val
case "arch":
return runtime.GOARCH == val
case "env":
return os.Getenv(val) != ""
case "nenv":
return os.Getenv(val) == ""
}
return false
}
// ToolInfoFromMeta builds a ToolInfo from a MetaFile.
// Applies variant resolution first. Returns zero ToolInfo if no variant matches.
func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo {
resolved := m.Resolve()
if resolved == nil {
return ToolInfo{}
}
args := resolved.Args
if args == nil {
args = json.RawMessage(`{"type":"object","properties":{}}`)
}
tier := resolved.Tier
if tier == "" {
tier = "hot"
}
return ToolInfo{
Name: name,
Description: resolved.Description,
InputSchema: args,
Prompt: resolved.Prompt,
Tier: tier,
Scope: resolved.Scope,
OutputFormat: resolved.OutputFormat,
ResetsCounter: resolved.ResetsCounter,
}
}
// ResolveTool returns the command string for a named tool.
// Resolution order:
// 1. ~/.config/ollie/tools/<name> (local script/binary)
// 2. .meta "cmd" field (returned as-is; it's a shell command string)
// 3. exec.LookPath(<name>) as final fallback
func ResolveTool(name string) (string, error) {
// Local tools path always wins — allows wrappers to shadow system binaries.
local := filepath.Join(ToolsPath(), name)
if info, err := os.Stat(local); err == nil && !info.IsDir() {
return local, nil
}
// Check .meta cmd field — return as-is (shell command string, not a path to resolve).
if m, err := LoadMetaFile(name); err == nil && m != nil {
resolved := m.Resolve()
if resolved != nil && resolved.Cmd != "" {
return resolved.Cmd, nil
}
}
// Final fallback: maybe name itself is in PATH.
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
return "", fmt.Errorf("tool %s: executable not found", name)
}