Skip to content

Commit 3e88a77

Browse files
authored
Adopt Lipgloss tree rendering for MCP hierarchy and status dependencies (#33276)
1 parent 75ffa26 commit 3e88a77

5 files changed

Lines changed: 435 additions & 1 deletion

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# ADR-33276: Adopt Lipgloss Tree Rendering for CLI Hierarchies
2+
3+
**Date**: 2026-05-19
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
## Part 1 — Narrative (Human-Friendly)
10+
11+
### Context
12+
13+
CLI output for `mcp inspect` and `status` previously mixed ad-hoc hierarchy formatting (manual indentation, custom prefixes) with table-only views. Nested relationships — a workflow's engine and MCP servers, or a workflow's dependency graph — were hard to read because they were either flattened into tables or rendered with bespoke string-building logic in each command. The codebase already standardized terminal styling under `pkg/styles` (including `TreeEnumerator` and `TreeNode`) and has adopted `charm.land/lipgloss/v2` as the terminal rendering library, so a shared tree primitive was available but not yet used for hierarchical command output.
14+
15+
### Decision
16+
17+
We will adopt `charm.land/lipgloss/v2/tree` as the canonical primitive for rendering hierarchical CLI output in `pkg/cli`, starting with `mcp inspect` (workflow → engine + MCP servers) and the verbose mode of `status` (workflow → dependencies). All such trees will use `tree.RoundedEnumerator` together with the existing `styles.TreeEnumerator` and `styles.TreeNode` styles, so hierarchical output is visually consistent across commands.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Keep manual hierarchy formatting per command
22+
23+
Each command would continue building its own indented strings (or rely on table-only views) to convey hierarchy. Rejected because it duplicates formatting logic across commands, drifts visually as each command evolves independently, and makes nested relationships (a tree with three or more levels) hard to express without reinventing tree primitives.
24+
25+
#### Alternative 2: Write a small in-repo tree printer
26+
27+
We could add a focused helper under `pkg/console` or `pkg/styles` that prints trees with the project's style tokens, avoiding a new dependency surface. Rejected because `lipgloss/v2/tree` is already a transitive dependency (the project uses `lipgloss/v2` extensively for terminal styling), and reimplementing tree layout, enumerator handling, and styling would duplicate well-tested upstream code with no clear benefit.
28+
29+
### Consequences
30+
31+
#### Positive
32+
- Hierarchical CLI output is rendered consistently across commands using one library and one set of styles.
33+
- Adding a new hierarchical view in another command becomes a small, declarative composition of `tree.Root(...).Child(...)` calls instead of bespoke string formatting.
34+
- The tree rendering is testable as a pure function (see the new `*_tree_test.go` files), which is harder to do for ad-hoc string concatenation.
35+
36+
#### Negative
37+
- Adds a concrete coupling between `pkg/cli` command code and the `charm.land/lipgloss/v2/tree` API; replacing the renderer in the future would require touching every site that builds a tree.
38+
- Tree output is written to `stderr` alongside existing info messages, which can be noisier in `mcp inspect` and in verbose `status` runs that pipe through tooling expecting a clean stderr.
39+
40+
#### Neutral
41+
- The shape of `WorkflowStatus` grows a new `Dependencies []string` field (JSON-tagged `omitempty`, console-tagged `-`), so JSON consumers may now see a `dependencies` key for workflows that import or include other files.
42+
- Dependency extraction logic now lives in `pkg/cli/status_command.go` (frontmatter `imports` in string/list/object forms plus inline `@include`/`@import` directives, with `#section` stripping, dedup, and sort); future changes to import/include syntax must update this extractor.
43+
44+
---
45+
46+
## Part 2 — Normative Specification (RFC 2119)
47+
48+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
49+
50+
### Tree Rendering Library
51+
52+
1. Hierarchical CLI output in `pkg/cli` **MUST** be rendered using `charm.land/lipgloss/v2/tree`.
53+
2. Implementations **MUST NOT** introduce a second tree-rendering library or hand-rolled ASCII tree printer in `pkg/cli` while this ADR is in effect.
54+
3. Tree composition **SHOULD** use `tree.Root(...).Child(...)` chains rather than mutating tree nodes after construction.
55+
56+
### Styling Consistency
57+
58+
1. Tree renderers in `pkg/cli` **MUST** apply `tree.RoundedEnumerator` as the enumerator.
59+
2. Tree renderers **MUST** apply `styles.TreeEnumerator` as the `EnumeratorStyle` and `styles.TreeNode` as the `ItemStyle`.
60+
3. Renderers **SHOULD NOT** override these styles inline with per-call style values; new style variants **SHOULD** be added to `pkg/styles` first.
61+
62+
### Output Channel and Triggering
63+
64+
1. Tree output for `mcp inspect` and `status` **MUST** be written to `stderr`, alongside existing informational messages, and **MUST NOT** be written to `stdout` when `stdout` is reserved for machine-readable output (e.g., JSON).
65+
2. The dependency tree in `status` **MUST** only be rendered when verbose text output is requested; it **MUST NOT** be rendered in JSON mode or in non-verbose runs.
66+
3. Renderers **MUST** return an empty string when there is nothing to display (e.g., zero MCP servers, zero dependencies) so the caller can skip the surrounding section header.
67+
68+
### Dependency Extraction (Status Command)
69+
70+
1. The status command **MUST** extract workflow dependencies from both frontmatter `imports` (accepting string, list, and object-with-`uses` forms) and inline `@include` / `@import` directives in the workflow body.
71+
2. Extracted dependencies **MUST** be normalized by stripping any `#section` suffix, deduplicated, and sorted before rendering.
72+
3. The `Dependencies` field on `WorkflowStatus` **MUST** be JSON-tagged `omitempty` so workflows without dependencies do not emit an empty list in JSON output.
73+
74+
### Conformance
75+
76+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
77+
78+
---
79+
80+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26092090247) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

pkg/cli/mcp_inspect.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import (
77
"os"
88
"os/exec"
99
"path/filepath"
10+
"slices"
11+
"strings"
1012
"time"
1113

14+
"charm.land/lipgloss/v2/tree"
1215
"github.com/github/gh-aw/pkg/console"
1316
"github.com/github/gh-aw/pkg/logger"
1417
"github.com/github/gh-aw/pkg/parser"
18+
"github.com/github/gh-aw/pkg/styles"
1519
"github.com/github/gh-aw/pkg/workflow"
1620
"github.com/spf13/cobra"
1721
)
@@ -155,6 +159,13 @@ func InspectWorkflowMCP(workflowFile string, serverFilter string, toolFilter str
155159
} else {
156160
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d MCP server(s) to inspect", len(mcpConfigs))))
157161
}
162+
if toolFilter == "" {
163+
if hierarchy := renderMCPInspectionTree(workflowPath, workflowData, mcpConfigs); hierarchy != "" {
164+
fmt.Fprintln(os.Stderr)
165+
fmt.Fprintln(os.Stderr, console.FormatSectionHeader("MCP Server Hierarchy"))
166+
fmt.Fprintln(os.Stderr, hierarchy)
167+
}
168+
}
158169
fmt.Fprintln(os.Stderr)
159170

160171
for i, config := range mcpConfigs {
@@ -172,6 +183,54 @@ func InspectWorkflowMCP(workflowFile string, serverFilter string, toolFilter str
172183
return nil
173184
}
174185

186+
func renderMCPInspectionTree(workflowPath string, workflowData *workflow.WorkflowData, mcpConfigs []parser.RegistryMCPServerConfig) string {
187+
if len(mcpConfigs) == 0 {
188+
return ""
189+
}
190+
191+
workflowLabel := workflowPath
192+
if workflowData != nil && workflowData.WorkflowID != "" {
193+
workflowLabel = workflowData.WorkflowID
194+
} else if workflowPath != "" {
195+
workflowLabel = filepath.Base(workflowPath)
196+
}
197+
198+
serversTree := tree.Root("MCP Servers")
199+
sortedConfigs := append([]parser.RegistryMCPServerConfig(nil), mcpConfigs...)
200+
slices.SortFunc(sortedConfigs, func(a, b parser.RegistryMCPServerConfig) int {
201+
if nameCmp := strings.Compare(a.Name, b.Name); nameCmp != 0 {
202+
return nameCmp
203+
}
204+
return strings.Compare(a.Type, b.Type)
205+
})
206+
207+
for _, config := range sortedConfigs {
208+
serversTree.Child(fmt.Sprintf("%s (%s)", config.Name, config.Type))
209+
}
210+
211+
return tree.
212+
Root("Workflow: " + workflowLabel).
213+
Child("Engine: " + resolveWorkflowEngineID(workflowData)).
214+
Child(serversTree).
215+
Enumerator(tree.RoundedEnumerator).
216+
EnumeratorStyle(styles.TreeEnumerator).
217+
ItemStyle(styles.TreeNode).
218+
String()
219+
}
220+
221+
func resolveWorkflowEngineID(workflowData *workflow.WorkflowData) string {
222+
if workflowData == nil {
223+
return "unknown"
224+
}
225+
if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" {
226+
return workflowData.EngineConfig.ID
227+
}
228+
if workflowData.AI != "" {
229+
return workflowData.AI
230+
}
231+
return "unknown"
232+
}
233+
175234
// NewMCPInspectSubcommand creates the mcp inspect subcommand
176235
// This is the former mcp inspect command now nested under mcp
177236
func NewMCPInspectSubcommand() *cobra.Command {

pkg/cli/mcp_inspect_tree_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/github/gh-aw/pkg/parser"
8+
"github.com/github/gh-aw/pkg/types"
9+
"github.com/github/gh-aw/pkg/workflow"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestRenderMCPInspectionTree(t *testing.T) {
14+
workflowData := &workflow.WorkflowData{
15+
WorkflowID: "audit-workflows",
16+
EngineConfig: &workflow.EngineConfig{
17+
ID: "copilot",
18+
},
19+
}
20+
mcpConfigs := []parser.RegistryMCPServerConfig{
21+
{BaseMCPServerConfig: types.BaseMCPServerConfig{Type: "stdio"}, Name: "github"},
22+
{BaseMCPServerConfig: types.BaseMCPServerConfig{Type: "http"}, Name: "playwright"},
23+
}
24+
25+
result := renderMCPInspectionTree("/tmp/audit-workflows.md", workflowData, mcpConfigs)
26+
27+
expected := []string{
28+
"Workflow: audit-workflows",
29+
"Engine: copilot",
30+
"MCP Servers",
31+
"github (stdio)",
32+
"playwright (http)",
33+
}
34+
for _, part := range expected {
35+
assert.Contains(t, result, part, "tree output should include expected hierarchy node")
36+
}
37+
}
38+
39+
func TestRenderMCPInspectionTree_SortsServersDeterministically(t *testing.T) {
40+
workflowData := &workflow.WorkflowData{
41+
WorkflowID: "audit-workflows",
42+
EngineConfig: &workflow.EngineConfig{
43+
ID: "copilot",
44+
},
45+
}
46+
mcpConfigs := []parser.RegistryMCPServerConfig{
47+
{BaseMCPServerConfig: types.BaseMCPServerConfig{Type: "http"}, Name: "playwright"},
48+
{BaseMCPServerConfig: types.BaseMCPServerConfig{Type: "stdio"}, Name: "github"},
49+
{BaseMCPServerConfig: types.BaseMCPServerConfig{Type: "docker"}, Name: "github"},
50+
}
51+
52+
result := renderMCPInspectionTree("/tmp/audit-workflows.md", workflowData, mcpConfigs)
53+
githubDockerIdx := strings.Index(result, "github (docker)")
54+
githubStdioIdx := strings.Index(result, "github (stdio)")
55+
playwrightIdx := strings.Index(result, "playwright (http)")
56+
57+
assert.NotEqual(t, -1, githubDockerIdx)
58+
assert.NotEqual(t, -1, githubStdioIdx)
59+
assert.NotEqual(t, -1, playwrightIdx)
60+
assert.Less(t, githubDockerIdx, githubStdioIdx)
61+
assert.Less(t, githubStdioIdx, playwrightIdx)
62+
}
63+
64+
func TestResolveWorkflowEngineID(t *testing.T) {
65+
tests := []struct {
66+
name string
67+
workflowData *workflow.WorkflowData
68+
want string
69+
}{
70+
{
71+
name: "nil workflow data",
72+
workflowData: nil,
73+
want: "unknown",
74+
},
75+
{
76+
name: "engine config id",
77+
workflowData: &workflow.WorkflowData{
78+
EngineConfig: &workflow.EngineConfig{ID: "copilot"},
79+
AI: "claude",
80+
},
81+
want: "copilot",
82+
},
83+
{
84+
name: "fallback to ai",
85+
workflowData: &workflow.WorkflowData{
86+
AI: "claude",
87+
},
88+
want: "claude",
89+
},
90+
{
91+
name: "unknown",
92+
workflowData: &workflow.WorkflowData{},
93+
want: "unknown",
94+
},
95+
}
96+
97+
for _, tt := range tests {
98+
t.Run(tt.name, func(t *testing.T) {
99+
assert.Equal(t, tt.want, resolveWorkflowEngineID(tt.workflowData))
100+
})
101+
}
102+
}

0 commit comments

Comments
 (0)