From 642c33d0a6629f77d41f6fb8ed39c00112240e00 Mon Sep 17 00:00:00 2001 From: Ilia Sidorenko Date: Fri, 29 May 2026 13:39:47 -0400 Subject: [PATCH] add quiz `sections`, positional ids, projectable `remove`, and `--lang`/`--jq` fixes to `stqry screens` --- CHANGELOG.md | 1 + internal/cli/helpers.go | 20 ++++ internal/cli/root.go | 12 +++ internal/cli/screens.go | 68 ++++++++---- internal/cli/screens_test.go | 147 +++++++++++++++++++++++--- internal/cli/sections_subitem_test.go | 28 +++++ internal/mcp/tools_screens.go | 23 ++-- internal/output/output.go | 11 ++ internal/output/output_test.go | 24 +++++ internal/skills/stqry-reference.md | 18 ++-- 10 files changed, 306 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c71fc..ec9995a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `stqry screens sections` gains quiz authoring and several ergonomic fixes: `add` / `update` can author `quiz_question` / `quiz_score` sections (`--quiz-question-id` / `--quiz-id`, optional `--zoomable`; `update` can re-point an existing section); ` list` (badges / links / media / prices / social / hours) accepts the screen id positionally; `remove` emits a projectable `{"deleted": true, "id": , "screen_id": }` record in machine modes; `--lang` paired with the flat, non-translatable `--name` field now warns on stderr; the `sections add --position ` help/reference now documents that the server inserts at the given slot and shifts existing siblings down (positions stay contiguous, no follow-up `sections reorder` needed); and `--jq` in `--raw` mode emits nothing (instead of literal `null`) for a null result. MCP's `create_section` gained the same quiz support. - The `--lang` whitelist now covers the full set of STQRY-supported content languages, generated from the server's accepted set rather than a hand-maintained subset, so any language the platform accepts is no longer rejected client-side. - `stqry quizzes` — full CRUD for the Quizzes Public API across the quiz → questions → answers hierarchy, with matching MCP tools, shell completion, and reference/coverage docs. Translated fields respect `--lang`, `--question-type` is validated client-side, answers carry a `--correct` flag, and `delete`/`remove` accept `--lang` for per-locale translation deletes. diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index de06377..c7b9f7d 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -23,3 +23,23 @@ func printHumanConfirmation(format string, args ...interface{}) { } fmt.Printf(format, args...) } + +// printDeletion reports a successful delete/remove. In machine modes +// (`--json`, `--quiet`, `--jq`) it emits a small projectable record +// (`{"deleted": true, "id": }`, plus any extra keys) so that +// `... remove --jq '.id'` and `... --jq '.deleted'` project something +// instead of the empty stream that a delete endpoint with a 204/empty body +// otherwise produces. In human mode it prints the friendly confirmation +// line via printHumanConfirmation. Mirrors the rest of the CLI where every +// other verb's `--jq` projects from a response body. +func printDeletion(id string, extra map[string]interface{}, humanFormat string, humanArgs ...interface{}) error { + if flagJSON || flagQuiet || flagJQ != "" { + result := map[string]interface{}{"deleted": true, "id": id} + for k, v := range extra { + result[k] = v + } + return printer.PrintOne(result, nil) + } + printHumanConfirmation(humanFormat, humanArgs...) + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f767272..d50cdab 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -113,6 +113,18 @@ func newRootCmd() *cobra.Command { } } + // 0b. Warn when --lang is combined with --name. `name` on + // collections / screens / media is a flat, non-translated + // label — there's no per-locale slot for it, so --lang is + // silently ignored and the single shared label is + // overwritten. An agent doing a translation pass who treats + // --name like --title clobbers the original instead of adding + // a locale; point them at --title. Stderr only, so + // machine-readable stdout is unaffected. + if flagLang != "" && cmd.Flags().Changed("name") { + fmt.Fprintf(os.Stderr, "warning: --name is a flat, non-translatable field — --lang does not apply to it (it overwrites the single shared label, not a per-locale value). Use --title for translatable per-locale text.\n") + } + // 1. Initialise printer. printer = &output.Printer{JSON: flagJSON, Quiet: flagQuiet, Raw: flagRaw} diff --git a/internal/cli/screens.go b/internal/cli/screens.go index 46d184c..30063dc 100644 --- a/internal/cli/screens.go +++ b/internal/cli/screens.go @@ -594,7 +594,7 @@ func newSectionsGetCmd() *cobra.Command { func newSectionsAddCmd() *cobra.Command { var sectionType, title, subtitle, body, description, textPosition, mapType, displayAddress string - var mediaItemID, position, exposableContent int + var mediaItemID, position, exposableContent, quizQuestionID, quizID int var lat, lng float64 var directionsEnabled, collapsable, autoPlay, zoomable, fullScreen, slideshowEnabled bool @@ -615,21 +615,30 @@ func newSectionsAddCmd() *cobra.Command { stqry screens sections add 42 --type single_media --media-item-id 99 --auto-play # Add a titled gallery section in French - stqry screens sections add 42 --type media_group --title "Galerie" --lang fr`, + stqry screens sections add 42 --type media_group --title "Galerie" --lang fr + + # Embed a quiz question on the screen (the QuizQuestion must already exist) + stqry screens sections add 42 --type quiz_question --quiz-question-id 10930 + + # Show the quiz's final score/result section + stqry screens sections add 42 --type quiz_score --quiz-id 2056`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := validateSectionType(sectionType); err != nil { return err } - // quiz_question and quiz_score are in the StorySection schema oneOf - // (so they stay in validSectionTypes), but the server rejects a - // create call for either with 422 'Subtype Question/Quiz can't be - // blank' because both require a nested Subtype payload that the CLI - // exposes no flags for. Fail client-side with a clear message - // instead of letting the user discover the gap through a confusing - // server-side error. - if sectionType == "quiz_question" || sectionType == "quiz_score" { - return fmt.Errorf("section type %q requires a Subtype (question / quiz) payload that the CLI cannot author yet. Build the quiz screen in STQRY Builder for now and use the CLI for everything else", sectionType) + // quiz_question / quiz_score are Subtype-backed sections: the + // server resolves the subtype from the type and assigns its + // subtype fields (quiz_question → quiz_question_id [+ optional + // zoomable]; quiz_score → quiz_id). Both ids are server-required + // (validates presence). Catch the missing id client-side so the + // user gets a clear pointer instead of a bare `422 Subtype … + // must exist`. + if sectionType == "quiz_question" && !cmd.Flags().Changed("quiz-question-id") { + return fmt.Errorf("--type quiz_question requires --quiz-question-id (the QuizQuestion to embed; create one with `stqry quizzes questions add`)") + } + if sectionType == "quiz_score" && !cmd.Flags().Changed("quiz-id") { + return fmt.Errorf("--type quiz_score requires --quiz-id (the Quiz whose score to show; create one with `stqry quizzes create`)") } if err := validateEnum("text-position", textPosition, validTextPositions); err != nil { return err @@ -700,6 +709,10 @@ func newSectionsAddCmd() *cobra.Command { fields["full_screen"] = fullScreen case "slideshow-enabled": fields["slideshow_enabled"] = slideshowEnabled + case "quiz-question-id": + fields["quiz_question_id"] = quizQuestionID + case "quiz-id": + fields["quiz_id"] = quizID } }) // Location sections need a map_type; default to single_location if lat/lng given without one. @@ -730,13 +743,15 @@ func newSectionsAddCmd() *cobra.Command { cmd.Flags().StringVar(&mapType, "map-type", "", "Map type for location sections (single_location, multiple_locations; default: single_location)") cmd.Flags().StringVar(&displayAddress, "display-address", "", "Display address for location sections") cmd.Flags().BoolVar(&directionsEnabled, "directions-enabled", false, "Enable directions for location sections") - cmd.Flags().IntVar(&position, "position", 0, "Position within the screen (0-based; omit to append to the end). Note: this writes a literal position value — the server does NOT shift existing siblings to make room. If you insert into a screen that already has sections, you may still need a follow-up 'sections reorder' to get the order you want (server-side).") + cmd.Flags().IntVar(&position, "position", 0, "Position within the screen (0-based; omit to append to the end). The server inserts at this slot and shifts existing siblings down, so the new section lands exactly here and positions stay contiguous — no follow-up 'sections reorder' needed.") cmd.Flags().BoolVar(&collapsable, "collapsable", false, "Render the section's body collapsed with a \"read more\" toggle. Pair with --exposable-content to control how much of the body is shown before the toggle. Available on any section that has translatable body content (text, single_media, etc.).") cmd.Flags().IntVar(&exposableContent, "exposable-content", 0, "Percentage of the body content (0–100) shown before the \"read more\" toggle when --collapsable is set. Only meaningful with --collapsable; the server stores it on every section but it has no visible effect when collapsable is false.") cmd.Flags().BoolVar(&autoPlay, "auto-play", false, "Start the attached audio/video automatically on screen entry (single_media sections). For media_group sections, set auto-play per item via 'stqry screens sections media add/update --auto-play'.") cmd.Flags().BoolVar(&zoomable, "zoomable", false, "Let viewers pinch/tap-to-zoom on the section's image (single_media / media_group / quiz_question sections). No effect on text or non-image sections.") cmd.Flags().BoolVar(&fullScreen, "full-screen", false, "Render the section's media full-screen (media_group sections). No effect on other section types.") cmd.Flags().BoolVar(&slideshowEnabled, "slideshow-enabled", false, "Auto-advance through the media items as a slideshow (media_group sections). No effect on other section types.") + cmd.Flags().IntVar(&quizQuestionID, "quiz-question-id", 0, "QuizQuestion ID to embed (required for --type quiz_question). Create the question with 'stqry quizzes questions add'.") + cmd.Flags().IntVar(&quizID, "quiz-id", 0, "Quiz ID whose final score to show (required for --type quiz_score). Create the quiz with 'stqry quizzes create'.") return cmd } @@ -745,7 +760,7 @@ func newSectionsAddCmd() *cobra.Command { func newSectionsUpdateCmd() *cobra.Command { var screenID, title, subtitle, body, description, textPosition, layout string - var mediaItemID, exposableContent int + var mediaItemID, exposableContent, quizQuestionID, quizID int var collapsable, autoPlay, zoomable, fullScreen, slideshowEnabled bool cmd := &cobra.Command{ @@ -814,6 +829,10 @@ func newSectionsUpdateCmd() *cobra.Command { fields["full_screen"] = fullScreen case "slideshow-enabled": fields["slideshow_enabled"] = slideshowEnabled + case "quiz-question-id": + fields["quiz_question_id"] = quizQuestionID + case "quiz-id": + fields["quiz_id"] = quizID } }) @@ -839,6 +858,8 @@ func newSectionsUpdateCmd() *cobra.Command { cmd.Flags().BoolVar(&zoomable, "zoomable", false, "Let viewers pinch/tap-to-zoom on the section's image (single_media / media_group / quiz_question sections).") cmd.Flags().BoolVar(&fullScreen, "full-screen", false, "Render the section's media full-screen (media_group sections).") cmd.Flags().BoolVar(&slideshowEnabled, "slideshow-enabled", false, "Auto-advance through the media items as a slideshow (media_group sections).") + cmd.Flags().IntVar(&quizQuestionID, "quiz-question-id", 0, "Point a quiz_question section at a different QuizQuestion ID.") + cmd.Flags().IntVar(&quizID, "quiz-id", 0, "Point a quiz_score section at a different Quiz ID.") return cmd } @@ -878,11 +899,11 @@ func newSectionsRemoveCmd() *cobra.Command { return err } if lang != "" { - printHumanConfirmation("Removed %s translations from section %s.", lang, args[0]) - } else { - printHumanConfirmation("Removed section %s from screen %s.", args[0], screenID) + return printDeletion(args[0], map[string]interface{}{"language": lang, "screen_id": screenID}, + "Removed %s translations from section %s.", lang, args[0]) } - return nil + return printDeletion(args[0], map[string]interface{}{"screen_id": screenID}, + "Removed section %s from screen %s.", args[0], screenID) }, } @@ -982,11 +1003,20 @@ func newSubItemListCmd(cmdName, apiPath string) *cobra.Command { var screenID, sectionID string cmd := &cobra.Command{ - Use: "list", + Use: "list [screen-id]", Short: fmt.Sprintf("List %s", cmdName), + // Accept the screen id either positionally (`... media list 42 + // --section-id 99`) or via --screen-id. The sibling `sections add` + // verb takes the screen id positionally, so agents naturally try the + // same shape here; previously the positional value was silently + // ignored and the command errored with "--screen-id is required". + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if screenID == "" && len(args) == 1 { + screenID = args[0] + } if screenID == "" { - return fmt.Errorf("--screen-id is required") + return fmt.Errorf("screen id is required: pass it positionally (`... %s list --section-id `) or via --screen-id", cmdName) } if sectionID == "" { return fmt.Errorf("--section-id is required") diff --git a/internal/cli/screens_test.go b/internal/cli/screens_test.go index 0b7a316..5d2be5e 100644 --- a/internal/cli/screens_test.go +++ b/internal/cli/screens_test.go @@ -1283,15 +1283,90 @@ func TestSectionsAddCmdBodyUnderLang(t *testing.T) { } } -// TestScreensSectionsAddQuizGuard asserts that `sections add --type -// quiz_question|quiz_score` fails client-side with a clear message -// instead of letting the server return a confusing 422 -// 'Subtype Question must exist' / 'Subtype Quiz can't be blank' -// Both types stay in `validSectionTypes` -// because they're real StorySection variants in the schema — but the -// CLI can't author the nested Subtype payload yet, so the create -// would be doomed end-to-end. -func TestScreensSectionsAddQuizGuard(t *testing.T) { +// TestScreensCreateLoopTitlesNotDropped pins a regression: a loop of separate +// `screens create --title X-N` invocations must each persist its own title — +// no first-write drop, no shift-by-one across the batch. Each iteration builds +// a fresh root command (a fresh process/client in real use), and we assert the +// POST body's title carries the right value every time, including the first. +func TestScreensCreateLoopTitlesNotDropped(t *testing.T) { + var titles []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&b) + if tm, ok := b["title"].(map[string]interface{}); ok { + if v, ok := tm["en"].(string); ok { + titles = append(titles, v) + } else { + titles = append(titles, "") + } + } else { + titles = append(titles, "") + } + w.WriteHeader(201) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"screen": map[string]interface{}{"id": len(titles)}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + want := []string{"Stop-1", "Stop-2", "Stop-3", "Stop-4"} + for _, title := range want { + cmd := newRootCmd() + cmd.SetArgs([]string{"--lang", "en", "screens", "create", "--type", "story", "--name", title, "--title", title, "--quiet"}) + cmd.SetErr(io.Discard) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("create %q: %v", title, err) + } + } + if len(titles) != len(want) { + t.Fatalf("expected %d creates, got %d (%v)", len(want), len(titles), titles) + } + for i, w := range want { + if titles[i] != w { + t.Errorf("create %d: title sent = %q, want %q (full: %v)", i, titles[i], w, titles) + } + } +} + +// TestSectionsRemoveProjectsWithJQ asserts that `sections remove --jq +// '.id'` projects the deleted section id (synthesized {deleted:true,id}), +// rather than the empty stream the 204 delete body would otherwise yield. +func TestSectionsRemoveProjectsWithJQ(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("unexpected method %s", r.Method) + } + w.WriteHeader(204) + })) + defer server.Close() + setupTestHome(t, server.URL) + + origStdout := os.Stdout + rp, wp, _ := os.Pipe() + os.Stdout = wp + + cmd := newRootCmd() + cmd.SetArgs([]string{"screens", "sections", "remove", "99", "--screen-id=42", "--jq", ".id"}) + cmd.SetErr(io.Discard) + err := cmd.Execute() + wp.Close() + os.Stdout = origStdout + out, _ := io.ReadAll(rp) + rp.Close() + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got := strings.TrimSpace(string(out)); got != `"99"` { + t.Errorf("expected --jq '.id' to project \"99\", got %q", got) + } +} + +// TestScreensSectionsAddQuizRequiresID asserts that `sections add --type +// quiz_question|quiz_score` without the matching subtype id fails +// client-side with a clear pointer, before any HTTP request. The server +// requires quiz_question_id / quiz_id (the subtype validates presence), so +// catching it locally beats a confusing `422 Subtype … must exist`. +func TestScreensSectionsAddQuizRequiresID(t *testing.T) { hits := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ @@ -1301,18 +1376,22 @@ func TestScreensSectionsAddQuizGuard(t *testing.T) { defer server.Close() setupTestHome(t, server.URL) - for _, ty := range []string{"quiz_question", "quiz_score"} { + cases := map[string]string{ + "quiz_question": "--quiz-question-id", + "quiz_score": "--quiz-id", + } + for ty, wantFlag := range cases { cmd := newRootCmd() cmd.SetArgs([]string{"screens", "sections", "add", "42", "--type", ty}) cmd.SetErr(io.Discard) cmd.SetOut(io.Discard) err := cmd.Execute() if err == nil { - t.Errorf("%s: expected client-side guard to error, got nil", ty) + t.Errorf("%s: expected missing-id error, got nil", ty) continue } - if !strings.Contains(err.Error(), "Subtype") { - t.Errorf("%s: error didn't mention Subtype payload; got: %v", ty, err) + if !strings.Contains(err.Error(), wantFlag) { + t.Errorf("%s: error didn't mention %s; got: %v", ty, wantFlag, err) } } if hits != 0 { @@ -1320,6 +1399,48 @@ func TestScreensSectionsAddQuizGuard(t *testing.T) { } } +// TestScreensSectionsAddQuizForwardsIDs asserts that, when the subtype id is +// provided, the create body carries the right subtype fields: +// quiz_question → quiz_question_id (+ zoomable when set); quiz_score → quiz_id. +func TestScreensSectionsAddQuizForwardsIDs(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(201) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"story_section": map[string]interface{}{"id": 1}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + // quiz_question with zoomable + cmd := newRootCmd() + cmd.SetArgs([]string{"screens", "sections", "add", "42", "--type", "quiz_question", "--quiz-question-id", "10930", "--zoomable"}) + cmd.SetErr(io.Discard) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("quiz_question create failed: %v", err) + } + if got, ok := body["quiz_question_id"].(float64); !ok || int(got) != 10930 { + t.Errorf("quiz_question_id not forwarded; body=%v", body) + } + if z, ok := body["zoomable"].(bool); !ok || !z { + t.Errorf("zoomable not forwarded; body=%v", body) + } + + // quiz_score + body = nil + cmd = newRootCmd() + cmd.SetArgs([]string{"screens", "sections", "add", "42", "--type", "quiz_score", "--quiz-id", "2056"}) + cmd.SetErr(io.Discard) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("quiz_score create failed: %v", err) + } + if got, ok := body["quiz_id"].(float64); !ok || int(got) != 2056 { + t.Errorf("quiz_id not forwarded; body=%v", body) + } +} + // TestSectionsAddCmdMediaToggles asserts that --zoomable / --full-screen / // --slideshow-enabled forward as the matching snake_case booleans in the // create body. All three are spec-defined writable fields on the media- diff --git a/internal/cli/sections_subitem_test.go b/internal/cli/sections_subitem_test.go index 6a702b3..1e72331 100644 --- a/internal/cli/sections_subitem_test.go +++ b/internal/cli/sections_subitem_test.go @@ -312,6 +312,34 @@ func TestSectionSubItemReorderInvalidID(t *testing.T) { } } +// TestSectionSubItemListPositionalScreenID asserts that the sub-item `list` +// command accepts the screen id positionally (like `sections add`), not only +// via --screen-id. Previously a positional screen id was silently ignored and +// the command errored "--screen-id is required". +func TestSectionSubItemListPositionalScreenID(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}}) + })) + defer server.Close() + setupTestHome(t, server.URL) + + cmd := newRootCmd() + cmd.SetArgs([]string{ + "screens", "sections", "media", "list", "42", "--section-id=99", + }) + cmd.SetErr(io.Discard) + cmd.SetOut(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute with positional screen id: %v", err) + } + if gotPath != "/api/public/screens/42/story_sections/99/media_items" { + t.Errorf("unexpected path %q", gotPath) + } +} + // TestSectionSubItemAddPosition asserts the new --position flag lands as an // integer in the POST body across every subitem type that supports it. The // generic builder previously passed everything as a string for non-links/ diff --git a/internal/mcp/tools_screens.go b/internal/mcp/tools_screens.go index 0bcd8e3..c6b6cf0 100644 --- a/internal/mcp/tools_screens.go +++ b/internal/mcp/tools_screens.go @@ -274,7 +274,7 @@ func registerSectionCRUD(s *server.MCPServer, sess *Session) { // create_section: creates a new story section for a screen s.AddTool( mcpgo.NewTool("create_section", - mcpgo.WithDescription("Create a new STQRY story section on a screen. Required: type — one of text, single_media, media_group, image_slider, link_group, social_group, location, menu, opening_time_group, price_group, badge_group, form, custom_widget. (quiz_question / quiz_score are real section types but require a Subtype payload this tool cannot author yet — build those in STQRY Builder.)"), + mcpgo.WithDescription("Create a new STQRY story section on a screen. Required: type — one of text, single_media, media_group, image_slider, link_group, social_group, location, menu, opening_time_group, price_group, badge_group, form, custom_widget, quiz_question, quiz_score. For quiz_question include fields.quiz_question_id (the QuizQuestion to embed; optional fields.zoomable); for quiz_score include fields.quiz_id (the Quiz whose score to show)."), mcpgo.WithString("screen_id", mcpgo.Required(), mcpgo.Description("The screen ID"), @@ -294,13 +294,20 @@ func registerSectionCRUD(s *server.MCPServer, sess *Session) { if !ok || fields == nil { return mcpgo.NewToolResultError("fields is required and must be an object"), nil } - // Mirror the CLI's client-side guard: quiz_question / quiz_score - // are real StorySection types but require a nested Subtype payload - // that neither the CLI nor MCP can author today; the server returns - // `422 Subtype … can't be blank`. Fail with a clear message instead. - if ty, _ := fields["type"].(string); ty == "quiz_question" || ty == "quiz_score" { - return mcpgo.NewToolResultError(fmt.Sprintf( - "section type %q requires a Subtype (question / quiz) payload that the CLI/MCP cannot author yet. Build the quiz screen in STQRY Builder for now.", ty)), nil + // quiz_question / quiz_score are Subtype-backed sections: the + // server resolves the subtype and assigns its subtype fields + // (quiz_question → quiz_question_id [+ optional zoomable]; + // quiz_score → quiz_id). Both ids are server-required, so guide + // the caller if the id is missing rather than letting the server + // 422 with `Subtype … must exist`. + if ty, _ := fields["type"].(string); ty == "quiz_question" { + if _, ok := fields["quiz_question_id"]; !ok { + return mcpgo.NewToolResultError("quiz_question sections require fields.quiz_question_id (the QuizQuestion to embed)"), nil + } + } else if ty == "quiz_score" { + if _, ok := fields["quiz_id"]; !ok { + return mcpgo.NewToolResultError("quiz_score sections require fields.quiz_id (the Quiz whose score to show)"), nil + } } client, err := ResolveClient(sess) if err != nil { diff --git a/internal/output/output.go b/internal/output/output.go index cfc8944..9f6160e 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -327,6 +327,17 @@ func applyJQ(w io.Writer, code *gojq.Code, data interface{}, raw bool) error { } continue } + // A null result (e.g. an out-of-range index like + // `.[0].id` on an empty list) emits NOTHING in --raw mode, + // rather than the literal `null` that plain --jq / `jq -r` + // print. This deliberate deviation makes the common + // use-or-create idiom safe: + // ID=$(stqry projects list --jq '.[0].id' -r) + // [ -z "$ID" ] && ID=$(stqry projects create ... --jq '.id' -r) + // Without it, ID="null" and the fallback never fires. + if v == nil { + continue + } } if err := enc.Encode(v); err != nil { return fmt.Errorf("jq: encoding output: %w", err) diff --git a/internal/output/output_test.go b/internal/output/output_test.go index bc5ce7d..c6f59b9 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -159,6 +159,30 @@ func TestApplyJQ_List(t *testing.T) { } } +// TestApplyJQ_RawNullEmpty asserts that in --raw mode a null result (e.g. an +// out-of-range index) emits nothing, so the use-or-create shell idiom +// `X=$(... --jq '.[0].id' -r); [ -z "$X" ] && ...` works. Plain +// --jq (raw=false) still emits the literal `null`. +func TestApplyJQ_RawNullEmpty(t *testing.T) { + empty := []interface{}{} + + var rawBuf bytes.Buffer + if err := applyJQ(&rawBuf, mustCompileJQ(t, ".[0].id"), empty, true); err != nil { + t.Fatalf("applyJQ raw: %v", err) + } + if got := rawBuf.String(); got != "" { + t.Errorf("raw mode: expected empty output for out-of-range index, got %q", got) + } + + var plainBuf bytes.Buffer + if err := applyJQ(&plainBuf, mustCompileJQ(t, ".[0].id"), empty, false); err != nil { + t.Fatalf("applyJQ plain: %v", err) + } + if got := strings.TrimSpace(plainBuf.String()); got != "null" { + t.Errorf("plain mode: expected literal null, got %q", got) + } +} + func TestApplyJQ_RuntimeError(t *testing.T) { // .foo on an array is a jq runtime error rows := []interface{}{1.0, 2.0} diff --git a/internal/skills/stqry-reference.md b/internal/skills/stqry-reference.md index 2fc26cb..91da9fb 100644 --- a/internal/skills/stqry-reference.md +++ b/internal/skills/stqry-reference.md @@ -366,8 +366,8 @@ stqry screens appears-in Show every place this screen # Sections stqry screens sections list [--page ] [--per-page ] stqry screens sections get --screen-id -stqry screens sections add --type [--title ] [--body ] [--media-item-id ] [--position ] [--auto-play] [--collapsable] [--exposable-content <0..100>] -stqry screens sections update --screen-id [--layout ] [--title ] [--body ] [--auto-play] [--collapsable] [--exposable-content <0..100>] ... +stqry screens sections add --type [--title ] [--body ] [--media-item-id ] [--position ] [--auto-play] [--collapsable] [--exposable-content <0..100>] [--quiz-question-id ] [--quiz-id ] +stqry screens sections update --screen-id [--layout ] [--title ] [--body ] [--auto-play] [--collapsable] [--exposable-content <0..100>] [--quiz-question-id ] [--quiz-id ] ... stqry screens sections remove --screen-id [--lang ] Remove a section entirely, or drop only one locale's translations with `--lang`. stqry screens sections reorder ... @@ -379,10 +379,16 @@ stqry screens sections reorder ... # "read more" toggle. # --exposable-content paired with --collapsable: 0-100 % of the body shown before # the toggle. Range-checked client-side. -# --position N insert at a literal 0-based position. The server does NOT -# shift existing siblings to make room, -# so an insert into a populated screen often still needs a -# follow-up `sections reorder`. +# --position N insert at a 0-based position. The server inserts at this +# slot and shifts existing siblings down, so the section +# lands exactly here and positions stay contiguous — no +# follow-up `sections reorder` needed. +# --quiz-question-id N quiz_question sections — the QuizQuestion to embed (required +# for --type quiz_question; optional --zoomable). Build the +# question first with `stqry quizzes questions add`. +# --quiz-id N quiz_score sections — the Quiz whose final score to show +# (required for --type quiz_score). Build it with +# `stqry quizzes create`. # Layouts (pass to `sections update --layout`): # link_group: list, button, icon, list_no_icon, button_no_icon, list_with_icon, button_with_icon, grid_image, wide_image, horizontal_slider