From c09e04e7d2b6977cfb09efb35312c6bcbabeb251 Mon Sep 17 00:00:00 2001 From: Haoliang Yu Date: Sun, 26 Apr 2026 23:54:37 -0400 Subject: [PATCH 1/2] prepare release --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74cb71e..da2602f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. @@ -39,7 +41,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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 51fbb79..6254b3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cmdgraph", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cmdgraph", - "version": "0.2.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@oclif/core": "^4.0.0", diff --git a/package.json b/package.json index 46b8ed9..4bb4093 100644 --- a/package.json +++ b/package.json @@ -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", From 77919d0e6df1baf130d539a06ff1e71bc03a8abd Mon Sep 17 00:00:00 2001 From: Haoliang Yu Date: Mon, 27 Apr 2026 10:17:58 -0400 Subject: [PATCH 2/2] support extracting version --- CHANGELOG.md | 4 ++ README.md | 5 +- src/core/crawler.ts | 17 +++++- src/core/executor.ts | 60 +++++++++++++++++++++ src/core/version.ts | 8 +++ src/formatters/html.tsx | 3 ++ src/formatters/llms-txt.ts | 4 ++ src/formatters/markdown.ts | 4 ++ src/parsers/heuristic.ts | 80 ++++++++++++++++++++++++++++ src/types.ts | 1 + test/unit/crawler.test.ts | 30 +++++++++++ test/unit/executor.test.ts | 76 +++++++++++++++++++++++++- test/unit/framework-parsers.test.ts | 1 + test/unit/heuristic-parser.test.ts | 19 +++++++ test/unit/html-formatter.test.ts | 2 + test/unit/llms-txt-formatter.test.ts | 2 + test/unit/markdown-formatter.test.ts | 2 + 17 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 src/core/version.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index da2602f..7e46cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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 diff --git a/README.md b/README.md index 7743a35..200e84b 100644 --- a/README.md +++ b/README.md @@ -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` @@ -226,6 +226,7 @@ JSON shape: { "name": "git", "description": "The stupid content tracker", + "version": "2.49.0", "usage": "git [options] [command]", "aliases": [], "arguments": [], @@ -249,6 +250,8 @@ The stupid content tracker **Usage:** `git [options] [command]` +**Version:** `2.49.0` + **Options** - `-h, --help`: display help diff --git a/src/core/crawler.ts b/src/core/crawler.ts index e3ac81e..5446111 100644 --- a/src/core/crawler.ts +++ b/src/core/crawler.ts @@ -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 { @@ -10,6 +11,7 @@ export interface CrawlOptions { parserName?: string parserRegistry?: ParserRegistry executor?: (commandPath: string[], timeoutMs: number) => Promise + versionExecutor?: (commandPath: string[], timeoutMs: number) => Promise onWarning?: (message: string) => void } @@ -50,6 +52,7 @@ export async function crawlCommandTree(rootCommand: string, options: CrawlOption parserName, parserRegistry = createDefaultParserRegistry(), executor = runHelpCommand, + versionExecutor = runVersionCommand, onWarning, } = options @@ -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}`) diff --git a/src/core/executor.ts b/src/core/executor.ts index 1b36e7a..cfc00a4 100644 --- a/src/core/executor.ts +++ b/src/core/executor.ts @@ -1,6 +1,7 @@ import { execa } from 'execa' const helpCommandCache = new Map>() +const versionCommandCache = new Map>() export class HelpExecutionTimeoutError extends Error { constructor(command: string, timeoutMs: number) { @@ -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}` } @@ -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 { const key = cacheKey(commandPath, timeoutMs) const cached = helpCommandCache.get(key) @@ -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 { + 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 +} diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..4516936 --- /dev/null +++ b/src/core/version.ts @@ -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] +} \ No newline at end of file diff --git a/src/formatters/html.tsx b/src/formatters/html.tsx index e1e5983..a777a2d 100644 --- a/src/formatters/html.tsx +++ b/src/formatters/html.tsx @@ -45,6 +45,7 @@ type SearchDocument = { id: string depth: number description: string + version: string usage: string aliases: string[] arguments: string[] @@ -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, @@ -128,6 +130,7 @@ function CommandSection({ entry }: { entry: CommandEntry }): React.JSX.Element { {node.description ?

{node.description}

: null}
+ {node.version ? Version {node.version} : null} {node.aliases.length > 0 ? {node.aliases.length} alias{node.aliases.length === 1 ? '' : 'es'} : null} {node.options.length > 0 ? {node.options.length} option{node.options.length === 1 ? '' : 's'} : null} {node.children.length > 0 ? {node.children.length} child command{node.children.length === 1 ? '' : 's'} : null} diff --git a/src/formatters/llms-txt.ts b/src/formatters/llms-txt.ts index 92a4952..b79cac3 100644 --- a/src/formatters/llms-txt.ts +++ b/src/formatters/llms-txt.ts @@ -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(', ')}`) } diff --git a/src/formatters/markdown.ts b/src/formatters/markdown.ts index 32c3aad..ebc7e0e 100644 --- a/src/formatters/markdown.ts +++ b/src/formatters/markdown.ts @@ -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(', ')}`) } diff --git a/src/parsers/heuristic.ts b/src/parsers/heuristic.ts index e250dca..bbb439a 100644 --- a/src/parsers/heuristic.ts +++ b/src/parsers/heuristic.ts @@ -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 { @@ -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) { @@ -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, diff --git a/src/types.ts b/src/types.ts index 97dfe69..448b425 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export interface Option { export interface ParsedCommand { name: string description?: string + version?: string usage?: string aliases: string[] arguments: string[] diff --git a/test/unit/crawler.test.ts b/test/unit/crawler.test.ts index a414c4b..3e6bfec 100644 --- a/test/unit/crawler.test.ts +++ b/test/unit/crawler.test.ts @@ -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([ + [ + 'tool', + [ + 'Usage: tool [command]', + '', + 'Commands:', + ' alpha Alpha command', + ].join('\n'), + ], + ['tool alpha', 'Usage: tool alpha'], + ]) + + const versionOutputs = new Map([ + ['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() + }) }) diff --git a/test/unit/executor.test.ts b/test/unit/executor.test.ts index 2f67ea8..3fcc93c 100644 --- a/test/unit/executor.test.ts +++ b/test/unit/executor.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { execa } from 'execa' -import { HelpExecutionTimeoutError, clearHelpCommandCache, runHelpCommand } from '../../src/core/executor.js' +import { + HelpExecutionTimeoutError, + VersionExecutionTimeoutError, + clearHelpCommandCache, + clearVersionCommandCache, + runHelpCommand, + runVersionCommand, +} from '../../src/core/executor.js' vi.mock('execa', () => ({ execa: vi.fn(), @@ -9,6 +16,7 @@ vi.mock('execa', () => ({ describe('runHelpCommand', () => { beforeEach(() => { clearHelpCommandCache() + clearVersionCommandCache() vi.clearAllMocks() }) @@ -111,3 +119,69 @@ describe('runHelpCommand', () => { expect(execa).toHaveBeenCalledTimes(2) }) }) + +describe('runVersionCommand', () => { + beforeEach(() => { + clearVersionCommandCache() + vi.clearAllMocks() + }) + + it('returns command output from -v', async () => { + vi.mocked(execa).mockResolvedValueOnce({ all: 'tool 1.2.3' } as Awaited>) + + const output = await runVersionCommand(['tool'], 2000) + + expect(output).toBe('tool 1.2.3') + expect(execa).toHaveBeenCalledWith( + 'tool', + ['-v'], + expect.objectContaining({ timeout: 2000 }), + ) + }) + + it('falls back to --version and version when earlier probes have no output', async () => { + vi.mocked(execa) + .mockResolvedValueOnce({ all: '' } as Awaited>) + .mockResolvedValueOnce({ all: '' } as Awaited>) + .mockResolvedValueOnce({ all: 'tool version 3.4.5' } as Awaited>) + + const output = await runVersionCommand(['tool'], 2000) + + expect(output).toBe('tool version 3.4.5') + expect(execa).toHaveBeenNthCalledWith( + 1, + 'tool', + ['-v'], + expect.objectContaining({ timeout: 2000 }), + ) + expect(execa).toHaveBeenNthCalledWith( + 2, + 'tool', + ['--version'], + expect.objectContaining({ timeout: 2000 }), + ) + expect(execa).toHaveBeenNthCalledWith( + 3, + 'tool', + ['version'], + expect.objectContaining({ timeout: 2000 }), + ) + }) + + it('throws timeout error when execution times out', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('Command timed out after 1000 milliseconds')) + + await expect(runVersionCommand(['docker'], 1000)).rejects.toBeInstanceOf(VersionExecutionTimeoutError) + }) + + it('reuses cached output for identical version requests', async () => { + vi.mocked(execa).mockResolvedValueOnce({ all: 'cached version text' } as Awaited>) + + const first = await runVersionCommand(['git'], 2000) + const second = await runVersionCommand(['git'], 2000) + + expect(first).toBe('cached version text') + expect(second).toBe('cached version text') + expect(execa).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/framework-parsers.test.ts b/test/unit/framework-parsers.test.ts index 3a68d17..801ba50 100644 --- a/test/unit/framework-parsers.test.ts +++ b/test/unit/framework-parsers.test.ts @@ -15,6 +15,7 @@ describe('framework parsers', () => { expect(parser.name).toBe('oclif') expect(parsed.name).toBe('acme') + expect(parsed.version).toBe('1.0.0') expect(parsed.subcommands).toEqual(['login', 'status']) }) diff --git a/test/unit/heuristic-parser.test.ts b/test/unit/heuristic-parser.test.ts index e0f7551..ee2e861 100644 --- a/test/unit/heuristic-parser.test.ts +++ b/test/unit/heuristic-parser.test.ts @@ -108,4 +108,23 @@ FLAGS expect(parsed.arguments).toContain('COMMAND') expect(parsed.subcommands).not.toContain('COMMAND') }) + + it('extracts version from explicit VERSION heading blocks', async () => { + const parsed = heuristicParser.parse(await fixture('oclif-help.txt')) + + expect(parsed.version).toBe('1.0.0') + }) + + it('extracts inline version labels and ignores --version option lines', () => { + const parsed = heuristicParser.parse(`mytool version: 2.4.1 + +Usage: mytool [options] + +Options: + -v, --version Print version + -h, --help Show help +`) + + expect(parsed.version).toBe('2.4.1') + }) }) diff --git a/test/unit/html-formatter.test.ts b/test/unit/html-formatter.test.ts index 66571ee..a0703b6 100644 --- a/test/unit/html-formatter.test.ts +++ b/test/unit/html-formatter.test.ts @@ -7,6 +7,7 @@ describe('formatAsHtml', () => { const root: CommandNode = { name: 'tool', description: 'A test command.', + version: '1.2.3', usage: 'tool [command]', aliases: ['tl'], arguments: [''], @@ -40,6 +41,7 @@ describe('formatAsHtml', () => { expect(html).toContain('id="command-search"') expect(html).toContain('Filter by command, option, alias, or usage') expect(html).toContain('Skip to content') + expect(html).toContain('Version 1.2.3') expect(html).not.toContain('Source of truth: JSON') expect(html).not.toContain('Overview') expect(html).toContain('tool inspect') diff --git a/test/unit/llms-txt-formatter.test.ts b/test/unit/llms-txt-formatter.test.ts index 73da8a7..4868489 100644 --- a/test/unit/llms-txt-formatter.test.ts +++ b/test/unit/llms-txt-formatter.test.ts @@ -7,6 +7,7 @@ describe('formatAsLlmsTxt', () => { const root: CommandNode = { name: 'tool', description: 'A test command.', + version: '1.2.3', usage: 'tool [command]', aliases: ['tl'], arguments: [], @@ -35,6 +36,7 @@ describe('formatAsLlmsTxt', () => { expect(output).toContain('# tool CLI Documentation') expect(output).toContain('Primary HTML documentation: https://docs.example.com/cli/index.html') expect(output).toContain('URL: https://docs.example.com/cli/index.html#tool-inspect') + expect(output).toContain('Version: 1.2.3') expect(output).toContain('Options: -h, --help') }) }) \ No newline at end of file diff --git a/test/unit/markdown-formatter.test.ts b/test/unit/markdown-formatter.test.ts index 6efa506..552db50 100644 --- a/test/unit/markdown-formatter.test.ts +++ b/test/unit/markdown-formatter.test.ts @@ -7,6 +7,7 @@ describe('formatAsMarkdown', () => { const root: CommandNode = { name: 'tool', description: 'A test command.', + version: '1.2.3', usage: 'tool [DEST]', aliases: ['t', 'tl'], arguments: ['', 'DEST'], @@ -20,6 +21,7 @@ describe('formatAsMarkdown', () => { const markdown = formatAsMarkdown(root) expect(markdown).toContain('**Aliases:** `t`, `tl`') + expect(markdown).toContain('**Version:** `1.2.3`') expect(markdown).toContain('**Arguments**') expect(markdown).toContain('- ``') expect(markdown).toContain('- `DEST`')