Tenderned — Printing Press CLI Prerequisites: Install the CLI This skill drives the binary. You must verify the CLI is installed before invoking any command from this skill. If it is missing, install it first: 1. Install via the Printing Press installer: 2. Verify: 3. Ensure (or ) is on . If the install fails before this CLI has a public-library category, install Node or use the category-specific Go fallback after publish. If reports "command not found" after install, the install step did not put the binary on . Do not proceed with skill commands until verification succeeds. Every Dutch publi…

|| next == '`' {\n\t\t\t\t\t\ti++\n\t\t\t\t\t\tcur = append(cur, next)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcur = append(cur, r)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tcur = append(cur, r)\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tcur = append(cur, r)\n\t\t\t}\n\t\tcase stateSingle:\n\t\t\tif r == '\\'' {\n\t\t\t\tstate = stateNormal\n\t\t\t} else {\n\t\t\t\tcur = append(cur, r)\n\t\t\t}\n\t\t}\n\t}\n\tflush()\n\treturn tokens\n}\n\n// RunCLICommand executes the companion CLI while preserving stdout as the\n// machine-readable channel. Stderr is included only in error text so post-run\n// telemetry or quota output cannot corrupt JSON results.\nfunc RunCLICommand(ctx context.Context, binPath string, args []string) (string, error) {\n\tcmd := exec.CommandContext(ctx, binPath, args...)\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\tif err := cmd.Run(); err != nil {\n\t\tmsg := strings.TrimSpace(stderr.String())\n\t\tif msg == \"\" {\n\t\t\tmsg = strings.TrimSpace(stdout.String())\n\t\t}\n\t\tif msg != \"\" {\n\t\t\tlabel := \"stderr\"\n\t\t\tif strings.TrimSpace(stderr.String()) == \"\" {\n\t\t\t\tlabel = \"output\"\n\t\t\t}\n\t\t\treturn stdout.String(), fmt.Errorf(\"cli %s: %w (%s: %s)\", binPath, err, label, msg)\n\t\t}\n\t\treturn stdout.String(), fmt.Errorf(\"cli %s: %w\", binPath, err)\n\t}\n\treturn stdout.String(), nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":5734,"content_sha256":"fc707345bb7d83842ba056184c3bb777ddaa93f45b770c05bfb5e54259749dc9"},{"filename":"internal/mcp/cobratree/typemap.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n)\n\nvar positionalPattern = regexp.MustCompile(`(?:^|\\s)(?:\u003c[^>]+>|\\[[^\\]]+\\])`)\n\nfunc toolOptionsForFlags(cmd *cobra.Command) []mcplib.ToolOption {\n\tvar opts []mcplib.ToolOption\n\tseen := map[string]bool{}\n\taddFlag := func(flag *pflag.Flag) {\n\t\tif flag == nil || flag.Hidden || flag.Deprecated != \"\" {\n\t\t\treturn\n\t\t}\n\t\tif seen[flag.Name] {\n\t\t\treturn\n\t\t}\n\t\tseen[flag.Name] = true\n\t\topts = append(opts, toolOptionForFlag(flag))\n\t}\n\tcmd.InheritedFlags().VisitAll(addFlag)\n\tcmd.NonInheritedFlags().VisitAll(addFlag)\n\treturn opts\n}\n\nfunc toolOptionForFlag(flag *pflag.Flag) mcplib.ToolOption {\n\tpropOpts := []mcplib.PropertyOption{mcplib.Description(flagDescription(flag))}\n\tif isRequired(flag) {\n\t\tpropOpts = append(propOpts, mcplib.Required())\n\t}\n\tswitch flag.Value.Type() {\n\tcase \"bool\":\n\t\treturn mcplib.WithBoolean(flag.Name, propOpts...)\n\tcase \"int\", \"int8\", \"int16\", \"int32\", \"int64\",\n\t\t\"uint\", \"uint8\", \"uint16\", \"uint32\", \"uint64\",\n\t\t\"float32\", \"float64\", \"count\":\n\t\treturn mcplib.WithNumber(flag.Name, propOpts...)\n\tcase \"string\", \"stringSlice\", \"stringArray\", \"duration\":\n\t\treturn mcplib.WithString(flag.Name, propOpts...)\n\tdefault:\n\t\tpropOpts[0] = mcplib.Description(flagDescription(flag) + \" (unknown Cobra flag type \" + flag.Value.Type() + \"; pass as a string)\")\n\t\treturn mcplib.WithString(flag.Name, propOpts...)\n\t}\n}\n\nfunc flagDescription(flag *pflag.Flag) string {\n\tusage := strings.TrimSpace(flag.Usage)\n\tif usage == \"\" {\n\t\tusage = \"Value for --\" + flag.Name\n\t}\n\tif flag.DefValue != \"\" && flag.DefValue != \"[]\" {\n\t\tusage += \" (default: \" + flag.DefValue + \")\"\n\t}\n\treturn usage\n}\n\nfunc isRequired(flag *pflag.Flag) bool {\n\tif flag == nil || flag.Annotations == nil {\n\t\treturn false\n\t}\n\t_, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]\n\treturn ok\n}\n\nfunc commandTakesArgs(cmd *cobra.Command) bool {\n\treturn positionalPattern.MatchString(cmd.Use)\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2196,"content_sha256":"dbb6e0ec4bc5ca975c7b109caa376cb11889ce995711ffdb3ec1a431ba0e715e"},{"filename":"internal/mcp/cobratree/walker.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage cobratree\n\nimport (\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/mark3labs/mcp-go/server\"\n\t\"github.com/spf13/cobra\"\n)\n\n// RegisterAll walks root's user-facing Cobra commands and registers shell-out\n// MCP tools for commands that are not already covered by typed endpoint tools.\nfunc RegisterAll(s *server.MCPServer, root *cobra.Command, cliPath func() (string, error)) {\n\tif root == nil {\n\t\treturn\n\t}\n\twalk(root, nil, func(cmd *cobra.Command, path []string) {\n\t\tswitch classify(cmd) {\n\t\tcase commandHidden:\n\t\t\treturn\n\t\tcase commandEndpoint, commandFramework:\n\t\t\treturn\n\t\t}\n\t\tif !cmd.Runnable() {\n\t\t\treturn\n\t\t}\n\n\t\ttoolName := toolNameForPath(path)\n\t\tif toolName == \"\" {\n\t\t\treturn\n\t\t}\n\t\toptions := []mcplib.ToolOption{mcplib.WithDescription(descriptionFor(cmd))}\n\t\toptions = append(options, toolOptionsForFlags(cmd)...)\n\t\tif commandTakesArgs(cmd) {\n\t\t\toptions = append(options, mcplib.WithString(\"args\", mcplib.Description(\"Additional positional arguments or raw CLI flags to append to the command.\")))\n\t\t}\n\t\tif isMCPReadOnly(cmd) {\n\t\t\toptions = append(options, mcplib.WithReadOnlyHintAnnotation(true), mcplib.WithDestructiveHintAnnotation(false))\n\t\t}\n\t\ts.AddTool(mcplib.NewTool(toolName, options...), shellOutToCLI(cliPath, path))\n\t})\n}\n\nfunc walk(cmd *cobra.Command, path []string, visit func(*cobra.Command, []string)) {\n\tfor _, child := range cmd.Commands() {\n\t\tif child.Hidden || isMCPHidden(child) {\n\t\t\tcontinue\n\t\t}\n\t\tchildPath := append(append([]string{}, path...), child.Name())\n\t\tvisit(child, childPath)\n\t\tif kind := classify(child); kind != commandHidden && kind != commandFramework {\n\t\t\twalk(child, childPath, visit)\n\t\t}\n\t}\n}\n\nfunc descriptionFor(cmd *cobra.Command) string {\n\tif cmd.Long != \"\" {\n\t\treturn cmd.Long\n\t}\n\tif cmd.Short != \"\" {\n\t\treturn cmd.Short\n\t}\n\treturn \"Run `\" + cmd.CommandPath() + \"` through the companion CLI binary.\"\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2055,"content_sha256":"ea9f2adf8f1752f4149a53dab9182d5fa2135f8d4f7dad185f3a6119de92aec1"},{"filename":"internal/mcp/tools_test.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage mcp\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\n// TestValidateReadOnlyQuery_AllowsSelectAndWITH pins the contract: the MCP\n// sql tool's allowlist accepts SELECT and WITH-prefix queries, including\n// CTEs, mixed case, leading whitespace, leading SQL comments, and leading\n// statement separators. SELECT-form CTEs (\"WITH x AS (SELECT ...) SELECT\")\n// must work because novel CLI sql commands in the public library accept\n// them as legitimate read-only queries; the MCP surface keeps parity.\nfunc TestValidateReadOnlyQuery_AllowsSelectAndWITH(t *testing.T) {\n\tallowed := []string{\n\t\t\"SELECT 1\",\n\t\t\"select * from resources\",\n\t\t\" SELECT 1\",\n\t\t\"\\tSELECT 1\",\n\t\t\"\\nSELECT 1\",\n\t\t\";SELECT 1\",\n\t\t\"-- comment\\nSELECT 1\",\n\t\t\"/* comment */ SELECT 1\",\n\t\t\"/* comment */SELECT 1\",\n\t\t\"/**/SELECT 1\",\n\t\t\"-- one\\n-- two\\nSELECT 1\",\n\t\t\"/* a *//* b */ SELECT 1\",\n\t\t\"WITH r AS (SELECT 1) SELECT * FROM r\",\n\t\t\"with r as (select 1) select * from r\",\n\t}\n\tfor _, q := range allowed {\n\t\tif err := validateReadOnlyQuery(q); err != nil {\n\t\t\tt.Errorf(\"validateReadOnlyQuery(%q) = %v, want nil\", q, err)\n\t\t}\n\t}\n}\n\n// TestValidateReadOnlyQuery_RejectsBypassVectors covers the comment-prefix\n// bypass class that defeated the earlier prefix-blocklist gate. mode=ro on\n// modernc.org/sqlite does not block VACUUM INTO (writes a fresh file) or\n// ATTACH DATABASE (opens a separate writable handle), so the gate is the\n// only defense against those vectors. A successful bypass at this layer\n// would let an MCP-trusting agent silently exfiltrate the local database.\nfunc TestValidateReadOnlyQuery_RejectsBypassVectors(t *testing.T) {\n\trejected := []string{\n\t\t\"VACUUM INTO '/tmp/x.db'\",\n\t\t\"ATTACH DATABASE 'file:/tmp/x.db?mode=rwc' AS evil\",\n\t\t\"INSERT INTO resources VALUES ('x', 'y', '{}')\",\n\t\t\"UPDATE resources SET resource_type = 'evil'\",\n\t\t\"DELETE FROM resources\",\n\t\t\"REPLACE INTO resources VALUES ('seed', 'evil', '{}')\",\n\t\t\"DROP TABLE resources\",\n\t\t\"PRAGMA writable_schema = ON\",\n\t\t\"REINDEX\",\n\t\t\"DETACH DATABASE x\",\n\t\t\"/* x */ VACUUM INTO '/tmp/exfil.db'\",\n\t\t\"/* x */VACUUM INTO '/tmp/exfil.db'\",\n\t\t\"-- x\\nVACUUM INTO '/tmp/exfil.db'\",\n\t\t\"/**/VACUUM INTO '/tmp/exfil.db'\",\n\t\t\"/* x */ ATTACH DATABASE 'file:/tmp/x.db?mode=rwc' AS evil\",\n\t\t\"-- x\\nATTACH DATABASE '/tmp/x.db' AS evil\",\n\t\t\";VACUUM INTO '/tmp/x.db'\",\n\t\t\"; ; VACUUM INTO '/tmp/x.db'\",\n\t\t\"/* a */ /* b */ INSERT INTO t VALUES (1)\",\n\t\t\"/* outer /* not nested */ */ SELECT 1\", // SQLite doesn't nest, so trailing \"*/\" closes; second SELECT remains. Reject — the gate must err on the side of caution when the leading shape is suspicious.\n\t\t\"-- only a comment\",\n\t\t\"/* only a comment */\",\n\t\t\"\",\n\t\t\" \",\n\t\t\";\",\n\t}\n\tfor _, q := range rejected {\n\t\tif err := validateReadOnlyQuery(q); err == nil {\n\t\t\tt.Errorf(\"validateReadOnlyQuery(%q) = nil, want error\", q)\n\t\t}\n\t}\n}\n\n// TestStripLeadingSQLNoise checks the helper directly so a regression in the\n// stripping logic (off-by-one on /* */ length, missing newline handling on\n// --) surfaces close to the source rather than only via the integration\n// behavior of validateReadOnlyQuery.\nfunc TestStripLeadingSQLNoise(t *testing.T) {\n\tcases := []struct {\n\t\tin, want string\n\t}{\n\t\t{\"SELECT 1\", \"SELECT 1\"},\n\t\t{\" SELECT 1\", \"SELECT 1\"},\n\t\t{\"\\t\\nSELECT 1\", \"SELECT 1\"},\n\t\t{\";SELECT 1\", \"SELECT 1\"},\n\t\t{\";; ;SELECT 1\", \"SELECT 1\"},\n\t\t{\"-- x\\nSELECT 1\", \"SELECT 1\"},\n\t\t{\"-- x\\n-- y\\nSELECT 1\", \"SELECT 1\"},\n\t\t{\"/* x */SELECT 1\", \"SELECT 1\"},\n\t\t{\"/**/SELECT 1\", \"SELECT 1\"},\n\t\t{\"/* x */ /* y */ SELECT 1\", \"SELECT 1\"},\n\t\t{\"-- only\", \"\"},\n\t\t{\"/* only\", \"\"},\n\t\t{\"\", \"\"},\n\t}\n\tfor _, c := range cases {\n\t\tgot := stripLeadingSQLNoise(c.in)\n\t\tif !strings.EqualFold(got, c.want) {\n\t\t\tt.Errorf(\"stripLeadingSQLNoise(%q) = %q, want %q\", c.in, got, c.want)\n\t\t}\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3912,"content_sha256":"a669fc7a36d31733f47d171094d638ff014905720bee943bbbbb1e601dd8fff3"},{"filename":"internal/mcp/tools.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\tmcplib \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/mark3labs/mcp-go/server\"\n\t\"github.com/mvanhorn/printing-press-library/library/sales-and-crm/tenderned/internal/cli\"\n\t\"github.com/mvanhorn/printing-press-library/library/sales-and-crm/tenderned/internal/client\"\n\t\"github.com/mvanhorn/printing-press-library/library/sales-and-crm/tenderned/internal/config\"\n\t\"github.com/mvanhorn/printing-press-library/library/sales-and-crm/tenderned/internal/mcp/cobratree\"\n\t\"github.com/mvanhorn/printing-press-library/library/sales-and-crm/tenderned/internal/store\"\n)\n\n// RegisterTools registers all API operations as MCP tools.\nfunc RegisterTools(s *server.MCPServer) {\n\ts.AddTool(\n\t\tmcplib.NewTool(\"buyers_get\",\n\t\t\tmcplib.WithDescription(\"Fetch one contracting authority by ID. Required: aanbestedendedienstId. Returns the Buyer.\"),\n\t\t\tmcplib.WithString(\"aanbestedendedienstId\", mcplib.Required(), mcplib.Description(\"Contracting authority UUID\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/aanbestedendediensten/{aanbestedendedienstId}\", true, false, nil, []mcpParamBinding{{PublicName: \"aanbestedendedienstId\", WireName: \"aanbestedendedienstId\", Location: \"path\"}}, []string{\"aanbestedendedienstId\"}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"buyers_list\",\n\t\t\tmcplib.WithDescription(\"List Dutch contracting authorities (paginated). Optional: page, size. Returns the BuyerPage.\"),\n\t\t\tmcplib.WithNumber(\"page\", mcplib.Description(\"Page number (0-based)\")),\n\t\t\tmcplib.WithNumber(\"size\", mcplib.Description(\"Page size (default 50, max 100)\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/aanbestedendediensten\", true, false, nil, []mcpParamBinding{{PublicName: \"page\", WireName: \"page\", Location: \"query\"}, {PublicName: \"size\", WireName: \"size\", Location: \"query\"}}, []string{}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"docs_download\",\n\t\t\tmcplib.WithDescription(\"Download all documents for one publication as a zip archive. Required: publicatieId. Returns the BinaryContent.\"),\n\t\t\tmcplib.WithNumber(\"publicatieId\", mcplib.Required(), mcplib.Description(\"Publication ID\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/publicaties/{publicatieId}/documenten/zip\", true, false, nil, []mcpParamBinding{{PublicName: \"publicatieId\", WireName: \"publicatieId\", Location: \"path\"}}, []string{\"publicatieId\"}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"docs_get\",\n\t\t\tmcplib.WithDescription(\"Download a single document's binary content (PDF/Word/etc.). Required: publicatieId, documentId. Returns the BinaryContent.\"),\n\t\t\tmcplib.WithNumber(\"publicatieId\", mcplib.Required(), mcplib.Description(\"Publication ID\")),\n\t\t\tmcplib.WithString(\"documentId\", mcplib.Required(), mcplib.Description(\"Document ID (from 'documents list')\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/publicaties/{publicatieId}/documenten/{documentId}/content\", true, false, nil, []mcpParamBinding{{PublicName: \"publicatieId\", WireName: \"publicatieId\", Location: \"path\"}, {PublicName: \"documentId\", WireName: \"documentId\", Location: \"path\"}}, []string{\"publicatieId\", \"documentId\"}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"docs_list\",\n\t\t\tmcplib.WithDescription(\"List attached documents for one publication. Required: publicatieId. Returns the DocumentList.\"),\n\t\t\tmcplib.WithNumber(\"publicatieId\", mcplib.Required(), mcplib.Description(\"Publication ID\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/publicaties/{publicatieId}/documenten\", true, false, nil, []mcpParamBinding{{PublicName: \"publicatieId\", WireName: \"publicatieId\", Location: \"path\"}}, []string{\"publicatieId\"}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"notices_get\",\n\t\t\tmcplib.WithDescription(\"Fetch full structured metadata for one publication. Required: publicatieId. Returns the Publication.\"),\n\t\t\tmcplib.WithNumber(\"publicatieId\", mcplib.Required(), mcplib.Description(\"TenderNed publication ID (e.g. 425283)\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/publicaties/{publicatieId}\", true, false, nil, []mcpParamBinding{{PublicName: \"publicatieId\", WireName: \"publicatieId\", Location: \"path\"}}, []string{\"publicatieId\"}),\n\t)\n\ts.AddTool(\n\t\tmcplib.NewTool(\"notices_list\",\n\t\t\tmcplib.WithDescription(\"Search and list tender publications with rich filters (CPV, dates, buyer, procedure, scope). Optional: search, cpvCodes, publicatieType (plus 11 more). Returns the PublicationPage.\"),\n\t\t\tmcplib.WithString(\"search\", mcplib.Description(\"Full-text keyword search across title and description\")),\n\t\t\tmcplib.WithString(\"cpvCodes\", mcplib.Description(\"CPV code in 8-digit-plus-check-digit form (e.g. 45000000-7). Repeat for multi-CPV intersection.\")),\n\t\t\tmcplib.WithString(\"publicatieType\", mcplib.Description(\"Publication type filter (AAO=open call, AGO=awarded, VOP=prior info, RVO=cancellation, WNO=ex-ante\")),\n\t\t\tmcplib.WithString(\"typeOpdracht\", mcplib.Description(\"Contract type: D=services (Diensten), L=supplies (Leveringen), W=works (Werken)\")),\n\t\t\tmcplib.WithString(\"procedure\", mcplib.Description(\"Procedure code (Openbaar, Niet-openbaar, Onderhandeling, Concurrentiegerichte dialoog, etc.)\")),\n\t\t\tmcplib.WithString(\"nationaalOfEuropees\", mcplib.Description(\"Scope: NA=national-only (sub-threshold), EU=European (above-threshold). National-only notices never reach TED.\")),\n\t\t\tmcplib.WithString(\"since\", mcplib.Description(\"Earliest publication date (YYYY-MM-DD)\")),\n\t\t\tmcplib.WithString(\"until\", mcplib.Description(\"Latest publication date (YYYY-MM-DD)\")),\n\t\t\tmcplib.WithString(\"closing-since\", mcplib.Description(\"Earliest closing date (YYYY-MM-DD)\")),\n\t\t\tmcplib.WithString(\"closing-until\", mcplib.Description(\"Latest closing date (YYYY-MM-DD)\")),\n\t\t\tmcplib.WithString(\"buyer-id\", mcplib.Description(\"Filter by contracting-authority UUID (use 'buyers list' to find IDs)\")),\n\t\t\tmcplib.WithString(\"buyer-type\", mcplib.Description(\"Filter by contracting-authority type code\")),\n\t\t\tmcplib.WithNumber(\"page\", mcplib.Description(\"Page number (0-based)\")),\n\t\t\tmcplib.WithNumber(\"size\", mcplib.Description(\"Page size (default 50, max 100)\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t\tmcplib.WithOpenWorldHintAnnotation(true),\n\t\t),\n\t\tmakeAPIHandler(\"GET\", \"/publicaties\", true, false, nil, []mcpParamBinding{{PublicName: \"search\", WireName: \"search\", Location: \"query\"}, {PublicName: \"cpvCodes\", WireName: \"cpvCodes\", Location: \"query\"}, {PublicName: \"publicatieType\", WireName: \"publicatieType\", Location: \"query\"}, {PublicName: \"typeOpdracht\", WireName: \"typeOpdracht\", Location: \"query\"}, {PublicName: \"procedure\", WireName: \"procedure\", Location: \"query\"}, {PublicName: \"nationaalOfEuropees\", WireName: \"nationaalOfEuropees\", Location: \"query\"}, {PublicName: \"since\", WireName: \"publicatieDatumVanaf\", Location: \"query\"}, {PublicName: \"until\", WireName: \"publicatieDatumTot\", Location: \"query\"}, {PublicName: \"closing-since\", WireName: \"sluitingsDatumVanaf\", Location: \"query\"}, {PublicName: \"closing-until\", WireName: \"sluitingsDatumTot\", Location: \"query\"}, {PublicName: \"buyer-id\", WireName: \"aanbestedendeDienstId\", Location: \"query\"}, {PublicName: \"buyer-type\", WireName: \"typeAanbestedendeDienst\", Location: \"query\"}, {PublicName: \"page\", WireName: \"page\", Location: \"query\"}, {PublicName: \"size\", WireName: \"size\", Location: \"query\"}}, []string{}),\n\t)\n\t// Search tool — faster than iterating list endpoints for finding specific items\n\ts.AddTool(\n\t\tmcplib.NewTool(\"search\",\n\t\t\tmcplib.WithDescription(\"Full-text search across all synced data. Faster than paginating list endpoints. Requires sync first.\"),\n\t\t\tmcplib.WithString(\"query\", mcplib.Required(), mcplib.Description(\"Search query (supports FTS5 syntax: AND, OR, NOT, quotes for phrases)\")),\n\t\t\tmcplib.WithNumber(\"limit\", mcplib.Description(\"Max results (default 25)\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t),\n\t\thandleSearch,\n\t)\n\t// SQL tool — ad-hoc analysis on synced data without API calls\n\ts.AddTool(\n\t\tmcplib.NewTool(\"sql\",\n\t\t\tmcplib.WithDescription(\"Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first.\"),\n\t\t\tmcplib.WithString(\"query\", mcplib.Required(), mcplib.Description(\"SQL query (SELECT or WITH...SELECT). Tables match resource names.\")),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t),\n\t\thandleSQL,\n\t)\n\n\t// Context tool — front-loaded domain knowledge for agents.\n\t// Call this first to understand the API taxonomy, query patterns, and capabilities.\n\ts.AddTool(\n\t\tmcplib.NewTool(\"context\",\n\t\t\tmcplib.WithDescription(\"Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first.\"),\n\t\t\tmcplib.WithReadOnlyHintAnnotation(true),\n\t\t\tmcplib.WithDestructiveHintAnnotation(false),\n\t\t),\n\t\thandleContext,\n\t)\n\n\t// Runtime Cobra-tree mirror — exposes every user-facing command that is\n\t// not already covered by a typed endpoint or framework MCP tool.\n\tcobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)\n}\n\ntype mcpParamBinding struct {\n\tPublicName string\n\tWireName string\n\tLocation string\n}\n\n// makeAPIHandler creates a generic MCP tool handler for an API endpoint.\nfunc makeAPIHandler(method, pathTemplate string, readOnly bool, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {\n\treturn func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\t\tc, err := newMCPClient()\n\t\tif err != nil {\n\t\t\treturn mcplib.NewToolResultError(err.Error()), nil\n\t\t}\n\n\t\t// mcp-go v0.47+ made CallToolParams.Arguments an `any` to support\n\t\t// non-map payloads; GetArguments() returns the map[string]any shape\n\t\t// we rely on here (or an empty map when the payload is something else).\n\t\targs := req.GetArguments()\n\n\t\t// positionalParams mixes real URL path params with CLI positional\n\t\t// args that map to query params (e.g. `search \u003cquery>` -> ?query=);\n\t\t// the placeholder check below disambiguates them at runtime.\n\t\tpath := pathTemplate\n\t\tknownArgs := make(map[string]bool, len(bindings))\n\t\tpathParams := make(map[string]bool, len(positionalParams))\n\t\tparams := make(map[string]string)\n\t\tbodyArgs := make(map[string]any)\n\t\tvar headers map[string]string\n\t\tif len(headerOverrides) > 0 {\n\t\t\theaders = make(map[string]string, len(headerOverrides)+1)\n\t\t\tfor k, v := range headerOverrides {\n\t\t\t\theaders[k] = v\n\t\t\t}\n\t\t}\n\t\tif binaryResponse {\n\t\t\tif headers == nil {\n\t\t\t\theaders = map[string]string{}\n\t\t\t}\n\t\t\theaders[client.BinaryResponseHeader] = \"true\"\n\t\t}\n\t\tfor _, binding := range bindings {\n\t\t\tknownArgs[binding.PublicName] = true\n\t\t\tv, ok := args[binding.PublicName]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch binding.Location {\n\t\t\tcase \"path\":\n\t\t\t\tplaceholder := \"{\" + binding.WireName + \"}\"\n\t\t\t\tpathParams[binding.PublicName] = true\n\t\t\t\tpath = strings.Replace(path, placeholder, fmt.Sprintf(\"%v\", v), 1)\n\t\t\tcase \"body\":\n\t\t\t\tbodyArgs[binding.WireName] = v\n\t\t\tdefault:\n\t\t\t\tparams[binding.WireName] = fmt.Sprintf(\"%v\", v)\n\t\t\t}\n\t\t}\n\t\tfor _, p := range positionalParams {\n\t\t\tplaceholder := \"{\" + p + \"}\"\n\t\t\tif !strings.Contains(pathTemplate, placeholder) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpathParams[p] = true\n\t\t\tif v, ok := args[p]; ok {\n\t\t\t\tpath = strings.Replace(path, placeholder, fmt.Sprintf(\"%v\", v), 1)\n\t\t\t}\n\t\t}\n\n\t\tfor k, v := range args {\n\t\t\tif pathParams[k] || knownArgs[k] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tswitch method {\n\t\t\tcase \"POST\", \"PUT\", \"PATCH\":\n\t\t\t\tbodyArgs[k] = v\n\t\t\tdefault:\n\t\t\t\tparams[k] = fmt.Sprintf(\"%v\", v)\n\t\t\t}\n\t\t}\n\n\t\tvar data json.RawMessage\n\t\tswitch method {\n\t\tcase \"GET\":\n\t\t\tif len(headers) > 0 {\n\t\t\t\tdata, err = c.GetWithHeaders(ctx, path, params, headers)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tdata, err = c.Get(ctx, path, params)\n\t\tcase \"POST\":\n\t\t\tif len(headers) > 0 {\n\t\t\t\tif readOnly {\n\t\t\t\t\tdata, _, err = c.PostQueryWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)\n\t\t\t\t} else {\n\t\t\t\t\tdata, _, err = c.PostWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif readOnly {\n\t\t\t\tdata, _, err = c.PostQueryWithParams(ctx, path, params, bodyArgs)\n\t\t\t} else {\n\t\t\t\tdata, _, err = c.PostWithParams(ctx, path, params, bodyArgs)\n\t\t\t}\n\t\tcase \"PUT\":\n\t\t\tif len(headers) > 0 {\n\t\t\t\tdata, _, err = c.PutWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tdata, _, err = c.PutWithParams(ctx, path, params, bodyArgs)\n\t\tcase \"PATCH\":\n\t\t\tif len(headers) > 0 {\n\t\t\t\tdata, _, err = c.PatchWithParamsAndHeaders(ctx, path, params, bodyArgs, headers)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tdata, _, err = c.PatchWithParams(ctx, path, params, bodyArgs)\n\t\tcase \"DELETE\":\n\t\t\tif len(headers) > 0 {\n\t\t\t\tdata, _, err = c.DeleteWithParamsAndHeaders(ctx, path, params, headers)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tdata, _, err = c.DeleteWithParams(ctx, path, params)\n\t\tdefault:\n\t\t\treturn mcplib.NewToolResultError(\"unsupported method: \" + method), nil\n\t\t}\n\n\t\tif err != nil {\n\t\t\tmsg := err.Error()\n\t\t\tswitch {\n\t\t\tcase strings.Contains(msg, \"HTTP 409\"):\n\t\t\t\treturn mcplib.NewToolResultText(\"already exists (no-op)\"), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 401\"):\n\t\t\t\treturn mcplib.NewToolResultError(\"authentication failed: \" + msg +\n\t\t\t\t\t\"\\nhint: check your API credentials.\" +\n\t\t\t\t\t\"\\n Run 'tenderned-pp-cli doctor' to check auth status.\"), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 403\"):\n\t\t\t\treturn mcplib.NewToolResultError(\"permission denied: \" + msg +\n\t\t\t\t\t\"\\nhint: this API is configured without credentials; the service may be blocking the request by rate limit, geography, bot protection, or endpoint policy.\" +\n\t\t\t\t\t\"\\n Run 'tenderned-pp-cli doctor' to check auth status.\"), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 404\"):\n\t\t\t\tif method == \"DELETE\" {\n\t\t\t\t\treturn mcplib.NewToolResultText(\"already deleted (no-op)\"), nil\n\t\t\t\t}\n\t\t\t\treturn mcplib.NewToolResultError(\"not found: \" + msg), nil\n\t\t\tcase strings.Contains(msg, \"HTTP 429\"):\n\t\t\t\treturn mcplib.NewToolResultError(\"rate limited: \" + msg), nil\n\t\t\tdefault:\n\t\t\t\treturn mcplib.NewToolResultError(msg), nil\n\t\t\t}\n\t\t}\n\n\t\t// For GET responses, wrap bare arrays with count metadata\n\t\tif method == \"GET\" {\n\t\t\ttrimmed := strings.TrimSpace(string(data))\n\t\t\tif len(trimmed) > 0 && trimmed[0] == '[' {\n\t\t\t\tvar items []json.RawMessage\n\t\t\t\tif json.Unmarshal(data, &items) == nil {\n\t\t\t\t\twrapped := map[string]any{\n\t\t\t\t\t\t\"count\": len(items),\n\t\t\t\t\t\t\"items\": items,\n\t\t\t\t\t}\n\t\t\t\t\tout, _ := json.Marshal(wrapped)\n\t\t\t\t\treturn mcplib.NewToolResultText(string(out)), nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif binaryResponse {\n\t\t\tout, _ := json.Marshal(map[string]any{\n\t\t\t\t\"content_encoding\": \"base64\",\n\t\t\t\t\"data_base64\": base64.StdEncoding.EncodeToString(data),\n\t\t\t\t\"byte_count\": len(data),\n\t\t\t})\n\t\t\treturn mcplib.NewToolResultText(string(out)), nil\n\t\t}\n\t\treturn mcplib.NewToolResultText(string(data)), nil\n\t}\n}\n\nfunc newMCPClient() (*client.Client, error) {\n\thome, _ := os.UserHomeDir()\n\tcfgPath := filepath.Join(home, \".config\", \"tenderned-pp-cli\", \"config.toml\")\n\tcfg, err := config.Load(cfgPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"loading config: %w\", err)\n\t}\n\tc := client.New(cfg, 60*time.Second, 0)\n\t// Agents calling through MCP need fresh data every call. The on-disk\n\t// response cache survives across MCP server invocations, so a\n\t// DELETE/PATCH followed by a GET would otherwise return the\n\t// pre-mutation snapshot for up to the cache TTL. The interactive CLI\n\t// constructs its own client and is unaffected.\n\tc.NoCache = true\n\treturn c, nil\n}\n\nfunc dbPath() string {\n\thome, _ := os.UserHomeDir()\n\treturn filepath.Join(home, \".local\", \"share\", \"tenderned-pp-cli\", \"data.db\")\n}\n\n// Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).\n// The CLI's defaultDBPath() in the cli package uses the same canonical path.\n\nfunc handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\targs := req.GetArguments()\n\tquery, ok := args[\"query\"].(string)\n\tif !ok || query == \"\" {\n\t\treturn mcplib.NewToolResultError(\"query is required\"), nil\n\t}\n\n\tlimit := 25\n\tif v, ok := args[\"limit\"].(float64); ok && v > 0 {\n\t\tlimit = int(v)\n\t}\n\n\tdb, err := store.OpenReadOnly(dbPath())\n\tif err != nil {\n\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"opening database: %v\", err)), nil\n\t}\n\tdefer db.Close()\n\n\tresults, err := db.Search(query, limit)\n\tif err != nil {\n\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"search failed: %v\", err)), nil\n\t}\n\n\tdata, _ := json.MarshalIndent(results, \"\", \" \")\n\treturn mcplib.NewToolResultText(string(data)), nil\n}\n\n// validateReadOnlyQuery gates the MCP sql tool. The agent contract advertised\n// to the host is ReadOnlyHintAnnotation(true); a false annotation on a\n// mutating tool lets MCP hosts auto-approve writes and is treated as a real\n// bug per the project's agent-native security model.\n//\n// The gate is an allowlist (SELECT or WITH only) applied AFTER stripping the\n// leading whitespace, line comments, block comments, and semicolons that\n// SQLite itself ignores before parsing. A naive HasPrefix check on a\n// keyword blocklist is bypassable by prefixing the dangerous statement with\n// \"/* x */\" or \"-- x\\n\" — TrimSpace strips outer whitespace but does not\n// understand SQL comment syntax. Combined with the empirical fact that\n// modernc.org/sqlite's mode=ro does NOT block VACUUM INTO (writes a snapshot\n// to a new file) or ATTACH DATABASE (opens a separate writable handle),\n// such a bypass produces silent exfiltration to an attacker-chosen path.\n//\n// SELECT and WITH are the only allowed leading keywords. WITH supports\n// SELECT-form CTEs; CTE-wrapped writes (\"WITH x AS (...) INSERT ...\") are\n// caught by OpenReadOnly's mode=ro one layer down. PRAGMA, ATTACH, VACUUM,\n// and every other DDL/DML keyword fail at this gate before reaching SQLite.\nfunc validateReadOnlyQuery(query string) error {\n\tupper := strings.ToUpper(stripLeadingSQLNoise(query))\n\tif !strings.HasPrefix(upper, \"SELECT\") && !strings.HasPrefix(upper, \"WITH\") {\n\t\treturn fmt.Errorf(\"only SELECT queries are allowed\")\n\t}\n\treturn nil\n}\n\n// stripLeadingSQLNoise removes leading whitespace, SQL line comments\n// (-- to end of line), block comments (/* ... */), and statement\n// separators (;) from query. SQLite skips these before parsing the first\n// keyword, so a security gate that does not strip them mismatches what the\n// driver actually executes.\nfunc stripLeadingSQLNoise(query string) string {\n\tfor {\n\t\tquery = strings.TrimLeft(query, \" \\t\\r\\n;\")\n\t\tswitch {\n\t\tcase strings.HasPrefix(query, \"--\"):\n\t\t\tif idx := strings.IndexByte(query, '\\n'); idx >= 0 {\n\t\t\t\tquery = query[idx+1:]\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn \"\"\n\t\tcase strings.HasPrefix(query, \"/*\"):\n\t\t\tif idx := strings.Index(query[2:], \"*/\"); idx >= 0 {\n\t\t\t\tquery = query[2+idx+2:]\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\treturn \"\"\n\t\tdefault:\n\t\t\treturn query\n\t\t}\n\t}\n}\n\nfunc handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\targs := req.GetArguments()\n\tquery, ok := args[\"query\"].(string)\n\tif !ok || query == \"\" {\n\t\treturn mcplib.NewToolResultError(\"query is required\"), nil\n\t}\n\n\tif err := validateReadOnlyQuery(query); err != nil {\n\t\treturn mcplib.NewToolResultError(err.Error()), nil\n\t}\n\n\tdb, err := store.OpenReadOnly(dbPath())\n\tif err != nil {\n\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"opening database: %v\", err)), nil\n\t}\n\tdefer db.Close()\n\n\trows, err := db.Query(query)\n\tif err != nil {\n\t\treturn mcplib.NewToolResultError(fmt.Sprintf(\"query failed: %v\", err)), nil\n\t}\n\tdefer rows.Close()\n\n\tcols, _ := rows.Columns()\n\tvar results []map[string]any\n\tfor rows.Next() {\n\t\tvalues := make([]any, len(cols))\n\t\tptrs := make([]any, len(cols))\n\t\tfor i := range values {\n\t\t\tptrs[i] = &values[i]\n\t\t}\n\t\trows.Scan(ptrs...)\n\t\trow := make(map[string]any)\n\t\tfor i, col := range cols {\n\t\t\trow[col] = values[i]\n\t\t}\n\t\tresults = append(results, row)\n\t}\n\n\tdata, _ := json.MarshalIndent(results, \"\", \" \")\n\treturn mcplib.NewToolResultText(string(data)), nil\n}\n\nfunc handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {\n\tctx := map[string]any{\n\t\t\"api\": \"tenderned\",\n\t\t\"description\": \"Dutch public-tender CLI with offline search, document corpus, and the sub-threshold long tail TED never sees.\",\n\t\t\"archetype\": \"generic\",\n\t\t\"tool_count\": 7,\n\t\t// tool_surface tells agents which surface a capability lives on.\n\t\t\"tool_surface\": \"MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion tenderned-pp-cli binary.\",\n\t\t\"resources\": []map[string]any{\n\t\t\t{\n\t\t\t\t\"name\": \"buyers\",\n\t\t\t\t\"description\": \"Browse contracting authorities (aanbestedende diensten) — Dutch public buyers\",\n\t\t\t\t\"endpoints\": []string{\"get\", \"list\"},\n\t\t\t\t\"syncable\": true,\n\t\t\t\t\"searchable\": true,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"name\": \"docs\",\n\t\t\t\t\"description\": \"List and download tender documents (bestek, PvE, evaluation criteria, Q&A)\",\n\t\t\t\t\"endpoints\": []string{\"download\", \"get\", \"list\"},\n\t\t\t\t\"searchable\": true,\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"name\": \"notices\",\n\t\t\t\t\"description\": \"Search, list and fetch tender notices (aankondigingen)\",\n\t\t\t\t\"endpoints\": []string{\"get\", \"list\"},\n\t\t\t\t\"syncable\": true,\n\t\t\t\t\"searchable\": true,\n\t\t\t},\n\t\t},\n\t\t\"query_tips\": []string{\n\t\t\t\"Pagination uses cursor-based paging. Pass page parameter for subsequent pages.\",\n\t\t\t\"Control page size with the size parameter (default 100).\",\n\t\t\t\"Use the sql tool for ad-hoc analysis on synced data. Run sync first to populate the local database.\",\n\t\t\t\"Use the search tool for full-text search across all synced resources. Faster than iterating list endpoints.\",\n\t\t\t\"Prefer sql/search over repeated API calls when the data is already synced.\",\n\t\t},\n\t}\n\tdata, _ := json.MarshalIndent(ctx, \"\", \" \")\n\treturn mcplib.NewToolResultText(string(data)), nil\n}\n\n// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP\n// mains. New generated mains call RegisterTools only; RegisterTools now\n// includes the runtime Cobra-tree mirror.\nfunc RegisterNovelFeatureTools(s *server.MCPServer) {\n\t_ = s\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":23026,"content_sha256":"6e367119a79f264ff107a98e846404fab4c6c4aee582a5375e7277bb96054959"},{"filename":"internal/store/extras.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n)\n\n// migrateExtras runs after the generated store migrations and before the\n// schema-version stamp. It is the canonical place for novel-feature auxiliary\n// tables that need to live in the local store.\n//\n// Edit this file when adding tables for novel commands. Keep migrations\n// idempotent with CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS so\n// every store open can safely re-run them.\nfunc (s *Store) migrateExtras(ctx context.Context, conn *sql.Conn) error {\n\tmigrations := []string{\n\t\t// Add CREATE TABLE IF NOT EXISTS statements here.\n\t}\n\tfor _, m := range migrations {\n\t\tif _, err := conn.ExecContext(ctx, m); err != nil {\n\t\t\treturn fmt.Errorf(\"extra migration failed: %w\", err)\n\t\t}\n\t}\n\treturn nil\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":869,"content_sha256":"431b9f40fdfadb1b9835e3f90e36f0c669f6f7ef4eba4a65b53d16b19eacd05d"},{"filename":"internal/store/fts_rehash_test.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestMigrate_v2ToV3_RebuildsFTS pins the FTS-hash regression fix: when an\n// existing v2 database is opened by a binary built from press ≥4.18.1, the\n// migration must drop and rebuild resources_fts so leftover polynomial-hash\n// rowids don't co-exist with new FNV-64a rowids. Without the rebuild, search\n// returns duplicate rows on every hit and the second-insert path tries to\n// write into a table that still contains the pre-hash entries.\nfunc TestMigrate_v2ToV3_RebuildsFTS(t *testing.T) {\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"tenderned.db\")\n\tresource := \"notices\"\n\titemBytes := json.RawMessage(`{\"id\":\"t-1\",\"title\":\"hello world\"}`)\n\n\t// Stage 1: fresh DB, sync one row, then downgrade user_version to 2 and\n\t// inject a ghost FTS row at a bogus rowid — simulating the leftover that\n\t// the polynomial-hash code would have written before the FNV switch.\n\t{\n\t\ts, err := Open(dbPath)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Open initial: %v\", err)\n\t\t}\n\t\tif _, _, err := s.UpsertBatch(resource, []json.RawMessage{itemBytes}); err != nil {\n\t\t\ts.Close()\n\t\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t\t}\n\t\ts.Close()\n\t}\n\n\t{\n\t\traw, err := sql.Open(\"sqlite\", \"file:\"+dbPath)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"raw open: %v\", err)\n\t\t}\n\t\tif _, err := raw.ExecContext(context.Background(), \"PRAGMA user_version = 2\"); err != nil {\n\t\t\traw.Close()\n\t\t\tt.Fatalf(\"downgrade user_version: %v\", err)\n\t\t}\n\t\tif _, err := raw.ExecContext(context.Background(),\n\t\t\t\"INSERT INTO resources_fts(rowid, id, resource_type, content) VALUES (?, ?, ?, ?)\",\n\t\t\t9999999999, \"t-1\", resource, \"ghost content\"); err != nil {\n\t\t\traw.Close()\n\t\t\tt.Fatalf(\"inject ghost row: %v\", err)\n\t\t}\n\t\traw.Close()\n\t}\n\n\t// Stage 2: reopen via Open — triggers the v2→v3 migration path which\n\t// must rehash resources_fts via drop + rebuild.\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"Open after downgrade: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tvar version int\n\tif err := s.db.QueryRowContext(context.Background(), \"PRAGMA user_version\").Scan(&version); err != nil {\n\t\tt.Fatalf(\"read user_version: %v\", err)\n\t}\n\tif version != StoreSchemaVersion {\n\t\tt.Errorf(\"user_version after migrate = %d, want %d\", version, StoreSchemaVersion)\n\t}\n\n\tvar ftsCount int\n\tif err := s.db.QueryRowContext(context.Background(),\n\t\t\"SELECT COUNT(*) FROM resources_fts WHERE resource_type = ?\", resource).Scan(&ftsCount); err != nil {\n\t\tt.Fatalf(\"count fts rows: %v\", err)\n\t}\n\tif ftsCount != 1 {\n\t\tt.Errorf(\"resources_fts rows = %d, want 1 (the ghost should have been dropped during v2→v3 migration; finding 2 means the rebuild didn't run, 0 means the rebuild didn't repopulate from resources)\", ftsCount)\n\t}\n\n\t// And the surviving row's rowid must match the current ftsRowID — the\n\t// new FNV-64a value, not the bogus ghost rowid we injected.\n\texpectedRowid := ftsRowID(resource, \"t-1\")\n\tvar actualRowid int64\n\tif err := s.db.QueryRowContext(context.Background(),\n\t\t\"SELECT rowid FROM resources_fts WHERE resource_type = ? LIMIT 1\", resource).Scan(&actualRowid); err != nil {\n\t\tt.Fatalf(\"read fts rowid: %v\", err)\n\t}\n\tif actualRowid != expectedRowid {\n\t\tt.Errorf(\"resources_fts.rowid = %d, want %d (the rebuild should have written rows at the current FNV-64a rowid)\", actualRowid, expectedRowid)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":3554,"content_sha256":"a0d0f8f8ef603c871873ca9a07f93c6f6257b63a6f762f44e57280fb23759cd2"},{"filename":"internal/store/open_read_only_test.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestOpenReadOnly_AgainstDeleteJournalDB pins the regression from the\n// modernc DSN retrofit: OpenReadOnly must succeed on a pre-WAL (journal_mode\n// = DELETE) database file. Earlier the DSN carried _pragma=journal_mode(WAL),\n// which requires write access to perform the conversion and fails with\n// SQLITE_READONLY on a mode=ro connection. Read-only commands (search,\n// doctor) on pre-#877 databases would otherwise fail every invocation until\n// the next sync.\nfunc TestOpenReadOnly_AgainstDeleteJournalDB(t *testing.T) {\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"tenderned.db\")\n\n\t// Materialize a database file in DELETE journal mode, mirroring the\n\t// shape of a database created before the modernc DSN retrofit landed.\n\twriter, err := sql.Open(\"sqlite\", \"file:\"+dbPath+\"?_pragma=journal_mode(DELETE)\")\n\tif err != nil {\n\t\tt.Fatalf(\"seeding writer: %v\", err)\n\t}\n\tif _, err := writer.ExecContext(context.Background(), \"CREATE TABLE smoke (id INTEGER PRIMARY KEY)\"); err != nil {\n\t\twriter.Close()\n\t\tt.Fatalf(\"seeding table: %v\", err)\n\t}\n\tvar jm string\n\tif err := writer.QueryRowContext(context.Background(), \"PRAGMA journal_mode\").Scan(&jm); err != nil {\n\t\twriter.Close()\n\t\tt.Fatalf(\"reading journal_mode after seed: %v\", err)\n\t}\n\twriter.Close()\n\tif jm != \"delete\" {\n\t\tt.Fatalf(\"test wiring: expected seeded DB to be in DELETE journal mode, got %q\", jm)\n\t}\n\n\tstore, err := OpenReadOnly(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"OpenReadOnly against DELETE-journal DB: %v\", err)\n\t}\n\tdefer store.Close()\n\n\t// Exercise the connection — driver pragmas apply at first-use time, so\n\t// a failure to silently drop journal_mode(WAL) would surface here.\n\tif _, err := store.db.ExecContext(context.Background(), \"SELECT COUNT(*) FROM smoke\"); err != nil {\n\t\tt.Fatalf(\"read after OpenReadOnly on DELETE-journal DB: %v\", err)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":2129,"content_sha256":"f589c34d3fad56850bc2b9480107fefc7140855484aed3f9825ddd2a5732d25c"},{"filename":"internal/store/schema_version_test.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage store\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"errors\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestSchemaVersion_StampedOnFreshDB verifies that opening a brand-new\n// database stamps the current schema version. This is the contract that\n// makes StoreSchemaVersion upgrades safe: every freshly-created DB\n// records the version it was built under.\nfunc TestSchemaVersion_StampedOnFreshDB(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open fresh db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tv, err := s.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"fresh db version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n}\n\n// TestOpenAppliesPragmas pins the connection-string contract: the store\n// must open in WAL journal mode with a non-zero busy_timeout so a read\n// concurrent with a write waits on the lock instead of failing immediately\n// with SQLITE_BUSY. It fails the instant the DSN regresses to the mattn-\n// style _journal_mode=WAL form, which modernc.org/sqlite silently drops —\n// see the OpenReadOnly comment for the driver-syntax detail.\nfunc TestOpenAppliesPragmas(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\trequirePragma(t, s.DB(), \"journal_mode\", \"wal\")\n\trequirePragma(t, s.DB(), \"busy_timeout\", \"5000\")\n\n\t// The read-only handle (MCP sql/search, analytics) must see the same WAL\n\t// file mode and carry the busy_timeout so it waits on a concurrent writer\n\t// rather than erroring.\n\tro, err := OpenReadOnly(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open read-only: %v\", err)\n\t}\n\tdefer ro.Close()\n\n\trequirePragma(t, ro.DB(), \"journal_mode\", \"wal\")\n\trequirePragma(t, ro.DB(), \"busy_timeout\", \"5000\")\n}\n\n// requirePragma fails the test unless `PRAGMA \u003cname>` reports want. It reads\n// the value as text so one helper covers both string pragmas (journal_mode)\n// and integer pragmas (busy_timeout).\nfunc requirePragma(t *testing.T, db *sql.DB, name, want string) {\n\tt.Helper()\n\tvar got string\n\tif err := db.QueryRow(\"PRAGMA \" + name).Scan(&got); err != nil {\n\t\tt.Fatalf(\"read pragma %s: %v\", name, err)\n\t}\n\tif got != want {\n\t\tt.Fatalf(\"PRAGMA %s = %q, want %q\", name, got, want)\n\t}\n}\n\n// TestSchemaVersion_StampExistingZeroDB verifies the stamp-and-continue\n// rule for existing deployed databases. A DB that predates the gate has\n// user_version = 0; opening it with this binary should stamp the version\n// to StoreSchemaVersion without touching any data.\nfunc TestSchemaVersion_StampExistingZeroDB(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-create the DB with user_version = 0 and no tables, simulating\n\t// a database created by a pre-gate version of the binary before any\n\t// migrations ran.\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 0`); err != nil {\n\t\tt.Fatalf(\"stamp zero: %v\", err)\n\t}\n\traw.Close()\n\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open pre-gate db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tv, err := s.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"post-stamp version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n}\n\n// TestSchemaVersion_RefusesNewerDB verifies fail-fast when the on-disk\n// schema is newer than the binary supports. Without this gate, a user\n// who upgrades their library but not their binary would hit silent\n// \"no such column\" errors instead of a clear version mismatch.\nfunc TestSchemaVersion_RefusesNewerDB(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {\n\t\tt.Fatalf(\"stamp future version: %v\", err)\n\t}\n\traw.Close()\n\n\t_, err = Open(dbPath)\n\tif err == nil {\n\t\tt.Fatalf(\"expected open to fail on newer schema, got nil\")\n\t}\n}\n\n// TestMigrate_ConcurrentFreshDB exercises the BEGIN IMMEDIATE migration\n// transaction. Without it, N goroutines opening the same fresh DB in\n// parallel race per CREATE TABLE statement and trip SQLITE_BUSY despite\n// the busy_timeout. With it, they serialize on the RESERVED lock\n// acquired at BEGIN time and every Open succeeds.\nfunc TestMigrate_ConcurrentFreshDB(t *testing.T) {\n\tif testing.Short() {\n\t\tt.Skip(\"concurrent migration test can take up to migrationLockTimeout under contention\")\n\t}\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\tconst n = 8\n\terrs := make(chan error, n)\n\tvar wg sync.WaitGroup\n\twg.Add(n)\n\tfor i := 0; i \u003c n; i++ {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\ts, err := Open(dbPath)\n\t\t\tif err != nil {\n\t\t\t\terrs \u003c- err\n\t\t\t\treturn\n\t\t\t}\n\t\t\ts.Close()\n\t\t}()\n\t}\n\twg.Wait()\n\tclose(errs)\n\n\tfor err := range errs {\n\t\tt.Fatalf(\"concurrent Open failed: %v\", err)\n\t}\n}\n\n// holdWriteLock takes an exclusive write lock on dbPath that a peer's\n// BEGIN IMMEDIATE cannot acquire until the returned cleanup runs. Used\n// to construct contention scenarios in the migration tests.\nfunc holdWriteLock(t *testing.T, dbPath string) (cleanup func()) {\n\tt.Helper()\n\tholder, err := sql.Open(\"sqlite\", dbPath+\"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)\")\n\tif err != nil {\n\t\tt.Fatalf(\"open holder: %v\", err)\n\t}\n\thtx, err := holder.Begin()\n\tif err != nil {\n\t\t_ = holder.Close()\n\t\tt.Fatalf(\"begin holder tx: %v\", err)\n\t}\n\tif _, err := htx.Exec(`CREATE TABLE IF NOT EXISTS holder_lock (id INTEGER)`); err != nil {\n\t\t_ = htx.Rollback()\n\t\t_ = holder.Close()\n\t\tt.Fatalf(\"seed holder write: %v\", err)\n\t}\n\treturn func() {\n\t\t_ = htx.Rollback()\n\t\t_ = holder.Close()\n\t}\n}\n\n// TestOpenWithContext_RespectsCancellation verifies that a caller that\n// cancels its context during a stalled migration sees the cancellation\n// surface as the returned error within a short window, instead of\n// having to wait out the full migrationLockTimeout. SIGINT in a Cobra\n// command's context must interrupt store.Open, not just block on it.\nfunc TestOpenWithContext_RespectsCancellation(t *testing.T) {\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\tdefer holdWriteLock(t, dbPath)()\n\n\t// Pre-cancel the context. The migration's BEGIN IMMEDIATE will BUSY\n\t// against the holder; the very first iteration of retryOnBusy then\n\t// hits the ctx.Done() arm of its select and propagates ctx.Canceled.\n\t// A blocked-then-cancel pattern using time.Sleep would prove the\n\t// same property but cost the sleep interval on every CI run.\n\tctx, cancel := context.WithCancel(context.Background())\n\tcancel()\n\n\tstart := time.Now()\n\t_, err := OpenWithContext(ctx, dbPath)\n\telapsed := time.Since(start)\n\n\tif err == nil {\n\t\tt.Fatalf(\"expected OpenWithContext to fail under contention with cancelled ctx\")\n\t}\n\tif !errors.Is(err, context.Canceled) {\n\t\tt.Fatalf(\"expected context.Canceled in error chain, got: %v\", err)\n\t}\n\t// Without ctx threading this would block until migrationLockTimeout\n\t// (default 30s). 5s is generous headroom over the actual return\n\t// time (microseconds for a pre-cancelled ctx) without flaking CI.\n\tif elapsed > 5*time.Second {\n\t\tt.Fatalf(\"OpenWithContext returned after %s; pre-cancelled ctx should short-circuit immediately\", elapsed)\n\t}\n}\n\n// TestMigrate_RejectsNewerDBImmediately verifies that an old binary\n// opening a newer-schema DB rejects fast even when a peer migrator is\n// still holding the write lock. The schema-version check runs on the\n// pinned connection BEFORE BEGIN IMMEDIATE so the rejection path\n// doesn't have to wait out the migration lock.\nfunc TestMigrate_RejectsNewerDBImmediately(t *testing.T) {\n\tt.Parallel()\n\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-stamp the DB at a version this binary doesn't support.\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 999`); err != nil {\n\t\tt.Fatalf(\"stamp future version: %v\", err)\n\t}\n\traw.Close()\n\n\tdefer holdWriteLock(t, dbPath)()\n\n\tstart := time.Now()\n\t_, err = Open(dbPath)\n\telapsed := time.Since(start)\n\n\tif err == nil {\n\t\tt.Fatalf(\"expected Open to refuse a newer-schema DB\")\n\t}\n\t// The fast-path goal: rejection must arrive well under\n\t// migrationLockTimeout. 5s leaves headroom over the WAL init race\n\t// (a few ms in practice) without being so tight CI flakes.\n\tif elapsed > 5*time.Second {\n\t\tt.Fatalf(\"Open rejected after %s; fast-path should reject in well under migrationLockTimeout (30s)\", elapsed)\n\t}\n}\n\n// TestSchemaVersion_ReopenIsIdempotent verifies that opening an already\n// correctly-stamped DB is a no-op — the second open reads the version\n// and the migrations are all idempotent (CREATE TABLE IF NOT EXISTS).\nfunc TestSchemaVersion_ReopenIsIdempotent(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\ts1, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"first open: %v\", err)\n\t}\n\ts1.Close()\n\n\ts2, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"second open: %v\", err)\n\t}\n\tdefer s2.Close()\n\n\tv, err := s2.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"reopened version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n}\n\nfunc TestResources_CompositeKeyPreservesOverlappingIDs(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tif err := s.Upsert(\"biz\", \"shared\", []byte(`{\"kind\":\"biz\",\"name\":\"Pinky restaurant\"}`)); err != nil {\n\t\tt.Fatalf(\"upsert biz: %v\", err)\n\t}\n\tif err := s.Upsert(\"bookmark\", \"shared\", []byte(`{\"kind\":\"bookmark\",\"note\":\"anniversary\"}`)); err != nil {\n\t\tt.Fatalf(\"upsert bookmark: %v\", err)\n\t}\n\n\tbiz, err := s.Get(\"biz\", \"shared\")\n\tif err != nil {\n\t\tt.Fatalf(\"get biz: %v\", err)\n\t}\n\tif string(biz) != `{\"kind\":\"biz\",\"name\":\"Pinky restaurant\"}` {\n\t\tt.Fatalf(\"biz payload = %s\", biz)\n\t}\n\n\tbookmark, err := s.Get(\"bookmark\", \"shared\")\n\tif err != nil {\n\t\tt.Fatalf(\"get bookmark: %v\", err)\n\t}\n\tif string(bookmark) != `{\"kind\":\"bookmark\",\"note\":\"anniversary\"}` {\n\t\tt.Fatalf(\"bookmark payload = %s\", bookmark)\n\t}\n\n\tvar count int\n\tif err := s.DB().QueryRow(`SELECT COUNT(*) FROM resources WHERE id = 'shared'`).Scan(&count); err != nil {\n\t\tt.Fatalf(\"count overlapping rows: %v\", err)\n\t}\n\tif count != 2 {\n\t\tt.Fatalf(\"overlapping row count = %d, want 2\", count)\n\t}\n\n\tmatches, err := s.Search(\"restaurant\", 10)\n\tif err != nil {\n\t\tt.Fatalf(\"search restaurant: %v\", err)\n\t}\n\tif len(matches) != 1 || string(matches[0]) != `{\"kind\":\"biz\",\"name\":\"Pinky restaurant\"}` {\n\t\tt.Fatalf(\"restaurant search = %q, want only biz payload\", matches)\n\t}\n}\n\n// Callers detect missing rows via errors.Is(err, sql.ErrNoRows); present\n// rows return the JSON payload with a nil error.\nfunc TestGet_MissingRowReturnsErrNoRows(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tdata, err := s.Get(\"missing_type\", \"missing_id\")\n\tif !errors.Is(err, sql.ErrNoRows) {\n\t\tt.Fatalf(\"Get missing row err = %v, want sql.ErrNoRows\", err)\n\t}\n\tif data != nil {\n\t\tt.Fatalf(\"Get missing row data = %s, want nil\", data)\n\t}\n\n\tif err := s.Upsert(\"present_type\", \"present_id\", []byte(`{\"ok\":true}`)); err != nil {\n\t\tt.Fatalf(\"upsert: %v\", err)\n\t}\n\tgot, err := s.Get(\"present_type\", \"present_id\")\n\tif err != nil {\n\t\tt.Fatalf(\"Get present row: %v\", err)\n\t}\n\tif string(got) != `{\"ok\":true}` {\n\t\tt.Fatalf(\"Get present row data = %s, want {\\\"ok\\\":true}\", got)\n\t}\n}\n\nfunc TestMigrate_ResourcesCompositeKeyUpgrade(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`CREATE TABLE resources (\n\t\tid TEXT PRIMARY KEY,\n\t\tresource_type TEXT NOT NULL,\n\t\tdata JSON NOT NULL,\n\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP\n\t)`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"create v1 resources: %v\", err)\n\t}\n\tif _, err := raw.Exec(`CREATE VIRTUAL TABLE resources_fts USING fts5(\n\t\tid, resource_type, content, tokenize='porter unicode61'\n\t)`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"create v1 resources_fts: %v\", err)\n\t}\n\tif _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('shared', 'biz', '{\"kind\":\"biz\",\"name\":\"legacy restaurant\"}')`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"insert v1 resource: %v\", err)\n\t}\n\tif _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (1, 'shared', 'biz', '{\"kind\":\"biz\",\"name\":\"legacy restaurant\"}')`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"insert v1 fts row: %v\", err)\n\t}\n\tif _, err := raw.Exec(`PRAGMA user_version = 1`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"stamp v1: %v\", err)\n\t}\n\traw.Close()\n\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open upgraded db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tv, err := s.SchemaVersion()\n\tif err != nil {\n\t\tt.Fatalf(\"read schema version: %v\", err)\n\t}\n\tif v != StoreSchemaVersion {\n\t\tt.Fatalf(\"upgraded version = %d, want %d\", v, StoreSchemaVersion)\n\t}\n\n\trows, err := s.DB().Query(`PRAGMA table_info(resources)`)\n\tif err != nil {\n\t\tt.Fatalf(\"table_info resources: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\tpk := map[string]int{}\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar name, typ string\n\t\tvar notnull, pkOrder int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pkOrder); err != nil {\n\t\t\tt.Fatalf(\"scan table_info: %v\", err)\n\t\t}\n\t\tpk[name] = pkOrder\n\t}\n\tif err := rows.Err(); err != nil {\n\t\tt.Fatalf(\"table_info rows: %v\", err)\n\t}\n\tif pk[\"resource_type\"] != 1 || pk[\"id\"] != 2 {\n\t\tt.Fatalf(\"resources primary key order = resource_type:%d id:%d, want resource_type:1 id:2\", pk[\"resource_type\"], pk[\"id\"])\n\t}\n\n\tif err := s.Upsert(\"bookmark\", \"shared\", []byte(`{\"kind\":\"bookmark\",\"note\":\"after upgrade\"}`)); err != nil {\n\t\tt.Fatalf(\"upsert overlapping resource after upgrade: %v\", err)\n\t}\n\n\tbiz, err := s.Get(\"biz\", \"shared\")\n\tif err != nil {\n\t\tt.Fatalf(\"get migrated biz: %v\", err)\n\t}\n\tif string(biz) != `{\"kind\":\"biz\",\"name\":\"legacy restaurant\"}` {\n\t\tt.Fatalf(\"migrated biz payload = %s\", biz)\n\t}\n\n\tbookmark, err := s.Get(\"bookmark\", \"shared\")\n\tif err != nil {\n\t\tt.Fatalf(\"get upgraded bookmark: %v\", err)\n\t}\n\tif string(bookmark) != `{\"kind\":\"bookmark\",\"note\":\"after upgrade\"}` {\n\t\tt.Fatalf(\"upgraded bookmark payload = %s\", bookmark)\n\t}\n\n\tmatches, err := s.Search(\"legacy\", 10)\n\tif err != nil {\n\t\tt.Fatalf(\"search migrated fts: %v\", err)\n\t}\n\tif len(matches) != 1 || string(matches[0]) != `{\"kind\":\"biz\",\"name\":\"legacy restaurant\"}` {\n\t\tt.Fatalf(\"legacy search = %q, want migrated biz payload\", matches)\n\t}\n}\n\n// TestOpenReadOnly_RejectsWrites pins the contract: direct and CTE-wrapped\n// writes against the main DB fail under mode=ro. Deliberately does not\n// assert VACUUM INTO and ATTACH DATABASE — modernc.org/sqlite allows both\n// under mode=ro, so those defenses live in the handleSQL keyword blocklist.\nfunc TestOpenReadOnly_RejectsWrites(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\trw, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"seed open: %v\", err)\n\t}\n\tif _, err := rw.DB().Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('seed', 'thing', '{}')`); err != nil {\n\t\tt.Fatalf(\"seed insert: %v\", err)\n\t}\n\trw.Close()\n\n\tro, err := OpenReadOnly(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open read-only: %v\", err)\n\t}\n\tdefer ro.Close()\n\n\twrites := []struct {\n\t\tname string\n\t\tstmt string\n\t}{\n\t\t{\"insert\", `INSERT INTO resources (id, resource_type, data) VALUES ('x', 'y', '{}')`},\n\t\t{\"update\", `UPDATE resources SET resource_type = 'hijacked' WHERE id = 'seed'`},\n\t\t{\"delete\", `DELETE FROM resources WHERE id = 'seed'`},\n\t\t{\"replace\", `REPLACE INTO resources (id, resource_type, data) VALUES ('seed', 'evil', '{}')`},\n\t\t// CTE-wrapped INSERT is load-bearing: it justifies leaving WITH\n\t\t// out of the handleSQL blocklist so SELECT-form CTEs work.\n\t\t{\"cte_insert\", `WITH stale AS (SELECT id FROM resources) INSERT INTO resources (id, resource_type, data) SELECT id || '-evil', 'thing', '{}' FROM stale`},\n\t}\n\tfor _, w := range writes {\n\t\tif _, err := ro.DB().Exec(w.stmt); err == nil {\n\t\t\tt.Errorf(\"%s succeeded under mode=ro; expected rejection. stmt=%q\", w.name, w.stmt)\n\t\t}\n\t}\n\n\tvar count int\n\tif err := ro.DB().QueryRow(`SELECT COUNT(*) FROM resources`).Scan(&count); err != nil {\n\t\tt.Fatalf(\"read-only SELECT failed: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Fatalf(\"SELECT returned %d rows, want 1 (only the seed should remain)\", count)\n\t}\n\tif err := ro.DB().QueryRow(`WITH r AS (SELECT id FROM resources WHERE id = 'seed') SELECT COUNT(*) FROM r`).Scan(&count); err != nil {\n\t\tt.Fatalf(\"read-only WITH...SELECT CTE failed: %v\", err)\n\t}\n\tif count != 1 {\n\t\tt.Fatalf(\"CTE SELECT returned %d rows, want 1\", count)\n\t}\n}\n\n// TestMigrate_AddsColumnsOnUpgrade_SyncState verifies that opening a\n// database created by an older binary succeeds and adds newly generated\n// columns before CREATE INDEX runs against the pre-existing table. Regression\n// coverage for parent_id upgrades and indexed generated columns.\nfunc TestMigrate_AddsColumnsOnUpgrade_SyncState(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\n\t// Pre-create the DB with the older table shape: id, data, synced_at and\n\t// none of the newer generated columns. user_version stays 0 (pre-gate).\n\traw, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open raw: %v\", err)\n\t}\n\tif _, err := raw.Exec(`CREATE TABLE \"sync_state\" (\n\t\tid TEXT PRIMARY KEY,\n\t\tdata JSON NOT NULL,\n\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP\n\t)`); err != nil {\n\t\traw.Close()\n\t\tt.Fatalf(\"create old table: %v\", err)\n\t}\n\traw.Close()\n\n\t// Opening with the new binary must run CREATE INDEX statements without\n\t// erroring on missing generated columns.\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open upgraded db: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// The migration must have added every generated column.\n\trows, err := s.DB().Query(`PRAGMA table_info(\"sync_state\")`)\n\tif err != nil {\n\t\tt.Fatalf(\"table_info: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\thasColumn := make(map[string]bool)\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar name, typ string\n\t\tvar notnull, pk int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {\n\t\t\tt.Fatalf(\"scan: %v\", err)\n\t\t}\n\t\thasColumn[name] = true\n\t}\n\tif err := rows.Err(); err != nil {\n\t\tt.Fatalf(\"rows: %v\", err)\n\t}\n\n\tfor _, want := range []string{\n\t\t\"last_cursor\",\n\t\t\"last_synced_at\",\n\t\t\"total_count\",\n\t} {\n\t\tif !hasColumn[want] {\n\t\t\tt.Fatalf(\"%s column missing from sync_state after migrate\", want)\n\t\t}\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":19011,"content_sha256":"b9ffde7f345fa6d0364d7ff4996b4ab2f1bf0000e95bccb1930423edc502718f"},{"filename":"internal/store/store.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\n// Package store provides local SQLite persistence for tenderned-pp-cli.\n// Uses modernc.org/sqlite (pure Go, no CGO) for zero-dependency cross-compilation.\n// FTS5 full-text search indexes are created for searchable content.\npackage store\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"math\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\nvar uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} pp-tenderned — Skillopedia )\n\n// validIdentifierRE pins ListField's `field` argument to a safe SQL\n// identifier shape before any Sprintf interpolation. Matches what\n// pragma_table_info implicitly enforces on the primary path, so the\n// fallback path inherits the same defense without depending on whether\n// the parent's typed domain table exists at the moment of the lookup.\nvar validIdentifierRE = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]* pp-tenderned — Skillopedia )\n\n// IsUUID returns true if the input looks like a UUID.\nfunc IsUUID(s string) bool {\n\treturn uuidPattern.MatchString(s)\n}\n\n// StoreSchemaVersion is the on-disk schema version this binary understands.\n// It is stamped into SQLite's PRAGMA user_version on fresh databases and\n// checked on every open. Non-learn CLIs stay at v2.\n// StoreSchemaVersion tracks on-disk schema state. Bumped to 3 when\n// ftsRowID's hash changed from polynomial to FNV-64a in press 4.18.1:\n// existing v2 databases retain FTS rows under the old hash, so DELETE\n// WHERE rowid=? with the new hash never matches and INSERT leaves a\n// ghost. The v2→v3 migration drops and rebuilds resources_fts under\n// the new hash, mirroring the v1→v2 rebuild for the same kind of break.\nconst StoreSchemaVersion = 3\n\nconst resourcesFTSCreateSQL = `CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(\n\tid, resource_type, content, tokenize='porter unicode61'\n)`\n\ntype Store struct {\n\tdb *sql.DB\n\t// writeMu serializes all DB writes. Read paths bypass the lock and run\n\t// concurrently against WAL. Resource-level concurrency in sync.go.tmpl\n\t// is 1 (one goroutine per resource via len(resources)-sized work channel)\n\t// — read-then-write sequences (e.g., GetSyncCursor → SaveSyncState) are\n\t// race-free by construction within a resource.\n\twriteMu sync.Mutex\n\tpath string\n}\n\n// Open opens or creates the SQLite store at dbPath using the background\n// context. Prefer OpenWithContext from a Cobra command so SIGINT during\n// a slow migration interrupts the open instead of stranding the caller.\nfunc Open(dbPath string) (*Store, error) {\n\treturn OpenWithContext(context.Background(), dbPath)\n}\n\n// OpenReadOnly opens an existing SQLite store at dbPath in read-only mode.\n// mode=ro rejects direct and CTE-wrapped writes (INSERT, UPDATE, DELETE,\n// REPLACE, \"WITH x AS (...) INSERT ...\") at the driver level. Skips\n// MkdirAll and migrate; the file is expected to exist.\n//\n// The file: URI prefix is load-bearing: modernc.org/sqlite only honors\n// SQLite's URI query parameters (mode, cache, etc.) when the DSN starts\n// with \"file:\". Without the prefix, \"?mode=ro\" is silently dropped and\n// the connection opens read-write. Pragmas use the driver's _pragma=\n// name(value) syntax — modernc.org/sqlite does NOT recognize the\n// mattn/go-sqlite3 _journal_mode=WAL / _busy_timeout=5000 form and drops\n// those keys silently, so the busy_timeout below is what keeps a read\n// concurrent with a writer from failing immediately with SQLITE_BUSY.\n//\n// journal_mode is intentionally omitted from this DSN: it is a database-\n// file-header property, not a per-connection setting, and applying\n// \"_pragma=journal_mode(WAL)\" on a mode=ro connection against a non-WAL\n// file fails with SQLITE_READONLY because the conversion requires write\n// access. Pre-WAL databases (e.g., created before the modernc DSN\n// retrofit) would otherwise fail every read until the next sync runs.\n// OpenWithContext still sets journal_mode(WAL) on the writable path, so\n// the conversion happens the first time the writer opens the file.\nfunc OpenReadOnly(dbPath string) (*Store, error) {\n\tdb, err := sql.Open(\"sqlite\", \"file:\"+dbPath+\"?mode=ro&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening database (read-only): %w\", err)\n\t}\n\tdb.SetMaxOpenConns(2)\n\treturn &Store{db: db, path: dbPath}, nil\n}\n\n// OpenWithContext opens or creates the SQLite store at dbPath. The\n// context is honored by the migration path: cancellation interrupts the\n// retry-on-SQLITE_BUSY loop and propagates ctx.Err() back to the caller\n// instead of waiting out the full migrationLockTimeout.\nfunc OpenWithContext(ctx context.Context, dbPath string) (*Store, error) {\n\tif err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {\n\t\treturn nil, fmt.Errorf(\"creating db directory: %w\", err)\n\t}\n\n\tdb, err := sql.Open(\"sqlite\", dbPath+\"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)\")\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening database: %w\", err)\n\t}\n\n\t// WAL mode + 2 connections allows one read cursor open while a second\n\t// query executes (e.g., analytics commands calling helpers during row\n\t// iteration). Writes are still serialized by SQLite's WAL lock.\n\tdb.SetMaxOpenConns(2)\n\n\ts := &Store{db: db, path: dbPath}\n\tif err := s.migrate(ctx); err != nil {\n\t\tdb.Close()\n\t\treturn nil, fmt.Errorf(\"running migrations: %w\", err)\n\t}\n\n\treturn s, nil\n}\n\nfunc (s *Store) Close() error {\n\treturn s.db.Close()\n}\n\n// Path returns the on-disk path of the backing SQLite file.\nfunc (s *Store) Path() string {\n\treturn s.path\n}\n\n// DB exposes the underlying *sql.DB for callers that need to run ad-hoc\n// queries (e.g., doctor's cache inspection, share snapshot import).\n// Callers must not call Close on the returned handle.\nfunc (s *Store) DB() *sql.DB {\n\treturn s.db\n}\n\n// SchemaVersion reads PRAGMA user_version, which is stamped by migrate().\n// A zero value means the database predates the schema-version gate — not\n// a bug, but the caller may want to warn.\nfunc (s *Store) SchemaVersion() (int, error) {\n\tvar v int\n\tif err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {\n\t\treturn 0, fmt.Errorf(\"read user_version: %w\", err)\n\t}\n\treturn v, nil\n}\n\n// ensureColumn adds a column to an existing table if it isn't already\n// present. It is the upgrade-path safety valve for schema additions:\n// CREATE TABLE IF NOT EXISTS is a no-op when the table already exists, so\n// columns added by newer binaries (e.g. parent_id from the dependent-\n// resources work) never land on databases created by older binaries —\n// which then trip \"no such column\" when a follow-on CREATE INDEX runs.\n//\n// Skips silently if the table doesn't yet exist (fresh install — the\n// CREATE TABLE migration will create it with the column already declared)\n// or if the column already exists. Runs on the pinned migration\n// connection so it sees the writes performed by the in-flight BEGIN\n// IMMEDIATE transaction; using s.db here would route through the pool\n// and BUSY against the holding writer under concurrent migrators.\nfunc (s *Store) ensureColumn(ctx context.Context, conn *sql.Conn, table, column, decl string) error {\n\tvar name string\n\terr := conn.QueryRowContext(ctx,\n\t\t`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,\n\t).Scan(&name)\n\tif err == sql.ErrNoRows {\n\t\treturn nil\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"checking table %s: %w\", table, err)\n\t}\n\n\trows, err := conn.QueryContext(ctx, fmt.Sprintf(`PRAGMA table_info(\"%s\")`, table))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"table_info %s: %w\", table, err)\n\t}\n\tdefer rows.Close()\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar n, typ string\n\t\tvar notnull, pk int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &n, &typ, ¬null, &dflt, &pk); err != nil {\n\t\t\treturn fmt.Errorf(\"scan table_info %s: %w\", table, err)\n\t\t}\n\t\tif n == column {\n\t\t\treturn nil\n\t\t}\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn fmt.Errorf(\"iterating table_info %s: %w\", table, err)\n\t}\n\n\tif _, err := conn.ExecContext(ctx, fmt.Sprintf(`ALTER TABLE \"%s\" ADD COLUMN \"%s\" %s`, table, column, decl)); err != nil {\n\t\t// A concurrent Open() may have added the column between our\n\t\t// PRAGMA check and this ALTER. SQLite returns SQLITE_ERROR with\n\t\t// \"duplicate column name\", which busy_timeout does not retry.\n\t\t// The DB is now in the desired state regardless of who won.\n\t\tif strings.Contains(err.Error(), \"duplicate column name\") {\n\t\t\treturn nil\n\t\t}\n\t\treturn fmt.Errorf(\"add column %s.%s: %w\", table, column, err)\n\t}\n\treturn nil\n}\n\n// backfillColumns adds columns that newer binaries declare but that\n// pre-existing databases (created before those columns were added) lack.\n// Must run before the migrations slice so that subsequent CREATE INDEX\n// statements referencing the column can succeed against the upgraded\n// table. Idempotent: safe to call on fresh DBs (table-not-found short-\n// circuit) and on already-current DBs (column-exists short-circuit).\n//\n// Table names are emitted bare (no safeName) — ensureColumn double-quotes\n// them at SQL emit time and uses parameter binding for the sqlite_master\n// lookup, so the values flow as Go string literals first and SQL\n// identifiers second. Wrapping with safeName here would embed literal\n// double-quote characters into the Go string and break compilation for\n// any spec whose dependent-resource snake_cased name is a SQL reserved\n// word.\nfunc (s *Store) backfillColumns(ctx context.Context, conn *sql.Conn) error {\n\tfor _, c := range []struct{ table, column, decl string }{\n\t\t{table: \"sync_state\", column: \"last_cursor\", decl: \"TEXT\"},\n\t\t{table: \"sync_state\", column: \"last_synced_at\", decl: \"DATETIME\"},\n\t\t{table: \"sync_state\", column: \"total_count\", decl: \"INTEGER DEFAULT 0\"},\n\t} {\n\t\tif err := s.ensureColumn(ctx, conn, c.table, c.column, c.decl); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (s *Store) migrate(ctx context.Context) error {\n\t// Acquiring the migration connection establishes a physical SQLite\n\t// connection, which runs the DSN _pragma directives — including the\n\t// journal_mode(WAL) conversion. On a fresh DB opened by several\n\t// processes at once, that conversion briefly needs an exclusive lock\n\t// and can return SQLITE_BUSY before any statement-level busy handler\n\t// applies, so retry the acquisition against the shared deadline.\n\tdeadline := time.Now().Add(migrationLockTimeout)\n\tvar conn *sql.Conn\n\tif err := retryOnBusy(ctx, deadline, \"acquiring migration connection\", func() error {\n\t\tc, err := s.db.Conn(ctx)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tconn = c\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\tdefer conn.Close()\n\n\t// Read user_version before the migration lock so an old binary\n\t// opening a newer-schema DB rejects immediately. WAL readers don't\n\t// normally block on writers, but the fresh-DB WAL-init race can BUSY\n\t// a SELECT — share the lock's deadline so total budget stays bounded.\n\tvar current int\n\tif err := retryOnBusy(ctx, deadline, \"reading schema version\", func() error {\n\t\treturn conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(¤t)\n\t}); err != nil {\n\t\treturn err\n\t}\n\tif current > StoreSchemaVersion {\n\t\treturn fmt.Errorf(\"database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database\", current, StoreSchemaVersion)\n\t}\n\n\tmigrations := []string{\n\t\t`CREATE TABLE IF NOT EXISTS resources (\n\t\t\tid TEXT NOT NULL,\n\t\t\tresource_type TEXT NOT NULL,\n\t\t\tdata JSON NOT NULL,\n\t\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\t\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\t\tPRIMARY KEY (resource_type, id)\n\t\t)`,\n\t\t`CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(resource_type)`,\n\t\t`CREATE INDEX IF NOT EXISTS idx_resources_synced ON resources(synced_at)`,\n\t\t`CREATE TABLE IF NOT EXISTS sync_state (\n\t\t\tresource_type TEXT PRIMARY KEY,\n\t\t\tlast_cursor TEXT,\n\t\t\tlast_synced_at DATETIME,\n\t\t\ttotal_count INTEGER DEFAULT 0\n\t\t)`,\n\t\tresourcesFTSCreateSQL,\n\t}\n\n\t// Run every migration — including the column backfill and the\n\t// schema-version stamp — inside a single BEGIN IMMEDIATE transaction\n\t// pinned to one connection. IMMEDIATE acquires SQLite's RESERVED lock\n\t// at BEGIN time so concurrent migrators serialize on it instead of\n\t// racing per-statement and tripping SQLITE_BUSY despite busy_timeout.\n\t// modernc.org/sqlite's busy_timeout does not always cover write-write\n\t// contention at BEGIN/COMMIT time, so we retry both explicitly on\n\t// SQLITE_BUSY for up to migrationLockTimeout.\n\treturn withMigrationLock(ctx, conn, deadline, func() error {\n\t\t// Re-read user_version inside the lock. This is load-bearing,\n\t\t// not paranoid: between the pre-lock read above and our\n\t\t// successful BEGIN IMMEDIATE, a newer-binary peer may have\n\t\t// committed a higher version stamp. Without this re-read, an\n\t\t// older binary (smaller StoreSchemaVersion) would proceed to\n\t\t// stamp its own lower version at the end of the closure,\n\t\t// silently downgrading user_version on a schema that's already\n\t\t// at the newer level. Future maintainers: leave this read in.\n\t\tvar current int\n\t\tif err := conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(¤t); err != nil {\n\t\t\treturn fmt.Errorf(\"reading schema version: %w\", err)\n\t\t}\n\t\tif current > StoreSchemaVersion {\n\t\t\treturn fmt.Errorf(\"database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database\", current, StoreSchemaVersion)\n\t\t}\n\n\t\tif current \u003c 2 {\n\t\t\tif err := s.migrateResourcesCompositeKey(ctx, conn); err != nil {\n\t\t\t\treturn fmt.Errorf(\"migrating resources composite key: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\t// v2→v3: ftsRowID switched from polynomial to FNV-64a between press\n\t\t// versions; existing v2 FTS rows are unreachable by the new DELETE\n\t\t// WHERE rowid=? path. Drop and rebuild. Skipped on v1 DBs because\n\t\t// the v1→v2 path above already rebuilds the FTS table.\n\t\tif current == 2 {\n\t\t\tif err := migrateRehashResourcesFTS(ctx, conn); err != nil {\n\t\t\t\treturn fmt.Errorf(\"rehashing resources_fts: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif err := s.backfillColumns(ctx, conn); err != nil {\n\t\t\treturn fmt.Errorf(\"backfilling columns: %w\", err)\n\t\t}\n\t\tfor _, m := range migrations {\n\t\t\tif _, err := conn.ExecContext(ctx, m); err != nil {\n\t\t\t\treturn fmt.Errorf(\"migration failed: %w\", err)\n\t\t\t}\n\t\t}\n\t\tif err := s.migrateExtras(ctx, conn); err != nil {\n\t\t\treturn fmt.Errorf(\"running extra migrations: %w\", err)\n\t\t}\n\t\t// Stamp the schema version. On a fresh DB this writes the current\n\t\t// StoreSchemaVersion; on an already-stamped DB this is a no-op\n\t\t// write of the same value.\n\t\t// An older DB with user_version = 0 and pre-existing tables gets\n\t\t// stamped here after any version-gated rewrites and idempotent\n\t\t// CREATE TABLE IF NOT EXISTS statements have completed.\n\t\tif _, err := conn.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, StoreSchemaVersion)); err != nil {\n\t\t\treturn fmt.Errorf(\"stamp user_version: %w\", err)\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (s *Store) migrateResourcesCompositeKey(ctx context.Context, conn *sql.Conn) error {\n\texists, err := tableExists(ctx, conn, \"resources\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn nil\n\t}\n\n\tcomposite, err := resourcesTableHasCompositeKey(ctx, conn)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !composite {\n\t\tif _, err := conn.ExecContext(ctx, `CREATE TABLE resources_v2 (\n\t\t\tid TEXT NOT NULL,\n\t\t\tresource_type TEXT NOT NULL,\n\t\t\tdata JSON NOT NULL,\n\t\t\tsynced_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\t\tupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n\t\t\tPRIMARY KEY (resource_type, id)\n\t\t)`); err != nil {\n\t\t\treturn fmt.Errorf(\"creating resources_v2: %w\", err)\n\t\t}\n\t\tif _, err := conn.ExecContext(ctx, `INSERT INTO resources_v2 (id, resource_type, data, synced_at, updated_at)\n\t\t\tSELECT id, resource_type, data, synced_at, updated_at FROM resources`); err != nil {\n\t\t\treturn fmt.Errorf(\"copying resources rows: %w\", err)\n\t\t}\n\t\tif _, err := conn.ExecContext(ctx, `DROP TABLE resources`); err != nil {\n\t\t\treturn fmt.Errorf(\"dropping old resources table: %w\", err)\n\t\t}\n\t\tif _, err := conn.ExecContext(ctx, `ALTER TABLE resources_v2 RENAME TO resources`); err != nil {\n\t\t\treturn fmt.Errorf(\"renaming resources_v2: %w\", err)\n\t\t}\n\t}\n\n\t// Always rebuild FTS during the v2 transition. The resources table may\n\t// already have the composite key, but v1 FTS rowids were scoped by id\n\t// alone and must be replaced with resource_type + id rowids.\n\tif _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS resources_fts`); err != nil {\n\t\treturn fmt.Errorf(\"dropping resources_fts: %w\", err)\n\t}\n\tif _, err := conn.ExecContext(ctx, resourcesFTSCreateSQL); err != nil {\n\t\treturn fmt.Errorf(\"creating resources_fts: %w\", err)\n\t}\n\tif err := rebuildResourcesFTS(ctx, conn); err != nil {\n\t\treturn fmt.Errorf(\"rebuilding resources_fts: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc tableExists(ctx context.Context, conn *sql.Conn, name string) (bool, error) {\n\tvar count int\n\tif err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&count); err != nil {\n\t\treturn false, fmt.Errorf(\"checking table %s: %w\", name, err)\n\t}\n\treturn count > 0, nil\n}\n\nfunc resourcesTableHasCompositeKey(ctx context.Context, conn *sql.Conn) (bool, error) {\n\trows, err := conn.QueryContext(ctx, `PRAGMA table_info(resources)`)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"reading resources table info: %w\", err)\n\t}\n\tdefer rows.Close()\n\n\tpk := map[string]int{}\n\tfor rows.Next() {\n\t\tvar cid int\n\t\tvar name, typ string\n\t\tvar notnull, pkOrder int\n\t\tvar dflt sql.NullString\n\t\tif err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pkOrder); err != nil {\n\t\t\treturn false, fmt.Errorf(\"scanning resources table info: %w\", err)\n\t\t}\n\t\tpk[name] = pkOrder\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn false, fmt.Errorf(\"reading resources table info rows: %w\", err)\n\t}\n\treturn pk[\"resource_type\"] == 1 && pk[\"id\"] == 2, nil\n}\n\n// migrateRehashResourcesFTS drops resources_fts and rebuilds it under the\n// current ftsRowID hash function. Called by the v2→v3 path; safe to call\n// against any schema state because DROP IF EXISTS + recreate is idempotent.\nfunc migrateRehashResourcesFTS(ctx context.Context, conn *sql.Conn) error {\n\tif _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS resources_fts`); err != nil {\n\t\treturn fmt.Errorf(\"dropping resources_fts: %w\", err)\n\t}\n\tif _, err := conn.ExecContext(ctx, resourcesFTSCreateSQL); err != nil {\n\t\treturn fmt.Errorf(\"creating resources_fts: %w\", err)\n\t}\n\tif err := rebuildResourcesFTS(ctx, conn); err != nil {\n\t\treturn fmt.Errorf(\"rebuilding resources_fts: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc rebuildResourcesFTS(ctx context.Context, conn *sql.Conn) error {\n\trows, err := conn.QueryContext(ctx, `SELECT id, resource_type, data FROM resources`)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"querying resources: %w\", err)\n\t}\n\n\ttype resourceRow struct {\n\t\tid string\n\t\tresourceType string\n\t\tdata string\n\t}\n\tvar resources []resourceRow\n\tfor rows.Next() {\n\t\tvar r resourceRow\n\t\tif err := rows.Scan(&r.id, &r.resourceType, &r.data); err != nil {\n\t\t\trows.Close()\n\t\t\treturn fmt.Errorf(\"scanning resource: %w\", err)\n\t\t}\n\t\tresources = append(resources, r)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\trows.Close()\n\t\treturn fmt.Errorf(\"reading resource rows: %w\", err)\n\t}\n\tif err := rows.Close(); err != nil {\n\t\treturn fmt.Errorf(\"closing resource rows: %w\", err)\n\t}\n\n\tfor _, r := range resources {\n\t\tif _, err := conn.ExecContext(ctx,\n\t\t\t`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, ?, ?, ?)`,\n\t\t\tftsRowID(r.resourceType, r.id), r.id, r.resourceType, r.data,\n\t\t); err != nil {\n\t\t\treturn fmt.Errorf(\"indexing resource %s/%s: %w\", r.resourceType, r.id, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nconst (\n\tmigrationLockTimeout = 30 * time.Second\n\tmigrationLockBackoffMin = 5 * time.Millisecond\n\tmigrationLockBackoffMax = 100 * time.Millisecond\n)\n\n// withMigrationLock runs fn inside a BEGIN IMMEDIATE / COMMIT pair on\n// conn, retrying both BEGIN and COMMIT on SQLITE_BUSY against the\n// caller-provided deadline. Sharing the deadline with the pre-lock\n// version read keeps total Open() latency bounded by a single budget.\n// The real upper bound is deadline + one trailing backoff interval\n// (≤100ms) + the driver's busy_timeout for the in-flight Exec, since\n// the deadline is checked after each failed attempt rather than as a\n// hard wall-clock cutoff. fn must use conn (not s.db) so its writes\n// participate in the held transaction.\nfunc withMigrationLock(ctx context.Context, conn *sql.Conn, deadline time.Time, fn func() error) error {\n\tif err := execWithBusyRetry(ctx, conn, \"BEGIN IMMEDIATE\", \"begin migration transaction\", deadline); err != nil {\n\t\treturn err\n\t}\n\tcommitted := false\n\tdefer func() {\n\t\tif committed {\n\t\t\treturn\n\t\t}\n\t\t// ROLLBACK uses context.Background() so caller-context cancellation\n\t\t// can't strand the connection in an open transaction. A failed\n\t\t// rollback is rare on local SQLite (broken file handle, fatal\n\t\t// driver error) but worth surfacing — silent swallow leaves a\n\t\t// pinned connection returned to the pool with state that will\n\t\t// confuse later queries.\n\t\tif _, rerr := conn.ExecContext(context.Background(), \"ROLLBACK\"); rerr != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"warning: store migration rollback failed: %v\\n\", rerr)\n\t\t}\n\t}()\n\n\tif err := fn(); err != nil {\n\t\treturn err\n\t}\n\n\tif err := execWithBusyRetry(ctx, conn, \"COMMIT\", \"commit migration transaction\", deadline); err != nil {\n\t\treturn err\n\t}\n\tcommitted = true\n\treturn nil\n}\n\n// execWithBusyRetry runs stmt on conn and retries on SQLITE_BUSY until\n// deadline. It covers BEGIN IMMEDIATE and COMMIT contention;\n// modernc.org/sqlite's busy_timeout does not reliably cover either when\n// multiple connections race for the WAL write lock.\nfunc execWithBusyRetry(ctx context.Context, conn *sql.Conn, stmt, label string, deadline time.Time) error {\n\treturn retryOnBusy(ctx, deadline, label, func() error {\n\t\t_, err := conn.ExecContext(ctx, stmt)\n\t\treturn err\n\t})\n}\n\n// retryOnBusy runs op and retries it on SQLITE_BUSY/LOCKED until\n// deadline. The same retry shape covers Exec, Query, and any other\n// SQLite call that can race the WAL writer lock — including the\n// pre-lock user_version read, where the WAL initialization race on a\n// fresh DB can BUSY a SELECT that should otherwise succeed under WAL\n// reader/writer concurrency.\nfunc retryOnBusy(ctx context.Context, deadline time.Time, label string, op func() error) error {\n\tbackoff := migrationLockBackoffMin\n\tfor {\n\t\terr := op()\n\t\tif err == nil {\n\t\t\treturn nil\n\t\t}\n\t\tif !isSQLiteBusy(err) {\n\t\t\treturn fmt.Errorf(\"%s: %w\", label, err)\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\t// The label carries the operation context (e.g. \"begin\n\t\t\t// migration transaction\", \"reading schema version\") — we\n\t\t\t// don't hardcode \"waiting for write lock\" because pre-lock\n\t\t\t// reads also flow through this helper.\n\t\t\treturn fmt.Errorf(\"%s: timed out after %s under SQLite contention: %w\", label, migrationLockTimeout, err)\n\t\t}\n\t\tselect {\n\t\tcase \u003c-ctx.Done():\n\t\t\treturn fmt.Errorf(\"%s: %w\", label, ctx.Err())\n\t\tcase \u003c-time.After(backoff):\n\t\t}\n\t\tbackoff = min(backoff*2, migrationLockBackoffMax)\n\t}\n}\n\n// isSQLiteBusy reports whether err is a retryable SQLite lock condition.\n// Covers both the file-level WAL writer race (SQLITE_BUSY / \"database is\n// locked\") and the table-level shared-cache contention (SQLITE_LOCKED /\n// \"database table is locked\"). The match is on the error string because\n// modernc.org/sqlite does not export an error type the generated code\n// can switch on without dragging the driver package into every store\n// consumer.\nfunc isSQLiteBusy(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"SQLITE_BUSY\") ||\n\t\tstrings.Contains(msg, \"SQLITE_LOCKED\") ||\n\t\tstrings.Contains(msg, \"database is locked\") ||\n\t\tstrings.Contains(msg, \"database table is locked\")\n}\n\nfunc (s *Store) upsertGenericResourceTx(tx *sql.Tx, resourceType, id string, data json.RawMessage) error {\n\t_, err := tx.Exec(\n\t\t`INSERT INTO resources (id, resource_type, data, synced_at, updated_at)\n\t\t VALUES (?, ?, ?, ?, ?)\n\t\t ON CONFLICT(resource_type, id) DO UPDATE SET data = excluded.data, synced_at = excluded.synced_at, updated_at = excluded.updated_at`,\n\t\tid, resourceType, string(data), time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tftsRowid := ftsRowID(resourceType, id)\n\t// Use explicit rowid for FTS5 compatibility with modernc.org/sqlite.\n\t// Standard DELETE WHERE column=? may not work on FTS5 virtual tables.\n\tif _, err = tx.Exec(`DELETE FROM resources_fts WHERE rowid = ?`, ftsRowid); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"warning: FTS index cleanup failed: %v\\n\", err)\n\t}\n\n\tif _, err = tx.Exec(\n\t\t`INSERT INTO resources_fts (rowid, id, resource_type, content)\n\t\t VALUES (?, ?, ?, ?)`,\n\t\tftsRowid, id, resourceType, string(data),\n\t); err != nil {\n\t\t// FTS insert failure is non-fatal\n\t\tfmt.Fprintf(os.Stderr, \"warning: FTS index update failed: %v\\n\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tif err := s.upsertGenericResourceTx(tx, resourceType, id, data); err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n\n// Propagates sql.ErrNoRows on a miss so callers can distinguish absence from\n// other scan errors via errors.Is.\nfunc (s *Store) Get(resourceType, id string) (json.RawMessage, error) {\n\tvar data string\n\terr := s.db.QueryRow(\n\t\t`SELECT data FROM resources WHERE resource_type = ? AND id = ?`,\n\t\tresourceType, id,\n\t).Scan(&data)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn json.RawMessage(data), nil\n}\n\nfunc (s *Store) List(resourceType string, limit int) ([]json.RawMessage, error) {\n\tif limit \u003c= 0 {\n\t\tlimit = 200\n\t}\n\trows, err := s.db.Query(\n\t\t`SELECT data FROM resources WHERE resource_type = ? ORDER BY updated_at DESC LIMIT ?`,\n\t\tresourceType, limit,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar results []json.RawMessage\n\tfor rows.Next() {\n\t\tvar data string\n\t\tif err := rows.Scan(&data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresults = append(results, json.RawMessage(data))\n\t}\n\treturn results, rows.Err()\n}\n\nfunc (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {\n\tif limit \u003c= 0 {\n\t\tlimit = 50\n\t}\n\trows, err := s.db.Query(\n\t\t`SELECT r.data FROM resources r\n\t\t JOIN resources_fts f ON r.id = f.id AND r.resource_type = f.resource_type\n\t\t WHERE resources_fts MATCH ?\n\t\t ORDER BY rank\n\t\t LIMIT ?`,\n\t\tquery, limit,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar results []json.RawMessage\n\tfor rows.Next() {\n\t\tvar data string\n\t\tif err := rows.Scan(&data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresults = append(results, json.RawMessage(data))\n\t}\n\treturn results, rows.Err()\n}\n\nfunc extractObjectID(obj map[string]any) string {\n\tfor _, key := range []string{\"id\", \"Id\", \"ID\", \"uuid\", \"slug\", \"name\"} {\n\t\tif v, ok := obj[key]; ok {\n\t\t\treturn ResourceIDString(v)\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// ftsRowID derives a deterministic rowid from a string ID for use with FTS5.\n// modernc.org/sqlite's FTS5 implementation may not support DELETE WHERE column=?\n// on virtual tables, so we use explicit rowids and DELETE WHERE rowid=? instead.\nfunc ftsRowID(scope, id string) int64 {\n\th := fnv.New64a()\n\t_, _ = h.Write([]byte(scope))\n\t_, _ = h.Write([]byte{0}) // separator so (\"ab\",\"c\") != (\"a\",\"bc\")\n\t_, _ = h.Write([]byte(id))\n\treturn int64(h.Sum64() & 0x7FFFFFFFFFFFFFFF) // ensure positive\n}\n\n// LookupFieldValue resolves a field value from a JSON object map, trying the\n// snake_case key first, then the camelCase rendering, then the PascalCase\n// rendering. Exported so the sync command's extractID and the upsert path\n// resolve fields the same way — a divergence here produces silent drops on\n// heterogeneous payloads. The PascalCase pass handles .NET-shaped responses\n// (`Id`, `Name`, `OrderId`) without forcing each spec to declare casing.\nfunc LookupFieldValue(obj map[string]any, snakeKey string) any {\n\tif v, ok := obj[snakeKey]; ok {\n\t\treturn sqliteFieldValue(v)\n\t}\n\tparts := strings.Split(snakeKey, \"_\")\n\tfor i := 1; i \u003c len(parts); i++ {\n\t\tif parts[i] == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]\n\t}\n\tcamel := strings.Join(parts, \"\")\n\tif v, ok := obj[camel]; ok {\n\t\treturn sqliteFieldValue(v)\n\t}\n\tif parts[0] != \"\" {\n\t\tpascal := strings.ToUpper(parts[0][:1]) + parts[0][1:] + strings.Join(parts[1:], \"\")\n\t\tif v, ok := obj[pascal]; ok {\n\t\t\treturn sqliteFieldValue(v)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc sqliteFieldValue(v any) any {\n\tswitch t := v.(type) {\n\tcase nil, string, bool, int, int64, float64, []byte:\n\t\treturn v\n\tcase json.Number:\n\t\treturn strings.TrimSpace(t.String())\n\tdefault:\n\t\tdata, err := json.Marshal(v)\n\t\tif err != nil {\n\t\t\treturn fmt.Sprint(v)\n\t\t}\n\t\treturn string(data)\n\t}\n}\n\n// lookupFieldValue is kept as an unexported alias for in-package callers so\n// the existing UpsertBatch code reads naturally without prefixing every call\n// with the package name.\nfunc lookupFieldValue(obj map[string]any, snakeKey string) any {\n\treturn LookupFieldValue(obj, snakeKey)\n}\n\n// DecodeJSONObject decodes data into an object while preserving JSON numbers.\n// Plain json.Unmarshal turns numbers into float64, and fmt on those values can\n// render large integer IDs as scientific notation before they reach resources.id.\nfunc DecodeJSONObject(data json.RawMessage) (map[string]any, error) {\n\tvar obj map[string]any\n\tdec := json.NewDecoder(bytes.NewReader(data))\n\tdec.UseNumber()\n\tif err := dec.Decode(&obj); err != nil {\n\t\treturn nil, err\n\t}\n\treturn obj, nil\n}\n\n// ResourceIDString returns the stable text form used for resources.id.\nfunc ResourceIDString(v any) string {\n\tswitch t := v.(type) {\n\tcase nil:\n\t\treturn \"\"\n\tcase json.Number:\n\t\treturn strings.TrimSpace(t.String())\n\tcase float64:\n\t\tif math.IsNaN(t) || math.IsInf(t, 0) {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn strconv.FormatFloat(t, 'f', -1, 64)\n\tcase float32:\n\t\tf := float64(t)\n\t\tif math.IsNaN(f) || math.IsInf(f, 0) {\n\t\t\treturn \"\"\n\t\t}\n\t\treturn strconv.FormatFloat(f, 'f', -1, 32)\n\tdefault:\n\t\t// fmt.Sprint on typed nil pointers returns \"\u003cnil>\"; callers still guard\n\t\t// that sentinel so unresolved IDs do not become stored resource keys.\n\t\treturn strings.TrimSpace(fmt.Sprint(t))\n\t}\n}\n\n// resourceIDFieldOverrides projects per-resource IDField (set by the profiler\n// from x-resource-id or response-schema fallback) into a runtime lookup map.\n// UpsertBatch consults this first so the templated path wins over the\n// generic fallback list. Empty when no resource declared an override; the\n// runtime fallback list still applies.\n//\n// Includes both flat resources and dependent (parent-child) resources so a\n// child path-item annotated with x-resource-id resolves the same as a flat\n// path-item.\nvar resourceIDFieldOverrides = map[string]string{}\n\n// genericIDFieldFallbacks is the runtime safety net for resources that did\n// NOT receive a templated IDField. API-specific names belong in spec\n// annotations (x-resource-id), not this list. Order matters: vendor\n// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`\n// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display\n// field and upsert on names — see #1394.\nvar genericIDFieldFallbacks = []string{\"id\", \"ID\", \"gid\", \"sid\", \"uid\", \"uuid\", \"guid\", \"name\", \"slug\", \"key\", \"code\"}\n\n// ExtractResourceID resolves the primary key UpsertBatch would use for a\n// resource item. Callers that need to gate best-effort writes can use this to\n// avoid passing non-entity envelopes into the batch path.\nfunc ExtractResourceID(resourceType string, obj map[string]any) string {\n\tif override, ok := resourceIDFieldOverrides[resourceType]; ok && override != \"\" {\n\t\tif v := lookupFieldValue(obj, override); v != nil {\n\t\t\ts := ResourceIDString(v)\n\t\t\tif s != \"\" && s != \"\u003cnil>\" {\n\t\t\t\treturn s\n\t\t\t}\n\t\t}\n\t}\n\tfor _, key := range genericIDFieldFallbacks {\n\t\tif v := lookupFieldValue(obj, key); v != nil {\n\t\t\ts := ResourceIDString(v)\n\t\t\tif s != \"\" && s != \"\u003cnil>\" {\n\t\t\t\treturn s\n\t\t\t}\n\t\t}\n\t}\n\treturn \"\"\n}\n\n// UpsertBatch inserts or replaces multiple records in a single transaction\n// and returns (stored, extractFailures, err). stored counts rows landed in\n// the generic resources table; extractFailures counts items that survived\n// JSON unmarshal but had no extractable primary key (templated IDField AND\n// generic fallback both missed). callers (sync.go.tmpl) compare these\n// against len(items) to emit the per-item primary_key_unresolved warning\n// and the F4b stored_count_zero_after_extraction probe.\n//\n// For resource types that have a domain-specific typed table, the per-item\n// generic insert is followed by a dispatch to the matching upsert\u003cPascal>Tx\n// inside the same transaction. Without that dispatch, paginated syncs would\n// only populate the generic resources table — typed tables (and indexed\n// columns like parent_id added by dependent-resource sync) would stay empty.\n//\n// Each typed-table dispatch runs inside a per-item SAVEPOINT so a constraint\n// failure in the typed insert (e.g. NOT NULL parent FK when the generator\n// didn't populate the parent path placeholder) rolls back only that typed\n// upsert. The generic resources row inserted just above it survives the\n// rollback, so successful API fetches never strand in memory because one\n// downstream typed table is misconfigured. Failures are surfaced via a\n// trailing stderr warning rather than aborting the batch.\nfunc (s *Store) UpsertBatch(resourceType string, items []json.RawMessage) (int, int, error) {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn 0, 0, fmt.Errorf(\"starting batch transaction: %w\", err)\n\t}\n\tdefer tx.Rollback()\n\n\tvar stored, skippedCount, extractFailures int\n\tfor _, item := range items {\n\t\tobj, err := DecodeJSONObject(item)\n\t\tif err != nil {\n\t\t\tskippedCount++\n\t\t\tcontinue\n\t\t}\n\t\t// Templated IDField wins; generic fallback list runs second when\n\t\t// the override is empty OR the override field is absent on this\n\t\t// particular item (response shape mismatches happen even when the\n\t\t// spec declares x-resource-id).\n\t\tid := ExtractResourceID(resourceType, obj)\n\t\tif id == \"\" {\n\t\t\tskippedCount++\n\t\t\textractFailures++\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := s.upsertGenericResourceTx(tx, resourceType, id, item); err != nil {\n\t\t\t// Return the running stored count rather than zero so callers\n\t\t\t// inspecting partial progress on failure see what already\n\t\t\t// landed in earlier loop iterations.\n\t\t\treturn stored, extractFailures, fmt.Errorf(\"upserting %s/%s: %w\", resourceType, id, err)\n\t\t}\n\t\tstored++\n\t}\n\n\t// Warn when most items in a batch lack an extractable ID — this likely\n\t// means the API uses a primary key field we don't recognize yet.\n\tif skippedCount > 0 && len(items) > 0 && skippedCount*2 > len(items) {\n\t\tfmt.Fprintf(os.Stderr, \"warning: %d/%d %s items returned but not cached locally (no extractable ID field; offline lookup against these rows will be incomplete; live queries unaffected)\\n\", skippedCount, len(items), resourceType)\n\t}\n\n\tif err := tx.Commit(); err != nil {\n\t\treturn 0, extractFailures, err\n\t}\n\treturn stored, extractFailures, nil\n}\n\nfunc (s *Store) SaveSyncState(resourceType, cursor string, count int) error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\t_, err := s.db.Exec(\n\t\t`INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)\n\t\t VALUES (?, ?, ?, ?)\n\t\t ON CONFLICT(resource_type) DO UPDATE SET last_cursor = excluded.last_cursor,\n\t\t last_synced_at = excluded.last_synced_at, total_count = excluded.total_count`,\n\t\tresourceType, cursor, time.Now().UTC().Format(time.RFC3339), count,\n\t)\n\treturn err\n}\n\nfunc (s *Store) GetSyncState(resourceType string) (cursor string, lastSynced time.Time, count int, err error) {\n\terr = s.db.QueryRow(\n\t\t`SELECT last_cursor, last_synced_at, total_count FROM sync_state WHERE resource_type = ?`,\n\t\tresourceType,\n\t).Scan(&cursor, &lastSynced, &count)\n\tif err == sql.ErrNoRows {\n\t\treturn \"\", time.Time{}, 0, nil\n\t}\n\treturn\n}\n\n// SaveSyncCursor stores the pagination cursor for a resource type.\nfunc (s *Store) SaveSyncCursor(resourceType, cursor string) error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\t_, err := s.db.Exec(\n\t\t`INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)\n\t\t VALUES (?, ?, CURRENT_TIMESTAMP, 0)\n\t\t ON CONFLICT(resource_type) DO UPDATE SET last_cursor = ?, last_synced_at = CURRENT_TIMESTAMP`,\n\t\tresourceType, cursor, cursor,\n\t)\n\treturn err\n}\n\n// GetSyncCursor returns the last pagination cursor for a resource type.\nfunc (s *Store) GetSyncCursor(resourceType string) string {\n\tvar cursor sql.NullString\n\ts.db.QueryRow(\"SELECT last_cursor FROM sync_state WHERE resource_type = ?\", resourceType).Scan(&cursor)\n\tif cursor.Valid {\n\t\treturn cursor.String\n\t}\n\treturn \"\"\n}\n\n// ListIDs returns all IDs from a resource's domain table, or from the generic\n// resources table if no domain table exists. Used by dependent sync to iterate parents.\n//\n// resourceType is never interpolated into SQL directly. We resolve it to a real\n// table name via a parameterized sqlite_master lookup; only that trusted name is\n// substituted (double-quoted) into the SELECT. Callers may pass any string.\nfunc (s *Store) ListIDs(resourceType string) ([]string, error) {\n\tvar table string\n\terr := s.db.QueryRow(\n\t\t`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,\n\t\tresourceType,\n\t).Scan(&table)\n\tvar rows *sql.Rows\n\tif err == nil && table != \"\" {\n\t\trows, err = s.db.Query(fmt.Sprintf(`SELECT id FROM \"%s\"`, strings.ReplaceAll(table, `\"`, `\"\"`)))\n\t}\n\tif err != nil || table == \"\" {\n\t\t// Fall back to generic resources table\n\t\trows, err = s.db.Query(\"SELECT id FROM resources WHERE resource_type = ?\", resourceType)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tdefer rows.Close()\n\n\tvar ids []string\n\tfor rows.Next() {\n\t\tvar id string\n\t\tif err := rows.Scan(&id); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tids = append(ids, id)\n\t}\n\treturn ids, rows.Err()\n}\n\n// ListField returns values of a named field from a resource's domain table,\n// or from the generic resources table via json_extract when no typed column\n// exists. Used by dependent sync to iterate parents when a spec-declared\n// walker extracts a non-PK field (Endpoint.Walker.KeyField in the upstream\n// printing-press repo) for the child path's placeholder.\n//\n// Defense in depth: field is validated against validIdentifierRE at entry\n// — the regex pins it to SQL-safe identifier shape covering both the\n// typed-column primary path AND the json_extract fallback (where\n// pragma_table_info validation would never run if the parent's domain\n// table doesn't exist yet). resourceType is never interpolated into SQL\n// directly; we resolve it to a real table name via a parameterized\n// sqlite_master lookup. Only validated names are substituted\n// (double-quoted) into the SELECT. Mirrors ListIDs's defense pattern so\n// callers may pass any string.\nfunc (s *Store) ListField(resourceType, field string) ([]string, error) {\n\tif !validIdentifierRE.MatchString(field) {\n\t\treturn nil, fmt.Errorf(\"ListField: invalid field name %q (must match %s)\", field, validIdentifierRE.String())\n\t}\n\tvar table string\n\terr := s.db.QueryRow(\n\t\t`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,\n\t\tresourceType,\n\t).Scan(&table)\n\tvar rows *sql.Rows\n\tif err == nil && table != \"\" {\n\t\t// Validate the column exists on the resolved table before splicing\n\t\t// it into the SELECT. pragma_table_info is parameterizable.\n\t\tvar colName string\n\t\tcolErr := s.db.QueryRow(\n\t\t\t`SELECT name FROM pragma_table_info(?) WHERE name=?`,\n\t\t\ttable, field,\n\t\t).Scan(&colName)\n\t\tif colErr == nil && colName != \"\" {\n\t\t\tqTable := strings.ReplaceAll(table, `\"`, `\"\"`)\n\t\t\tqCol := strings.ReplaceAll(colName, `\"`, `\"\"`)\n\t\t\t// DISTINCT: callers iterate the returned values as parent keys\n\t\t\t// for child-resource fan-out. Multiple parent rows sharing a\n\t\t\t// key_field value (legal for non-PK fields) would otherwise\n\t\t\t// cause the child endpoint to be fetched once per duplicate row.\n\t\t\trows, err = s.db.Query(fmt.Sprintf(\n\t\t\t\t`SELECT DISTINCT \"%s\" FROM \"%s\" WHERE \"%s\" IS NOT NULL AND \"%s\" != ''`,\n\t\t\t\tqCol, qTable, qCol, qCol,\n\t\t\t))\n\t\t} else {\n\t\t\terr = colErr\n\t\t}\n\t}\n\tif err != nil || rows == nil {\n\t\t// Fall back to generic resources table via json_extract. Path is\n\t\t// Sprintf'd into the SQL string (matches ResolveByName below).\n\t\t// DISTINCT for the same reason as the typed-column path above.\n\t\tfallback := fmt.Sprintf(\n\t\t\t`SELECT DISTINCT json_extract(data, '$.%s') FROM resources WHERE resource_type = ? AND json_extract(data, '$.%s') IS NOT NULL`,\n\t\t\tfield, field,\n\t\t)\n\t\trows, err = s.db.Query(fallback, resourceType)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tdefer rows.Close()\n\n\tvar values []string\n\tfor rows.Next() {\n\t\tvar v sql.NullString\n\t\tif err := rows.Scan(&v); err == nil && v.Valid && v.String != \"\" {\n\t\t\tvalues = append(values, v.String)\n\t\t}\n\t}\n\treturn values, rows.Err()\n}\n\n// ListFieldSets returns row-correlated values from the generic resources\n// table. Dependent sync uses this for multi-placeholder paths where values\n// such as owner/repo or server/webapp must stay paired per parent row.\nfunc (s *Store) ListFieldSets(resourceType string, fields []string) ([]map[string]string, error) {\n\tif len(fields) == 0 {\n\t\treturn nil, nil\n\t}\n\tfor _, field := range fields {\n\t\tif !validIdentifierRE.MatchString(field) {\n\t\t\treturn nil, fmt.Errorf(\"ListFieldSets: invalid field name %q (must match %s)\", field, validIdentifierRE.String())\n\t\t}\n\t}\n\n\trows, err := s.db.Query(`SELECT id, data FROM resources WHERE resource_type = ?`, resourceType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar out []map[string]string\n\tseenRows := map[string]bool{}\n\tfor rows.Next() {\n\t\tvar id string\n\t\tvar data []byte\n\t\tif err := rows.Scan(&id, &data); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvar obj map[string]any\n\t\tif len(data) > 0 {\n\t\t\tvar err error\n\t\t\tobj, err = DecodeJSONObject(data)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"decode %s parent row %s: %w\", resourceType, id, err)\n\t\t\t}\n\t\t}\n\t\tvalues := make(map[string]string, len(fields))\n\t\tcomplete := true\n\t\tfor _, field := range fields {\n\t\t\tvar value any\n\t\t\tif field == \"id\" {\n\t\t\t\tvalue = id\n\t\t\t} else {\n\t\t\t\tvalue = LookupFieldValue(obj, field)\n\t\t\t}\n\t\t\tvalueString := ResourceIDString(value)\n\t\t\tif value == nil || valueString == \"\" {\n\t\t\t\tcomplete = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tvalues[field] = valueString\n\t\t}\n\t\tif complete {\n\t\t\tkeyParts := make([]string, 0, len(fields))\n\t\t\tfor _, field := range fields {\n\t\t\t\tkeyParts = append(keyParts, values[field])\n\t\t\t}\n\t\t\tkey := strings.Join(keyParts, \"\\x00\")\n\t\t\tif seenRows[key] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tseenRows[key] = true\n\t\t\tout = append(out, values)\n\t\t}\n\t}\n\treturn out, rows.Err()\n}\n\n// GetLastSyncedAt returns the last sync timestamp for a resource type.\nfunc (s *Store) GetLastSyncedAt(resourceType string) string {\n\tvar ts sql.NullString\n\ts.db.QueryRow(\"SELECT last_synced_at FROM sync_state WHERE resource_type = ?\", resourceType).Scan(&ts)\n\tif ts.Valid {\n\t\treturn ts.String\n\t}\n\treturn \"\"\n}\n\n// ClearSyncCursors resets all sync state for a full resync.\nfunc (s *Store) ClearSyncCursors() error {\n\ts.writeMu.Lock()\n\tdefer s.writeMu.Unlock()\n\t_, err := s.db.Exec(\"DELETE FROM sync_state\")\n\treturn err\n}\n\n// Query executes a raw SQL query and returns the rows.\n// Used by workflow commands that need custom queries against the local store.\nfunc (s *Store) Query(query string, args ...any) (*sql.Rows, error) {\n\treturn s.db.Query(query, args...)\n}\n\nfunc (s *Store) Count(resourceType string) (int, error) {\n\tvar count int\n\terr := s.db.QueryRow(\n\t\t`SELECT COUNT(*) FROM resources WHERE resource_type = ?`,\n\t\tresourceType,\n\t).Scan(&count)\n\treturn count, err\n}\n\nfunc (s *Store) Status() (map[string]int, error) {\n\trows, err := s.db.Query(\n\t\t`SELECT resource_type, COUNT(*) FROM resources GROUP BY resource_type ORDER BY resource_type`,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tstatus := make(map[string]int)\n\tfor rows.Next() {\n\t\tvar rt string\n\t\tvar count int\n\t\tif err := rows.Scan(&rt, &count); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tstatus[rt] = count\n\t}\n\treturn status, rows.Err()\n}\n\n// ResolveByName resolves a human-readable name to a UUID from synced data.\n// If the input is already a UUID, it is returned as-is.\n// matchFields are JSON field names to search against (e.g., \"name\", \"key\", \"email\").\n//\n// json_extract path components cannot be bound as SQL parameters, so each\n// field is validated against validIdentifierRE before being spliced into\n// the query.\nfunc (s *Store) ResolveByName(resourceType string, input string, matchFields ...string) (string, error) {\n\tif IsUUID(input) {\n\t\treturn input, nil\n\t}\n\n\tvar matches []string\n\tfor _, field := range matchFields {\n\t\tif !validIdentifierRE.MatchString(field) {\n\t\t\tcontinue\n\t\t}\n\t\tquery := fmt.Sprintf(\n\t\t\t`SELECT id FROM resources WHERE resource_type = ? AND LOWER(json_extract(data, '$.%s')) = LOWER(?)`,\n\t\t\tfield,\n\t\t)\n\t\trows, err := s.db.Query(query, resourceType, input)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tfor rows.Next() {\n\t\t\tvar id string\n\t\t\tif rows.Scan(&id) == nil {\n\t\t\t\t// Deduplicate\n\t\t\t\tfound := false\n\t\t\t\tfor _, m := range matches {\n\t\t\t\t\tif m == id {\n\t\t\t\t\t\tfound = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !found {\n\t\t\t\t\tmatches = append(matches, id)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\trows.Close()\n\t}\n\n\tswitch len(matches) {\n\tcase 0:\n\t\treturn \"\", fmt.Errorf(\"%s %q not found in local store. Run 'sync' first, or use the UUID directly\", resourceType, input)\n\tcase 1:\n\t\treturn matches[0], nil\n\tdefault:\n\t\thint := matches[0]\n\t\tif len(matches) > 5 {\n\t\t\thint = strings.Join(matches[:5], \", \") + \"...\"\n\t\t} else {\n\t\t\thint = strings.Join(matches, \", \")\n\t\t}\n\t\treturn \"\", fmt.Errorf(\"ambiguous: %q matches %d %s entries (%s). Use the exact UUID instead\", input, len(matches), resourceType, hint)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":46359,"content_sha256":"e1bec80491d278bf78c00a36dcbd6df0afe44fa1f4ddab17d630d4346b1a93e2"},{"filename":"internal/store/upsert_batch_test.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage store\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// TestStoreWrite_NoSQLITE_BUSY_HighConcurrency exercises the writeMu serialization\n// guarantee: 16 fetcher-style goroutines hammer the store with a mix of\n// UpsertBatch, SaveSyncState, and SaveSyncCursor calls. Before the mutex\n// fix, this test reproduces SQLITE_BUSY at default sync concurrency on\n// pure-Go SQLite (modernc.org/sqlite + WAL) because multiple writers\n// race for the WAL lock and busy_timeout retries are not exhaustive.\n//\n// Run under `go test -race` to catch any data races on Store fields.\nfunc TestStoreWrite_NoSQLITE_BUSY_HighConcurrency(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tconst goroutines = 16\n\tconst itemsPerBatch = 5\n\n\tvar wg sync.WaitGroup\n\terrCh := make(chan error, goroutines*3)\n\n\tfor g := 0; g \u003c goroutines; g++ {\n\t\twg.Add(1)\n\t\tgo func(gid int) {\n\t\t\tdefer wg.Done()\n\t\t\trt := fmt.Sprintf(\"rt_%d\", gid)\n\t\t\titems := make([]json.RawMessage, 0, itemsPerBatch)\n\t\t\tfor i := 0; i \u003c itemsPerBatch; i++ {\n\t\t\t\titems = append(items, json.RawMessage(fmt.Sprintf(`{\"id\": \"g%d-i%d\"}`, gid, i)))\n\t\t\t}\n\t\t\tif _, _, err := s.UpsertBatch(rt, items); err != nil {\n\t\t\t\terrCh \u003c- fmt.Errorf(\"UpsertBatch goroutine %d: %w\", gid, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := s.SaveSyncState(rt, fmt.Sprintf(\"cursor-%d\", gid), itemsPerBatch); err != nil {\n\t\t\t\terrCh \u003c- fmt.Errorf(\"SaveSyncState goroutine %d: %w\", gid, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err := s.SaveSyncCursor(rt, fmt.Sprintf(\"cursor2-%d\", gid)); err != nil {\n\t\t\t\terrCh \u003c- fmt.Errorf(\"SaveSyncCursor goroutine %d: %w\", gid, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t}(g)\n\t}\n\twg.Wait()\n\tclose(errCh)\n\n\tfor err := range errCh {\n\t\tif err == nil {\n\t\t\tcontinue\n\t\t}\n\t\t// SQLITE_BUSY surfaces as \"database is locked\" or \"SQLITE_BUSY\"\n\t\t// in the error message — assert neither occurs.\n\t\tmsg := err.Error()\n\t\tif strings.Contains(msg, \"SQLITE_BUSY\") || strings.Contains(strings.ToLower(msg), \"database is locked\") {\n\t\t\tt.Fatalf(\"got SQLITE_BUSY-class error under concurrent writers: %v\", err)\n\t\t}\n\t\tt.Fatalf(\"unexpected error under concurrent writers: %v\", err)\n\t}\n\n\t// Verify all rows persisted: goroutines * itemsPerBatch in the generic\n\t// resources table.\n\tdb := s.DB()\n\tvar total int\n\tif err := db.QueryRow(`SELECT COUNT(*) FROM resources`).Scan(&total); err != nil {\n\t\tt.Fatalf(\"count resources: %v\", err)\n\t}\n\tif total != goroutines*itemsPerBatch {\n\t\tt.Fatalf(\"resources total = %d, want %d\", total, goroutines*itemsPerBatch)\n\t}\n}\n\n// TestStoreWrite_PanicReleasesLock confirms that a panic inside a locked\n// section unwinds via defer s.writeMu.Unlock() so subsequent writers can\n// proceed. A leaked lock would deadlock the second call indefinitely.\nfunc TestStoreWrite_PanicReleasesLock(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// Trigger panic by passing a nil *Store method receiver indirectly:\n\t// we call UpsertBatch with malformed JSON that survives Unmarshal\n\t// (it's wrapped in skipped-count handling) — there's no easy panic\n\t// path inside a locked section that doesn't also corrupt state, so\n\t// we instead simulate the post-panic state by manually locking and\n\t// unlocking, then assert subsequent calls succeed.\n\tfunc() {\n\t\tdefer func() {\n\t\t\trecover()\n\t\t}()\n\t\ts.writeMu.Lock()\n\t\tdefer s.writeMu.Unlock()\n\t\tpanic(\"simulated writer panic\")\n\t}()\n\n\t// Subsequent writer must not block.\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tif _, _, err := s.UpsertBatch(\"post_panic\", []json.RawMessage{json.RawMessage(`{\"id\": \"x\"}`)}); err != nil {\n\t\t\tt.Errorf(\"post-panic UpsertBatch: %v\", err)\n\t\t}\n\t\tclose(done)\n\t}()\n\t\u003c-done\n}\n\n// TestUpsertBatch_TemplatedIDFieldOverrideWins exercises the\n// per-resource ID-field override. When the spec author annotates a\n// path-item with x-resource-id, the profiler emits SyncableResource.IDField,\n// the generator templates this into resourceIDFieldOverrides, and\n// UpsertBatch consults that map first. This test seeds the override map\n// at runtime (since the generated table here may or may not declare any\n// override) to assert the lookup path itself works.\nfunc TestUpsertBatch_TemplatedIDFieldOverrideWins(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\t// Inject a runtime override for a synthetic resource. Item carries\n\t// no generic-fallback field (no id/name/uuid/...) — only a custom\n\t// \"ticker\" field. Without the override, all 3 items would be\n\t// dropped as PK-unresolved; with it, all 3 land.\n\tprev, hadPrev := resourceIDFieldOverrides[\"overrideTest\"]\n\tresourceIDFieldOverrides[\"overrideTest\"] = \"ticker\"\n\tdefer func() {\n\t\tif hadPrev {\n\t\t\tresourceIDFieldOverrides[\"overrideTest\"] = prev\n\t\t} else {\n\t\t\tdelete(resourceIDFieldOverrides, \"overrideTest\")\n\t\t}\n\t}()\n\n\titems := []json.RawMessage{\n\t\tjson.RawMessage(`{\"ticker\": \"AAPL\", \"price\": 100}`),\n\t\tjson.RawMessage(`{\"ticker\": \"GOOG\", \"price\": 200}`),\n\t\tjson.RawMessage(`{\"ticker\": \"MSFT\", \"price\": 300}`),\n\t}\n\tstored, extractFailures, err := s.UpsertBatch(\"overrideTest\", items)\n\tif err != nil {\n\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t}\n\tif stored != 3 {\n\t\tt.Fatalf(\"stored = %d, want 3 (templated override should resolve all PKs)\", stored)\n\t}\n\tif extractFailures != 0 {\n\t\tt.Fatalf(\"extractFailures = %d, want 0\", extractFailures)\n\t}\n}\n\n// TestUpsertBatch_GenericFallbackList covers each name in the reduced\n// fallback list. The kalshi-accreted names (ticker/event_ticker/series_ticker)\n// were dropped because the user owns kalshi and will regenerate\n// it with x-resource-id annotations; this test pins what the generic list\n// is now responsible for so a future trim doesn't silently break unannotated\n// specs.\nfunc TestUpsertBatch_GenericFallbackList(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\tfor _, key := range []string{\"id\", \"ID\", \"gid\", \"sid\", \"uid\", \"uuid\", \"guid\", \"name\", \"slug\", \"key\", \"code\"} {\n\t\tt.Run(key, func(t *testing.T) {\n\t\t\trt := \"fallback_\" + key\n\t\t\titems := []json.RawMessage{\n\t\t\t\tjson.RawMessage(fmt.Sprintf(`{%q: %q}`, key, \"value-1\")),\n\t\t\t\tjson.RawMessage(fmt.Sprintf(`{%q: %q}`, key, \"value-2\")),\n\t\t\t}\n\t\t\tstored, extractFailures, err := s.UpsertBatch(rt, items)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"UpsertBatch(%q): %v\", key, err)\n\t\t\t}\n\t\t\tif stored != 2 {\n\t\t\t\tt.Fatalf(\"stored = %d, want 2 (fallback %q must resolve)\", stored, key)\n\t\t\t}\n\t\t\tif extractFailures != 0 {\n\t\t\t\tt.Fatalf(\"extractFailures = %d, want 0\", extractFailures)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Negative: API-specific names dropped must NOT resolve.\n\t// Spec authors annotate these via x-resource-id instead.\n\tfor _, key := range []string{\"ticker\", \"event_ticker\", \"series_ticker\"} {\n\t\tt.Run(\"dropped_\"+key, func(t *testing.T) {\n\t\t\trt := \"dropped_\" + key\n\t\t\titems := []json.RawMessage{\n\t\t\t\tjson.RawMessage(fmt.Sprintf(`{%q: %q}`, key, \"v1\")),\n\t\t\t}\n\t\t\tstored, extractFailures, err := s.UpsertBatch(rt, items)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"UpsertBatch(%q): %v\", key, err)\n\t\t\t}\n\t\t\tif stored != 0 {\n\t\t\t\tt.Fatalf(\"stored = %d, want 0 (%q must NOT be in the generic fallback list)\", stored, key)\n\t\t\t}\n\t\t\tif extractFailures != 1 {\n\t\t\t\tt.Fatalf(\"extractFailures = %d, want 1 (%q drop must surface as extract failure)\", extractFailures, key)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestUpsertBatch_PreservesLargeIntegerResourceIDs(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\titems := []json.RawMessage{\n\t\tjson.RawMessage(`{\"id\": 55043301, \"name\": \"large\"}`),\n\t\tjson.RawMessage(`{\"id\": 100, \"name\": \"small\"}`),\n\t\tjson.RawMessage(`{\"id\": 7, \"name\": \"tiny\"}`),\n\t}\n\tstored, extractFailures, err := s.UpsertBatch(\"numeric_ids\", items)\n\tif err != nil {\n\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t}\n\tif stored != len(items) {\n\t\tt.Fatalf(\"stored = %d, want %d\", stored, len(items))\n\t}\n\tif extractFailures != 0 {\n\t\tt.Fatalf(\"extractFailures = %d, want 0\", extractFailures)\n\t}\n\n\trows, err := s.DB().Query(`SELECT id FROM resources WHERE resource_type = ? ORDER BY CAST(id AS INTEGER)`, \"numeric_ids\")\n\tif err != nil {\n\t\tt.Fatalf(\"query resources: %v\", err)\n\t}\n\tdefer rows.Close()\n\n\tvar got []string\n\tfor rows.Next() {\n\t\tvar id string\n\t\tif err := rows.Scan(&id); err != nil {\n\t\t\tt.Fatalf(\"scan id: %v\", err)\n\t\t}\n\t\tgot = append(got, id)\n\t}\n\tif err := rows.Err(); err != nil {\n\t\tt.Fatalf(\"rows: %v\", err)\n\t}\n\twant := []string{\"7\", \"100\", \"55043301\"}\n\tif !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"resource ids = %v, want %v\", got, want)\n\t}\n\n\tvar literalMatches int\n\tif err := s.DB().QueryRow(\n\t\t`SELECT COUNT(*) FROM resources WHERE resource_type = ? AND id IN ('55043301', '100', '7')`,\n\t\t\"numeric_ids\",\n\t).Scan(&literalMatches); err != nil {\n\t\tt.Fatalf(\"count literal id matches: %v\", err)\n\t}\n\tif literalMatches != len(items) {\n\t\tt.Fatalf(\"literal id matches = %d, want %d\", literalMatches, len(items))\n\t}\n}\n\n// TestUpsertBatch_ExtractFailuresReturnedForPerItemMisses pins the third\n// return value: items that survive JSON unmarshal but have no extractable\n// PK (templated override AND generic fallback both miss) bump\n// extractFailures. The sync.go.tmpl call site uses this to emit the\n// per-resource primary_key_unresolved sync_anomaly the first time silent\n// drops occur.\nfunc TestUpsertBatch_ExtractFailuresReturnedForPerItemMisses(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"data.db\")\n\ts, err := Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open: %v\", err)\n\t}\n\tdefer s.Close()\n\n\titems := []json.RawMessage{\n\t\tjson.RawMessage(`{\"id\": \"ok-1\"}`),\n\t\tjson.RawMessage(`{\"some_random_field\": \"no-pk-here\"}`),\n\t\tjson.RawMessage(`{\"id\": \"ok-2\"}`),\n\t\tjson.RawMessage(`{\"another_field\": 42}`),\n\t}\n\tstored, extractFailures, err := s.UpsertBatch(\"mixed_extraction\", items)\n\tif err != nil {\n\t\tt.Fatalf(\"UpsertBatch: %v\", err)\n\t}\n\tif stored != 2 {\n\t\tt.Fatalf(\"stored = %d, want 2 (only items with id should land)\", stored)\n\t}\n\tif extractFailures != 2 {\n\t\tt.Fatalf(\"extractFailures = %d, want 2 (two items have no extractable PK)\", extractFailures)\n\t}\n}\n","content_type":"text/plain; charset=utf-8","language":"go","size":10528,"content_sha256":"b854fece92205349a3a7435c7a943306a31f871867652800e4ba24c7884a80e6"},{"filename":"internal/types/types.go","content":"// Copyright 2026 markvandeven and contributors. Licensed under Apache-2.0. See LICENSE.\n// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.\n\npackage types\n","content_type":"text/plain; charset=utf-8","language":"go","size":202,"content_sha256":"5c6eab8fde420ddfc5ac5780a557a9af3d1c6880c58633e38ddbb6e375f9a4b2"},{"filename":"Makefile","content":".PHONY: build test lint install clean\n\nbuild:\n\tgo build -o bin/tenderned-pp-cli ./cmd/tenderned-pp-cli\n\ntest:\n\tgo test ./...\n\nlint:\n\tgolangci-lint run\n\ninstall:\n\tgo install ./cmd/tenderned-pp-cli\n\nclean:\n\trm -rf bin/\n\nbuild-mcp:\n\tgo build -o bin/tenderned-pp-mcp ./cmd/tenderned-pp-mcp\n\ninstall-mcp:\n\tgo install ./cmd/tenderned-pp-mcp\n\nbuild-all: build build-mcp\n","content_type":"text/plain; charset=utf-8","language":"makefile","size":363,"content_sha256":"3f85b6f3be536efeb64a6f30da11512f0b314ab7d39bad0eca848beac04e5a89"},{"filename":"manifest.json","content":"{\n \"manifest_version\": \"0.3\",\n \"name\": \"tenderned-pp-mcp\",\n \"display_name\": \"TenderNed\",\n \"version\": \"4.18.1\",\n \"description\": \"Dutch public-tender CLI with offline search, document corpus, and the sub-threshold long tail TED never sees.\",\n \"author\": {\n \"name\": \"CLI Printing Press\"\n },\n \"license\": \"Apache-2.0\",\n \"server\": {\n \"type\": \"binary\",\n \"entry_point\": \"bin/tenderned-pp-mcp\",\n \"mcp_config\": {\n \"command\": \"${__dirname}/bin/tenderned-pp-mcp\",\n \"args\": []\n }\n },\n \"compatibility\": {\n \"claude_desktop\": \">=1.0.0\",\n \"platforms\": [\n \"darwin\",\n \"linux\",\n \"win32\"\n ]\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":635,"content_sha256":"6676c2d7563564794d892df9d9e82e1005ea2abe31109992522458dacbb02f24"},{"filename":"NOTICE","content":"tenderned-pp-cli\nCopyright 2026 markvandeven and contributors\n\nCreated by markvandeven (@markvandeven).\n\nThis CLI was generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press)\nby Matt Van Horn and Trevin Chow. The Non-Obvious Insight, domain archetype detection, workflow commands,\nand behavioral insight commands were produced by the printing press's creative vision engine.\n\nCLI Printing Press is licensed separately under the MIT License.\n","content_type":"text/plain; charset=utf-8","language":null,"size":465,"content_sha256":"417e1bad10358b361be1d6979a08978577a17b982dba8562ef77f40e190e4074"},{"filename":"README.md","content":"# Tenderned CLI\n\nEvery Dutch public tender, with the sub-threshold long tail that EU TED never sees, in a local-first CLI you can pipe.\n\nTenderNed is the Dutch national public procurement platform run by PIANOo / Ministerie van EZK. Every Dutch contracting authority must publish above- and below-threshold tender notices here. Above-threshold notices are forwarded to EU TED; the sub-threshold long tail (€40k–€220k) is TenderNed-only.\n\nThis CLI covers the unauthenticated TNS publication webservice (search/list/filter publications, document download, contracting-authority directory, RSS feed) and the Basic-auth eForms XML endpoint. Data is CC-0 public domain.\n\nCreated by [@markvandeven](https://github.com/markvandeven) (markvandeven).\n\n## Install\n\nThe recommended path installs both the `tenderned-pp-cli` binary and the `pp-tenderned` agent skill (Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, and other agents supported by the upstream [`skills`](https://github.com/vercel-labs/skills) CLI) in one shot:\n\n```bash\nnpx -y @mvanhorn/printing-press-library install tenderned\n```\n\nFor CLI only (no skill):\n\n```bash\nnpx -y @mvanhorn/printing-press-library install tenderned --cli-only\n```\n\nFor skill only — installs the skill into the same agents as the default command above, but skips the CLI binary (use this to update or reinstall just the skill):\n\n```bash\nnpx -y @mvanhorn/printing-press-library install tenderned --skill-only\n```\n\nTo constrain the skill install to one or more specific agents (repeatable — agent names match the [`skills`](https://github.com/vercel-labs/skills) CLI):\n\n```bash\nnpx -y @mvanhorn/printing-press-library install tenderned --agent claude-code\nnpx -y @mvanhorn/printing-press-library install tenderned --agent claude-code --agent codex\n```\n\n### Without Node\n\nThe generated install path is category-agnostic until this CLI is published. If `npx` is not available before publish, install Node or use the category-specific Go fallback from the public-library entry after publish.\n\n### Pre-built binary\n\nDownload a pre-built binary for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/tenderned-current). On macOS, clear the Gatekeeper quarantine: `xattr -d com.apple.quarantine \u003cbinary>`. On Unix, mark it executable: `chmod +x \u003cbinary>`.\n\n\u003c!-- pp-hermes-install-anchor -->\n## Install for Hermes\n\nFrom the Hermes CLI:\n\n```bash\nhermes skills install mvanhorn/printing-press-library/cli-skills/pp-tenderned --force\n```\n\nInside a Hermes chat session:\n\n```bash\n/skills install mvanhorn/printing-press-library/cli-skills/pp-tenderned --force\n```\n\n## Install for OpenClaw\n\nTell your OpenClaw agent (copy this):\n\n```\nInstall the pp-tenderned skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-tenderned. The skill defines how its required CLI can be installed.\n```\n\n## Use with Claude Desktop\n\nThis CLI ships an [MCPB](https://github.com/modelcontextprotocol/mcpb) bundle — Claude Desktop's standard format for one-click MCP extension installs (no JSON config required).\n\nTo install:\n\n1. Download the `.mcpb` for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/tenderned-current).\n2. Double-click the `.mcpb` file. Claude Desktop opens and walks you through the install.\n\nRequires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.\n\n\u003cdetails>\n\u003csummary>Manual JSON config (advanced)\u003c/summary>\n\nIf you can't use the MCPB bundle (older Claude Desktop, unsupported platform), install the MCP binary and configure it manually.\n\n\nInstall the MCP binary from this CLI's published public-library entry or pre-built release.\n\nAdd to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):\n\n```json\n{\n \"mcpServers\": {\n \"tenderned\": {\n \"command\": \"tenderned-pp-mcp\"\n }\n }\n}\n```\n\n\u003c/details>\n\n## Quick Start\n\n### 1. Install\n\nSee [Install](#install) above.\n\n### 2. Verify Setup\n\n```bash\ntenderned-pp-cli doctor\n```\n\nThis checks your configuration.\n\n### 3. Try Your First Command\n\n```bash\ntenderned-pp-cli buyers list\n```\n\n## Usage\n\nRun `tenderned-pp-cli --help` for the full command reference and flag list.\n\n## Commands\n\n### buyers\n\nBrowse contracting authorities (aanbestedende diensten) — Dutch public buyers\n\n- **`tenderned-pp-cli buyers get`** - Fetch one contracting authority by ID\n- **`tenderned-pp-cli buyers list`** - List Dutch contracting authorities (paginated)\n\n### docs\n\nList and download tender documents (bestek, PvE, evaluation criteria, Q&A)\n\n- **`tenderned-pp-cli docs download`** - Download all documents for one publication as a zip archive\n- **`tenderned-pp-cli docs get`** - Download a single document's binary content (PDF/Word/etc.)\n- **`tenderned-pp-cli docs list`** - List attached documents for one publication\n\n### notices\n\nSearch, list and fetch tender notices (aankondigingen) from TenderNed — mirrors 'eu-tenders notices' for the Dutch market\n\n- **`tenderned-pp-cli notices get`** - Fetch full structured metadata for one publication\n- **`tenderned-pp-cli notices list`** - Search and list tender publications with rich filters (CPV, dates, buyer, procedure, scope)\n\n\n## Output Formats\n\n```bash\n# Human-readable table (default in terminal, JSON when piped)\ntenderned-pp-cli buyers list\n\n# JSON for scripting and agents\ntenderned-pp-cli buyers list --json\n\n# Filter to specific fields\ntenderned-pp-cli buyers list --json --select id,name,status\n\n# Dry run — show the request without sending\ntenderned-pp-cli buyers list --dry-run\n\n# Agent mode — JSON + compact + no prompts in one flag\ntenderned-pp-cli buyers list --agent\n```\n\n## Agent Usage\n\nThis CLI is designed for AI agent consumption:\n\n- **Non-interactive** - never prompts, every input is a flag\n- **Pipeable** - `--json` output to stdout, errors to stderr\n- **Filterable** - `--select id,name` returns only fields you need\n- **Previewable** - `--dry-run` shows the request without sending\n- **Read-only by default** - this CLI does not create, update, delete, publish, send, or mutate remote resources\n- **Offline-friendly** - sync/search commands can use the local SQLite store when available\n- **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set\n\nExit codes: `0` success, `2` usage error, `3` not found, `5` API error, `7` rate limited, `10` config error.\n\n## Health Check\n\n```bash\ntenderned-pp-cli doctor\n```\n\nVerifies configuration and connectivity to the API.\n\n## Configuration\n\nConfig file: `~/.config/tenderned-pp-cli/config.toml`\n\nStatic request headers can be configured under `headers`; per-command header overrides take precedence.\n\n## Troubleshooting\n**Not found errors (exit code 3)**\n- Check the resource ID is correct\n- Run the `list` command to see available items\n\n---\n\nGenerated by [CLI Printing Press](https://github.com/mvanhorn/cli-printing-press)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7060,"content_sha256":"6bf765f6d5f949ed88d7a7c7b60b89fbadbb9adff7bff68d33f007aec2672d4d"},{"filename":"spec.yaml","content":"name: tenderned\ndisplay_name: TenderNed\ndescription: |-\n Every Dutch public tender, with the sub-threshold long tail that EU TED never sees, in a local-first CLI you can pipe.\n\n TenderNed is the Dutch national public procurement platform run by PIANOo / Ministerie van EZK. Every Dutch contracting authority must publish above- and below-threshold tender notices here. Above-threshold notices are forwarded to EU TED; the sub-threshold long tail (€40k–€220k) is TenderNed-only.\n\n This CLI covers the unauthenticated TNS publication webservice (search/list/filter publications, document download, contracting-authority directory, RSS feed) and the Basic-auth eForms XML endpoint. Data is CC-0 public domain.\ncli_description: Dutch public-tender CLI with offline search, document corpus, and the sub-threshold long tail TED never sees.\nversion: \"0.1.0\"\nbase_url: \"https://www.tenderned.nl/papi/tenderned-rs-tns/v2\"\n\nregions: [\"NL\"]\napi_language: \"nl\"\n\nauth:\n type: none\n\nconfig:\n format: toml\n path: \"~/.config/tenderned-pp-cli/config.toml\"\n\nresources:\n notices:\n description: \"Search, list and fetch tender notices (aankondigingen) from TenderNed — mirrors 'eu-tenders notices' for the Dutch market\"\n endpoints:\n list:\n method: GET\n path: \"/publicaties\"\n description: \"Search and list tender publications with rich filters (CPV, dates, buyer, procedure, scope)\"\n params:\n - name: search\n type: string\n description: \"Full-text keyword search across title and description\"\n - name: cpvCodes\n type: string\n description: \"CPV code in 8-digit-plus-check-digit form (e.g. 45000000-7). Repeat for multi-CPV intersection.\"\n - name: publicatieType\n type: string\n description: \"Publication type filter (AAO=open call, AGO=awarded, VOP=prior info, RVO=cancellation, WNO=ex-ante, MAC=market consultation, REC=rectification)\"\n - name: typeOpdracht\n type: string\n description: \"Contract type: D=services (Diensten), L=supplies (Leveringen), W=works (Werken)\"\n - name: procedure\n type: string\n description: \"Procedure code (Openbaar, Niet-openbaar, Onderhandeling, Concurrentiegerichte dialoog, etc.)\"\n - name: nationaalOfEuropees\n type: string\n description: \"Scope: NA=national-only (sub-threshold), EU=European (above-threshold). National-only notices never reach TED.\"\n - name: publicatieDatumVanaf\n flag_name: since\n type: string\n description: \"Earliest publication date (YYYY-MM-DD)\"\n - name: publicatieDatumTot\n flag_name: until\n type: string\n description: \"Latest publication date (YYYY-MM-DD)\"\n - name: sluitingsDatumVanaf\n flag_name: closing-since\n type: string\n description: \"Earliest closing date (YYYY-MM-DD)\"\n - name: sluitingsDatumTot\n flag_name: closing-until\n type: string\n description: \"Latest closing date (YYYY-MM-DD)\"\n - name: aanbestedendeDienstId\n flag_name: buyer-id\n type: string\n description: \"Filter by contracting-authority UUID (use 'buyers list' to find IDs)\"\n - name: typeAanbestedendeDienst\n flag_name: buyer-type\n type: string\n description: \"Filter by contracting-authority type code\"\n - name: page\n type: int\n description: \"Page number (0-based)\"\n - name: size\n type: int\n description: \"Page size (default 50, max 100)\"\n pagination:\n type: page\n cursor_param: page\n limit_param: size\n has_more_field: last\n response:\n type: object\n item: PublicationPage\n\n get:\n method: GET\n path: \"/publicaties/{publicatieId}\"\n description: \"Fetch full structured metadata for one publication\"\n params:\n - name: publicatieId\n type: int\n required: true\n positional: true\n description: \"TenderNed publication ID (e.g. 425283)\"\n response:\n type: object\n item: Publication\n\n docs:\n description: \"List and download tender documents (bestek, PvE, evaluation criteria, Q&A)\"\n endpoints:\n list:\n method: GET\n path: \"/publicaties/{publicatieId}/documenten\"\n description: \"List attached documents for one publication\"\n params:\n - name: publicatieId\n type: int\n required: true\n positional: true\n description: \"Publication ID\"\n response:\n type: object\n item: DocumentList\n\n get:\n method: GET\n path: \"/publicaties/{publicatieId}/documenten/{documentId}/content\"\n description: \"Download a single document's binary content (PDF/Word/etc.)\"\n params:\n - name: publicatieId\n type: int\n required: true\n positional: true\n description: \"Publication ID\"\n - name: documentId\n type: string\n required: true\n positional: true\n description: \"Document ID (from 'documents list')\"\n response:\n type: object\n item: BinaryContent\n\n download:\n method: GET\n path: \"/publicaties/{publicatieId}/documenten/zip\"\n description: \"Download all documents for one publication as a zip archive\"\n params:\n - name: publicatieId\n type: int\n required: true\n positional: true\n description: \"Publication ID\"\n response:\n type: object\n item: BinaryContent\n\n buyers:\n description: \"Browse contracting authorities (aanbestedende diensten) — Dutch public buyers\"\n endpoints:\n list:\n method: GET\n path: \"/aanbestedendediensten\"\n description: \"List Dutch contracting authorities (paginated)\"\n params:\n - name: page\n type: int\n description: \"Page number (0-based)\"\n - name: size\n type: int\n description: \"Page size (default 50, max 100)\"\n pagination:\n type: page\n cursor_param: page\n limit_param: size\n has_more_field: last\n response:\n type: object\n item: BuyerPage\n\n get:\n method: GET\n path: \"/aanbestedendediensten/{aanbestedendedienstId}\"\n description: \"Fetch one contracting authority by ID\"\n params:\n - name: aanbestedendedienstId\n type: string\n required: true\n positional: true\n description: \"Contracting authority UUID\"\n response:\n type: object\n item: Buyer\n\nmcp:\n transport: [stdio, http]\n orchestration: endpoint-mirror\n","content_type":"application/yaml; charset=utf-8","language":"yaml","size":6983,"content_sha256":"5d98a1ee6e331681cd6759533e987803ab56777836e5d592b297adf86024d4f1"},{"filename":"tools-manifest.json","content":"{\n \"api_name\": \"tenderned\",\n \"base_url\": \"https://www.tenderned.nl/papi/tenderned-rs-tns/v2\",\n \"description\": \"Every Dutch public tender, with the sub-threshold long tail that EU TED never sees, in a local-first CLI you can pipe.\\n\\nTenderNed is the Dutch national public procurement platform run by PIANOo / Ministerie van EZK. Every Dutch contracting authority must publish above- and below-threshold tender notices here. Above-threshold notices are forwarded to EU TED; the sub-threshold long tail (€40k–€220k) is TenderNed-only.\\n\\nThis CLI covers the unauthenticated TNS publication webservice (search/list/filter publications, document download, contracting-authority directory, RSS feed) and the Basic-auth eForms XML endpoint. Data is CC-0 public domain.\",\n \"mcp_ready\": \"full\",\n \"http_transport\": \"standard\",\n \"mcp\": {\n \"orchestration\": \"endpoint-mirror\"\n },\n \"auth\": {\n \"type\": \"none\"\n },\n \"required_headers\": [],\n \"tools\": [\n {\n \"name\": \"buyers_get\",\n \"description\": \"Fetch one contracting authority by ID. Required: aanbestedendedienstId. Returns the Buyer.\",\n \"method\": \"GET\",\n \"path\": \"/aanbestedendediensten/{aanbestedendedienstId}\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"aanbestedendedienstId\",\n \"type\": \"string\",\n \"location\": \"path\",\n \"description\": \"Contracting authority UUID\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"buyers_list\",\n \"description\": \"List Dutch contracting authorities (paginated). Optional: page, size. Returns the BuyerPage.\",\n \"method\": \"GET\",\n \"path\": \"/aanbestedendediensten\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"page\",\n \"type\": \"int\",\n \"location\": \"query\",\n \"description\": \"Page number (0-based)\"\n },\n {\n \"name\": \"size\",\n \"type\": \"int\",\n \"location\": \"query\",\n \"description\": \"Page size (default 50, max 100)\"\n }\n ]\n },\n {\n \"name\": \"docs_download\",\n \"description\": \"Download all documents for one publication as a zip archive. Required: publicatieId. Returns the BinaryContent.\",\n \"method\": \"GET\",\n \"path\": \"/publicaties/{publicatieId}/documenten/zip\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"publicatieId\",\n \"type\": \"int\",\n \"location\": \"path\",\n \"description\": \"Publication ID\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"docs_get\",\n \"description\": \"Download a single document's binary content (PDF/Word/etc.). Required: publicatieId, documentId. Returns the BinaryContent.\",\n \"method\": \"GET\",\n \"path\": \"/publicaties/{publicatieId}/documenten/{documentId}/content\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"publicatieId\",\n \"type\": \"int\",\n \"location\": \"path\",\n \"description\": \"Publication ID\",\n \"required\": true\n },\n {\n \"name\": \"documentId\",\n \"type\": \"string\",\n \"location\": \"path\",\n \"description\": \"Document ID (from 'documents list')\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"docs_list\",\n \"description\": \"List attached documents for one publication. Required: publicatieId. Returns the DocumentList.\",\n \"method\": \"GET\",\n \"path\": \"/publicaties/{publicatieId}/documenten\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"publicatieId\",\n \"type\": \"int\",\n \"location\": \"path\",\n \"description\": \"Publication ID\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"notices_get\",\n \"description\": \"Fetch full structured metadata for one publication. Required: publicatieId. Returns the Publication.\",\n \"method\": \"GET\",\n \"path\": \"/publicaties/{publicatieId}\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"publicatieId\",\n \"type\": \"int\",\n \"location\": \"path\",\n \"description\": \"TenderNed publication ID (e.g. 425283)\",\n \"required\": true\n }\n ]\n },\n {\n \"name\": \"notices_list\",\n \"description\": \"Search and list tender publications with rich filters (CPV, dates, buyer, procedure, scope). Optional: search, cpvCodes, publicatieType (plus 11 more). Returns the PublicationPage.\",\n \"method\": \"GET\",\n \"path\": \"/publicaties\",\n \"no_auth\": true,\n \"params\": [\n {\n \"name\": \"search\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Full-text keyword search across title and description\"\n },\n {\n \"name\": \"cpvCodes\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"CPV code in 8-digit-plus-check-digit form (e.g. 45000000-7). Repeat for multi-CPV intersection.\"\n },\n {\n \"name\": \"publicatieType\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Publication type filter (AAO=open call, AGO=awarded, VOP=prior info, RVO=cancellation, WNO=ex-ante, MAC=market consultation, REC=rectification)\"\n },\n {\n \"name\": \"typeOpdracht\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Contract type: D=services (Diensten), L=supplies (Leveringen), W=works (Werken)\"\n },\n {\n \"name\": \"procedure\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Procedure code (Openbaar, Niet-openbaar, Onderhandeling, Concurrentiegerichte dialoog, etc.)\"\n },\n {\n \"name\": \"nationaalOfEuropees\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Scope: NA=national-only (sub-threshold), EU=European (above-threshold). National-only notices never reach TED.\"\n },\n {\n \"name\": \"since\",\n \"wire_name\": \"publicatieDatumVanaf\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Earliest publication date (YYYY-MM-DD)\"\n },\n {\n \"name\": \"until\",\n \"wire_name\": \"publicatieDatumTot\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Latest publication date (YYYY-MM-DD)\"\n },\n {\n \"name\": \"closing-since\",\n \"wire_name\": \"sluitingsDatumVanaf\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Earliest closing date (YYYY-MM-DD)\"\n },\n {\n \"name\": \"closing-until\",\n \"wire_name\": \"sluitingsDatumTot\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Latest closing date (YYYY-MM-DD)\"\n },\n {\n \"name\": \"buyer-id\",\n \"wire_name\": \"aanbestedendeDienstId\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Filter by contracting-authority UUID (use 'buyers list' to find IDs)\"\n },\n {\n \"name\": \"buyer-type\",\n \"wire_name\": \"typeAanbestedendeDienst\",\n \"type\": \"string\",\n \"location\": \"query\",\n \"description\": \"Filter by contracting-authority type code\"\n },\n {\n \"name\": \"page\",\n \"type\": \"int\",\n \"location\": \"query\",\n \"description\": \"Page number (0-based)\"\n },\n {\n \"name\": \"size\",\n \"type\": \"int\",\n \"location\": \"query\",\n \"description\": \"Page size (default 50, max 100)\"\n }\n ]\n }\n ]\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":7650,"content_sha256":"a45d9b5bcc793fd6e9dc6ad6ff6ef9fc7a4c53762b8a2e9183080683e1081685"},{"filename":"workflow-verify-report.json","content":"{\n \"dir\": \"\\u003ccli-dir\\u003e/tenderned\",\n \"workflows\": null,\n \"verdict\": \"workflow-pass\",\n \"issues\": [\n \"no workflow manifest found, skipping\"\n ]\n}","content_type":"application/json; charset=utf-8","language":"json","size":157,"content_sha256":"b2d677eaf082b903c33d15c481b499fad09b7ad5a9ab641fb25bf67a9b73475f"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Tenderned — Printing Press CLI","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites: Install the CLI","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill drives the ","type":"text"},{"text":"tenderned-pp-cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" binary. ","type":"text"},{"text":"You must verify the CLI is installed before invoking any command from this skill.","type":"text","marks":[{"type":"strong"}]},{"text":" If it is missing, install it first:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Install via the Printing Press installer:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"npx -y @mvanhorn/printing-press-library install tenderned --cli-only","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify: ","type":"text"},{"text":"tenderned-pp-cli --version","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ensure ","type":"text"},{"text":"$GOPATH/bin","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"$HOME/go/bin","type":"text","marks":[{"type":"code_inline"}]},{"text":") is on ","type":"text"},{"text":"$PATH","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If the ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" install fails before this CLI has a public-library category, install Node or use the category-specific Go fallback after publish.","type":"text"}]},{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"--version","type":"text","marks":[{"type":"code_inline"}]},{"text":" reports \"command not found\" after install, the install step did not put the binary on ","type":"text"},{"text":"$PATH","type":"text","marks":[{"type":"code_inline"}]},{"text":". Do not proceed with skill commands until verification succeeds.","type":"text"}]},{"type":"paragraph","content":[{"text":"Every Dutch public tender, with the sub-threshold long tail that EU TED never sees, in a local-first CLI you can pipe.","type":"text"}]},{"type":"paragraph","content":[{"text":"TenderNed is the Dutch national public procurement platform run by PIANOo / Ministerie van EZK. Every Dutch contracting authority must publish above- and below-threshold tender notices here. Above-threshold notices are forwarded to EU TED; the sub-threshold long tail (€40k–€220k) is TenderNed-only.","type":"text"}]},{"type":"paragraph","content":[{"text":"This CLI covers the unauthenticated TNS publication webservice (search/list/filter publications, document download, contracting-authority directory, RSS feed) and the Basic-auth eForms XML endpoint. Data is CC-0 public domain.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When Not to Use This CLI","type":"text"}]},{"type":"paragraph","content":[{"text":"Do not activate this CLI for requests that require creating, updating, deleting, publishing, commenting, upvoting, inviting, ordering, sending messages, booking, purchasing, or changing remote state. This printed CLI exposes read-only commands for inspection, export, sync, and analysis.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Command Reference","type":"text"}]},{"type":"paragraph","content":[{"text":"buyers","type":"text","marks":[{"type":"strong"}]},{"text":" — Browse contracting authorities (aanbestedende diensten) — Dutch public buyers","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli buyers get","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Fetch one contracting authority by ID","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli buyers list","type":"text","marks":[{"type":"code_inline"}]},{"text":" — List Dutch contracting authorities (paginated)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"docs","type":"text","marks":[{"type":"strong"}]},{"text":" — List and download tender documents (bestek, PvE, evaluation criteria, Q&A)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli docs download","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Download all documents for one publication as a zip archive","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli docs get","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Download a single document's binary content (PDF/Word/etc.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli docs list","type":"text","marks":[{"type":"code_inline"}]},{"text":" — List attached documents for one publication","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"notices","type":"text","marks":[{"type":"strong"}]},{"text":" — Search, list and fetch tender notices (aankondigingen) from TenderNed — mirrors 'eu-tenders notices' for the Dutch market","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli notices get","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Fetch full structured metadata for one publication","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tenderned-pp-cli notices list","type":"text","marks":[{"type":"code_inline"}]},{"text":" — Search and list tender publications with rich filters (CPV, dates, buyer, procedure, scope)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Finding the right command","type":"text"}]},{"type":"paragraph","content":[{"text":"When you know what you want to do but not which command does it, ask the CLI directly:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"tenderned-pp-cli which \"\u003ccapability in your own words>\"","type":"text"}]},{"type":"paragraph","content":[{"text":"which","type":"text","marks":[{"type":"code_inline"}]},{"text":" resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code ","type":"text"},{"text":"0","type":"text","marks":[{"type":"code_inline"}]},{"text":" means at least one match; exit code ","type":"text"},{"text":"2","type":"text","marks":[{"type":"code_inline"}]},{"text":" means no confident match — fall back to ","type":"text"},{"text":"--help","type":"text","marks":[{"type":"code_inline"}]},{"text":" or use a narrower query.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Auth Setup","type":"text"}]},{"type":"paragraph","content":[{"text":"No authentication required.","type":"text"}]},{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"tenderned-pp-cli doctor","type":"text","marks":[{"type":"code_inline"}]},{"text":" to verify setup.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Agent Mode","type":"text"}]},{"type":"paragraph","content":[{"text":"Add ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" to any command. Expands to: ","type":"text"},{"text":"--json --compact --no-input --no-color --yes","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pipeable","type":"text","marks":[{"type":"strong"}]},{"text":" — JSON on stdout, errors on stderr","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Filterable","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"--select","type":"text","marks":[{"type":"code_inline"}]},{"text":" keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"tenderned-pp-cli buyers list --agent --select id,name,status","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Previewable","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"--dry-run","type":"text","marks":[{"type":"code_inline"}]},{"text":" shows the request without sending","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Offline-friendly","type":"text","marks":[{"type":"strong"}]},{"text":" — sync/search commands can use the local SQLite store when available","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Non-interactive","type":"text","marks":[{"type":"strong"}]},{"text":" — never prompts, every input is a flag","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read-only","type":"text","marks":[{"type":"strong"}]},{"text":" — do not use this CLI for create, update, delete, publish, comment, upvote, invite, order, send, or other mutating requests","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Response envelope","type":"text"}]},{"type":"paragraph","content":[{"text":"Commands that read from the local store or the API wrap output in a provenance envelope:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"meta\": {\"source\": \"live\" | \"local\", \"synced_at\": \"...\", \"reason\": \"...\"},\n \"results\": \u003cdata>\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Parse ","type":"text"},{"text":".results","type":"text","marks":[{"type":"code_inline"}]},{"text":" for data and ","type":"text"},{"text":".meta.source","type":"text","marks":[{"type":"code_inline"}]},{"text":" to know whether it's live or local. A human-readable ","type":"text"},{"text":"N results (live)","type":"text","marks":[{"type":"code_inline"}]},{"text":" summary is printed to stderr only when stdout is a terminal AND no machine-format flag (","type":"text"},{"text":"--json","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--csv","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--compact","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--quiet","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--plain","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--select","type":"text","marks":[{"type":"code_inline"}]},{"text":") is set — piped/agent consumers and explicit-format runs get pure JSON on stdout.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Agent Feedback","type":"text"}]},{"type":"paragraph","content":[{"text":"When you (or the agent) notice something off about this CLI, record it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"tenderned-pp-cli feedback \"the --since flag is inclusive but docs say exclusive\"\ntenderned-pp-cli feedback --stdin \u003c notes.txt\ntenderned-pp-cli feedback list --json --limit 10","type":"text"}]},{"type":"paragraph","content":[{"text":"Entries are stored locally at ","type":"text"},{"text":"~/.local/share/tenderned-pp-cli/feedback.jsonl","type":"text","marks":[{"type":"code_inline"}]},{"text":". They are never POSTed unless ","type":"text"},{"text":"TENDERNED_FEEDBACK_ENDPOINT","type":"text","marks":[{"type":"code_inline"}]},{"text":" is set AND either ","type":"text"},{"text":"--send","type":"text","marks":[{"type":"code_inline"}]},{"text":" is passed or ","type":"text"},{"text":"TENDERNED_FEEDBACK_AUTO_SEND=true","type":"text","marks":[{"type":"code_inline"}]},{"text":". Default behavior is local-only.","type":"text"}]},{"type":"paragraph","content":[{"text":"Write what ","type":"text"},{"text":"surprised","type":"text","marks":[{"type":"em"}]},{"text":" you, not a bug report. Short, specific, one line: that is the part that compounds.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Output Delivery","type":"text"}]},{"type":"paragraph","content":[{"text":"Every command accepts ","type":"text"},{"text":"--deliver \u003csink>","type":"text","marks":[{"type":"code_inline"}]},{"text":". The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sink","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Effect","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"stdout","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default; write to stdout only","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"file:\u003cpath>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Atomically write output to ","type":"text"},{"text":"\u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (tmp + rename)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"webhook:\u003curl>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"POST the output body to the URL (","type":"text"},{"text":"application/json","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"application/x-ndjson","type":"text","marks":[{"type":"code_inline"}]},{"text":" when ","type":"text"},{"text":"--compact","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Unknown schemes are refused with a structured error naming the supported set. Webhook failures return non-zero and log the URL + HTTP status on stderr.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Named Profiles","type":"text"}]},{"type":"paragraph","content":[{"text":"A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's \"Beacon\" pattern.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"tenderned-pp-cli profile save briefing --json\ntenderned-pp-cli --profile briefing buyers list\ntenderned-pp-cli profile list --json\ntenderned-pp-cli profile show briefing\ntenderned-pp-cli profile delete briefing --yes","type":"text"}]},{"type":"paragraph","content":[{"text":"Explicit flags always win over profile values; profile values win over defaults. ","type":"text"},{"text":"agent-context","type":"text","marks":[{"type":"code_inline"}]},{"text":" lists all available profiles under ","type":"text"},{"text":"available_profiles","type":"text","marks":[{"type":"code_inline"}]},{"text":" so introspecting agents discover them at runtime.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Exit Codes","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Code","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"0","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Success","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Usage error (wrong arguments)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Resource not found","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"API error (upstream issue)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rate limited (wait and retry)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"10","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Config error","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Argument Parsing","type":"text"}]},{"type":"paragraph","content":[{"text":"Parse ","type":"text"},{"text":"$ARGUMENTS","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Empty, ","type":"text","marks":[{"type":"strong"}]},{"text":"help","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", or ","type":"text","marks":[{"type":"strong"}]},{"text":"--help","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → show ","type":"text"},{"text":"tenderned-pp-cli --help","type":"text","marks":[{"type":"code_inline"}]},{"text":" output","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Starts with ","type":"text","marks":[{"type":"strong"}]},{"text":"install","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → ends with ","type":"text"},{"text":"mcp","type":"text","marks":[{"type":"code_inline"}]},{"text":" → MCP installation; otherwise → see Prerequisites above","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Anything else","type":"text","marks":[{"type":"strong"}]},{"text":" → Direct Use (execute as CLI command with ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"MCP Server Installation","type":"text"}]},{"type":"paragraph","content":[{"text":"Install the MCP binary from this CLI's published public-library entry or pre-built release, then register it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"claude mcp add tenderned-pp-mcp -- tenderned-pp-mcp","type":"text"}]},{"type":"paragraph","content":[{"text":"Verify: ","type":"text"},{"text":"claude mcp list","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Direct Use","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check if installed: ","type":"text"},{"text":"which tenderned-pp-cli","type":"text","marks":[{"type":"code_inline"}]},{"text":" If not found, offer to install (see Prerequisites at the top of this skill).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Match the user query to the best command from the Unique Capabilities and Command Reference above.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Execute with the ","type":"text"},{"text":"--agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"tenderned-pp-cli \u003ccommand> [subcommand] [args] --agent","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If ambiguous, drill into subcommand help: ","type":"text"},{"text":"tenderned-pp-cli \u003ccommand> --help","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"pp-tenderned","author":"@skillopedia","source":{"stars":1369,"repo_name":"printing-press-library","origin_url":"https://github.com/mvanhorn/printing-press-library/blob/HEAD/library/sales-and-crm/tenderned/SKILL.md","repo_owner":"mvanhorn","body_sha256":"139e6604518a02c755d514a2ccd83ff36d1dffb585f8a49e369fe96fe58c0667","cluster_key":"cb4c303a88d9e1d596f24a3c3ffa2c6c194cf850aa236f4389033b3fcc5fb648","clean_bundle":{"format":"clean-skill-bundle-v1","source":"mvanhorn/printing-press-library/library/sales-and-crm/tenderned/SKILL.md","attachments":[{"id":"dbdf113d-b800-5db9-b2ee-d0d438968c7c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dbdf113d-b800-5db9-b2ee-d0d438968c7c/attachment.yml","path":".golangci.yml","size":147,"sha256":"41e91d2f2ed2b361555240c1ce682a57a3f3a650d7349ec5f8d10ac43c936b31","contentType":"application/yaml; charset=utf-8"},{"id":"a2440012-fa95-5857-8d75-3d7da0cf1d61","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a2440012-fa95-5857-8d75-3d7da0cf1d61/attachment.yaml","path":".goreleaser.yaml","size":1302,"sha256":"9d29cfd11628d14763a1e36e903e911da4da3a7e2a3e0905741532cfcfea50dd","contentType":"application/yaml; charset=utf-8"},{"id":"493a2460-7216-5026-9d87-a450234f2f51","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/493a2460-7216-5026-9d87-a450234f2f51/attachment.json","path":".manuscripts/20260517-211252/proofs/phase5-acceptance.json","size":230,"sha256":"2be7f5de5ff4fcff83a028ba2a30708f5ea33dc8954afff8b20275e9d81e9467","contentType":"application/json; charset=utf-8"},{"id":"17c00e73-7e60-558b-89c0-7477135fc612","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/17c00e73-7e60-558b-89c0-7477135fc612/attachment.json","path":".manuscripts/20260517-211252/research.json","size":20842,"sha256":"f9520a0dde54cbc4f1415eb74b6144fa42a899d6001483c6255e6125406cd4b6","contentType":"application/json; charset=utf-8"},{"id":"665090ed-2649-5357-8582-2aa0f202de51","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/665090ed-2649-5357-8582-2aa0f202de51/attachment.md","path":".manuscripts/20260517-211252/research/2026-05-17-211252-feat-tenderned-pp-cli-absorb-manifest.md","size":6333,"sha256":"7c515999f8ef35565785c90a9e89a7ffafdeb46e2e5325c1e300f06e7c195b85","contentType":"text/markdown; charset=utf-8"},{"id":"c1ebd820-dcb5-5d66-a82b-697bae1fcffa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c1ebd820-dcb5-5d66-a82b-697bae1fcffa/attachment.md","path":".manuscripts/20260517-211252/research/2026-05-17-211252-feat-tenderned-pp-cli-brief.md","size":6672,"sha256":"05e897bd9f80e97ea63d5377b93390b18d31b582925e80b0ab59e20c8a7dc020","contentType":"text/markdown; charset=utf-8"},{"id":"571d57b5-30c4-55cb-8f4f-6303d3367919","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/571d57b5-30c4-55cb-8f4f-6303d3367919/attachment.md","path":".manuscripts/20260517-211252/research/2026-05-17-211252-novel-features-brainstorm.md","size":7162,"sha256":"4fd86c8c4bb383f65d47879ab31a6f7006302d835e72e8785ef5390624f771e3","contentType":"text/markdown; charset=utf-8"},{"id":"6b31da69-3c0e-5a71-9704-dce5ed24feca","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6b31da69-3c0e-5a71-9704-dce5ed24feca/attachment.yaml","path":".manuscripts/20260517-211252/research/tenderned-internal-spec.yaml","size":6941,"sha256":"4cfb655b67c2ff78604cfb28ecb14aa9279c0430aa886499bf7f29fc5e2f16e4","contentType":"application/yaml; charset=utf-8"},{"id":"85efc847-e2ac-50db-bf65-32228dd8cb62","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/85efc847-e2ac-50db-bf65-32228dd8cb62/attachment.yaml","path":".manuscripts/20260517-211252/research/tenderned-spec.yaml","size":2631,"sha256":"ae16b7e43a6531c4c82f1567f10374e7febdbd7d81d02e3ed7abe39f2898e9f5","contentType":"application/yaml; charset=utf-8"},{"id":"bf565ea4-52ca-5c8c-a9b7-26a88385bad4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bf565ea4-52ca-5c8c-a9b7-26a88385bad4/attachment.json","path":".printing-press-patches.json","size":1831,"sha256":"f7f51ef52686070033c91558d64e492a450e8711999539683d47da9b483a2362","contentType":"application/json; charset=utf-8"},{"id":"8de20e74-3124-5e8a-b62e-bf5384f7d62b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8de20e74-3124-5e8a-b62e-bf5384f7d62b/attachment.json","path":".printing-press-pii-polish.json","size":4649,"sha256":"4100da5641fa620e34be816a430a2c7cf00434906cd86af16b2d6f6a2b2a1f0a","contentType":"application/json; charset=utf-8"},{"id":"61c52e9f-928f-5106-937f-1cb56743d2ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/61c52e9f-928f-5106-937f-1cb56743d2ad/attachment.json","path":".printing-press-tools-polish.json","size":231,"sha256":"c8b8ae7852d2a93a378148faea0bc54843290ac8974ab8097a0fee4d140c8f2e","contentType":"application/json; charset=utf-8"},{"id":"17dd9f42-28a3-5bd2-a8d2-3a924829675f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/17dd9f42-28a3-5bd2-a8d2-3a924829675f/attachment.json","path":".printing-press.json","size":900,"sha256":"3c9b0afd42a61e55af1d1c6b1380c59a64391180735dbd28f8dca6596b0dcd7e","contentType":"application/json; charset=utf-8"},{"id":"985ae275-ceaf-53e5-86ec-43f87c5bbefa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/985ae275-ceaf-53e5-86ec-43f87c5bbefa/attachment.md","path":"AGENTS.md","size":3727,"sha256":"7968d2dc524510075171d7d772ef66640abbc3c9876cf141fcaa992dbf7b30bc","contentType":"text/markdown; charset=utf-8"},{"id":"1214271d-1442-52fb-89a2-f8503fe98aa0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1214271d-1442-52fb-89a2-f8503fe98aa0/attachment","path":"Makefile","size":363,"sha256":"3f85b6f3be536efeb64a6f30da11512f0b314ab7d39bad0eca848beac04e5a89","contentType":"text/plain; charset=utf-8"},{"id":"4ceb0810-bacb-5c1a-9c94-3c7290680bf6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4ceb0810-bacb-5c1a-9c94-3c7290680bf6/attachment","path":"NOTICE","size":465,"sha256":"417e1bad10358b361be1d6979a08978577a17b982dba8562ef77f40e190e4074","contentType":"text/plain; charset=utf-8"},{"id":"0c51c32a-e736-59ad-9514-a03f3e272e9b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0c51c32a-e736-59ad-9514-a03f3e272e9b/attachment.md","path":"README.md","size":7060,"sha256":"6bf765f6d5f949ed88d7a7c7b60b89fbadbb9adff7bff68d33f007aec2672d4d","contentType":"text/markdown; charset=utf-8"},{"id":"b04bcf7e-f342-509b-a67d-ac2b3049659a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b04bcf7e-f342-509b-a67d-ac2b3049659a/attachment.go","path":"cmd/tenderned-pp-cli/main.go","size":399,"sha256":"184c047f567f6bf57d52e3bb184884d5535879d41fa40f067be85885c176f8fe","contentType":"text/plain; charset=utf-8"},{"id":"036f367b-6b94-5355-9a61-93dd3e4daaf4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/036f367b-6b94-5355-9a61-93dd3e4daaf4/attachment.go","path":"cmd/tenderned-pp-mcp/main.go","size":2117,"sha256":"377f6e815dcfd5e20683e9d86035516447c1edeba7d177ff99603317e9045f3a","contentType":"text/plain; charset=utf-8"},{"id":"c69a9d45-f321-5462-afc3-a47530f5c093","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c69a9d45-f321-5462-afc3-a47530f5c093/attachment.json","path":"dogfood-results.json","size":2671,"sha256":"21e21a233397d7bd9af9c5c3d17434a387bd260551f5bda9aef4f85e915cf8b2","contentType":"application/json; charset=utf-8"},{"id":"350a87a7-4955-53c6-9aa0-44a6ed202a61","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/350a87a7-4955-53c6-9aa0-44a6ed202a61/attachment.mod","path":"go.mod","size":986,"sha256":"cdb996676e61b161b1d3ad4e98cb556d55f9a6d80ba492ac707352e7c478cfc1","contentType":"application/xml-dtd"},{"id":"bdaf91f3-5733-5d63-b675-4039304d0451","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bdaf91f3-5733-5d63-b675-4039304d0451/attachment.sum","path":"go.sum","size":7139,"sha256":"0852efc65f9a89bb477ae578df41a69fe630feab2567291d2d765ea10e913375","contentType":"text/plain; charset=utf-8"},{"id":"937d1042-9221-5212-9f36-5297ae7975b1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/937d1042-9221-5212-9f36-5297ae7975b1/attachment.go","path":"internal/cache/cache.go","size":1729,"sha256":"0691d0ac0776f6e2669c6484f3896864b61601e61922a6fd2b899abf49a796ed","contentType":"text/plain; charset=utf-8"},{"id":"4450b8d5-7b97-5ca8-8add-1047c1e2c0a7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4450b8d5-7b97-5ca8-8add-1047c1e2c0a7/attachment.go","path":"internal/cli/agent_context.go","size":6768,"sha256":"a28c3b2cc14b868e27dce8bab97d20b31fbfb6f752a29bcaec0b7f22e5cfbb38","contentType":"text/plain; charset=utf-8"},{"id":"eb2aa6a1-03e2-5ca1-a981-0aa484af5f53","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eb2aa6a1-03e2-5ca1-a981-0aa484af5f53/attachment.go","path":"internal/cli/analytics.go","size":3782,"sha256":"2fedbe77cc4cb46363f69bf54eb8cf5ed8e032234a834144d1ef930fdb1fd2b9","contentType":"text/plain; charset=utf-8"},{"id":"0a94321b-078d-5aa4-8040-349c555af9c2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0a94321b-078d-5aa4-8040-349c555af9c2/attachment.go","path":"internal/cli/awards.go","size":3994,"sha256":"76d090559fc76e226791c168977f93bf041bfb48bb0f34d4b3da9b5fca576818","contentType":"text/plain; charset=utf-8"},{"id":"2bd2c173-67cb-5af4-8728-7222baf0bb1b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2bd2c173-67cb-5af4-8728-7222baf0bb1b/attachment.go","path":"internal/cli/buyer.go","size":5179,"sha256":"e8ef84b78146016ff5d4ca3c8d657cd18d7c600188c88fc6e12471f63a504a18","contentType":"text/plain; charset=utf-8"},{"id":"2bc90740-b979-560b-84c6-adc6bc4407c0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2bc90740-b979-560b-84c6-adc6bc4407c0/attachment.go","path":"internal/cli/buyers.go","size":643,"sha256":"3a99b2f5e22bce5573caf84cb2773ce9fb81bf0e279a76f3b34a15a5367e1e03","contentType":"text/plain; charset=utf-8"},{"id":"6f848ecd-df32-573c-b24e-db77bc5409b0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6f848ecd-df32-573c-b24e-db77bc5409b0/attachment.go","path":"internal/cli/buyers_get.go","size":3193,"sha256":"97ef7a257dc1052b20500a6104ba7981de81132d1bac8b2e8cb513dec1a85e6a","contentType":"text/plain; charset=utf-8"},{"id":"e646e698-e70a-5f2a-9709-1737af64b631","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e646e698-e70a-5f2a-9709-1737af64b631/attachment.go","path":"internal/cli/buyers_list.go","size":3362,"sha256":"e7e7616d3baa2f36fccb0caf37b2eac1e2edef5aa868e947fc8430bae81826be","contentType":"text/plain; charset=utf-8"},{"id":"9fd2cd13-0b20-5204-b20d-3a78117f6063","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9fd2cd13-0b20-5204-b20d-3a78117f6063/attachment.go","path":"internal/cli/buyers_top.go","size":2550,"sha256":"5514866cb808943fef0bf67462f96f176d3723e75829d7d80dc2e5f5ba272920","contentType":"text/plain; charset=utf-8"},{"id":"0ebe3f86-b008-52de-a33f-36c7a56e9544","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0ebe3f86-b008-52de-a33f-36c7a56e9544/attachment.go","path":"internal/cli/channel_workflow.go","size":4905,"sha256":"f39cacb4dff415667aee15c59941f676880630776609a1b04ce590d719e5a712","contentType":"text/plain; charset=utf-8"},{"id":"a9ac1939-397f-5e9a-80c4-b985a9667f40","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a9ac1939-397f-5e9a-80c4-b985a9667f40/attachment.go","path":"internal/cli/concentration.go","size":3286,"sha256":"6daed13876f6195ada01b41a6716ac2fc078a3622c7925217ee4e66cf2880355","contentType":"text/plain; charset=utf-8"},{"id":"dd144978-9a6c-598c-ae3c-9e9c60f9262f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dd144978-9a6c-598c-ae3c-9e9c60f9262f/attachment.go","path":"internal/cli/cpv_drift.go","size":3652,"sha256":"57ed1dc150fd3648e36fabb780b6dab12f600c4e45b3486e234b0610f0c317fb","contentType":"text/plain; charset=utf-8"},{"id":"50076981-32de-5853-851c-963f563d68b4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/50076981-32de-5853-851c-963f563d68b4/attachment.go","path":"internal/cli/data_source.go","size":22286,"sha256":"395f06cff85a9b4af76ded92c3be4a2221911b133ff067761d0d9797ec571907","contentType":"text/plain; charset=utf-8"},{"id":"0e6c5169-be55-5f51-bd97-84d50d1788d2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0e6c5169-be55-5f51-bd97-84d50d1788d2/attachment.go","path":"internal/cli/deadline.go","size":3102,"sha256":"6e1a5238ee0a753d712215cddf5c3cf49b10f6ecddb0da69477a28d839c21eb9","contentType":"text/plain; charset=utf-8"},{"id":"1e821c6a-dffe-5a21-a10e-f467edadd5e5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1e821c6a-dffe-5a21-a10e-f467edadd5e5/attachment.go","path":"internal/cli/deadline_heat.go","size":3126,"sha256":"99030d5850348f51795f727c99e902bbb0018e477c9efb458b2949b8cdf3f263","contentType":"text/plain; charset=utf-8"},{"id":"988e4044-8af1-5873-8f17-bf0e28d3dd19","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/988e4044-8af1-5873-8f17-bf0e28d3dd19/attachment.go","path":"internal/cli/deliver.go","size":3563,"sha256":"6f0286b60cf0bcf151e3051d14c0e3c712b9ad2ad102456ded5ee24b84c5f405","contentType":"text/plain; charset=utf-8"},{"id":"7be2bd6c-a66a-576c-abe5-450f5e618261","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7be2bd6c-a66a-576c-abe5-450f5e618261/attachment.go","path":"internal/cli/docs.go","size":673,"sha256":"ffaf54d15bf11b28e0417f309ba3f6d7092e89fbae94836fabe05e7cc09b645f","contentType":"text/plain; charset=utf-8"},{"id":"fe6e10dd-1f6a-5ecb-acaf-a9397f9839b9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fe6e10dd-1f6a-5ecb-acaf-a9397f9839b9/attachment.go","path":"internal/cli/docs_download.go","size":3167,"sha256":"aef8988f7ee153466d05e87618510b6e287c84bb260d8f1f14ed39b2486c6158","contentType":"text/plain; charset=utf-8"},{"id":"ef4a8ac7-8d53-58fe-91c1-69c5f0ebb669","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ef4a8ac7-8d53-58fe-91c1-69c5f0ebb669/attachment.go","path":"internal/cli/docs_get.go","size":3421,"sha256":"9c72e19e29c8986d31499f009ff080b2f4cf9d8f816246a3a7ae3c44d58697f4","contentType":"text/plain; charset=utf-8"},{"id":"a9237cf2-4fcd-580e-8895-5dc9fd38e9d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a9237cf2-4fcd-580e-8895-5dc9fd38e9d5/attachment.go","path":"internal/cli/docs_grep.go","size":7000,"sha256":"ff9323e33849c85a887e9c9d12e3d0649c59932fd5c89f5fa24b799371b38127","contentType":"text/plain; charset=utf-8"},{"id":"22e547db-31e7-5770-b2aa-0cf7f2e52397","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/22e547db-31e7-5770-b2aa-0cf7f2e52397/attachment.go","path":"internal/cli/docs_list.go","size":3127,"sha256":"07821d5bb57b00a327573598bead37069336cc77071a508a142a45287913c781","contentType":"text/plain; charset=utf-8"},{"id":"1118c8fe-7c56-5d5f-a88d-91cd38d6fc40","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1118c8fe-7c56-5d5f-a88d-91cd38d6fc40/attachment.go","path":"internal/cli/doctor.go","size":18540,"sha256":"01c00026321a4759ff4f028baf00a000e3bcbcc3415092f2907516a279440a3f","contentType":"text/plain; charset=utf-8"},{"id":"f1bbb2b2-0f04-59fa-b948-9e6f45ba95d3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f1bbb2b2-0f04-59fa-b948-9e6f45ba95d3/attachment.go","path":"internal/cli/extract_envelope_test.go","size":3060,"sha256":"3a085be235f759a2c2020d9a99a734b7fd1d318161ca242e83aa9467e5c06f08","contentType":"text/plain; charset=utf-8"},{"id":"ee72ee5b-5ad0-574c-8dbd-771ecd918e25","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ee72ee5b-5ad0-574c-8dbd-771ecd918e25/attachment.go","path":"internal/cli/feedback.go","size":6665,"sha256":"8301b0ccd07997b85a8a3f9af5746f2bf453cb5541c87fe80946d9968cdfc0d3","contentType":"text/plain; charset=utf-8"},{"id":"9f77c462-a3e0-5a32-ae2a-ad0d77788f9f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9f77c462-a3e0-5a32-ae2a-ad0d77788f9f/attachment.go","path":"internal/cli/helpers.go","size":50788,"sha256":"8ad3e829d5bf146a43ac8eaa7386dd57fa138c64d201081c34ccaa0812dea6e8","contentType":"text/plain; charset=utf-8"},{"id":"7179af43-52f3-5307-8d44-d7d14e91ee6d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7179af43-52f3-5307-8d44-d7d14e91ee6d/attachment.go","path":"internal/cli/import.go","size":2987,"sha256":"7b30115eb3ada89ec4bfb59d96ab247f69be8dd5b32def8a28a22b1f0fbce00e","contentType":"text/plain; charset=utf-8"},{"id":"d8b0ee1f-bd23-501f-8a5a-4a0d21cf2744","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d8b0ee1f-bd23-501f-8a5a-4a0d21cf2744/attachment.go","path":"internal/cli/leads.go","size":4233,"sha256":"ab73f47d96786ebcd175e5570c4c6eb17a33c75e627df13ec7830721719c9eeb","contentType":"text/plain; charset=utf-8"},{"id":"621fa8f5-0844-53d7-8da2-a46665101d5f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/621fa8f5-0844-53d7-8da2-a46665101d5f/attachment.go","path":"internal/cli/notices.go","size":622,"sha256":"b7c87556d2bb71b033a1b3d76aac680e78ffe30952bf1836994a64f29d567194","contentType":"text/plain; charset=utf-8"},{"id":"cae726ab-3091-5dfa-9c19-4e6ca414c4cf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cae726ab-3091-5dfa-9c19-4e6ca414c4cf/attachment.go","path":"internal/cli/notices_get.go","size":3120,"sha256":"b9a0acadf8b28092aad445c222915210cca690e28fe001ddb034b7f56e3cc858","contentType":"text/plain; charset=utf-8"},{"id":"1867e209-90c2-5791-bf04-ba30612d4fee","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1867e209-90c2-5791-bf04-ba30612d4fee/attachment.go","path":"internal/cli/notices_list.go","size":6270,"sha256":"056e91f6319562289096e4e872afd5141a03aed194c0d8c7d293bac8e24306dc","contentType":"text/plain; charset=utf-8"},{"id":"b289e499-e4cc-5122-87e0-73d7f234048b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b289e499-e4cc-5122-87e0-73d7f234048b/attachment.go","path":"internal/cli/profile.go","size":10367,"sha256":"fa5e81da3076207195954b6b89b9a703901e3d4329ce9cb0f1b8bf68292e495a","contentType":"text/plain; charset=utf-8"},{"id":"0dc528ac-62d9-5f5f-9b0f-b887d63bf2e6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0dc528ac-62d9-5f5f-9b0f-b887d63bf2e6/attachment.go","path":"internal/cli/root.go","size":11736,"sha256":"f0b27f01e64c585eb842ccf7f2afb0696e01594efdca4ded13d38244e2328d9d","contentType":"text/plain; charset=utf-8"},{"id":"3b06ea13-15c0-5153-8447-e982212038a9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3b06ea13-15c0-5153-8447-e982212038a9/attachment.go","path":"internal/cli/root_test.go","size":8503,"sha256":"f69c9c6eb79588dff0eb7dd0e7e9dbf8774c647e1fb9174ff1a3ef6135ffbcbc","contentType":"text/plain; charset=utf-8"},{"id":"e603f7d4-2481-51ca-84c9-68c8987fa196","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e603f7d4-2481-51ca-84c9-68c8987fa196/attachment.go","path":"internal/cli/search.go","size":7288,"sha256":"2240d7133f645a7b067e3ac69c7efe91aa741793a04dcfc822d6cb4733732487","contentType":"text/plain; charset=utf-8"},{"id":"20b3d37b-d4cd-5555-950c-2c85e66b0919","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/20b3d37b-d4cd-5555-950c-2c85e66b0919/attachment.go","path":"internal/cli/sync.go","size":49768,"sha256":"0cc080496733fba2de4c163ca536756bcdd4b216b8820bb2fe71d8403ece39df","contentType":"text/plain; charset=utf-8"},{"id":"175a54ce-1fb3-5a35-b785-5238794036c5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/175a54ce-1fb3-5a35-b785-5238794036c5/attachment.go","path":"internal/cli/sync_hint.go","size":3323,"sha256":"8fe6a26f2f004b98dd184950f700750e8930806af515344ee3d7b581bd14aaa4","contentType":"text/plain; charset=utf-8"},{"id":"1a2636b3-ec16-5c0b-b7d5-1d5cac438160","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1a2636b3-ec16-5c0b-b7d5-1d5cac438160/attachment.go","path":"internal/cli/sync_hint_test.go","size":5406,"sha256":"3f3554d106b8ac322918956ac63b1355d309104199bda7d5c15b59bd4327dba2","contentType":"text/plain; charset=utf-8"},{"id":"90637823-38ba-5655-bcfb-4294745c7c5c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/90637823-38ba-5655-bcfb-4294745c7c5c/attachment.go","path":"internal/cli/sync_numeric_id_test.go","size":1343,"sha256":"31c5736e4d4c24fb5584aad207a3440febd483cb20d4fcdbe5a5ef8aedf8a28a","contentType":"text/plain; charset=utf-8"},{"id":"5b97eb87-bbe2-58ae-887d-c69f3eb3f45a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5b97eb87-bbe2-58ae-887d-c69f3eb3f45a/attachment.go","path":"internal/cli/ted_link.go","size":2652,"sha256":"30ec4f5f1ad2954f71789ffbfdcfc4a3bcb549bb72bdef17fced8e71ae4f4e10","contentType":"text/plain; charset=utf-8"},{"id":"c6f5bc13-912a-57d0-86a6-d250703fdab6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c6f5bc13-912a-57d0-86a6-d250703fdab6/attachment.go","path":"internal/cli/thread.go","size":4343,"sha256":"169c7595081aa0f606841831210311a8fee76315f802c621dcc4d917c8421792","contentType":"text/plain; charset=utf-8"},{"id":"9ebbd6c4-59bc-54a5-863c-79c4225a144b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9ebbd6c4-59bc-54a5-863c-79c4225a144b/attachment.go","path":"internal/cli/tn_helpers.go","size":12338,"sha256":"5212d8bc7d1e5895cad9e6352225e27ad7f04b69cf92de284f244704cf931274","contentType":"text/plain; charset=utf-8"},{"id":"c7ecd05a-b03a-5f4a-b7c0-01a60c5fdd7c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c7ecd05a-b03a-5f4a-b7c0-01a60c5fdd7c/attachment.go","path":"internal/cli/velocity.go","size":2507,"sha256":"5362669dc9328f96c10979d411bc4e68384e48ad1fb54414c0cf21568d2971bc","contentType":"text/plain; charset=utf-8"},{"id":"b145a563-3761-5974-9f33-ad39d6a5cf72","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b145a563-3761-5974-9f33-ad39d6a5cf72/attachment.go","path":"internal/cli/watch.go","size":11267,"sha256":"86e297472a6291b78e6e87ba3d83dbed7548fb0dd6c46ae2d46fc10743fbfbbb","contentType":"text/plain; charset=utf-8"},{"id":"15a64fd8-0d60-56d3-8613-2112ce6f981e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/15a64fd8-0d60-56d3-8613-2112ce6f981e/attachment.go","path":"internal/cli/which.go","size":7898,"sha256":"dee846eb6fbf379c2d56a3160c98fa00283695903e6796024c654a63702f0f75","contentType":"text/plain; charset=utf-8"},{"id":"b4716ca1-cfa9-5601-ab44-8a9e4c9f5461","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b4716ca1-cfa9-5601-ab44-8a9e4c9f5461/attachment.go","path":"internal/cli/which_test.go","size":3903,"sha256":"bbac06c3eeff8d394d39adea545e068752ed50f6f1c639bccf8ccb6717f53d77","contentType":"text/plain; charset=utf-8"},{"id":"9159af0f-f5c6-5dcc-b9ed-b26645f1f491","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9159af0f-f5c6-5dcc-b9ed-b26645f1f491/attachment.go","path":"internal/cli/xml.go","size":1594,"sha256":"3d3f76681f6fdec55fb12e536285df33882ea3d090dd467a212753efde2747e5","contentType":"text/plain; charset=utf-8"},{"id":"aed02017-e7b0-5387-abf2-876a8383334d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aed02017-e7b0-5387-abf2-876a8383334d/attachment.go","path":"internal/client/client.go","size":29584,"sha256":"e66c4d721cd15b15c2a1d0cd1a36a70ef718d105e54146c7627ac3eafc62c9b6","contentType":"text/plain; charset=utf-8"},{"id":"db08b10f-d537-543d-8687-d34356cb48ff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/db08b10f-d537-543d-8687-d34356cb48ff/attachment.go","path":"internal/client/client_redirect_test.go","size":3903,"sha256":"f0af4571b8fc9baf3b1cd504cf3144eecb7b86d70ab58e71b421720f6e508583","contentType":"text/plain; charset=utf-8"},{"id":"676b98fb-4374-5399-bc7f-1fee9fcece5a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/676b98fb-4374-5399-bc7f-1fee9fcece5a/attachment.go","path":"internal/client/client_test.go","size":2055,"sha256":"42a9e38b8559cd36ff70582cbd386cbeeb21b8cca93ac11a6167af5b6d4e9797","contentType":"text/plain; charset=utf-8"},{"id":"9e6346d2-12eb-5907-a7e1-a232b230b690","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9e6346d2-12eb-5907-a7e1-a232b230b690/attachment.go","path":"internal/client/client_verify_short_circuit_test.go","size":6438,"sha256":"465372631bd8a118e5ad1009d456f12cd057bd29ad5a95fb4205a94c82be1964","contentType":"text/plain; charset=utf-8"},{"id":"5853c83f-1bc9-59d4-b3ee-a35f9be98173","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5853c83f-1bc9-59d4-b3ee-a35f9be98173/attachment.go","path":"internal/cliutil/cliutil_test.go","size":25088,"sha256":"296f6521aea050756129e38c164b07e85cdcef0d613c0c584aee3ae3ce0ba068","contentType":"text/plain; charset=utf-8"},{"id":"7fb75bc1-a329-5efe-a891-4d8efa8ef526","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7fb75bc1-a329-5efe-a891-4d8efa8ef526/attachment.go","path":"internal/cliutil/duration.go","size":1553,"sha256":"4f3d5f136867603ce160cbc6fe5dc5ef200b41b464da27555160039e519b40d1","contentType":"text/plain; charset=utf-8"},{"id":"8530de20-8ec6-51fd-9665-39249df874cc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8530de20-8ec6-51fd-9665-39249df874cc/attachment.go","path":"internal/cliutil/duration_test.go","size":1656,"sha256":"d41bb577e341f07a1096cbc6cb6e1640c5ba287e7af57229b2353dc235511832","contentType":"text/plain; charset=utf-8"},{"id":"6a283977-39c3-5576-8336-06c653d8a983","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6a283977-39c3-5576-8336-06c653d8a983/attachment.go","path":"internal/cliutil/extractnumber.go","size":2471,"sha256":"4c9e991b772daf5bfbab078960a5cdf9fd23d28c365499167a16dfbd43b6c94a","contentType":"text/plain; charset=utf-8"},{"id":"2da2791c-daa5-5df7-98d9-ecff5633f264","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2da2791c-daa5-5df7-98d9-ecff5633f264/attachment.go","path":"internal/cliutil/extractnumber_test.go","size":3442,"sha256":"bcf85dc9a437672d1f6170e05baa3170a95e460caac7c629de243980ee6c5f0f","contentType":"text/plain; charset=utf-8"},{"id":"95304361-9050-582b-a1ec-47c169a64a87","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/95304361-9050-582b-a1ec-47c169a64a87/attachment.go","path":"internal/cliutil/fanout.go","size":6387,"sha256":"096c00cf2e39f0316a5d43ce186ff61885d7c237b4c8bd5364dabec0f9d1f72d","contentType":"text/plain; charset=utf-8"},{"id":"9a726dbd-67d2-5c35-b3c2-c2840dca2301","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9a726dbd-67d2-5c35-b3c2-c2840dca2301/attachment.go","path":"internal/cliutil/jwtshape.go","size":3601,"sha256":"e7952362391647b81b4a1c8bf9faaf8612c0717ee46c57b65b6d021c5cfc01a5","contentType":"text/plain; charset=utf-8"},{"id":"c4a6be89-e1f2-5d4b-bd60-c6c858b6c049","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c4a6be89-e1f2-5d4b-bd60-c6c858b6c049/attachment.go","path":"internal/cliutil/jwtshape_test.go","size":4443,"sha256":"28e6c4edfd20545e654f0d97a2809b00bbe06804ec2ff7751a20d35546a1bb9e","contentType":"text/plain; charset=utf-8"},{"id":"a1b621d7-bd66-5454-a86f-c143b59972aa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a1b621d7-bd66-5454-a86f-c143b59972aa/attachment.go","path":"internal/cliutil/odata_date.go","size":1549,"sha256":"71be11f6c9abb7111fb978c4ab93a4d425ab887b32382eb7e957525cd060a59f","contentType":"text/plain; charset=utf-8"},{"id":"eda7edb7-2f59-5409-96f1-64cb14bdcf6b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eda7edb7-2f59-5409-96f1-64cb14bdcf6b/attachment.go","path":"internal/cliutil/odata_date_test.go","size":1715,"sha256":"7d2e87d8018e4c2fee71d8a7d0c432b94e0d1e80cfbb2767f200f122e1a07b13","contentType":"text/plain; charset=utf-8"},{"id":"d64fba96-6135-5991-862e-18f35176ebfe","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d64fba96-6135-5991-862e-18f35176ebfe/attachment.go","path":"internal/cliutil/probe.go","size":4776,"sha256":"40732dc00da63c6e75c63fa9e7ccf955aa06759e54d85e6fee4778a8a5083cf6","contentType":"text/plain; charset=utf-8"},{"id":"4c8265f0-cda1-5ed1-8f43-38ccd2b1519f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4c8265f0-cda1-5ed1-8f43-38ccd2b1519f/attachment.go","path":"internal/cliutil/ratelimit.go","size":4858,"sha256":"8330900a45eb390c4bfc5bac24c5b7243448123e601404b18fd2219639615b5c","contentType":"text/plain; charset=utf-8"},{"id":"4c1d3578-063a-5727-85e4-35d662ef3a69","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4c1d3578-063a-5727-85e4-35d662ef3a69/attachment.go","path":"internal/cliutil/text.go","size":1738,"sha256":"7710574bcfbaf138e028d013a192fc283db559c543c8ffdcd7f28096da5f711e","contentType":"text/plain; charset=utf-8"},{"id":"6b0d5d13-11e7-5dea-9e07-49421617bc3e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6b0d5d13-11e7-5dea-9e07-49421617bc3e/attachment.go","path":"internal/cliutil/verifyenv.go","size":4395,"sha256":"a23b2512a9539d5f7817198130e32fdad5a1f16b2a91c80c7d11d56c59bcc1a7","contentType":"text/plain; charset=utf-8"},{"id":"a544ca2f-af9e-51b4-9333-5312f47e3646","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a544ca2f-af9e-51b4-9333-5312f47e3646/attachment.go","path":"internal/config/config.go","size":2728,"sha256":"d7c106e7cc316952b0fda83d6f76c3d5bf3cc175abed8c7345c30fa2a05604c4","contentType":"text/plain; charset=utf-8"},{"id":"b8603c34-071a-543b-a621-c12fe2040591","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b8603c34-071a-543b-a621-c12fe2040591/attachment.go","path":"internal/mcp/cobratree/classify.go","size":3763,"sha256":"9a5a63a974ecbd438b75e70fd7a289452ada66e4353f780edf234a7dd92ff1af","contentType":"text/plain; charset=utf-8"},{"id":"fc918721-aaaa-5359-8f0d-98757d8ee6b6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fc918721-aaaa-5359-8f0d-98757d8ee6b6/attachment.go","path":"internal/mcp/cobratree/cli_path.go","size":888,"sha256":"68211c7f479bed79fb8e3afc89dca9b7ad26d6056d36103fd00bf53250c5b023","contentType":"text/plain; charset=utf-8"},{"id":"a7b0af14-5e91-5f30-ba0f-8e435b6b15ac","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a7b0af14-5e91-5f30-ba0f-8e435b6b15ac/attachment.go","path":"internal/mcp/cobratree/names.go","size":660,"sha256":"6fed5a1fedd63969c7337239675782e8f98e0ff4ec43ad4d84bfd64e20296a76","contentType":"text/plain; charset=utf-8"},{"id":"4d727415-ee01-5aeb-9a9a-08c8b0881b50","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4d727415-ee01-5aeb-9a9a-08c8b0881b50/attachment.go","path":"internal/mcp/cobratree/shellout.go","size":5734,"sha256":"fc707345bb7d83842ba056184c3bb777ddaa93f45b770c05bfb5e54259749dc9","contentType":"text/plain; charset=utf-8"},{"id":"7c4bc3c2-6fad-5b74-bb48-8b2ff1bbddc3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7c4bc3c2-6fad-5b74-bb48-8b2ff1bbddc3/attachment.go","path":"internal/mcp/cobratree/shellout_test.go","size":9069,"sha256":"ba67447c8b2c233db9504b594623db985bec6b9cffffa3bae2194f0bd7272841","contentType":"text/plain; charset=utf-8"},{"id":"05398480-ee71-5316-831c-4748bb8ab00b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/05398480-ee71-5316-831c-4748bb8ab00b/attachment.go","path":"internal/mcp/cobratree/typemap.go","size":2196,"sha256":"dbb6e0ec4bc5ca975c7b109caa376cb11889ce995711ffdb3ec1a431ba0e715e","contentType":"text/plain; charset=utf-8"},{"id":"80ac2d09-b030-5b87-b5cd-63dd0f9b5280","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/80ac2d09-b030-5b87-b5cd-63dd0f9b5280/attachment.go","path":"internal/mcp/cobratree/walker.go","size":2055,"sha256":"ea9f2adf8f1752f4149a53dab9182d5fa2135f8d4f7dad185f3a6119de92aec1","contentType":"text/plain; charset=utf-8"},{"id":"9c6d1133-5953-58e9-bb93-2bd646a8ae75","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9c6d1133-5953-58e9-bb93-2bd646a8ae75/attachment.go","path":"internal/mcp/tools.go","size":23026,"sha256":"6e367119a79f264ff107a98e846404fab4c6c4aee582a5375e7277bb96054959","contentType":"text/plain; charset=utf-8"},{"id":"f4c0b2f6-ea94-5e4f-821d-50af4dc757e3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f4c0b2f6-ea94-5e4f-821d-50af4dc757e3/attachment.go","path":"internal/mcp/tools_test.go","size":3912,"sha256":"a669fc7a36d31733f47d171094d638ff014905720bee943bbbbb1e601dd8fff3","contentType":"text/plain; charset=utf-8"},{"id":"8b5d750d-e621-56fc-a906-6a522ee7f438","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8b5d750d-e621-56fc-a906-6a522ee7f438/attachment.go","path":"internal/store/extras.go","size":869,"sha256":"431b9f40fdfadb1b9835e3f90e36f0c669f6f7ef4eba4a65b53d16b19eacd05d","contentType":"text/plain; charset=utf-8"},{"id":"b5e5e476-8814-5dca-9f04-c8e2246b978a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b5e5e476-8814-5dca-9f04-c8e2246b978a/attachment.go","path":"internal/store/fts_rehash_test.go","size":3554,"sha256":"a0d0f8f8ef603c871873ca9a07f93c6f6257b63a6f762f44e57280fb23759cd2","contentType":"text/plain; charset=utf-8"},{"id":"996b254f-4590-5cc8-a514-63c4a528d0bd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/996b254f-4590-5cc8-a514-63c4a528d0bd/attachment.go","path":"internal/store/open_read_only_test.go","size":2129,"sha256":"f589c34d3fad56850bc2b9480107fefc7140855484aed3f9825ddd2a5732d25c","contentType":"text/plain; charset=utf-8"},{"id":"a518c29b-e861-5be3-8123-b7cbd86474f7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a518c29b-e861-5be3-8123-b7cbd86474f7/attachment.go","path":"internal/store/schema_version_test.go","size":19011,"sha256":"b9ffde7f345fa6d0364d7ff4996b4ab2f1bf0000e95bccb1930423edc502718f","contentType":"text/plain; charset=utf-8"},{"id":"93ed4567-786d-5825-8779-845cb5111c86","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/93ed4567-786d-5825-8779-845cb5111c86/attachment.go","path":"internal/store/store.go","size":46359,"sha256":"e1bec80491d278bf78c00a36dcbd6df0afe44fa1f4ddab17d630d4346b1a93e2","contentType":"text/plain; charset=utf-8"},{"id":"637f761d-caa5-528e-836b-f873e0a776ca","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/637f761d-caa5-528e-836b-f873e0a776ca/attachment.go","path":"internal/store/upsert_batch_test.go","size":10528,"sha256":"b854fece92205349a3a7435c7a943306a31f871867652800e4ba24c7884a80e6","contentType":"text/plain; charset=utf-8"},{"id":"39720ef3-514e-5a97-8cca-f9d20677dc47","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/39720ef3-514e-5a97-8cca-f9d20677dc47/attachment.go","path":"internal/types/types.go","size":202,"sha256":"5c6eab8fde420ddfc5ac5780a557a9af3d1c6880c58633e38ddbb6e375f9a4b2","contentType":"text/plain; charset=utf-8"},{"id":"e049a827-27aa-584e-88de-07afc60faec9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e049a827-27aa-584e-88de-07afc60faec9/attachment.json","path":"manifest.json","size":635,"sha256":"6676c2d7563564794d892df9d9e82e1005ea2abe31109992522458dacbb02f24","contentType":"application/json; charset=utf-8"},{"id":"50546a89-929b-5dd5-bc19-661aeeb3aa86","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/50546a89-929b-5dd5-bc19-661aeeb3aa86/attachment.yaml","path":"spec.yaml","size":6983,"sha256":"5d98a1ee6e331681cd6759533e987803ab56777836e5d592b297adf86024d4f1","contentType":"application/yaml; charset=utf-8"},{"id":"6ac9d718-cecb-5cb2-98a3-fc6ebc2d1de9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6ac9d718-cecb-5cb2-98a3-fc6ebc2d1de9/attachment.json","path":"tools-manifest.json","size":7650,"sha256":"a45d9b5bcc793fd6e9dc6ad6ff6ef9fc7a4c53762b8a2e9183080683e1081685","contentType":"application/json; charset=utf-8"},{"id":"0f73e110-c4b1-5d6a-a1e8-521121add81c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0f73e110-c4b1-5d6a-a1e8-521121add81c/attachment.json","path":"workflow-verify-report.json","size":157,"sha256":"b2d677eaf082b903c33d15c481b499fad09b7ad5a9ab641fb25bf67a9b73475f","contentType":"application/json; charset=utf-8"}],"bundle_sha256":"45370a4269c5aedd68bef3ed7b341129e503abdfd759866c33167c5afdc678f8","attachment_count":107,"text_attachments":107,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"library/sales-and-crm/tenderned/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"documents-office","category_label":"Documents"},"exact_dupes_collapsed_into_this":0},"license":"Apache-2.0","regions":["NL"],"version":"v1","category":"documents-office","metadata":{"openclaw":{"requires":{"bins":["tenderned-pp-cli"]}}},"import_tag":"clean-skills-v1","description":"Dutch public-tender CLI with offline search, document corpus, and the sub-threshold long tail TED never sees.","api_language":"nl","allowed-tools":"Read Bash","argument-hint":"\u003ccommand> [args] | install cli|mcp"}},"renderedAt":1782980685982}

Tenderned — Printing Press CLI Prerequisites: Install the CLI This skill drives the binary. You must verify the CLI is installed before invoking any command from this skill. If it is missing, install it first: 1. Install via the Printing Press installer: 2. Verify: 3. Ensure (or ) is on . If the install fails before this CLI has a public-library category, install Node or use the category-specific Go fallback after publish. If reports "command not found" after install, the install step did not put the binary on . Do not proceed with skill commands until verification succeeds. Every Dutch publi…