mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-20 01:25:20 +00:00
9657051e43
Item responses carry implementation_notes and decision_log in TWO places: 1. Top-level arrays on the item (item.ImplementationNotes, item.DecisionLog) — populated by hydrateItemComputedMetadata. 2. Inside the stringified `fields` blob — written there by AppendImplementationNote / AppendDecisionLogEntry at write time. This duplicate forces agents to dedup or pick a source; the bug report's recommendation was to keep the top-level arrays as the canonical shape and drop the embed. Path-consistent with BUG-991 path A (also MCP-only normalization): extend the boundary normalizer in packageJSONResult so that AFTER fields is parsed (BUG-991), implementation_notes and decision_log keys are dropped from the parsed fields object. The top-level arrays continue to surface unchanged. Server / web / CLI keep their existing behavior — fields still carries the embed at rest, hydration still extracts to top-level. The boundary fix is the cheap clean-up; a write-side migration (stop persisting into the fields blob, plus a one-shot data migration to clean existing items) is the architecturally proper fix and remains tracked in BUG-992's notes. Tests: - internal/mcp/bug992_test.go: stripDuplicatedFieldsKeys helper (strips both keys, no-op when absent, defensive on non-object inputs) + end-to-end packageJSONResult cases for single-item and array-style responses. Parent: BUG-992.
550 lines
20 KiB
Go
550 lines
20 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/cmdhelp"
|
|
)
|
|
|
|
// Dispatcher executes a pad CLI command on behalf of a tool call. It
|
|
// always returns a *mcp.CallToolResult — error paths set IsError on
|
|
// the result so MCP clients see structured stderr without having to
|
|
// distinguish protocol errors from tool errors.
|
|
//
|
|
// Two implementations ship:
|
|
//
|
|
// - ExecDispatcher (this file) — shells out to the pad binary with
|
|
// cliArgs. Used by `pad mcp serve` for local stdio MCP, where the
|
|
// subprocess inherits the user's credentials from `~/.pad/credentials.json`.
|
|
// - HTTPHandlerDispatcher (dispatch_http.go) — calls pad-cloud's
|
|
// HTTP handlers in-process with the requesting user attached to
|
|
// the request context. Used by the future `/mcp` endpoint
|
|
// (PLAN-943 TASK-950) where the dispatcher serves multiple OAuth
|
|
// users from a single process and can't safely shell out.
|
|
//
|
|
// Both consume the same cliArgs (built by BuildCLIArgs) so the
|
|
// registry stays transport-agnostic. The HTTP dispatcher additionally
|
|
// reads the original JSON input map via DispatchInputFromContext so it
|
|
// doesn't have to reverse-parse cliArgs back to typed values; the
|
|
// registry attaches that input via WithDispatchInput before calling
|
|
// Dispatch.
|
|
type Dispatcher interface {
|
|
Dispatch(ctx context.Context, cmdPath []string, cliArgs []string) (*mcp.CallToolResult, error)
|
|
}
|
|
|
|
// dispatchInputKey is the unexported context-key type used to forward
|
|
// the original MCP tool-call JSON arguments from the registry to a
|
|
// dispatcher implementation. Unexported so callers can only set/read
|
|
// it via WithDispatchInput / DispatchInputFromContext, which keeps the
|
|
// type discipline obvious.
|
|
type dispatchInputKey struct{}
|
|
|
|
// WithDispatchInput returns ctx decorated with the original MCP tool-
|
|
// call JSON input map. Called from the registry's tool handler before
|
|
// invoking Dispatch. ExecDispatcher ignores it (it dispatches via
|
|
// cliArgs); HTTPHandlerDispatcher reads it to build a structured HTTP
|
|
// body without reverse-parsing CLI flags.
|
|
func WithDispatchInput(ctx context.Context, input map[string]any) context.Context {
|
|
if input == nil {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, dispatchInputKey{}, input)
|
|
}
|
|
|
|
// DispatchInputFromContext returns the original MCP tool-call JSON
|
|
// input attached by WithDispatchInput, or nil if none. Safe to call
|
|
// on any context.
|
|
func DispatchInputFromContext(ctx context.Context) map[string]any {
|
|
v, _ := ctx.Value(dispatchInputKey{}).(map[string]any)
|
|
return v
|
|
}
|
|
|
|
// mergeDispatchInput returns a copy of the user-supplied MCP input
|
|
// augmented with the same default injections BuildCLIArgs applies —
|
|
// session workspace, root flags — so the dispatcher receives the
|
|
// effective input rather than a partial map. Pure function (no
|
|
// mutation of `input`); safe to call with a nil input map.
|
|
//
|
|
// HTTPHandlerDispatcher reads the merged map via
|
|
// DispatchInputFromContext to build a structured request body without
|
|
// reverse-parsing CLI flags. ExecDispatcher ignores it (it dispatches
|
|
// via cliArgs).
|
|
func mergeDispatchInput(input map[string]any, sessionWorkspace string, rootFlags map[string]string) map[string]any {
|
|
out := make(map[string]any, len(input)+len(rootFlags)+1)
|
|
for k, v := range input {
|
|
out[k] = v
|
|
}
|
|
if _, ok := out["workspace"]; !ok && sessionWorkspace != "" {
|
|
out["workspace"] = sessionWorkspace
|
|
}
|
|
for k, v := range rootFlags {
|
|
if v == "" {
|
|
continue
|
|
}
|
|
// MCP property names are snake_case (TASK-964) — translate
|
|
// rootFlags' kebab-case keys before checking collision.
|
|
propName := MCPPropertyName(k)
|
|
if _, ok := out[propName]; ok {
|
|
continue
|
|
}
|
|
out[propName] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ExecDispatcher shells out to the pad binary at Binary. stdout is
|
|
// returned as Text content; if it parses as JSON the result is also
|
|
// surfaced via StructuredContent so MCP clients can consume it
|
|
// natively. Non-zero exit returns an IsError-flagged result with a
|
|
// structured ErrorEnvelope (TASK-973) — stderr is classified into a
|
|
// closed-set ErrorCode so agents can branch on the code rather than
|
|
// parsing free-form text.
|
|
type ExecDispatcher struct {
|
|
// Binary is the path to the pad executable. Required.
|
|
Binary string
|
|
|
|
// RootArgs are pre-formatted root-flag tokens (e.g. ["--url", X])
|
|
// to forward to every spawned subprocess. Same data as the
|
|
// rootFlags map carried by RegistryOptions but pre-flattened so
|
|
// the dispatcher doesn't re-iterate it on every Dispatch call.
|
|
// Used by the WorkspaceLister side-channel — when classifyExecError
|
|
// needs to populate available_workspaces, it spawns
|
|
// `pad workspace list` with these flags so it hits the same
|
|
// endpoint as the original failed call.
|
|
RootArgs []string
|
|
}
|
|
|
|
// Dispatch runs `<Binary> <cmdPath...> <cliArgs...>` and packages the
|
|
// output for an MCP client.
|
|
func (d *ExecDispatcher) Dispatch(ctx context.Context, cmdPath []string, cliArgs []string) (*mcp.CallToolResult, error) {
|
|
if d.Binary == "" {
|
|
return mcp.NewToolResultError("dispatcher: binary path not configured"), nil
|
|
}
|
|
full := append(append([]string{}, cmdPath...), cliArgs...)
|
|
cmd := exec.CommandContext(ctx, d.Binary, full...)
|
|
var stdout, stderr strings.Builder
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
// Classify into the structured envelope. WorkspaceLister
|
|
// uses the same dispatcher (recursive in spirit but a fresh
|
|
// subprocess) to enrich no_workspace / unknown_workspace
|
|
// errors with available_workspaces.
|
|
return classifyExecError(ctx, cmdPath, err, stderr.String(), d), nil
|
|
}
|
|
out := stdout.String()
|
|
// If stdout is JSON, surface it as structured content alongside
|
|
// the text fallback. This is what `--format json` emits for most
|
|
// pad commands; clients that parse structured content (Claude
|
|
// Desktop, Cursor) get richer rendering.
|
|
return packageJSONResult(out), nil
|
|
}
|
|
|
|
// packageJSONResult converts a CLI stdout string into an MCP tool
|
|
// result. JSON bodies surface as structuredContent; non-JSON falls
|
|
// back to text. Top-level arrays are wrapped in `{items: [...]}` so
|
|
// MCP host validators (Claude Desktop) accept the structured shape —
|
|
// without the wrap they reject the tool call with "expected: record"
|
|
// (BUG-985 bug 3).
|
|
//
|
|
// Item-shaped objects also get their `fields` and `tags` properties
|
|
// parsed from JSON-encoded strings into native objects/arrays
|
|
// (BUG-991, path A). Server / web / CLI keep their existing
|
|
// stringified contract; the MCP boundary normalizes for agents.
|
|
//
|
|
// Shared between ExecDispatcher and HTTPHandlerDispatcher's
|
|
// packageHTTPResponse so both transports produce the same wire
|
|
// shape.
|
|
func packageJSONResult(out string) *mcp.CallToolResult {
|
|
trimmed := strings.TrimSpace(out)
|
|
if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
|
|
return mcp.NewToolResultText(out)
|
|
}
|
|
var parsed any
|
|
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
|
return mcp.NewToolResultText(out)
|
|
}
|
|
parsed = normalizeStringEncodedJSONFields(parsed)
|
|
if arr, ok := parsed.([]any); ok {
|
|
// Wrap top-level arrays. MCP host validators (Claude Desktop)
|
|
// require `structuredContent` to be an object/record, not an
|
|
// array. The original JSON text is still returned as the text
|
|
// fallback so clients that don't parse structured content keep
|
|
// seeing the raw shape they used to.
|
|
return mcp.NewToolResultStructured(map[string]any{"items": arr}, out)
|
|
}
|
|
return mcp.NewToolResultStructured(parsed, out)
|
|
}
|
|
|
|
// normalizeStringEncodedJSONFields recursively walks a parsed JSON
|
|
// value, finds string-typed `fields` and `tags` properties, parses
|
|
// them as embedded JSON, and substitutes the native object/array.
|
|
// Additionally drops `implementation_notes` and `decision_log` from
|
|
// the parsed fields object — those entries are duplicates of the
|
|
// top-level arrays the server hydrates onto each item (BUG-992).
|
|
//
|
|
// Why: the server-side `models.Item` carries `Fields` and `Tags` as
|
|
// JSON-string columns (a SQLite-era choice that propagated through
|
|
// the API contract). For agents going through MCP this means a
|
|
// double-encode every read — the agent has to JSON.parse the field
|
|
// before doing anything useful. Normalizing at the MCP boundary
|
|
// gives agents clean JSON without coordinating a server / web / CLI
|
|
// migration (which is the bigger BUG-991 path B).
|
|
//
|
|
// The walk handles every common item shape:
|
|
// - Single-item responses (top-level item object).
|
|
// - Item arrays (item list, dashboard.active_items, comment lists).
|
|
// - Nested items (dashboard.recent_activity[].item, parent_*).
|
|
//
|
|
// Strings that don't parse as JSON pass through unchanged — covers
|
|
// hand-written field values that happen to share a key name.
|
|
func normalizeStringEncodedJSONFields(v any) any {
|
|
switch x := v.(type) {
|
|
case map[string]any:
|
|
for k, val := range x {
|
|
if s, ok := val.(string); ok && (k == "fields" || k == "tags") {
|
|
if parsed, ok := tryParseEmbeddedJSON(s); ok {
|
|
if k == "fields" {
|
|
parsed = stripDuplicatedFieldsKeys(parsed)
|
|
}
|
|
x[k] = parsed
|
|
continue
|
|
}
|
|
}
|
|
x[k] = normalizeStringEncodedJSONFields(val)
|
|
}
|
|
return x
|
|
case []any:
|
|
for i, val := range x {
|
|
x[i] = normalizeStringEncodedJSONFields(val)
|
|
}
|
|
return x
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// stripDuplicatedFieldsKeys removes implementation_notes and
|
|
// decision_log from a parsed fields object. The server hydrates both
|
|
// onto items as top-level arrays (item.ImplementationNotes,
|
|
// item.DecisionLog) by extracting them from the fields blob — so
|
|
// every response that carries the fields blob ALSO carries the
|
|
// arrays as top-level. Surfacing them inside fields too is a
|
|
// duplicate that bloats payloads and forces agents to dedupe.
|
|
//
|
|
// Per BUG-992 the top-level arrays are the canonical shape; the
|
|
// fields embed is a write-side artifact of how AppendImplementation*
|
|
// stores data. Stripping at the MCP boundary keeps the wire shape
|
|
// clean without coordinating a write-side migration.
|
|
//
|
|
// Returns the input unchanged when it isn't an object — defensive
|
|
// against hand-written fields values that aren't shaped as objects
|
|
// (rare but plausible if someone stuffs a primitive into the column).
|
|
func stripDuplicatedFieldsKeys(parsed any) any {
|
|
m, ok := parsed.(map[string]any)
|
|
if !ok {
|
|
return parsed
|
|
}
|
|
for _, dup := range []string{"implementation_notes", "decision_log"} {
|
|
delete(m, dup)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// tryParseEmbeddedJSON attempts to parse s as JSON, returning the
|
|
// native value when it succeeds. Returns (nil, false) when the
|
|
// string is empty, doesn't start with `{` or `[`, or fails to
|
|
// unmarshal — leaves the caller free to keep the original string.
|
|
//
|
|
// We require the leading-brace check before attempting Unmarshal so
|
|
// hand-written values that happen to be quoted-but-numeric or
|
|
// quoted-but-bare strings don't get spuriously coerced into Go
|
|
// values agents weren't expecting.
|
|
func tryParseEmbeddedJSON(s string) (any, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil, false
|
|
}
|
|
if !strings.HasPrefix(s, "{") && !strings.HasPrefix(s, "[") {
|
|
return nil, false
|
|
}
|
|
var out any
|
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// ListWorkspaces satisfies WorkspaceLister so error helpers can
|
|
// populate available_workspaces hints. Best-effort: if listing fails
|
|
// (no auth, network down, etc.) the caller treats an error as "no
|
|
// listing available" and surfaces the bare error envelope.
|
|
//
|
|
// Implementation note: spawns a fresh `pad workspace list --format
|
|
// json` subprocess. Adds one extra exec per error path that needs the
|
|
// hint — acceptable cost given how rare these errors are. Honors
|
|
// RootArgs so the listing hits the same server endpoint as the
|
|
// originally-failed call (e.g. --url for non-default servers).
|
|
func (d *ExecDispatcher) ListWorkspaces(ctx context.Context) ([]WorkspaceHint, error) {
|
|
if d.Binary == "" {
|
|
return nil, errors.New("dispatcher: binary path not configured")
|
|
}
|
|
args := []string{"workspace", "list", "--format", "json"}
|
|
args = append(args, d.RootArgs...)
|
|
cmd := exec.CommandContext(ctx, d.Binary, args...)
|
|
var stdout, stderr strings.Builder
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("workspace list: %s", strings.TrimSpace(stderr.String()))
|
|
}
|
|
return parseWorkspaceListJSON(stdout.String())
|
|
}
|
|
|
|
// parseWorkspaceListJSON decodes the JSON shape `pad workspace list
|
|
// --format json` emits into the WorkspaceHint slice. Lenient about
|
|
// optional fields — only `slug` is required; everything else gets
|
|
// zero values when absent.
|
|
func parseWorkspaceListJSON(body string) ([]WorkspaceHint, error) {
|
|
body = strings.TrimSpace(body)
|
|
if body == "" || body == "null" {
|
|
return nil, nil
|
|
}
|
|
var raw []map[string]any
|
|
if err := json.Unmarshal([]byte(body), &raw); err != nil {
|
|
return nil, fmt.Errorf("decode workspace list: %w", err)
|
|
}
|
|
out := make([]WorkspaceHint, 0, len(raw))
|
|
for _, w := range raw {
|
|
slug, _ := w["slug"].(string)
|
|
if slug == "" {
|
|
continue
|
|
}
|
|
name, _ := w["name"].(string)
|
|
isDefault, _ := w["default"].(bool)
|
|
out = append(out, WorkspaceHint{Slug: slug, Name: name, Default: isDefault})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// BuildCLIArgs translates an MCP tool call's JSON arguments into the
|
|
// CLI argument list that should be appended after the command path.
|
|
// Pure function — no side effects, no subprocess — so it tests cleanly.
|
|
//
|
|
// Behaviour:
|
|
// - cmdInfo.Args are emitted as positional arguments in their
|
|
// declared order. Required args missing from input return an error.
|
|
// - Repeatable args expand each element into a separate positional.
|
|
// - cmdInfo.Flags are emitted as `--flag value` pairs in
|
|
// alphabetical order (deterministic for tests). Bool flags are
|
|
// presence-only (`--flag`, never `--flag=true`).
|
|
// - Repeatable / slice-typed flags expand to repeated `--flag value`
|
|
// pairs.
|
|
// - Flags listed in flagsHiddenFromMCP (e.g. `stdin`) are dropped
|
|
// defensively — they reference subprocess stdin which the MCP
|
|
// transport doesn't pipe through.
|
|
// - sessionWorkspace is appended as `--workspace <ws>` if the
|
|
// command does NOT already define a `workspace` flag in the
|
|
// input map AND sessionWorkspace is non-empty.
|
|
// - rootFlags (e.g. `--url` captured at server startup) are
|
|
// appended when the input doesn't already supply them. Empty
|
|
// values in rootFlags are skipped.
|
|
// - `--format json` is appended unless the input explicitly sets a
|
|
// format flag (e.g. an agent specifically requests markdown).
|
|
func BuildCLIArgs(
|
|
cmdInfo cmdhelp.Command,
|
|
input map[string]any,
|
|
sessionWorkspace string,
|
|
rootFlags map[string]string,
|
|
) ([]string, error) {
|
|
if input == nil {
|
|
input = map[string]any{}
|
|
}
|
|
|
|
var positionals []string
|
|
for _, arg := range cmdInfo.Args {
|
|
// MCP property names are snake_case (TASK-964) — look up the
|
|
// translated form so kebab-case CLI args (rare for positionals
|
|
// but possible in custom commands) round-trip correctly.
|
|
propName := MCPPropertyName(arg.Name)
|
|
v, ok := input[propName]
|
|
if !ok {
|
|
if arg.Required {
|
|
return nil, fmt.Errorf("missing required argument %q", arg.Name)
|
|
}
|
|
continue
|
|
}
|
|
if arg.Repeatable {
|
|
items, err := toStringSlice(v)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("argument %q: %w", arg.Name, err)
|
|
}
|
|
positionals = append(positionals, items...)
|
|
} else {
|
|
positionals = append(positionals, fmt.Sprint(v))
|
|
}
|
|
}
|
|
|
|
// Sorted flag order makes test assertions stable.
|
|
flagNames := make([]string, 0, len(cmdInfo.Flags))
|
|
for name := range cmdInfo.Flags {
|
|
flagNames = append(flagNames, name)
|
|
}
|
|
sort.Strings(flagNames)
|
|
|
|
formatProvided := false
|
|
workspaceProvided := false
|
|
var flagArgs []string
|
|
for _, name := range flagNames {
|
|
// Defensive: even if an agent's stale schema cache passes
|
|
// stdin=true, drop it here — buildTool already hides it from
|
|
// new schemas, and the running subprocess can't read agent stdin.
|
|
if _, hidden := flagsHiddenFromMCP[name]; hidden {
|
|
continue
|
|
}
|
|
flag := cmdInfo.Flags[name]
|
|
// Input map keys are MCP property names (snake_case per TASK-964),
|
|
// while `name` is the CLI flag name (kebab-case for compound
|
|
// flags like --due-date). Translate when reading the input;
|
|
// continue to emit the kebab-case form on the CLI.
|
|
propName := MCPPropertyName(name)
|
|
val, ok := input[propName]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name == "format" {
|
|
formatProvided = true
|
|
}
|
|
if name == "workspace" {
|
|
workspaceProvided = true
|
|
}
|
|
switch flag.Type {
|
|
case "bool":
|
|
b, err := toBool(val)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("flag %q: %w", name, err)
|
|
}
|
|
if b {
|
|
flagArgs = append(flagArgs, "--"+name)
|
|
}
|
|
default:
|
|
if isSliceFlag(flag) {
|
|
items, err := toStringSlice(val)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("flag %q: %w", name, err)
|
|
}
|
|
for _, item := range items {
|
|
flagArgs = append(flagArgs, "--"+name, item)
|
|
}
|
|
continue
|
|
}
|
|
flagArgs = append(flagArgs, "--"+name, fmt.Sprint(val))
|
|
}
|
|
}
|
|
|
|
out := append(positionals, flagArgs...)
|
|
|
|
// Inject root-level flags (e.g. --url) when the input didn't
|
|
// already supply them. Sorted for deterministic test output.
|
|
// rootFlags keys are CLI form (kebab-case if compound); input keys
|
|
// are MCP form (snake_case per TASK-964) — translate before the
|
|
// collision check so we don't double-emit a flag the agent already
|
|
// passed.
|
|
if len(rootFlags) > 0 {
|
|
rootNames := make([]string, 0, len(rootFlags))
|
|
for name := range rootFlags {
|
|
rootNames = append(rootNames, name)
|
|
}
|
|
sort.Strings(rootNames)
|
|
for _, name := range rootNames {
|
|
val := rootFlags[name]
|
|
if val == "" {
|
|
continue
|
|
}
|
|
if _, alreadyInInput := input[MCPPropertyName(name)]; alreadyInInput {
|
|
continue
|
|
}
|
|
out = append(out, "--"+name, val)
|
|
}
|
|
}
|
|
|
|
// `--workspace` is a persistent root flag on cobra (registered
|
|
// globally via rootCmd, not on individual leaf commands), so it
|
|
// never appears in cmdInfo.Flags and the per-flag iteration above
|
|
// can't see it. Honor it here directly: prefer explicit
|
|
// input["workspace"], fall back to sessionWorkspace.
|
|
//
|
|
// Without this branch, BUG-985 reproduced: agents passing
|
|
// `workspace=docapp` explicitly got no_workspace errors because
|
|
// the flag was silently dropped — only the session-default
|
|
// fallback below ever ran, which itself only fires when no
|
|
// explicit value is set.
|
|
if !workspaceProvided {
|
|
if explicit, _ := input["workspace"].(string); explicit != "" {
|
|
out = append(out, "--workspace", explicit)
|
|
} else if sessionWorkspace != "" {
|
|
out = append(out, "--workspace", sessionWorkspace)
|
|
}
|
|
}
|
|
if !formatProvided {
|
|
out = append(out, "--format", "json")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// isSliceFlag is true when a flag should be emitted as repeated
|
|
// `--flag value` pairs rather than a single `--flag value`. Covers
|
|
// both pflag's slice types and cmdhelp's `repeatable` annotation.
|
|
func isSliceFlag(f cmdhelp.Flag) bool {
|
|
if f.Repeatable {
|
|
return true
|
|
}
|
|
switch f.Type {
|
|
case "[]string", "stringSlice", "[]int", "intSlice", "stringArray":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func toStringSlice(v any) ([]string, error) {
|
|
switch t := v.(type) {
|
|
case []string:
|
|
return t, nil
|
|
case []any:
|
|
out := make([]string, 0, len(t))
|
|
for _, e := range t {
|
|
out = append(out, fmt.Sprint(e))
|
|
}
|
|
return out, nil
|
|
case string:
|
|
return []string{t}, nil
|
|
}
|
|
return nil, fmt.Errorf("expected array or string, got %T", v)
|
|
}
|
|
|
|
func toBool(v any) (bool, error) {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t, nil
|
|
case string:
|
|
switch t {
|
|
case "true", "1", "yes":
|
|
return true, nil
|
|
case "false", "0", "no", "":
|
|
return false, nil
|
|
}
|
|
case json.Number:
|
|
s := t.String()
|
|
return s != "" && s != "0", nil
|
|
}
|
|
return false, fmt.Errorf("expected bool, got %T", v)
|
|
}
|