feat(cli): let agents build Dify apps with difyctl and an embedded skill

- the difyctl skill (skills/difyctl) ships inside the binary; `difyctl install
  skills <dir>` writes it, installers and help point agents at it, and a test
  lints every command and flag the docs name
- login needs --server, agents log in with --no-wait then --resume,
  not_logged_in names the config folder, and Linux without a DBus session
  keeps the token in a file
- help prints examples as real commands with <arg> and --flag names, input
  errors name unknown fields, and errors print the server's next steps
- CI runs the built binary and checks the skill it installs matches the tree
This commit is contained in:
GareArc
2026-10-16 00:51:38 +00:00
committed by GitHub
parent 8c5eb6d16e
commit 5a821dbfac
69 changed files with 2333 additions and 641 deletions
+5 -1
View File
@@ -57,7 +57,11 @@ jobs:
run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh
- name: Smoke the runner-arch binary
run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version
run: |
bin="./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64"
"$bin" version
"$bin" install skills "$RUNNER_TEMP/skill"
diff -r "$RUNNER_TEMP/skill/difyctl" ../skills/difyctl
- name: Publish to R2
env:
+7
View File
@@ -121,6 +121,13 @@ jobs:
- name: Generate sha256 checksum file
run: scripts/release-write-checksums.sh
- name: Smoke the runner-arch binary
run: |
bin="./dist/bin/$(node scripts/release-naming.mjs asset "$version" linux-x64)"
"$bin" version
"$bin" install skills "$RUNNER_TEMP/skill"
diff -r "$RUNNER_TEMP/skill/difyctl" ../skills/difyctl
- name: Attach difyctl assets to Dify release
if: ${{ !inputs.dry_run }}
env:
+1 -1
View File
@@ -1 +1 @@
1.3.11
1.4.2
+1 -1
View File
@@ -25,7 +25,7 @@ src/
net/ fetch init and proxy dispatcher, shared by the http plugin and the pre-login device flow
store/ config/session/token file and keychain persistence
sys/ the process edges — env, argv, exit, platform paths; io/ holds streams.ts, color.ts and view.ts (a JSON value paired with its text form, the mode picks one)
skills/ reads skills from a GitHub folder or a local folder, and installs them
skills/ the one embedded difyctl skill (or a local folder) and its installer
```
---
+17 -20
View File
@@ -45,21 +45,20 @@ difyctl has no built-in business commands. Every operation the server publishes
## Commands
| Command | Purpose |
| -------------------------- | --------------------------------------------------------------- |
| `login` | Log in via the OAuth device flow |
| `logout` | Log out of the current session |
| `version` | Print the client and (if logged in) server versions |
| `use workspace <id>` | Pin the local session to a workspace |
| `get config [key]` | Print the local config, or one key |
| `set config <key> <value>` | Set a local config value |
| `unset config <key>` | Remove a local config value, restoring its default |
| `refresh cache` | Refetch the server catalog and replace the local cache |
| `delete cache` | Delete the local server catalog cache |
| `get skills` | List the skills in the collection |
| `install skills <dir>` | Write skills from the collection into a skills root (`--skill`) |
| Command | Purpose |
| -------------------------- | ------------------------------------------------------ |
| `login` | Log in via the OAuth device flow |
| `logout` | Log out of the current session |
| `version` | Print the client and (if logged in) server versions |
| `use workspace <id>` | Pin the local session to a workspace |
| `get config [key]` | Print the local config, or one key |
| `set config <key> <value>` | Set a local config value |
| `unset config <key>` | Remove a local config value, restoring its default |
| `refresh cache` | Refetch the server catalog and replace the local cache |
| `delete cache` | Delete the local server catalog cache |
| `install skills <dir>` | Write the difyctl skill into a skills root (`--from`) |
`login` blocks until the browser approval arrives. An agent runs it in the background, relays the `open <url>` and `code <code>` lines from its stderr to the user, and does not cancel the job. In a sandbox it adds `--no-keyring` and sets `DIFY_CONFIG_DIR` to persistent storage on every call, so the login survives the next session.
`login` needs `--server`; there is no default server. It blocks until the browser approval arrives. An agent uses `--no-wait` instead: it gives the user the URL and code, then runs `login --resume` after the user approves. If it sets `DIFY_CONFIG_DIR`, it sets it on every call.
`--verbose` is global: it may appear on any command, and it keeps the raw server
response in the error envelope instead of dropping it.
@@ -68,11 +67,9 @@ response in the error envelope instead of dropping it.
## Agent skills
difyctl installs agent skills from [`skills/`] at the repo root. It downloads them from GitHub at install time, from the commit the binary was built from; the binary holds no copy and needs no git. The basic skill, `difyctl`, teaches the discovery flow — `help` to see the map or search, `help <id>` to inspect one operation, then the operation's own command to run it — so it stays correct as the server's catalog grows. Scenario skills add guidance for one kind of task and open by reading the basic one.
difyctl ships one skill, `difyctl`, built into the binary. It teaches the discovery flow — `help` to see the map or search, `help <id>` to inspect one operation, then the operation's own command to run it — so it stays correct as the server's catalog grows. The skill is a tree of linked docs under [`skills/difyctl/`].
- `difyctl get skills` — the collection: each skill's name and description. Writes nothing.
- `difyctl install skills <dir>` — write every skill into `<dir>`, the agent's skills root: the folder that holds one subfolder per skill, for example `~/.claude/skills` for Claude Code or `~/.codex/skills` for Codex. Each skill's folder is replaced whole. `--skill <name>` (repeatable) writes only those skills; a scenario skill brings `difyctl` along.
- `--from` on both reads skills from elsewhere: any GitHub folder in the standard layout (`https://github.com/<owner>/<repo>/tree/<ref>/<folder>`), or a local folder when GitHub is blocked. Set `GITHUB_TOKEN` if GitHub's rate limit (60 requests an hour per IP) gets in the way.
- `difyctl install skills <dir> [--from <folder>]` — write the skill into `<dir>`, the agent's skills root: the folder that holds one subfolder per skill, for example `~/.claude/skills` for Claude Code or `~/.codex/skills` for Codex. The `difyctl` folder is replaced whole. It works offline. `--from` installs a local skill folder instead of the built-in one.
difyctl does not detect agents. You name the root, and re-running the same command refreshes the copies.
@@ -88,7 +85,7 @@ The same files install through the Vercel skills installer: `npx skills add lang
`get config [key]` prints the whole local config or one key; `set config <key> <value>` and `unset config <key>` change it. The only config key today is `http.timeout` (request timeout in milliseconds).
Tokens use the OS keychain by default, falling back to `tokens.yml` on hosts without one; `login --no-keyring` chooses the file outright. Config and cache files are written `0600`, their directory `0700`.
Tokens use the OS keychain by default, falling back to `tokens.yml` on hosts without one. On Linux without a DBus session (`DBUS_SESSION_BUS_ADDRESS` unset), the keychain would only hold the token until the session ends, so difyctl uses `tokens.yml` there too; `login --no-keyring` chooses the file outright. Config and cache files are written `0600`, their directory `0700`.
| Env var | Effect |
| ------------------- | ----------------------------------------------------------- |
@@ -135,4 +132,4 @@ Apache-2.0.
[Agent skill]: #agent-skills
[Dify]: https://dify.ai
[`ARD.md`]: ARD.md
[`skills/`]: ../skills
[`skills/difyctl/`]: ../skills/difyctl
+10 -3
View File
@@ -28,6 +28,10 @@ err() { printf '%s\n' "install-cli: $*" >&2; }
die() { err "$*"; rm -f "$FETCH_ERR_FILE"; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; }
re_escape() { printf '%s' "$1" | sed 's/[][\\.^$*+?(){}|/]/\\&/g'; }
print_skill_hint() {
printf '%s\n' 'AI agents: run `difyctl install skills <your skills root>` to load the difyctl skill.'
printf '%s\n' 'Run it again after each difyctl upgrade; the skill ships inside the binary.'
}
detect_target() {
case "$(uname -s)" in
@@ -229,15 +233,18 @@ main() {
case ":${PATH}:" in
*":${bin_dir}:"*)
"$target_bin" version >/dev/null 2>&1 \
&& printf 'verify: run "difyctl version"\n' \
|| err "binary present but failed to execute; check ${target_bin}"
if ! "$target_bin" version >/dev/null 2>&1; then
err "binary present but failed to execute; check ${target_bin}"
return 0
fi
printf 'verify: run "difyctl version"\n'
;;
*)
printf '\n%s is not on your PATH. Add this to your shell profile:\n' "$bin_dir"
printf ' export PATH="%s:$PATH"\n' "$bin_dir"
;;
esac
print_skill_hint
}
if [ "${DIFYCTL_INSTALL_LIB:-0}" != "1" ]; then
+6
View File
@@ -38,6 +38,11 @@ function Get-ChecksumTarget([string]$text, [string]$target) {
return $null
}
function Write-SkillHint {
Write-Host 'AI agents: run `difyctl install skills <your skills root>` to load the difyctl skill.'
Write-Host 'Run it again after each difyctl upgrade; the skill ships inside the binary.'
}
# Download, sha256-verify, place. Returns nothing; throws on mismatch.
function Install-DifyctlBinary([string]$dlUrl, [string]$sha, [string]$version, [string]$channel, [string]$installDir) {
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetFileName($dlUrl))
@@ -55,6 +60,7 @@ function Install-DifyctlBinary([string]$dlUrl, [string]$sha, [string]$version, [
Write-Host "$installDir is not on your PATH. Add it with:"
Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$installDir;`$env:PATH`", 'User')"
}
Write-SkillHint
}
function Install-DifyctlR2 {
+5
View File
@@ -21,6 +21,10 @@ trap 'rm -f "$tmp_m" "${tmp_c:-}" "${tmp_b:-}"' EXIT INT TERM
err() { printf '%s\n' "install-r2: $*" >&2; }
die() { err "$*"; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; }
print_skill_hint() {
printf '%s\n' 'AI agents: run `difyctl install skills <your skills root>` to load the difyctl skill.'
printf '%s\n' 'Run it again after each difyctl upgrade; the skill ships inside the binary.'
}
detect_target() {
case "$(uname -s)" in
@@ -98,6 +102,7 @@ fetch_verify_install() {
*":${install_dir}:"*) ;;
*) printf 'note: add %s to your PATH\n' "$install_dir" ;;
esac
print_skill_hint
}
# Resolve a pinned build into download url + sha. Sets: version, dl_url, dl_sha.
+6
View File
@@ -115,6 +115,11 @@ function Resolve-Release {
}
}
function Write-SkillHint {
Write-Host 'AI agents: run `difyctl install skills <your skills root>` to load the difyctl skill.'
Write-Host 'Run it again after each difyctl upgrade; the skill ships inside the binary.'
}
function Invoke-Main {
$release = Resolve-Release
$difyTag = $release.tag_name
@@ -158,6 +163,7 @@ function Invoke-Main {
else {
Write-Host 'verify: run "difyctl version"'
}
Write-SkillHint
}
finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
+7 -2
View File
@@ -41,6 +41,10 @@ DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
[[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json"
repo_root="$(cd "${cli_root}/.." && pwd)"
skill_dir="${repo_root}/skills/difyctl"
[[ -f "${skill_dir}/SKILL.md" ]] || die "skill not found: ${skill_dir}"
[[ -f "$entry" ]] || die "entry not found: $entry"
rm -rf "$out_dir"
@@ -60,12 +64,13 @@ defines=(
while IFS=$'\t' read -r bun_target asset_target _exe; do
out="${out_dir}/$(naming asset "$CLI_VERSION" "$asset_target")"
log::info "compiling ${asset_target} -> $(basename "$out")..."
bun build "$entry" \
(cd "$repo_root" && bun build "$entry" \
--asset "$skill_dir" \
--target="$bun_target" \
--compile \
--minify \
"${defines[@]}" \
--outfile="$out" >/dev/null
--outfile="$out") >/dev/null
done < <(naming targets)
log::info "built $(find "$out_dir" -type f | wc -l | tr -d ' ') binaries:"
+10
View File
@@ -1,16 +1,26 @@
import type { CommandContext } from '@/plugins/base'
import type { Login } from '@/plugins/session'
import { join } from 'node:path'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { errorMessage } from '@/errors/message'
import { catalog } from '@/plugins/catalog'
import { env } from '@/plugins/env'
import { http } from '@/plugins/http'
import { io } from '@/plugins/io'
import { session } from '@/plugins/session'
import { token } from '@/plugins/token'
import { YamlStore } from '@/store/store'
const SESSIONS_SELF_PATH = '/openapi/v1/account/sessions/self'
const REVOKE_FAILED_PREFIX = 'server revoke failed: '
export const PENDING_FILE_NAME = 'login-pending.yml'
/** The device code a `login --no-wait` saved for `login --resume` to finish. */
export async function pendingLoginStore(ctx: CommandContext): Promise<YamlStore> {
const { configDir } = await ctx.get(env)
return new YamlStore(join(configDir, PENDING_FILE_NAME))
}
/** Throws when the current login comes from DIFY_SERVER/DIFY_TOKEN, which neither
* login nor logout may touch — each passes its own explanation. */
+11 -17
View File
@@ -7,22 +7,23 @@ import { unsupportedFlags } from '@/call/flags'
import { applyPins, loadInput, resolveFileRefs } from '@/call/input'
import { rendererFor } from '@/call/render/index'
import { buildRequest } from '@/call/request'
import { validateInput } from '@/call/validate'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { runSignal } from '@/plugins/commands/cancel'
import { helpHint, Outcome } from '@/plugins/commands/command'
import { assertValidInput, Outcome } from '@/plugins/commands/command'
import { http } from '@/plugins/http'
import { io } from '@/plugins/io'
import { session } from '@/plugins/session'
import { KIND } from '@/protocol/kinds'
import { spacedId } from '@/protocol/op-id'
import { commandWords } from '@/protocol/op-id'
import { PIN } from '@/protocol/pins'
import { flagToken } from '@/util/flag-name'
/** One catalog operation, its call flags, and the fields typed as flags of their own. */
export type CallRequest = Readonly<{
id: string
op: CatalogOp
positional: readonly string[]
flags: CallFlags
fields: Record<string, unknown>
}>
@@ -36,7 +37,7 @@ function assertSupportedFlags(kind: string, flags: CallFlags): void {
if (refused !== undefined) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `--${refused} is not supported by ${kind} operations`,
message: `${flagToken(refused)} is not supported by ${kind} operations`,
})
}
// --only filters the events --stream emits; on its own it filters nothing.
@@ -51,18 +52,6 @@ async function readAll(stream: NodeJS.ReadableStream): Promise<string> {
return text
}
function assertValidInput(op: CatalogOp, opId: string, input: Record<string, unknown>): void {
const details = validateInput(op.input, input)
if (details.length === 0) return
throw new BaseError({
code: ErrorCode.InputInvalid,
message: `invalid input for "${opId}"`,
hint: helpHint(spacedId(opId)),
details: [...details],
schema: op.input,
})
}
export async function executeCall(req: CallRequest, ctx: CommandContext): Promise<Outcome> {
assertSupportedFlags(req.op.kind, req.flags)
@@ -75,7 +64,12 @@ export async function executeCall(req: CallRequest, ctx: CommandContext): Promis
const fields = await resolveFileRefs(req.fields, req.op.input, source)
const workspaceId = await (await ctx.get(session)).workspaceId()
const body = applyPins({ ...whole, ...fields }, req.op.input, { [PIN.Workspace]: workspaceId })
assertValidInput(req.op, req.id, body)
assertValidInput({
command: commandWords(req.id),
schema: req.op.input,
positional: req.positional,
input: body,
})
if (req.op.deprecated) out.notice(`${DEPRECATED_NOTICE}: ${req.id}`)
+4 -3
View File
@@ -1,7 +1,9 @@
import type { JsonSchema } from '@/plugins/catalog'
import { inputInvalid } from '@/call/errors'
import { CALL_FLAG } from '@/call/flags'
import { errorMessage } from '@/errors/message'
import { isScalar, propertiesOf, shapeOf } from '@/protocol/shape'
import { flagToken } from '@/util/flag-name'
import { isRecord } from '@/util/is-record'
export type InputSource = {
@@ -18,8 +20,7 @@ type Referenced = Readonly<{ text: string; source: string }>
const FILE_PREFIX = '@'
const STDIN_MARKER = '-'
const STDIN_SOURCE = `${FILE_PREFIX}${STDIN_MARKER}`
const FLAG_PREFIX = '--'
const INLINE_SOURCE = '--input'
const INLINE_SOURCE = flagToken(CALL_FLAG.Input)
const STDIN_LABEL = 'stdin'
export function parseJsonValue(raw: string, source: string): unknown {
@@ -69,7 +70,7 @@ export async function resolveFileRefs(
if (typeof value !== 'string' || !value.startsWith(FILE_PREFIX)) continue
if (isScalar(shapeOf(property))) continue
const { text } = await readRef(value, src)
resolved[name] = parseJsonValue(text, `${FLAG_PREFIX}${name}`)
resolved[name] = parseJsonValue(text, flagToken(name))
}
return resolved
}
+11 -8
View File
@@ -21,17 +21,20 @@ function compile(schema: JsonSchema): ValidateFunction {
return validate
}
function locFor(error: ErrorObject): (string | number)[] {
const loc = error.instancePath.split('/').filter(Boolean)
if (error.keyword === 'required') {
const missingProperty = (error.params as { missingProperty?: string }).missingProperty
if (missingProperty !== undefined) loc.push(missingProperty)
}
return loc
const INSTANCE_PATH_SEPARATOR = '/'
const READINGS: Readonly<Record<string, Readonly<{ param: string; msg: string }>>> = {
required: { param: 'missingProperty', msg: 'required' },
additionalProperties: { param: 'additionalProperty', msg: 'unknown field' },
}
function toDetail(error: ErrorObject): ValidationDetail {
return { type: error.keyword, loc: locFor(error), msg: error.message ?? error.keyword }
const reading = READINGS[error.keyword]
const loc: (string | number)[] = error.instancePath.split(INSTANCE_PATH_SEPARATOR).filter(Boolean)
const named =
reading === undefined ? undefined : (error.params as Record<string, unknown>)[reading.param]
if (typeof named === 'string') loc.push(named)
return { type: error.keyword, loc, msg: reading?.msg ?? error.message ?? error.keyword }
}
export function validateInput(schema: JsonSchema, input: unknown): readonly ValidationDetail[] {
-16
View File
@@ -1,16 +0,0 @@
import { z } from 'zod'
import { Command } from '@/plugins/commands/command'
import { loadSkills } from '@/skills/collection'
import { FROM_FIELD, openSource } from '@/skills/source'
const INPUT = z.object({ from: FROM_FIELD })
export default class SkillsList extends Command<typeof INPUT> {
static override summary = 'List the skills in the collection'
static override input = INPUT
async run(input: z.infer<typeof INPUT>) {
const skills = await loadSkills(await openSource(input.from))
return { skills: skills.map(({ name, description }) => ({ name, description })) }
}
}
@@ -1,23 +1,20 @@
import type { TestWorld } from '@test/fixtures/kernel'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { testContext } from '@test/fixtures/kernel'
import { afterEach, expect, it } from 'vite-plus/test'
import { commands } from '@/plugins/commands'
const REPO_SKILLS = fileURLToPath(new URL('../../../../../skills', import.meta.url))
const worlds: TestWorld[] = []
afterEach(async () => {
for (const w of worlds.splice(0)) await w.stop()
})
it('writes the repo skills into the given skills root', async () => {
it('writes the difyctl skill into the given skills root', async () => {
const seed = await testContext({ login: false })
worlds.push(seed)
const dir = join(seed.dir, 'target')
const argv = ['install', 'skills', dir, '--from', REPO_SKILLS]
const argv = ['install', 'skills', dir]
const w = await testContext({ login: false, argv, reuseDirOf: seed })
worlds.push(w)
expect(await (await w.ctx.get(commands)).run()).toBe(0)
+4 -12
View File
@@ -1,8 +1,7 @@
import { resolve } from 'node:path'
import { z } from 'zod'
import { Command } from '@/plugins/commands/command'
import { loadSkills } from '@/skills/collection'
import { installSkills, selectSkills } from '@/skills/install'
import { installSkill } from '@/skills/install'
import { FROM_FIELD, openSource } from '@/skills/source'
const INPUT = z.object({
@@ -10,26 +9,19 @@ const INPUT = z.object({
.string()
.min(1)
.describe("The agent's skills root: the folder that holds one subfolder per skill"),
skill: z
.array(z.string())
.default([])
.describe('Skill to install (repeatable); default: the whole collection'),
from: FROM_FIELD,
})
export default class SkillsInstall extends Command<typeof INPUT> {
static override summary = 'Write skills from the collection into a skills root'
static override summary = 'Write the difyctl skill into a skills root'
static override effect = 'write' as const
static override input = INPUT
static override positional = ['dir'] as const
static override examples = [
{ title: 'Install every skill for Claude Code', input: { dir: '~/.claude/skills' } },
{ title: 'Install one skill for Codex', input: { dir: '~/.codex/skills', skill: ['difyctl'] } },
{ title: 'Install the skill for Claude Code', input: { dir: '~/.claude/skills' } },
]
async run(input: z.infer<typeof INPUT>) {
const source = await openSource(input.from)
const skills = selectSkills(await loadSkills(source), input.skill)
return { wrote: await installSkills(resolve(input.dir), skills, source) }
return { wrote: await installSkill(resolve(input.dir), await openSource(input.from)) }
}
}
+82
View File
@@ -1,6 +1,10 @@
import type { TestWorld } from '@test/fixtures/kernel'
import { existsSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { testContext } from '@test/fixtures/kernel'
import { afterEach, expect, it } from 'vite-plus/test'
import { PENDING_FILE_NAME } from '@/auth/logout'
import { Context } from '@/kernel/context'
import { commands } from '@/plugins/commands'
import { session } from '@/plugins/session'
import { token } from '@/plugins/token'
@@ -95,3 +99,81 @@ it('falls back to the external-SSO subject email when there is no account', asyn
expect(login?.email).toBe('sso@dify.ai')
expect(login?.account).toBeNull()
})
it('resume with no pending login reports the saved login', async () => {
const w = await testContext({ login: true, argv: ['login', '--resume'] })
worlds.push(w)
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(JSON.parse(w.io.outBuf())).toMatchObject({
server: w.mock.url,
email: 'me@x',
workspace_id: 'ws-1',
})
})
it('resume with no pending login and no saved login is not_logged_in', async () => {
const w = await testContext({ login: false, argv: ['login', '--resume'] })
worlds.push(w)
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'not_logged_in',
message: `not logged in: no login saved in ${w.dir}; DIFY_CONFIG_DIR picks this folder`,
})
})
it('resume with no pending login and the token gone is not_logged_in', async () => {
const w = await testContext({ login: true, argv: ['login', '--resume'] })
worlds.push(w)
const fresh = new Context()
const login = await (await fresh.get(session)).require()
await (await fresh.get(token)).remove(login)
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({ code: 'not_logged_in' })
})
it('resume under an env login reports the env login', async () => {
const w = await testContext({ login: false, env: true, argv: ['login', '--resume'] })
worlds.push(w)
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(JSON.parse(w.io.outBuf())).toMatchObject({ server: w.mock.url, email: 'env' })
})
it('a new login drops a pending --no-wait login', async () => {
const seed = await testContext({ login: false })
worlds.push(seed)
const pendingFile = join(seed.dir, PENDING_FILE_NAME)
writeFileSync(
pendingFile,
`server: ${seed.mock.url}\ninsecure: true\nno_keyring: true\ndevice_code: stale\n`,
)
const w = await testContext({
login: false,
argv: ['login', '--server', seed.mock.url, '--no-browser', '--no-keyring', '--insecure'],
reuseDirOf: seed,
})
worlds.push(w)
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(existsSync(pendingFile)).toBe(false)
})
it.each([[[]], [['--server', ' ']]])(
'refuses to start without a server, keeping a pending login',
async (flags) => {
const seed = await testContext({ login: false })
worlds.push(seed)
const pendingPath = join(seed.dir, PENDING_FILE_NAME)
writeFileSync(
pendingPath,
`server: ${seed.mock.url}\ninsecure: true\nno_keyring: true\ndevice_code: kept\n`,
)
const w = await testContext({
login: false,
argv: ['login', '--no-wait', ...flags],
reuseDirOf: seed,
})
worlds.push(w)
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'usage_missing_arg',
hint: 'ask the user which server; Dify Cloud is https://cloud.dify.ai',
})
expect(existsSync(pendingPath)).toBe(true)
},
)
+42 -25
View File
@@ -1,24 +1,25 @@
import type { PollSuccess } from '@/auth/device-api'
import type { CommandContext } from '@/plugins/base'
import type { YamlStore } from '@/store/store'
import { hostname } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
import { deviceApi } from '@/auth/device-api'
import { awaitAuthorization, pollAuthorization, realClock } from '@/auth/device-flow'
import { assertNotEnvLogin, revokeAndClearSession } from '@/auth/logout'
import { assertNotEnvLogin, pendingLoginStore, revokeAndClearSession } from '@/auth/logout'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { Command } from '@/plugins/commands/command'
import { env } from '@/plugins/env'
import { io } from '@/plugins/io'
import { session } from '@/plugins/session'
import { token } from '@/plugins/token'
import { YamlStore } from '@/store/store'
import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser'
import { DEFAULT_HOST, resolveHost, validateVerificationURI } from '@/util/host'
import { resolveHost, validateVerificationURI } from '@/util/host'
const INPUT = z.object({
server: z.string().default(DEFAULT_HOST).describe('Dify server base URL'),
server: z
.string()
.optional()
.describe('Dify server base URL. Required to start a login; there is no default'),
no_browser: z
.boolean()
.default(false)
@@ -41,11 +42,10 @@ const INPUT = z.object({
.boolean()
.default(false)
.describe(
'Check a login started with --no-wait once. Prints status pending until the user approves',
'Check a login started with --no-wait once. Prints status pending until the user approves. With no pending login, prints the saved login, or fails with not_logged_in',
),
})
const PENDING_FILE_NAME = 'login-pending.yml'
const PENDING_STATUS = 'pending'
const PENDING_SCHEMA = z.object({
server: z.string(),
@@ -54,11 +54,12 @@ const PENDING_SCHEMA = z.object({
device_code: z.string(),
})
type Pending = z.infer<typeof PENDING_SCHEMA>
const NO_PENDING_MESSAGE = 'no pending login; start one with login --no-wait'
const FINAL_POLL_ERRORS: readonly string[] = [ErrorCode.AuthExpired, ErrorCode.AccessDenied]
const ENV_LOGIN_MESSAGE = 'unset DIFY_TOKEN to log in interactively'
const NO_EMAIL_MESSAGE = 'login response carries no account or subject email'
const NO_SERVER_MESSAGE = 'pass --server <url>: the Dify server to log in to'
const NO_SERVER_HINT = 'ask the user which server; Dify Cloud is https://cloud.dify.ai'
// A present-but-empty email is the same as absent: neither account.email nor
// subject_email is meaningful until it has a value.
@@ -68,35 +69,44 @@ function meaningfulEmail(value: string | undefined): string | undefined {
export default class Login extends Command<typeof INPUT> {
static override summary =
'Log in via the OAuth device flow. Blocks until approval: run it in the background, never cancel it'
'Log in via the OAuth device flow. Agents: use --no-wait, then --resume after the user approves'
static override effect = 'write' as const
static override input = INPUT
static override examples = [
{
title: 'Agent: run in the background, relay the url and code to the user, do not cancel',
input: { server: 'https://dify.example.com', no_browser: true },
},
{
title: 'Agent in a sandbox: add --no-keyring and point DIFY_CONFIG_DIR at persistent storage',
input: { server: 'https://dify.example.com', no_browser: true, no_keyring: true },
},
{
title: 'Agent that cannot keep a process alive: start without waiting',
title: 'Agent: start without waiting, give the user the url and code, then end the turn',
input: { server: 'https://dify.example.com', no_browser: true, no_wait: true },
},
{
title: 'Then, after the user approves, finish the login (repeat while status is pending)',
title: 'Agent in a sandbox: add --no-keyring and point DIFY_CONFIG_DIR at persistent storage',
input: {
server: 'https://dify.example.com',
no_browser: true,
no_wait: true,
no_keyring: true,
},
},
{
title:
'Finish a --no-wait login once the user says they approved; with no pending login, reports the saved login or fails with not_logged_in',
input: { resume: true },
},
]
async run(input: z.infer<typeof INPUT>, ctx: CommandContext) {
const pendingStore = await pendingLoginStore(ctx)
if (input.resume) return resume(ctx, pendingStore)
if (input.server === undefined || input.server.trim() === '')
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: NO_SERVER_MESSAGE,
hint: NO_SERVER_HINT,
})
const sessionService = await ctx.get(session)
assertNotEnvLogin(sessionService.fromEnv, ENV_LOGIN_MESSAGE)
const { configDir } = await ctx.get(env)
const pendingStore = new YamlStore(join(configDir, PENDING_FILE_NAME))
if (input.resume) return resume(ctx, pendingStore)
await pendingStore.rm()
const server = resolveHost({ raw: input.server, insecure: input.insecure })
const streams = await ctx.get(io)
@@ -142,7 +152,14 @@ export default class Login extends Command<typeof INPUT> {
async function resume(ctx: CommandContext, pendingStore: YamlStore) {
const parsed = PENDING_SCHEMA.safeParse(await pendingStore.getTyped<unknown>())
if (!parsed.success) {
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: NO_PENDING_MESSAGE })
const current = await (await ctx.get(session)).require()
await (await ctx.get(token)).get()
return {
server: current.server,
email: current.email,
account: current.account,
workspace_id: current.workspaceId,
}
}
const pending = parsed.data
const api = deviceApi(pending.server, { insecure: pending.insecure })
+11 -1
View File
@@ -1,8 +1,9 @@
import type { TestWorld } from '@test/fixtures/kernel'
import { existsSync, readdirSync } from 'node:fs'
import { existsSync, readdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { testContext } from '@test/fixtures/kernel'
import { afterEach, expect, it } from 'vite-plus/test'
import { PENDING_FILE_NAME } from '@/auth/logout'
import { Context } from '@/kernel/context'
import { commands } from '@/plugins/commands'
import { session } from '@/plugins/session'
@@ -32,6 +33,15 @@ it('revokes the session on the server and clears local state', async () => {
})
})
it('drops a pending --no-wait login', async () => {
const w = await testContext({ login: true, argv: ['logout'] })
worlds.push(w)
const pendingFile = join(w.dir, PENDING_FILE_NAME)
writeFileSync(pendingFile, 'device_code: stale\n')
expect(await (await w.ctx.get(commands)).run()).toBe(0)
expect(existsSync(pendingFile)).toBe(false)
})
it('still clears local state and reports a failed server revoke', async () => {
const w = await testContext({ login: true, argv: ['logout'] })
worlds.push(w)
+2 -1
View File
@@ -1,6 +1,6 @@
import type { CommandContext } from '@/plugins/base'
import { z } from 'zod'
import { assertNotEnvLogin, revokeAndClearSession } from '@/auth/logout'
import { assertNotEnvLogin, pendingLoginStore, revokeAndClearSession } from '@/auth/logout'
import { Command } from '@/plugins/commands/command'
import { session } from '@/plugins/session'
@@ -18,6 +18,7 @@ export default class Logout extends Command<typeof INPUT> {
const login = await sessionService.require()
assertNotEnvLogin(sessionService.fromEnv, ENV_LOGOUT_MESSAGE)
await (await pendingLoginStore(ctx)).rm()
await revokeAndClearSession(ctx, login)
return { logged_out: true, server: login.server }
-2
View File
@@ -4,7 +4,6 @@
import type { CommandTree } from '@/plugins/commands/registry'
import DeleteCache from '@/commands/delete/cache/index'
import GetConfig from '@/commands/get/config/index'
import GetSkills from '@/commands/get/skills/index'
import InstallSkills from '@/commands/install/skills/index'
import Login from '@/commands/login/index'
import Logout from '@/commands/logout/index'
@@ -23,7 +22,6 @@ export const commandTree: CommandTree = {
get: {
subcommands: {
config: { command: GetConfig, subcommands: {} },
skills: { command: GetSkills, subcommands: {} },
},
},
install: {
+6 -2
View File
@@ -1,4 +1,5 @@
import type { ErrorCodeValue, ExitCodeValue } from './codes'
import type { Hint } from '@/protocol/fold'
import { BINARY } from '@/version/info'
import { ErrorCode, exitFor } from './codes'
@@ -6,6 +7,7 @@ export type ServerErrorDetail = {
readonly type: string
readonly loc?: (string | number)[]
readonly msg: string
readonly field?: string
}
export type ServerErrorBody = {
@@ -13,6 +15,7 @@ export type ServerErrorBody = {
readonly message: string
readonly status?: number
readonly hint?: string
readonly hints?: Hint[]
readonly details?: ServerErrorDetail[]
}
@@ -118,8 +121,9 @@ export function unknownError(message: string, cause?: unknown): BaseError {
export const LOGIN_HINT = `run ${BINARY} login`
export function notLoggedIn(): BaseError {
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint: LOGIN_HINT })
export function notLoggedIn(reason?: string): BaseError {
const message = reason === undefined ? 'not logged in' : `not logged in: ${reason}`
return new BaseError({ code: ErrorCode.NotLoggedIn, message, hint: LOGIN_HINT })
}
type HttpClientErrorOptions = BaseErrorOptions & {
+21
View File
@@ -43,3 +43,24 @@ it('drops schema in text and shows raw_response only when verbose', () => {
)
expect(renderEnvelope(env, plain, { verbose: true })).toMatch(/raw_response: Bearer \[redacted\]/)
})
it('renders each server hint as a next line with the op spelled as a command', () => {
const text = renderEnvelope(
{
error: {
code: 'dsl_invalid',
message: 'the DSL has errors',
server: {
message: 'the DSL has errors',
hints: [{ summary: 'See every problem', op: 'check.console_app.dsl', input: {} }],
},
},
},
plain,
{ verbose: false },
)
expect(text.split('\n')).toEqual([
'dsl_invalid: the DSL has errors',
'next: difyctl check console_app dsl — See every problem',
])
})
+16 -5
View File
@@ -1,6 +1,9 @@
import type { ErrorEnvelope } from './base'
import type { ErrorEnvelope, ServerErrorDetail } from './base'
import type { Hint } from '@/protocol/fold'
import type { Style } from '@/sys/io/color'
import { redactBearer } from '@/errors/sanitize'
import { spacedId } from '@/protocol/op-id'
import { BINARY } from '@/version/info'
const RAW_RESPONSE_HINT = 'run again with --verbose to see the raw server response'
@@ -15,17 +18,25 @@ function resolveHint(e: ErrorEnvelope['error'], opts: RenderOptions): string | u
return rawHiddenAndUnparsed ? RAW_RESPONSE_HINT : undefined
}
function detailLine(d: ServerErrorDetail): string {
if (d.field !== undefined) return `${d.field}: ${d.msg}`
const loc = (d.loc ?? []).join('.')
return `${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`
}
function nextLine(h: Hint, style: Style): string {
return `${style.magenta('next:')} ${style.cyan(`${BINARY} ${spacedId(h.op)}`)} — ${h.summary}`
}
export function renderEnvelope(env: ErrorEnvelope, style: Style, opts: RenderOptions): string {
const e = env.error
const server = e.server
const headerCode = server?.code ?? e.code
const lines: string[] = [`${headerCode}: ${e.message}`]
for (const d of e.details ?? server?.details ?? []) {
const loc = (d.loc ?? []).join('.')
lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`)
}
for (const d of e.details ?? server?.details ?? []) lines.push(` - ${detailLine(d)}`)
const hint = resolveHint(e, opts)
if (hint !== undefined) lines.push(`${style.magenta('hint:')} ${style.cyan(hint)}`)
for (const h of server?.hints ?? []) lines.push(nextLine(h, style))
if (e.method !== undefined && e.url !== undefined) lines.push(`request: ${e.method} ${e.url}`)
if (e.http_status !== undefined) lines.push(`http_status: ${e.http_status}`)
if (opts.verbose && e.raw_response) lines.push(`raw_response: ${redactBearer(e.raw_response)}`)
+32 -11
View File
@@ -9,6 +9,7 @@ import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { inputSchema } from '@/plugins/argv/parse'
import { COMMAND_SEPARATOR } from '@/protocol/op-id'
import { typedName } from '@/util/flag-name'
import { BINARY } from '@/version/info'
import { commandRow } from './describe'
@@ -23,6 +24,36 @@ export function helpHint(...words: readonly string[]): string {
return ['run', BINARY, HELP_WORD, ...words].join(COMMAND_SEPARATOR)
}
const LOC_SEPARATOR = '.'
type InputCheck = Readonly<{
command: readonly string[]
schema: JsonSchema
positional: readonly string[]
input: Record<string, unknown>
}>
function typedField(loc: readonly (string | number)[], positional: readonly string[]) {
const [head, ...rest] = loc
if (typeof head !== 'string') return undefined
return [typedName(head, positional), ...rest].join(LOC_SEPARATOR)
}
export function assertValidInput(check: InputCheck): void {
const details = validateInput(check.schema, check.input)
if (details.length === 0) return
throw new BaseError({
code: ErrorCode.InputInvalid,
message: `invalid input for "${check.command.join(COMMAND_SEPARATOR)}"`,
hint: helpHint(...check.command),
details: details.map((detail) => ({
...detail,
field: typedField(detail.loc, check.positional),
})),
schema: check.schema,
})
}
/** Returned by a command that already wrote its output or needs a non-zero exit. */
export class Outcome {
readonly code: ExitCodeValue
@@ -69,17 +100,7 @@ export abstract class Command<S extends z.ZodObject = z.ZodObject> {
input: Record<string, unknown>,
path: readonly string[],
): Record<string, unknown> {
const schema = this.schema()
const details = validateInput(schema, input)
if (details.length > 0) {
throw new BaseError({
code: ErrorCode.InputInvalid,
message: `invalid input for "${path.join(COMMAND_SEPARATOR)}"`,
hint: helpHint(...path),
details: [...details],
schema,
})
}
assertValidInput({ command: path, schema: this.schema(), positional: this.positional, input })
const parsed = this.input.safeParse(input)
if (!parsed.success) {
throw new BaseError({
+1 -1
View File
@@ -24,7 +24,7 @@ it('renders the row from the statics with a JSON Schema input', () => {
summary: 'Do a thing',
effect: 'write',
positional: ['id'],
examples: [{ title: 'Basic', input: { id: 'x' } }],
examples: [{ title: 'Basic', command: 'difyctl fake thing x' }],
})
expect(row.input).toMatchObject({
type: 'object',
+9 -5
View File
@@ -1,6 +1,9 @@
import type { CommandConstructor, CommandEffect } from './command'
import type { Example, JsonSchema } from '@/plugins/catalog'
import type { CommandExample } from './examples'
import type { JsonSchema } from '@/plugins/catalog'
import { argToken } from '@/util/flag-name'
import { BINARY } from '@/version/info'
import { commandExamples } from './examples'
export type CommandRow = {
id: string
@@ -9,7 +12,7 @@ export type CommandRow = {
effect?: CommandEffect
input: JsonSchema
positional: readonly string[]
examples: readonly Example[]
examples: readonly CommandExample[]
}
// The op side of a descriptor: present once a command wraps a catalog op instead of a
@@ -31,14 +34,15 @@ const FLAGS = '[flags]'
export function commandRow(ctor: CommandConstructor, path: readonly string[]): CommandRow {
const id = path.join(' ')
const args = ctor.positional.map((name) => `<${name}>`)
const args = ctor.positional.map(argToken)
const input = ctor.schema()
const row: CommandRow = {
id,
usage: [BINARY, id, ...args, FLAGS].join(' '),
summary: ctor.summary,
input: ctor.schema(),
input,
positional: ctor.positional,
examples: ctor.examples,
examples: commandExamples({ id, input, positional: ctor.positional }, ctor.examples),
}
return ctor.effect === undefined ? row : { ...row, effect: ctor.effect }
}
+66
View File
@@ -0,0 +1,66 @@
import type { Example, JsonSchema } from '@/plugins/catalog'
import { COMMAND_SEPARATOR } from '@/protocol/op-id'
import { isRepeatable, isScalar, propertiesOf, shapeOf } from '@/protocol/shape'
import { argToken, flagToken } from '@/util/flag-name'
import { shellWord } from '@/util/shell-word'
import { BINARY } from '@/version/info'
export type CommandExample = Readonly<{ title: string; command: string }>
type ExampleSource = Readonly<{ id: string; input: JsonSchema; positional: readonly string[] }>
const PLACEHOLDER = /^<[^<>]*>$/
const SKELETON_TITLE = 'Required fields'
// A placeholder stands for a value the caller fills in, whatever the field's shape.
function word(text: string): string {
return PLACEHOLDER.test(text) ? text : shellWord(text)
}
function valueToken(property: JsonSchema | undefined, value: unknown): string {
const scalar =
typeof value === 'string' || (property !== undefined && isScalar(shapeOf(property)))
return word(scalar ? String(value) : JSON.stringify(value))
}
// How the field is typed: a boolean as its presence, a repeatable list as the flag
// once per item, anything else as one value.
function flagTokens(name: string, property: JsonSchema | undefined, value: unknown): string[] {
const flag = flagToken(name)
if (value === true) return [flag]
if (value === false) return [`${flag}=false`]
if (property !== undefined && isRepeatable(shapeOf(property)) && Array.isArray(value))
return value.flatMap((item) => [flag, word(String(item))])
return [flag, valueToken(property, value)]
}
// Positionals keep their declared order, so an example that skips one does not shift the
// rest along: the one it skipped prints as itself.
export function exampleLine(d: ExampleSource, example: Example): string {
const properties = propertiesOf(d.input)
const flags = Object.keys(example.input).filter((name) => !d.positional.includes(name))
return [
BINARY,
d.id,
...d.positional.map((name) =>
name in example.input ? valueToken(properties[name], example.input[name]) : argToken(name),
),
...flags.flatMap((name) => flagTokens(name, properties[name], example.input[name])),
].join(COMMAND_SEPARATOR)
}
// A server that ships no example still gets a runnable line: the required fields,
// each standing for itself.
export function commandExamples(d: ExampleSource, examples: readonly Example[]): CommandExample[] {
const required = Array.isArray(d.input.required) ? d.input.required.map(String) : []
const shown =
examples.length > 0
? examples
: [
{
title: SKELETON_TITLE,
input: Object.fromEntries(required.map((n) => [n, argToken(n)])),
},
]
return shown.map((example) => ({ title: example.title, command: exampleLine(d, example) }))
}
+1 -1
View File
@@ -68,7 +68,7 @@ it('every command the tree hides comes back with includeHidden', () => {
})
it('search ranks the plain words against ids, summaries, fields and examples', () => {
expect(helpSearch(['chatbot'], tree).entries[0]).toMatchObject({
expect(helpSearch(['chatbot', 'streams'], tree).entries[0]).toMatchObject({
id: 'run console_app chat',
kind: 'sse',
})
+3 -2
View File
@@ -4,7 +4,7 @@ import type { SearchDoc } from '@/discovery/search'
import type { View } from '@/sys/io/view'
import { search, searchDoc } from '@/discovery/search'
import { byHead, groupsOf, namespacesOf, under } from '@/discovery/tree'
import { COMMAND_SEPARATOR, OP_SEPARATOR, spacedId } from '@/protocol/op-id'
import { COMMAND_SEPARATOR, commandWords, OP_SEPARATOR } from '@/protocol/op-id'
import { view } from '@/sys/io/view'
import { BINARY } from '@/version/info'
import { HELP_WORD } from './command'
@@ -13,6 +13,7 @@ import { collectCommands } from './registry'
const SEARCH_LIMIT = 20
const NEWLINE = '\n'
const SKILL_WORDS = 'install skills <dir>'
export const SKILL_NOTICE = `AI agents: run \`${BINARY} ${SKILL_WORDS.replace('<dir>', '<skills root>')}\` once to learn how to use ${BINARY}.`
const POINTER_MESSAGE = `${BINARY} has no built-in business commands; every server operation is a command`
export type HelpEntry = {
@@ -41,7 +42,7 @@ export function pointer(): View<Record<string, string>> {
export function spacedWords(words: readonly string[]): readonly string[] {
const [only] = words
if (words.length !== 1 || only === undefined || !only.includes(OP_SEPARATOR)) return words
return spacedId(only).split(COMMAND_SEPARATOR)
return commandWords(only)
}
function entryOf(ctor: CommandConstructor, id: string): HelpEntry {
+10 -8
View File
@@ -8,6 +8,7 @@ import { z } from 'zod'
import { parseCatalog } from '@/plugins/catalog'
import { Command } from '@/plugins/commands/command'
import { propertiesOf } from '@/protocol/shape'
import { SKILL_NOTICE } from './help'
import { commands, runPipeline } from './index'
// What the mock serves by default, so an op's descriptor and its errors can be compared.
@@ -67,7 +68,7 @@ it('the map with no server known is the static half and says so on stderr', asyn
const map = JSON.parse(w.io.outBuf())
expect(map.version).toMatchObject({ count: 1 })
expect(map.console_app).toBeUndefined()
expect(w.io.errBuf()).toMatch(/log in to list server operations/)
expect(w.io.errBuf()).toMatch(/not logged in: run difyctl login --help/)
expect(w.mock.requestCount).toBe(0)
})
@@ -78,8 +79,10 @@ it('the map still prints the static half when the catalog cannot be fetched', as
const map = JSON.parse(w.io.outBuf())
expect(map.version).toMatchObject({ count: 1 })
expect(map.console_app).toBeUndefined()
expect(w.io.errBuf().trim().split('\n')).toHaveLength(1)
expect(w.io.errBuf()).toMatch(/^could not list server operations: failed to fetch the catalog: /)
const [couldNotList, notice, ...rest] = w.io.errBuf().trim().split('\n')
expect(couldNotList).toMatch(/^could not list server operations: failed to fetch the catalog: /)
expect(notice).toBe(SKILL_NOTICE)
expect(rest).toEqual([])
})
it('help accepts a dotted id and prints the same descriptor as the spaced path', async () => {
@@ -145,11 +148,10 @@ it('help routes a word to a namespace listing or a search, and --full is the fla
const c = await world(true, ['help', 'get'])
await (await c.ctx.get(commands)).run()
const ids = JSON.parse(c.io.outBuf()).entries.map((e: { id: string }) => e.id)
expect(ids).toContain('get skills')
expect(ids).toContain('get workspace member')
expect(ids).toEqual([...ids].sort())
const d = await world(true, ['help', 'chatbot'])
const d = await world(true, ['help', 'chatbot', 'streams'])
await (await d.ctx.get(commands)).run()
expect(JSON.parse(d.io.outBuf()).entries[0]).toMatchObject({
id: 'run console_app chat',
@@ -255,17 +257,17 @@ it('a command word may be spelled with dashes where the op id has underscores',
})
it('an unknown path refetches the catalog once before giving up', async () => {
const w = await world(true, ['get', 'nope'])
const w = await world(true, ['get', 'qqqq'])
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
code: 'usage_invalid_flag',
message: 'unknown command: get nope',
message: 'unknown command: get qqqq',
hint: 'run difyctl help',
})
expect(w.mock.requestCount).toBe(2) // the cached catalog, then one refetch
const typo = await world(true, ['get', 'consol_app'])
await expect((await typo.ctx.get(commands)).run()).rejects.toMatchObject({
hint: 'did you mean: get console_app',
hint: 'did you mean: get console_app, get console_app version',
})
})
+3 -1
View File
@@ -17,6 +17,7 @@ import {
helpMap,
helpSearch,
pointer,
SKILL_NOTICE,
spacedWords,
} from '@/plugins/commands/help'
import {
@@ -41,7 +42,7 @@ const HELP_FLAGS: readonly string[] = ['--help', '-h']
const FULL_FLAG = '--full'
const ALL_FLAG = '--all'
const FLAG_PREFIX = '-'
const NO_SERVER_NOTICE = 'log in to list server operations'
const NO_SERVER_NOTICE = 'not logged in: run difyctl login --help'
const NO_SERVER_HINT = 'log in to use server operations'
const OPS_UNAVAILABLE_PREFIX = 'could not list server operations: '
const NO_MATCH_NOTICE = `nothing matched; ${helpHint()} for the map`
@@ -131,6 +132,7 @@ async function discover(args: Discovery): Promise<number> {
const entries = entriesOf(tree, listing)
if (path.length === 0) {
streams.notice(SKILL_NOTICE)
await streams.document(mapView(helpMap(entries)))
return ExitCode.Success
}
+25 -28
View File
@@ -7,8 +7,9 @@ import SkillsInstall from '@/commands/install/skills/index'
import { parseCatalog } from '@/plugins/catalog'
import { colorScheme } from '@/sys/io/color'
import { commandRow } from './describe'
import { commandExamples, exampleLine } from './examples'
import { helpMap } from './help'
import { descriptorView, exampleLine, fieldRows, listingView, mapView, SECTIONS } from './text'
import { descriptorView, fieldRows, listingView, mapView, SECTIONS } from './text'
const OPS = parseCatalog(readFileSync(join(__dirname, '../../../test/fixtures/catalog.json'))).ops
const plain = colorScheme(false)
@@ -23,7 +24,7 @@ function opDescriptor(id: string, path: readonly string[]): Descriptor {
effect: 'write',
input: op.input,
positional: [],
examples: op.examples,
examples: commandExamples({ id: path.join(' '), input: op.input, positional: [] }, op.examples),
op: id,
method: op.method,
path: op.path,
@@ -59,18 +60,18 @@ it('renders an op descriptor with input, options, global and examples', () => {
difyctl run console_app workflow [flags]
Flags
--app-id string required
--attachments file[] optional Local files attached to the run itself (the app's
\`sys.files\`), not to a variable
--files map<string, json> optional Local files keyed by the app's file variable name; the
server uploads each one and sets \`inputs[<name>]\`. Send a
list (part name \`files[<name>][]\`) for a file-list variable
--inputs map<string, json> required Variables declared by the app. The exact shape is per app:
read \`input_schema\` from describe.console_app. A file
variable takes a Dify file mapping (remote url or upload id)
here, or a local file in \`files\`, not both.
--workflow-id string optional Pin a published workflow version
--workspace-id string optional Workspace that owns the app
--app-id string required
--attachments file[] optional Local files attached to the run itself (the app's
\`sys.files\`), not to a variable
--files map<string, file | file[]> optional Local files keyed by the app's file variable name; the
server uploads each one and sets \`inputs[<name>]\`. Send a
list (part name \`files[<name>][]\`) for a file-list variable
--inputs map<string, json> required Variables declared by the app. The exact shape is per app:
read \`input_schema\` from describe.console_app. A file
variable takes a Dify file mapping (remote url or upload id)
here, or a local file in \`files\`, not both.
--workflow-id string optional Pin a published workflow version
--workspace-id string optional Workspace that owns the app
Options
--input string optional Operation input as JSON, @file or @- for stdin
@@ -92,7 +93,7 @@ it('renders an op descriptor with input, options, global and examples', () => {
it('renders a static descriptor with arguments and flags', () => {
expect(descriptorView(skillsInstall).text(plain)).toMatchInlineSnapshot(`
"install skills Write skills from the collection into a skills root write
"install skills Write the difyctl skill into a skills root write
Usage
difyctl install skills <dir> [flags]
@@ -102,19 +103,16 @@ it('renders a static descriptor with arguments and flags', () => {
per skill
Flags
--skill string[] [] Skill to install (repeatable); default: the whole collection
--from string "https://github.com/langgenius/dify/tree/0000000/skills" Where the skills come from: a GitHub folder URL or a local
folder
--from string optional A local skill folder to install instead of the one built
into difyctl
Global
--verbose boolean false Keep the raw server response in error envelopes
--json boolean false Print JSON even on a terminal
Examples
# Install every skill for Claude Code
difyctl install skills ~/.claude/skills
# Install one skill for Codex
difyctl install skills ~/.codex/skills --skill difyctl"
# Install the skill for Claude Code
difyctl install skills ~/.claude/skills"
`)
})
@@ -125,9 +123,9 @@ it('example lines put positionals first, scalars as --flag value, non-scalars as
expect(
exampleLine(skillsInstall, {
title: 't',
input: { dir: '~/.claude/skills', skill: ['difyctl'] },
input: { dir: '~/.claude/skills', from: './skill' },
}),
).toBe('difyctl install skills ~/.claude/skills --skill difyctl')
).toBe('difyctl install skills ~/.claude/skills --from ./skill')
})
it('a missing positional prints as itself and a non-scalar one as json', () => {
@@ -205,10 +203,9 @@ it('a description prints its first line only', () => {
})
it('an op without examples prints a skeleton of its required fields', () => {
expect(workflowRun.examples).toEqual([])
expect(descriptorView(workflowRun).text(plain)).toContain(
'difyctl run console_app workflow --app-id <app_id> --inputs <inputs>',
)
const skeleton = 'difyctl run console_app workflow --app-id <app_id> --inputs <inputs>'
expect(workflowRun.examples).toEqual([{ title: 'Required fields', command: skeleton }])
expect(descriptorView(workflowRun).text(plain)).toContain(skeleton)
})
it('the map names each head, leaves by summary and groups by count', () => {
+5 -52
View File
@@ -1,16 +1,15 @@
import type { CommandEffect } from './command'
import type { Descriptor } from './describe'
import type { HelpEntry, HelpHead, HelpListing, HelpMap } from './help'
import type { Example, JsonSchema } from '@/plugins/catalog'
import type { JsonSchema } from '@/plugins/catalog'
import type { Style } from '@/sys/io/color'
import type { View } from '@/sys/io/view'
import { inputSchema } from '@/plugins/argv/parse'
import { GLOBAL_INPUT } from '@/plugins/global-flags'
import { COMMAND_SEPARATOR } from '@/protocol/op-id'
import { isRepeatable, isScalar, labelOf, propertiesOf, shapeOf } from '@/protocol/shape'
import { labelOf, propertiesOf, shapeOf } from '@/protocol/shape'
import { view } from '@/sys/io/view'
import { flagFor } from '@/util/flag-name'
import { BINARY } from '@/version/info'
import { argToken, flagToken } from '@/util/flag-name'
export type FieldRow = Readonly<{
name: string
@@ -25,7 +24,6 @@ type Tagged = Readonly<{ effect?: CommandEffect; kind?: string; deprecated?: boo
const PRESENCE = { Required: 'required', Optional: 'optional' } as const
const FIRST_LINE = /\r?\n/
const PLACEHOLDER = /^<[^<>]*>$/
const GAP = ' '
const MAX_COLUMN_WIDTH = 60
const WORD_SEPARATOR = ' '
@@ -37,7 +35,6 @@ const LIST_SEPARATOR = ', '
const COMMAND_NOUN = { one: 'command', many: 'commands' } as const
const DEPRECATED = 'deprecated'
const NO_PIN = '(none)'
const SKELETON_TITLE = 'Required fields'
const COMMENT = '#'
const NO_POSITIONAL: readonly string[] = []
@@ -100,7 +97,7 @@ export function fieldRows(schema: JsonSchema, positional: readonly string[]): Fi
function rowsToLines(rows: readonly FieldRow[]): string[] {
return aligned(
rows.map((row) => [
row.positional ? `<${row.name}>` : `--${flagFor(row.name)}`,
row.positional ? argToken(row.name) : flagToken(row.name),
row.label,
row.presence,
row.description,
@@ -108,47 +105,6 @@ function rowsToLines(rows: readonly FieldRow[]): string[] {
)
}
// A placeholder stands for a value the caller fills in, whatever the field's shape.
function valueToken(property: JsonSchema | undefined, value: unknown): string {
const placeholder = typeof value === 'string' && PLACEHOLDER.test(value)
const scalar = property !== undefined && isScalar(shapeOf(property))
return placeholder || scalar ? String(value) : `'${JSON.stringify(value)}'`
}
// How the field is typed: a boolean as its presence, a repeatable list as the flag
// once per item, anything else as one value.
function flagTokens(name: string, property: JsonSchema | undefined, value: unknown): string[] {
const flag = `--${flagFor(name)}`
if (value === true) return [flag]
if (value === false) return [`${flag}=false`]
if (property !== undefined && isRepeatable(shapeOf(property)) && Array.isArray(value))
return value.flatMap((item) => [flag, String(item)])
return [flag, valueToken(property, value)]
}
// Positionals keep their declared order, so an example that skips one does not shift the
// rest along: the one it skipped prints as itself.
export function exampleLine(d: Descriptor, example: Example): string {
const properties = propertiesOf(d.input)
const flags = Object.keys(example.input).filter((name) => !d.positional.includes(name))
return [
BINARY,
d.id,
...d.positional.map((name) =>
name in example.input ? valueToken(properties[name], example.input[name]) : `<${name}>`,
),
...flags.flatMap((name) => flagTokens(name, properties[name], example.input[name])),
].join(COMMAND_SEPARATOR)
}
// A server that ships no example still gets a runnable line: the required fields,
// each standing for itself.
function examplesOf(d: Descriptor): readonly Example[] {
if (d.examples.length > 0) return d.examples
const required = Array.isArray(d.input.required) ? d.input.required.map(String) : []
return [{ title: SKELETON_TITLE, input: Object.fromEntries(required.map((n) => [n, `<${n}>`])) }]
}
export const SECTIONS: readonly Section[] = [
{ title: 'Usage', lines: (d) => [d.usage] },
{
@@ -170,10 +126,7 @@ export const SECTIONS: readonly Section[] = [
{
title: 'Examples',
lines: (d, style) =>
examplesOf(d).flatMap((example) => [
style.dim(`${COMMENT} ${example.title}`),
exampleLine(d, example),
]),
d.examples.flatMap((example) => [style.dim(`${COMMENT} ${example.title}`), example.command]),
},
{
title: 'Pins',
+2 -1
View File
@@ -4,6 +4,7 @@ import { ErrorCode } from '@/errors/codes'
import { definePlugin } from '@/kernel/plugin'
import { argv } from '@/plugins/argv'
import { inputSchema, parseArgv, partitionArgv } from '@/plugins/argv/parse'
import { flagToken } from '@/util/flag-name'
export const GLOBAL_INPUT = z.object({
verbose: z.boolean().default(false).describe('Keep the raw server response in error envelopes'),
@@ -21,7 +22,7 @@ const NO_POSITIONALS: readonly string[] = []
function invalidGlobalFlags(error: z.ZodError): BaseError {
const message = error.issues
.map((issue) => `--${issue.path.join('.')}: ${issue.message}`)
.map((issue) => `${flagToken(issue.path.join('.'))}: ${issue.message}`)
.join('; ')
return new BaseError({ code: ErrorCode.UsageInvalidFlag, message, cause: error })
}
+1 -1
View File
@@ -100,7 +100,7 @@ export function opCommand(id: string, op: CatalogOp): CommandConstructor {
const fields = Object.fromEntries(
Object.entries(input).filter(([key]) => !(key in CALL_FLAGS)),
)
return executeCall({ id, op, flags, fields }, ctx)
return executeCall({ id, op, positional: OpCommand.positional, flags, fields }, ctx)
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { CatalogOp } from '@/plugins/catalog'
import type { CommandConstructor } from '@/plugins/commands/command'
import type { CommandTree } from '@/plugins/commands/registry'
import { OP_SEPARATOR } from '@/protocol/op-id'
import { commandWords } from '@/protocol/op-id'
import { opCommand } from './command'
// A node is readonly once it is in a tree; building one needs a mutable view of it.
@@ -10,7 +10,7 @@ type BuildingNode = { command?: CommandConstructor; subcommands: Record<string,
export function opsTree(ops: Readonly<Record<string, CatalogOp>>): CommandTree {
const tree: Record<string, BuildingNode> = {}
for (const [id, op] of Object.entries(ops)) {
const segments = id.split(OP_SEPARATOR)
const segments = commandWords(id)
let level = tree
for (const [index, segment] of segments.entries()) {
const node = (level[segment] ??= { subcommands: {} })
+3 -2
View File
@@ -4,7 +4,7 @@ import { z } from 'zod'
import { BaseError, notLoggedIn } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { definePlugin } from '@/kernel/plugin'
import { env } from '@/plugins/env'
import { ENV, env } from '@/plugins/env'
import { STORAGE_MODES, YamlStore } from '@/store/store'
export const LOGIN_FILE_NAME = 'login.yml'
@@ -106,7 +106,8 @@ export const session = definePlugin({
current,
require: async () => {
const login = await current()
if (login === null) throw notLoggedIn()
if (login === null)
throw notLoggedIn(`no login saved in ${configDir}; ${ENV.ConfigDir} picks this folder`)
return login
},
save: async (login: Login) => {
+5 -1
View File
@@ -1,9 +1,13 @@
export const OP_SEPARATOR = '.'
export const COMMAND_SEPARATOR = ' '
export function commandWords(id: string): readonly string[] {
return id.split(OP_SEPARATOR)
}
/** An op id as the words it is typed as: `get.console_app` -> `get console_app`. */
export function spacedId(id: string): string {
return id.split(OP_SEPARATOR).join(COMMAND_SEPARATOR)
return commandWords(id).join(COMMAND_SEPARATOR)
}
export function dottedId(path: readonly string[]): string {
+1 -1
View File
@@ -18,7 +18,7 @@ it.each([
[nullable({ type: 'string', enum: ['x'] }), SHAPE.Enum, 'x'],
[{ enum: [] }, SHAPE.Json, 'json'],
[{ type: 'string', enum: [] }, SHAPE.Json, 'json'],
[{ oneOf: [{ type: 'string' }, { type: 'integer' }] }, SHAPE.Json, 'json'],
[{ oneOf: [{ type: 'string' }, { type: 'integer' }] }, SHAPE.Union, 'string | integer'],
[{ someFutureKeyword: true }, SHAPE.Json, 'json'],
])('%j classifies as %s labelled %s', (schema, shape, label) => {
const node = shapeOf(schema)
+22 -11
View File
@@ -12,9 +12,15 @@ export const SHAPE = {
Map: 'map',
Object: 'object',
Json: 'json',
Union: 'union',
} as const
export type Shape = (typeof SHAPE)[keyof typeof SHAPE]
export type ShapeNode = Readonly<{ shape: Shape; item?: ShapeNode; values?: readonly string[] }>
export type ShapeNode = Readonly<{
shape: Shape
item?: ShapeNode
values?: readonly string[]
options?: readonly ShapeNode[]
}>
type Coercion = (raw: string) => unknown
@@ -24,7 +30,7 @@ const ARRAY_TYPE = 'array'
const OBJECT_TYPE = 'object'
const STRING_TYPE = 'string'
const FILE_REF_PREFIX = '@'
const ENUM_SEPARATOR = ' | '
const ALTERNATIVE_SEPARATOR = ' | '
const BOOLEAN_LITERALS: Readonly<Record<string, boolean>> = { true: true, false: false }
const keepRaw: Coercion = (raw) => raw
@@ -64,7 +70,7 @@ const SHAPE_INFO: Readonly<Record<Shape, ShapeInfo>> = {
[SHAPE.Number]: { label: () => SHAPE.Number, coerce: () => toNumber, scalar: true },
[SHAPE.Boolean]: { label: () => SHAPE.Boolean, coerce: () => toBoolean, scalar: true },
[SHAPE.Enum]: {
label: (n) => (n.values ?? []).join(ENUM_SEPARATOR),
label: (n) => (n.values ?? []).join(ALTERNATIVE_SEPARATOR),
coerce: () => keepRaw,
scalar: true,
},
@@ -81,19 +87,24 @@ const SHAPE_INFO: Readonly<Record<Shape, ShapeInfo>> = {
},
[SHAPE.Object]: { label: () => SHAPE.Object, coerce: () => toJson, scalar: false },
[SHAPE.Json]: { label: () => SHAPE.Json, coerce: () => toJson, scalar: false },
[SHAPE.Union]: {
label: (n) => (n.options ?? []).map(labelOf).join(ALTERNATIVE_SEPARATOR),
coerce: () => toJson,
scalar: false,
},
}
// pydantic optional: anyOf [T, null]. Anything else with anyOf is json.
function unwrapNullable(schema: JsonSchema): JsonSchema | undefined {
const branches = schema.anyOf
if (!Array.isArray(branches) || branches.length !== 2) return undefined
const real = branches.filter((b) => !(isRecord(b) && b.type === NULL_TYPE))
return real.length === 1 && isRecord(real[0]) ? (real[0] as JsonSchema) : undefined
function alternativesOf(schema: JsonSchema): JsonSchema[] {
const branches = schema.anyOf ?? schema.oneOf
if (!Array.isArray(branches)) return []
return branches.filter((b) => isRecord(b) && b.type !== NULL_TYPE) as JsonSchema[]
}
export function shapeOf(schema: JsonSchema): ShapeNode {
const inner = unwrapNullable(schema)
if (inner !== undefined) return shapeOf(inner)
const alternatives = alternativesOf(schema)
const [only] = alternatives
if (alternatives.length === 1 && only !== undefined) return shapeOf(only)
if (alternatives.length > 1) return { shape: SHAPE.Union, options: alternatives.map(shapeOf) }
const type = typeof schema.type === 'string' ? schema.type : undefined
// An enum with no members names no value at all, so it degrades to json rather than
// labelling as the empty string.
-76
View File
@@ -1,76 +0,0 @@
import type { SkillEntry, SkillSource } from './source'
import { posix } from 'node:path'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { parseFrontmatter } from './frontmatter'
import { SKILL_FILE } from './source'
export const BASIC_SKILL = 'difyctl'
const ROOT_DIR = '.'
const SKILL_NAME_RE = /^[a-z0-9-]{1,64}$/
export type Skill = {
readonly name: string
readonly description: string
readonly dir: string
readonly files: readonly SkillEntry[]
}
function skillDirs(entries: readonly SkillEntry[]): string[] {
const dirs = entries
.filter(
(entry) => entry.path.split('/').length <= 2 && posix.basename(entry.path) === SKILL_FILE,
)
.map((entry) => posix.dirname(entry.path))
return dirs.includes(ROOT_DIR) ? [ROOT_DIR] : dirs
}
function filesIn(entries: readonly SkillEntry[], dir: string): SkillEntry[] {
return entries
.filter((entry) => dir === ROOT_DIR || entry.path.startsWith(`${dir}/`))
.map((entry) => ({
...entry,
path: dir === ROOT_DIR ? entry.path : entry.path.slice(dir.length + 1),
}))
}
async function loadSkill(source: SkillSource, dir: string): Promise<Skill> {
const path = posix.join(dir, SKILL_FILE)
const meta = parseFrontmatter(new TextDecoder().decode(await source.read(path)))
const { name, description } = meta
if (typeof name !== 'string' || !SKILL_NAME_RE.test(name) || typeof description !== 'string') {
throw new BaseError({
code: ErrorCode.ServerError,
message: `${path}: frontmatter needs a name (lowercase letters, digits, dashes) and a description`,
})
}
return { name, description, dir, files: filesIn(source.entries, dir) }
}
export async function loadSkills(source: SkillSource): Promise<Skill[]> {
const skills = await Promise.all(skillDirs(source.entries).map((dir) => loadSkill(source, dir)))
if (skills.length === 0) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: 'no skills found',
hint: 'point --from at a folder of skills, or at one skill folder',
})
}
return skills
}
export function isScenario(skill: Skill): boolean {
return skill.name !== BASIC_SKILL
}
export function findSkill(skills: readonly Skill[], name: string): Skill {
const skill = skills.find((candidate) => candidate.name === name)
if (skill === undefined) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `unknown skill "${name}"`,
hint: `known skills: ${skills.map((candidate) => candidate.name).join(', ')}`,
})
}
return skill
}
-19
View File
@@ -1,19 +0,0 @@
import { load } from 'js-yaml'
import { isRecord } from '@/util/is-record'
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/
export type Frontmatter = { readonly head: string; readonly body: string }
export function splitFrontmatter(text: string): Frontmatter | undefined {
const match = FRONTMATTER_RE.exec(text)
if (match === null) return undefined
return { head: match[1] as string, body: text.slice(match[0].length) }
}
export function parseFrontmatter(text: string): Record<string, unknown> {
const split = splitFrontmatter(text)
if (split === undefined) return {}
const doc: unknown = load(split.head)
return isRecord(doc) ? doc : {}
}
-112
View File
@@ -1,112 +0,0 @@
import type { SkillSource } from './source'
import { createHash } from 'node:crypto'
import { z } from 'zod'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { buildFetchInit } from '@/net/fetch-init'
import { proxyDispatcher } from '@/net/proxy'
import { getEnv } from '@/sys'
export const GITHUB_ORIGIN = 'https://github.com'
const API_ORIGIN = 'https://api.github.com'
const RAW_ORIGIN = 'https://raw.githubusercontent.com'
const TREE_SEGMENT = 'tree'
const DEFAULT_REF = 'HEAD'
const TOKEN_ENV = 'GITHUB_TOKEN'
const RATE_LIMIT_HEADER = 'x-ratelimit-remaining'
const TOO_MANY_REQUESTS = 429
const EXECUTABLE_MODE = '100755'
const FILE_MODES: readonly string[] = ['100644', EXECUTABLE_MODE]
const FROM_HINT = 'check --from, or download the skills folder and pass --from <folder>'
const TREE_SCHEMA = z.object({
truncated: z.boolean(),
tree: z.array(z.object({ path: z.string(), mode: z.string(), sha: z.string() })),
})
export type GitHubFolder = {
readonly owner: string
readonly repo: string
readonly ref: string
readonly path: string
}
export function parseGitHubUrl(url: string): GitHubFolder {
const [owner, repo, segment, ref, ...rest] = new URL(url).pathname.split('/').filter(Boolean)
const tree = segment === undefined || (segment === TREE_SEGMENT && ref !== undefined)
if (owner === undefined || repo === undefined || !tree) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `unsupported GitHub URL "${url}"`,
hint: `use ${GITHUB_ORIGIN}/<owner>/<repo>/tree/<ref>/<folder>`,
})
}
return { owner, repo, ref: ref ?? DEFAULT_REF, path: rest.join('/') }
}
async function get(url: string, token?: string): Promise<Response> {
const headers = token === undefined ? undefined : { authorization: `Bearer ${token}` }
let res: Response
try {
res = await fetch(
url,
buildFetchInit({ headers }, { insecure: false, dispatcher: proxyDispatcher() }),
)
} catch (cause) {
throw new BaseError({
code: ErrorCode.NetworkConnection,
message: `GET ${url} failed`,
hint: FROM_HINT,
cause,
})
}
if (res.ok) return res
const limited = res.status === TOO_MANY_REQUESTS || res.headers.get(RATE_LIMIT_HEADER) === '0'
throw new BaseError({
code: limited ? ErrorCode.RateLimited : ErrorCode.ServerError,
message: `GET ${url} returned ${res.status}`,
hint: limited ? `set ${TOKEN_ENV} to raise GitHub's rate limit, or ${FROM_HINT}` : FROM_HINT,
})
}
function gitBlobSha(bytes: Uint8Array): string {
return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
}
export async function openGitHub(url: string): Promise<SkillSource> {
const { owner, repo, ref, path } = parseGitHubUrl(url)
const treeUrl = `${API_ORIGIN}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(`${ref}:${path}`)}?recursive=1`
const parsed = TREE_SCHEMA.safeParse(await (await get(treeUrl, getEnv(TOKEN_ENV))).json())
if (!parsed.success)
throw new BaseError({
code: ErrorCode.ServerError,
message: `unexpected response from ${treeUrl}`,
})
if (parsed.data.truncated) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `${url} holds too many files to list`,
hint: 'point --from at the skills folder',
})
}
const files = parsed.data.tree.filter((entry) => FILE_MODES.includes(entry.mode))
const shas = new Map(files.map((entry) => [entry.path, entry.sha]))
return {
entries: files.map((entry) => ({
path: entry.path,
executable: entry.mode === EXECUTABLE_MODE,
})),
read: async (file) => {
const rawUrl = [RAW_ORIGIN, owner, repo, ref, path, file].filter(Boolean).join('/')
const bytes = new Uint8Array(await (await get(rawUrl)).arrayBuffer())
if (gitBlobSha(bytes) !== shas.get(file)) {
throw new BaseError({
code: ErrorCode.ServerError,
message: `${rawUrl} changed during the install`,
hint: 'retry in a few minutes',
})
}
return bytes
},
}
}
+12 -41
View File
@@ -1,13 +1,9 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
import { ErrorCode } from '@/errors/codes'
import { BASIC_SKILL, loadSkills } from './collection'
import { installSkills, selectSkills } from './install'
import { openSource } from './source'
const SCENARIO = 'difyctl-demo'
import { afterEach, beforeEach, expect, it } from 'vite-plus/test'
import { installSkill } from './install'
import { openDir, SKILL_FILE, SKILL_NAME } from './source'
let tmp: string
let from: string
@@ -19,47 +15,22 @@ async function put(path: string, text: string): Promise<void> {
await writeFile(abs, text)
}
function skillMd(name: string): string {
return `---\nname: ${name}\ndescription: ${name} skill\n---\n\n# ${name}\n`
}
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'difyctl-skills-'))
tmp = await mkdtemp(join(tmpdir(), 'difyctl-skill-'))
from = join(tmp, 'source')
root = join(tmp, 'root')
await put(`${BASIC_SKILL}/SKILL.md`, skillMd(BASIC_SKILL))
await put(`${SCENARIO}/SKILL.md`, skillMd(SCENARIO))
await put(SKILL_FILE, `---\nname: ${SKILL_NAME}\ndescription: d\n---\n`)
await put('references/setup.md', 'setup')
})
afterEach(async () => {
await rm(tmp, { recursive: true, force: true })
})
describe('selectSkills', () => {
it('selects the whole collection when no names are given', async () => {
const skills = await loadSkills(await openSource(from))
expect(selectSkills(skills, [])).toEqual(skills)
})
it('selects the named skills', async () => {
const skills = await loadSkills(await openSource(from))
expect(selectSkills(skills, [BASIC_SKILL]).map((s) => s.name)).toEqual([BASIC_SKILL])
})
it('rejects an unknown name as a usage error', async () => {
const skills = await loadSkills(await openSource(from))
expect(() => selectSkills(skills, ['nope'])).toThrow(
expect.objectContaining({ code: ErrorCode.UsageInvalidFlag }),
)
})
})
describe('installSkills', () => {
it('writes <root>/<skill>/<file> for every file of every skill', async () => {
const source = await openSource(from)
const basic = selectSkills(await loadSkills(source), [BASIC_SKILL])
const target = join(root, BASIC_SKILL, 'SKILL.md')
expect(await installSkills(root, basic, source)).toEqual([target])
expect(await readFile(target, 'utf8')).toBe(skillMd(BASIC_SKILL))
})
it('writes every file under <root>/difyctl', async () => {
const wrote = await installSkill(root, await openDir(from))
expect(wrote.sort()).toEqual(
[join(root, SKILL_NAME, SKILL_FILE), join(root, SKILL_NAME, 'references/setup.md')].sort(),
)
expect(await readFile(join(root, SKILL_NAME, 'references/setup.md'), 'utf8')).toBe('setup')
})
+17 -26
View File
@@ -1,37 +1,28 @@
import type { Skill } from './collection'
import type { SkillSource } from './source'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { dirname, join, posix } from 'node:path'
import { BASIC_SKILL, findSkill, isScenario } from './collection'
import { dirname, join } from 'node:path'
import { SKILL_NAME } from './source'
const FILE_MODE = 0o644
const EXECUTABLE_MODE = 0o755
const FILE_MODE = 0o644
const SHEBANG = [0x23, 0x21]
export function selectSkills(skills: readonly Skill[], names: readonly string[]): Skill[] {
if (names.length === 0) return [...skills]
const chosen = new Map(names.map((name) => [name, findSkill(skills, name)]))
const basic = skills.find((skill) => skill.name === BASIC_SKILL)
if (basic !== undefined && [...chosen.values()].some(isScenario)) chosen.set(BASIC_SKILL, basic)
return [...chosen.values()]
function modeFor(bytes: Uint8Array): number {
return SHEBANG.every((byte, i) => bytes[i] === byte) ? EXECUTABLE_MODE : FILE_MODE
}
export async function installSkills(
root: string,
skills: readonly Skill[],
source: SkillSource,
): Promise<string[]> {
const downloads = []
for (const skill of skills) {
for (const file of skill.files)
downloads.push({ skill, file, bytes: await source.read(posix.join(skill.dir, file.path)) })
}
for (const skill of skills) await rm(join(root, skill.name), { recursive: true, force: true })
export async function installSkill(root: string, source: SkillSource): Promise<string[]> {
const files = await Promise.all(
source.paths.map(async (path) => ({ path, bytes: await source.read(path) })),
)
const target = join(root, SKILL_NAME)
await rm(target, { recursive: true, force: true })
const wrote: string[] = []
for (const { skill, file, bytes } of downloads) {
const path = join(root, skill.name, file.path)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes, { mode: file.executable ? EXECUTABLE_MODE : FILE_MODE })
wrote.push(path)
for (const { path, bytes } of files) {
const abs = join(target, path)
await mkdir(dirname(abs), { recursive: true })
await writeFile(abs, bytes, { mode: modeFor(bytes) })
wrote.push(abs)
}
return wrote
}
+45 -49
View File
@@ -1,76 +1,72 @@
import type { Dirent } from 'node:fs'
import type { EmbeddedFile } from '@/sys'
import { readdir, readFile, stat } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { z } from 'zod'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { versionInfo } from '@/version/info'
import { GITHUB_ORIGIN, openGitHub } from './github'
import { embeddedFiles, isCompiledBinary } from '@/sys'
export const SKILL_NAME = 'difyctl'
export const SKILL_FILE = 'SKILL.md'
const EXECUTABLE_BITS = 0o111
const URL_RE = /^https?:\/\//
export type SkillEntry = { readonly path: string; readonly executable: boolean }
const EMBED_PREFIX = `${SKILL_NAME}/`
const REPO_SKILL_DIR = fileURLToPath(new URL(`../../../skills/${EMBED_PREFIX}`, import.meta.url))
export const NO_EMBEDDED_SKILL =
'this difyctl build has no embedded skill; reinstall difyctl or pass --from <folder>'
export type SkillSource = {
readonly entries: readonly SkillEntry[]
readonly paths: readonly string[]
readonly read: (path: string) => Promise<Uint8Array>
}
export const DEFAULT_SOURCE = `${GITHUB_ORIGIN}/langgenius/dify/tree/${versionInfo.commit}/skills`
export const FROM_FIELD = z
.string()
.min(1)
.default(DEFAULT_SOURCE)
.describe('Where the skills come from: a GitHub folder URL or a local folder')
.optional()
.describe('A local skill folder to install instead of the one built into difyctl')
async function hasSkillFile(dir: string): Promise<boolean> {
return stat(join(dir, SKILL_FILE)).then(
export async function openDir(root: string): Promise<SkillSource> {
const found = await stat(join(root, SKILL_FILE)).then(
() => true,
() => false,
)
}
async function walk(root: string, dir: string): Promise<SkillEntry[]> {
const entries: SkillEntry[] = []
for (const entry of await readdir(dir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile()) continue
const abs = join(entry.parentPath, entry.name)
const executable = ((await stat(abs)).mode & EXECUTABLE_BITS) !== 0
entries.push({ path: relative(root, abs).split(sep).join('/'), executable })
}
return entries
}
async function openDir(root: string): Promise<SkillSource> {
let children: Dirent[]
try {
children = await readdir(root, { withFileTypes: true })
} catch (cause) {
if (!found) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `cannot read folder "${root}"`,
cause,
message: `no ${SKILL_FILE} in "${root}"`,
})
}
const dirs = (await hasSkillFile(root))
? [root]
: children.filter((child) => child.isDirectory()).map((child) => join(root, child.name))
const entries: SkillEntry[] = []
for (const dir of dirs) if (await hasSkillFile(dir)) entries.push(...(await walk(root, dir)))
return { entries, read: (path) => readFile(join(root, path)) }
const paths: string[] = []
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
if (entry.isFile())
paths.push(relative(root, join(entry.parentPath, entry.name)).split(sep).join('/'))
}
return { paths, read: (path) => readFile(join(root, path)) }
}
export function openSource(from: string): Promise<SkillSource> {
if (from.startsWith(`${GITHUB_ORIGIN}/`)) return openGitHub(from)
if (URL_RE.test(from)) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `unsupported --from URL "${from}"`,
hint: `use a ${GITHUB_ORIGIN} folder URL or a local folder`,
})
export function embeddedSource(files: readonly EmbeddedFile[]): SkillSource {
const byPath = new Map(files.map((file) => [file.name.slice(EMBED_PREFIX.length), file]))
return {
paths: [...byPath.keys()],
read: async (path) => new Uint8Array(await (byPath.get(path) as EmbeddedFile).arrayBuffer()),
}
return openDir(from)
}
export type SourceInputs = Readonly<{ files: readonly EmbeddedFile[]; compiled: boolean }>
export async function pickSource({ files, compiled }: SourceInputs): Promise<SkillSource> {
if (files.length > 0) return embeddedSource(files)
if (compiled) throw new BaseError({ code: ErrorCode.Unknown, message: NO_EMBEDDED_SKILL })
return openDir(REPO_SKILL_DIR)
}
export function defaultSource(): Promise<SkillSource> {
return pickSource({
files: embeddedFiles().filter((file) => file.name.startsWith(EMBED_PREFIX)),
compiled: isCompiledBinary(),
})
}
export function openSource(from: string | undefined): Promise<SkillSource> {
return from === undefined ? defaultSource() : openDir(from)
}
+32 -1
View File
@@ -1,6 +1,6 @@
import type { TokenStore } from './token-store'
import { describe, expect, it, vi } from 'vite-plus/test'
import { detectTokenStore, getTokenStore } from './manager'
import { detectTokenStore, getTokenStore, keyringDurable } from './manager'
function memStore(label: string): TokenStore & { _label: string } {
const map = new Map<string, string>()
@@ -38,6 +38,7 @@ describe('detectTokenStore', () => {
const f = memStore('file')
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
@@ -51,6 +52,7 @@ describe('detectTokenStore', () => {
}) as TokenStore['write']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
@@ -62,6 +64,7 @@ describe('detectTokenStore', () => {
k.read = vi.fn(async () => 'something-else') as TokenStore['read']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
@@ -86,6 +89,7 @@ describe('detectTokenStore', () => {
const f = memStore('file')
await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(await k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('')
})
@@ -99,6 +103,7 @@ describe('detectTokenStore', () => {
}) as TokenStore['read']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(removeSpy).toHaveBeenCalledWith('__difyctl_probe__', '__difyctl_probe__')
expect(result.mode).toBe('file')
@@ -129,3 +134,29 @@ describe('getTokenStore', () => {
expect(keyringFactory).not.toHaveBeenCalled()
})
})
describe('keyringDurable', () => {
const env = (vars: Record<string, string>) => (name: string) => vars[name]
it.each([
['linux', {}, false],
['linux', { DBUS_SESSION_BUS_ADDRESS: '' }, false],
['linux', { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' }, true],
['darwin', {}, true],
['win32', {}, true],
['freebsd', {}, true],
])('%s %o → %s', (platform, vars, durable) => {
expect(keyringDurable(platform, env(vars))).toBe(durable)
})
it('a non-durable keyring skips the probe and uses the file store', async () => {
const k = memStore('keyring')
const f = memStore('file')
const writeSpy = vi.spyOn(k, 'write')
const result = await detectTokenStore({
keyringDurable: false,
factory: { keyring: () => k, file: () => f },
})
expect(writeSpy).not.toHaveBeenCalled()
expect(result.mode).toBe('file')
})
})
+18 -1
View File
@@ -1,6 +1,7 @@
import type { StorageMode } from './store'
import type { TokenStore } from './token-store'
import { join } from 'node:path'
import { getEnv } from '@/sys'
import { resolveConfigDir } from './dir'
import { YamlStore } from './store'
import { FileTokenStore, KeychainTokenStore } from './token-store'
@@ -9,6 +10,8 @@ const TOKENS_FILE = 'tokens.yml'
export const CONFIG_FILE_NAME = 'config.yml'
const KEYRING_SERVICE = 'difyctl'
const LINUX = 'linux'
const DBUS_SESSION = 'DBUS_SESSION_BUS_ADDRESS'
function getStore(filePath: string): YamlStore {
return new YamlStore(filePath)
@@ -31,6 +34,18 @@ export type GetTokenStoreOptions = {
export type DetectTokenStoreOptions = GetTokenStoreOptions & {
readonly skipKeyring?: boolean
readonly keyringDurable?: boolean
}
/**
* Without a DBus session, the Linux keyring falls back to the kernel keyutils
* store: the probe round-trips, but the token is gone when the session ends.
*/
export function keyringDurable(
platform: string,
env: (name: string) => string | undefined,
): boolean {
return platform !== LINUX || Boolean(env(DBUS_SESSION))
}
const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => TokenStore> = {
@@ -47,7 +62,9 @@ const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => T
export async function detectTokenStore(
opts: DetectTokenStoreOptions = {},
): Promise<{ store: TokenStore; mode: StorageMode }> {
if (opts.skipKeyring === true) return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' }
const durable = opts.keyringDurable ?? keyringDurable(process.platform, getEnv)
if (opts.skipKeyring === true || !durable)
return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' }
try {
const k = TOKEN_STORE_OPENERS.keychain(opts)
await k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE)
+18
View File
@@ -6,6 +6,24 @@ export function getEnv(name: string): string | undefined {
return process.env[name]
}
export type EmbeddedFile = Blob & { readonly name: string }
type BunGlobal = { embeddedFiles?: readonly EmbeddedFile[]; main?: string }
const COMPILED_ENTRY = /^(?:\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/i
function bun(): BunGlobal | undefined {
return (globalThis as { Bun?: BunGlobal }).Bun
}
export function embeddedFiles(): readonly EmbeddedFile[] {
return bun()?.embeddedFiles ?? []
}
export function isCompiledBinary(): boolean {
return COMPILED_ENTRY.test(bun()?.main ?? '')
}
export function env(): NodeJS.ProcessEnv {
return process.env
}
+15
View File
@@ -1,5 +1,8 @@
const FLAG_WORD_SEPARATOR = '-'
const PROPERTY_WORD_SEPARATOR = '_'
const FLAG_PREFIX = '--'
const ARG_OPEN = '<'
const ARG_CLOSE = '>'
/** The name a typed word stands for: dashes are an alias for the underscores ids use. */
export function propertyFor(name: string): string {
@@ -10,3 +13,15 @@ export function propertyFor(name: string): string {
export function flagFor(name: string): string {
return name.split(PROPERTY_WORD_SEPARATOR).join(FLAG_WORD_SEPARATOR)
}
export function flagToken(name: string): string {
return `${FLAG_PREFIX}${flagFor(name)}`
}
export function argToken(name: string): string {
return `${ARG_OPEN}${name}${ARG_CLOSE}`
}
export function typedName(name: string, positional: readonly string[]): string {
return positional.includes(name) ? argToken(name) : flagToken(name)
}
-3
View File
@@ -1,8 +1,6 @@
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
export const DEFAULT_HOST = 'https://cloud.dify.ai'
export function openAPIBase(host: string): string {
return `${host.replace(/\/+$/, '')}/openapi/v1/`
}
@@ -14,7 +12,6 @@ export type ResolveHostOptions = {
export function resolveHost(opts: ResolveHostOptions): string {
let raw = opts.raw.trim()
if (raw === '') raw = DEFAULT_HOST
if (!raw.includes('://')) raw = `https://${raw}`
let url: URL
try {
+8
View File
@@ -0,0 +1,8 @@
const SHELL_SAFE = /^[\w@%+=:,./~-]+$/
const QUOTE = "'"
const ESCAPED_QUOTE = `'\\''`
export function shellWord(text: string): string {
if (SHELL_SAFE.test(text)) return text
return `${QUOTE}${text.split(QUOTE).join(ESCAPED_QUOTE)}${QUOTE}`
}
+69 -4
View File
@@ -92,6 +92,33 @@
"workspace_id": "body"
},
"deprecated": false,
"examples": [
{
"input": {
"app_id": "<app_id>",
"query": "Summarise the latest release"
},
"title": "Start a new conversation"
},
{
"input": {
"app_id": "<app_id>",
"conversation_id": "<conversation_id>",
"query": "Make it shorter"
},
"title": "Reply in an existing conversation"
},
{
"input": {
"app_id": "<app_id>",
"files": {
"document": "./report.pdf"
},
"query": "Summarise the document"
},
"title": "Fill a file variable with a local file"
}
],
"input": {
"properties": {
"app_id": {
@@ -111,7 +138,7 @@
}
],
"default": null,
"description": "Local files attached to the run itself (the app's `sys.files`), not to a variable",
"description": "Local file paths attached to the run itself (the app's `sys.files`), not to a variable",
"title": "Attachments"
},
"auto_generate_name": {
@@ -158,12 +185,12 @@
}
],
"default": null,
"description": "Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable",
"description": "Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable",
"title": "Files"
},
"inputs": {
"additionalProperties": true,
"description": "Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both.",
"description": "Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both.",
"title": "Inputs",
"type": "object"
},
@@ -199,7 +226,7 @@
"title": "Workspace Id"
}
},
"required": ["app_id", "inputs", "query"],
"required": ["app_id", "query"],
"type": "object"
},
"internal": false,
@@ -510,6 +537,7 @@
"bind": {
"app_id": "body",
"description": "body",
"draft_hash": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
@@ -520,6 +548,30 @@
"yaml_url": "body"
},
"deprecated": false,
"examples": [
{
"input": {
"mode": "yaml-content",
"yaml_content": "<yaml text of the app DSL>"
},
"title": "Import an app from inline YAML"
},
{
"input": {
"mode": "yaml-url",
"yaml_url": "https://example.com/apps/release-summary.yml"
},
"title": "Import an app from a URL"
},
{
"input": {
"app_id": "<app_id>",
"mode": "yaml-content",
"yaml_content": "<yaml text of the app DSL>"
},
"title": "Overwrite an existing workflow app from YAML"
}
],
"input": {
"properties": {
"app_id": {
@@ -548,6 +600,19 @@
"description": "Override the app description from the DSL",
"title": "Description"
},
"draft_hash": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "draft_hash from the export or restore this import is based on. The import fails if the draft's graph, features, environment variables or conversation variables changed since. Requires app_id",
"title": "Draft Hash"
},
"icon": {
"anyOf": [
{
+908 -4
View File
@@ -57,6 +57,20 @@
"path": "/openapi/v1/account/sessions/self",
"summary": "Revoke the session behind this token"
},
"restore.console_app.version": {
"bind": { "app_id": "path", "version_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "version_id": {} },
"required": ["app_id", "version_id"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}/versions/{version_id}:restore",
"summary": "Copy a published version into the draft; publish again to make it live"
},
"run.console_app.advanced_chat": {
"bind": {
"app_id": "path",
@@ -70,6 +84,28 @@
"workspace_id": "body"
},
"deprecated": false,
"examples": [
{
"input": { "app_id": "<app_id>", "query": "Summarise the latest release" },
"title": "Start a new conversation"
},
{
"input": {
"app_id": "<app_id>",
"conversation_id": "<conversation_id>",
"query": "Make it shorter"
},
"title": "Reply in an existing conversation"
},
{
"input": {
"app_id": "<app_id>",
"files": { "document": "./report.pdf" },
"query": "Summarise the document"
},
"title": "Fill a file variable with a local file"
}
],
"input": {
"properties": {
"app_id": { "type": "string" },
@@ -79,7 +115,7 @@
{ "type": "null" }
],
"default": null,
"description": "Local files attached to the run itself (the app's `sys.files`), not to a variable",
"description": "Local file paths attached to the run itself (the app's `sys.files`), not to a variable",
"title": "Attachments"
},
"auto_generate_name": {
@@ -108,12 +144,12 @@
{ "type": "null" }
],
"default": null,
"description": "Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable",
"description": "Local file paths keyed by the app's file variable name; each file is uploaded and becomes that variable's value. Give a list of paths for a file-list variable",
"title": "Files"
},
"inputs": {
"additionalProperties": true,
"description": "Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both.",
"description": "Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local path in `files`, not both.",
"title": "Inputs",
"type": "object"
},
@@ -131,7 +167,7 @@
"title": "Workspace Id"
}
},
"required": ["app_id", "inputs", "query"],
"required": ["app_id", "query"],
"type": "object"
},
"internal": false,
@@ -346,6 +382,7 @@
"bind": {
"app_id": "body",
"description": "body",
"draft_hash": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
@@ -356,6 +393,27 @@
"yaml_url": "body"
},
"deprecated": false,
"examples": [
{
"input": { "mode": "yaml-content", "yaml_content": "<yaml text of the app DSL>" },
"title": "Import an app from inline YAML"
},
{
"input": {
"mode": "yaml-url",
"yaml_url": "https://example.com/apps/release-summary.yml"
},
"title": "Import an app from a URL"
},
{
"input": {
"app_id": "<app_id>",
"mode": "yaml-content",
"yaml_content": "<yaml text of the app DSL>"
},
"title": "Overwrite an existing workflow app from YAML"
}
],
"input": {
"properties": {
"app_id": {
@@ -370,6 +428,12 @@
"description": "Override the app description from the DSL",
"title": "Description"
},
"draft_hash": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"default": null,
"description": "draft_hash from the export or restore this import is based on. The import fails if the draft's graph, features, environment variables or conversation variables changed since. Requires app_id",
"title": "Draft Hash"
},
"icon": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"default": null,
@@ -917,6 +981,846 @@
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}:switch",
"summary": "Server-side current switch workspace (shared with the web console)"
},
"create.console_app.workflow": {
"bind": {
"description": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"name": "body",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"description": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"name": {},
"workspace_id": {}
},
"required": ["workspace_id", "name"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/apps/workflow",
"summary": "Create a blank workflow app; export its DSL to start building"
},
"create.console_app.advanced_chat": {
"bind": {
"description": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"name": "body",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"description": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"name": {},
"workspace_id": {}
},
"required": ["workspace_id", "name"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/apps/advanced-chat",
"summary": "Create a blank advanced-chat app; export its DSL to start building"
},
"test.node.workflow": {
"bind": { "app_id": "path", "files": "body", "inputs": "body", "node_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "files": {}, "inputs": {}, "node_id": {} },
"required": ["app_id", "node_id"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}/draft/workflow/nodes/{node_id}:run",
"summary": "Test one node of a workflow draft, reusing what the last draft run saved"
},
"test.node.advanced_chat": {
"bind": {
"app_id": "path",
"files": "body",
"inputs": "body",
"node_id": "path",
"query": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "files": {}, "inputs": {}, "node_id": {}, "query": {} },
"required": ["app_id", "node_id"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run",
"summary": "Test one node of an advanced-chat draft, reusing what the last draft run saved"
},
"test.console_app.workflow": {
"bind": {
"app_id": "path",
"attachments": "file",
"files": "file",
"inputs": "body",
"workspace_id": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"attachments": {},
"files": {},
"inputs": {},
"workspace_id": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "sse",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}/draft/workflow:run",
"summary": "Test-run the draft of a workflow app; streams workflow events. A human-input pause in a draft run cannot be resumed over openapi."
},
"test.console_app.advanced_chat": {
"bind": {
"app_id": "path",
"attachments": "file",
"auto_generate_name": "body",
"conversation_id": "body",
"files": "file",
"inputs": "body",
"query": "body",
"workspace_id": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"attachments": {},
"auto_generate_name": {},
"conversation_id": {},
"files": {},
"inputs": {},
"query": {},
"workspace_id": {}
},
"required": ["app_id", "query"]
},
"internal": false,
"kind": "sse",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}/draft/advanced-chat:run",
"summary": "Test-run the draft of an advanced-chat app; streams message and workflow events. A human-input pause in a draft run cannot be resumed over openapi."
},
"check.console_app.dsl": {
"bind": { "app_id": "body", "workspace_id": "path", "yaml_content": "body" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "workspace_id": {}, "yaml_content": {} },
"required": ["workspace_id", "yaml_content"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/apps/imports:check",
"summary": "Check app DSL and list every problem"
},
"check.console_app.release": {
"bind": { "app_id": "path" },
"deprecated": false,
"input": { "type": "object", "properties": { "app_id": {} }, "required": ["app_id"] },
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/apps/{app_id}/release:check",
"summary": "Check whether an app draft is ready to publish and what changed"
},
"get.knowledge_base": {
"bind": { "limit": "query", "page": "query", "query": "query", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "limit": {}, "page": {}, "query": {}, "workspace_id": {} },
"required": ["workspace_id"]
},
"internal": false,
"kind": "list",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/knowledge-bases",
"summary": "List knowledge bases"
},
"describe.service_api": {
"bind": { "app_id": "path" },
"deprecated": false,
"input": { "type": "object", "properties": { "app_id": {} }, "required": ["app_id"] },
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/apps/{app_id}/service-api",
"summary": "Show an app's Service API settings"
},
"set.service_api": {
"bind": { "app_id": "path", "enabled": "body" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "enabled": {} },
"required": ["app_id", "enabled"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/service-api",
"summary": "Turn an app's Service API on or off"
},
"describe.webapp_access": {
"bind": { "app_id": "path" },
"deprecated": false,
"input": { "type": "object", "properties": { "app_id": {} }, "required": ["app_id"] },
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/apps/{app_id}/webapp-access",
"summary": "Show who can open an app's web app"
},
"set.webapp_access": {
"bind": { "access_mode": "body", "app_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "access_mode": {}, "app_id": {} },
"required": ["app_id", "access_mode"]
},
"internal": false,
"kind": "object",
"method": "PUT",
"path": "/openapi/v1/apps/{app_id}/webapp-access",
"summary": "Set who can open an app's web app"
},
"reset.webapp": {
"bind": { "app_id": "path" },
"deprecated": false,
"input": { "type": "object", "properties": { "app_id": {} }, "required": ["app_id"] },
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}/webapp:reset",
"summary": "Reset an app's web app address"
},
"get.node_type": {
"bind": {},
"deprecated": false,
"input": { "type": "object", "properties": {} },
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/node-types",
"summary": "Every node type this server can run; import decides which a mode allows"
},
"describe.node_type": {
"bind": { "node_type": "path" },
"deprecated": false,
"input": { "type": "object", "properties": { "node_type": {} }, "required": ["node_type"] },
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/node-types/{node_type}",
"summary": "The JSON schema of a node type's data and its default config"
},
"get.run": {
"bind": {
"app_id": "path",
"last_id": "query",
"limit": "query",
"status": "query",
"triggered_from": "query"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"last_id": {},
"limit": {},
"status": {},
"triggered_from": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/apps/{app_id}/runs",
"summary": "List runs of a workflow or advanced-chat app, newest first"
},
"get.run.node": {
"bind": { "app_id": "path", "run_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "run_id": {} },
"required": ["app_id", "run_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/apps/{app_id}/runs/{run_id}/nodes",
"summary": "Each node step of a run, to find the one that failed"
},
"publish.console_app": {
"bind": { "app_id": "path", "marked_comment": "body", "marked_name": "body" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "marked_comment": {}, "marked_name": {} },
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/apps/{app_id}:publish",
"summary": "Publish the draft as a new version and make the app use it"
},
"get.console_app.version": {
"bind": { "app_id": "path", "limit": "query", "named_only": "query", "page": "query" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "limit": {}, "named_only": {}, "page": {} },
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/apps/{app_id}/versions",
"summary": "List published versions, newest first"
},
"set.webapp.workflow": {
"bind": {
"app_id": "path",
"copyright": "body",
"custom_disclaimer": "body",
"default_language": "body",
"description": "body",
"enabled": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"privacy_policy": "body",
"show_workflow_steps": "body",
"title": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"copyright": {},
"custom_disclaimer": {},
"default_language": {},
"description": {},
"enabled": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"privacy_policy": {},
"show_workflow_steps": {},
"title": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/webapp/workflow",
"summary": "Change a workflow app's web app; only the fields passed change"
},
"set.webapp.advanced_chat": {
"bind": {
"app_id": "path",
"chat_color_theme": "body",
"chat_color_theme_inverted": "body",
"copyright": "body",
"custom_disclaimer": "body",
"default_language": "body",
"description": "body",
"enabled": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"input_placeholder": "body",
"privacy_policy": "body",
"show_workflow_steps": "body",
"title": "body",
"use_icon_as_answer_icon": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"chat_color_theme": {},
"chat_color_theme_inverted": {},
"copyright": {},
"custom_disclaimer": {},
"default_language": {},
"description": {},
"enabled": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"input_placeholder": {},
"privacy_policy": {},
"show_workflow_steps": {},
"title": {},
"use_icon_as_answer_icon": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/webapp/advanced-chat",
"summary": "Change an advanced-chat app's web app; only the fields passed change"
},
"set.webapp.chat": {
"bind": {
"app_id": "path",
"chat_color_theme": "body",
"chat_color_theme_inverted": "body",
"copyright": "body",
"custom_disclaimer": "body",
"default_language": "body",
"description": "body",
"enabled": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"input_placeholder": "body",
"privacy_policy": "body",
"title": "body",
"use_icon_as_answer_icon": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"chat_color_theme": {},
"chat_color_theme_inverted": {},
"copyright": {},
"custom_disclaimer": {},
"default_language": {},
"description": {},
"enabled": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"input_placeholder": {},
"privacy_policy": {},
"title": {},
"use_icon_as_answer_icon": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/webapp/chat",
"summary": "Change a chat app's web app; only the fields passed change"
},
"set.webapp.agent_chat": {
"bind": {
"app_id": "path",
"chat_color_theme": "body",
"chat_color_theme_inverted": "body",
"copyright": "body",
"custom_disclaimer": "body",
"default_language": "body",
"description": "body",
"enabled": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"input_placeholder": "body",
"privacy_policy": "body",
"title": "body",
"use_icon_as_answer_icon": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"chat_color_theme": {},
"chat_color_theme_inverted": {},
"copyright": {},
"custom_disclaimer": {},
"default_language": {},
"description": {},
"enabled": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"input_placeholder": {},
"privacy_policy": {},
"title": {},
"use_icon_as_answer_icon": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/webapp/agent-chat",
"summary": "Change an agent-chat app's web app; only the fields passed change"
},
"set.webapp.completion": {
"bind": {
"app_id": "path",
"copyright": "body",
"custom_disclaimer": "body",
"default_language": "body",
"description": "body",
"enabled": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"privacy_policy": "body",
"title": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"copyright": {},
"custom_disclaimer": {},
"default_language": {},
"description": {},
"enabled": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"privacy_policy": {},
"title": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/webapp/completion",
"summary": "Change a completion app's web app; only the fields passed change"
},
"set.webapp.agent": {
"bind": {
"app_id": "path",
"chat_color_theme": "body",
"chat_color_theme_inverted": "body",
"copyright": "body",
"custom_disclaimer": "body",
"default_language": "body",
"description": "body",
"enabled": "body",
"icon": "body",
"icon_background": "body",
"icon_type": "body",
"input_placeholder": "body",
"privacy_policy": "body",
"title": "body",
"use_icon_as_answer_icon": "body"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"app_id": {},
"chat_color_theme": {},
"chat_color_theme_inverted": {},
"copyright": {},
"custom_disclaimer": {},
"default_language": {},
"description": {},
"enabled": {},
"icon": {},
"icon_background": {},
"icon_type": {},
"input_placeholder": {},
"privacy_policy": {},
"title": {},
"use_icon_as_answer_icon": {}
},
"required": ["app_id"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/webapp/agent",
"summary": "Change an agent app's web app; only the fields passed change"
},
"set.service_api.agent": {
"bind": { "app_id": "path", "enabled": "body" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "app_id": {}, "enabled": {} },
"required": ["app_id", "enabled"]
},
"internal": false,
"kind": "object",
"method": "PATCH",
"path": "/openapi/v1/apps/{app_id}/service-api/agent",
"summary": "Turn an agent app's Service API on or off"
},
"set.webapp_access.agent": {
"bind": { "access_mode": "body", "app_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "access_mode": {}, "app_id": {} },
"required": ["app_id", "access_mode"]
},
"internal": false,
"kind": "object",
"method": "PUT",
"path": "/openapi/v1/apps/{app_id}/webapp-access/agent",
"summary": "Set who may open an agent app's web app (Enterprise)"
},
"get.marketplace.plugin": {
"bind": {
"category": "query",
"limit": "query",
"page": "query",
"query": "query",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": { "category": {}, "limit": {}, "page": {}, "query": {}, "workspace_id": {} },
"required": ["workspace_id"]
},
"internal": false,
"kind": "list",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/marketplace/plugins",
"summary": "Search the marketplace for plugins to install; each hit says if it is installed here"
},
"get.plugin": {
"bind": { "category": "query", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "category": {}, "workspace_id": {} },
"required": ["workspace_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/plugins",
"summary": "Plugins installed in the workspace, with the provider ids each one adds"
},
"install.plugin": {
"bind": { "identifiers": "body", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "identifiers": {}, "workspace_id": {} },
"required": ["workspace_id", "identifiers"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/plugins:install",
"summary": "Start installing marketplace plugins; finishes in the background, check describe.plugin.task"
},
"describe.plugin.task": {
"bind": { "task_id": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "task_id": {}, "workspace_id": {} },
"required": ["task_id", "workspace_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/plugin-tasks/{task_id}",
"summary": "Progress of a plugin install or upgrade; check again while pending or running"
},
"delete.plugin": {
"bind": { "plugin_id": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "plugin_id": {}, "workspace_id": {} },
"required": ["plugin_id", "workspace_id"]
},
"internal": false,
"kind": "object",
"method": "DELETE",
"path": "/openapi/v1/workspaces/{workspace_id}/plugins/{plugin_id}",
"summary": "Uninstall a plugin from the workspace"
},
"describe.model_provider": {
"bind": { "provider": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "provider": {}, "workspace_id": {} },
"required": ["provider", "workspace_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/model-providers/{provider}",
"summary": "One model provider with its credential form and saved credentials (names only)"
},
"create.model_provider.credential": {
"bind": { "credentials": "body", "name": "body", "provider": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "credentials": {}, "name": {}, "provider": {}, "workspace_id": {} },
"required": ["provider", "workspace_id", "credentials"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/model-providers/{provider}/credentials",
"summary": "Save a credential for a model provider; the provider checks it first"
},
"get.model": {
"bind": {
"limit": "query",
"model_type": "query",
"page": "query",
"provider": "query",
"query": "query",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"limit": {},
"model_type": {},
"page": {},
"provider": {},
"query": {},
"workspace_id": {}
},
"required": ["workspace_id"]
},
"internal": false,
"kind": "list",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/models",
"summary": "Models in the workspace across providers, with whether each can be used now"
},
"create.model.credential": {
"bind": {
"credentials": "body",
"model": "body",
"model_type": "body",
"name": "body",
"provider": "path",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"credentials": {},
"model": {},
"model_type": {},
"name": {},
"provider": {},
"workspace_id": {}
},
"required": ["provider", "workspace_id", "model", "model_type", "credentials"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/model-providers/{provider}/models/credentials",
"summary": "Save a credential for one model you name, for providers such as OpenAI-compatible or Ollama"
},
"get.tool": {
"bind": {
"limit": "query",
"page": "query",
"provider": "query",
"provider_type": "query",
"query": "query",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"limit": {},
"page": {},
"provider": {},
"provider_type": {},
"query": {},
"workspace_id": {}
},
"required": ["workspace_id"]
},
"internal": false,
"kind": "list",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/tools",
"summary": "Tools a workflow can call here, each with a ready tool node data"
},
"describe.tool_provider": {
"bind": { "provider": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "provider": {}, "workspace_id": {} },
"required": ["provider", "workspace_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/tool-providers/{provider}",
"summary": "One tool provider with its API-key form and saved credentials (names only)"
},
"create.tool_provider.credential": {
"bind": { "credentials": "body", "name": "body", "provider": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "credentials": {}, "name": {}, "provider": {}, "workspace_id": {} },
"required": ["provider", "workspace_id", "credentials"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/tool-providers/{provider}/credentials",
"summary": "Save an API-key credential for a tool provider; the provider checks it first"
}
}
}
+219
View File
@@ -0,0 +1,219 @@
import { readdir, readFile } from 'node:fs/promises'
import { dirname, join, normalize, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vite-plus/test'
import { callOptionsSchema } from '@/call/flags'
import { commandTree } from '@/commands/tree.generated'
import { HELP_WORD } from '@/plugins/commands/command'
import { GLOBAL_INPUT } from '@/plugins/global-flags'
import { RESERVED_FLAGS } from '@/plugins/ops/command'
import { propertiesOf } from '@/protocol/shape'
import { flagFor } from '@/util/flag-name'
type CatalogOp = { kind: string; input?: { properties?: Record<string, unknown> } }
const SKILL_DIR = fileURLToPath(new URL('../../../skills/difyctl/', import.meta.url))
const CATALOG = fileURLToPath(new URL('../fixtures/catalog.json', import.meta.url))
const ROOT_DOC = 'SKILL.md'
const MAX_HOPS = 2
const LEAF_DEPTH = MAX_HOPS
const MAP_MAX_LINES = 60
const TOC_AFTER_LINES = 100
const TOC_HEADING = '## Contents'
const H2 = /^## .*$/m
const LINK = /\]\(([^)#\s]+\.md)\)/g
const COMMAND = /`difyctl ([^`]+)`/g
const FENCE = /^(`{3,})/
const CONTINUATION = /\\\s*\n\s*/g
const CODE_COMMAND = /^difyctl (.+)$/
const PIPE = ' | '
const FLAG = /(?:^|\s)--([a-z][a-z0-9-]*)/g
const PLACEHOLDER = /^<[^>]+>$/
const BUILTIN_ROOTS = new Set(['help'])
const WORD = /^[a-z_]+(?:-[a-z_]+)*$/
const DOTTED_ID = /^[a-z_]+(?:\.[a-z_]+)+$/
const SPAN = /`([^`]+)`/g
const NON_WORD_START = /^[-<]/
const GLOBAL_FLAGS = [...Object.keys(GLOBAL_INPUT.shape), HELP_WORD]
async function docs(): Promise<string[]> {
const out: string[] = []
for (const entry of await readdir(SKILL_DIR, { recursive: true, withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.md'))
out.push(relative(SKILL_DIR, join(entry.parentPath, entry.name)).split(sep).join('/'))
}
return out
}
function links(doc: string, text: string): string[] {
return [...text.matchAll(LINK)].map((m) =>
normalize(join(dirname(doc), m[1] as string))
.split(sep)
.join('/'),
)
}
function depths(text: ReadonlyMap<string, string>): Map<string, number> {
const depth = new Map([[ROOT_DOC, 0]])
const queue = [ROOT_DOC]
while (queue.length > 0) {
const doc = queue.shift() as string
for (const next of links(doc, text.get(doc) ?? '')) {
if (depth.has(next)) continue
depth.set(next, (depth.get(doc) as number) + 1)
queue.push(next)
}
}
return depth
}
/** `difyctl` command lines inside fenced code blocks, with `\` continuations joined. */
function fencedCommands(body: string): string[] {
const code: string[] = []
let fence: string | undefined
for (const line of body.split('\n')) {
const open = FENCE.exec(line.trim())?.[1]
if (fence === undefined) {
if (open !== undefined) fence = open
} else if (open !== undefined && open.length >= fence.length && line.trim() === open) {
fence = undefined
} else {
code.push(line)
}
}
return code
.join('\n')
.replace(CONTINUATION, ' ')
.split('\n')
.map((line) => CODE_COMMAND.exec(line.trim())?.[1])
.filter((span): span is string => span !== undefined)
}
function commandWords(span: string): string[] {
const words: string[] = []
for (const word of span.split(' ')) {
if (!WORD.test(word)) break
words.push(word.replaceAll('-', '_'))
}
return words
}
function isLocal(words: readonly string[]): boolean {
if (BUILTIN_ROOTS.has(words[0] as string)) return true
let node: { subcommands?: Record<string, unknown> } | undefined = { subcommands: commandTree }
for (const word of words) {
node = node?.subcommands?.[word] as typeof node
if (node === undefined) return false
}
return true
}
function isOpOrNamespace(words: readonly string[], ops: readonly string[]): boolean {
const full = words.join('.')
return ops.includes(full) || ops.some((op) => op.startsWith(`${full}.`))
}
/**
* The ops a command line names: the exact op, or — when a `<placeholder>` follows a
* namespace — every op one word below it.
*/
function opsNamed(span: string, words: readonly string[], ops: readonly string[]): string[] {
const full = words.join('.')
if (ops.includes(full)) return [full]
const next = span.split(' ')[words.length] ?? ''
if (!PLACEHOLDER.test(next)) return []
return ops.filter((op) => op.startsWith(`${full}.`) && op.split('.').length === words.length + 1)
}
function flagsOf(op: CatalogOp): Set<string> {
const fields = Object.keys(op.input?.properties ?? {}).filter(
(name) => !RESERVED_FLAGS.includes(name),
)
const options = Object.keys(propertiesOf(callOptionsSchema(op.kind)))
return new Set([...fields, ...options, ...GLOBAL_FLAGS].map(flagFor))
}
function typedFlags(span: string): string[] {
return [...(span.split(PIPE)[0] as string).matchAll(FLAG)].map((m) => m[1] as string)
}
describe('skills/difyctl tree', async () => {
const all = await docs()
const text = new Map(
await Promise.all(
all.map(async (d) => [d, await readFile(join(SKILL_DIR, d), 'utf8')] as const),
),
)
const catalog = (
JSON.parse(await readFile(CATALOG, 'utf8')) as { ops: Record<string, CatalogOp> }
).ops
const ops = Object.keys(catalog)
const depth = depths(text)
const commands = [...text].flatMap(([doc, body]) => [
...[...body.matchAll(COMMAND)].map((m) => ({ doc, span: m[1] as string })),
...fencedCommands(body).map((span) => ({ doc, span })),
])
it('every relative link resolves', () => {
for (const [doc, body] of text)
for (const target of links(doc, body)) expect(all, `${doc} → ${target}`).toContain(target)
})
it('every doc is reachable within two hops of SKILL.md', () => {
for (const doc of all) {
expect(depth.has(doc), `${doc} unreachable`).toBe(true)
expect(depth.get(doc) as number, doc).toBeLessThanOrEqual(MAX_HOPS)
}
})
it('maps stay short and leaves link only to siblings', () => {
for (const [doc, body] of text) {
if ((depth.get(doc) as number) < LEAF_DEPTH) {
expect(body.split('\n').length, doc).toBeLessThan(MAP_MAX_LINES)
continue
}
for (const target of links(doc, body))
expect(dirname(target), `${doc} → ${target}`).toBe(dirname(doc))
}
})
it('long leaves open with contents', () => {
for (const [doc, body] of text) {
if ((depth.get(doc) as number) < LEAF_DEPTH) continue
if (body.split('\n').length <= TOC_AFTER_LINES) continue
expect(H2.exec(body)?.[0], doc).toBe(TOC_HEADING)
}
})
it('every difyctl command exists locally or in the catalog', () => {
for (const { doc, span } of commands) {
const words = commandWords(span)
if (words.length === 0) {
expect(span, `${doc}: difyctl ${span} names no command`).toMatch(NON_WORD_START)
continue
}
expect(isLocal(words) || isOpOrNamespace(words, ops), `${doc}: difyctl ${span}`).toBe(true)
}
})
it('every flag on an op command is a field of that op or a CLI flag', () => {
for (const { doc, span } of commands) {
const named = opsNamed(span, commandWords(span), ops)
if (named.length === 0) continue
const allowed = new Set(named.flatMap((id) => [...flagsOf(catalog[id] as CatalogOp)]))
for (const flag of typedFlags(span))
expect(allowed.has(flag), `${doc}: difyctl ${span}: --${flag}`).toBe(true)
}
})
it('every dotted op id exists in the catalog', () => {
const verbs = new Set(ops.map((op) => op.split('.')[0]))
for (const [doc, body] of text) {
for (const m of body.matchAll(SPAN)) {
const span = m[1] as string
if (!DOTTED_ID.test(span) || !verbs.has(span.split('.')[0])) continue
expect(ops, `${doc}: ${span}`).toContain(span)
}
}
})
})
+29 -21
View File
@@ -1,26 +1,34 @@
# difyctl skills
# difyctl skill
Agent skills for the difyctl CLI. They use the standard Agent Skills layout: one
folder per skill, each with a `SKILL.md`. There is no index to maintain.
One agent skill for the difyctl CLI: `skills/difyctl/`. It uses the standard
Agent Skills layout. `SKILL.md` is the root; the docs it links to sit under
`references/`.
`difyctl install skills <dir>` downloads them from GitHub at install time, from
the commit that difyctl was built from. `--from` points at any GitHub folder
with the same layout, or at a local folder. The same files install through the
Vercel installer: `npx skills add langgenius/dify --skill difyctl -g`.
The difyctl binary carries a built-in copy. `difyctl install skills <dir>`
writes that copy into `<dir>`. `--from <folder>` installs a local folder with
the same layout instead. The same files install through the Vercel installer:
`npx skills add langgenius/dify --skill difyctl -g`.
## Adding a skill
## Changing the skill
- One folder per skill: `difyctl` is the base skill, scenario skills are
`difyctl-<scenario>`.
- `SKILL.md` frontmatter has `name` (equal to the folder name: lowercase
letters, digits, dashes) and `description`. The description says when to use
the skill; the base skill quotes it.
- Any files may sit beside `SKILL.md`: `references/`, `scripts/`, `assets/`.
Commit scripts as executable (`chmod +x`); the installer keeps the bit.
- A scenario skill opens with: Read `../difyctl/SKILL.md` first.
- Every `difyctl <command>` named in backticks must exist in the command tree.
Every dotted op id must exist in `cli/test/fixtures/catalog.json`.
- List every scenario skill in the base skill's "Other skills" section.
The skill is a tree of linked docs, so an agent reads only the branch it needs.
A change here reaches users with the next difyctl build. Anyone can get it
sooner with `--from https://github.com/langgenius/dify/tree/main/skills`.
- `SKILL.md` frontmatter has `name` (`difyctl`) and `description`. The
description says when to use the skill.
- `SKILL.md` and the docs it links to are maps. Keep each map under 60 lines.
- The docs one level further down are leaves. A leaf may link only to other
leaves in its own folder.
- Every doc is at most 2 hops from `SKILL.md`.
- Write links as "Read X when ...", so the agent knows when to follow them.
- In a leaf over 100 lines, `## Contents` is the first `##` heading.
- Put each fact in one place and link to it from elsewhere.
- Any file in `skills/difyctl/` ships. Files starting with `#!` are installed
executable.
`cli/test/skills/tree.test.ts` checks links, depth, sizes, and every `difyctl`
command, in backticks or in code blocks. Each command must exist locally or in
`cli/test/fixtures/catalog.json`, and each flag on an operation must be one of
its input fields or a CLI flag. Add an op's descriptor there when a doc starts
naming it.
A change here reaches users with the next difyctl build.
+6 -31
View File
@@ -1,6 +1,6 @@
---
name: difyctl
description: 'Base skill for the difyctl CLI: find any command or Dify operation through help and run it as a command. Use when the task involves difyctl or a Dify server from the command line.'
description: 'Operate a Dify server through difyctl: find and run any operation, and build, test, publish and change Workflow and Chatflow apps. Use when the task involves difyctl, a Dify server, or Dify apps.'
---
# difyctl
@@ -24,36 +24,11 @@ Four views under `help`, covering local commands and server operations as one tr
Not logged in, or the server unreachable, gives the local commands plus one line on stderr.
## Running an operation
`difyctl run console_app workflow --app-id <id> --inputs '{"a":1}'`. Each top-level field of `input` is a flag, dashes for underscores. Scalars take a value; objects, maps and lists of objects take a JSON literal or `@file.json`; lists of scalars repeat the flag. Fields named `input`, `stream`, `only`, `output`, `verbose`, `json` or `help` have no flag; pass them through `--input`.
`--input '{"a":1}'`, `--input @file.json` or `--input @-` (stdin) sends the whole body; field flags merge over it. A dotted id is a command with the dots as spaces: `run.console_app.workflow` is `difyctl run console_app workflow`.
Run an app with the op for its mode: `run console_app workflow`, `run console_app chat`, `run console_app advanced_chat`, `run console_app completion`. First `difyctl describe console_app --app-id <id> --json` and read `input_schema`; that is the shape of `inputs`. Local files go under `files`, keyed by the app's file variable name (`{"files":{"doc":"./r.pdf"}}` or a list of paths); files for the run itself under `attachments`; URLs straight into `inputs`.
## difyctl-only flags
`--stream` prints each event of a streaming op as one JSON line instead of folding them. `--only <event>` filters streamed events (repeatable). `--output <path>` saves a file-kind response; the default is the current directory under the server's filename, or `<op-id>.<ext>`. `--verbose` adds the raw server response to errors. A flag the operation's kind does not support exits 2.
## Reading results
Streaming ops fold to `{status, text, outputs?, total_tokens?, message_id?, conversation_id?, error?, hints}`. `text.answer` is the reply; `text.by_source` appears only while `status` is `incomplete`. `status` is `ended`, `failed` (exit 1), `suspended` (a form is waiting; follow `hints`) or `incomplete` (the stream broke early). Any response may carry `hints: [{summary, op, input, form?}]`, the server's next steps: next page, confirm step, reply op, form submit. Fill any `null` in `input` and pass it back with `difyctl <op, dots as spaces> --input '<input>'`.
## Errors and exit codes
Errors are one envelope on stderr: `{"error":{"code","message","hint"?,"details"?,"schema"?}}`. Exit 2 is bad input (schema included), 4 not logged in or forbidden, 6 catalog unavailable or unknown op (run `difyctl refresh cache` or upgrade difyctl), 7 rate limited.
## Login and environment
`difyctl login --server https://...` (device flow; `--no-browser` prints the URL and code; `--no-keyring` keeps the token in a file; `--insecure` skips TLS verification). One login at a time. `difyctl use workspace <id>` pins a workspace; `difyctl get workspace` shows them. Scripts skip login with `DIFY_SERVER` and `DIFY_TOKEN`; `DIFY_WORKSPACE_ID` overrides the pin; `DIFY_CONFIG_DIR` and `DIFY_CACHE_DIR` move the files.
`login` blocks until the browser approval arrives. Run `difyctl login --server <url> --no-browser` as a background job, relay the `open <url>` and `code <code>` lines from its stderr to the user, and do not cancel the job. Its exit code reports the result. In a sandbox, add `--no-keyring` and set `DIFY_CONFIG_DIR` to persistent storage on every call, so the login survives the next session. If background jobs do not survive between calls, run `login --no-wait` instead, relay the `verification_uri` and `user_code` it prints, and after the user approves run `login --resume` until it stops printing `status: pending`.
## Destructive operations
Operations that remove or revoke have no confirmation prompt. Confirm with the person first.
## Other skills
## Where to read next
Scenario skills add guidance for one kind of task. `difyctl get skills` shows them; `difyctl install skills <skills root> --skill <name>` installs one, where `<skills root>` is the folder above this file's folder. Re-running `difyctl install skills <skills root>` after upgrading difyctl refreshes every skill.
- Read [`references/setup.md`](references/setup.md) to check login or log in, switching server or workspace, or running in a sandbox or CI.
- Read [`references/operations.md`](references/operations.md) before running any operation: flags, inputs, files, results, hints, errors and exit codes.
- Read [`references/build-workflow.md`](references/build-workflow.md) when asked to create, change, test or publish a Workflow or Chatflow app.
- Read [`references/plugins.md`](references/plugins.md) to find tools, models and knowledge bases before building, when a plugin, model, tool or credential is missing, or when the user asks to install or remove a plugin. Prefer an existing tool over an HTTP Request or Code node; the human picks.
@@ -0,0 +1,33 @@
# Build a Workflow or Chatflow app
Four phases. Each ends with a gate; never skip one, even for a small app.
1. **Spec**: agree what the app must do. Gate: the human approves `spec.md`.
2. **Plan**: decide every node and config. Gate: the human approves `plan.md` and chooses how to build.
3. **Build**: create the app and build it slice by slice. Gate: each slice passes its tests and review.
4. **Hand over**: final checks, then publish and open access. Gate: the human says yes to going live.
## Rules
- Keep the working files in `difyctl/<app-slug>/` in the current folder: `spec.md`, `plan.md`, `app.yml`, and `live.yml` when changing an existing app.
- Requirement ids (`R1`, `R2`, …) from the spec run through the plan, the build and the final report.
- One writer per draft at a time.
- Nothing goes live without the human's yes.
- **Report problems; never work around them.** When something blocks the agreed design, stop and tell the human what you found and the options. Examples: a tool that isn't configured, a missing credential or plugin, a requirement a node can't meet, a check you can't pass. Never swap in a substitute on your own: an HTTP Request or Code node in place of a tool, prompt text in place of a knowledge base, a dropped or weakened requirement.
- **Every change from the approved spec or plan needs the human's yes before you build it.** List each one: what changes, why, and what it costs. "Use defaults" or "go" approves only the options you already showed with your recommendation. It never approves a substitute you didn't show.
## Read next
- [`build-workflow/change.md`](build-workflow/change.md): read first when the app already exists.
- [`build-workflow/spec.md`](build-workflow/spec.md): read in the spec phase.
- [`build-workflow/plan.md`](build-workflow/plan.md): read in the plan phase.
- [`build-workflow/build.md`](build-workflow/build.md): read in the build phase.
- [`build-workflow/handover.md`](build-workflow/handover.md): read when the build is done.
- [`build-workflow/dsl.md`](build-workflow/dsl.md): read when writing or reading app YAML.
- Read [`plugins.md`](plugins.md) in the spec phase to find tools, models and knowledge bases, and when a plugin, model, tool or credential is missing.
## Limits
- A draft run paused on a human-input node can't be resumed over difyctl.
- Trigger-started workflows (webhook, schedule, plugin) can't be draft-tested.
- Loop and iteration nodes can't be tested alone; use a full draft run.
@@ -0,0 +1,80 @@
# Build phase
Start only when the human approved the plan and chose how to build. `<mode>` is `workflow` for a Workflow app and `advanced_chat` for a Chatflow app.
## Before the first slice
When you change an existing app, skip creating it and the skeleton slice. Start from the current draft.
Otherwise create the app once, and write its `app_id` into `plan.md`:
```bash
difyctl create console_app <mode> --name "<name>" --json
```
Build this one app; never make copies.
## Each slice
1. Export the draft. Keep `draft_hash`. Save `data` as `app.yml` on the first slice.
```bash
difyctl export console_app dsl --app-id <app_id> --json
```
2. Write the slice's nodes and edges into `app.yml` from the node table. Make no new decisions. Read [dsl.md](dsl.md).
3. Check the YAML. Fix every `error` by its `loc` and check again until `valid` is true.
```bash
jq -Rs '{yaml_content: .}' difyctl/<app-slug>/app.yml |
difyctl check console_app dsl --app-id <app_id> --input @- --json
```
4. Import over the draft. Always send the YAML through stdin.
```bash
jq -Rs '{yaml_content: .}' difyctl/<app-slug>/app.yml |
difyctl import console_app dsl --app-id <app_id> --mode yaml-content --draft-hash <draft_hash> --input @- --json
```
- `completed`: go on. Export again before the next import; the hash changed.
- `completed-with-warnings`: stop and read `warnings`.
- `pending`: the DSL version differs from the server's. Show the human both versions. On a yes, run `difyctl confirm console_app dsl_import --import-id <id> --json`.
- `dsl_invalid`: fix the issues in `details` by `loc`.
- The draft changed since your export: someone else edited it. Export again, show the human the difference, and never overwrite it.
- Any other failure: stop and show the human the message.
5. Test each new node alone. It reuses the last full run's values; override them with `--inputs`, keyed `#<node_id>.<var>#`. Give a file variable a local file with `--files '{"<variable>": "<path>"}'`.
```bash
difyctl test node <mode> --app-id <app_id> --node-id <node_id> --inputs '{"#<node_id>.<var>#": "<value>"}' --json
```
6. Run the whole draft on the slice's acceptance cases; check each by its check type. Chatflow also takes `--query "<message>"`. Leave out `--inputs` when the start node has no variables.
```bash
difyctl test console_app <mode> --app-id <app_id> --inputs '{"<var>": "<value>"}' --json
```
7. Review the slice against the plan, and the YAML against the node table.
8. Tick the slice in `plan.md` with its run ids. A new session resumes at the first unticked slice.
## When something fails
Find the node that failed and what it received:
```bash
difyctl get run node --app-id <app_id> --run-id <run_id> --json
```
A fix that changes the plan or spec follows the next section.
## Changes to the plan or spec
Never build a change first and report it later. For each change:
1. Stop and tell the human what changes, why, and what it costs.
2. On a yes, update the plan (and the spec, if it changes), then the YAML.
3. Log it under "Changes during build" with when the human approved it.
The build phase never publishes.
@@ -0,0 +1,44 @@
# Change an existing app
Use this path when the app already exists. It reuses the four phases.
1. Find the app and confirm with the human that it is the right one:
```bash
difyctl get console_app --name <name> --json
```
An app whose Service API is off does not show up here, and `describe console_app` cannot read it. If the app is missing, ask the human for its id and whether its Service API is off.
2. Export first.
- List the versions. Note the `id` of the row with `current: true`: that is the live version.
- Export that version and save it as `difyctl/<app-slug>/live.yml`. This is the live baseline.
- Export the draft separately and keep its `draft_hash`. An export without `--workflow-id` returns the draft, not the live version.
- Export exits 4 when the app's Service API is off. Ask an admin to turn it on.
```bash
difyctl get console_app version --app-id <app_id> --json
difyctl export console_app dsl --app-id <app_id> --workflow-id <version_id> --json | jq -r .data > difyctl/<app-slug>/live.yml
difyctl export console_app dsl --app-id <app_id> --json
```
If the draft differs from the live version, show the human the difference. Ask which one to start from. If the human picks live, import `live.yml` over the draft with the draft's `draft_hash`. Check the result as in [build.md](build.md).
```bash
jq -Rs '{yaml_content: .}' difyctl/<app-slug>/live.yml |
difyctl import console_app dsl --app-id <app_id> --mode yaml-content --draft-hash <draft_hash> --input @- --json
```
Publishing replaces the live version, so that version id is the one to go back to. To go back, copy it into the draft, then publish:
```bash
difyctl restore console_app version --app-id <app_id> --version-id <version_id> --json
difyctl publish console_app --app-id <app_id> --json
```
If restore fails, export the draft for a fresh `draft_hash`, import `live.yml` over the draft instead, then publish.
3. If `difyctl/<app-slug>/` has no `spec.md`, the app was built elsewhere. Rebuild a baseline spec from the export: the graph, the requirements you can infer, the inputs and outputs. The human confirms it.
4. Write the change as a "Change" section in the spec: new, changed and removed requirements and cases. Existing cases become regression cases.
5. Plan only the nodes that change, in slices as usual. After you edit the export, run `difyctl check console_app dsl` on it before every import, as in [build.md](build.md).
6. At the end, run every old case again. Then hand over as usual.
@@ -0,0 +1,39 @@
# App DSL
Start from an export of the app's draft: a new app's export is a valid empty skeleton. Keep its `version`, `kind`, `features`, `environment_variables` and `conversation_variables`.
## Where things go
- `app.mode` is `workflow` for a Workflow app and `advanced-chat` for a Chatflow app.
- `workflow.graph.nodes[]`: `{id, type: custom, data: {type, title, version, ...}, position: {x, y}}`. The rest of `data`, and `version`, come from `difyctl describe node_type --node-type <type>`.
- `workflow.graph.edges[]`: `{id, source, target, sourceHandle, targetHandle: target}`. `sourceHandle` is `source`, except on branch nodes:
- if-else: each case's `case_id`, plus `false` for else;
- question classifier: each class's `id`;
- human input: each `user_actions` id, plus `__timeout`;
- `error_strategy: fail-branch`: `source` for success, `fail-branch` for failure.
- Give every branch an edge, including `false` and `__timeout`.
- End node output names must be unique across all End nodes.
- A node id is any unique string. Never change the id of an existing node.
- Read another node's output as `{{#<node_id>.<var>#}}` in text, and `[<node_id>, <var>]` in a `value_selector`.
- An LLM node's `model` is the `node_model` of a row from `difyctl get model --model-type llm`, as the plan names it.
- A knowledge-retrieval node starts from a row's `node_data` in `difyctl get knowledge_base`.
- Never put secret values in the DSL.
- Don't use `datasource` or `knowledge-index` nodes. They belong to knowledge pipelines.
- An import over an existing app copies the YAML's `app.name`, description and icon onto the app.
- Import fills empty containers, such as Start `variables` and tool parameter maps. It never fills in a model, classes or a prompt.
## Tool node
- Start from the tool's `node_data` in `difyctl get tool`. Put it in the node's `data` as it is.
- Keep `tool_node_version: "2"`. Every value in `tool_parameters` and `tool_configurations` stays `{type, value}`.
- Fill every `null` value: `{type: constant, value: …}`, `{type: mixed, value: "{{#node_id.var#}}"}`, or `{type: variable, value: [node_id, var]}`.
- Leave out `credential_id`; the workspace's default credential is used.
## The check
`difyctl check console_app dsl` and the import run the same check. Each issue has a `code`, a `severity`, a `node_id`, a `loc` that points to the field, and a `message`.
- An `error` stops the import. Fix it by its `loc`.
- A `warning` does not stop the import, but the release check fails on it. Fix it, or report it to the human:
- `resource_unavailable`: a model, tool or plugin the workspace lacks. Runs fail on it.
- `branch_unconnected`: a branch with no edge. A run that takes it stops there.
@@ -0,0 +1,47 @@
# Hand-over phase
Start when every slice in `plan.md` is ticked. `<mode>` is `workflow` or `advanced_chat`.
1. Run every acceptance case in the spec on the draft. Report pass or fail per requirement and per case, with your reasoning for each `judged` case.
2. Check the release, after the acceptance run so `tested` covers the current draft:
```bash
difyctl check console_app release --app-id <app_id> --json
```
Fix every item in `issues`. If `tested` fails, run the draft again. Go on only when `ready` is true.
3. Stop at the human gate. Show in plain words: the `changes` against the published version, the `checks`, the acceptance results, the "Changes during build" list, and anything still open. Nothing goes live without a yes.
4. On a yes, publish. Show any `warning` in the result. Then confirm the row whose `id` is the result's `version_id` shows `current: true`:
```bash
difyctl publish console_app --app-id <app_id> --json
difyctl get console_app version --app-id <app_id> --json
```
5. Settle access. A new app starts with the web app and the Service API on. Ask the human about each one. `--enabled=false` turns a setting off. Agent apps use the `agent` variant of each command.
- Web app. When it stays on, give the human the `url` from the result.
```bash
difyctl set webapp <mode> --app-id <app_id> --enabled --json
```
- Service API (needs an admin). Warn the human first: with it off, difyctl can't export, test, run, publish or find this app; only import still works. Note the app id first; an admin turns it back on with `--enabled`.
```bash
difyctl set service_api --app-id <app_id> --enabled --json
```
- Who may open the web app (Enterprise): `public`, `private_all` or `sso_verified`. Picking members or groups (`private`) is done in the Dify console. `difyctl run` obeys this too, so the logged-in account must be allowed for the smoke test.
```bash
difyctl set webapp_access --app-id <app_id> --access-mode <access_mode> --json
```
6. Smoke test the live app with one acceptance case. Chatflow also takes `--query "<message>"`.
```bash
difyctl run console_app <mode> --app-id <app_id> --inputs '{"<var>": "<value>"}' --json
```
7. Close. Mark `plan.md` as published with the `version_id`, the date and the url. Give the human a short summary that lists every change from the spec and plan, and when the human approved it.
@@ -0,0 +1,58 @@
# Plan phase
Start only when the human has approved `spec.md`. The plan phase ends when the human approves `difyctl/<app-slug>/plan.md` and chooses how to build.
## How to plan
1. Turn each spec step into nodes. Use only node types `difyctl get node_type` lists. Read each type's schema, defaults and `version` with `difyctl describe node_type --node-type <type>`.
2. Settle every config in full, so the build only changes format:
- LLM: model, prompt text and outputs.
- If-else: conditions, and where each branch goes.
- Tool: the row from `difyctl get tool --provider <id>`, and a value for every parameter. Never guess one.
- Human input: the form, the actions, the timeout, and where each action and `__timeout` goes.
- Code: the code itself.
- Variable references, written as `{{#node_id.var#}}`.
3. Draw the node-level Mermaid graph with real node ids, every edge, and a label on every branch.
4. Cut the build into slices. Each slice is small and connected, and imports and runs on its own. Slice 1 is the skeleton: start → end (Chatflow: start → answer) with the final inputs and outputs.
5. Map the tests: a `difyctl test node <mode>` call with inputs for each new node, and a full draft run (`difyctl test console_app <mode>`) at the end of each slice on the cases it can pass. `<mode>` is `workflow` or `advanced_chat`.
6. Check coverage. Every requirement maps to a node and a test. Every resource is confirmed, and each chosen plugin is installed and configured. An open resource blocks approval.
7. Ask the human to review the plan, and to choose how to build:
- you build it alone; or
- one fresh subagent per slice, one after another, given the slice text, the file paths and the `app_id`. A reviewer subagent checks each slice. Never run two on one app at once: their imports collide on the draft hash.
## Template
Copy this into `plan.md` and fill it in.
````markdown
# <App name>: plan
App: <name>. Mode: <workflow or advanced_chat>. `app_id`: <filled in at build>. Spec: spec.md.
## Node graph
```mermaid
flowchart LR
start --> classify
classify -- order --> order_llm --> answer
classify -- other --> answer
```
## Node table
| Id | Type | Version | Title | Config | Inputs used | Outputs | Covers |
| --------- | ---- | ------- | ----------- | ---------------------------------------------------- | ----------------- | ------- | ------ |
| order_llm | llm | 1 | Order reply | model: <provider>/<model>; system prompt: "<prompt>" | `{{#sys.query#}}` | text | R1 |
## Slices
- [ ] Slice 2: add `classify`, `order_llm`. Covers R1. Node tests: `order_llm` with query "Où est ma commande ?". Full run: A1. Runs: <run ids>
## Test map
- A1: `test console_app advanced_chat`, query "Où est ma commande ?", judged.
## Changes during build
- <what changed>: <why>. Cost if wrong: <cost>. Approved: <when>.
````
@@ -0,0 +1,64 @@
# Spec phase
The spec phase ends when the human approves `difyctl/<app-slug>/spec.md`. Don't plan before that.
## How to run the spec talk
1. Start from intent: what the app is for, who uses it, what a good result looks like. Ask one question at a time, with choices where you can.
2. Choose the mode early and say why. Workflow: one run, inputs to outputs. Chatflow: a conversation with memory.
3. As you talk, keep two things:
- **Requirements.** Each condition or feature the human states becomes `R<n>`, in their words, typed `feature` (something the app does), `rule` (a condition or limit) or `failure` (what happens when something goes wrong).
- **Process graph.** A Mermaid graph of business steps, not nodes.
4. Walk 3 to 6 realistic cases through the graph with the human. Include edge cases: empty input, a tool failing, an answer too long.
5. Collect resources: models, tools and plugins, knowledge bases, credentials.
- For each step that calls an outside service or needs a model, search first: `difyctl get tool --query <service>`, `difyctl get model --model-type llm`, `difyctl get knowledge_base --query <words>`, then `difyctl get marketplace plugin --query <service>`.
- Show the human a short list per step: name, brief, publisher type, install count, installed and configured or not. Recommend an installed and configured tool first, then an official or partner plugin, then a workflow tool. List HTTP Request or Code last.
- If the best option needs something from the human (an API key, an install, a knowledge base to upload), ask for it. A missing key is not a reason to pick HTTP Request or Code.
- The human picks. Record the choice and the options shown. Write an unknown item as open; never guess a name.
6. Before you ask for approval, read the requirements back and ask "anything missing?". Each requirement needs a graph step and at least one acceptance case. Raise any gap with the human.
7. Write the spec, check it against itself, and ask the human to review it.
Acceptance check types: `exact` (the output must match), `contains` (given text or a pattern), `judged` (you grade against the case's rubric and show your reasoning).
## Template
Copy this into `spec.md` and fill it in.
````markdown
# <App name>: spec
## Goal
<What it does, for whom, what success looks like.>
## Mode
<Workflow or Chatflow>, because <why>.
## Requirements
- R1 (feature): Reply in the language the customer wrote in. Step: Answer.
## Inputs and outputs
- <name>: <type>, <required or optional>, e.g. <example>.
## Process
```mermaid
flowchart LR
A[Read the question] --> B[Find the answer] --> C[Reply]
```
## Acceptance cases
- A1: Customer asks in French. Inputs: query "Où est ma commande ?". Expected: French, gives the status. Check: judged, French and under 80 words. Covers R1.
## Resources
- <need>: <choice>. Shown: <options>. <confirmed or open>, provided by <who>.
## Out of scope and open questions
- <item>
````
+19
View File
@@ -0,0 +1,19 @@
# Running operations
`difyctl run console_app workflow --app-id <id> --inputs '{"a":1}'`. Each top-level field of `input` is a flag, dashes for underscores. Scalars take a value; objects, maps and lists of objects take a JSON literal or `@file.json`; lists of scalars repeat the flag. Fields named `input`, `stream`, `only`, `output`, `verbose`, `json` or `help` have no flag; pass them through `--input`.
`--input '{"a":1}'`, `--input @file.json` or `--input @-` (stdin) sends the whole body; field flags merge over it. A dotted id is a command with the dots as spaces: `run.console_app.workflow` is `difyctl run console_app workflow`.
Run an app with the op for its mode: `run console_app workflow`, `run console_app chat`, `run console_app advanced_chat`, `run console_app completion`. First `difyctl describe console_app --app-id <id> --json` and read `input_schema`; that is the shape of `inputs`. Local files go under `files`, keyed by the app's file variable name (`{"files":{"doc":"./r.pdf"}}` or a list of paths); files for the run itself under `attachments`; URLs straight into `inputs`.
## difyctl-only flags
`--stream` prints each event of a streaming op as one JSON line instead of folding them. `--only <event>` filters streamed events (repeatable). `--output <path>` saves a file-kind response; the default is the current directory under the server's filename, or `<op-id>.<ext>`. `--verbose` adds the raw server response to errors. A flag the operation's kind does not support exits 2.
## Reading results
Streaming ops fold to `{status, text, outputs?, total_tokens?, message_id?, conversation_id?, error?, hints}`. `text.answer` is the reply; `text.by_source` appears only while `status` is `incomplete`. `status` is `ended`, `failed` (exit 1), `suspended` (a form is waiting; follow `hints`) or `incomplete` (the stream broke early). Any response may carry `hints: [{summary, op, input, form?}]`, the server's next steps: next page, confirm step, reply op, form submit. Fill any `null` in `input` and pass it back with `difyctl <op, dots as spaces> --input '<input>'`.
## Errors and exit codes
Errors are one envelope on stderr: `{"error":{"code","message","hint"?,"details"?,"schema"?}}`. Exit 2 is bad input (schema included), 4 not logged in or forbidden, 6 catalog unavailable or unknown op (run `difyctl refresh cache` or upgrade difyctl), 7 rate limited.
+38
View File
@@ -0,0 +1,38 @@
# Plugins, models and credentials
## Find tools, models and knowledge bases
1. `difyctl get tool --query <service>` lists tools the workspace can use: built-in and plugin tools, workflows published as tools, API tools and MCP tools. Each row has `configured` and a ready `node_data`.
2. `difyctl get model --model-type llm` lists models across providers (`--provider <id>` for one). Use one whose `status` is `active`. Paste the row's `node_model` as the LLM node's `model` block; don't build it by hand.
3. For knowledge bases: `difyctl get knowledge_base --query <words>`. A knowledge-retrieval node needs a row's `node_data`.
4. Nothing fits: `difyctl get marketplace plugin --query <service> --category tool` (or `--category model`).
5. Show the human the options and let them pick. Never install or choose for them.
## Missing plugins after an import
1. `difyctl check console_app dependency --app-id <app_id>` lists what the app needs and lacks. Its hint is a ready `install.plugin` call with every missing marketplace plugin.
2. Plugins from GitHub or a package file can't be installed with difyctl. Tell the user to install them in the console.
## Find and install
1. `difyctl get marketplace plugin --query <words>` searches the marketplace. Each hit has `identifier` (the versioned id to install) and `installed`.
2. Confirm with the user before installing. Then `difyctl install plugin --identifiers <identifier>`.
3. Install finishes in the background. Run the `describe.plugin.task` hint until `status` is `success` or `failed`. On `failed`, show the user each plugin's `message`. If `all_installed` is true there is no task; follow the provider hint.
4. On success the hint names the provider to set up: `describe.model_provider` or `describe.tool_provider`.
`get.plugin` lists installed plugins; `provides` gives their provider ids.
## Credentials
1. `difyctl describe model_provider --provider <id>` (or `describe tool_provider`) shows `credential_form` and the saved `credentials` (names only).
2. Ask the user for each form value. Never invent one, never print it, never write it into the working folder.
3. Pass the values on stdin: `difyctl create model_provider credential --provider <id> --credentials @-` (for tools, `difyctl create tool_provider credential --provider <id> --credentials @-`), then write the JSON object to stdin.
4. A 422 means the provider rejected the values; nothing was saved. Show the user the message and ask again.
5. If another credential is already active, the hint offers to replace its values with `set`.
6. A tool provider whose `credential_types` lacks `api-key` uses OAuth. Ask the user to authorize it in the console.
Self-hosted or OpenAI-compatible models: `create model credential` with `--model`, `--model-type` and `--credentials @-`.
## Remove
Only when the user asks: `delete.plugin`. Removing a plugin breaks apps that use it; say so first. Uninstalling also deletes the plugin's saved credentials.
+37
View File
@@ -0,0 +1,37 @@
# Login and environment
## Log in
`login` uses the device flow: it prints a URL and a code, the user approves in a browser, and difyctl stores a token. One login at a time. A new login replaces the old one, and it replaces the code the user is looking at.
**Check first.** Run `difyctl login --resume`. If it prints an `email`, you are logged in: skip login. If it fails with `not_logged_in`, log in.
**Server.** Always pass `--server <url>`. Without it, difyctl logs in to Dify Cloud. If the user has not named a server, ask which one before you log in. Never guess one.
Log in only this way:
1. `difyctl login --server <url> --no-browser --no-wait`. It saves a pending login and prints `verification_uri`, `user_code` and `expires_in` (seconds), then exits.
2. Give the user the URL and the code. Ask them to reply when they have approved. Then end your turn. Do not poll, do not sleep, and do not start a background job.
3. When the user replies, run `difyctl login --resume` once. If it prints the account `email`, you are logged in. If it prints `status: pending`, tell the user it is not approved yet and end your turn again.
4. If the code expires or the user denies it, `--resume` fails. Start again from step 1.
Never run `login` without `--no-wait`. It blocks until the user approves.
A script that already has a token skips login: set `DIFY_SERVER` and `DIFY_TOKEN`.
`--no-browser` prints the URL instead of opening one; always pass it as an agent. `--insecure` skips TLS verification and allows `http://` (local dev only).
## Sandboxes and machines without a keyring
The token goes to the OS keyring by default. On Linux without a DBus session, difyctl uses a file instead, because the keyring there is lost when the session ends. In a sandbox, a container or a headless VM, the keyring can still be lost between sessions. There:
- Add `--no-keyring` to `login`, so the token goes to a file. Pass it on the `--no-wait` call; `--resume` remembers it.
- Leave `DIFY_CONFIG_DIR` unset unless the default folder (`~/.config/difyctl`) is lost between sessions. If you set it, put it on every command (`DIFY_CONFIG_DIR=<dir> difyctl ...`): an `export` does not carry over when each command runs in a new shell. The pending login from `--no-wait` lives there too, so `--resume` needs the same value. A `not_logged_in` error names the folder it checked.
## Workspace
`difyctl get workspace` lists your workspaces. `difyctl use workspace <id>` pins one; later commands fill `workspace_id` from it. `DIFY_WORKSPACE_ID` overrides the pin for one call.
## Files
`DIFY_CONFIG_DIR` moves the login and token files; `DIFY_CACHE_DIR` moves the catalog cache.