mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(cli): unified help system (#36896)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b77f5f1e4a
commit
f0fd7ddb60
50 files changed
+1569
-246
No files matched your search
@@ -3,6 +3,11 @@ import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, Op
|
||||
import { setVerbose } from './context'
|
||||
import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags'
|
||||
|
||||
// What invoking a command does to remote/persistent state. Drives the skill's
|
||||
// safety section and the `effect` bit in machine-readable help. Defaults to
|
||||
// `read`; write/destructive commands must opt in explicitly.
|
||||
export type CommandEffect = 'read' | 'write' | 'destructive'
|
||||
|
||||
export type CommandConstructor = {
|
||||
new(): Command
|
||||
description?: string
|
||||
@@ -11,6 +16,7 @@ export type CommandConstructor = {
|
||||
examples?: string[]
|
||||
hidden?: boolean
|
||||
deprecated?: string
|
||||
effect?: CommandEffect
|
||||
}
|
||||
|
||||
type InferCommandArgs<C extends CommandConstructor> = C['args'] extends Record<string, ArgDefinition<string | undefined>>
|
||||
@@ -32,6 +38,7 @@ export abstract class Command implements ICommand {
|
||||
|
||||
static args: Record<string, ArgDefinition<string | undefined>> = {}
|
||||
static examples: string[] = []
|
||||
static effect: CommandEffect = 'read'
|
||||
|
||||
abstract run(argv: string[]): Promise<CommandOutput | void>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const GLOBAL_FLAGS: Record<string, FlagDefinition> = {
|
||||
[VERBOSE_FLAG]: Flags.boolean({
|
||||
char: VERBOSE_CHAR,
|
||||
description: 'enable verbose output',
|
||||
helpGroup: 'GLOBAL',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ function stringRepeatedFlag<const Opts extends { description: string, char?: str
|
||||
}
|
||||
}
|
||||
|
||||
function booleanFlag(opts: { description: string, char?: string, default?: boolean }): FlagDefinition<boolean> {
|
||||
function booleanFlag(opts: { description: string, char?: string, default?: boolean, helpGroup?: 'GLOBAL' }): FlagDefinition<boolean> {
|
||||
return { type: 'boolean', ...opts }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CommandConstructor } from './command'
|
||||
import type { CommandTree } from './registry'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Args, Flags } from './flags'
|
||||
import { formatHelp } from './help'
|
||||
import { formatHelp, formatTopLevelHelp } from './help'
|
||||
|
||||
function makeCmd(opts: {
|
||||
description?: string
|
||||
@@ -9,12 +10,14 @@ function makeCmd(opts: {
|
||||
args?: CommandConstructor['args']
|
||||
examples?: string[]
|
||||
agentGuide?: string
|
||||
effect?: CommandConstructor['effect']
|
||||
}): CommandConstructor {
|
||||
class Cmd {
|
||||
static description = opts.description
|
||||
static flags = opts.flags ?? {}
|
||||
static args = opts.args ?? {}
|
||||
static examples = opts.examples ?? []
|
||||
static effect = opts.effect
|
||||
static agentGuide = opts.agentGuide
|
||||
|
||||
async run(_argv: string[]) {}
|
||||
@@ -133,4 +136,71 @@ describe('formatHelp', () => {
|
||||
const ctor = makeCmd({})
|
||||
expect(formatHelp(ctor, 'run app')).not.toContain('WORKFLOW')
|
||||
})
|
||||
|
||||
it('renders aliases comma-separated and the type after a space', () => {
|
||||
const ctor = makeCmd({
|
||||
flags: { output: Flags.string({ description: 'fmt', char: 'o' }) },
|
||||
})
|
||||
const out = formatHelp(ctor, 'get app')
|
||||
expect(out).toContain('-o, --output <string>')
|
||||
expect(out).not.toContain('--output, <string>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHelp structured output', () => {
|
||||
it('emits a JSON descriptor under json format', () => {
|
||||
const ctor = makeCmd({
|
||||
description: 'Lists apps',
|
||||
flags: { output: Flags.outputFormat({ options: ['json', 'yaml', 'name', 'wide'], default: '' }) },
|
||||
args: { id: Args.string({ description: 'app id', required: true }) },
|
||||
examples: ['<%= config.bin %> get app'],
|
||||
agentGuide: 'WORKFLOW',
|
||||
})
|
||||
const obj = JSON.parse(formatHelp(ctor, 'get app', 'json'))
|
||||
expect(obj.command).toBe('get app')
|
||||
expect(obj.description).toBe('Lists apps')
|
||||
expect(obj.flags[0]).toMatchObject({ name: 'output', char: 'o', type: 'string' })
|
||||
expect(obj.flags[0].options).toEqual(['json', 'yaml', 'name', 'wide'])
|
||||
expect(obj.args[0]).toMatchObject({ name: 'id', required: true })
|
||||
expect(obj.examples).toEqual(['difyctl get app'])
|
||||
expect(obj.agentGuide).toBe('WORKFLOW')
|
||||
})
|
||||
|
||||
it('sets a flag options to null when the flag has no enum constraint', () => {
|
||||
const ctor = makeCmd({
|
||||
flags: { name: Flags.string({ description: 'a name' }) },
|
||||
})
|
||||
const obj = JSON.parse(formatHelp(ctor, 'get app', 'json'))
|
||||
expect(obj.flags[0].options).toBeNull()
|
||||
})
|
||||
|
||||
it('sets agentGuide to null when absent', () => {
|
||||
const obj = JSON.parse(formatHelp(makeCmd({}), 'get app', 'json'))
|
||||
expect(obj.agentGuide).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults effect to read when unset', () => {
|
||||
const obj = JSON.parse(formatHelp(makeCmd({}), 'get app', 'json'))
|
||||
expect(obj.effect).toBe('read')
|
||||
})
|
||||
|
||||
it('carries an explicit effect through to the descriptor', () => {
|
||||
const obj = JSON.parse(formatHelp(makeCmd({ effect: 'destructive' }), 'delete member', 'json'))
|
||||
expect(obj.effect).toBe('destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTopLevelHelp', () => {
|
||||
it('emits bin, contract, commands and topics as a JSON site map', () => {
|
||||
const tree: CommandTree = {
|
||||
get: { subcommands: { app: { command: makeCmd({ description: 'apps' }), subcommands: {} } } },
|
||||
}
|
||||
const obj = JSON.parse(formatTopLevelHelp(tree, 'json'))
|
||||
expect(obj.bin).toBe('difyctl')
|
||||
expect(obj.contract.exitCodes['0']).toBeDefined()
|
||||
expect(obj.contract.outputFormats).toContain('json')
|
||||
expect(obj.commands.some((c: { command: string }) => c.command === 'get app')).toBe(true)
|
||||
expect(obj.commands.every((c: { effect?: string }) => typeof c.effect === 'string')).toBe(true)
|
||||
expect(obj.topics.map((t: { name: string }) => t.name)).toContain('account')
|
||||
})
|
||||
})
|
||||
+193
-16
@@ -1,18 +1,61 @@
|
||||
import type { CommandConstructor } from './command'
|
||||
import type { FlagDefinition } from './types'
|
||||
import type { CommandConstructor, CommandEffect } from './command'
|
||||
import type { CommandTree } from './registry'
|
||||
import type { ArgValueType, FlagDefinition } from './types'
|
||||
import type { HelpTopic } from '@/help/topics'
|
||||
import yaml from 'js-yaml'
|
||||
import { CONTRACT, GLOBAL_FLAG_HELP } from '@/help/contract'
|
||||
import { TOPICS } from '@/help/topics'
|
||||
import { collectCommands } from './registry'
|
||||
|
||||
const BIN = 'difyctl'
|
||||
|
||||
export type FlagDescriptor = {
|
||||
name: string
|
||||
char: string | null
|
||||
type: string
|
||||
default: ArgValueType | null
|
||||
multiple: boolean
|
||||
options: readonly string[] | null
|
||||
description: string
|
||||
}
|
||||
|
||||
export type ArgDescriptor = {
|
||||
name: string
|
||||
required: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export type CommandDescriptor = {
|
||||
command: string
|
||||
description: string | null
|
||||
effect: CommandEffect
|
||||
args: ArgDescriptor[]
|
||||
flags: FlagDescriptor[]
|
||||
examples: string[]
|
||||
agentGuide: string | null
|
||||
}
|
||||
|
||||
function isStructured(format: string): boolean {
|
||||
return format === 'json' || format === 'yaml'
|
||||
}
|
||||
|
||||
function serialize(value: unknown, format: string): string {
|
||||
if (format === 'yaml')
|
||||
return yaml.dump(value, { indent: 2, lineWidth: -1 })
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
|
||||
function flagLabel(name: string, def: FlagDefinition): string {
|
||||
const parts: string[] = []
|
||||
const aliases: string[] = []
|
||||
|
||||
if (def.char)
|
||||
parts.push(`-${def.char}`)
|
||||
aliases.push(`-${def.char}`)
|
||||
|
||||
parts.push(`--${name}`)
|
||||
aliases.push(`--${name}`)
|
||||
|
||||
if (def.type !== 'boolean')
|
||||
parts.push(`<${def.type}>`)
|
||||
const label = aliases.join(', ')
|
||||
|
||||
return parts.join(', ')
|
||||
return def.type === 'boolean' ? label : `${label} <${def.type}>`
|
||||
}
|
||||
|
||||
function flagDefault(def: FlagDefinition): string {
|
||||
@@ -22,14 +65,48 @@ function flagDefault(def: FlagDefinition): string {
|
||||
return ` [default: ${JSON.stringify(def.default)}]`
|
||||
}
|
||||
|
||||
export function formatHelp(ctor: CommandConstructor, path: string): string {
|
||||
function renderExamples(ctor: CommandConstructor): string[] {
|
||||
return (ctor.examples ?? []).map(ex => ex.replace('<%= config.bin %>', BIN))
|
||||
}
|
||||
|
||||
function agentGuideOf(ctor: CommandConstructor): string {
|
||||
const C = ctor
|
||||
return new C().agentGuide()
|
||||
}
|
||||
|
||||
export function describeCommand(ctor: CommandConstructor, path: string): CommandDescriptor {
|
||||
const guide = agentGuideOf(ctor)
|
||||
|
||||
return {
|
||||
command: path,
|
||||
description: ctor.description ?? null,
|
||||
effect: ctor.effect ?? 'read',
|
||||
args: Object.entries(ctor.args ?? {}).map(([name, def]) => ({
|
||||
name,
|
||||
required: def.required ?? false,
|
||||
description: def.description,
|
||||
})),
|
||||
flags: Object.entries(ctor.flags ?? {}).map(([name, def]) => ({
|
||||
name,
|
||||
char: def.char ?? null,
|
||||
type: def.type,
|
||||
default: def.default ?? null,
|
||||
multiple: def.multiple ?? false,
|
||||
options: def.options ?? null,
|
||||
description: def.description,
|
||||
})),
|
||||
examples: renderExamples(ctor),
|
||||
agentGuide: guide.length > 0 ? guide : null,
|
||||
}
|
||||
}
|
||||
|
||||
function formatHelpText(ctor: CommandConstructor, path: string): string {
|
||||
const lines: string[] = []
|
||||
const bin = 'difyctl'
|
||||
|
||||
if (ctor.description)
|
||||
lines.push(ctor.description, '')
|
||||
|
||||
lines.push('USAGE', ` $ ${bin} ${path}${ctor.args && Object.keys(ctor.args).length > 0 ? ' [ARGS]' : ''}${ctor.flags && Object.keys(ctor.flags).length > 0 ? ' [FLAGS]' : ''}`, '')
|
||||
lines.push('USAGE', ` $ ${BIN} ${path}${ctor.args && Object.keys(ctor.args).length > 0 ? ' [ARGS]' : ''}${ctor.flags && Object.keys(ctor.flags).length > 0 ? ' [FLAGS]' : ''}`, '')
|
||||
|
||||
if (ctor.args && Object.keys(ctor.args).length > 0) {
|
||||
lines.push('ARGUMENTS')
|
||||
@@ -55,18 +132,118 @@ export function formatHelp(ctor: CommandConstructor, path: string): string {
|
||||
if (ctor.examples && ctor.examples.length > 0) {
|
||||
lines.push('EXAMPLES')
|
||||
|
||||
for (const ex of ctor.examples) {
|
||||
lines.push(` $ ${ex.replace('<%= config.bin %>', bin)}`)
|
||||
for (const ex of renderExamples(ctor)) {
|
||||
lines.push(` $ ${ex}`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const C = ctor
|
||||
const guide = ((new C())).agentGuide()
|
||||
const guide = agentGuideOf(ctor)
|
||||
|
||||
if (typeof guide === 'string' && guide.length > 0)
|
||||
if (guide.length > 0)
|
||||
lines.push(guide)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatHelp(ctor: CommandConstructor, path: string, format = ''): string {
|
||||
if (isStructured(format))
|
||||
return serialize(describeCommand(ctor, path), format)
|
||||
|
||||
return formatHelpText(ctor, path)
|
||||
}
|
||||
|
||||
export function formatTopic(topic: HelpTopic, format = ''): string {
|
||||
if (isStructured(format))
|
||||
return serialize({ name: topic.name, summary: topic.summary, body: topic.render() }, format)
|
||||
|
||||
return topic.render()
|
||||
}
|
||||
|
||||
// Renders a list of commands as aligned `path description` rows, grouped by
|
||||
// their first path segment (a blank line between groups). Shared by the
|
||||
// top-level overview and namespace drill-in (`difyctl <group> --help`) so both
|
||||
// derive from the same full-depth `collectCommands` walk and the canonical
|
||||
// space-joined command path.
|
||||
export function renderCommandRows(commands: Array<{ command: CommandConstructor, path: string[] }>): string {
|
||||
const rows = commands.map(({ command, path }) => ({
|
||||
label: path.join(' '),
|
||||
desc: command.description ?? '',
|
||||
group: path[0] ?? '',
|
||||
}))
|
||||
|
||||
const width = rows.reduce((max, r) => Math.max(max, r.label.length), 0) + 2
|
||||
const lines: string[] = []
|
||||
let prevGroup: string | undefined
|
||||
|
||||
for (const r of rows) {
|
||||
if (prevGroup !== undefined && r.group !== prevGroup)
|
||||
lines.push('')
|
||||
|
||||
prevGroup = r.group
|
||||
lines.push(r.desc ? ` ${r.label.padEnd(width)}${r.desc}` : ` ${r.label}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Renders a command list (a namespace subtree for `<group> --help`) in the
|
||||
// requested format: structured formats serialize per-command descriptors — the
|
||||
// same shape as the top-level site map's `commands` — while text renders the
|
||||
// aligned rows. Keeps `<group> --help -o json` machine-readable like every
|
||||
// other help surface.
|
||||
export function formatCommandList(commands: Array<{ command: CommandConstructor, path: string[] }>, format: string): string {
|
||||
if (isStructured(format))
|
||||
return serialize({ commands: commands.map(({ command, path }) => describeCommand(command, path.join(' '))) }, format)
|
||||
|
||||
return `COMMANDS\n${renderCommandRows(commands)}\n`
|
||||
}
|
||||
|
||||
// Curated onboarding examples for the top-level overview (gh-style): the
|
||||
// shortest path from zero to a structured app run. Editorial, not an exhaustive
|
||||
// dump — per-command examples live in each command's own `--help`.
|
||||
const ROOT_EXAMPLES = [
|
||||
`${BIN} auth login`,
|
||||
`${BIN} get app`,
|
||||
`${BIN} run app <id> "hello" -o json`,
|
||||
]
|
||||
|
||||
function renderTopicRows(): string {
|
||||
const width = TOPICS.reduce((max, t) => Math.max(max, t.name.length), 0) + 2
|
||||
return TOPICS.map(t => ` ${t.name.padEnd(width)}${t.summary}`).join('\n')
|
||||
}
|
||||
|
||||
function renderGlobalFlagRows(): string {
|
||||
const width = GLOBAL_FLAG_HELP.reduce((max, f) => Math.max(max, f.label.length), 0) + 2
|
||||
return GLOBAL_FLAG_HELP.map(f => ` ${f.label.padEnd(width)}${f.description}`).join('\n')
|
||||
}
|
||||
|
||||
function formatTopLevelHelpText(tree: CommandTree): string {
|
||||
const sections = [
|
||||
`${BIN} — Dify command-line interface`,
|
||||
`USAGE\n ${BIN} <command> <subcommand> [flags]`,
|
||||
`COMMANDS\n${renderCommandRows(collectCommands(tree))}`,
|
||||
`EXAMPLES\n${ROOT_EXAMPLES.map(ex => ` $ ${ex}`).join('\n')}`,
|
||||
`GLOBAL FLAGS\n${renderGlobalFlagRows()}`,
|
||||
`GUIDES\n${renderTopicRows()}`,
|
||||
`LEARN MORE\n`
|
||||
+ ` Use \`${BIN} <command> --help\` for details on a command.\n`
|
||||
+ ` New here? Run \`${BIN} help account\`. Agents: \`${BIN} help agent\` or \`${BIN} --help -o json\`.`,
|
||||
]
|
||||
|
||||
return `${sections.join('\n\n')}\n`
|
||||
}
|
||||
|
||||
export function formatTopLevelHelp(tree: CommandTree, format: string): string {
|
||||
if (isStructured(format)) {
|
||||
return serialize({
|
||||
bin: BIN,
|
||||
contract: CONTRACT,
|
||||
commands: collectCommands(tree).map(({ command, path }) => describeCommand(command, path.join(' '))),
|
||||
topics: TOPICS.map(t => ({ name: t.name, summary: t.summary })),
|
||||
}, format)
|
||||
}
|
||||
|
||||
return formatTopLevelHelpText(tree)
|
||||
}
|
||||
@@ -58,6 +58,24 @@ function editDistance(a: string, b: string): number {
|
||||
return prev[n] ?? 0
|
||||
}
|
||||
|
||||
export function collectCommands(
|
||||
tree: CommandTree,
|
||||
): Array<{ command: CommandConstructor, path: string[] }> {
|
||||
const results: Array<{ command: CommandConstructor, path: string[] }> = []
|
||||
|
||||
function walk(node: CommandNode, path: string[]): void {
|
||||
if (node.command && node.command.hidden !== true)
|
||||
results.push({ command: node.command, path })
|
||||
for (const [key, child] of Object.entries(node.subcommands))
|
||||
walk(child, [...path, key])
|
||||
}
|
||||
|
||||
for (const [key, node] of Object.entries(tree))
|
||||
walk(node, [key])
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export function findSuggestions(tree: CommandTree, argv: string[]): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CommandTree } from './registry'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BaseError, HttpClientError, newError } from '@/errors/base'
|
||||
import { ErrorCode, ExitCode } from '@/errors/codes'
|
||||
import { CONTRACT } from '@/help/contract'
|
||||
import { Command } from './command'
|
||||
import { run, sniffOutputFormat } from './run'
|
||||
|
||||
@@ -351,3 +352,157 @@ describe('deprecated commands', () => {
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() help routing', () => {
|
||||
class GetApp extends Command {
|
||||
static override description = 'List or get apps'
|
||||
async run() {}
|
||||
}
|
||||
|
||||
const tree: CommandTree = {
|
||||
get: { subcommands: { app: { command: GetApp, subcommands: {} } } },
|
||||
}
|
||||
|
||||
it('renders a concept guide for `help <topic>`', async () => {
|
||||
const result = await captureRun(tree, ['help', 'account'])
|
||||
expect(result.stdout).toContain('account-bearer onboarding')
|
||||
expect(result.stdout).toContain('difyctl auth login')
|
||||
expect(result.stdout).not.toContain('COMMANDS')
|
||||
expect(result.exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders the environment guide for `help environment`', async () => {
|
||||
const result = await captureRun(tree, ['help', 'environment'])
|
||||
expect(result.stdout).toContain('ENVIRONMENT VARIABLES')
|
||||
})
|
||||
|
||||
it('renders per-command help for `help <cmd...>`', async () => {
|
||||
const result = await captureRun(tree, ['help', 'get', 'app'])
|
||||
expect(result.stdout).toContain('List or get apps')
|
||||
expect(result.stdout).toContain('USAGE')
|
||||
expect(result.exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suggests and exits non-zero for an unknown help topic', async () => {
|
||||
const result = await captureRun(tree, ['help', 'xyz'])
|
||||
expect(result.stderr).toContain('unknown help topic: xyz')
|
||||
expect(result.exit).toBe(1)
|
||||
})
|
||||
|
||||
it('lists USAGE, COMMANDS, EXAMPLES, GLOBAL FLAGS, GUIDES and LEARN MORE in the top-level overview', async () => {
|
||||
const result = await captureRun(tree, ['help'])
|
||||
expect(result.stdout).toContain('USAGE')
|
||||
expect(result.stdout).toContain('COMMANDS')
|
||||
expect(result.stdout).toContain('EXAMPLES')
|
||||
expect(result.stdout).toContain('GLOBAL FLAGS')
|
||||
expect(result.stdout).toContain('GUIDES')
|
||||
expect(result.stdout).toContain('LEARN MORE')
|
||||
expect(result.stdout).toContain('account')
|
||||
expect(result.stdout).toContain('environment')
|
||||
expect(result.stdout).toContain('external')
|
||||
})
|
||||
|
||||
it('derives the GLOBAL FLAGS output format list from the contract', async () => {
|
||||
const result = await captureRun(tree, ['help'])
|
||||
expect(result.stdout).toContain('-o, --output')
|
||||
expect(result.stdout).toContain(`Output format: ${CONTRACT.outputFormats.join('|')}`)
|
||||
expect(result.stdout).toContain('--http-retry')
|
||||
})
|
||||
|
||||
it('does not print trailing whitespace on group rows', async () => {
|
||||
const groupTree: CommandTree = {
|
||||
auth: { subcommands: { devices: { subcommands: { list: { command: GetApp, subcommands: {} } } } } },
|
||||
}
|
||||
const result = await captureRun(groupTree, ['help'])
|
||||
expect(result.stdout).not.toMatch(/ \n/)
|
||||
})
|
||||
|
||||
it('emits a JSON site map for `help -o json`', async () => {
|
||||
const result = await captureRun(tree, ['help', '-o', 'json'])
|
||||
const obj = JSON.parse(result.stdout)
|
||||
expect(obj.bin).toBe('difyctl')
|
||||
expect(obj.contract.exitCodes).toBeDefined()
|
||||
expect(obj.commands.some((c: { command: string }) => c.command === 'get app')).toBe(true)
|
||||
expect(obj.topics.some((t: { name: string }) => t.name === 'account')).toBe(true)
|
||||
expect(result.exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits a JSON descriptor for `help <cmd> -o json`', async () => {
|
||||
const result = await captureRun(tree, ['help', 'get', 'app', '-o', 'json'])
|
||||
const obj = JSON.parse(result.stdout)
|
||||
expect(obj.command).toBe('get app')
|
||||
expect(Array.isArray(obj.flags)).toBe(true)
|
||||
})
|
||||
|
||||
class Login extends Command {
|
||||
static override description = 'Sign in'
|
||||
async run() {}
|
||||
}
|
||||
|
||||
class DevicesList extends Command {
|
||||
static override description = 'List sessions'
|
||||
async run() {}
|
||||
}
|
||||
|
||||
class DevicesRevoke extends Command {
|
||||
static override description = 'Revoke a session'
|
||||
async run() {}
|
||||
}
|
||||
|
||||
const groupTree: CommandTree = {
|
||||
auth: {
|
||||
subcommands: {
|
||||
login: { command: Login, subcommands: {} },
|
||||
devices: {
|
||||
subcommands: {
|
||||
list: { command: DevicesList, subcommands: {} },
|
||||
revoke: { command: DevicesRevoke, subcommands: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
it('drills into a namespace node for `<group> --help` instead of erroring', async () => {
|
||||
const result = await captureRun(groupTree, ['auth', '--help'])
|
||||
expect(result.stdout).toContain('auth login')
|
||||
expect(result.stdout).toContain('auth devices list')
|
||||
expect(result.stdout).toContain('auth devices revoke')
|
||||
expect(result.stderr).not.toContain('unknown help topic')
|
||||
expect(result.exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drills into a nested namespace node for `<group> <sub> --help`', async () => {
|
||||
const result = await captureRun(groupTree, ['auth', 'devices', '--help'])
|
||||
expect(result.stdout).toContain('auth devices list')
|
||||
expect(result.stdout).toContain('auth devices revoke')
|
||||
expect(result.stdout).not.toContain('auth login')
|
||||
expect(result.stderr).not.toContain('unknown help topic')
|
||||
expect(result.exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still errors for an unknown namespace-looking path', async () => {
|
||||
const result = await captureRun(groupTree, ['auth', 'nope', '--help'])
|
||||
expect(result.stderr).toContain('unknown help topic: auth nope')
|
||||
expect(result.exit).toBe(1)
|
||||
})
|
||||
|
||||
it('serializes the subtree as JSON for `<group> --help -o json`', async () => {
|
||||
const result = await captureRun(groupTree, ['auth', '--help', '-o', 'json'])
|
||||
expect(result.exit).toBeUndefined()
|
||||
expect(result.stdout).not.toContain('COMMANDS')
|
||||
const parsed = JSON.parse(result.stdout) as { commands: Array<{ command: string }> }
|
||||
expect(parsed.commands.map(c => c.command)).toEqual([
|
||||
'auth login',
|
||||
'auth devices list',
|
||||
'auth devices revoke',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders full-depth leaves at the top level (third-level commands visible)', async () => {
|
||||
const result = await captureRun(groupTree, [])
|
||||
expect(result.stdout).toContain('auth login')
|
||||
expect(result.stdout).toContain('auth devices list')
|
||||
expect(result.stdout).toContain('auth devices revoke')
|
||||
})
|
||||
})
|
||||
+58
-30
@@ -1,25 +1,76 @@
|
||||
import type { CommandTree } from './registry'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { formatErrorForCli } from '@/errors/format'
|
||||
import { formatHelp } from './help'
|
||||
import { findTopic } from '@/help/topics'
|
||||
import { formatCommandList, formatHelp, formatTopic, formatTopLevelHelp } from './help'
|
||||
import { stringifyOutput } from './output'
|
||||
import { findSuggestions, resolveCommand } from './registry'
|
||||
import { collectCommands, findSuggestions, resolveCommand } from './registry'
|
||||
|
||||
export async function run(tree: CommandTree, argv: string[]): Promise<void> {
|
||||
if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) {
|
||||
const helpArgv = argv.filter(a => a !== '--help' && a !== '-h' && a !== 'help')
|
||||
const format = sniffOutputFormat(argv)
|
||||
// The command/topic path is the leading positional run; stop at the first
|
||||
// flag so output flags like `-o json` never leak into resolution.
|
||||
const helpArgv: string[] = []
|
||||
|
||||
for (const a of argv) {
|
||||
if (a === 'help' || a === '--help' || a === '-h')
|
||||
continue
|
||||
if (a.startsWith('-'))
|
||||
break
|
||||
helpArgv.push(a)
|
||||
}
|
||||
|
||||
if (helpArgv.length > 0) {
|
||||
const resolved = resolveCommand(tree, helpArgv)
|
||||
|
||||
if (resolved) {
|
||||
process.stdout.write(`${formatHelp(resolved.command, resolved.path.join(' '))}\n`)
|
||||
const out = formatHelp(resolved.command, resolved.path.join(' '), format)
|
||||
process.stdout.write(isStructuredFormat(format) ? out : `${out}\n`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const first = helpArgv[0]
|
||||
|
||||
if (helpArgv.length === 1 && first !== undefined) {
|
||||
const topic = findTopic(first)
|
||||
|
||||
if (topic) {
|
||||
process.stdout.write(formatTopic(topic, format))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace drill-in: `difyctl auth --help` / `difyctl auth devices --help`.
|
||||
// Group nodes have no command of their own (no index.ts), so resolveCommand
|
||||
// misses them; surface their subtree instead of erroring. A strict-prefix
|
||||
// match over the full-depth command walk keeps this purely derived.
|
||||
const subtree = collectCommands(tree).filter(
|
||||
c => c.path.length > helpArgv.length && helpArgv.every((token, i) => c.path[i] === token),
|
||||
)
|
||||
|
||||
if (subtree.length > 0) {
|
||||
process.stdout.write(formatCommandList(subtree, format))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
process.stderr.write(`unknown help topic: ${helpArgv.join(' ')}\n`)
|
||||
const suggestions = findSuggestions(tree, helpArgv)
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
process.stderr.write('\nDid you mean:\n')
|
||||
|
||||
for (const s of suggestions.slice(0, 5))
|
||||
process.stderr.write(` ${s}\n`)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
printTopLevelHelp(tree)
|
||||
process.stdout.write(formatTopLevelHelp(tree, format))
|
||||
|
||||
return
|
||||
}
|
||||
@@ -93,29 +144,6 @@ export function sniffOutputFormat(argv: readonly string[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function printTopLevelHelp(tree: CommandTree): void {
|
||||
process.stdout.write('difyctl — Dify command-line interface\n\n')
|
||||
process.stdout.write('COMMANDS\n')
|
||||
|
||||
for (const [topic, node] of Object.entries(tree)) {
|
||||
if (node.command?.hidden === true)
|
||||
continue
|
||||
|
||||
if (node.command) {
|
||||
const desc = node.command.description ?? ''
|
||||
process.stdout.write(` ${topic} ${desc}\n`)
|
||||
}
|
||||
else {
|
||||
process.stdout.write(` ${topic}\n`)
|
||||
}
|
||||
|
||||
for (const [verb, sub] of Object.entries(node.subcommands)) {
|
||||
if (sub.command?.hidden === true)
|
||||
continue
|
||||
const desc = sub.command?.description ?? ''
|
||||
process.stdout.write(` ${verb} ${desc}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write('\n')
|
||||
function isStructuredFormat(format: string): boolean {
|
||||
return format === 'json' || format === 'yaml'
|
||||
}
|
||||
@@ -10,6 +10,9 @@ export type FlagDefinition<T extends OptionalArgValueType = OptionalArgValueType
|
||||
readonly default?: ArgValueType
|
||||
readonly multiple?: boolean
|
||||
readonly options?: readonly string[]
|
||||
// Marks a flag that applies across commands; surfaced once in the top-level
|
||||
// GLOBAL FLAGS section rather than per command.
|
||||
readonly helpGroup?: 'GLOBAL'
|
||||
readonly _flagValue?: T
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user