diff --git a/cli/README.md b/cli/README.md index 4d46465945..61414a2276 100644 --- a/cli/README.md +++ b/cli/README.md @@ -33,13 +33,25 @@ difyctl run app "hello" -o json | jq .answer # JSON output difyctl run app --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 --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 --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; `` 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 | diff --git a/cli/src/commands/agent-guides.test.ts b/cli/src/commands/agent-guides.test.ts new file mode 100644 index 0000000000..e1bcb0925a --- /dev/null +++ b/cli/src/commands/agent-guides.test.ts @@ -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 = [ + ['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) + }) +}) diff --git a/cli/src/commands/auth/devices/revoke/index.ts b/cli/src/commands/auth/devices/revoke/index.ts index 5081a92bbc..b08bdb376b 100644 --- a/cli/src/commands/auth/devices/revoke/index.ts +++ b/cli/src/commands/auth/devices/revoke/index.ts @@ -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', diff --git a/cli/src/commands/auth/login/guide.ts b/cli/src/commands/auth/login/guide.ts new file mode 100644 index 0000000000..1f117dc737 --- /dev/null +++ b/cli/src/commands/auth/login/guide.ts @@ -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 +` diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index e6f25b0a6f..d9b6dd27fc 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -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 + } } diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 478ab79936..5da93932f2 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -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', ] diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts index 36aa97d17b..099ab559ca 100644 --- a/cli/src/commands/config/set/index.ts +++ b/cli/src/commands/config/set/index.ts @@ -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', diff --git a/cli/src/commands/config/unset/index.ts b/cli/src/commands/config/unset/index.ts index f8ae6fe15b..3888aef500 100644 --- a/cli/src/commands/config/unset/index.ts +++ b/cli/src/commands/config/unset/index.ts @@ -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', ] diff --git a/cli/src/commands/create/member/index.ts b/cli/src/commands/create/member/index.ts index 7140c3a133..6a96a15fab 100644 --- a/cli/src/commands/create/member/index.ts +++ b/cli/src/commands/create/member/index.ts @@ -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', diff --git a/cli/src/commands/delete/member/index.ts b/cli/src/commands/delete/member/index.ts index 426738c87c..9f916f2776 100644 --- a/cli/src/commands/delete/member/index.ts +++ b/cli/src/commands/delete/member/index.ts @@ -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', diff --git a/cli/src/commands/describe/app/guide.ts b/cli/src/commands/describe/app/guide.ts new file mode 100644 index 0000000000..6d29e2ef09 --- /dev/null +++ b/cli/src/commands/describe/app/guide.ts @@ -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 -o json + difyctl describe app -o json | jq '.info.mode' + +NEXT + Feed the discovered inputs to run: + difyctl run app --inputs '{"key":"value"}' -o json + +ERROR RECOVERY + app not found (404) difyctl get app + not logged in (exit 4) difyctl auth login +` diff --git a/cli/src/commands/describe/app/index.ts b/cli/src/commands/describe/app/index.ts index ea9500cec1..49ac206f7c 100644 --- a/cli/src/commands/describe/app/index.ts +++ b/cli/src/commands/describe/app/index.ts @@ -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 + } } diff --git a/cli/src/commands/get/app/guide.ts b/cli/src/commands/get/app/guide.ts new file mode 100644 index 0000000000..fb0b1e31c7 --- /dev/null +++ b/cli/src/commands/get/app/guide.ts @@ -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 -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 ' for the full + input schema. + +ERROR RECOVERY + not logged in (exit 4) difyctl auth login + empty list wrong workspace — try -A or --workspace +` diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts index aac6e83696..9b60ab8e95 100644 --- a/cli/src/commands/get/app/index.ts +++ b/cli/src/commands/get/app/index.ts @@ -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 + } } diff --git a/cli/src/commands/help/account/account.test.ts b/cli/src/commands/help/account/account.test.ts deleted file mode 100644 index b9f93d67da..0000000000 --- a/cli/src/commands/help/account/account.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cli/src/commands/help/account/account.ts b/cli/src/commands/help/account/account.ts deleted file mode 100644 index ab5334682e..0000000000 --- a/cli/src/commands/help/account/account.ts +++ /dev/null @@ -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 - - 4. Run an app and capture structured output: - difyctl run app "hello" -o json - -Tips: - * Pass --workspace 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 ]' switches the active Dify instance. - * 'difyctl use account [--email ]' switches accounts on the current host. - * 'difyctl env list' shows every env var difyctl reads. -` - -export function runHelpAccount(): string { - return ACCOUNT_HELP_TEXT -} diff --git a/cli/src/commands/help/account/index.ts b/cli/src/commands/help/account/index.ts deleted file mode 100644 index fd60123092..0000000000 --- a/cli/src/commands/help/account/index.ts +++ /dev/null @@ -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()) - } -} diff --git a/cli/src/commands/help/environment/environment.test.ts b/cli/src/commands/help/environment/environment.test.ts deleted file mode 100644 index 20024b0075..0000000000 --- a/cli/src/commands/help/environment/environment.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/cli/src/commands/help/environment/environment.ts b/cli/src/commands/help/environment/environment.ts deleted file mode 100644 index e168294cf7..0000000000 --- a/cli/src/commands/help/environment/environment.ts +++ /dev/null @@ -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 -} diff --git a/cli/src/commands/help/environment/index.ts b/cli/src/commands/help/environment/index.ts deleted file mode 100644 index 8856dd7748..0000000000 --- a/cli/src/commands/help/environment/index.ts +++ /dev/null @@ -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()) - } -} diff --git a/cli/src/commands/help/external/external.test.ts b/cli/src/commands/help/external/external.test.ts deleted file mode 100644 index 31025c4c55..0000000000 --- a/cli/src/commands/help/external/external.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/cli/src/commands/help/external/external.ts b/cli/src/commands/help/external/external.ts deleted file mode 100644 index 19763a8b91..0000000000 --- a/cli/src/commands/help/external/external.ts +++ /dev/null @@ -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 "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 -} diff --git a/cli/src/commands/help/external/index.ts b/cli/src/commands/help/external/index.ts deleted file mode 100644 index 6b5a731102..0000000000 --- a/cli/src/commands/help/external/index.ts +++ /dev/null @@ -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()) - } -} diff --git a/cli/src/commands/resume/app/guide.ts b/cli/src/commands/resume/app/guide.ts new file mode 100644 index 0000000000..03b904f3d5 --- /dev/null +++ b/cli/src/commands/resume/app/guide.ts @@ -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 --workflow-run-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 +` diff --git a/cli/src/commands/resume/app/index.ts b/cli/src/commands/resume/app/index.ts index 3446c62824..596654181b 100644 --- a/cli/src/commands/resume/app/index.ts +++ b/cli/src/commands/resume/app/index.ts @@ -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 + } } diff --git a/cli/src/commands/run/app/guide.ts b/cli/src/commands/run/app/guide.ts index 186f9cc454..70433fb463 100644 --- a/cli/src/commands/run/app/guide.ts +++ b/cli/src/commands/run/app/guide.ts @@ -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 Resume a conversation (chat/advanced-chat only). - --workspace 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. diff --git a/cli/src/commands/run/app/index.ts b/cli/src/commands/run/app/index.ts index 3c72ea394f..16bd30c069 100644 --- a/cli/src/commands/run/app/index.ts +++ b/cli/src/commands/run/app/index.ts @@ -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 }), diff --git a/cli/src/commands/set/member/index.ts b/cli/src/commands/set/member/index.ts index 75e992d985..7d0ef20168 100644 --- a/cli/src/commands/set/member/index.ts +++ b/cli/src/commands/set/member/index.ts @@ -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', diff --git a/cli/src/commands/skills/install/index.test.ts b/cli/src/commands/skills/install/index.test.ts new file mode 100644 index 0000000000..03b0dc07b5 --- /dev/null +++ b/cli/src/commands/skills/install/index.test.ts @@ -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 { + 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) + }) +}) diff --git a/cli/src/commands/skills/install/index.ts b/cli/src/commands/skills/install/index.ts new file mode 100644 index 0000000000..dff3363fda --- /dev/null +++ b/cli/src/commands/skills/install/index.ts @@ -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 { + 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) + } +} diff --git a/cli/src/commands/skills/install/registry.test.ts b/cli/src/commands/skills/install/registry.test.ts new file mode 100644 index 0000000000..815d15e79a --- /dev/null +++ b/cli/src/commands/skills/install/registry.test.ts @@ -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') + }) +}) diff --git a/cli/src/commands/skills/install/registry.ts b/cli/src/commands/skills/install/registry.ts new file mode 100644 index 0000000000..0d0242514f --- /dev/null +++ b/cli/src/commands/skills/install/registry.ts @@ -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 `. +// +// `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))) +} diff --git a/cli/src/commands/skills/install/run.test.ts b/cli/src/commands/skills/install/run.test.ts new file mode 100644 index 0000000000..734ac357dd --- /dev/null +++ b/cli/src/commands/skills/install/run.test.ts @@ -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 => ({ + 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 ') + 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 to write only some') + expect(result.text).toContain('skills install ') + 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) + }) +}) diff --git a/cli/src/commands/skills/install/run.ts b/cli/src/commands/skills/install/run.ts new file mode 100644 index 0000000000..ddb9a992c0 --- /dev/null +++ b/cli/src/commands/skills/install/run.ts @@ -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 { + 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 `, or\n' + + 'print the skill with `difyctl skills install --stdout`.\n', + wrote: [], + } + } + + return detected.map(target) +} + +export async function runSkillsInstall(opts: SkillsInstallOptions): Promise { + 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 : 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 to write only some.' + : 'Re-run with --yes to write.' + const footer = `${pick}\nAgent not listed? Install into its directory with \`difyctl skills install \`.` + 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 } +} diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index 6e85da428a..966fd5b56d 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -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: {} }, diff --git a/cli/src/commands/use/workspace/index.ts b/cli/src/commands/use/workspace/index.ts index 1f882d10b7..805d765423 100644 --- a/cli/src/commands/use/workspace/index.ts +++ b/cli/src/commands/use/workspace/index.ts @@ -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', ] diff --git a/cli/src/framework/command.ts b/cli/src/framework/command.ts index 1b38df0074..44bec49990 100644 --- a/cli/src/framework/command.ts +++ b/cli/src/framework/command.ts @@ -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['args'] extends Record> @@ -32,6 +38,7 @@ export abstract class Command implements ICommand { static args: Record> = {} static examples: string[] = [] + static effect: CommandEffect = 'read' abstract run(argv: string[]): Promise diff --git a/cli/src/framework/flags.ts b/cli/src/framework/flags.ts index 7ff0590e1a..0924d76c1e 100644 --- a/cli/src/framework/flags.ts +++ b/cli/src/framework/flags.ts @@ -16,6 +16,7 @@ const GLOBAL_FLAGS: Record = { [VERBOSE_FLAG]: Flags.boolean({ char: VERBOSE_CHAR, description: 'enable verbose output', + helpGroup: 'GLOBAL', }), } @@ -56,7 +57,7 @@ function stringRepeatedFlag { +function booleanFlag(opts: { description: string, char?: string, default?: boolean, helpGroup?: 'GLOBAL' }): FlagDefinition { return { type: 'boolean', ...opts } } diff --git a/cli/src/framework/help.test.ts b/cli/src/framework/help.test.ts index ca809d6168..6d2dd3b176 100644 --- a/cli/src/framework/help.test.ts +++ b/cli/src/framework/help.test.ts @@ -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 ') + expect(out).not.toContain('--output, ') + }) +}) + +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') + }) }) diff --git a/cli/src/framework/help.ts b/cli/src/framework/help.ts index 2de5bcc6a1..47af137d19 100644 --- a/cli/src/framework/help.ts +++ b/cli/src/framework/help.ts @@ -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 --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 ` --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 ` --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 "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} [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} --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) +} diff --git a/cli/src/framework/registry.ts b/cli/src/framework/registry.ts index 4e64686ebe..a2249788e9 100644 --- a/cli/src/framework/registry.ts +++ b/cli/src/framework/registry.ts @@ -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[] = [] diff --git a/cli/src/framework/run.test.ts b/cli/src/framework/run.test.ts index 0015913ec8..c41913961f 100644 --- a/cli/src/framework/run.test.ts +++ b/cli/src/framework/run.test.ts @@ -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 `', 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 `', 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 -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 ` --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 ` --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 ` --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') + }) +}) diff --git a/cli/src/framework/run.ts b/cli/src/framework/run.ts index c078247241..5682a9e188 100644 --- a/cli/src/framework/run.ts +++ b/cli/src/framework/run.ts @@ -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 { 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' } diff --git a/cli/src/framework/types.ts b/cli/src/framework/types.ts index 0a7672a89c..242fc280a1 100644 --- a/cli/src/framework/types.ts +++ b/cli/src/framework/types.ts @@ -10,6 +10,9 @@ export type FlagDefinition> + 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> = { + [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 { + const out: Record = {} + 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 --workflow-run-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 ', description: `Output format: ${CONTRACT.outputFormats.join('|')}` }, + { label: '-v, --verbose', description: 'Enable verbose logging' }, + { label: '--http-retry ', description: 'Retry idempotent GET/PUT/DELETE on transient errors (0 disables)' }, +] diff --git a/cli/src/help/skill-template.ts b/cli/src/help/skill-template.ts new file mode 100644 index 0000000000..1038a8f99e --- /dev/null +++ b/cli/src/help/skill-template.ts @@ -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. +` diff --git a/cli/src/help/skill.test.ts b/cli/src/help/skill.test.ts new file mode 100644 index 0000000000..7ab087e982 --- /dev/null +++ b/cli/src/help/skill.test.ts @@ -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') + }) +}) diff --git a/cli/src/help/skill.ts b/cli/src/help/skill.ts new file mode 100644 index 0000000000..be35d392de --- /dev/null +++ b/cli/src/help/skill.ts @@ -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) +} diff --git a/cli/src/help/topics.test.ts b/cli/src/help/topics.test.ts new file mode 100644 index 0000000000..f5b2d1843e --- /dev/null +++ b/cli/src/help/topics.test.ts @@ -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) + }) +}) diff --git a/cli/src/help/topics.ts b/cli/src/help/topics.ts new file mode 100644 index 0000000000..3da5c0f8df --- /dev/null +++ b/cli/src/help/topics.ts @@ -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 + + 4. Run an app and capture structured output: + difyctl run app "hello" -o json + +Tips: + * 'difyctl auth list' shows your authenticated contexts; 'difyctl use host' + and 'difyctl use account' switch between them. + * Pass --workspace 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 "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 one app's mode and input schema + +AUTH + Interactive: difyctl auth login (browser device flow) + Non-interactive: export DIFY_TOKEN= (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 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) +}