From f205bec1f656367b32c98bf792cf6e34d55635d8 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Wed, 13 May 2026 00:24:51 +0200 Subject: [PATCH] feat(cli): add walkCommandTree and formatCommandTree helpers --- packages/cli/src/command-tree.test.ts | 81 +++++++++++++++++++++++++++ packages/cli/src/command-tree.ts | 35 ++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 packages/cli/src/command-tree.test.ts create mode 100644 packages/cli/src/command-tree.ts diff --git a/packages/cli/src/command-tree.test.ts b/packages/cli/src/command-tree.test.ts new file mode 100644 index 00000000..ee751224 --- /dev/null +++ b/packages/cli/src/command-tree.test.ts @@ -0,0 +1,81 @@ +import { Command } from '@commander-js/extra-typings'; +import { describe, expect, it } from 'vitest'; +import { formatCommandTree, walkCommandTree } from './command-tree.js'; + +describe('walkCommandTree', () => { + it('captures name, description, aliases, and nested children', () => { + const root = new Command('root').description('the root'); + const child = new Command('child').description('a child').alias('c').alias('ch'); + const grandchild = new Command('grand').description('a grandchild'); + child.addCommand(grandchild); + root.addCommand(child); + + const tree = walkCommandTree(root); + + expect(tree).toEqual({ + name: 'root', + description: 'the root', + aliases: [], + children: [ + { + name: 'child', + description: 'a child', + aliases: ['c', 'ch'], + children: [{ name: 'grand', description: 'a grandchild', aliases: [], children: [] }], + }, + ], + }); + }); + + it('returns an empty children array when there are no subcommands', () => { + const leaf = new Command('leaf').description('alone'); + expect(walkCommandTree(leaf)).toEqual({ + name: 'leaf', + description: 'alone', + aliases: [], + children: [], + }); + }); + + it('uses an empty string when description is unset', () => { + const command = new Command('bare'); + expect(walkCommandTree(command).description).toBe(''); + }); +}); + +describe('formatCommandTree', () => { + it('renders a single node with no children', () => { + const node = { name: 'solo', description: 'just me', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('solo - just me\n'); + }); + + it('renders aliases in parentheses before the description', () => { + const node = { name: 'cmd', description: 'does things', aliases: ['c', 'co'], children: [] }; + expect(formatCommandTree(node)).toBe('cmd (c, co) - does things\n'); + }); + + it('omits the dash when description is empty', () => { + const node = { name: 'bare', description: '', aliases: [], children: [] }; + expect(formatCommandTree(node)).toBe('bare\n'); + }); + + it('indents children by two spaces per depth level and sorts siblings alphabetically', () => { + const tree = { + name: 'root', + description: 'top', + aliases: [], + children: [ + { name: 'beta', description: 'b', aliases: [], children: [] }, + { + name: 'alpha', + description: 'a', + aliases: ['al'], + children: [{ name: 'inner', description: 'i', aliases: [], children: [] }], + }, + ], + }; + expect(formatCommandTree(tree)).toBe( + 'root - top\n' + ' alpha (al) - a\n' + ' inner - i\n' + ' beta - b\n', + ); + }); +}); diff --git a/packages/cli/src/command-tree.ts b/packages/cli/src/command-tree.ts new file mode 100644 index 00000000..2cbe7d49 --- /dev/null +++ b/packages/cli/src/command-tree.ts @@ -0,0 +1,35 @@ +import type { Command } from '@commander-js/extra-typings'; + +export interface CommandTreeNode { + name: string; + description: string; + aliases: string[]; + children: CommandTreeNode[]; +} + +export function walkCommandTree(command: Command): CommandTreeNode { + return { + name: command.name(), + description: command.description(), + aliases: command.aliases(), + children: command.commands.map((child) => walkCommandTree(child as Command)), + }; +} + +export function formatCommandTree(node: CommandTreeNode): string { + const lines: string[] = []; + appendNode(node, 0, lines); + return `${lines.join('\n')}\n`; +} + +function appendNode(node: CommandTreeNode, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + const aliasPart = node.aliases.length > 0 ? ` (${node.aliases.join(', ')})` : ''; + const descriptionPart = node.description.length > 0 ? ` - ${node.description}` : ''; + lines.push(`${indent}${node.name}${aliasPart}${descriptionPart}`); + + const sortedChildren = [...node.children].sort((a, b) => a.name.localeCompare(b.name)); + for (const child of sortedChildren) { + appendNode(child, depth + 1, lines); + } +}