Step-by-step guide for implementing new CLI commands in docmgr, covering command structure, workspace integration, and output formatting.
docmgr commands follow a consistent pattern that separates command definition from business logic, integrates with the workspace system for document discovery and querying, and supports both human-friendly and structured output formats. This design enables commands to focus on their specific functionality while leveraging shared infrastructure for workspace resolution, indexing, and output formatting.
This guide covers: Creating new command groups, implementing list and action commands, integrating with the workspace system, and supporting dual-mode output (human-friendly and structured).
Intended audience: Developers extending docmgr with new commands or command groups.
docmgr uses Cobra for CLI parsing and the Glazed framework for structured output. Commands are organized in cmd/docmgr/cmds/ with subdirectories for each command group (doc, vocab, ticket, etc.). Each group follows a consistent structure with an Attach() function that registers subcommands and individual command files implementing specific operations.
Command hierarchy:
docmgr (root)
├── doc
│ ├── add
│ ├── list
│ ├── search
│ └── relate
├── vocab
│ ├── list
│ └── add
├── ticket
│ ├── create-ticket
│ ├── list
│ └── close
└── skill (new)
├── list
└── show
Dual-mode output:
Commands implement two interfaces to support different use cases:
Users enable structured output with --with-glaze-output, then select format via --output json|yaml|csv|table.
Create a new subdirectory in cmd/docmgr/cmds/ for your command group:
mkdir -p cmd/docmgr/cmds/skill
Create the main attachment file (cmd/docmgr/cmds/skill/skill.go):
package skill
import (
"github.com/spf13/cobra"
)
// Attach registers the skill command tree under the provided root command.
func Attach(root *cobra.Command) error {
skillCmd := &cobra.Command{
Use: "skill",
Short: "Manage skills documentation",
}
listCmd, err := newListCommand()
if err != nil {
return err
}
showCmd, err := newShowCommand()
if err != nil {
return err
}
skillCmd.AddCommand(listCmd, showCmd)
root.AddCommand(skillCmd)
return nil
}
Register in root (cmd/docmgr/cmds/root.go):
import (
// ... existing imports
"github.com/go-go-golems/docmgr/cmd/docmgr/cmds/skill"
)
func NewRootCommand(helpSystem *help.HelpSystem) (*cobra.Command, error) {
// ... existing code
if err := skill.Attach(rootCmd); err != nil {
return nil, err
}
return rootCmd, nil
}
List commands follow a consistent pattern: discover workspace, query documents, output results. They implement both BareCommand and GlazeCommand interfaces.
Create command file (cmd/docmgr/cmds/skill/list.go):
package skill
import (
"github.com/carapace-sh/carapace"
"github.com/go-go-golems/docmgr/cmd/docmgr/cmds/common"
"github.com/go-go-golems/docmgr/pkg/commands"
"github.com/go-go-golems/docmgr/pkg/completion"
"github.com/go-go-golems/glazed/pkg/cli"
"github.com/spf13/cobra"
)
func newListCommand() (*cobra.Command, error) {
cmd, err := commands.NewSkillListCommand()
if err != nil {
return nil, err
}
cobraCmd, err := common.BuildCommand(
cmd,
cli.WithDualMode(true),
cli.WithGlazeToggleFlag("with-glaze-output"),
)
if err != nil {
return nil, err
}
carapace.Gen(cobraCmd).FlagCompletion(carapace.ActionMap{
"root": completion.ActionDirectories(),
"ticket": completion.ActionTickets(),
"topics": completion.ActionTopics(),
})
return cobraCmd, nil
}
Implement command (pkg/commands/skill_list.go):
package commands
import (
"context"
"fmt"
"github.com/go-go-golems/docmgr/internal/workspace"
"github.com/go-go-golems/glazed/pkg/cmds"
"github.com/go-go-golems/glazed/pkg/cmds/layers"
"github.com/go-go-golems/glazed/pkg/cmds/parameters"
"github.com/go-go-golems/glazed/pkg/middlewares"
"github.com/go-go-golems/glazed/pkg/types"
)
// SkillListCommand lists all skills
type SkillListCommand struct {
*cmds.CommandDescription
}
// SkillListSettings holds command parameters
type SkillListSettings struct {
Root string `glazed.parameter:"root"`
Ticket string `glazed.parameter:"ticket"`
Topics []string `glazed.parameter:"topics"`
}
func NewSkillListCommand() (*SkillListCommand, error) {
return &SkillListCommand{
CommandDescription: cmds.NewCommandDescription(
"list",
cmds.WithShort("List all skills"),
cmds.WithLong(`Lists all skills found in the workspace.
Skills can be located in:
- Workspace root: /skills directory
- Ticket-specific: <ticket>/skills directory
Columns:
skill,what_for,when_to_use,topics,related_paths,path
Examples:
# Human output
docmgr skill list
docmgr skill list --ticket MEN-3475
docmgr skill list --topics api,backend
# Structured output
docmgr skill list --with-glaze-output --output json
`),
cmds.WithFlags(
parameters.NewParameterDefinition(
"root",
parameters.ParameterTypeString,
parameters.WithHelp("Root directory for docs"),
parameters.WithDefault("ttmp"),
),
parameters.NewParameterDefinition(
"ticket",
parameters.ParameterTypeString,
parameters.WithHelp("Filter by ticket identifier"),
parameters.WithDefault(""),
),
parameters.NewParameterDefinition(
"topics",
parameters.ParameterTypeStringList,
parameters.WithHelp("Filter by topics"),
),
),
),
}, nil
}
// RunIntoGlazeProcessor implements GlazeCommand for structured output
func (c *SkillListCommand) RunIntoGlazeProcessor(
ctx context.Context,
parsedLayers *layers.ParsedLayers,
gp middlewares.Processor,
) error {
settings := &SkillListSettings{}
if err := parsedLayers.InitializeStruct(layers.DefaultSlug, settings); err != nil {
return fmt.Errorf("failed to parse settings: %w", err)
}
// Discover workspace
ws, err := workspace.DiscoverWorkspace(ctx, workspace.DiscoverOptions{
RootOverride: settings.Root,
})
if err != nil {
return fmt.Errorf("failed to discover workspace: %w", err)
}
// Initialize index
if err := ws.InitIndex(ctx, workspace.BuildIndexOptions{
IncludeBody: false,
}); err != nil {
return fmt.Errorf("failed to initialize workspace index: %w", err)
}
// Query skills (DocType == "skill")
scope := workspace.Scope{Kind: workspace.ScopeRepo}
if settings.Ticket != "" {
scope = workspace.Scope{
Kind: workspace.ScopeTicket,
TicketID: settings.Ticket,
}
}
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: scope,
Filters: workspace.DocFilters{
DocType: "skill",
TopicsAny: settings.Topics,
},
Options: workspace.DocQueryOptions{
IncludeErrors: false,
IncludeArchivedPath: true,
IncludeScriptsPath: true,
IncludeControlDocs: true,
},
})
if err != nil {
return fmt.Errorf("failed to query skills: %w", err)
}
// Output results
for _, h := range res.Docs {
if h.Doc == nil {
continue
}
// Extract related paths
relatedPaths := make([]string, 0, len(h.Doc.RelatedFiles))
for _, rf := range h.Doc.RelatedFiles {
relatedPaths = append(relatedPaths, rf.Path)
}
row := types.NewRow(
types.MRP("skill", h.Doc.Title),
types.MRP("what_for", h.Doc.WhatFor), // Custom field
types.MRP("when_to_use", h.Doc.WhenToUse), // Custom field
types.MRP("topics", h.Doc.Topics),
types.MRP("related_paths", relatedPaths),
types.MRP("path", h.Path),
)
if err := gp.AddRow(ctx, row); err != nil {
return err
}
}
return nil
}
// Run implements BareCommand for human-friendly output
func (c *SkillListCommand) Run(
ctx context.Context,
parsedLayers *layers.ParsedLayers,
) error {
settings := &SkillListSettings{}
if err := parsedLayers.InitializeStruct(layers.DefaultSlug, settings); err != nil {
return fmt.Errorf("failed to parse settings: %w", err)
}
// Apply config root if present
settings.Root = workspace.ResolveRoot(settings.Root)
// Discover workspace and query (same as GlazeCommand)
ws, err := workspace.DiscoverWorkspace(ctx, workspace.DiscoverOptions{
RootOverride: settings.Root,
})
if err != nil {
return fmt.Errorf("failed to discover workspace: %w", err)
}
if err := ws.InitIndex(ctx, workspace.BuildIndexOptions{
IncludeBody: false,
}); err != nil {
return fmt.Errorf("failed to initialize workspace index: %w", err)
}
scope := workspace.Scope{Kind: workspace.ScopeRepo}
if settings.Ticket != "" {
scope = workspace.Scope{
Kind: workspace.ScopeTicket,
TicketID: settings.Ticket,
}
}
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: scope,
Filters: workspace.DocFilters{
DocType: "skill",
TopicsAny: settings.Topics,
},
Options: workspace.DocQueryOptions{
IncludeErrors: false,
IncludeArchivedPath: true,
IncludeScriptsPath: true,
IncludeControlDocs: true,
},
})
if err != nil {
return fmt.Errorf("failed to query skills: %w", err)
}
// Human-friendly output
for _, h := range res.Docs {
if h.Doc == nil {
continue
}
fmt.Printf("Skill: %s\n", h.Doc.Title)
if h.Doc.WhatFor != "" {
fmt.Printf(" What for: %s\n", h.Doc.WhatFor)
}
if h.Doc.WhenToUse != "" {
fmt.Printf(" When to use: %s\n", h.Doc.WhenToUse)
}
if len(h.Doc.Topics) > 0 {
fmt.Printf(" Topics: %s\n", strings.Join(h.Doc.Topics, ", "))
}
fmt.Printf(" Path: %s\n\n", h.Path)
}
return nil
}
var _ cmds.GlazeCommand = &SkillListCommand{}
var _ cmds.BareCommand = &SkillListCommand{}
Show commands display detailed information about a single item. They typically use human-friendly output by default but can support structured output for scripting.
Create command file (cmd/docmgr/cmds/skill/show.go):
package skill
import (
"github.com/go-go-golems/docmgr/cmd/docmgr/cmds/common"
"github.com/go-go-golems/docmgr/pkg/commands"
"github.com/go-go-golems/glazed/pkg/cli"
"github.com/spf13/cobra"
)
func newShowCommand() (*cobra.Command, error) {
cmd, err := commands.NewSkillShowCommand()
if err != nil {
return nil, err
}
cobraCmd, err := common.BuildCommand(
cmd,
cli.WithDualMode(true),
cli.WithGlazeToggleFlag("with-glaze-output"),
)
if err != nil {
return nil, err
}
return cobraCmd, nil
}
Implement command (pkg/commands/skill_show.go):
package commands
import (
"context"
"fmt"
"strings"
"github.com/go-go-golems/docmgr/internal/documents"
"github.com/go-go-golems/docmgr/internal/workspace"
"github.com/go-go-golems/glazed/pkg/cmds"
"github.com/go-go-golems/glazed/pkg/cmds/layers"
"github.com/go-go-golems/glazed/pkg/cmds/parameters"
)
// SkillShowCommand shows detailed information about a skill
type SkillShowCommand struct {
*cmds.CommandDescription
}
// SkillShowSettings holds command parameters
type SkillShowSettings struct {
Root string `glazed.parameter:"root"`
Skill string `glazed.parameter:"skill"`
}
func NewSkillShowCommand() (*SkillShowCommand, error) {
return &SkillShowCommand{
CommandDescription: cmds.NewCommandDescription(
"show",
cmds.WithShort("Show detailed information about a skill"),
cmds.WithLong(`Shows detailed information about a specific skill.
The skill name can be matched by:
- Exact title match
- Filename slug (e.g., "01-api-design" matches "API Design")
Examples:
docmgr skill show "API Design"
docmgr skill show 01-api-design
`),
cmds.WithFlags(
parameters.NewParameterDefinition(
"root",
parameters.ParameterTypeString,
parameters.WithHelp("Root directory for docs"),
parameters.WithDefault("ttmp"),
),
parameters.NewParameterDefinition(
"skill",
parameters.ParameterTypeString,
parameters.WithHelp("Skill name or slug to show"),
parameters.WithRequired(true),
),
),
),
}, nil
}
// Run implements BareCommand (show commands typically don't need structured output)
func (c *SkillShowCommand) Run(
ctx context.Context,
parsedLayers *layers.ParsedLayers,
) error {
settings := &SkillShowSettings{}
if err := parsedLayers.InitializeStruct(layers.DefaultSlug, settings); err != nil {
return fmt.Errorf("failed to parse settings: %w", err)
}
settings.Root = workspace.ResolveRoot(settings.Root)
// Discover workspace and query for matching skills
ws, err := workspace.DiscoverWorkspace(ctx, workspace.DiscoverOptions{
RootOverride: settings.Root,
})
if err != nil {
return fmt.Errorf("failed to discover workspace: %w", err)
}
if err := ws.InitIndex(ctx, workspace.BuildIndexOptions{
IncludeBody: true, // Need body for full display
}); err != nil {
return fmt.Errorf("failed to initialize workspace index: %w", err)
}
// Query all skills and find matches
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{Kind: workspace.ScopeRepo},
Filters: workspace.DocFilters{
DocType: "skill",
},
Options: workspace.DocQueryOptions{
IncludeBody: true,
IncludeErrors: false,
IncludeArchivedPath: true,
IncludeScriptsPath: true,
IncludeControlDocs: true,
},
})
if err != nil {
return fmt.Errorf("failed to query skills: %w", err)
}
// Find matching skill(s)
var matches []workspace.DocHandle
skillQuery := strings.ToLower(strings.TrimSpace(settings.Skill))
for _, h := range res.Docs {
if h.Doc == nil {
continue
}
// Match by title or slug
titleLower := strings.ToLower(h.Doc.Title)
if titleLower == skillQuery || strings.Contains(titleLower, skillQuery) {
matches = append(matches, h)
}
}
if len(matches) == 0 {
return fmt.Errorf("skill not found: %s", settings.Skill)
}
if len(matches) > 1 {
fmt.Fprintf(os.Stderr, "Multiple skills found, showing first match:\n")
for _, m := range matches {
fmt.Fprintf(os.Stderr, " - %s (%s)\n", m.Doc.Title, m.Path)
}
}
// Display skill details
h := matches[0]
doc := h.Doc
fmt.Printf("Title: %s\n", doc.Title)
if doc.WhatFor != "" {
fmt.Printf("\nWhat this skill is for:\n%s\n", doc.WhatFor)
}
if doc.WhenToUse != "" {
fmt.Printf("\nWhen to use this skill:\n%s\n", doc.WhenToUse)
}
if len(doc.Topics) > 0 {
fmt.Printf("\nTopics: %s\n", strings.Join(doc.Topics, ", "))
}
if len(doc.RelatedFiles) > 0 {
fmt.Printf("\nRelated Files:\n")
for _, rf := range doc.RelatedFiles {
fmt.Printf(" - %s", rf.Path)
if rf.Note != "" {
fmt.Printf(": %s", rf.Note)
}
fmt.Printf("\n")
}
}
if h.Body != "" {
fmt.Printf("\n%s\n", h.Body)
}
return nil
}
var _ cmds.BareCommand = &SkillShowCommand{}
Workspace integration:
Always use workspace.DiscoverWorkspace() to resolve the docs root. This ensures commands work consistently regardless of where they're invoked:
ws, err := workspace.DiscoverWorkspace(ctx, workspace.DiscoverOptions{
RootOverride: settings.Root,
})
if err != nil {
return fmt.Errorf("failed to discover workspace: %w", err)
}
Index initialization:
Initialize the index only when you need to query documents. For simple file operations, you can use documents.WalkDocuments() instead:
// For queries (filtering, searching)
if err := ws.InitIndex(ctx, workspace.BuildIndexOptions{
IncludeBody: false, // Set true only if you need body content
}); err != nil {
return fmt.Errorf("failed to initialize workspace index: %w", err)
}
// For simple traversal (no filtering needed)
err := documents.WalkDocuments(root, func(path string, doc *models.Document, body string, readErr error) error {
// Process each document
return nil
})
Error handling:
Handle parse errors gracefully. Documents with parse errors are still indexed (with error metadata), allowing diagnostics and repair workflows:
for _, h := range res.Docs {
if h.Doc == nil {
// Document failed to parse, skip or report
continue
}
// Process valid document
}
Output formatting:
Use types.MRP() (Make Row Pair) for structured output to ensure type-safe key-value pairs:
row := types.NewRow(
types.MRP("field1", value1),
types.MRP("field2", value2),
// Use appropriate types (string, int, []string, etc.)
)
gp.AddRow(ctx, row)
Parameter definition:
Define parameters with appropriate types and defaults:
parameters.NewParameterDefinition(
"ticket",
parameters.ParameterTypeString,
parameters.WithHelp("Filter by ticket identifier"),
parameters.WithDefault(""), // Empty string for optional filters
),
parameters.NewParameterDefinition(
"topics",
parameters.ParameterTypeStringList, // For multiple values
parameters.WithHelp("Filter by topics"),
),
parameters.NewParameterDefinition(
"skill",
parameters.ParameterTypeString,
parameters.WithHelp("Skill name to show"),
parameters.WithRequired(true), // Required parameter
),
Manual testing:
# Build docmgr
cd docmgr
go build -o docmgr ./cmd/docmgr
# Test human output
./docmgr skill list
./docmgr skill show "API Design"
# Test structured output
./docmgr skill list --with-glaze-output --output json
./docmgr skill list --with-glaze-output --output yaml
Integration testing:
Create test documents in ttmp/skills/:
---
Title: API Design Skill
DocType: skill
Topics: [api, design]
WhatFor: Designing RESTful APIs
WhenToUse: When starting a new API endpoint
---
# API Design Skill
This skill covers...
Run your command and verify output matches expectations.
Forgetting to initialize index:
ws.InitIndex() before ws.QueryDocs()IncludeBody: true only if you need body content (increases memory usage)Incorrect scope:
ScopeRepo for repository-wide queriesScopeTicket with TicketID for ticket-specific queriesMissing error handling:
h.Doc == nil before accessing document fieldsWrong output format:
types.MRP() for structured output (not plain strings)After implementing your command:
pkg/completion/actions.gopkg/commands/ and integration tests in test-scenarios/For more details on workspace integration, see docmgr help codebase-architecture.