Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Added best-effort command `version` extraction and surfaced it in generated metadata/docs when available.
- Added a version probe fallback that tries `-v`, then `--version`, then `version` when help output does not expose a parseable version.

## [0.4.0] - 2026-04-26

### Added
- Added JSON config file support for `generate`, with automatic loading from `./cmdgraph.config.json` when present.
- Added `--config` to load options and flags from any JSON file path.
Expand Down Expand Up @@ -39,7 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release.

[Unreleased]: https://github.com/haoliangyu/cmdgraph/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/haoliangyu/cmdgraph/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/haoliangyu/cmdgraph/releases/tag/v0.4.0
[0.3.0]: https://github.com/haoliangyu/cmdgraph/releases/tag/v0.3.0
[0.2.0]: https://github.com/haoliangyu/cmdgraph/releases/tag/v0.2.0
[0.1.0]: https://github.com/haoliangyu/cmdgraph/releases/tag/v0.1.0
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Most CLIs are documented in unstructured terminal text. `cmdgraph` turns that in

- Recursive command discovery from `--help`, `-h`, `-H`, or `help`
- Plugin parser system (`heuristic`, `oclif`, `commander`, `yargs`, `cobra`, `thor`, `picocli`, `urfave-cli`, `system-commandline`, `commandlineparser`, `click`, `typer`, `clap`, `argparse`)
- Best-effort metadata extraction for arguments, examples, and aliases
- Best-effort metadata extraction for version, arguments, examples, and aliases
- Concurrency control for recursive help crawling
- Automatic in-memory caching of help outputs within a process
- Timeout-safe command execution using `execa`
Expand Down Expand Up @@ -226,6 +226,7 @@ JSON shape:
{
"name": "git",
"description": "The stupid content tracker",
"version": "2.49.0",
"usage": "git [options] [command]",
"aliases": [],
"arguments": [],
Expand All @@ -249,6 +250,8 @@ The stupid content tracker

**Usage:** `git [options] [command]`

**Version:** `2.49.0`

**Options**
- `-h, --help`: display help

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cmdgraph",
"version": "0.3.0",
"version": "0.4.0",
"description": "CLI documentation introspection tool for AI agents",
"homepage": "https://github.com/haoliangyu/cmdgraph",
"type": "module",
Expand Down
17 changes: 16 additions & 1 deletion src/core/crawler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pLimit from 'p-limit'
import { runHelpCommand } from './executor.js'
import { runHelpCommand, runVersionCommand } from './executor.js'
import { createDefaultParserRegistry, ParserRegistry } from './parser-registry.js'
import { extractVersionFromText } from './version.js'
import type { CommandNode, ParsedCommand } from '../types.js'

export interface CrawlOptions {
Expand All @@ -10,6 +11,7 @@ export interface CrawlOptions {
parserName?: string
parserRegistry?: ParserRegistry
executor?: (commandPath: string[], timeoutMs: number) => Promise<string>
versionExecutor?: (commandPath: string[], timeoutMs: number) => Promise<string>
onWarning?: (message: string) => void
}

Expand Down Expand Up @@ -50,6 +52,7 @@ export async function crawlCommandTree(rootCommand: string, options: CrawlOption
parserName,
parserRegistry = createDefaultParserRegistry(),
executor = runHelpCommand,
versionExecutor = runVersionCommand,
onWarning,
} = options

Expand All @@ -65,6 +68,18 @@ export async function crawlCommandTree(rootCommand: string, options: CrawlOption
const helpText = await limit(() => executor(commandPath, timeoutMs))
const parser = parserRegistry.select(helpText, parserName)
parsed = parser.parse(helpText)

if (!parsed.version) {
try {
const versionOutput = await limit(() => versionExecutor(commandPath, timeoutMs))
const version = extractVersionFromText(versionOutput)
if (version) {
parsed = { ...parsed, version }
}
} catch {
// Version probing is best-effort and should never block command discovery.
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
onWarning?.(`Skipping \"${normalizedPath}\": ${message}`)
Expand Down
60 changes: 60 additions & 0 deletions src/core/executor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execa } from 'execa'

const helpCommandCache = new Map<string, Promise<string>>()
const versionCommandCache = new Map<string, Promise<string>>()

export class HelpExecutionTimeoutError extends Error {
constructor(command: string, timeoutMs: number) {
Expand All @@ -9,6 +10,13 @@ export class HelpExecutionTimeoutError extends Error {
}
}

export class VersionExecutionTimeoutError extends Error {
constructor(command: string, timeoutMs: number) {
super(`Timed out probing ${command} version after ${timeoutMs}ms`)
this.name = 'VersionExecutionTimeoutError'
}
}

function cacheKey(commandPath: string[], timeoutMs: number): string {
return `${commandPath.join('\u0000')}::${timeoutMs}`
}
Expand All @@ -17,6 +25,10 @@ export function clearHelpCommandCache(): void {
helpCommandCache.clear()
}

export function clearVersionCommandCache(): void {
versionCommandCache.clear()
}

export async function runHelpCommand(commandPath: string[], timeoutMs: number): Promise<string> {
const key = cacheKey(commandPath, timeoutMs)
const cached = helpCommandCache.get(key)
Expand Down Expand Up @@ -64,3 +76,51 @@ export async function runHelpCommand(commandPath: string[], timeoutMs: number):
helpCommandCache.set(key, pending)
return pending
}

export async function runVersionCommand(commandPath: string[], timeoutMs: number): Promise<string> {
const key = cacheKey(commandPath, timeoutMs)
const cached = versionCommandCache.get(key)
if (cached) {
return cached
}

const [binary, ...args] = commandPath

const pending = (async () => {
try {
const attempts = [[...args, '-v'], [...args, '--version'], [...args, 'version']]

for (const fullArgs of attempts) {
const result = await execa(binary, fullArgs, {
timeout: timeoutMs,
all: true,
reject: false,
env: {
...process.env,
CI: '1',
NO_COLOR: '1',
},
stdin: 'ignore',
})

const output = (result.all ?? '').trim()
if (output) {
return output
}
}

return ''
} catch (error) {
versionCommandCache.delete(key)

if (error instanceof Error && /timed out/i.test(error.message)) {
throw new VersionExecutionTimeoutError(commandPath.join(' '), timeoutMs)
}

throw error
}
})()

versionCommandCache.set(key, pending)
return pending
}
8 changes: 8 additions & 0 deletions src/core/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function extractVersionFromText(text: string): string | undefined {
if (!text.trim()) {
return undefined
}

const match = text.match(/\bv?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?\b/)
return match?.[0]
}
3 changes: 3 additions & 0 deletions src/formatters/html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type SearchDocument = {
id: string
depth: number
description: string
version: string
usage: string
aliases: string[]
arguments: string[]
Expand All @@ -68,6 +69,7 @@ function buildSearchDocument(root: CommandNode, entries: CommandEntry[], descrip
id: entry.id,
depth: entry.depth,
description: entry.node.description ?? '',
version: entry.node.version ?? '',
usage: entry.node.usage ?? '',
aliases: entry.node.aliases,
arguments: entry.node.arguments,
Expand Down Expand Up @@ -128,6 +130,7 @@ function CommandSection({ entry }: { entry: CommandEntry }): React.JSX.Element {
{node.description ? <p className="max-w-3xl text-base leading-7 text-muted-foreground">{node.description}</p> : null}
</div>
<div className="flex flex-wrap gap-2 lg:max-w-xs lg:justify-end">
{node.version ? <Badge>Version {node.version}</Badge> : null}
{node.aliases.length > 0 ? <Badge variant="outline">{node.aliases.length} alias{node.aliases.length === 1 ? '' : 'es'}</Badge> : null}
{node.options.length > 0 ? <Badge variant="outline">{node.options.length} option{node.options.length === 1 ? '' : 's'}</Badge> : null}
{node.children.length > 0 ? <Badge variant="outline">{node.children.length} child command{node.children.length === 1 ? '' : 's'}</Badge> : null}
Expand Down
4 changes: 4 additions & 0 deletions src/formatters/llms-txt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export function formatAsLlmsTxt(root: CommandNode, options: LlmsTxtOptions = {})
lines.push(` Usage: ${entry.node.usage}`)
}

if (entry.node.version) {
lines.push(` Version: ${entry.node.version}`)
}

if (entry.node.aliases.length > 0) {
lines.push(` Aliases: ${entry.node.aliases.join(', ')}`)
}
Expand Down
4 changes: 4 additions & 0 deletions src/formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ function formatNode(node: CommandNode, depth: number): string {
sections.push(`**Usage:** \`${node.usage}\``)
}

if (node.version) {
sections.push(`**Version:** \`${node.version}\``)
}

if (node.aliases.length > 0) {
sections.push(`**Aliases:** ${node.aliases.map((alias) => `\`${alias}\``).join(', ')}`)
}
Expand Down
80 changes: 80 additions & 0 deletions src/parsers/heuristic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CLIParser } from '../core/parser.js'
import { extractVersionFromText } from '../core/version.js'
import type { ParsedCommand } from '../types.js'

function isSectionHeading(line: string): boolean {
Expand Down Expand Up @@ -257,6 +258,84 @@ function extractExamples(lines: string[]): string[] {
return uniqueValues(examples)
}

function isLikelyOptionLine(line: string): boolean {
const trimmed = line.trim()
if (!trimmed) {
return false
}

if (trimmed.startsWith('-') || trimmed.startsWith('[')) {
return true
}

return /^\S+\s{2,}.+$/.test(trimmed)
}

function extractVersion(lines: string[]): string | undefined {
// Prefer explicit version headings/labels, then fallback to first meaningful lines.
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]?.trim() ?? ''
if (!line) {
continue
}

const inlineMatch = line.match(/^version\s*[:=]\s*(.+)$/i)
if (inlineMatch?.[1]) {
const token = extractVersionFromText(inlineMatch[1])
if (token) {
return token
}
}

if (!/^version:?$/i.test(line)) {
continue
}

for (let j = i + 1; j < lines.length && j <= i + 4; j += 1) {
const candidate = lines[j]?.trim() ?? ''
if (!candidate) {
continue
}

if (isSectionHeading(candidate)) {
break
}

const token = extractVersionFromText(candidate)
if (token) {
return token
}
}
}

const maxLines = Math.min(lines.length, 10)
for (let i = 0; i < maxLines; i += 1) {
const line = lines[i]?.trim() ?? ''
if (!line) {
continue
}

if (/--version|\[-v\]|\[--version\]/i.test(line)) {
continue
}

if (isLikelyOptionLine(line)) {
continue
}

if (!/\bversion\b/i.test(line) && i > 1) {
continue
}

const token = extractVersionFromText(line)
if (token) {
return token
}
}

return undefined
}

function extractName(lines: string[]): string {
const usage = extractUsage(lines)
if (usage) {
Expand Down Expand Up @@ -341,6 +420,7 @@ export class HeuristicParser implements CLIParser {
return {
name: extractName(lines),
description: extractDescription(lines),
version: extractVersion(lines),
usage,
aliases: extractAliases(lines),
arguments: argumentsList,
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface Option {
export interface ParsedCommand {
name: string
description?: string
version?: string
usage?: string
aliases: string[]
arguments: string[]
Expand Down
30 changes: 30 additions & 0 deletions test/unit/crawler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,34 @@ describe('crawlCommandTree', () => {
expect(maxActive).toBeLessThanOrEqual(2)
expect(maxActive).toBeGreaterThan(1)
})

it('probes version output when help text does not include a version', async () => {
const outputs = new Map<string, string>([
[
'tool',
[
'Usage: tool [command]',
'',
'Commands:',
' alpha Alpha command',
].join('\n'),
],
['tool alpha', 'Usage: tool alpha'],
])

const versionOutputs = new Map<string, string>([
['tool', 'tool version 9.8.7'],
['tool alpha', ''],
])

const tree = await crawlCommandTree('tool', {
maxDepth: 1,
timeoutMs: 1000,
executor: async (path) => outputs.get(path.join(' ')) ?? '',
versionExecutor: async (path) => versionOutputs.get(path.join(' ')) ?? '',
})

expect(tree.version).toBe('9.8.7')
expect(tree.children[0]?.version).toBeUndefined()
})
})
Loading
Loading