From 09d053233e4ac3cc6a2bc300f4c8abca88425eaf Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 12:37:49 -0800 Subject: [PATCH] feat: Add bd formula list/show commands (gt-8tmz.14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements formula management commands: - bd formula list: Lists formulas from all search paths - bd formula show : Shows formula details, steps, composition rules Search paths in priority order: 1. .beads/formulas/ (project) 2. ~/.beads/formulas/ (user) 3. ~/gt/.beads/formulas/ (town) Features: - Type filtering (--type workflow|expansion|aspect) - JSON output support (--json) - Shows variables, steps, advice, bond points, and aspects - Formulas in earlier paths shadow later ones πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 2 +- cmd/bd/formula.go | 480 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 cmd/bd/formula.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6b06fa06..67dd68e8 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -90,7 +90,7 @@ {"id":"bd-687v","title":"Consider caching external dep resolution results","description":"Each call to GetReadyWork re-checks all external dependencies by:\n1. Querying for external deps in the local database\n2. Opening each external project's database\n3. Querying for closed issues with provides: labels\n\nFor workloads with many external deps or slow external databases, this adds latency on every bd ready call.\n\nPotential optimizations:\n- In-memory TTL cache for external dep status (e.g., 60 second TTL)\n- Store resolved status in a local cache table with timestamp\n- Batch resolution of common project/capability pairs\n\nThis is not urgent - current implementation is correct and performant for typical workloads. Only becomes an issue with many external deps across many projects.","status":"deferred","priority":3,"issue_type":"task","created_at":"2025-12-21T23:45:16.360877-08:00","updated_at":"2025-12-23T12:27:02.223409-08:00","dependencies":[{"issue_id":"bd-687v","depends_on_id":"bd-zmmy","type":"discovered-from","created_at":"2025-12-21T23:45:16.361493-08:00","created_by":"daemon"}]} {"id":"bd-68bf","title":"Code review: bd mol bond implementation","description":"Review the mol bond command implementation before shipping.\n\nFocus areas:\n1. runMolBond() - polymorphic dispatch logic correctness\n2. bondProtoProto() - compound proto creation, dependency wiring\n3. bondProtoMol() / bondMolProto() - spawn and attach logic\n4. bondMolMol() - joining molecules, lineage tracking\n5. BondRef usage - is lineage tracked correctly?\n6. Error handling - are all failure modes covered?\n7. Edge cases - what could go wrong?\n\nFile: cmd/bd/mol.go (lines 485-859)\nCommit: 386b513e","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-21T10:13:09.425229-08:00","updated_at":"2025-12-21T11:18:14.206869-08:00","closed_at":"2025-12-21T11:18:14.206869-08:00","close_reason":"Reviewed and fixed label persistence bug","dependencies":[{"issue_id":"bd-68bf","depends_on_id":"bd-o91r","type":"discovered-from","created_at":"2025-12-21T10:13:09.426471-08:00","created_by":"daemon"}]} {"id":"bd-68e4","title":"doctor --fix should export when DB has more issues than JSONL","description":"When 'bd doctor' detects a count mismatch (DB has more issues than JSONL), it currently recommends 'bd sync --import-only', which imports JSONL into DB. But JSONL is the source of truth, not the DB.\n\n**Current behavior:**\n- Doctor detects: DB has 355 issues, JSONL has 292\n- Recommends: 'bd sync --import-only' \n- User runs it: Returns '0 created, 0 updated' (no-op, because JSONL hasn't changed)\n- User is stuck\n\n**Root cause:**\nThe doctor fix is one-directional (JSONLβ†’DB) when it should be bidirectional. If DB has MORE issues, they haven't been exported yet - the fix should be 'bd export' (DBβ†’JSONL), not import.\n\n**Desired fix:**\nIn fix.DBJSONLSync(), detect which has more data:\n- If DB \u003e JSONL: Run 'bd export' to sync JSONL (since DB is the working copy)\n- If JSONL \u003e DB: Run 'bd sync --import-only' to import (JSONL is source of truth)\n- If equal but timestamps differ: Detect based on file mtime\n\nThis makes 'bd doctor --fix' actually fix the problem instead of being a no-op.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-12-21T11:17:20.994319182-07:00","updated_at":"2025-12-21T11:23:24.38523731-07:00","closed_at":"2025-12-21T11:23:24.38523731-07:00"} -{"id":"bd-6a5z","title":"Add stale molecule check to bd doctor","description":"Extend bd doctor to detect stale molecules.\n\n**New check:**\n- Name: 'Stale Molecules'\n- Category: Workflow\n- Severity: Warning (don't fail overall check)\n\n**Detection:**\nReuse logic from bd mol stale command:\n- Find mols where Completed \u003e= Total but root is open\n- Filter to orphaned (not assigned, not pinned)\n- Extra weight if blocking other work\n\n**Output:**\n```\n⚠ Stale Molecules\n Found 2 complete-but-unclosed molecules:\n - bd-xyz: Version bump v0.36.0 (blocking 1 issue)\n - bd-uvw: Old patrol (not blocking)\n Fix: bd close \u003cid\u003e or bd mol squash \u003cid\u003e\n```\n\n**--fix behavior:**\n- Auto-close stale mols (with reason 'Auto-closed by bd doctor')\n- Or prompt interactively with -i flag\n\nDepends on: bd mol stale command","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-24T18:23:24.549941-08:00","updated_at":"2025-12-24T18:23:24.549941-08:00","dependencies":[{"issue_id":"bd-6a5z","depends_on_id":"bd-anv2","type":"blocks","created_at":"2025-12-24T18:23:48.682552-08:00","created_by":"daemon"}]} +{"id":"bd-6a5z","title":"Add stale molecule check to bd doctor","description":"Extend bd doctor to detect stale molecules.\n\n**New check:**\n- Name: 'Stale Molecules'\n- Category: Workflow\n- Severity: Warning (don't fail overall check)\n\n**Detection:**\nReuse logic from bd mol stale command:\n- Find mols where Completed \u003e= Total but root is open\n- Filter to orphaned (not assigned, not pinned)\n- Extra weight if blocking other work\n\n**Output:**\n```\n⚠ Stale Molecules\n Found 2 complete-but-unclosed molecules:\n - bd-xyz: Version bump v0.36.0 (blocking 1 issue)\n - bd-uvw: Old patrol (not blocking)\n Fix: bd close \u003cid\u003e or bd mol squash \u003cid\u003e\n```\n\n**--fix behavior:**\n- Auto-close stale mols (with reason 'Auto-closed by bd doctor')\n- Or prompt interactively with -i flag\n\nDepends on: bd mol stale command","status":"in_progress","priority":3,"issue_type":"task","created_at":"2025-12-24T18:23:24.549941-08:00","updated_at":"2025-12-25T12:37:15.966682-08:00","dependencies":[{"issue_id":"bd-6a5z","depends_on_id":"bd-anv2","type":"blocks","created_at":"2025-12-24T18:23:48.682552-08:00","created_by":"daemon"}]} {"id":"bd-6fe4622f","title":"Remove unreachable utility functions","description":"Several small utility functions are unreachable:\n\nFiles to clean:\n1. `internal/storage/sqlite/hash.go` - `computeIssueContentHash` (line 17)\n - Check if entire file can be deleted if only contains this function\n\n2. `internal/config/config.go` - `FileUsed` (line 151)\n - Delete unused config helper\n\n3. `cmd/bd/git_sync_test.go` - `verifyIssueOpen` (line 300)\n - Delete dead test helper\n\n4. `internal/compact/haiku.go` - `HaikuClient.SummarizeTier2` (line 81)\n - Tier 2 summarization not implemented\n - Options: implement feature OR delete method\n\nImpact: Removes 50-100 LOC depending on decisions","status":"tombstone","priority":2,"issue_type":"task","created_at":"2025-10-28T16:20:02.434573-07:00","updated_at":"2025-12-25T01:21:01.952723-08:00","close_reason":"Closed","deleted_at":"2025-12-25T01:21:01.952723-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"task"} {"id":"bd-6gd","title":"Remove legacy MCP Agent Mail integration","description":"## Summary\n\nRemove the legacy MCP Agent Mail system that requires an external HTTP server. Keep the native `bd mail` system which stores messages as git-synced issues.\n\n## Background\n\nTwo mail systems exist in the codebase:\n1. **Legacy Agent Mail** (`bd message`) - External server dependency, complex setup\n2. **Native bd mail** (`bd mail`) - Built-in, git-synced, no dependencies\n\nThe legacy system causes confusion and is no longer needed. Gas Town's Town Mail will use the native `bd mail` system.\n\n## Files to Delete\n\n### CLI Command\n- [ ] `cmd/bd/message.go` - The `bd message` command implementation\n\n### MCP Integration\n- [ ] `integrations/beads-mcp/src/beads_mcp/mail.py` - HTTP wrapper for Agent Mail server\n- [ ] `integrations/beads-mcp/src/beads_mcp/mail_tools.py` - MCP tool definitions\n- [ ] `integrations/beads-mcp/tests/test_mail.py` - Tests for legacy mail\n\n### Documentation\n- [ ] `docs/AGENT_MAIL.md`\n- [ ] `docs/AGENT_MAIL_QUICKSTART.md`\n- [ ] `docs/AGENT_MAIL_DEPLOYMENT.md`\n- [ ] `docs/AGENT_MAIL_MULTI_WORKSPACE_SETUP.md`\n- [ ] `docs/adr/002-agent-mail-integration.md`\n\n## Code to Update\n\n- [ ] Remove `message` command registration from `cmd/bd/main.go`\n- [ ] Remove mail tool imports/registration from MCP server `__init__.py` or `server.py`\n- [ ] Check for any other references to Agent Mail in the codebase\n\n## Verification\n\n- [ ] `bd message` command no longer exists\n- [ ] `bd mail` command still works\n- [ ] MCP server starts without errors\n- [ ] Tests pass\n","status":"tombstone","priority":1,"issue_type":"task","created_at":"2025-12-17T23:04:04.099935-08:00","updated_at":"2025-12-25T01:21:01.952723-08:00","close_reason":"Removed legacy MCP Agent Mail integration. Kept native bd mail system.","deleted_at":"2025-12-25T01:21:01.952723-08:00","deleted_by":"batch delete","delete_reason":"batch delete","original_type":"task"} {"id":"bd-6ns7","title":"test hook pin","status":"tombstone","priority":2,"issue_type":"task","assignee":"stevey","created_at":"2025-12-23T04:39:16.619755-08:00","updated_at":"2025-12-23T04:51:29.436788-08:00","deleted_at":"2025-12-23T04:51:29.436788-08:00","deleted_by":"daemon","delete_reason":"delete","original_type":"task"} diff --git a/cmd/bd/formula.go b/cmd/bd/formula.go new file mode 100644 index 00000000..35bad954 --- /dev/null +++ b/cmd/bd/formula.go @@ -0,0 +1,480 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/formula" + "github.com/steveyegge/beads/internal/ui" +) + +// formulaCmd is the parent command for formula operations. +var formulaCmd = &cobra.Command{ + Use: "formula", + Short: "Manage workflow formulas", + Long: `Manage workflow formulas - the source layer for molecule templates. + +Formulas are YAML/JSON files that define workflows with composition rules. +They are "cooked" into proto beads which can then be poured or wisped. + +The Rig β†’ Cook β†’ Run lifecycle: + - Rig: Compose formulas (extends, compose) + - Cook: Transform to proto (bd cook expands macros, applies aspects) + - Run: Agents execute poured mols or wisps + +Search paths (in order): + 1. .beads/formulas/ (project) + 2. ~/.beads/formulas/ (user) + 3. ~/gt/.beads/formulas/ (town) + +Commands: + list List available formulas from all search paths + show Show formula details, steps, and composition rules`, +} + +// formulaListCmd lists all available formulas. +var formulaListCmd = &cobra.Command{ + Use: "list", + Short: "List available formulas", + Long: `List all formulas from search paths. + +Search paths (in order of priority): + 1. .beads/formulas/ (project - highest priority) + 2. ~/.beads/formulas/ (user) + 3. ~/gt/.beads/formulas/ (town) + +Formulas in earlier paths shadow those with the same name in later paths. + +Examples: + bd formula list + bd formula list --json + bd formula list --type workflow + bd formula list --type aspect`, + Run: runFormulaList, +} + +// formulaShowCmd shows details of a specific formula. +var formulaShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show formula details", + Long: `Show detailed information about a formula. + +Displays: + - Formula metadata (name, type, description) + - Variables with defaults and constraints + - Steps with dependencies + - Composition rules (extends, aspects, expansions) + - Bond points for external composition + +Examples: + bd formula show shiny + bd formula show rule-of-five + bd formula show security-audit --json`, + Args: cobra.ExactArgs(1), + Run: runFormulaShow, +} + +// FormulaListEntry represents a formula in the list output. +type FormulaListEntry struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Source string `json:"source"` + Steps int `json:"steps"` + Vars int `json:"vars"` +} + +func runFormulaList(cmd *cobra.Command, args []string) { + typeFilter, _ := cmd.Flags().GetString("type") + + // Get all search paths + searchPaths := getFormulaSearchPaths() + + // Track seen formulas (first occurrence wins) + seen := make(map[string]bool) + var entries []FormulaListEntry + + // Scan each search path + for _, dir := range searchPaths { + formulas, err := scanFormulaDir(dir) + if err != nil { + continue // Skip inaccessible directories + } + + for _, f := range formulas { + if seen[f.Formula] { + continue // Skip shadowed formulas + } + seen[f.Formula] = true + + // Apply type filter + if typeFilter != "" && string(f.Type) != typeFilter { + continue + } + + entries = append(entries, FormulaListEntry{ + Name: f.Formula, + Type: string(f.Type), + Description: truncateDescription(f.Description, 60), + Source: f.Source, + Steps: countSteps(f.Steps), + Vars: len(f.Vars), + }) + } + } + + // Sort by name + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name < entries[j].Name + }) + + if jsonOutput { + outputJSON(entries) + return + } + + if len(entries) == 0 { + fmt.Println("No formulas found.") + fmt.Println("\nSearch paths:") + for _, p := range searchPaths { + fmt.Printf(" %s\n", p) + } + return + } + + fmt.Printf("πŸ“œ Formulas (%d found)\n\n", len(entries)) + + // Group by type + byType := make(map[string][]FormulaListEntry) + for _, e := range entries { + byType[e.Type] = append(byType[e.Type], e) + } + + // Print in type order: workflow, expansion, aspect + typeOrder := []string{"workflow", "expansion", "aspect"} + for _, t := range typeOrder { + typeEntries := byType[t] + if len(typeEntries) == 0 { + continue + } + + typeIcon := getTypeIcon(t) + fmt.Printf("%s %s:\n", typeIcon, strings.Title(t)) + + for _, e := range typeEntries { + varInfo := "" + if e.Vars > 0 { + varInfo = fmt.Sprintf(" (%d vars)", e.Vars) + } + fmt.Printf(" %-25s %s%s\n", e.Name, e.Description, varInfo) + } + fmt.Println() + } +} + +func runFormulaShow(cmd *cobra.Command, args []string) { + name := args[0] + + // Create parser with default search paths + parser := formula.NewParser() + + // Try to load the formula + f, err := parser.LoadByName(name) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintf(os.Stderr, "\nSearch paths:\n") + for _, p := range getFormulaSearchPaths() { + fmt.Fprintf(os.Stderr, " %s\n", p) + } + os.Exit(1) + } + + if jsonOutput { + outputJSON(f) + return + } + + // Print header + typeIcon := getTypeIcon(string(f.Type)) + fmt.Printf("\n%s %s\n", typeIcon, f.Formula) + fmt.Printf(" Type: %s\n", f.Type) + if f.Description != "" { + fmt.Printf(" Description: %s\n", f.Description) + } + fmt.Printf(" Source: %s\n", f.Source) + + // Print extends + if len(f.Extends) > 0 { + fmt.Printf("\n%s Extends:\n", ui.RenderAccent("πŸ“Ž")) + for _, ext := range f.Extends { + fmt.Printf(" - %s\n", ext) + } + } + + // Print variables + if len(f.Vars) > 0 { + fmt.Printf("\n%s Variables:\n", ui.RenderWarn("πŸ“")) + // Sort for consistent output + varNames := make([]string, 0, len(f.Vars)) + for name := range f.Vars { + varNames = append(varNames, name) + } + sort.Strings(varNames) + + for _, name := range varNames { + v := f.Vars[name] + attrs := []string{} + if v.Required { + attrs = append(attrs, ui.RenderFail("required")) + } + if v.Default != "" { + attrs = append(attrs, fmt.Sprintf("default=%q", v.Default)) + } + if len(v.Enum) > 0 { + attrs = append(attrs, fmt.Sprintf("enum=[%s]", strings.Join(v.Enum, ","))) + } + if v.Pattern != "" { + attrs = append(attrs, fmt.Sprintf("pattern=%q", v.Pattern)) + } + attrStr := "" + if len(attrs) > 0 { + attrStr = fmt.Sprintf(" [%s]", strings.Join(attrs, ", ")) + } + desc := "" + if v.Description != "" { + desc = fmt.Sprintf(": %s", v.Description) + } + fmt.Printf(" {{%s}}%s%s\n", name, desc, attrStr) + } + } + + // Print steps + if len(f.Steps) > 0 { + fmt.Printf("\n%s Steps (%d):\n", ui.RenderPass("🌲"), countSteps(f.Steps)) + printFormulaStepsTree(f.Steps, " ") + } + + // Print template (for expansion formulas) + if len(f.Template) > 0 { + fmt.Printf("\n%s Template (%d steps):\n", ui.RenderAccent("πŸ“"), len(f.Template)) + printFormulaStepsTree(f.Template, " ") + } + + // Print advice rules + if len(f.Advice) > 0 { + fmt.Printf("\n%s Advice:\n", ui.RenderWarn("πŸ’‘")) + for _, a := range f.Advice { + parts := []string{} + if a.Before != nil { + parts = append(parts, fmt.Sprintf("before: %s", a.Before.ID)) + } + if a.After != nil { + parts = append(parts, fmt.Sprintf("after: %s", a.After.ID)) + } + if a.Around != nil { + parts = append(parts, "around") + } + fmt.Printf(" %s β†’ %s\n", a.Target, strings.Join(parts, ", ")) + } + } + + // Print compose rules + if f.Compose != nil { + hasCompose := len(f.Compose.BondPoints) > 0 || len(f.Compose.Expand) > 0 || + len(f.Compose.Map) > 0 || len(f.Compose.Aspects) > 0 + + if hasCompose { + fmt.Printf("\n%s Composition:\n", ui.RenderAccent("πŸ”—")) + + if len(f.Compose.BondPoints) > 0 { + fmt.Printf(" Bond Points:\n") + for _, bp := range f.Compose.BondPoints { + loc := "" + if bp.AfterStep != "" { + loc = fmt.Sprintf("after %s", bp.AfterStep) + } else if bp.BeforeStep != "" { + loc = fmt.Sprintf("before %s", bp.BeforeStep) + } + fmt.Printf(" - %s (%s)\n", bp.ID, loc) + } + } + + if len(f.Compose.Expand) > 0 { + fmt.Printf(" Expansions:\n") + for _, e := range f.Compose.Expand { + fmt.Printf(" - %s β†’ %s\n", e.Target, e.With) + } + } + + if len(f.Compose.Map) > 0 { + fmt.Printf(" Maps:\n") + for _, m := range f.Compose.Map { + fmt.Printf(" - %s β†’ %s\n", m.Select, m.With) + } + } + + if len(f.Compose.Aspects) > 0 { + fmt.Printf(" Aspects: %s\n", strings.Join(f.Compose.Aspects, ", ")) + } + } + } + + // Print pointcuts (for aspects) + if len(f.Pointcuts) > 0 { + fmt.Printf("\n%s Pointcuts:\n", ui.RenderWarn("🎯")) + for _, p := range f.Pointcuts { + parts := []string{} + if p.Glob != "" { + parts = append(parts, fmt.Sprintf("glob=%q", p.Glob)) + } + if p.Type != "" { + parts = append(parts, fmt.Sprintf("type=%q", p.Type)) + } + if p.Label != "" { + parts = append(parts, fmt.Sprintf("label=%q", p.Label)) + } + fmt.Printf(" - %s\n", strings.Join(parts, ", ")) + } + } + + fmt.Println() +} + +// getFormulaSearchPaths returns the formula search paths in priority order. +func getFormulaSearchPaths() []string { + var paths []string + + // Project-level formulas + if cwd, err := os.Getwd(); err == nil { + paths = append(paths, filepath.Join(cwd, ".beads", "formulas")) + } + + // User-level formulas + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, ".beads", "formulas")) + // Gas Town formulas + paths = append(paths, filepath.Join(home, "gt", ".beads", "formulas")) + } + + return paths +} + +// scanFormulaDir scans a directory for formula files. +func scanFormulaDir(dir string) ([]*formula.Formula, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + parser := formula.NewParser(dir) + var formulas []*formula.Formula + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !strings.HasSuffix(entry.Name(), formula.FormulaExt) { + continue + } + + path := filepath.Join(dir, entry.Name()) + f, err := parser.ParseFile(path) + if err != nil { + continue // Skip invalid formulas + } + formulas = append(formulas, f) + } + + return formulas, nil +} + +// countSteps recursively counts steps including children. +func countSteps(steps []*formula.Step) int { + count := len(steps) + for _, s := range steps { + count += countSteps(s.Children) + } + return count +} + +// truncateDescription truncates a description to maxLen characters. +func truncateDescription(desc string, maxLen int) string { + // Take first line only + if idx := strings.Index(desc, "\n"); idx >= 0 { + desc = desc[:idx] + } + if len(desc) > maxLen { + return desc[:maxLen-3] + "..." + } + return desc +} + +// getTypeIcon returns an icon for the formula type. +func getTypeIcon(t string) string { + switch t { + case "workflow": + return "πŸ“‹" + case "expansion": + return "πŸ“" + case "aspect": + return "🎯" + default: + return "πŸ“œ" + } +} + +// printFormulaStepsTree prints steps in a tree format. +func printFormulaStepsTree(steps []*formula.Step, indent string) { + for i, step := range steps { + connector := "β”œβ”€β”€" + if i == len(steps)-1 { + connector = "└──" + } + + // Collect dependency info + var depParts []string + if len(step.DependsOn) > 0 { + depParts = append(depParts, fmt.Sprintf("depends: %s", strings.Join(step.DependsOn, ", "))) + } + if len(step.Needs) > 0 { + depParts = append(depParts, fmt.Sprintf("needs: %s", strings.Join(step.Needs, ", "))) + } + if step.WaitsFor != "" { + depParts = append(depParts, fmt.Sprintf("waits_for: %s", step.WaitsFor)) + } + + depStr := "" + if len(depParts) > 0 { + depStr = fmt.Sprintf(" [%s]", strings.Join(depParts, ", ")) + } + + typeStr := "" + if step.Type != "" && step.Type != "task" { + typeStr = fmt.Sprintf(" (%s)", step.Type) + } + + fmt.Printf("%s%s %s: %s%s%s\n", indent, connector, step.ID, step.Title, typeStr, depStr) + + if len(step.Children) > 0 { + childIndent := indent + if i == len(steps)-1 { + childIndent += " " + } else { + childIndent += "β”‚ " + } + printFormulaStepsTree(step.Children, childIndent) + } + } +} + +func init() { + formulaListCmd.Flags().String("type", "", "Filter by type (workflow, expansion, aspect)") + + formulaCmd.AddCommand(formulaListCmd) + formulaCmd.AddCommand(formulaShowCmd) + rootCmd.AddCommand(formulaCmd) +}