mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
f5579300fb
* feat(cli,mcp): expose 'collection update' via CLI and MCP catalog (TASK-1510) The HTTP handler at handlers_collections.go::handleUpdateCollection already supported PATCHing a collection's name, icon, description, prefix, schema, settings, and sort_order (plus field-value migrations). The CLI and MCP surfaces never exposed it, so agents couldn't rename collections, swap icons, or reshape schemas — a hard blocker for the adaptive /pad onboard playbook (TASK-1499) which needs to rewrite seeded collections to match each project's actual vocabulary. This wires both agent-facing surfaces to the existing handler: - cmd/pad: new 'pad collection update <slug>' Cobra subcommand with --name / --icon / --description / --prefix / --schema / --fields / --sort-order flags. Only flags explicitly set are sent (uses cmd.Flags().Changed); --schema and --fields reuse the existing collectionSchemaJSONFromFlags helper so DSL parity stays. - internal/mcp/catalog_collection: add 'update' action plus the slug, prefix, and sort_order params on padCollectionTool. - internal/mcp/dispatch_http_routes: new mapCollectionUpdate handles the schema-object-vs-string coercion. The catalog declares schema as a JSON object for MCP ergonomics, but models.CollectionUpdate.Schema is *string — and its UnmarshalJSON only flexes settings, not schema. The mapper re-marshals object input to its JSON-string form before sending, symmetric to what the CLI does via collectionSchemaJSONFromFlags. Tests cover canonical body, schema-object-to-string coercion (round-trip through CollectionUpdate.UnmarshalJSON), schema-string pass-through, empty-field omission, and required-arg validation. catalog_readonly_test bijection + liveCmdhelpDoc fake updated. Parent: PLAN-1496. * fix(mcp): collection update — clear-on-empty + fields DSL parity per Codex review (round 1) Addresses two P2 findings on PR #572: 1. The catalog advertises `icon=""` / `description=""` / `prefix=""` as clear-the-field, and the CLI flag help says the same, but the HTTP mapper filtered empty strings via `v != ""` — leaving MCP HTTP callers unable to clear fields the CLI can. Switched to key-presence semantics for the four string fields so explicit empty strings round-trip to the store (which honors *string("") as "clear"). 2. The catalog advertises `fields OR schema` as mutually exclusive (mirroring `pad collection create`), but the mapper only consumed `schema`. An MCP HTTP request with `fields=...` produced a `{}` PATCH body silently. Extracted the DSL parser to a shared package (internal/collections/dsl.go::ParseFieldsDSL + FieldsDSLToSchemaJSON) so the CLI and the mapper share one parser; mapper now resolves fields-or-schema with the same mutual-exclusion guard the CLI has. Tests added in dispatch_http_routes_extras_test.go: - TestMapCollectionUpdate_EmptyStringClearsField - TestMapCollectionUpdate_AcceptsFieldsDSL (round-trips through models.CollectionSchema to confirm the parsed shape) - TestMapCollectionUpdate_RejectsFieldsAndSchemaTogether cmd/pad/main.go's parseFieldsDSL becomes a one-line alias for collections.ParseFieldsDSL so the CLI's behavior stays identical. Parent: PLAN-1496, fixing PR #572 / TASK-1510. * fix(mcp): collection update — use encodeSchemaForBody + normalize empty schema (round 2) Addresses two more findings from Codex round 2 on PR #572: 1. P2: mapCollectionUpdate bypassed encodeSchemaForBody, so structured schemas didn't get label backfill and string schemas weren't validated before PATCH — diverged from collection create + CLI. Now reuses encodeSchemaForBody (the same encoder collection create uses at dispatch_http_routes.go:418), getting label-backfill via the Title-Case-of-key heuristic and shape validation for free. 2. P3: schema=null or schema="" plus a real fields=... update tripped the mutual-exclusion check. Now normalizes empty inputs as absent BEFORE checking exclusivity, matching the relaxed handling collection create has for optional empty params. Tests: - Renamed TestMapCollectionUpdate_PassesSchemaStringVerbatim to TestMapCollectionUpdate_AcceptsSchemaString — the new property is round-trip parity + label backfill, not verbatim pass-through. - New TestMapCollectionUpdate_EmptySchemaDoesNotBlockFields covers both nil and empty-string schema combined with a real fields value. Parent: PLAN-1496, addressing Codex round 2 on PR #572 / TASK-1510.
80 lines
2.6 KiB
Go
80 lines
2.6 KiB
Go
// Package collections holds collection-shape utilities shared by the
|
|
// CLI, the MCP HTTP dispatcher, and (eventually) any other surface
|
|
// that needs to translate user-friendly DSL forms into the canonical
|
|
// JSON shapes the API consumes.
|
|
//
|
|
// `dsl.go` is the home for the legacy "compact field DSL" parser that
|
|
// originally lived in cmd/pad/main.go. Moved up to this shared package
|
|
// (PR #572 follow-up to Codex finding on TASK-1510) so the MCP HTTP
|
|
// route mapper can accept `fields=...` for `collection update` without
|
|
// reimplementing the parser. The CLI calls the same helper via
|
|
// CollectionSchemaJSONFromDSL; both surfaces now have parity.
|
|
package collections
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// ParseFieldsDSL parses the compact field DSL (key:type[:options];...)
|
|
// into a CollectionSchema. Empty input returns an empty schema with no
|
|
// error.
|
|
//
|
|
// First select field named `status` automatically gets `required=true`
|
|
// and `default=<first option>` — backward-compatible behavior preserved
|
|
// from the original cmd/pad implementation.
|
|
func ParseFieldsDSL(fieldsDSL string) (models.CollectionSchema, error) {
|
|
schema := models.CollectionSchema{}
|
|
if fieldsDSL == "" {
|
|
return schema, nil
|
|
}
|
|
for _, f := range strings.Split(fieldsDSL, ";") {
|
|
f = strings.TrimSpace(f)
|
|
if f == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(f, ":", 3)
|
|
if len(parts) < 2 {
|
|
return schema, fmt.Errorf("invalid field definition: %q (expected key:type[:options])", f)
|
|
}
|
|
fd := models.FieldDef{
|
|
Key: parts[0],
|
|
Label: cases.Title(language.English).String(strings.ReplaceAll(parts[0], "_", " ")),
|
|
Type: parts[1],
|
|
}
|
|
if len(parts) == 3 && parts[2] != "" {
|
|
fd.Options = strings.Split(parts[2], ",")
|
|
}
|
|
if fd.Type == "select" && fd.Key == "status" {
|
|
fd.Required = true
|
|
if len(fd.Options) > 0 {
|
|
fd.Default = fd.Options[0]
|
|
}
|
|
}
|
|
schema.Fields = append(schema.Fields, fd)
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
// FieldsDSLToSchemaJSON parses the DSL and returns the marshaled
|
|
// CollectionSchema JSON string. Empty input returns "{}" — an empty
|
|
// schema. Convenience wrapper for callers that want to feed the
|
|
// result straight into models.CollectionUpdate.Schema (which is a
|
|
// *string).
|
|
func FieldsDSLToSchemaJSON(fieldsDSL string) (string, error) {
|
|
schema, err := ParseFieldsDSL(fieldsDSL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out, err := json.Marshal(schema)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal schema from DSL: %w", err)
|
|
}
|
|
return string(out), nil
|
|
}
|