mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +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:
parent
b77f5f1e4a
commit
f0fd7ddb60
@ -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 |
|
||||
|
||||
23
cli/src/commands/agent-guides.test.ts
Normal file
23
cli/src/commands/agent-guides.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
18
cli/src/commands/auth/login/guide.ts
Normal file
18
cli/src/commands/auth/login/guide.ts
Normal 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
|
||||
`
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
15
cli/src/commands/describe/app/guide.ts
Normal file
15
cli/src/commands/describe/app/guide.ts
Normal 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
|
||||
`
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
15
cli/src/commands/get/app/guide.ts
Normal file
15
cli/src/commands/get/app/guide.ts
Normal 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>
|
||||
`
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
15
cli/src/commands/help/external/external.test.ts
vendored
15
cli/src/commands/help/external/external.test.ts
vendored
@ -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')
|
||||
})
|
||||
})
|
||||
26
cli/src/commands/help/external/external.ts
vendored
26
cli/src/commands/help/external/external.ts
vendored
@ -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
|
||||
}
|
||||
16
cli/src/commands/help/external/index.ts
vendored
16
cli/src/commands/help/external/index.ts
vendored
@ -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())
|
||||
}
|
||||
}
|
||||
16
cli/src/commands/resume/app/guide.ts
Normal file
16
cli/src/commands/resume/app/guide.ts
Normal 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
|
||||
`
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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',
|
||||
|
||||
64
cli/src/commands/skills/install/index.test.ts
Normal file
64
cli/src/commands/skills/install/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
60
cli/src/commands/skills/install/index.ts
Normal file
60
cli/src/commands/skills/install/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
68
cli/src/commands/skills/install/registry.test.ts
Normal file
68
cli/src/commands/skills/install/registry.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
58
cli/src/commands/skills/install/registry.ts
Normal file
58
cli/src/commands/skills/install/registry.ts
Normal 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)))
|
||||
}
|
||||
153
cli/src/commands/skills/install/run.test.ts
Normal file
153
cli/src/commands/skills/install/run.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
119
cli/src/commands/skills/install/run.ts
Normal file
119
cli/src/commands/skills/install/run.ts
Normal 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 }
|
||||
}
|
||||
@ -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: {} },
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
58
cli/src/help/contract.ts
Normal file
58
cli/src/help/contract.ts
Normal 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)' },
|
||||
]
|
||||
40
cli/src/help/skill-template.ts
Normal file
40
cli/src/help/skill-template.ts
Normal 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.
|
||||
`
|
||||
53
cli/src/help/skill.test.ts
Normal file
53
cli/src/help/skill.test.ts
Normal 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
14
cli/src/help/skill.ts
Normal 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)
|
||||
}
|
||||
85
cli/src/help/topics.test.ts
Normal file
85
cli/src/help/topics.test.ts
Normal 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
129
cli/src/help/topics.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user