diff --git a/cmd/root.go b/cmd/root.go index 5497e038..c726408e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "log" "os" @@ -29,6 +30,7 @@ var ( debugMode bool promptFlag string quietFlag bool + jsonFlag bool noExitFlag bool maxSteps int streamFlag bool // Enable streaming output @@ -203,6 +205,8 @@ func init() { StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt") rootCmd.PersistentFlags(). BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)") + rootCmd.PersistentFlags(). + BoolVar(&jsonFlag, "json", false, "output response as JSON (only works with --prompt)") rootCmd.PersistentFlags(). BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead") rootCmd.PersistentFlags(). @@ -458,6 +462,12 @@ func runNormalMode(ctx context.Context) error { if quietFlag && promptFlag == "" { return fmt.Errorf("--quiet flag can only be used with --prompt/-p") } + if jsonFlag && promptFlag == "" { + return fmt.Errorf("--json flag can only be used with --prompt/-p") + } + if jsonFlag && noExitFlag { + return fmt.Errorf("--json and --no-exit flags cannot be used together") + } if noExitFlag && promptFlag == "" { return fmt.Errorf("--no-exit flag can only be used with --prompt/-p") } @@ -734,7 +744,7 @@ func runNormalMode(ctx context.Context) error { // Check if running in non-interactive mode if promptFlag != "" { - return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility) + return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility) } // Quiet mode is not allowed in interactive mode @@ -755,8 +765,20 @@ func runNormalMode(ctx context.Context) error { // // When --no-exit is set, after the prompt completes the interactive BubbleTea // TUI is started so the user can continue the conversation. -func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility) error { - if quiet { +func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility) error { + if jsonOutput { + // JSON mode: no intermediate display, structured JSON output. + result, err := appInstance.RunOnceResult(ctx, prompt) + if err != nil { + writeJSONError(err) + return err + } + data, err := buildJSONOutput(result, modelName) + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + fmt.Println(string(data)) + } else if quiet { // Quiet mode: no intermediate display, just print final response. if err := appInstance.RunOnce(ctx, prompt); err != nil { return err @@ -787,6 +809,83 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui return nil } +// --------------------------------------------------------------------------- +// JSON output helpers (--json mode) +// --------------------------------------------------------------------------- + +// buildJSONOutput converts a TurnResult into a structured JSON byte slice +// suitable for machine consumption. +func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) { + type jsonPart struct { + Type string `json:"type"` + Data any `json:"data"` + } + type jsonMessage struct { + Role string `json:"role"` + Parts []jsonPart `json:"parts"` + } + type jsonUsage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreationTokens int64 `json:"cache_creation_tokens"` + } + type jsonEnvelope struct { + Response string `json:"response"` + Model string `json:"model"` + Usage *jsonUsage `json:"usage,omitempty"` + Messages []jsonMessage `json:"messages"` + } + + out := jsonEnvelope{ + Response: result.Response, + Model: model, + } + + if result.TotalUsage != nil { + out.Usage = &jsonUsage{ + InputTokens: result.TotalUsage.InputTokens, + OutputTokens: result.TotalUsage.OutputTokens, + TotalTokens: result.TotalUsage.TotalTokens, + CacheReadTokens: result.TotalUsage.CacheReadTokens, + CacheCreationTokens: result.TotalUsage.CacheCreationTokens, + } + } + + for _, fmsg := range result.Messages { + converted := kit.ConvertFromFantasyMessage(fmsg) + m := jsonMessage{Role: string(converted.Role)} + for _, p := range converted.Parts { + switch c := p.(type) { + case kit.TextContent: + m.Parts = append(m.Parts, jsonPart{Type: "text", Data: c}) + case kit.ToolCall: + m.Parts = append(m.Parts, jsonPart{Type: "tool_call", Data: c}) + case kit.ToolResult: + m.Parts = append(m.Parts, jsonPart{Type: "tool_result", Data: c}) + case kit.ReasoningContent: + m.Parts = append(m.Parts, jsonPart{Type: "reasoning", Data: c}) + case kit.Finish: + m.Parts = append(m.Parts, jsonPart{Type: "finish", Data: c}) + } + } + out.Messages = append(out.Messages, m) + } + + return json.MarshalIndent(out, "", " ") +} + +// writeJSONError writes a JSON-formatted error object to stdout so that +// callers using --json always receive parseable output. +func writeJSONError(err error) { + type jsonError struct { + Error string `json:"error"` + } + data, _ := json.MarshalIndent(jsonError{Error: err.Error()}, "", " ") + fmt.Fprintln(os.Stderr, string(data)) +} + // runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI. // // It: diff --git a/examples/extensions/kit-kit.go b/examples/extensions/kit-kit.go index 61f8eba0..ea91ca14 100644 --- a/examples/extensions/kit-kit.go +++ b/examples/extensions/kit-kit.go @@ -19,6 +19,7 @@ package main import ( + "bytes" "encoding/json" "fmt" "os" @@ -31,6 +32,16 @@ import ( "kit/ext" ) +// kitJSONOutput matches the JSON envelope produced by `kit --json`. +type kitJSONOutput struct { + Response string `json:"response"` + Model string `json:"model"` + Usage *struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + } `json:"usage,omitempty"` +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti } tmpFile.Close() - // Build subprocess arguments. Don't pass --model; the subprocess - // inherits the same config/env and will use the same default. + // Build subprocess arguments. Use --json for structured output parsing. + // Don't pass --model; the subprocess inherits the same config/env default. args := []string{ "--prompt", question, - "--quiet", + "--json", "--no-session", "--no-extensions", "--system-prompt", tmpFile.Name(), } + var stdoutBuf, stderrBuf bytes.Buffer cmd := exec.Command(kitBinary, args...) cmd.Env = os.Environ() + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf - outBytes, err := cmd.CombinedOutput() + err = cmd.Run() close(done) elapsed = time.Since(start) - result := strings.TrimSpace(string(outBytes)) if err != nil { - // Extract a single-line summary for the card (no newlines). - errLine := result + // On error, prefer stderr for the error message; fall back to stdout. + errText := strings.TrimSpace(stderrBuf.String()) + if errText == "" { + errText = strings.TrimSpace(stdoutBuf.String()) + } + errLine := errText if idx := strings.Index(errLine, "\n"); idx >= 0 { errLine = errLine[:idx] } @@ -505,10 +522,18 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti if exitErr, ok := err.(*exec.ExitError); ok { code = exitErr.ExitCode() } - return result, code, elapsed + return errText, code, elapsed } - // Success — extract last non-empty line for the card. + // Parse JSON output from subprocess. + var parsed kitJSONOutput + result := strings.TrimSpace(stdoutBuf.String()) + if err := json.Unmarshal([]byte(result), &parsed); err == nil { + result = parsed.Response + } + // else: fall back to raw stdout (e.g. older kit binary without --json) + + // Extract last non-empty line for the card. lines := strings.Split(result, "\n") var lastLine string for i := len(lines) - 1; i >= 0; i-- { diff --git a/examples/extensions/subagent-widget.go b/examples/extensions/subagent-widget.go index 45dbf820..9666bd8b 100644 --- a/examples/extensions/subagent-widget.go +++ b/examples/extensions/subagent-widget.go @@ -35,6 +35,11 @@ import ( "kit/ext" ) +// subJSONOutput matches the JSON envelope produced by `kit --json`. +type subJSONOutput struct { + Response string `json:"response"` +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -205,7 +210,7 @@ func spawnAgent(state *subState) { args := []string{ "--prompt", prompt, - "--quiet", + "--json", "--no-session", "--no-extensions", } @@ -261,7 +266,7 @@ func spawnAgent(state *subState) { } }() - // Read stderr in background goroutine. + // Read stderr in background goroutine (live widget updates). var readWg sync.WaitGroup readWg.Add(1) go func() { @@ -277,12 +282,12 @@ func spawnAgent(state *subState) { } }() - // Read stdout in foreground. + // Read stdout into a separate buffer (JSON output from --json mode). + var stdoutBuf strings.Builder scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 256*1024), 256*1024) for scanner.Scan() { - state.appendChunk(scanner.Text() + "\n") - updateWidgets() + stdoutBuf.WriteString(scanner.Text() + "\n") } // Wait for all pipe readers, then the process. @@ -290,6 +295,17 @@ func spawnAgent(state *subState) { waitErr := cmd.Wait() close(doneCh) // stop timer + // Parse JSON output from --json mode to extract the response. + var result string + rawStdout := strings.TrimSpace(stdoutBuf.String()) + var parsed subJSONOutput + if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" { + result = parsed.Response + } else { + // Fallback: use raw stdout (e.g. older kit binary without --json). + result = rawStdout + } + state.mu.Lock() state.Elapsed = time.Since(start) state.Proc = nil @@ -298,7 +314,6 @@ func spawnAgent(state *subState) { } else { state.Status = "done" } - result := strings.Join(state.Chunks, "") // Save history for /subcont continuations (cap at 16 KB). state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n", diff --git a/internal/app/app.go b/internal/app/app.go index 8b4cda52..c29db0f1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -254,6 +254,20 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error { return nil } +// RunOnceResult executes a single agent step synchronously and returns the +// full TurnResult without printing anything. This is used by --json mode to +// capture structured output for serialization. +func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) { + stepCtx, cancel := context.WithCancel(ctx) + defer cancel() + + a.mu.Lock() + a.cancelStep = cancel + a.mu.Unlock() + + return a.executeStep(stepCtx, prompt, nil) +} + // RunOnceWithDisplay executes a single agent step synchronously, sending // intermediate display events (spinner, tool calls, streaming chunks, etc.) // to eventFn. This is the non-TUI equivalent of the interactive Run() path —