Skip to content

feat: built-in structured output (JSON mode)#332

Merged
SabaPing merged 5 commits into
mainfrom
feature/json-output-mode
Apr 3, 2026
Merged

feat: built-in structured output (JSON mode)#332
SabaPing merged 5 commits into
mainfrom
feature/json-output-mode

Conversation

@SabaPing

@SabaPing SabaPing commented Apr 2, 2026

Copy link
Copy Markdown
Collaborator

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 from fmt.Fprintf(os.Stdout) to cc.Screen.Print() so output is properly controlled by Screen abstraction (QuietScreen, BgTaskScreen, etc.)

Framework infrastructure (for future command authors):

  • sys.output.format env 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 stderr
  • TextFormatter interface — custom text rendering for types
  • Flow visualization, tip boxes, cmd stack/result frames suppressed when sys.output.format=json
  • JSON error output includes reflect.TypeOf(err).String() for unknown error types

Usage

# Dedicated JSON commands (always JSON, no env flag needed)
ticat api.cmd.json.info cmd=hub.add
# {"command":"hub.add","dir":"/path/to/dir","meta_file":"/path/to/meta","path":"/path/to/bin","type":"normal"}

ticat api.cmd.json.list.all
# {"commands":["api.cmd.dir","api.cmd.json.info",...]}

# Text commands (unchanged behavior, now properly using Screen)
ticat api.cmd.type cmd=hub.add
# normal

Test plan

  • All existing tests pass (go test ./...)
  • Unit tests for Output(), OutputJson(), OutputError(), IsJsonOutputMode(), TextFormatter
  • Tests for marshal-failure fallback branches
  • Tests for nil-detail OutputError
  • Manual: ticat api.cmd.json.info cmd=hub.add produces valid JSON
  • Manual: ticat api.cmd.json.list.all produces valid JSON
  • Manual: existing api.cmd.* still produce plain text

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings April 2, 2026 03:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (default text) 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.

Comment thread pkg/core/model/output.go Outdated
if IsJsonOutputMode(env) {
b, err := json.Marshal(data)
if err != nil {
return cc.Screen.Error(fmt.Sprintf(`{"error":"marshal failed: %v"}`+"\n", err))

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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")

Copilot uses AI. Check for mistakes.
Comment thread pkg/core/model/output.go
Comment on lines +50 to +53
b, marshalErr := json.Marshal(obj)
if marshalErr != nil {
_ = cc.Screen.Error(fmt.Sprintf(`{"error":%q}`+"\n", err.Error()))
return true

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread pkg/cli/display/err.go
Comment on lines +10 to +13
func printErrorJson(cc *model.Cli, env *model.Env, err error) {
detail := map[string]string{}
errType := "unknown"

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread pkg/mods/builtin/api.go Outdated
Comment on lines +152 to +157
if model.IsJsonOutputMode(env) {
var names []string
cmdCollectNames(cc.Cmds, &names)
return currCmdIdx, model.Output(cc, env, map[string]any{
"commands": names,
})

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +43
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)
}
}

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
home and others added 2 commits April 2, 2026 11:17
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>

@SabaPing SabaPing left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot review this PR again

@SabaPing

SabaPing commented Apr 2, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot /review

Copilot AI commented Apr 2, 2026

Copy link
Copy Markdown

@copilot review this PR again

All the review feedback from the previous review has been addressed in commits c34bdc3 and 083e811:

  1. Marshal-failure fallback (output.go:26) — uses map[string]any + json.Marshal instead of string interpolation, emitting valid JSON.
  2. OutputError fallback (output.go:57-64) — similarly uses proper json.Marshal for the fallback error object.
  3. detail field omission (err.go:10-13) — var detail map[string]string is nil in Go, so the unknown/default case correctly passes nil to OutputError, which omits the detail key.
  4. api.cmd.list.all schema — the PR description already documents {"commands":[...]} (a JSON object with a commands array), which matches the implementation.
  5. Marshal-failure testsTestOutputJsonMarshalFailure and TestOutputErrorJsonNilDetail cover those branches.

All tests pass (go test ./pkg/...).

@SabaPing

SabaPing commented Apr 2, 2026

Copy link
Copy Markdown
Collaborator Author

Comments from @innerr:

  1. Output() 在 JSON 模式下绕过了 Screen 的 stdout @ pkg/mods/builtin/api.go — 现有的 api 命令在 text 模式下使用 fmt.Fprintf(os.Stdout, ...) 直接写 stdout,但在 JSON 模式下切换到了 model.Output() 写 cc.Screen。这意味着 text 模式的输出不受 Screen 控制(例如 QuietScreen 无法抑制它),而 JSON 模式可以。这是一个不一致性。不过这不是本 PR 引入的问题(text 模式下直写 os.Stdout 是原有行为),只是需要注意 JSON 模式走的是正确路径。应该是 text 和 json 都应该受 screen 控制,原来的就有不足,json 沿用了错误的做法,一起改掉最好
  2. printErrorJson 的 error type switch 不完整 @ pkg/cli/display/err.go:10-47 — 只覆盖了 4 种已知错误类型,default 分支走 model.OutputError 传入 detail=nil。这意味着未知错误也会以 JSON 格式输出,但 errType="unknown",没有提供足够的调试信息。建议至少把 err 的 Go type 名写进去,如 reflect.TypeOf(err).String()。
    其实我心里想的是:
    这一组 API 在平文本模式下需要拆成多个命令:api.cmd.type, api.cmd.meta, api.cmd.path, api.cmd.dir, api.cmd.list.all,但在 json 输出的情况下,几个可以合成一个(因为平文本单行内容不宜过多,大部分是 kv 结构,json 的话可以写很复杂的结构体)
    如果使用单独的 json 命令 而不是 env 来决定的话,那可以 api.cmd.json.*

下面这个太重了有点难维护,单纯影响 api.cmd.* 或者(我建议的) api.cmd.json.*,其它命令不影响,可能最方便

For command authors
// In your command handler:
func MyCommand(argv model.ArgVals, cc *model.Cli, env *model.Env, ...) {
    result := doWork()
    model.Output(cc, env, map[string]any{
        "status": "ok",
        "data":   result,
    })
}

- 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>
@innerr

innerr commented Apr 2, 2026

Copy link
Copy Markdown
Owner

Review

Major: ApiCmdJsonListAll uses Output() instead of OutputJson()

pkg/mods/builtin/api.go:173-176 — This is a dedicated api.cmd.json.* command and should always produce JSON, but it currently calls Output(cc, env, data) which only outputs JSON when sys.output.format=json. This is inconsistent with ApiCmdJson, which correctly uses OutputJson(cc, data).

// Current (depends on env flag):
return currCmdIdx, model.Output(cc, env, map[string]any{
    "commands": names,
})

// Should be (always JSON):
return currCmdIdx, model.OutputJson(cc, map[string]any{
    "commands": names,
})

Lint is failing

CI lint check is red on the latest commit: https://github.com/innerr/ticat/actions/runs/23900275388/job/69694887972

Summary

Everything else LGTM. Once Major #1 and the lint failure are fixed, this PR is ready to merge.

- 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>
@innerr

innerr commented Apr 3, 2026

Copy link
Copy Markdown
Owner

Both issues from the previous review are fixed:

  1. Major cli: implement of command line parsing and executing framework #1 (Output()OutputJson())ApiCmdJsonListAll now correctly uses OutputJson(cc, ...)
  2. Lint (ineffassign)errType changed from errType := "unknown" to var errType string

CI is all green. LGTM, ready to merge.

@innerr innerr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@SabaPing SabaPing merged commit 7c5b835 into main Apr 3, 2026
4 checks passed
@SabaPing SabaPing deleted the feature/json-output-mode branch April 3, 2026 07:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Built-in structured output (JSON mode)

4 participants