将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main, 服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。 主要内容: - 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355) - DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390) - envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化 - skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等) - product/oa 进一步对齐 wukong 排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com); 以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。 Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com> Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
641 lines
21 KiB
Go
641 lines
21 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
// Package helpers — readable-output enrichment for `report inbox list` and
|
||
// `report outbox list`.
|
||
//
|
||
// What this file adds:
|
||
//
|
||
// - EnrichReportListContent: pure function that walks an MCP `content`
|
||
// response, finds the report items list, and overlays five extra fields
|
||
// the agent layer relies on:
|
||
// success
|
||
// count
|
||
// agentDisplayContentIncluded (bool — inbox false, outbox true)
|
||
// agentDisplayColumns ([]string — wukong-aligned column set)
|
||
// agentDisplayMarkdown (string — markdown table)
|
||
// The function never mutates the input map in-place; it returns a fresh
|
||
// map so callers (including tests) can compare before/after safely.
|
||
//
|
||
// - AttachReportListReadableEnrichment: post-merge hook that finds the
|
||
// dynamic-built `report inbox list` / `report outbox list` leaves in the
|
||
// merged command tree and wraps their RunE to apply EnrichReportListContent
|
||
// to the JSON payload before it is written to stdout. Behaviour for other
|
||
// formats (--format raw / table / csv etc.) is preserved verbatim —
|
||
// enrichment only fires when the output is JSON.
|
||
//
|
||
// Why a post-merge hook (mirroring AttachReportLegacyInboxAlias):
|
||
//
|
||
// the envelope already publishes `report inbox` and `report outbox` as
|
||
// groups with a `list` leaf each; the open-source CLI cannot add a same-
|
||
// named helper leaf (MergeHardcodedLeaves would reject it as a shape
|
||
// mismatch with the envelope). Wrapping the existing leaf's RunE keeps the
|
||
// envelope as the single source of truth for flags/schema while letting us
|
||
// layer wukong-equivalent enrichment on top.
|
||
package helpers
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// ── public constants (mirrors wukong/products/report.go) ────────────────
|
||
|
||
// reportInboxColumns / reportOutboxColumns must stay byte-identical to the
|
||
// REPORT_*_COLUMNS constants in dws-wukong/auto-test/cli_to_mcp/testcases/
|
||
// report/test_90_report_param_regression.py — drift breaks the param-
|
||
// regression assertions. The only difference between the two is the "日志内容"
|
||
// column, which inbox elides (privacy: never echo inbox bodies back as
|
||
// agent-visible content) and outbox keeps (the user is the author).
|
||
var (
|
||
reportInboxColumns = []string{"日期", "标题", "发送人", "状态", "钉钉链接"}
|
||
reportOutboxColumns = []string{"日期", "标题", "发送人", "状态", "日志内容", "钉钉链接"}
|
||
)
|
||
|
||
// reportDingtalkLinkText is the visible label used inside the markdown link
|
||
// in the "钉钉链接" column. The wukong upstream also uses this literal; tests
|
||
// (REPORT_LINK_MARKER) match on the prefix "[在钉钉中查看日志](" so any change
|
||
// here must be mirrored in test_90_report_param_regression.py.
|
||
const reportDingtalkLinkText = "在钉钉中查看日志"
|
||
|
||
// ── EnrichReportListContent: pure transform ─────────────────────────────
|
||
|
||
// EnrichReportListContent overlays wukong-style agent-display fields on the
|
||
// MCP `content` map of a report list response. It is a pure function:
|
||
// - input map is never mutated;
|
||
// - on any structural mismatch (nil, no list, etc.) it returns the input
|
||
// untouched so the caller's behaviour is byte-stable.
|
||
//
|
||
// includeContent controls whether the "日志内容" column appears in
|
||
// agentDisplayColumns / agentDisplayMarkdown and whether each result row
|
||
// retains the 日志内容 key. Pass true for outbox (sender == self, content
|
||
// safe to echo), false for inbox.
|
||
func EnrichReportListContent(content map[string]any, includeContent bool) map[string]any {
|
||
if content == nil {
|
||
return content
|
||
}
|
||
items, found := findReportItemsForEnrichment(content)
|
||
if !found {
|
||
// Still attach display fields so the agent layer has a stable
|
||
// schema to read from even on empty / malformed list responses;
|
||
// rows will be empty and markdown table will only carry headers.
|
||
items = nil
|
||
}
|
||
rows := make([]map[string]string, 0, len(items))
|
||
for _, item := range items {
|
||
rows = append(rows, reportRowForItem(item, includeContent))
|
||
}
|
||
sortReportRowsByDate(rows)
|
||
|
||
columns := reportInboxColumns
|
||
if includeContent {
|
||
columns = reportOutboxColumns
|
||
}
|
||
markdown := reportRenderMarkdownTable(columns, rows)
|
||
|
||
// Build a shallow copy so callers never see in-place mutation. We
|
||
// preserve every key the upstream MCP server returned (notably
|
||
// hasMore / nextCursor / cursor at the result-wrapper level) by
|
||
// copying them into the new map verbatim.
|
||
out := make(map[string]any, len(content)+5)
|
||
for k, v := range content {
|
||
out[k] = v
|
||
}
|
||
// Replace `result` with a typed list of rows so the agent can iterate
|
||
// over a stable shape. The original `result` body is kept under the
|
||
// rebuilt key so paginator fields like nextCursor still ride at the
|
||
// expected level when MCP nested them there.
|
||
rebuiltResult := buildReportResultPayload(content, rows)
|
||
if rebuiltResult != nil {
|
||
out["result"] = rebuiltResult
|
||
} else {
|
||
// No nested wrapper to preserve — emit rows directly under result
|
||
// so callers can read result[] uniformly.
|
||
out["result"] = rowsAsAnySlice(rows)
|
||
}
|
||
|
||
out["count"] = len(rows)
|
||
if _, present := out["success"]; !present {
|
||
out["success"] = true
|
||
}
|
||
out["agentDisplayContentIncluded"] = includeContent
|
||
out["agentDisplayColumns"] = columns
|
||
out["agentDisplayMarkdown"] = markdown
|
||
return out
|
||
}
|
||
|
||
// findReportItemsForEnrichment locates the list of report items inside an
|
||
// MCP `content` map. The wukong upstream uses a permissive search across the
|
||
// well-known keys ("list", "items", "data", "reports", "records",
|
||
// "report_list", "reportList", "result"); we mirror that so envelope
|
||
// renames don't silently drop enrichment.
|
||
func findReportItemsForEnrichment(root map[string]any) ([]map[string]any, bool) {
|
||
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"} {
|
||
value, ok := root[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if items, found := reportItemsFromValueForEnrichment(value); found {
|
||
return items, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func reportItemsFromValueForEnrichment(v any) ([]map[string]any, bool) {
|
||
switch x := v.(type) {
|
||
case []any:
|
||
items := make([]map[string]any, 0, len(x))
|
||
for _, raw := range x {
|
||
item, ok := raw.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
return items, true
|
||
case map[string]any:
|
||
// Recurse into nested wrappers (e.g. {result: {list: [...]}}).
|
||
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
|
||
value, ok := x[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if items, found := reportItemsFromValueForEnrichment(value); found {
|
||
return items, true
|
||
}
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// buildReportResultPayload rebuilds the `result` field so that, when the
|
||
// upstream MCP wrapped items under `result.list`, the rebuilt payload keeps
|
||
// the wrapper (preserving sibling fields like nextCursor / hasMore) while
|
||
// swapping the list contents for the enriched rows. When no wrapper exists,
|
||
// returns nil and the caller emits rows directly.
|
||
func buildReportResultPayload(content map[string]any, rows []map[string]string) any {
|
||
wrapper, ok := content["result"].(map[string]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
out := make(map[string]any, len(wrapper))
|
||
for k, v := range wrapper {
|
||
out[k] = v
|
||
}
|
||
for _, key := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
|
||
if _, found := wrapper[key]; found {
|
||
out[key] = rowsAsAnySlice(rows)
|
||
return out
|
||
}
|
||
}
|
||
// Wrapper had no recognised list key — the list must live somewhere
|
||
// else; we still publish rows at out["list"] so agents have a uniform
|
||
// place to read.
|
||
out["list"] = rowsAsAnySlice(rows)
|
||
return out
|
||
}
|
||
|
||
func rowsAsAnySlice(rows []map[string]string) []any {
|
||
out := make([]any, 0, len(rows))
|
||
for _, row := range rows {
|
||
copyRow := make(map[string]any, len(row))
|
||
for k, v := range row {
|
||
copyRow[k] = v
|
||
}
|
||
out = append(out, copyRow)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// reportRowForItem extracts the four base columns (date / title / sender /
|
||
// status) plus the dingtalk markdown link from a single item map. The
|
||
// "日志内容" column is injected only when includeContent=true; inbox callers
|
||
// pass false to enforce the privacy contract (no inbox bodies leaked back).
|
||
func reportRowForItem(item map[string]any, includeContent bool) map[string]string {
|
||
row := map[string]string{
|
||
"日期": reportItemDate(item),
|
||
"标题": reportItemTitle(item),
|
||
"发送人": reportItemSender(item),
|
||
"状态": reportItemStatus(item),
|
||
"钉钉链接": reportItemMarkdownLink(item),
|
||
}
|
||
if includeContent {
|
||
row["日志内容"] = reportItemContent(item)
|
||
}
|
||
return row
|
||
}
|
||
|
||
func reportItemDate(item map[string]any) string {
|
||
for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime", "日期"} {
|
||
if ms := reportMillisFromValue(item[key]); ms > 0 {
|
||
return time.UnixMilli(ms).Local().Format("2006-01-02 15:04")
|
||
}
|
||
if s := reportStringFromValue(item[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportItemTitle(item map[string]any) string {
|
||
for _, key := range []string{"report_name", "reportName", "title", "summary", "report_template_name", "templateName", "标题"} {
|
||
if s := reportStringFromValue(item[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
if sender := reportItemSender(item); sender != "" {
|
||
return sender + "的日志"
|
||
}
|
||
return "日志"
|
||
}
|
||
|
||
func reportItemSender(item map[string]any) string {
|
||
for _, key := range []string{"creatorName", "senderName", "userName", "creator", "sender", "发送人"} {
|
||
if s := reportStringFromValue(item[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportItemStatus(item map[string]any) string {
|
||
for _, key := range []string{"readStatus", "isRead", "hasRead", "read", "状态"} {
|
||
v, ok := item[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch x := v.(type) {
|
||
case bool:
|
||
if x {
|
||
return "已读"
|
||
}
|
||
return "未读"
|
||
case string:
|
||
lower := strings.TrimSpace(strings.ToLower(x))
|
||
switch lower {
|
||
case "true", "read", "1", "已读":
|
||
return "已读"
|
||
case "false", "unread", "0", "未读":
|
||
return "未读"
|
||
default:
|
||
return strings.TrimSpace(x)
|
||
}
|
||
case float64:
|
||
if x == 1 {
|
||
return "已读"
|
||
}
|
||
if x == 0 {
|
||
return "未读"
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// reportItemMarkdownLink prefers an upstream-supplied markdown link
|
||
// ("dingtalkOpenMarkdownLink"); otherwise it composes one from any URL field
|
||
// it can find on the item. As a last resort it emits the plain label "查看
|
||
// 详情" so the column is never empty (tests assert the column header always
|
||
// exists and the marker shows when count>0; an empty cell would still
|
||
// satisfy markdown but would degrade the user-facing render).
|
||
func reportItemMarkdownLink(item map[string]any) string {
|
||
if s := reportStringFromValue(item["dingtalkOpenMarkdownLink"]); s != "" {
|
||
return s
|
||
}
|
||
if s := reportStringFromValue(item["钉钉链接"]); s != "" {
|
||
return s
|
||
}
|
||
for _, key := range []string{"url", "dingtalkOpenUrl", "openUrl", "webUrl"} {
|
||
if s := reportStringFromValue(item[key]); s != "" {
|
||
return fmt.Sprintf("[%s](%s)", reportDingtalkLinkText, s)
|
||
}
|
||
}
|
||
if link, ok := item["dingtalkOpenLink"].(map[string]any); ok {
|
||
if s := reportStringFromValue(link["url"]); s != "" {
|
||
return fmt.Sprintf("[%s](%s)", reportDingtalkLinkText, s)
|
||
}
|
||
}
|
||
return "查看详情"
|
||
}
|
||
|
||
func reportItemContent(item map[string]any) string {
|
||
for _, key := range []string{"report_content", "reportContent", "content", "summary", "日志内容"} {
|
||
v, ok := item[key]
|
||
if !ok {
|
||
continue
|
||
}
|
||
if s := reportFlattenContent(v); s != "" {
|
||
return reportCompactCell(s, 600)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportFlattenContent(v any) string {
|
||
switch x := v.(type) {
|
||
case string:
|
||
return strings.TrimSpace(x)
|
||
case []any:
|
||
parts := make([]string, 0, len(x))
|
||
for _, item := range x {
|
||
if s := reportFlattenContent(item); s != "" {
|
||
parts = append(parts, s)
|
||
}
|
||
}
|
||
return strings.Join(parts, ";")
|
||
case map[string]any:
|
||
label := reportFirstString(x, "key", "field_name", "fieldName", "name", "title")
|
||
value := reportFirstString(x, "value", "content", "text", "plainText", "markdown")
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if label != "" {
|
||
return label + ":" + value
|
||
}
|
||
return value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportFirstString(m map[string]any, keys ...string) string {
|
||
for _, k := range keys {
|
||
if s := reportStringFromValue(m[k]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func reportStringFromValue(v any) string {
|
||
switch x := v.(type) {
|
||
case string:
|
||
return strings.TrimSpace(x)
|
||
case float64:
|
||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||
case json.Number:
|
||
return x.String()
|
||
case bool:
|
||
if x {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// reportMillisFromValue normalises numeric / numeric-string timestamps to
|
||
// milliseconds. Second-precision values (< 1e11) are scaled up. Non-numeric
|
||
// inputs return 0 so the caller can fall back to a string formatter.
|
||
func reportMillisFromValue(v any) int64 {
|
||
switch x := v.(type) {
|
||
case float64:
|
||
return normaliseReportTimestamp(int64(x))
|
||
case int64:
|
||
return normaliseReportTimestamp(x)
|
||
case int:
|
||
return normaliseReportTimestamp(int64(x))
|
||
case json.Number:
|
||
n, err := x.Int64()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return normaliseReportTimestamp(n)
|
||
case string:
|
||
s := strings.TrimSpace(x)
|
||
if s == "" {
|
||
return 0
|
||
}
|
||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||
return normaliseReportTimestamp(n)
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func normaliseReportTimestamp(ts int64) int64 {
|
||
if ts <= 0 {
|
||
return 0
|
||
}
|
||
if ts < 100000000000 {
|
||
ts *= 1000
|
||
}
|
||
return ts
|
||
}
|
||
|
||
func reportCompactCell(s string, maxRunes int) string {
|
||
s = strings.ReplaceAll(s, "|", "|")
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
if maxRunes <= 0 {
|
||
return s
|
||
}
|
||
r := []rune(s)
|
||
if len(r) <= maxRunes {
|
||
return s
|
||
}
|
||
return string(r[:maxRunes]) + "..."
|
||
}
|
||
|
||
// sortReportRowsByDate sorts rows by date string descending so the most
|
||
// recent log appears first — matches the wukong upstream's user-visible
|
||
// ordering. String compare is safe because dates are formatted as
|
||
// "2006-01-02 15:04" (lexicographic order == chronological order).
|
||
func sortReportRowsByDate(rows []map[string]string) {
|
||
sort.SliceStable(rows, func(i, j int) bool {
|
||
return rows[i]["日期"] > rows[j]["日期"]
|
||
})
|
||
}
|
||
|
||
// reportRenderMarkdownTable composes a standard GFM markdown table from the
|
||
// given column header and row maps. Cell values are sanitised so that
|
||
// embedded pipes do not break the table; long whitespace runs are squashed.
|
||
func reportRenderMarkdownTable(columns []string, rows []map[string]string) string {
|
||
var b strings.Builder
|
||
b.WriteString("| ")
|
||
b.WriteString(strings.Join(columns, " | "))
|
||
b.WriteString(" |")
|
||
b.WriteString("\n")
|
||
dividers := make([]string, len(columns))
|
||
for i := range dividers {
|
||
dividers[i] = "---"
|
||
}
|
||
b.WriteString("| ")
|
||
b.WriteString(strings.Join(dividers, " | "))
|
||
b.WriteString(" |")
|
||
for _, row := range rows {
|
||
b.WriteString("\n")
|
||
cells := make([]string, 0, len(columns))
|
||
for _, col := range columns {
|
||
cells = append(cells, reportCellForMarkdown(row[col]))
|
||
}
|
||
b.WriteString("| ")
|
||
b.WriteString(strings.Join(cells, " | "))
|
||
b.WriteString(" |")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func reportCellForMarkdown(s string) string {
|
||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||
s = strings.ReplaceAll(s, "\r", "\n")
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
return strings.ReplaceAll(s, "|", "|")
|
||
}
|
||
|
||
// ── AttachReportListReadableEnrichment: post-merge hook ─────────────────
|
||
|
||
// AttachReportListReadableEnrichment wires EnrichReportListContent into the
|
||
// envelope-built `report inbox list` and `report outbox list` leaves.
|
||
//
|
||
// Mechanism (decorator over the leaf's existing RunE):
|
||
// 1. Replace leaf.RunE with a closure that delegates to the original RunE.
|
||
// 2. Before delegating, redirect cmd.SetOut() to an in-memory buffer when
|
||
// the resolved output format is JSON (the only format the agent display
|
||
// schema cares about).
|
||
// 3. After the original RunE returns, unmarshal the captured bytes, locate
|
||
// the MCP `content` payload, call EnrichReportListContent, and write
|
||
// the enriched JSON to the leaf's original stdout.
|
||
// 4. For non-JSON formats (raw / table / csv / ...) we never replace stdout,
|
||
// so behaviour stays byte-for-byte identical to the unwrapped envelope.
|
||
//
|
||
// `runner` is accepted for API symmetry with AttachReportLegacyInboxAlias —
|
||
// the wrapper itself does not invoke runner; the wrapped RunE already does.
|
||
// Keeping the parameter avoids a churn on app/legacy.go if we ever need to
|
||
// dispatch a sibling tool from inside the wrapper.
|
||
func AttachReportListReadableEnrichment(commands []*cobra.Command, runner executor.Runner) {
|
||
_ = runner // reserved — see comment above
|
||
for _, top := range commands {
|
||
if top == nil || top.Name() != "report" {
|
||
continue
|
||
}
|
||
wrapReportListLeaf(top, "inbox", false)
|
||
wrapReportListLeaf(top, "outbox", true)
|
||
}
|
||
}
|
||
|
||
func wrapReportListLeaf(reportCmd *cobra.Command, groupName string, includeContent bool) {
|
||
var group *cobra.Command
|
||
for _, child := range reportCmd.Commands() {
|
||
if child != nil && child.Name() == groupName {
|
||
group = child
|
||
break
|
||
}
|
||
}
|
||
if group == nil {
|
||
return
|
||
}
|
||
var leaf *cobra.Command
|
||
for _, child := range group.Commands() {
|
||
if child != nil && child.Name() == "list" {
|
||
leaf = child
|
||
break
|
||
}
|
||
}
|
||
if leaf == nil {
|
||
return
|
||
}
|
||
originalRunE := leaf.RunE
|
||
if originalRunE == nil {
|
||
return
|
||
}
|
||
leaf.RunE = func(cmd *cobra.Command, args []string) error {
|
||
// Resolve the format the user asked for. Enrichment only applies
|
||
// when the eventual writer would emit JSON — other formats stay
|
||
// untouched so `--format table`, `--format raw`, etc. remain
|
||
// byte-stable against the envelope.
|
||
fmtChoice := output.ResolveFormat(cmd, output.FormatJSON)
|
||
if fmtChoice != output.FormatJSON {
|
||
return originalRunE(cmd, args)
|
||
}
|
||
|
||
originalStdout := cmd.OutOrStdout()
|
||
buf := &bytes.Buffer{}
|
||
cmd.SetOut(buf)
|
||
runErr := originalRunE(cmd, args)
|
||
// Restore the leaf's original writer regardless of whether the
|
||
// run succeeded — if it failed we still want any partial bytes
|
||
// (rare, defensive) flushed back to the caller.
|
||
cmd.SetOut(originalStdout)
|
||
if runErr != nil {
|
||
// Pass through any captured bytes the inner RunE produced
|
||
// before failing so the caller's logs / stderr show the
|
||
// same diagnostic context as the un-wrapped envelope.
|
||
if buf.Len() > 0 {
|
||
_, _ = originalStdout.Write(buf.Bytes())
|
||
}
|
||
return runErr
|
||
}
|
||
enriched, ok := enrichCapturedReportListJSON(buf.Bytes(), includeContent)
|
||
if !ok {
|
||
// Captured payload did not match the expected shape (e.g.
|
||
// dry-run output, non-content envelope, malformed JSON) —
|
||
// echo the original bytes verbatim so the caller's behaviour
|
||
// is unchanged for those code paths.
|
||
_, _ = originalStdout.Write(buf.Bytes())
|
||
return nil
|
||
}
|
||
return output.WriteJSON(originalStdout, enriched)
|
||
}
|
||
}
|
||
|
||
// enrichCapturedReportListJSON parses bytes the wrapped RunE wrote to its
|
||
// captured stdout buffer and, if the payload is the unwrapped MCP `content`
|
||
// map (the shape produced by output.WriteCommandPayload for successful
|
||
// compat_invocation Results), applies EnrichReportListContent and returns
|
||
// the enriched payload. Returns ok=false for any shape the function does
|
||
// not recognise so the caller falls back to passthrough.
|
||
func enrichCapturedReportListJSON(raw []byte, includeContent bool) (any, bool) {
|
||
trimmed := bytes.TrimSpace(raw)
|
||
if len(trimmed) == 0 {
|
||
return nil, false
|
||
}
|
||
var decoded any
|
||
if err := json.Unmarshal(trimmed, &decoded); err != nil {
|
||
return nil, false
|
||
}
|
||
root, ok := decoded.(map[string]any)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
// Case A: payload is the MCP `content` map directly (the post-unwrap
|
||
// shape that output.WriteCommandPayload emits for successful
|
||
// compat_invocation results in --format json). Enrich in place.
|
||
if _, hasResult := root["result"]; hasResult || hasReportListKey(root) {
|
||
return EnrichReportListContent(root, includeContent), true
|
||
}
|
||
// Case B: dry-run / error envelopes carry {invocation, response}. We
|
||
// leave those untouched — the agent-display schema only applies to
|
||
// successful list responses; dry-run is for inspection.
|
||
return nil, false
|
||
}
|
||
|
||
// hasReportListKey reports whether the map looks like an MCP list response
|
||
// even if `result` is absent (some servers return `list` at the top level).
|
||
func hasReportListKey(m map[string]any) bool {
|
||
for _, k := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
|
||
if _, ok := m[k]; ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|