feat: built-in structured output (JSON mode)#332
Conversation
Add framework-level JSON output mode so commands can produce machine-parseable output without per-command implementation. - Add `sys.output.format` env flag (text|json, default text) - Add `Output()` and `OutputError()` helpers in core/model - Add `TextFormatter` interface for custom text formatting - Update all api.* commands to emit JSON when in JSON mode - Suppress flow visualization, tip boxes, and frame rendering in JSON mode - Emit structured JSON errors on stderr in JSON mode - Add unit tests for Output, OutputError, and IsJsonOutputMode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a framework-level structured output mode controlled by an env flag so commands and framework display/error rendering can emit machine-parseable JSON instead of human-oriented formatting.
Changes:
- Introduces
sys.output.format(defaulttext) with abbreviations ({sys.out.fmt=...}) to toggle JSON output mode. - Adds
model.Output()/model.OutputError()helpers and unit tests for JSON/text output behavior. - Updates built-in
api.*commands and suppresses flow/tip/stack/result visualizations in JSON mode; routes errors to structured JSON on stderr.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/mods/builtin/env.go | Sets default output format and adds env abbreviations for sys.output.format. |
| pkg/mods/builtin/api.go | Emits structured output for several api.cmd.* commands when JSON mode is enabled. |
| pkg/core/model/output.go | Adds JSON/text output helpers and JSON-mode detection. |
| pkg/core/model/output_test.go | Adds unit tests for the new output helpers. |
| pkg/cli/display/tip.go | Suppresses tip box titles in JSON mode. |
| pkg/cli/display/render.go | Suppresses command stack/result frames in JSON mode. |
| pkg/cli/display/flow.go | Suppresses flow visualization in JSON mode. |
| pkg/cli/display/err.go | Emits structured JSON errors on stderr in JSON mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if IsJsonOutputMode(env) { | ||
| b, err := json.Marshal(data) | ||
| if err != nil { | ||
| return cc.Screen.Error(fmt.Sprintf(`{"error":"marshal failed: %v"}`+"\n", err)) |
There was a problem hiding this comment.
In JSON mode, the marshal-failure path prints a hard-coded JSON string that includes backslashes (e.g. \") and interpolates %v directly into a JSON string. This will emit invalid JSON (and may break JSON consumers) if marshal fails. Consider building the error payload as a Go map/struct and json.Marshal it (or at least remove the backslashes and use proper string escaping like %q).
| return cc.Screen.Error(fmt.Sprintf(`{"error":"marshal failed: %v"}`+"\n", err)) | |
| errorObj := map[string]any{ | |
| "error": fmt.Sprintf("marshal failed: %v", err), | |
| } | |
| errorJSON, marshalErr := json.Marshal(errorObj) | |
| if marshalErr != nil { | |
| return cc.Screen.Error("{\"error\":\"marshal failed\"}\n") | |
| } | |
| return cc.Screen.Error(string(errorJSON) + "\n") |
| b, marshalErr := json.Marshal(obj) | ||
| if marshalErr != nil { | ||
| _ = cc.Screen.Error(fmt.Sprintf(`{"error":%q}`+"\n", err.Error())) | ||
| return true |
There was a problem hiding this comment.
The marshalErr fallback in OutputError formats {"error":%q} which includes backslashes in the emitted output, making it invalid JSON. Please emit a valid JSON object in this fallback (e.g., marshal a simple map or format without the extra escaping).
| func printErrorJson(cc *model.Cli, env *model.Env, err error) { | ||
| detail := map[string]string{} | ||
| errType := "unknown" | ||
|
|
There was a problem hiding this comment.
printErrorJson always initializes detail as an empty (non-nil) map, so OutputError will always include a detail field (often {}), even for the default/unknown case. If the intention is to omit detail when there’s no extra info (as OutputError’s behavior implies), pass nil when no fields were added (e.g., only allocate the map in typed cases or set it to nil when len(detail)==0).
| if model.IsJsonOutputMode(env) { | ||
| var names []string | ||
| cmdCollectNames(cc.Cmds, &names) | ||
| return currCmdIdx, model.Output(cc, env, map[string]any{ | ||
| "commands": names, | ||
| }) |
There was a problem hiding this comment.
PR description/test plan says api.cmd.list.all in JSON mode should produce a JSON array, but this implementation outputs an object with a commands field. Either adjust the output to be a raw array, or update the PR description/usage expectations to match the actual schema.
| func TestOutputJson(t *testing.T) { | ||
| var stdout, stderr bytes.Buffer | ||
| screen := NewStdScreen(&stdout, &stderr) | ||
| env := NewEnvEx(EnvLayerDefault) | ||
| env.Set("sys.output.format", "json") | ||
| cc := &Cli{Screen: screen} | ||
|
|
||
| data := map[string]string{"key": "value", "status": "ok"} | ||
| err := Output(cc, env, data) | ||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
|
|
||
| output := strings.TrimSpace(stdout.String()) | ||
| var result map[string]string | ||
| if err := json.Unmarshal([]byte(output), &result); err != nil { | ||
| t.Fatalf("output is not valid JSON: %v, got: %s", err, output) | ||
| } | ||
| if result["key"] != "value" || result["status"] != "ok" { | ||
| t.Errorf("unexpected JSON content: %v", result) | ||
| } | ||
| } |
There was a problem hiding this comment.
There’s no unit test covering the JSON-mode marshal-failure branches in Output / OutputError (the paths that write a fallback JSON error to stderr). Adding a test that forces a json.Marshal error (e.g., by passing a channel/function) and asserts the emitted stderr is valid JSON would prevent regressions here.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use json.Marshal for error fallback paths instead of string interpolation to ensure valid JSON output in all cases - Pass nil detail map in printErrorJson when no typed error fields are set, so the "detail" key is omitted from the JSON output for unknown errors - Add tests for marshal-failure branches (Output with unmarshable data) and nil-detail OutputError behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@copilot /review |
All the review feedback from the previous review has been addressed in commits
All tests pass ( |
|
Comments from @innerr:
下面这个太重了有点难维护,单纯影响 api.cmd.* 或者(我建议的) api.cmd.json.*,其它命令不影响,可能最方便 |
- Fix text-mode api commands to use cc.Screen.Print() instead of fmt.Fprintf(os.Stdout), so all output goes through Screen abstraction - Remove env-flag checking from existing api.cmd.* commands (revert to clean text-only behavior) - Add dedicated api.cmd.json.info command that returns combined command info (type, meta, path, dir) as a single JSON object - Add api.cmd.json.list.all command for listing all commands as JSON - Add OutputJson() helper that always marshals to JSON (for dedicated json commands, independent of sys.output.format env flag) - Use reflect.TypeOf(err).String() for unknown error types in JSON error output, providing better debug info Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ReviewMajor:
|
- ApiCmdJsonListAll now uses OutputJson() instead of Output() so it always produces JSON regardless of sys.output.format env flag - Fix ineffectual assignment lint error in printErrorJson by using var declaration instead of initial value that gets overwritten Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Both issues from the previous review are fixed:
CI is all green. LGTM, ready to merge. |
Summary
Resolves #330 — adds framework-level JSON output support and dedicated JSON API commands for ticat.
Design
Per @innerr's feedback, the approach is dedicated
api.cmd.json.*commands rather than env-flag switching on existing commands. This keeps JSON output explicit and allows combining multiple fields into one response (which makes sense for JSON but not plain text).Changes
New JSON API commands:
api.cmd.json.info cmd=<name>— returns combined command info as one JSON object:{"command":"...", "type":"...", "meta_file":"...", "path":"...", "dir":"..."}api.cmd.json.list.all— returns all command names:{"commands":["...", ...]}Fix: existing api commands now use Screen:
api.cmd.type,api.cmd.meta,api.cmd.path,api.cmd.dir— changed fromfmt.Fprintf(os.Stdout)tocc.Screen.Print()so output is properly controlled by Screen abstraction (QuietScreen, BgTaskScreen, etc.)Framework infrastructure (for future command authors):
sys.output.formatenv flag (text|json) — controls framework behavior (flow viz suppression, error formatting)OutputJson(cc, data)— always marshals to JSON via Screen (for dedicated json commands)Output(cc, env, data)— format-aware output (json when env flag set, text otherwise)OutputError(cc, env, errType, err, detail)— structured JSON errors on stderrTextFormatterinterface — custom text rendering for typessys.output.format=jsonreflect.TypeOf(err).String()for unknown error typesUsage
Test plan
go test ./...)Output(),OutputJson(),OutputError(),IsJsonOutputMode(),TextFormatterticat api.cmd.json.info cmd=hub.addproduces valid JSONticat api.cmd.json.list.allproduces valid JSONapi.cmd.*still produce plain text🤖 Generated with Claude Code