227 lines
6.0 KiB
Go
227 lines
6.0 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"`
|
|
ReadOnly bool `json:"readOnly,omitempty"`
|
|
OutputFormat string `json:"outputFormat,omitempty"`
|
|
Cmd string `json:"cmd,omitempty"`
|
|
Sudo bool `json:"sudo,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"`
|
|
ReadOnly bool `json:"readOnly,omitempty"`
|
|
OutputFormat string `json:"outputFormat,omitempty"`
|
|
Cmd string `json:"cmd,omitempty"`
|
|
Sudo bool `json:"sudo,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.ReadOnly {
|
|
resolved.ReadOnly = true
|
|
}
|
|
if v.Sudo {
|
|
resolved.Sudo = 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,
|
|
ReadOnly: resolved.ReadOnly,
|
|
OutputFormat: resolved.OutputFormat,
|
|
}
|
|
}
|
|
|
|
// ResolveTool returns the executable path for a named tool.
|
|
// Resolution order:
|
|
// 1. ~/.config/ollie/tools/<name> (local wrapper wins over system binary)
|
|
// 2. .meta "cmd" field (after variant resolution; absolute path or bare name via $PATH)
|
|
// 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 (after variant resolution).
|
|
if m, err := LoadMetaFile(name); err == nil && m != nil {
|
|
resolved := m.Resolve()
|
|
if resolved != nil && resolved.Cmd != "" {
|
|
// Check tools path for the cmd name first
|
|
cmdLocal := filepath.Join(ToolsPath(), resolved.Cmd)
|
|
if info, err := os.Stat(cmdLocal); err == nil && !info.IsDir() {
|
|
return cmdLocal, nil
|
|
}
|
|
if filepath.IsAbs(resolved.Cmd) {
|
|
return resolved.Cmd, nil
|
|
}
|
|
if p, err := exec.LookPath(resolved.Cmd); err == nil {
|
|
return p, nil
|
|
}
|
|
return "", fmt.Errorf("tool %s: cmd %q not found in PATH", name, resolved.Cmd)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|