Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

421 lines
13 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
)
var (
callIndex string
callJSON string
callJSONFile string
callArgs []string
callFormat string
callDry bool
callQuiet bool
)
// callDaemonTool is the daemon-tool relay seam. It is indirected through a
// package var so a test can stub the daemon call (and the catalog fetch used
// for name validation) without a running daemon.
var callDaemonTool = requireDaemonTool
var callCmd = &cobra.Command{
Use: "call <tool>",
Short: "Invoke any registered MCP tool by name over the daemon",
Long: `Invokes any tool the daemon's MCP surface registers — the generic escape
hatch when no dedicated CLI verb exists yet. The argument object is assembled
from three layers (last wins per key):
1. a base object from --json-file <path> or --json - (stdin)
2. an inline --json '<obj>' merged over the base
3. one or more --arg key=value merged on top
--arg coercion is deterministic: true/false -> bool, an integer or float ->
number, null -> null, a value starting with [ or { -> parsed JSON, key:=<raw>
forces raw-JSON parse of the right-hand side (so version:="1.0" stays the
string "1.0"), key= -> the empty string, and anything else stays a string.
Repeating a key replaces the earlier value.
Use --dry to print the lowered argument object and the target tool without
calling the daemon (works with no daemon running). Use --format to pick the
wire format the tool renders (json|gcx|toon|text).
Requires a running daemon that tracks the repo (except for --dry).`,
Args: cobra.ExactArgs(1),
SilenceUsage: true, // a tool/daemon error should read cleanly, not dump usage
RunE: runCall,
}
func init() {
callCmd.Flags().StringVar(&callIndex, "index", ".", "repository path the daemon must track")
callCmd.Flags().StringVar(&callIndex, "repo", ".", "alias for --index")
callCmd.Flags().StringVar(&callJSON, "json", "", "base argument object as inline JSON, or \"-\" to read from stdin")
callCmd.Flags().StringVar(&callJSONFile, "json-file", "", "read the base argument object from a JSON file")
callCmd.Flags().StringArrayVar(&callArgs, "arg", nil, "add one key=value argument (repeatable); see help for coercion rules")
callCmd.Flags().StringVar(&callFormat, "format", "json", "output / wire format forwarded to the tool: json|gcx|toon|text")
callCmd.Flags().BoolVar(&callDry, "dry", false, "print the lowered argument object and target tool without calling the daemon")
callCmd.Flags().BoolVar(&callQuiet, "quiet", false, "suppress the stderr note when calling a mutating tool")
rootCmd.AddCommand(callCmd)
}
func runCall(cmd *cobra.Command, args []string) error {
tool := args[0]
// Lower the argument object — pure-local, so --dry never needs a daemon.
argObj, err := lowerCallArgs(cmd, callJSON, callJSONFile, callArgs)
if err != nil {
return err
}
if callDry {
return printCallDry(cmd, tool, argObj)
}
// Best-effort name validation against the live daemon's catalog. When no
// daemon is reachable this is skipped entirely so the call below returns
// the normal daemonRequiredErr instead of a confusing "unknown tool".
if cat, ok := fetchToolCatalog(callIndex); ok {
if !cat.has(tool) {
return unknownToolErr(tool, cat)
}
if cat.mutating(tool) && !callQuiet {
fmt.Fprintf(cmd.ErrOrStderr(), "note: %s writes to your working tree / graph\n", tool)
}
}
// Forward the chosen wire format to the tool. The executor pins
// format=json by default; an explicit format here overrides it.
if callFormat != "" {
argObj["format"] = callFormat
}
raw, err := callDaemonTool(callIndex, tool, argObj)
if err != nil {
return err
}
switch callFormat {
case "gcx", "toon":
// Compact wire formats are printed verbatim — re-indenting would
// corrupt them.
fmt.Fprintln(cmd.OutOrStdout(), strings.TrimRight(string(raw), "\n"))
return nil
default: // json | text
return emitDaemonJSON(cmd, raw)
}
}
// printCallDry prints the lowered argument object (indented JSON) and the
// target tool name without touching the daemon.
func printCallDry(cmd *cobra.Command, tool string, argObj map[string]any) error {
out := cmd.OutOrStdout()
fmt.Fprintf(out, "tool: %s\n", tool)
fmt.Fprintln(out, "arguments:")
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(argObj)
}
// lowerCallArgs assembles the argument object from the three precedence layers:
// the --json-file / --json - base, an inline --json '<obj>' merged over it, and
// the --arg key=value pairs merged on top (last wins per key). It never touches
// the daemon, so the --dry path is fully exercisable offline.
func lowerCallArgs(cmd *cobra.Command, inlineJSON, jsonFile string, kvs []string) (map[string]any, error) {
obj := map[string]any{}
// Layer 1: base from --json-file <path> or --json - (stdin).
if jsonFile != "" {
data, err := os.ReadFile(jsonFile)
if err != nil {
return nil, fmt.Errorf("reading --json-file %s: %w", jsonFile, err)
}
if err := mergeJSONObject(obj, data, "--json-file"); err != nil {
return nil, err
}
}
if inlineJSON == "-" {
data, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("reading --json from stdin: %w", err)
}
if err := mergeJSONObject(obj, data, "--json -"); err != nil {
return nil, err
}
} else if inlineJSON != "" {
// Layer 2: inline base merged over the file/stdin base.
if err := mergeJSONObject(obj, []byte(inlineJSON), "--json"); err != nil {
return nil, err
}
}
// Layer 3: --arg key=value, merged on top (last occurrence of a key wins).
for _, kv := range kvs {
key, val, err := coerceArg(kv)
if err != nil {
return nil, err
}
obj[key] = val
}
return obj, nil
}
// mergeJSONObject decodes data as a JSON object and merges its keys into dst.
// A non-object payload (array, scalar) is rejected — tool arguments are always
// a key/value object.
func mergeJSONObject(dst map[string]any, data []byte, source string) error {
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return fmt.Errorf("%s must be a JSON object: %w", source, err)
}
for k, v := range parsed {
dst[k] = v
}
return nil
}
// coerceArg parses one --arg token into a (key, value) pair with deterministic
// type coercion. The grammar:
//
// key:=<raw> walrus — the right-hand side is parsed as raw JSON (so
// version:="1.0" stays the string "1.0", not a number)
// key=value value is coerced: true/false -> bool, int/float -> number,
// null -> null, a value starting with [ or { -> parsed JSON,
// key= -> empty string, everything else -> string
func coerceArg(token string) (string, any, error) {
// Walrus first: a "key:=rhs" forces raw-JSON parse of the RHS. Detect the
// ":=" boundary before the plain "=" so version:="1.0" is not mistaken for
// a key of "version:" with an "=" value.
if i := strings.Index(token, ":="); i >= 0 {
key := token[:i]
if key == "" {
return "", nil, fmt.Errorf("--arg %q: empty key", token)
}
raw := token[i+2:]
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return "", nil, fmt.Errorf("--arg %q: right-hand side is not valid JSON: %w", token, err)
}
return key, v, nil
}
eq := strings.Index(token, "=")
if eq < 0 {
return "", nil, fmt.Errorf("--arg %q: expected key=value or key:=<json>", token)
}
key := token[:eq]
if key == "" {
return "", nil, fmt.Errorf("--arg %q: empty key", token)
}
val := token[eq+1:]
return key, coerceScalar(val), nil
}
// coerceScalar applies the deterministic --arg value coercion to a bare value:
// true/false -> bool, integer/float -> number, null -> null, a value starting
// with [ or { -> parsed JSON (falling back to the literal string if it does not
// parse), "" -> empty string, everything else -> string.
func coerceScalar(val string) any {
switch val {
case "":
return ""
case "true":
return true
case "false":
return false
case "null":
return nil
}
// A leading [ or { signals an inline JSON array / object.
if val[0] == '[' || val[0] == '{' {
var v any
if err := json.Unmarshal([]byte(val), &v); err == nil {
return v
}
// Not valid JSON — keep the literal string so the value is not lost.
return val
}
// Integers (json.Number-friendly: keep them as float64 like encoding/json
// would, so the wire shape matches a parsed JSON object).
if i, err := strconv.ParseInt(val, 10, 64); err == nil {
return i
}
if f, err := strconv.ParseFloat(val, 64); err == nil {
return f
}
return val
}
// toolCatalog is the subset of the tool_profile response the call command uses
// for best-effort name validation and the mutating-tool note.
type toolCatalog struct {
names map[string]bool
mutators map[string]bool
all []string
}
func (c *toolCatalog) has(name string) bool { return c != nil && c.names[name] }
func (c *toolCatalog) mutating(name string) bool { return c != nil && c.mutators[name] }
// fetchToolCatalog asks the daemon for the full tool_profile and distills it
// into a toolCatalog. The bool result is false when no daemon is reachable (or
// the response is unusable) — the caller then SKIPS validation so the real call
// surfaces the normal daemonRequiredErr.
func fetchToolCatalog(repoPath string) (*toolCatalog, bool) {
raw, err := callDaemonTool(repoPath, "tool_profile", map[string]any{})
if err != nil {
return nil, false
}
var profile struct {
Live []string `json:"live"`
Deferred []string `json:"deferred"`
Descriptors []struct {
Name string `json:"name"`
Mutating bool `json:"mutating"`
} `json:"descriptors"`
}
if err := json.Unmarshal(raw, &profile); err != nil {
return nil, false
}
cat := &toolCatalog{
names: map[string]bool{},
mutators: map[string]bool{},
}
add := func(n string) {
if n == "" || cat.names[n] {
return
}
cat.names[n] = true
cat.all = append(cat.all, n)
}
for _, n := range profile.Live {
add(n)
}
for _, n := range profile.Deferred {
add(n)
}
for _, d := range profile.Descriptors {
add(d.Name)
if d.Mutating {
cat.mutators[d.Name] = true
}
}
if len(cat.names) == 0 {
return nil, false
}
sort.Strings(cat.all)
return cat, true
}
// unknownToolErr builds the actionable error for an unknown tool name: it lists
// the nearest catalog matches (by edit distance, with a substring fallback) and
// points at `gortex tools search`.
func unknownToolErr(tool string, cat *toolCatalog) error {
matches := cat.nearest(tool, 5)
var b strings.Builder
fmt.Fprintf(&b, "unknown tool %q", tool)
if len(matches) > 0 {
fmt.Fprintf(&b, " — did you mean: %s?", strings.Join(matches, ", "))
}
fmt.Fprintf(&b, "\nRun `gortex tools search %s` to find the right tool.", tool)
return fmt.Errorf("%s", b.String())
}
// nearest returns up to n catalog names closest to query: every name that
// contains the query as a substring first (cheap, high-signal), then the
// remaining names ranked by Levenshtein distance.
func (c *toolCatalog) nearest(query string, n int) []string {
if c == nil || len(c.all) == 0 {
return nil
}
q := strings.ToLower(query)
type scored struct {
name string
dist int
sub bool
}
ranked := make([]scored, 0, len(c.all))
for _, name := range c.all {
lower := strings.ToLower(name)
ranked = append(ranked, scored{
name: name,
dist: levenshtein(q, lower),
sub: strings.Contains(lower, q) || strings.Contains(q, lower),
})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].sub != ranked[j].sub {
return ranked[i].sub // substring matches first
}
if ranked[i].dist != ranked[j].dist {
return ranked[i].dist < ranked[j].dist
}
return ranked[i].name < ranked[j].name
})
out := make([]string, 0, n)
for _, s := range ranked {
// Keep substring matches always; otherwise require a reasonable
// distance so we don't suggest wholly unrelated names.
if s.sub || s.dist <= len(q)/2+2 {
out = append(out, s.name)
}
if len(out) >= n {
break
}
}
return out
}
// levenshtein computes the edit distance between a and b with a rolling
// two-row buffer (O(len(a)*len(b)) time, O(len(b)) space).
func levenshtein(a, b string) int {
if a == b {
return 0
}
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}