feat(cli): unified help system (#36896)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
L1nSn0w 2026-06-04 15:27:28 +08:00 committed by GitHub
parent b77f5f1e4a
commit f0fd7ddb60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1569 additions and 246 deletions

View File

@ -33,13 +33,25 @@ difyctl run app <app-id> "hello" -o json | jq .answer # JSON output
difyctl run app <app-id> --input name=world --input topic=cats # workflow inputs
```
Background docs: `difyctl help account`, `difyctl help external`, `difyctl help environment`.
Background docs: `difyctl help account`, `difyctl help external`, `difyctl help environment`, `difyctl help agent`.
## Commands
Run `difyctl --help` for the full list of commands.
Run `difyctl <cmd> --help` for per-command reference.
For agents (and scripting), start with `difyctl help agent` — the cross-command operating guide (output, discovery, auth, exit codes, errors, HITL, retry). Every help surface is also machine-readable: `difyctl help -o json` dumps the whole command tree plus the global contract (exit codes, output formats, error envelope, HITL protocol), and `difyctl <cmd> --help -o json` returns one command's descriptor.
## Agent skill
`difyctl skills install` installs a single, pure-delegation `SKILL.md` into your local agents so they auto-load it. The skill does not freeze the command set — it points the agent at `difyctl help -o json` for the live surface, so it never drifts from your binary. It is embedded in the binary (version-stamped) rather than checked in.
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi) and print where the skill would land. Writes nothing.
- `difyctl skills install --yes` — write to every detected agent, printing each path. `--agent claude-code[,cursor]` restricts to a subset; `<dir>` forces one explicit directory (handy when your agent isn't detected).
- `difyctl skills install --stdout` — print the `SKILL.md` to stdout (for piping or self-install); writes nothing.
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`). If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
## Output formats
| Flag | Behavior |

View File

@ -0,0 +1,23 @@
import type { CommandConstructor } from '@/framework/command'
import { describe, expect, it } from 'vitest'
import Login from '@/commands/auth/login/index'
import DescribeApp from '@/commands/describe/app/index'
import GetApp from '@/commands/get/app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
// Commands an agent chains through; each must expose a non-empty agentGuide
// so the wiring (index.ts override + guide.ts) is never silently dropped.
const GUIDED_COMMANDS: ReadonlyArray<readonly [string, CommandConstructor]> = [
['run app', RunApp],
['resume app', ResumeApp],
['describe app', DescribeApp],
['get app', GetApp],
['auth login', Login],
]
describe('agent guides', () => {
it.each(GUIDED_COMMANDS)('%s exposes a non-empty agentGuide', (_name, Ctor) => {
expect(new Ctor().agentGuide().length).toBeGreaterThan(0)
})
})

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { runDevicesRevoke } from '@/commands/auth/devices/_shared/devices'
@ -6,6 +7,8 @@ import { Args, Flags } from '@/framework/flags'
export default class DevicesRevoke extends DifyCommand {
static override description = 'Revoke one or all session devices'
static override effect: CommandEffect = 'destructive'
static override examples = [
'<%= config.bin %> auth devices revoke "difyctl on laptop"',
'<%= config.bin %> auth devices revoke --all',

View File

@ -0,0 +1,18 @@
export const agentGuide = `
WHEN TO USE
Establish a session before any authenticated command. Interactive browser
device flow:
difyctl auth login
difyctl auth login --host https://cloud.dify.ai
difyctl auth login --no-browser print the code/URL instead of opening
NON-INTERACTIVE
Agents without a browser can supply a bearer token via the DIFY_TOKEN env
var instead of logging in; difyctl reads it on every command. See
'difyctl help account' and 'difyctl help external'.
AFTER LOGIN
difyctl auth whoami check the active session
difyctl auth list list every authenticated context
difyctl get app list apps
`

View File

@ -1,11 +1,15 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { Flags } from '@/framework/flags'
import { realStreams } from '@/sys/io/streams'
import { agentGuide } from './guide'
import { runLogin } from './login'
export default class Login extends DifyCommand {
static override description = 'Sign in to Dify via OAuth device flow'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> auth login',
'<%= config.bin %> auth login --host https://cloud.dify.ai',
@ -36,4 +40,8 @@ export default class Login extends DifyCommand {
insecure: flags.insecure,
})
}
override agentGuide(): string {
return agentGuide
}
}

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import type { HttpClient } from '@/http/types'
import { Registry } from '@/auth/hosts'
import { DifyCommand } from '@/commands/_shared/dify-command'
@ -11,6 +12,8 @@ import { runLogout } from './logout.js'
export default class Logout extends DifyCommand {
static override description = 'Log out of the active Dify host'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> auth logout',
]

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { Args } from '@/framework/flags'
import { raw } from '@/framework/output'
@ -7,6 +8,8 @@ import { runConfigSet } from './run'
export default class ConfigSet extends DifyCommand {
static override description = 'Set a config key (validates value)'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> config set defaults.format json',
'<%= config.bin %> config set defaults.limit 50',

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { Args } from '@/framework/flags'
import { raw } from '@/framework/output'
@ -7,6 +8,8 @@ import { runConfigUnset } from './run'
export default class ConfigUnset extends DifyCommand {
static override description = 'Reset a config key to its zero value'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> config unset defaults.format',
]

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Flags } from '@/framework/flags'
@ -7,6 +8,8 @@ import { runCreateMember } from './run'
export default class CreateMember extends DifyCommand {
static override description = 'Invite a member to the active (or specified) workspace by email'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> create member --email user@example.com --role normal',
'<%= config.bin %> create member --email user@example.com --role admin -w ws-1',

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
@ -7,6 +8,8 @@ import { runDeleteMember } from './run'
export default class DeleteMember extends DifyCommand {
static override description = 'Remove a member from the active (or specified) workspace'
static override effect: CommandEffect = 'destructive'
static override examples = [
'<%= config.bin %> delete member acct-1',
'<%= config.bin %> delete member acct-1 -w ws-1',

View File

@ -0,0 +1,15 @@
export const agentGuide = `
WHEN TO USE
Inspect one app before running it reveals its mode and the input
variables / parameters it expects:
difyctl describe app <id> -o json
difyctl describe app <id> -o json | jq '.info.mode'
NEXT
Feed the discovered inputs to run:
difyctl run app <id> --inputs '{"key":"value"}' -o json
ERROR RECOVERY
app not found (404) difyctl get app
not logged in (exit 4) difyctl auth login
`

View File

@ -2,6 +2,7 @@ import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { formatted, OutputFormat } from '@/framework/output'
import { agentGuide } from './guide'
import { runDescribeApp } from './run'
export default class DescribeApp extends DifyCommand {
@ -36,4 +37,8 @@ export default class DescribeApp extends DifyCommand {
),
})
}
override agentGuide(): string {
return agentGuide
}
}

View File

@ -0,0 +1,15 @@
export const agentGuide = `
DISCOVERY
List apps to find their ids and modes before running one:
difyctl get app -o json all apps in the default workspace
difyctl get app -A -o json across every workspace you can see
difyctl get app <id> -o json one app's basic info
Each app's "mode" (chat / advanced-chat / completion / workflow / agent-chat)
decides how you call run app. Use 'difyctl describe app <id>' for the full
input schema.
ERROR RECOVERY
not logged in (exit 4) difyctl auth login
empty list wrong workspace try -A or --workspace <id>
`

View File

@ -3,6 +3,7 @@ import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { OutputFormat, table } from '@/framework/output'
import { agentGuide } from './guide'
import { runGetApp } from './run'
const APP_MODE_VALUES: readonly AppMode[] = [
@ -65,4 +66,8 @@ export default class GetApp extends DifyCommand {
data: result.data,
})
}
override agentGuide(): string {
return agentGuide
}
}

View File

@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest'
import { runHelpAccount } from './account'
describe('runHelpAccount', () => {
it('mentions auth login device flow', () => {
expect(runHelpAccount()).toContain('difyctl auth login')
})
it('mentions get/describe/run app commands', () => {
const out = runHelpAccount()
expect(out).toContain('difyctl get app')
expect(out).toContain('difyctl describe app')
expect(out).toContain('difyctl run app')
})
it('mentions --workspace and env list pointers', () => {
const out = runHelpAccount()
expect(out).toContain('--workspace')
expect(out).toContain('difyctl env list')
})
})

View File

@ -1,26 +0,0 @@
export const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
1. Sign in interactively (browser device flow):
difyctl auth login
2. List accessible apps in your default workspace:
difyctl get app
3. Describe one app to see its parameters:
difyctl describe app <id>
4. Run an app and capture structured output:
difyctl run app <id> "hello" -o json
Tips:
* Pass --workspace <id> when you need to target a non-default workspace.
* Use --stream for long-running workflow calls.
* 'difyctl auth list' shows all authenticated hosts and accounts.
* 'difyctl use host [--domain <host>]' switches the active Dify instance.
* 'difyctl use account [--email <email>]' switches accounts on the current host.
* 'difyctl env list' shows every env var difyctl reads.
`
export function runHelpAccount(): string {
return ACCOUNT_HELP_TEXT
}

View File

@ -1,16 +0,0 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { raw } from '@/framework/output'
import { runHelpAccount } from './account'
export default class HelpAccount extends DifyCommand {
static override description = 'Agent-onboarding text for account bearers (dfoa_)'
static override examples = [
'<%= config.bin %> help account',
]
async run(argv: string[]) {
this.parse(HelpAccount, argv)
return raw(runHelpAccount())
}
}

View File

@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ENV_REGISTRY } from '@/env/registry'
import { runHelpEnvironment } from './environment'
describe('runHelpEnvironment', () => {
it('starts with the ENVIRONMENT VARIABLES header', () => {
expect(runHelpEnvironment().startsWith('ENVIRONMENT VARIABLES\n\n')).toBe(true)
})
it('lists every var from ENV_REGISTRY with its description', () => {
const out = runHelpEnvironment()
for (const v of ENV_REGISTRY) {
expect(out).toContain(v.name)
expect(out).toContain(v.description)
}
})
it('marks sensitive vars with a never-echoed notice', () => {
const out = runHelpEnvironment()
expect(out).toContain('(treat as secret; never echoed)')
const sensitiveCount = ENV_REGISTRY.filter(v => v.sensitive).length
const noticeCount = (out.match(/treat as secret/g) ?? []).length
expect(noticeCount).toBe(sensitiveCount)
})
})

View File

@ -1,12 +0,0 @@
import { ENV_REGISTRY } from '@/env/registry'
export function runHelpEnvironment(): string {
let out = 'ENVIRONMENT VARIABLES\n\n'
for (const v of ENV_REGISTRY) {
out += ` ${v.name}\n ${v.description}\n`
if (v.sensitive)
out += ' (treat as secret; never echoed)\n'
out += '\n'
}
return out
}

View File

@ -1,16 +0,0 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { raw } from '@/framework/output'
import { runHelpEnvironment } from './environment'
export default class HelpEnvironment extends DifyCommand {
static override description = 'Long-form documentation for every DIFY_* env var'
static override examples = [
'<%= config.bin %> help environment',
]
async run(argv: string[]) {
this.parse(HelpEnvironment, argv)
return raw(runHelpEnvironment())
}
}

View File

@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest'
import { runHelpExternal } from './external'
describe('runHelpExternal', () => {
it('mentions external bearer prefix and login flag', () => {
const out = runHelpExternal()
expect(out).toContain('dfoe_')
expect(out).toContain('--external')
expect(out).toContain('DIFY_TOKEN')
})
it('explains workspace empty-list expectation', () => {
expect(runHelpExternal()).toContain('get workspace')
})
})

View File

@ -1,26 +0,0 @@
export const EXTERNAL_HELP_TEXT = `difyctl: external-SSO bearer onboarding
Most agents authenticate as a human account (see 'difyctl help account').
External-SSO bearers (dfoe_) skip the human flow and exchange an upstream
identity for a Dify token. The CLI surfaces the same commands but a
smaller dataset:
1. Acquire a token through your SSO provider (out of band).
2. Hand it to the CLI:
difyctl auth login --external --token "$DIFY_TOKEN"
3. List apps your subject is permitted to invoke:
difyctl get app
4. Run an app:
difyctl run app <id> "hello" -o json
Notes:
* 'difyctl get workspace' returns an empty list for external bearers that
is expected; external subjects have no workspace membership.
* Tokens are best stored in DIFY_TOKEN; difyctl reads it on every command.
`
export function runHelpExternal(): string {
return EXTERNAL_HELP_TEXT
}

View File

@ -1,16 +0,0 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { raw } from '@/framework/output'
import { runHelpExternal } from './external'
export default class HelpExternal extends DifyCommand {
static override description = 'Agent-onboarding text for external-SSO bearers (dfoe_)'
static override examples = [
'<%= config.bin %> help external',
]
async run(argv: string[]) {
this.parse(HelpExternal, argv)
return raw(runHelpExternal())
}
}

View File

@ -0,0 +1,16 @@
export const agentGuide = `
WHEN TO USE
Continue a workflow that paused for human input. run app (or a prior
resume app) exits 2 and prints a JSON object with status "paused",
form_token, workflow_run_id and resolved_default_values. Resume with:
difyctl resume app <app_id> <form_token> --workflow-run-id <id> \\
--inputs '{"name":"Alice"}' -o json
LOOP
A resume can pause again (exit 2 with a new form_token). Repeat until
exit 0. Pass --stream to print events live.
ERROR RECOVERY
not logged in (exit 4) difyctl auth login
stale form/run id re-run the app to get a fresh pause token
`

View File

@ -1,12 +1,16 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { OutputFormat } from '@/framework/output'
import { agentGuide } from './guide'
import { resumeApp } from './run'
export default class ResumeApp extends DifyCommand {
static override description = 'Resume a paused workflow app after submitting a human input form'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> resume app app-1 ft-abc --workflow-run-id wf-run-1 --action submit --inputs \'{"name":"Alice"}\'',
'<%= config.bin %> resume app app-1 ft-abc --workflow-run-id wf-run-1 --inputs-file form.json',
@ -52,4 +56,8 @@ export default class ResumeApp extends DifyCommand {
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
)
}
override agentGuide(): string {
return agentGuide
}
}

View File

@ -16,18 +16,6 @@ APP MODES
JSON object via --inputs.
agent-chat Conversational with autonomous tool use.
FLAGS
--inputs '{"k":"v"}' All input variables as one JSON object.
--inputs '{"language":"English","topic":"AI safety"}'
--inputs-file path Load inputs from a JSON file. Mutually exclusive
with --inputs.
--file key=@path Named file input. Supports local files (--file key=@/path/to/file)
and remote URLs (--file key=https://url). Repeatable for multiple
file inputs.
--stream Print output live as tokens/events arrive.
--conversation <id> Resume a conversation (chat/advanced-chat only).
--workspace <id> Target a specific workspace.
HITL PAUSE (exit code 2)
When a workflow pauses for human input, stdout receives a JSON object
with status "paused", form_token, workflow_run_id, and resolved_default_values.

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
@ -8,6 +9,8 @@ import { runApp } from './run'
export default class RunApp extends DifyCommand {
static override description = 'Run an app and print the response'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> run app app-1 "hello"',
'<%= config.bin %> run app app-1 --inputs \'{"name":"world"}\'',
@ -26,8 +29,8 @@ export default class RunApp extends DifyCommand {
static override flags = {
'inputs': Flags.string({ description: 'Input variables as a JSON object, e.g. --inputs \'{"key":"value"}\'. Mutually exclusive with --inputs-file.' }),
'inputs-file': Flags.string({ description: 'Path to a JSON file containing the inputs object. Mutually exclusive with --inputs.' }),
'file': Flags.stringArray({ description: 'Named file input (--file key=@path, repeatable)', default: [] }),
'conversation': Flags.string({ description: 'Resume a chat conversation by id' }),
'file': Flags.stringArray({ description: 'Named file input: --file key=@path for a local file or --file key=https://url for a remote URL. Repeatable.', default: [] }),
'conversation': Flags.string({ description: 'Resume a chat conversation by id (chat/advanced-chat only)' }),
'workflow-id': Flags.string({ description: 'Pin to a specific published workflow version' }),
'workspace': Flags.string({ description: 'Workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
@ -7,6 +8,8 @@ import { runSetMember } from './run'
export default class SetMember extends DifyCommand {
static override description = 'Change a member\'s role in the active (or specified) workspace'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> set member acct-1 --role admin',
'<%= config.bin %> set member acct-1 --role normal -w ws-1',

View File

@ -0,0 +1,64 @@
import type { CommandTree } from '@/framework/registry'
import { describe, expect, it } from 'vitest'
import { ExitCode } from '@/errors/codes'
import { run } from '@/framework/run'
import { renderSkill } from '@/help/skill'
import { versionInfo } from '@/version/info'
import SkillsInstall from './index'
const tree: CommandTree = { skills: { subcommands: { install: { command: SkillsInstall, subcommands: {} } } } }
type Captured = { stdout: string, stderr: string, exit: number | undefined }
async function captureRun(argv: string[]): Promise<Captured> {
const captured: Captured = { stdout: '', stderr: '', exit: undefined }
const origStdout = process.stdout.write.bind(process.stdout)
const origStderr = process.stderr.write.bind(process.stderr)
const origExit = process.exit.bind(process)
process.stdout.write = ((chunk: string | Uint8Array) => {
captured.stdout += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
return true
}) as typeof process.stdout.write
process.stderr.write = ((chunk: string | Uint8Array) => {
captured.stderr += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
return true
}) as typeof process.stderr.write
process.exit = ((code?: number) => {
captured.exit = code
}) as typeof process.exit
try {
await run(tree, argv)
}
finally {
process.stdout.write = origStdout
process.stderr.write = origStderr
process.exit = origExit
}
return captured
}
describe('skills install command', () => {
it('prints the skill verbatim under --stdout and exits 0', async () => {
const result = await captureRun(['skills', 'install', '--stdout'])
expect(result.stdout).toBe(renderSkill({ version: versionInfo.version }))
expect(result.exit).toBeUndefined()
})
it('rejects --stdout combined with --yes (exit 2, no output)', async () => {
const result = await captureRun(['skills', 'install', '--stdout', '--yes'])
expect(result.exit).toBe(ExitCode.Usage)
expect(result.stdout).toBe('')
})
it('rejects --stdout combined with a directory (exit 2)', async () => {
const result = await captureRun(['skills', 'install', './out', '--stdout'])
expect(result.exit).toBe(ExitCode.Usage)
})
it('rejects a directory combined with --agent (exit 2)', async () => {
const result = await captureRun(['skills', 'install', './out', '--agent', 'claude-code'])
expect(result.exit).toBe(ExitCode.Usage)
})
})

View File

@ -0,0 +1,60 @@
import type { CommandEffect } from '@/framework/command'
import type { CommandOutput } from '@/framework/output'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { Command } from '@/framework/command'
import { Args, Flags } from '@/framework/flags'
import { raw } from '@/framework/output'
import { versionInfo } from '@/version/info'
import { runSkillsInstall } from './run'
export default class SkillsInstall extends Command {
static override description = 'Install the difyctl agent skill (SKILL.md) into detected agents'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> skills install',
'<%= config.bin %> skills install --yes',
'<%= config.bin %> skills install --yes --agent claude-code',
'<%= config.bin %> skills install ./my-skills/difyctl --yes',
'<%= config.bin %> skills install --stdout',
]
static override args = {
dir: Args.string({ description: 'force install into a single explicit directory (bypasses detection)' }),
}
static override flags = {
yes: Flags.boolean({ char: 'y', description: 'write the skill (otherwise dry-run)', default: false }),
agent: Flags.stringArray({ description: 'restrict to specific detected agents (repeatable or comma-separated)', default: [], multiple: true }),
stdout: Flags.boolean({ description: 'print SKILL.md to stdout and write nothing', default: false }),
}
async run(argv: string[]): Promise<CommandOutput> {
const { args, flags } = this.parse(SkillsInstall, argv)
const dir = args.dir
const hasDir = dir !== undefined && dir !== ''
const agents = flags.agent.flatMap(a => a.split(',')).map(s => s.trim()).filter(s => s.length > 0)
if (flags.stdout && (flags.yes || hasDir || agents.length > 0))
throw newError(ErrorCode.IllegalArgumentError, '--stdout writes nothing; do not combine it with --yes, --agent, or [dir]')
if (hasDir && agents.length > 0)
throw newError(ErrorCode.IllegalArgumentError, 'pass either [dir] or --agent, not both')
const result = await runSkillsInstall({
version: versionInfo.version,
write: flags.yes,
stdout: flags.stdout,
dir,
agents,
})
if (result.kind === 'usage')
throw newError(ErrorCode.IllegalArgumentError, result.message)
return raw(result.text)
}
}

View File

@ -0,0 +1,68 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AGENTS, detectAgents } from './registry'
describe('detectAgents', () => {
let home: string
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'difyctl-detect-'))
})
afterEach(async () => {
await rm(home, { recursive: true, force: true })
})
it('detects nothing in an empty home', () => {
expect(detectAgents(home)).toEqual([])
})
it('detects an agent once its config dir exists', async () => {
await mkdir(join(home, '.codex'))
expect(detectAgents(home).map(a => a.name)).toEqual(['codex'])
})
it('detects every agent whose config dir exists, in registry order', async () => {
await mkdir(join(home, '.claude'))
await mkdir(join(home, '.codex'))
await mkdir(join(home, '.config', 'opencode'), { recursive: true })
await mkdir(join(home, '.cursor'))
await mkdir(join(home, '.pi'))
expect(detectAgents(home).map(a => a.name)).toEqual(['claude-code', 'codex', 'opencode', 'cursor', 'pi'])
})
it('detects cursor and pi by their config dirs', async () => {
await mkdir(join(home, '.cursor'))
await mkdir(join(home, '.pi'))
expect(detectAgents(home).map(a => a.name)).toEqual(['cursor', 'pi'])
})
})
describe('agent registry paths', () => {
it('installs Codex skills under ~/.agents/skills, detected via ~/.codex', () => {
const codex = AGENTS.find(a => a.name === 'codex')
expect(codex?.probeDir('/home/dev')).toBe('/home/dev/.codex')
expect(codex?.skillDir('/home/dev')).toBe('/home/dev/.agents/skills/difyctl')
})
it('installs Claude Code and opencode skills under their native dirs', () => {
const claude = AGENTS.find(a => a.name === 'claude-code')
const opencode = AGENTS.find(a => a.name === 'opencode')
expect(claude?.skillDir('/home/dev')).toBe('/home/dev/.claude/skills/difyctl')
expect(opencode?.skillDir('/home/dev')).toBe('/home/dev/.config/opencode/skills/difyctl')
})
it('installs Cursor under ~/.cursor/skills, detected via ~/.cursor', () => {
const cursor = AGENTS.find(a => a.name === 'cursor')
expect(cursor?.probeDir('/home/dev')).toBe('/home/dev/.cursor')
expect(cursor?.skillDir('/home/dev')).toBe('/home/dev/.cursor/skills/difyctl')
})
it('installs pi under ~/.pi/agent/skills, detected via ~/.pi', () => {
const pi = AGENTS.find(a => a.name === 'pi')
expect(pi?.probeDir('/home/dev')).toBe('/home/dev/.pi')
expect(pi?.skillDir('/home/dev')).toBe('/home/dev/.pi/agent/skills/difyctl')
})
})

View File

@ -0,0 +1,58 @@
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
// The agents difyctl knows how to install its skill into: how to tell each one
// is in use on this machine, and where it reads user-level skills.
//
// Detection is config-DIRECTORY existence only — no PATH probe, no subprocess.
// difyctl spawns nothing at runtime, and a tool's config dir (`~/.claude`,
// `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`) is the reliable "this
// agent is set up here" signal; agents themselves discover each other's skills
// by reading these dirs, not by locating executables. The narrow "installed but
// never launched, so no config dir yet" case is served by `skills install <dir>`.
//
// `probeDir` (the detection signal) and `skillDir` (the install target) are kept
// separate because they diverge for some agents: Codex is configured under
// `~/.codex` but reads user skills from `~/.agents/skills`, and pi is configured
// under `~/.pi` but reads them from `~/.pi/agent/skills` (each tool's documented
// location). Adding an agent is one entry; paths are verified against its docs.
export type AgentEntry = {
readonly name: string
readonly probeDir: (home: string) => string
readonly skillDir: (home: string) => string
}
export const AGENTS: readonly AgentEntry[] = [
{
name: 'claude-code',
probeDir: home => join(home, '.claude'),
skillDir: home => join(home, '.claude', 'skills', 'difyctl'),
},
{
name: 'codex',
probeDir: home => join(home, '.codex'),
skillDir: home => join(home, '.agents', 'skills', 'difyctl'),
},
{
name: 'opencode',
probeDir: home => join(home, '.config', 'opencode'),
skillDir: home => join(home, '.config', 'opencode', 'skills', 'difyctl'),
},
{
name: 'cursor',
probeDir: home => join(home, '.cursor'),
skillDir: home => join(home, '.cursor', 'skills', 'difyctl'),
},
{
name: 'pi',
probeDir: home => join(home, '.pi'),
skillDir: home => join(home, '.pi', 'agent', 'skills', 'difyctl'),
},
]
// Agents whose config dir exists under `home`. `home` is injectable so tests can
// point at a temp dir instead of the real home.
export function detectAgents(home: string = homedir()): readonly AgentEntry[] {
return AGENTS.filter(agent => existsSync(agent.probeDir(home)))
}

View File

@ -0,0 +1,153 @@
import type { SkillsInstallOptions } from './run'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { renderSkill } from '@/help/skill'
import { runSkillsInstall } from './run'
const VERSION = '9.9.9-test'
const SKILL = renderSkill({ version: VERSION })
describe('runSkillsInstall', () => {
let home: string
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'difyctl-skills-'))
})
afterEach(async () => {
await rm(home, { recursive: true, force: true })
})
const opts = (over: Partial<SkillsInstallOptions>): SkillsInstallOptions => ({
version: VERSION,
write: false,
stdout: false,
agents: [],
home,
...over,
})
const claudeTarget = () => join(home, '.claude', 'skills', 'difyctl', 'SKILL.md')
it('--stdout returns the skill verbatim and writes nothing', async () => {
expect(await runSkillsInstall(opts({ stdout: true }))).toEqual({ kind: 'ok', text: SKILL, wrote: [] })
})
it('dry-run lists detected agents and writes nothing', async () => {
await mkdir(join(home, '.claude'))
const result = await runSkillsInstall(opts({}))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok')
return
expect(result.text).toContain('Detected 1 agent: claude-code')
expect(result.text).toContain(`would write to claude-code: ${claudeTarget()}`)
expect(result.text).toContain('Re-run with --yes')
// A single detected agent: hint the manual-directory escape hatch, but not
// the subset selector (nothing to subset).
expect(result.text).toContain('skills install <dir>')
expect(result.text).not.toContain('--agent')
expect(result.wrote).toEqual([])
expect(existsSync(claudeTarget())).toBe(false)
})
it('dry-run summarizes detected agents and enumerates --agent names', async () => {
await mkdir(join(home, '.claude'))
await mkdir(join(home, '.codex'))
const result = await runSkillsInstall(opts({}))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok')
return
// The detection summary lists the selectable names; the footer just shows the flag.
expect(result.text).toContain('Detected 2 agents: claude-code, codex')
expect(result.text).toContain('--agent <name> to write only some')
expect(result.text).toContain('skills install <dir>')
expect(result.wrote).toEqual([])
})
it('--yes writes the skill and reports the actual path', async () => {
await mkdir(join(home, '.claude'))
const result = await runSkillsInstall(opts({ write: true }))
expect(result).toEqual({ kind: 'ok', text: `wrote ${claudeTarget()}\n`, wrote: [claudeTarget()] })
expect(await readFile(claudeTarget(), 'utf8')).toBe(SKILL)
})
it('overwrites an existing skill in place', async () => {
await mkdir(join(home, '.claude', 'skills', 'difyctl'), { recursive: true })
await writeFile(claudeTarget(), 'stale skill', 'utf8')
await runSkillsInstall(opts({ write: true }))
expect(await readFile(claudeTarget(), 'utf8')).toBe(SKILL)
})
it('guides the user and writes nothing when no agents are detected', async () => {
const result = await runSkillsInstall(opts({ write: true }))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok')
return
expect(result.text).toContain('No agents detected')
expect(result.wrote).toEqual([])
})
it('rejects an --agent name that is not detected', async () => {
await mkdir(join(home, '.claude'))
const result = await runSkillsInstall(opts({ write: true, agents: ['bogus'] }))
expect(result.kind).toBe('usage')
if (result.kind !== 'usage')
return
expect(result.message).toContain('bogus')
})
it('rejects --agent for an agent that is not present (none detected)', async () => {
const result = await runSkillsInstall(opts({ write: true, agents: ['claude-code'] }))
expect(result.kind).toBe('usage')
})
it('[dir] forces a single directory, bypassing detection', async () => {
const dest = join(home, 'forced')
const target = join(dest, 'SKILL.md')
const result = await runSkillsInstall(opts({ write: true, dir: dest }))
expect(result).toEqual({ kind: 'ok', text: `wrote ${target}\n`, wrote: [target] })
expect(await readFile(target, 'utf8')).toBe(SKILL)
})
it('writes one copy per detected agent', async () => {
await mkdir(join(home, '.claude'))
await mkdir(join(home, '.codex'))
await mkdir(join(home, '.config', 'opencode'), { recursive: true })
const result = await runSkillsInstall(opts({ write: true }))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok')
return
expect(result.wrote).toEqual([
join(home, '.claude', 'skills', 'difyctl', 'SKILL.md'),
join(home, '.agents', 'skills', 'difyctl', 'SKILL.md'),
join(home, '.config', 'opencode', 'skills', 'difyctl', 'SKILL.md'),
])
})
it('narrows writes to the named agent subset', async () => {
await mkdir(join(home, '.claude'))
await mkdir(join(home, '.codex'))
const result = await runSkillsInstall(opts({ write: true, agents: ['codex'] }))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok')
return
expect(result.wrote).toEqual([join(home, '.agents', 'skills', 'difyctl', 'SKILL.md')])
})
it('writes cursor and pi to their documented dirs (pi under agent/skills)', async () => {
await mkdir(join(home, '.cursor'))
await mkdir(join(home, '.pi'))
const result = await runSkillsInstall(opts({ write: true }))
expect(result.kind).toBe('ok')
if (result.kind !== 'ok')
return
expect(result.wrote).toEqual([
join(home, '.cursor', 'skills', 'difyctl', 'SKILL.md'),
join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'),
])
expect(await readFile(join(home, '.pi', 'agent', 'skills', 'difyctl', 'SKILL.md'), 'utf8')).toBe(SKILL)
})
})

View File

@ -0,0 +1,119 @@
import type { AgentEntry } from './registry'
import { mkdir, rename, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { renderSkill } from '@/help/skill'
import { AGENTS, detectAgents } from './registry'
export type SkillsInstallOptions = {
readonly version: string
// Write to disk (true) or just preview the targets (false / dry-run).
readonly write: boolean
// Print the skill to stdout and write nothing.
readonly stdout: boolean
// Force a single explicit directory, bypassing agent detection.
readonly dir?: string
// Restrict to these detected agents (by name); empty = all detected.
readonly agents: readonly string[]
// Injectable home dir (defaults to os.homedir()); tests pass a temp dir.
readonly home?: string
}
export type SkillsInstallResult
= | { readonly kind: 'ok', readonly text: string, readonly wrote: readonly string[] }
| { readonly kind: 'usage', readonly message: string }
type InstallTarget = {
readonly name: string
readonly path: string
}
// Atomic write: temp file in the destination dir, then rename over any existing
// SKILL.md. Mirrors the store/skill-init pattern; the unique temp name makes
// concurrent installs safe.
async function writeSkill(content: string, target: string): Promise<void> {
await mkdir(dirname(target), { recursive: true })
const tmp = `${target}.tmp-${process.pid}-${process.hrtime.bigint()}`
await writeFile(tmp, content, 'utf8')
await rename(tmp, target)
}
function resolveTargets(opts: SkillsInstallOptions, home: string): InstallTarget[] | SkillsInstallResult {
// Explicit directory: skip detection entirely.
if (opts.dir !== undefined && opts.dir !== '')
return [{ name: opts.dir, path: join(resolve(opts.dir), 'SKILL.md') }]
const detected = detectAgents(home)
const target = (a: AgentEntry): InstallTarget => ({ name: a.name, path: join(a.skillDir(home), 'SKILL.md') })
// An explicit --agent must name agents that are actually detected. This is
// checked before the zero-detected guidance below: naming an agent that is
// not present (including when none are present) is a usage error, per spec.
if (opts.agents.length > 0) {
const known = new Set(detected.map(a => a.name))
const unknown = opts.agents.filter(name => !known.has(name))
if (unknown.length > 0) {
return {
kind: 'usage',
message: `unknown or undetected agent(s): ${unknown.join(', ')} (detected: ${[...known].join(', ') || 'none'})`,
}
}
return detected.filter(a => opts.agents.includes(a.name)).map(target)
}
// No --agent and nothing detected: not an error — guide the user, write nothing.
if (detected.length === 0) {
const lookedFor = AGENTS.map(a => a.probeDir(home).replace(home, '~')).join(', ')
return {
kind: 'ok',
text: `No agents detected (looked for ${lookedFor}).\n`
+ 'Install into a directory manually with `difyctl skills install <dir>`, or\n'
+ 'print the skill with `difyctl skills install --stdout`.\n',
wrote: [],
}
}
return detected.map(target)
}
export async function runSkillsInstall(opts: SkillsInstallOptions): Promise<SkillsInstallResult> {
const home = opts.home ?? homedir()
const content = renderSkill({ version: opts.version })
// --stdout: emit the skill, write nothing.
if (opts.stdout)
return { kind: 'ok', text: content, wrote: [] }
const targets = resolveTargets(opts, home)
// resolveTargets short-circuits to a terminal result (zero detected / usage).
if (!Array.isArray(targets))
return targets
// Dry-run: list where the skill would land, write nothing.
if (!opts.write) {
const lines = targets.map(t => `would write to ${t.name}: ${t.path}`).join('\n')
// Explicit <dir>: no detection happened, so no agent summary / selectors.
if (opts.dir !== undefined && opts.dir !== '')
return { kind: 'ok', text: `${lines}\n\nRe-run with --yes to write.\n`, wrote: [] }
const names = targets.map(t => t.name)
const selected = opts.agents.length > 0
const header = `${selected ? 'Selected' : 'Detected'} ${names.length} agent${names.length === 1 ? '' : 's'}: ${names.join(', ')}`
// Only suggest --agent when the user hasn't already used it and there is more
// than one to choose from. The selectable names are the ones listed above, so
// the hint just shows the flag, not the (already-visible) name list.
const pick = (!selected && names.length > 1)
? 'Re-run with --yes to write all, or --agent <name> to write only some.'
: 'Re-run with --yes to write.'
const footer = `${pick}\nAgent not listed? Install into its directory with \`difyctl skills install <dir>\`.`
return { kind: 'ok', text: `${header}\n\n${lines}\n\n${footer}\n`, wrote: [] }
}
const wrote: string[] = []
for (const target of targets) {
await writeSkill(content, target.path)
wrote.push(target.path)
}
return { kind: 'ok', text: `${wrote.map(p => `wrote ${p}`).join('\n')}\n`, wrote }
}

View File

@ -20,12 +20,10 @@ import EnvList from '@/commands/env/list/index'
import GetApp from '@/commands/get/app/index'
import GetMember from '@/commands/get/member/index'
import GetWorkspace from '@/commands/get/workspace/index'
import HelpAccount from '@/commands/help/account/index'
import HelpEnvironment from '@/commands/help/environment/index'
import HelpExternal from '@/commands/help/external/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
import SetMember from '@/commands/set/member/index'
import SkillsInstall from '@/commands/skills/install/index'
import UseAccount from '@/commands/use/account/index'
import UseHost from '@/commands/use/host/index'
import UseWorkspace from '@/commands/use/workspace/index'
@ -82,13 +80,6 @@ export const commandTree: CommandTree = {
workspace: { command: GetWorkspace, subcommands: {} },
},
},
help: {
subcommands: {
account: { command: HelpAccount, subcommands: {} },
environment: { command: HelpEnvironment, subcommands: {} },
external: { command: HelpExternal, subcommands: {} },
},
},
resume: {
subcommands: {
app: { command: ResumeApp, subcommands: {} },
@ -104,6 +95,11 @@ export const commandTree: CommandTree = {
member: { command: SetMember, subcommands: {} },
},
},
skills: {
subcommands: {
install: { command: SkillsInstall, subcommands: {} },
},
},
use: {
subcommands: {
account: { command: UseAccount, subcommands: {} },

View File

@ -1,3 +1,4 @@
import type { CommandEffect } from '@/framework/command'
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args } from '@/framework/flags'
@ -6,6 +7,8 @@ import { runUseWorkspace } from './use'
export default class UseWorkspace extends DifyCommand {
static override description = 'Switch the active workspace on the server and refresh hosts.yml'
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> use workspace ws-abc123',
]

View File

@ -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>

View File

@ -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 }
}

View File

@ -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')
})
})

View File

@ -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)
}

View File

@ -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[] = []

View File

@ -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')
})
})

View File

@ -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'
}

View File

@ -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
}

58
cli/src/help/contract.ts Normal file
View File

@ -0,0 +1,58 @@
import { ExitCode } from '@/errors/codes'
export type Contract = {
readonly exitCodes: Readonly<Record<string, string>>
readonly outputFormats: readonly string[]
readonly errorEnvelope: {
readonly description: string
readonly shape: string
}
readonly hitl: {
readonly description: string
readonly resume: string
}
}
const EXIT_CODE_DESCRIPTIONS: Readonly<Record<number, string>> = {
[ExitCode.Success]: 'success',
[ExitCode.Generic]: 'generic error',
[ExitCode.Usage]: 'usage error (bad flag / missing arg), or a workflow paused for human input',
[ExitCode.Auth]: 'auth error (not logged in / token expired)',
[ExitCode.VersionCompat]: 'version / compatibility error',
}
function buildExitCodes(): Record<string, string> {
const out: Record<string, string> = {}
for (const code of Object.values(ExitCode))
out[String(code)] = EXIT_CODE_DESCRIPTIONS[code] ?? 'see docs'
return out
}
// Single machine-readable source for the cross-command contract an agent
// needs to drive difyctl: exit semantics, output formats, the stderr error
// envelope, and the human-in-the-loop pause protocol.
export const CONTRACT: Contract = {
exitCodes: buildExitCodes(),
outputFormats: ['json', 'yaml', 'name', 'wide', 'text'],
errorEnvelope: {
description:
'On failure the error goes to stderr. Under -o json/yaml it is a structured envelope; otherwise a human line.',
shape:
'{ "error": { "code": string, "message": string, "hint"?: string, "http_status"?: number, "request"?: string } }',
},
hitl: {
description:
'When a workflow pauses for human input, `run app` exits 2 and writes a JSON object to stdout with status "paused", form_token, workflow_run_id and resolved_default_values.',
resume:
'difyctl resume app <app_id> <form_token> --workflow-run-id <id> [--inputs \'{"key":"value"}\']',
},
}
// Single source for the top-level GLOBAL FLAGS section: flags that work across
// commands. `-o` is parsed globally (see sniffOutputFormat); its accepted values
// come straight from CONTRACT.outputFormats so the two can never drift.
export const GLOBAL_FLAG_HELP: ReadonlyArray<{ label: string, description: string }> = [
{ label: '-o, --output <format>', description: `Output format: ${CONTRACT.outputFormats.join('|')}` },
{ label: '-v, --verbose', description: 'Enable verbose logging' },
{ label: '--http-retry <n>', description: 'Retry idempotent GET/PUT/DELETE on transient errors (0 disables)' },
]

View File

@ -0,0 +1,40 @@
// The difyctl agent skill, in full — one hand-authored, pure-delegation file.
//
// It inlines NO command list, NO flag list, and ships no reference files. The
// command surface is discovered at runtime via `difyctl help -o json`, so this
// template has nothing to derive from the binary and nothing that can drift
// from it. `{{VERSION}}` is the only substitution; it is filled at emit time by
// renderSkill() in ./skill.
//
// RED LINE: keep this pure delegation. The moment a command or flag listing is
// added here, the embedded static copy can fall out of sync with the command
// surface — at which point it must instead be generated from the command model
// with a snapshot test (see SKILL-SPEC.md §10, decision D2).
export const SKILL_TEMPLATE = `---
name: difyctl
description: Drive the difyctl CLI to manage Dify apps, workspaces, members and runs. Use when the task involves difyctl or operating a Dify instance from the command line.
---
# difyctl
difyctl is self-describing do not guess commands.
## Discover the command surface
Run \`difyctl help -o json\` for the version-current map: every command
(args, flags, examples, \`effect\`) plus the global \`contract\` (exit codes,
output formats, error envelope, HITL protocol). Treat that JSON as the
source of truth; this file only bootstraps you into it.
## The one non-obvious thing: HITL pauses are not failures
A run can pause for human input. It exits with **code 2** and emits a
\`paused\` JSON payload — this is success-with-pending, NOT a crash.
Resume as the payload instructs (see \`difyctl resume app --help\`).
## Before any write/destructive action
Check the command's \`effect\` (\`read\` / \`write\` / \`destructive\`) in
\`difyctl help -o json\` before running it.
---
difyctl skill v{{VERSION}} if \`difyctl version\` differs, re-run
\`difyctl skills install\` to refresh.
`

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { commandTree } from '@/commands/tree.generated'
import { collectCommands } from '@/framework/registry'
import { versionInfo } from '@/version/info'
import { renderSkill } from './skill'
import { SKILL_TEMPLATE } from './skill-template'
// Self-references the skill is allowed to name — operational pointers, not a
// command listing. Everything else must be discovered via `help -o json`.
const SELF_REFERENCES = new Set(['resume app', 'skills install', 'version'])
describe('renderSkill', () => {
it('substitutes the version stamp and changes nothing else', () => {
expect(renderSkill({ version: '9.9.9-test' }))
.toBe(SKILL_TEMPLATE.replaceAll('{{VERSION}}', '9.9.9-test'))
})
it('stamps the running binary version (deterministic under test setup)', () => {
expect(versionInfo.version).toBe('0.0.0-test')
expect(renderSkill({ version: versionInfo.version })).toContain('difyctl skill v0.0.0-test')
})
it('leaves no unfilled template tokens', () => {
expect(renderSkill({ version: versionInfo.version })).not.toContain('{{')
})
it('points at the machine-readable surface instead of inlining it', () => {
expect(renderSkill({ version: versionInfo.version })).toContain('difyctl help -o json')
})
it('enumerates no command from the tree (zero drift surface)', () => {
const skill = renderSkill({ version: versionInfo.version })
for (const { path } of collectCommands(commandTree)) {
const command = path.join(' ')
if (SELF_REFERENCES.has(command))
continue
expect(skill, `skill must not enumerate command "${command}"`).not.toContain(command)
}
})
it('carries none of the old enumerated sections', () => {
const skill = renderSkill({ version: versionInfo.version })
for (const marker of ['## Safety', '## Core workflow', '## Reference', 'OUTPUT FORMATS', 'EXIT CODES', 'reference/'])
expect(skill).not.toContain(marker)
})
it('inlines no command-specific flags (only the --help pointer)', () => {
const skill = renderSkill({ version: versionInfo.version })
const longFlags = skill.match(/--[a-z][\w-]+/g) ?? []
for (const flag of longFlags)
expect(flag, `unexpected flag in skill: ${flag}`).toBe('--help')
})
})

14
cli/src/help/skill.ts Normal file
View File

@ -0,0 +1,14 @@
import { SKILL_TEMPLATE } from './skill-template'
export type RenderSkillOptions = {
readonly version: string
}
// Renders the difyctl SKILL.md by substituting only the version stamp into the
// hand-authored, pure-delegation template. There is no command-tree walk: the
// skill points agents at `difyctl help -o json` for the live command surface
// rather than enumerating it, so there is nothing to derive and nothing to
// drift. The single file is what `skills install` writes / prints.
export function renderSkill(opts: RenderSkillOptions): string {
return SKILL_TEMPLATE.replaceAll('{{VERSION}}', opts.version)
}

View File

@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { ENV_REGISTRY } from '@/env/registry'
import { findTopic, TOPICS } from './topics'
function render(name: string): string {
const topic = findTopic(name)
if (!topic)
throw new Error(`topic not found: ${name}`)
return topic.render()
}
describe('topic registry', () => {
it('registers account, agent, environment and external', () => {
expect(TOPICS.map(t => t.name)).toEqual(['account', 'agent', 'environment', 'external'])
})
it('findTopic returns undefined for unknown names', () => {
expect(findTopic('nope')).toBeUndefined()
})
})
describe('account topic', () => {
it('mentions auth login device flow', () => {
expect(render('account')).toContain('difyctl auth login')
})
it('mentions get/describe/run app commands', () => {
const out = render('account')
expect(out).toContain('difyctl get app')
expect(out).toContain('difyctl describe app')
expect(out).toContain('difyctl run app')
})
it('mentions --workspace and env list pointers', () => {
const out = render('account')
expect(out).toContain('--workspace')
expect(out).toContain('difyctl env list')
})
})
describe('agent topic', () => {
it('covers output, exit codes, auth, errors and HITL', () => {
const out = render('agent')
expect(out).toContain('-o json')
expect(out).toContain('EXIT CODES')
expect(out).toContain('DIFY_TOKEN')
expect(out).toContain('difyctl help -o json')
expect(out).toContain('HUMAN-IN-THE-LOOP')
})
})
describe('external topic', () => {
it('mentions external bearer prefix and login flag', () => {
const out = render('external')
expect(out).toContain('dfoe_')
expect(out).toContain('--external')
expect(out).toContain('DIFY_TOKEN')
})
it('explains workspace empty-list expectation', () => {
expect(render('external')).toContain('get workspace')
})
})
describe('environment topic', () => {
it('starts with the ENVIRONMENT VARIABLES header', () => {
expect(render('environment').startsWith('ENVIRONMENT VARIABLES\n\n')).toBe(true)
})
it('lists every var from ENV_REGISTRY with its description', () => {
const out = render('environment')
for (const v of ENV_REGISTRY) {
expect(out).toContain(v.name)
expect(out).toContain(v.description)
}
})
it('marks sensitive vars with a never-echoed notice', () => {
const out = render('environment')
expect(out).toContain('(treat as secret; never echoed)')
const sensitiveCount = ENV_REGISTRY.filter(v => v.sensitive).length
const noticeCount = (out.match(/treat as secret/g) ?? []).length
expect(noticeCount).toBe(sensitiveCount)
})
})

129
cli/src/help/topics.ts Normal file
View File

@ -0,0 +1,129 @@
import { ENV_REGISTRY } from '@/env/registry'
import { CONTRACT } from './contract'
export type HelpTopic = {
readonly name: string
readonly summary: string
readonly render: () => string
}
const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
1. Sign in interactively (browser device flow):
difyctl auth login
2. List accessible apps in your default workspace:
difyctl get app
3. Describe one app to see its parameters:
difyctl describe app <id>
4. Run an app and capture structured output:
difyctl run app <id> "hello" -o json
Tips:
* 'difyctl auth list' shows your authenticated contexts; 'difyctl use host'
and 'difyctl use account' switch between them.
* Pass --workspace <id> to target a non-default workspace.
* Pass --stream to 'difyctl run app' for live token/event output.
* 'difyctl env list' shows every env var difyctl reads.
`
const EXTERNAL_HELP_TEXT = `difyctl: external-SSO bearer onboarding
Most agents authenticate as a human account (see 'difyctl help account').
External-SSO bearers (dfoe_) skip the human flow and exchange an upstream
identity for a Dify token. The CLI surfaces the same commands but a
smaller dataset:
1. Acquire a token through your SSO provider (out of band).
2. Hand it to the CLI:
difyctl auth login --external --token "$DIFY_TOKEN"
3. List apps your subject is permitted to invoke:
difyctl get app
4. Run an app:
difyctl run app <id> "hello" -o json
Notes:
* 'difyctl get workspace' returns an empty list for external bearers that
is expected; external subjects have no workspace membership.
* Tokens are best stored in DIFY_TOKEN; difyctl reads it on every command.
`
function renderEnvironment(): string {
let out = 'ENVIRONMENT VARIABLES\n\n'
for (const v of ENV_REGISTRY) {
out += ` ${v.name}\n ${v.description}\n`
if (v.sensitive)
out += ' (treat as secret; never echoed)\n'
out += '\n'
}
return out
}
function renderAgent(): string {
const exitCodes = Object.entries(CONTRACT.exitCodes)
.map(([code, desc]) => ` ${code} ${desc}`)
.join('\n')
return `difyctl: agent operating guide
OUTPUT
Pass -o json (or -o yaml) on every command the JSON shape is stable and
documented. Without it you get human tables meant for a terminal.
DISCOVERY
difyctl help -o json full command tree + this contract, machine-readable
difyctl get app -o json list apps (ids + modes)
difyctl describe app <id> one app's mode and input schema
AUTH
Interactive: difyctl auth login (browser device flow)
Non-interactive: export DIFY_TOKEN=<bearer> (read on every command)
Details: difyctl help account / difyctl help external
EXIT CODES
${exitCodes}
ERRORS
Under -o json/yaml a failure writes a structured envelope to stderr:
${CONTRACT.errorEnvelope.shape}
HUMAN-IN-THE-LOOP
${CONTRACT.hitl.description}
Resume: ${CONTRACT.hitl.resume}
RETRY
Idempotent GET/PUT/DELETE retry on transient errors (default 3); POST/PATCH
never. Override with --http-retry <n> or DIFYCTL_HTTP_RETRY.
`
}
export const TOPICS: readonly HelpTopic[] = [
{
name: 'account',
summary: 'Agent-onboarding text for account bearers (dfoa_)',
render: () => ACCOUNT_HELP_TEXT,
},
{
name: 'agent',
summary: 'Cross-command contract for agents driving difyctl',
render: renderAgent,
},
{
name: 'environment',
summary: 'Long-form documentation for every DIFY_* env var',
render: renderEnvironment,
},
{
name: 'external',
summary: 'Agent-onboarding text for external-SSO bearers (dfoe_)',
render: () => EXTERNAL_HELP_TEXT,
},
]
export function findTopic(name: string): HelpTopic | undefined {
return TOPICS.find(t => t.name === name)
}