← back to Cli Printing Press
feat(cli): add cliutil.ExtractNumber/ExtractInt for JSON-string-encoded numeric fields (#1002)
182395caaa631a5fa7721dc0a6f158c557cd473e · 2026-05-10 18:54:05 -0700 · Trevin Chow
Streaming and market-data APIs (Binance, Coinbase, Kraken, Stripe
*_decimal, vendor WebSocket feeds) commonly encode numeric values as
JSON-encoded strings ("price":"1.91"). json.Unmarshal of a JSON string
into a float64 struct field returns no error and silently leaves the
field at 0 — combined with NULL-on-zero patterns this discards the
entire numeric feed with no error signal at any structural test layer.
Emit a shared cliutil.ExtractNumber + ExtractInt helper into every
generated CLI. Both accept either a JSON number or a JSON-encoded
string, report ok=false on missing/null/empty/unparseable, and operate
on map[string]json.RawMessage so streaming-frame normalizers can use
them directly.
Surface the helper to novel-feature authors in the Phase 3 "Shared
helpers available to novel code" block of the skill, alongside the
existing FanoutRun / CleanText guidance.
Closes #989
Files touched
M internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/cliutil_extractnumber.go.tmplA internal/generator/templates/cliutil_extractnumber_test.go.tmplM skills/printing-press/SKILL.md
Diff
commit 182395caaa631a5fa7721dc0a6f158c557cd473e
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 18:54:05 2026 -0700
feat(cli): add cliutil.ExtractNumber/ExtractInt for JSON-string-encoded numeric fields (#1002)
Streaming and market-data APIs (Binance, Coinbase, Kraken, Stripe
*_decimal, vendor WebSocket feeds) commonly encode numeric values as
JSON-encoded strings ("price":"1.91"). json.Unmarshal of a JSON string
into a float64 struct field returns no error and silently leaves the
field at 0 — combined with NULL-on-zero patterns this discards the
entire numeric feed with no error signal at any structural test layer.
Emit a shared cliutil.ExtractNumber + ExtractInt helper into every
generated CLI. Both accept either a JSON number or a JSON-encoded
string, report ok=false on missing/null/empty/unparseable, and operate
on map[string]json.RawMessage so streaming-frame normalizers can use
them directly.
Surface the helper to novel-feature authors in the Phase 3 "Shared
helpers available to novel code" block of the skill, alongside the
existing FanoutRun / CleanText guidance.
Closes #989
---
internal/generator/generator.go | 76 ++++++-------
internal/generator/generator_test.go | 14 ++-
.../templates/cliutil_extractnumber.go.tmpl | 67 ++++++++++++
.../templates/cliutil_extractnumber_test.go.tmpl | 117 +++++++++++++++++++++
skills/printing-press/SKILL.md | 2 +
5 files changed, 235 insertions(+), 41 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3d1efb1e..c5180e6f 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -1387,37 +1387,39 @@ func (g *Generator) prepareOutput() error {
func (g *Generator) renderSingleFiles() error {
singleFiles := map[string]string{
- "main.go.tmpl": filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
- "helpers.go.tmpl": filepath.Join("internal", "cli", "helpers.go"),
- "doctor.go.tmpl": filepath.Join("internal", "cli", "doctor.go"),
- "agent_context.go.tmpl": filepath.Join("internal", "cli", "agent_context.go"),
- "profile.go.tmpl": filepath.Join("internal", "cli", "profile.go"),
- "deliver.go.tmpl": filepath.Join("internal", "cli", "deliver.go"),
- "feedback.go.tmpl": filepath.Join("internal", "cli", "feedback.go"),
- "which.go.tmpl": filepath.Join("internal", "cli", "which.go"),
- "which_test.go.tmpl": filepath.Join("internal", "cli", "which_test.go"),
- "config.go.tmpl": filepath.Join("internal", "config", "config.go"),
- "cache.go.tmpl": filepath.Join("internal", "cache", "cache.go"),
- "client.go.tmpl": filepath.Join("internal", "client", "client.go"),
- "cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
- "cliutil_text.go.tmpl": filepath.Join("internal", "cliutil", "text.go"),
- "cliutil_probe.go.tmpl": filepath.Join("internal", "cliutil", "probe.go"),
- "cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
- "cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
- "cliutil_test.go.tmpl": filepath.Join("internal", "cliutil", "cliutil_test.go"),
- "cobratree/walker.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "walker.go"),
- "cobratree/classify.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "classify.go"),
- "cobratree/typemap.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "typemap.go"),
- "cobratree/shellout.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "shellout.go"),
- "cobratree/cli_path.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "cli_path.go"),
- "cobratree/names.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "names.go"),
- "types.go.tmpl": filepath.Join("internal", "types", "types.go"),
- "golangci.yml.tmpl": ".golangci.yml",
- "readme.md.tmpl": "README.md",
- "agents.md.tmpl": "AGENTS.md",
- "skill.md.tmpl": "SKILL.md",
- "LICENSE.tmpl": "LICENSE",
- "NOTICE.tmpl": "NOTICE",
+ "main.go.tmpl": filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
+ "helpers.go.tmpl": filepath.Join("internal", "cli", "helpers.go"),
+ "doctor.go.tmpl": filepath.Join("internal", "cli", "doctor.go"),
+ "agent_context.go.tmpl": filepath.Join("internal", "cli", "agent_context.go"),
+ "profile.go.tmpl": filepath.Join("internal", "cli", "profile.go"),
+ "deliver.go.tmpl": filepath.Join("internal", "cli", "deliver.go"),
+ "feedback.go.tmpl": filepath.Join("internal", "cli", "feedback.go"),
+ "which.go.tmpl": filepath.Join("internal", "cli", "which.go"),
+ "which_test.go.tmpl": filepath.Join("internal", "cli", "which_test.go"),
+ "config.go.tmpl": filepath.Join("internal", "config", "config.go"),
+ "cache.go.tmpl": filepath.Join("internal", "cache", "cache.go"),
+ "client.go.tmpl": filepath.Join("internal", "client", "client.go"),
+ "cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
+ "cliutil_text.go.tmpl": filepath.Join("internal", "cliutil", "text.go"),
+ "cliutil_probe.go.tmpl": filepath.Join("internal", "cliutil", "probe.go"),
+ "cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
+ "cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
+ "cliutil_extractnumber.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber.go"),
+ "cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
+ "cliutil_test.go.tmpl": filepath.Join("internal", "cliutil", "cliutil_test.go"),
+ "cobratree/walker.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "walker.go"),
+ "cobratree/classify.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "classify.go"),
+ "cobratree/typemap.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "typemap.go"),
+ "cobratree/shellout.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "shellout.go"),
+ "cobratree/cli_path.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "cli_path.go"),
+ "cobratree/names.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "names.go"),
+ "types.go.tmpl": filepath.Join("internal", "types", "types.go"),
+ "golangci.yml.tmpl": ".golangci.yml",
+ "readme.md.tmpl": "README.md",
+ "agents.md.tmpl": "AGENTS.md",
+ "skill.md.tmpl": "SKILL.md",
+ "LICENSE.tmpl": "LICENSE",
+ "NOTICE.tmpl": "NOTICE",
}
for tmplName, outPath := range singleFiles {
@@ -1670,11 +1672,13 @@ func (g *Generator) GenerateMCPSurface() error {
// predates a helper the new MCP template uses (SanitizeErrorBody,
// LooksLikeAuthError) fail to build after migration. See the
// GenerateMCPSurface doc comment for the full rationale.
- "cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
- "cliutil_text.go.tmpl": filepath.Join("internal", "cliutil", "text.go"),
- "cliutil_probe.go.tmpl": filepath.Join("internal", "cliutil", "probe.go"),
- "cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
- "cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
+ "cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
+ "cliutil_text.go.tmpl": filepath.Join("internal", "cliutil", "text.go"),
+ "cliutil_probe.go.tmpl": filepath.Join("internal", "cliutil", "probe.go"),
+ "cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
+ "cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
+ "cliutil_extractnumber.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber.go"),
+ "cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
}
for tmplName, outPath := range mcpFiles {
if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 438a08eb..bf54a5e9 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -56,6 +56,8 @@ func TestGenerateProjectsCompile(t *testing.T) {
"internal/cliutil/probe.go",
"internal/cliutil/ratelimit.go",
"internal/cliutil/verifyenv.go",
+ "internal/cliutil/extractnumber.go",
+ "internal/cliutil/extractnumber_test.go",
"internal/cliutil/cliutil_test.go",
"internal/client/client.go",
"internal/config/config.go",
@@ -76,9 +78,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
// Bump it AND add to mustInclude above when adding always-emitted
// templates. Per-spec dynamic files (per-resource command files,
// generated tests) account for the difference between fixtures.
- {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 55},
- {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 60},
- {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 57},
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 57},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 62},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 59},
}
for _, tt := range tests {
@@ -125,9 +127,9 @@ func TestGenerateCliutilPackage(t *testing.T) {
gen := New(apiSpec, outputDir)
require.NoError(t, gen.Generate())
- // All three cliutil files must be emitted.
+ // All cliutil files must be emitted.
cliutilDir := filepath.Join(outputDir, "internal", "cliutil")
- for _, name := range []string{"fanout.go", "text.go", "cliutil_test.go"} {
+ for _, name := range []string{"fanout.go", "text.go", "extractnumber.go", "extractnumber_test.go", "cliutil_test.go"} {
_, err := os.Stat(filepath.Join(cliutilDir, name))
require.NoError(t, err, "expected %s to be emitted", name)
}
@@ -146,6 +148,8 @@ func TestGenerateCliutilPackage(t *testing.T) {
{"text.go", "func CleanText("},
{"text.go", "func LooksLikeAuthError("},
{"text.go", "func SanitizeErrorBody("},
+ {"extractnumber.go", "func ExtractNumber("},
+ {"extractnumber.go", "func ExtractInt("},
} {
data, err := os.ReadFile(filepath.Join(cliutilDir, probe.file))
require.NoError(t, err)
diff --git a/internal/generator/templates/cliutil_extractnumber.go.tmpl b/internal/generator/templates/cliutil_extractnumber.go.tmpl
new file mode 100644
index 00000000..d309cc46
--- /dev/null
+++ b/internal/generator/templates/cliutil_extractnumber.go.tmpl
@@ -0,0 +1,67 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// ExtractNumber reads a numeric value from a json.RawMessage map, accepting
+// either a JSON number (1.91) or a JSON-encoded string ("1.91"). Returns
+// (value, ok). ok=false when the key is missing, JSON null, an empty raw
+// payload, an empty string, or unparseable as a float64.
+//
+// Why this exists: many streaming and market-data APIs (Binance, Coinbase,
+// Kraken, Stripe *_decimal fields, vendor-specific WebSocket feeds) encode
+// numeric values as JSON-encoded strings. json.Unmarshal of "1.91" into a
+// float64 struct field silently zeroes the field — no error is returned.
+// Combined with a NULL-on-zero pattern (common for SQL aggregation
+// correctness) every such field becomes a SQL NULL and downstream
+// aggregations return empty. The failure is silent at every structural
+// test layer; only frame-level inspection reveals it.
+//
+// Prefer this helper over hand-rolled float64 struct fields when authoring
+// streaming frame normalizers or any decoder whose wire shape is not
+// fully fixed by the spec.
+func ExtractNumber(probe map[string]json.RawMessage, key string) (float64, bool) {
+ raw, present := probe[key]
+ if !present || len(raw) == 0 || string(raw) == "null" {
+ return 0, false
+ }
+ var f float64
+ if err := json.Unmarshal(raw, &f); err == nil {
+ return f, true
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ if v, perr := strconv.ParseFloat(s, 64); perr == nil {
+ return v, true
+ }
+ }
+ return 0, false
+}
+
+// ExtractInt is the integer companion of ExtractNumber. It accepts a JSON
+// integer (123) or a JSON-encoded integer string ("123"). Returns
+// (value, ok). ok=false when the key is missing, null, empty, or
+// unparseable as an int64. A non-integer JSON number (1.5) is rejected
+// rather than truncated.
+func ExtractInt(probe map[string]json.RawMessage, key string) (int64, bool) {
+ raw, present := probe[key]
+ if !present || len(raw) == 0 || string(raw) == "null" {
+ return 0, false
+ }
+ var n int64
+ if err := json.Unmarshal(raw, &n); err == nil {
+ return n, true
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil && s != "" {
+ if v, perr := strconv.ParseInt(s, 10, 64); perr == nil {
+ return v, true
+ }
+ }
+ return 0, false
+}
diff --git a/internal/generator/templates/cliutil_extractnumber_test.go.tmpl b/internal/generator/templates/cliutil_extractnumber_test.go.tmpl
new file mode 100644
index 00000000..a8b17559
--- /dev/null
+++ b/internal/generator/templates/cliutil_extractnumber_test.go.tmpl
@@ -0,0 +1,117 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+ "encoding/json"
+ "math"
+ "testing"
+)
+
+func TestExtractNumber(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ input string
+ key string
+ wantV float64
+ wantOk bool
+ }{
+ {"json number", `{"price":1.91}`, "price", 1.91, true},
+ {"json integer", `{"price":42}`, "price", 42, true},
+ {"json zero", `{"price":0}`, "price", 0, true},
+ {"json negative number", `{"price":-3.5}`, "price", -3.5, true},
+ {"string-encoded number", `{"price":"1.91"}`, "price", 1.91, true},
+ {"string-encoded integer", `{"qty":"100"}`, "qty", 100, true},
+ {"string-encoded negative", `{"x":"-3.5"}`, "x", -3.5, true},
+ {"string-encoded exponent", `{"x":"1e2"}`, "x", 100, true},
+ {"null", `{"price":null}`, "price", 0, false},
+ {"missing key", `{}`, "price", 0, false},
+ {"empty string", `{"price":""}`, "price", 0, false},
+ {"unparseable string", `{"price":"abc"}`, "price", 0, false},
+ {"bool true", `{"price":true}`, "price", 0, false},
+ {"object", `{"price":{"a":1}}`, "price", 0, false},
+ {"array", `{"price":[1,2]}`, "price", 0, false},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(tc.input), &probe); err != nil {
+ t.Fatalf("unmarshal probe: %v", err)
+ }
+ got, ok := ExtractNumber(probe, tc.key)
+ if ok != tc.wantOk {
+ t.Fatalf("ExtractNumber ok = %v, want %v", ok, tc.wantOk)
+ }
+ if ok && math.Abs(got-tc.wantV) > 1e-9 {
+ t.Fatalf("ExtractNumber value = %v, want %v", got, tc.wantV)
+ }
+ })
+ }
+}
+
+func TestExtractNumber_NilProbe(t *testing.T) {
+ t.Parallel()
+
+ got, ok := ExtractNumber(nil, "anything")
+ if ok || got != 0 {
+ t.Fatalf("ExtractNumber(nil, ...) = (%v, %v), want (0, false)", got, ok)
+ }
+}
+
+func TestExtractInt(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ input string
+ key string
+ wantV int64
+ wantOk bool
+ }{
+ {"json integer", `{"id":123}`, "id", 123, true},
+ {"json zero", `{"id":0}`, "id", 0, true},
+ {"json negative", `{"id":-5}`, "id", -5, true},
+ {"string-encoded int", `{"id":"123"}`, "id", 123, true},
+ {"string-encoded negative", `{"id":"-5"}`, "id", -5, true},
+ {"null", `{"id":null}`, "id", 0, false},
+ {"missing key", `{}`, "id", 0, false},
+ {"empty string", `{"id":""}`, "id", 0, false},
+ {"unparseable string", `{"id":"abc"}`, "id", 0, false},
+ {"float rejected", `{"id":1.5}`, "id", 0, false},
+ {"float string rejected", `{"id":"1.5"}`, "id", 0, false},
+ {"bool", `{"id":true}`, "id", 0, false},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(tc.input), &probe); err != nil {
+ t.Fatalf("unmarshal probe: %v", err)
+ }
+ got, ok := ExtractInt(probe, tc.key)
+ if ok != tc.wantOk {
+ t.Fatalf("ExtractInt ok = %v, want %v", ok, tc.wantOk)
+ }
+ if ok && got != tc.wantV {
+ t.Fatalf("ExtractInt value = %v, want %v", got, tc.wantV)
+ }
+ })
+ }
+}
+
+func TestExtractInt_NilProbe(t *testing.T) {
+ t.Parallel()
+
+ got, ok := ExtractInt(nil, "anything")
+ if ok || got != 0 {
+ t.Fatalf("ExtractInt(nil, ...) = (%v, %v), want (0, false)", got, ok)
+ }
+}
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index b87b0af0..2dba62ec 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2366,6 +2366,8 @@ For features that combine both (cache an API response in the store, or fall thro
**Shared helpers available to novel code:** The generator emits `internal/cliutil/` in every CLI. When authoring novel commands, prefer `cliutil.FanoutRun` for any aggregation command (any `--site`/`--source`/`--region` CSV fan-out) and `cliutil.CleanText` for any text extracted from HTML or schema.org JSON-LD. Re-implementing these inline is how recipe-goat's trending silent-drop and `'` entity bugs shipped.
+**Streaming frame normalizers MUST use `cliutil.ExtractNumber` / `cliutil.ExtractInt` rather than raw `float64`/`int64` struct fields.** Real-world WebSocket and streaming JSON feeds (Binance, Coinbase, Kraken, Stripe `*_decimal`, vendor-specific market-data feeds) commonly encode numeric values as JSON-encoded strings (`"price":"1.91"`). `json.Unmarshal` of a JSON string into a `float64` field returns no error and silently leaves the field at 0; combined with NULL-on-zero patterns this discards the entire numeric feed with no error signal anywhere in the pipeline. The helpers accept both shapes (JSON number or JSON-encoded string), report `ok=false` on missing/null/unparseable, and are the canonical extraction path for `map[string]json.RawMessage` decoders. Re-implementing this inline as a `float64` struct field is the silent-aggregation-failure bug class.
+
**Typed exit-code verification:** If a novel command intentionally returns a non-zero code for a non-error control-flow result, add `cmd.Annotations["pp:typed-exit-codes"] = "0,<code>"` (or the equivalent `Annotations: map[string]string{...}` literal) and document the same command-specific codes in its help. Do not list the global failure palette in command help unless those exits should count as a verify pass for that command; keep general exit-code troubleshooting in README/SKILL prose.
**MCP exposure:** The generator emits `internal/mcp/cobratree/`, and the MCP binary mirrors the Cobra tree at startup. When you add, rename, or remove a user-facing Cobra command, the MCP surface follows automatically. Two annotations control how each command appears as an MCP tool:
← 6e086c00 fix(cli): fold per-spec base URL path prefixes on multi-spec
·
back to Cli Printing Press
·
fix(cli): bind typed upsert values in column-declaration ord fef70ba1 →