mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
chore: move the difyctl and skill changes to their own PR and regenerate the contracts
This commit is contained in:
@@ -57,11 +57,7 @@ jobs:
|
||||
run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh
|
||||
|
||||
- name: Smoke the runner-arch binary
|
||||
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
|
||||
run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version
|
||||
|
||||
- name: Publish to R2
|
||||
env:
|
||||
|
||||
@@ -121,13 +121,6 @@ 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:
|
||||
|
||||
@@ -16362,7 +16362,6 @@ This class is used to store the schema information of an api based tool.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | | No |
|
||||
| description | string | | No |
|
||||
| draft_hash | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
|
||||
@@ -2408,7 +2408,7 @@ Portable DSL reference that could not be restored in the target workspace.
|
||||
| loc | [ string<br>integer ] | | Yes |
|
||||
| message | string | | Yes |
|
||||
| node_id | string | | Yes |
|
||||
| severity | string | error: import refuses it; warning: import allows it, publish does not | Yes |
|
||||
| severity | string | error: import refuses it; warning: import allows it, the release check does not | Yes |
|
||||
|
||||
#### EnvVariableListResponse
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.4.2
|
||||
1.3.11
|
||||
|
||||
+1
-1
@@ -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/ the one embedded difyctl skill (or a local folder) and its installer
|
||||
skills/ reads skills from a GitHub folder or a local folder, and installs them
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+20
-17
@@ -45,20 +45,21 @@ 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 |
|
||||
| `install skills <dir>` | Write the difyctl skill into a skills root (`--from`) |
|
||||
| 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`) |
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
`--verbose` is global: it may appear on any command, and it keeps the raw server
|
||||
response in the error envelope instead of dropping it.
|
||||
@@ -67,9 +68,11 @@ response in the error envelope instead of dropping it.
|
||||
|
||||
## Agent skills
|
||||
|
||||
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 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 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 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 does not detect agents. You name the root, and re-running the same command refreshes the copies.
|
||||
|
||||
@@ -85,7 +88,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. 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`.
|
||||
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`.
|
||||
|
||||
| Env var | Effect |
|
||||
| ------------------- | ----------------------------------------------------------- |
|
||||
@@ -128,4 +131,4 @@ Apache-2.0.
|
||||
[Agent skill]: #agent-skills
|
||||
[Dify]: https://dify.ai
|
||||
[`ARD.md`]: ARD.md
|
||||
[`skills/difyctl/`]: ../skills/difyctl
|
||||
[`skills/`]: ../skills
|
||||
|
||||
@@ -28,10 +28,6 @@ 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
|
||||
@@ -233,18 +229,15 @@ main() {
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":${bin_dir}:"*)
|
||||
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'
|
||||
"$target_bin" version >/dev/null 2>&1 \
|
||||
&& printf 'verify: run "difyctl version"\n' \
|
||||
|| err "binary present but failed to execute; check ${target_bin}"
|
||||
;;
|
||||
*)
|
||||
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
|
||||
|
||||
@@ -38,11 +38,6 @@ 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))
|
||||
@@ -60,7 +55,6 @@ 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 {
|
||||
|
||||
@@ -21,10 +21,6 @@ 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
|
||||
@@ -102,7 +98,6 @@ 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.
|
||||
|
||||
@@ -115,11 +115,6 @@ 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
|
||||
@@ -163,7 +158,6 @@ function Invoke-Main {
|
||||
else {
|
||||
Write-Host 'verify: run "difyctl version"'
|
||||
}
|
||||
Write-SkillHint
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -41,10 +41,6 @@ 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"
|
||||
@@ -64,13 +60,12 @@ 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")..."
|
||||
(cd "$repo_root" && bun build "$entry" \
|
||||
--asset "$skill_dir" \
|
||||
bun build "$entry" \
|
||||
--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:"
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
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. */
|
||||
|
||||
+17
-11
@@ -7,23 +7,22 @@ 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 { assertValidInput, Outcome } from '@/plugins/commands/command'
|
||||
import { helpHint, 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 { commandWords } from '@/protocol/op-id'
|
||||
import { spacedId } 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>
|
||||
}>
|
||||
@@ -37,7 +36,7 @@ function assertSupportedFlags(kind: string, flags: CallFlags): void {
|
||||
if (refused !== undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `${flagToken(refused)} is not supported by ${kind} operations`,
|
||||
message: `--${refused} is not supported by ${kind} operations`,
|
||||
})
|
||||
}
|
||||
// --only filters the events --stream emits; on its own it filters nothing.
|
||||
@@ -52,6 +51,18 @@ 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)
|
||||
|
||||
@@ -64,12 +75,7 @@ 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({
|
||||
command: commandWords(req.id),
|
||||
schema: req.op.input,
|
||||
positional: req.positional,
|
||||
input: body,
|
||||
})
|
||||
assertValidInput(req.op, req.id, body)
|
||||
|
||||
if (req.op.deprecated) out.notice(`${DEPRECATED_NOTICE}: ${req.id}`)
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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 = {
|
||||
@@ -20,7 +18,8 @@ type Referenced = Readonly<{ text: string; source: string }>
|
||||
const FILE_PREFIX = '@'
|
||||
const STDIN_MARKER = '-'
|
||||
const STDIN_SOURCE = `${FILE_PREFIX}${STDIN_MARKER}`
|
||||
const INLINE_SOURCE = flagToken(CALL_FLAG.Input)
|
||||
const FLAG_PREFIX = '--'
|
||||
const INLINE_SOURCE = '--input'
|
||||
const STDIN_LABEL = 'stdin'
|
||||
|
||||
export function parseJsonValue(raw: string, source: string): unknown {
|
||||
@@ -70,7 +69,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, flagToken(name))
|
||||
resolved[name] = parseJsonValue(text, `${FLAG_PREFIX}${name}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -21,20 +21,17 @@ function compile(schema: JsonSchema): ValidateFunction {
|
||||
return validate
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
function toDetail(error: ErrorObject): ValidationDetail {
|
||||
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 }
|
||||
return { type: error.keyword, loc: locFor(error), msg: error.message ?? error.keyword }
|
||||
}
|
||||
|
||||
export function validateInput(schema: JsonSchema, input: unknown): readonly ValidationDetail[] {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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,20 +1,23 @@
|
||||
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 difyctl skill into the given skills root', async () => {
|
||||
it('writes the repo skills 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]
|
||||
const argv = ['install', 'skills', dir, '--from', REPO_SKILLS]
|
||||
const w = await testContext({ login: false, argv, reuseDirOf: seed })
|
||||
worlds.push(w)
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { Command } from '@/plugins/commands/command'
|
||||
import { installSkill } from '@/skills/install'
|
||||
import { loadSkills } from '@/skills/collection'
|
||||
import { installSkills, selectSkills } from '@/skills/install'
|
||||
import { FROM_FIELD, openSource } from '@/skills/source'
|
||||
|
||||
const INPUT = z.object({
|
||||
@@ -9,19 +10,26 @@ 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 the difyctl skill into a skills root'
|
||||
static override summary = 'Write skills from the collection 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 the skill for Claude Code', input: { dir: '~/.claude/skills' } },
|
||||
{ title: 'Install every skill for Claude Code', input: { dir: '~/.claude/skills' } },
|
||||
{ title: 'Install one skill for Codex', input: { dir: '~/.codex/skills', skill: ['difyctl'] } },
|
||||
]
|
||||
|
||||
async run(input: z.infer<typeof INPUT>) {
|
||||
return { wrote: await installSkill(resolve(input.dir), await openSource(input.from)) }
|
||||
const source = await openSource(input.from)
|
||||
const skills = selectSkills(await loadSkills(source), input.skill)
|
||||
return { wrote: await installSkills(resolve(input.dir), skills, source) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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'
|
||||
@@ -99,81 +95,3 @@ 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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
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, pendingLoginStore, revokeAndClearSession } from '@/auth/logout'
|
||||
import { assertNotEnvLogin, 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 { resolveHost, validateVerificationURI } from '@/util/host'
|
||||
import { DEFAULT_HOST, resolveHost, validateVerificationURI } from '@/util/host'
|
||||
|
||||
const INPUT = z.object({
|
||||
server: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Dify server base URL. Required to start a login; there is no default'),
|
||||
server: z.string().default(DEFAULT_HOST).describe('Dify server base URL'),
|
||||
no_browser: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
@@ -42,10 +41,11 @@ const INPUT = z.object({
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'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',
|
||||
'Check a login started with --no-wait once. Prints status pending until the user approves',
|
||||
),
|
||||
})
|
||||
|
||||
const PENDING_FILE_NAME = 'login-pending.yml'
|
||||
const PENDING_STATUS = 'pending'
|
||||
const PENDING_SCHEMA = z.object({
|
||||
server: z.string(),
|
||||
@@ -54,12 +54,11 @@ 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.
|
||||
@@ -69,44 +68,35 @@ 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. Agents: use --no-wait, then --resume after the user approves'
|
||||
'Log in via the OAuth device flow. Blocks until approval: run it in the background, never cancel it'
|
||||
static override effect = 'write' as const
|
||||
static override input = INPUT
|
||||
static override examples = [
|
||||
{
|
||||
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: '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_wait: true,
|
||||
no_keyring: true,
|
||||
},
|
||||
input: { server: 'https://dify.example.com', no_browser: 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',
|
||||
title: 'Agent that cannot keep a process alive: start without waiting',
|
||||
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)',
|
||||
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)
|
||||
await pendingStore.rm()
|
||||
|
||||
const { configDir } = await ctx.get(env)
|
||||
const pendingStore = new YamlStore(join(configDir, PENDING_FILE_NAME))
|
||||
if (input.resume) return resume(ctx, pendingStore)
|
||||
|
||||
const server = resolveHost({ raw: input.server, insecure: input.insecure })
|
||||
const streams = await ctx.get(io)
|
||||
@@ -152,14 +142,7 @@ 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) {
|
||||
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,
|
||||
}
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: NO_PENDING_MESSAGE })
|
||||
}
|
||||
const pending = parsed.data
|
||||
const api = deviceApi(pending.server, { insecure: pending.insecure })
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { TestWorld } from '@test/fixtures/kernel'
|
||||
import { existsSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync } 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'
|
||||
@@ -33,15 +32,6 @@ 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandContext } from '@/plugins/base'
|
||||
import { z } from 'zod'
|
||||
import { assertNotEnvLogin, pendingLoginStore, revokeAndClearSession } from '@/auth/logout'
|
||||
import { assertNotEnvLogin, revokeAndClearSession } from '@/auth/logout'
|
||||
import { Command } from '@/plugins/commands/command'
|
||||
import { session } from '@/plugins/session'
|
||||
|
||||
@@ -18,7 +18,6 @@ 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 }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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'
|
||||
@@ -22,6 +23,7 @@ export const commandTree: CommandTree = {
|
||||
get: {
|
||||
subcommands: {
|
||||
config: { command: GetConfig, subcommands: {} },
|
||||
skills: { command: GetSkills, subcommands: {} },
|
||||
},
|
||||
},
|
||||
install: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ErrorCodeValue, ExitCodeValue } from './codes'
|
||||
import type { Hint } from '@/protocol/fold'
|
||||
import { BINARY } from '@/version/info'
|
||||
import { ErrorCode, exitFor } from './codes'
|
||||
|
||||
@@ -7,7 +6,6 @@ export type ServerErrorDetail = {
|
||||
readonly type: string
|
||||
readonly loc?: (string | number)[]
|
||||
readonly msg: string
|
||||
readonly field?: string
|
||||
}
|
||||
|
||||
export type ServerErrorBody = {
|
||||
@@ -15,7 +13,6 @@ export type ServerErrorBody = {
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly hint?: string
|
||||
readonly hints?: Hint[]
|
||||
readonly details?: ServerErrorDetail[]
|
||||
}
|
||||
|
||||
@@ -121,9 +118,8 @@ export function unknownError(message: string, cause?: unknown): BaseError {
|
||||
|
||||
export const LOGIN_HINT = `run ${BINARY} login`
|
||||
|
||||
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 })
|
||||
export function notLoggedIn(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint: LOGIN_HINT })
|
||||
}
|
||||
|
||||
type HttpClientErrorOptions = BaseErrorOptions & {
|
||||
|
||||
@@ -43,24 +43,3 @@ 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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ErrorEnvelope, ServerErrorDetail } from './base'
|
||||
import type { Hint } from '@/protocol/fold'
|
||||
import type { ErrorEnvelope } from './base'
|
||||
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'
|
||||
|
||||
@@ -18,25 +15,17 @@ 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 ?? []) lines.push(` - ${detailLine(d)}`)
|
||||
for (const d of e.details ?? server?.details ?? []) {
|
||||
const loc = (d.loc ?? []).join('.')
|
||||
lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`)
|
||||
}
|
||||
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)}`)
|
||||
|
||||
@@ -9,7 +9,6 @@ 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'
|
||||
|
||||
@@ -24,36 +23,6 @@ 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
|
||||
@@ -100,7 +69,17 @@ export abstract class Command<S extends z.ZodObject = z.ZodObject> {
|
||||
input: Record<string, unknown>,
|
||||
path: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
assertValidInput({ command: path, schema: this.schema(), positional: this.positional, input })
|
||||
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,
|
||||
})
|
||||
}
|
||||
const parsed = this.input.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw new BaseError({
|
||||
|
||||
@@ -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', command: 'difyctl fake thing x' }],
|
||||
examples: [{ title: 'Basic', input: { id: 'x' } }],
|
||||
})
|
||||
expect(row.input).toMatchObject({
|
||||
type: 'object',
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { CommandConstructor, CommandEffect } from './command'
|
||||
import type { CommandExample } from './examples'
|
||||
import type { JsonSchema } from '@/plugins/catalog'
|
||||
import { argToken } from '@/util/flag-name'
|
||||
import type { Example, JsonSchema } from '@/plugins/catalog'
|
||||
import { BINARY } from '@/version/info'
|
||||
import { commandExamples } from './examples'
|
||||
|
||||
export type CommandRow = {
|
||||
id: string
|
||||
@@ -12,7 +9,7 @@ export type CommandRow = {
|
||||
effect?: CommandEffect
|
||||
input: JsonSchema
|
||||
positional: readonly string[]
|
||||
examples: readonly CommandExample[]
|
||||
examples: readonly Example[]
|
||||
}
|
||||
|
||||
// The op side of a descriptor: present once a command wraps a catalog op instead of a
|
||||
@@ -34,15 +31,14 @@ const FLAGS = '[flags]'
|
||||
|
||||
export function commandRow(ctor: CommandConstructor, path: readonly string[]): CommandRow {
|
||||
const id = path.join(' ')
|
||||
const args = ctor.positional.map(argToken)
|
||||
const input = ctor.schema()
|
||||
const args = ctor.positional.map((name) => `<${name}>`)
|
||||
const row: CommandRow = {
|
||||
id,
|
||||
usage: [BINARY, id, ...args, FLAGS].join(' '),
|
||||
summary: ctor.summary,
|
||||
input,
|
||||
input: ctor.schema(),
|
||||
positional: ctor.positional,
|
||||
examples: commandExamples({ id, input, positional: ctor.positional }, ctor.examples),
|
||||
examples: ctor.examples,
|
||||
}
|
||||
return ctor.effect === undefined ? row : { ...row, effect: ctor.effect }
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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) }))
|
||||
}
|
||||
@@ -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', 'streams'], tree).entries[0]).toMatchObject({
|
||||
expect(helpSearch(['chatbot'], tree).entries[0]).toMatchObject({
|
||||
id: 'run console_app chat',
|
||||
kind: 'sse',
|
||||
})
|
||||
|
||||
@@ -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, commandWords, OP_SEPARATOR } from '@/protocol/op-id'
|
||||
import { COMMAND_SEPARATOR, OP_SEPARATOR, spacedId } from '@/protocol/op-id'
|
||||
import { view } from '@/sys/io/view'
|
||||
import { BINARY } from '@/version/info'
|
||||
import { HELP_WORD } from './command'
|
||||
@@ -13,7 +13,6 @@ 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 = {
|
||||
@@ -42,7 +41,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 commandWords(only)
|
||||
return spacedId(only).split(COMMAND_SEPARATOR)
|
||||
}
|
||||
|
||||
function entryOf(ctor: CommandConstructor, id: string): HelpEntry {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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.
|
||||
@@ -68,7 +67,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(/not logged in: run difyctl login --help/)
|
||||
expect(w.io.errBuf()).toMatch(/log in to list server operations/)
|
||||
expect(w.mock.requestCount).toBe(0)
|
||||
})
|
||||
|
||||
@@ -79,10 +78,8 @@ 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()
|
||||
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([])
|
||||
expect(w.io.errBuf().trim().split('\n')).toHaveLength(1)
|
||||
expect(w.io.errBuf()).toMatch(/^could not list server operations: failed to fetch the catalog: /)
|
||||
})
|
||||
|
||||
it('help accepts a dotted id and prints the same descriptor as the spaced path', async () => {
|
||||
@@ -148,10 +145,11 @@ 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', 'streams'])
|
||||
const d = await world(true, ['help', 'chatbot'])
|
||||
await (await d.ctx.get(commands)).run()
|
||||
expect(JSON.parse(d.io.outBuf()).entries[0]).toMatchObject({
|
||||
id: 'run console_app chat',
|
||||
@@ -257,17 +255,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', 'qqqq'])
|
||||
const w = await world(true, ['get', 'nope'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: 'unknown command: get qqqq',
|
||||
message: 'unknown command: get nope',
|
||||
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, get console_app version',
|
||||
hint: 'did you mean: get console_app',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
helpMap,
|
||||
helpSearch,
|
||||
pointer,
|
||||
SKILL_NOTICE,
|
||||
spacedWords,
|
||||
} from '@/plugins/commands/help'
|
||||
import {
|
||||
@@ -42,7 +41,7 @@ const HELP_FLAGS: readonly string[] = ['--help', '-h']
|
||||
const FULL_FLAG = '--full'
|
||||
const ALL_FLAG = '--all'
|
||||
const FLAG_PREFIX = '-'
|
||||
const NO_SERVER_NOTICE = 'not logged in: run difyctl login --help'
|
||||
const NO_SERVER_NOTICE = 'log in to list server operations'
|
||||
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`
|
||||
@@ -132,7 +131,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ 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, fieldRows, listingView, mapView, SECTIONS } from './text'
|
||||
import { descriptorView, exampleLine, fieldRows, listingView, mapView, SECTIONS } from './text'
|
||||
|
||||
const OPS = parseCatalog(readFileSync(join(__dirname, '../../../test/fixtures/catalog.json'))).ops
|
||||
const plain = colorScheme(false)
|
||||
@@ -24,7 +23,7 @@ function opDescriptor(id: string, path: readonly string[]): Descriptor {
|
||||
effect: 'write',
|
||||
input: op.input,
|
||||
positional: [],
|
||||
examples: commandExamples({ id: path.join(' '), input: op.input, positional: [] }, op.examples),
|
||||
examples: op.examples,
|
||||
op: id,
|
||||
method: op.method,
|
||||
path: op.path,
|
||||
@@ -60,18 +59,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, 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
|
||||
--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
|
||||
|
||||
Options
|
||||
--input string optional Operation input as JSON, @file or @- for stdin
|
||||
@@ -93,7 +92,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 the difyctl skill into a skills root write
|
||||
"install skills Write skills from the collection into a skills root write
|
||||
|
||||
Usage
|
||||
difyctl install skills <dir> [flags]
|
||||
@@ -103,16 +102,19 @@ it('renders a static descriptor with arguments and flags', () => {
|
||||
per skill
|
||||
|
||||
Flags
|
||||
--from string optional A local skill folder to install instead of the one built
|
||||
into difyctl
|
||||
--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
|
||||
|
||||
Global
|
||||
--verbose boolean false Keep the raw server response in error envelopes
|
||||
--json boolean false Print JSON even on a terminal
|
||||
|
||||
Examples
|
||||
# Install the skill for Claude Code
|
||||
difyctl install skills ~/.claude/skills"
|
||||
# Install every skill for Claude Code
|
||||
difyctl install skills ~/.claude/skills
|
||||
# Install one skill for Codex
|
||||
difyctl install skills ~/.codex/skills --skill difyctl"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -123,9 +125,9 @@ it('example lines put positionals first, scalars as --flag value, non-scalars as
|
||||
expect(
|
||||
exampleLine(skillsInstall, {
|
||||
title: 't',
|
||||
input: { dir: '~/.claude/skills', from: './skill' },
|
||||
input: { dir: '~/.claude/skills', skill: ['difyctl'] },
|
||||
}),
|
||||
).toBe('difyctl install skills ~/.claude/skills --from ./skill')
|
||||
).toBe('difyctl install skills ~/.claude/skills --skill difyctl')
|
||||
})
|
||||
|
||||
it('a missing positional prints as itself and a non-scalar one as json', () => {
|
||||
@@ -203,9 +205,10 @@ it('a description prints its first line only', () => {
|
||||
})
|
||||
|
||||
it('an op without examples prints a skeleton of its required fields', () => {
|
||||
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)
|
||||
expect(workflowRun.examples).toEqual([])
|
||||
expect(descriptorView(workflowRun).text(plain)).toContain(
|
||||
'difyctl run console_app workflow --app-id <app_id> --inputs <inputs>',
|
||||
)
|
||||
})
|
||||
|
||||
it('the map names each head, leaves by summary and groups by count', () => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { CommandEffect } from './command'
|
||||
import type { Descriptor } from './describe'
|
||||
import type { HelpEntry, HelpHead, HelpListing, HelpMap } from './help'
|
||||
import type { JsonSchema } from '@/plugins/catalog'
|
||||
import type { Example, 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 { labelOf, propertiesOf, shapeOf } from '@/protocol/shape'
|
||||
import { isRepeatable, isScalar, labelOf, propertiesOf, shapeOf } from '@/protocol/shape'
|
||||
import { view } from '@/sys/io/view'
|
||||
import { argToken, flagToken } from '@/util/flag-name'
|
||||
import { flagFor } from '@/util/flag-name'
|
||||
import { BINARY } from '@/version/info'
|
||||
|
||||
export type FieldRow = Readonly<{
|
||||
name: string
|
||||
@@ -24,6 +25,7 @@ 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 = ' '
|
||||
@@ -35,6 +37,7 @@ 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[] = []
|
||||
|
||||
@@ -97,7 +100,7 @@ export function fieldRows(schema: JsonSchema, positional: readonly string[]): Fi
|
||||
function rowsToLines(rows: readonly FieldRow[]): string[] {
|
||||
return aligned(
|
||||
rows.map((row) => [
|
||||
row.positional ? argToken(row.name) : flagToken(row.name),
|
||||
row.positional ? `<${row.name}>` : `--${flagFor(row.name)}`,
|
||||
row.label,
|
||||
row.presence,
|
||||
row.description,
|
||||
@@ -105,6 +108,47 @@ 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] },
|
||||
{
|
||||
@@ -126,7 +170,10 @@ export const SECTIONS: readonly Section[] = [
|
||||
{
|
||||
title: 'Examples',
|
||||
lines: (d, style) =>
|
||||
d.examples.flatMap((example) => [style.dim(`${COMMENT} ${example.title}`), example.command]),
|
||||
examplesOf(d).flatMap((example) => [
|
||||
style.dim(`${COMMENT} ${example.title}`),
|
||||
exampleLine(d, example),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: 'Pins',
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'),
|
||||
@@ -22,7 +21,7 @@ const NO_POSITIONALS: readonly string[] = []
|
||||
|
||||
function invalidGlobalFlags(error: z.ZodError): BaseError {
|
||||
const message = error.issues
|
||||
.map((issue) => `${flagToken(issue.path.join('.'))}: ${issue.message}`)
|
||||
.map((issue) => `--${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('; ')
|
||||
return new BaseError({ code: ErrorCode.UsageInvalidFlag, message, cause: error })
|
||||
}
|
||||
|
||||
@@ -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, positional: OpCommand.positional, flags, fields }, ctx)
|
||||
return executeCall({ id, op, flags, fields }, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { commandWords } from '@/protocol/op-id'
|
||||
import { OP_SEPARATOR } 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 = commandWords(id)
|
||||
const segments = id.split(OP_SEPARATOR)
|
||||
let level = tree
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const node = (level[segment] ??= { subcommands: {} })
|
||||
|
||||
@@ -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, env } from '@/plugins/env'
|
||||
import { env } from '@/plugins/env'
|
||||
import { STORAGE_MODES, YamlStore } from '@/store/store'
|
||||
|
||||
export const LOGIN_FILE_NAME = 'login.yml'
|
||||
@@ -106,8 +106,7 @@ export const session = definePlugin({
|
||||
current,
|
||||
require: async () => {
|
||||
const login = await current()
|
||||
if (login === null)
|
||||
throw notLoggedIn(`no login saved in ${configDir}; ${ENV.ConfigDir} picks this folder`)
|
||||
if (login === null) throw notLoggedIn()
|
||||
return login
|
||||
},
|
||||
save: async (login: Login) => {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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 commandWords(id).join(COMMAND_SEPARATOR)
|
||||
return id.split(OP_SEPARATOR).join(COMMAND_SEPARATOR)
|
||||
}
|
||||
|
||||
export function dottedId(path: readonly string[]): string {
|
||||
|
||||
@@ -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.Union, 'string | integer'],
|
||||
[{ oneOf: [{ type: 'string' }, { type: 'integer' }] }, SHAPE.Json, 'json'],
|
||||
[{ someFutureKeyword: true }, SHAPE.Json, 'json'],
|
||||
])('%j classifies as %s labelled %s', (schema, shape, label) => {
|
||||
const node = shapeOf(schema)
|
||||
|
||||
+11
-22
@@ -12,15 +12,9 @@ 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[]
|
||||
options?: readonly ShapeNode[]
|
||||
}>
|
||||
export type ShapeNode = Readonly<{ shape: Shape; item?: ShapeNode; values?: readonly string[] }>
|
||||
|
||||
type Coercion = (raw: string) => unknown
|
||||
|
||||
@@ -30,7 +24,7 @@ const ARRAY_TYPE = 'array'
|
||||
const OBJECT_TYPE = 'object'
|
||||
const STRING_TYPE = 'string'
|
||||
const FILE_REF_PREFIX = '@'
|
||||
const ALTERNATIVE_SEPARATOR = ' | '
|
||||
const ENUM_SEPARATOR = ' | '
|
||||
const BOOLEAN_LITERALS: Readonly<Record<string, boolean>> = { true: true, false: false }
|
||||
|
||||
const keepRaw: Coercion = (raw) => raw
|
||||
@@ -70,7 +64,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(ALTERNATIVE_SEPARATOR),
|
||||
label: (n) => (n.values ?? []).join(ENUM_SEPARATOR),
|
||||
coerce: () => keepRaw,
|
||||
scalar: true,
|
||||
},
|
||||
@@ -87,24 +81,19 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
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[]
|
||||
// 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
|
||||
}
|
||||
|
||||
export function shapeOf(schema: JsonSchema): ShapeNode {
|
||||
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 inner = unwrapNullable(schema)
|
||||
if (inner !== undefined) return shapeOf(inner)
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 : {}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vite-plus/test'
|
||||
import { installSkill } from './install'
|
||||
import { openDir, SKILL_FILE, SKILL_NAME } from './source'
|
||||
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'
|
||||
|
||||
let tmp: string
|
||||
let from: string
|
||||
@@ -15,22 +19,47 @@ 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-skill-'))
|
||||
tmp = await mkdtemp(join(tmpdir(), 'difyctl-skills-'))
|
||||
from = join(tmp, 'source')
|
||||
root = join(tmp, 'root')
|
||||
await put(SKILL_FILE, `---\nname: ${SKILL_NAME}\ndescription: d\n---\n`)
|
||||
await put('references/setup.md', 'setup')
|
||||
await put(`${BASIC_SKILL}/SKILL.md`, skillMd(BASIC_SKILL))
|
||||
await put(`${SCENARIO}/SKILL.md`, skillMd(SCENARIO))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
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')
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
+26
-17
@@ -1,28 +1,37 @@
|
||||
import type { Skill } from './collection'
|
||||
import type { SkillSource } from './source'
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { SKILL_NAME } from './source'
|
||||
import { dirname, join, posix } from 'node:path'
|
||||
import { BASIC_SKILL, findSkill, isScenario } from './collection'
|
||||
|
||||
const EXECUTABLE_MODE = 0o755
|
||||
const FILE_MODE = 0o644
|
||||
const SHEBANG = [0x23, 0x21]
|
||||
const EXECUTABLE_MODE = 0o755
|
||||
|
||||
function modeFor(bytes: Uint8Array): number {
|
||||
return SHEBANG.every((byte, i) => bytes[i] === byte) ? EXECUTABLE_MODE : FILE_MODE
|
||||
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()]
|
||||
}
|
||||
|
||||
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 })
|
||||
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 })
|
||||
const wrote: string[] = []
|
||||
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)
|
||||
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)
|
||||
}
|
||||
return wrote
|
||||
}
|
||||
|
||||
+51
-47
@@ -1,72 +1,76 @@
|
||||
import type { EmbeddedFile } from '@/sys'
|
||||
import type { Dirent } from 'node:fs'
|
||||
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 { embeddedFiles, isCompiledBinary } from '@/sys'
|
||||
import { versionInfo } from '@/version/info'
|
||||
import { GITHUB_ORIGIN, openGitHub } from './github'
|
||||
|
||||
export const SKILL_NAME = 'difyctl'
|
||||
export const SKILL_FILE = 'SKILL.md'
|
||||
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>'
|
||||
const EXECUTABLE_BITS = 0o111
|
||||
const URL_RE = /^https?:\/\//
|
||||
|
||||
export type SkillEntry = { readonly path: string; readonly executable: boolean }
|
||||
|
||||
export type SkillSource = {
|
||||
readonly paths: readonly string[]
|
||||
readonly entries: readonly SkillEntry[]
|
||||
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)
|
||||
.optional()
|
||||
.describe('A local skill folder to install instead of the one built into difyctl')
|
||||
.default(DEFAULT_SOURCE)
|
||||
.describe('Where the skills come from: a GitHub folder URL or a local folder')
|
||||
|
||||
export async function openDir(root: string): Promise<SkillSource> {
|
||||
const found = await stat(join(root, SKILL_FILE)).then(
|
||||
async function hasSkillFile(dir: string): Promise<boolean> {
|
||||
return stat(join(dir, SKILL_FILE)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
if (!found) {
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `no ${SKILL_FILE} in "${root}"`,
|
||||
message: `cannot read folder "${root}"`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
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('/'))
|
||||
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)) }
|
||||
}
|
||||
|
||||
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`,
|
||||
})
|
||||
}
|
||||
return { paths, read: (path) => readFile(join(root, path)) }
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return openDir(from)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TokenStore } from './token-store'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { detectTokenStore, getTokenStore, keyringDurable } from './manager'
|
||||
import { detectTokenStore, getTokenStore } from './manager'
|
||||
|
||||
function memStore(label: string): TokenStore & { _label: string } {
|
||||
const map = new Map<string, string>()
|
||||
@@ -38,7 +38,6 @@ 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)
|
||||
@@ -52,7 +51,6 @@ 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)
|
||||
@@ -64,7 +62,6 @@ 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)
|
||||
@@ -89,7 +86,6 @@ describe('detectTokenStore', () => {
|
||||
const f = memStore('file')
|
||||
await detectTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
keyringDurable: true,
|
||||
})
|
||||
expect(await k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('')
|
||||
})
|
||||
@@ -103,7 +99,6 @@ 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')
|
||||
@@ -134,29 +129,3 @@ 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -10,8 +9,6 @@ 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)
|
||||
@@ -34,18 +31,6 @@ 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> = {
|
||||
@@ -62,9 +47,7 @@ const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => T
|
||||
export async function detectTokenStore(
|
||||
opts: DetectTokenStoreOptions = {},
|
||||
): Promise<{ store: TokenStore; mode: StorageMode }> {
|
||||
const durable = opts.keyringDurable ?? keyringDurable(process.platform, getEnv)
|
||||
if (opts.skipKeyring === true || !durable)
|
||||
return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' }
|
||||
if (opts.skipKeyring === true) 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)
|
||||
|
||||
@@ -6,24 +6,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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 {
|
||||
@@ -13,15 +10,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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/`
|
||||
}
|
||||
@@ -12,6 +14,7 @@ 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 {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
Vendored
+4
-69
@@ -92,33 +92,6 @@
|
||||
"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": {
|
||||
@@ -138,7 +111,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Local file paths attached to the run itself (the app's `sys.files`), not to a variable",
|
||||
"description": "Local files attached to the run itself (the app's `sys.files`), not to a variable",
|
||||
"title": "Attachments"
|
||||
},
|
||||
"auto_generate_name": {
|
||||
@@ -185,12 +158,12 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"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",
|
||||
"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",
|
||||
"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 path 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 file in `files`, not both.",
|
||||
"title": "Inputs",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -226,7 +199,7 @@
|
||||
"title": "Workspace Id"
|
||||
}
|
||||
},
|
||||
"required": ["app_id", "query"],
|
||||
"required": ["app_id", "inputs", "query"],
|
||||
"type": "object"
|
||||
},
|
||||
"internal": false,
|
||||
@@ -537,7 +510,6 @@
|
||||
"bind": {
|
||||
"app_id": "body",
|
||||
"description": "body",
|
||||
"draft_hash": "body",
|
||||
"icon": "body",
|
||||
"icon_background": "body",
|
||||
"icon_type": "body",
|
||||
@@ -548,30 +520,6 @@
|
||||
"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": {
|
||||
@@ -600,19 +548,6 @@
|
||||
"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": [
|
||||
{
|
||||
|
||||
Vendored
+4
-908
@@ -57,20 +57,6 @@
|
||||
"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",
|
||||
@@ -84,28 +70,6 @@
|
||||
"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" },
|
||||
@@ -115,7 +79,7 @@
|
||||
{ "type": "null" }
|
||||
],
|
||||
"default": null,
|
||||
"description": "Local file paths attached to the run itself (the app's `sys.files`), not to a variable",
|
||||
"description": "Local files attached to the run itself (the app's `sys.files`), not to a variable",
|
||||
"title": "Attachments"
|
||||
},
|
||||
"auto_generate_name": {
|
||||
@@ -144,12 +108,12 @@
|
||||
{ "type": "null" }
|
||||
],
|
||||
"default": null,
|
||||
"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",
|
||||
"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",
|
||||
"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 path 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 file in `files`, not both.",
|
||||
"title": "Inputs",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -167,7 +131,7 @@
|
||||
"title": "Workspace Id"
|
||||
}
|
||||
},
|
||||
"required": ["app_id", "query"],
|
||||
"required": ["app_id", "inputs", "query"],
|
||||
"type": "object"
|
||||
},
|
||||
"internal": false,
|
||||
@@ -382,7 +346,6 @@
|
||||
"bind": {
|
||||
"app_id": "body",
|
||||
"description": "body",
|
||||
"draft_hash": "body",
|
||||
"icon": "body",
|
||||
"icon_background": "body",
|
||||
"icon_type": "body",
|
||||
@@ -393,27 +356,6 @@
|
||||
"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": {
|
||||
@@ -428,12 +370,6 @@
|
||||
"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,
|
||||
@@ -981,846 +917,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -55,7 +55,6 @@ export type AppDetailWithSite = {
|
||||
export type AppImportPayload = {
|
||||
app_id?: string | null
|
||||
description?: string | null
|
||||
draft_hash?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as z from 'zod'
|
||||
export const zAppImportPayload = z.object({
|
||||
app_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
draft_hash: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
|
||||
@@ -864,6 +864,7 @@ export type OpenApiErrorCode =
|
||||
| 'credential_invalid'
|
||||
| 'credential_not_found'
|
||||
| 'credential_oauth_only'
|
||||
| 'draft_changed'
|
||||
| 'draft_not_found'
|
||||
| 'dsl_invalid'
|
||||
| 'env_variable_not_found'
|
||||
|
||||
@@ -1148,6 +1148,7 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'credential_invalid',
|
||||
'credential_not_found',
|
||||
'credential_oauth_only',
|
||||
'draft_changed',
|
||||
'draft_not_found',
|
||||
'dsl_invalid',
|
||||
'env_variable_not_found',
|
||||
|
||||
+21
-29
@@ -1,34 +1,26 @@
|
||||
# difyctl skill
|
||||
# difyctl skills
|
||||
|
||||
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/`.
|
||||
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.
|
||||
|
||||
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`.
|
||||
`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`.
|
||||
|
||||
## Changing the skill
|
||||
## Adding a skill
|
||||
|
||||
The skill is a tree of linked docs, so an agent reads only the branch it needs.
|
||||
- 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.
|
||||
|
||||
- `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.
|
||||
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`.
|
||||
|
||||
+31
-6
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: difyctl
|
||||
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.'
|
||||
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.'
|
||||
---
|
||||
|
||||
# difyctl
|
||||
@@ -24,11 +24,36 @@ 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.
|
||||
|
||||
## Where to read next
|
||||
## Other skills
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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>.
|
||||
````
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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>
|
||||
````
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HttpNodeType } from '../../nodes/http/types'
|
||||
import type { IfElseNodeType } from '../../nodes/if-else/types'
|
||||
import type { IterationNodeType } from '../../nodes/iteration/types'
|
||||
import type { KnowledgeRetrievalNodeType } from '../../nodes/knowledge-retrieval/types'
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
DEFAULT_RETRY_MAX,
|
||||
NESTED_ELEMENT_Z_INDEX,
|
||||
} from '@/app/components/workflow/constants'
|
||||
import { BodyPayloadValueType, BodyType } from '@/app/components/workflow/nodes/http/types'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
|
||||
import { BlockEnum, ErrorHandleMode } from '@/app/components/workflow/types'
|
||||
@@ -497,61 +495,6 @@ describe('initialNodes', () => {
|
||||
expect(result[0]!.data.retry_config).toEqual(existingConfig)
|
||||
})
|
||||
|
||||
it('should default missing body.data to an empty list for HttpRequest nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
data: {
|
||||
type: BlockEnum.HttpRequest,
|
||||
title: '',
|
||||
desc: '',
|
||||
body: { type: BodyType.none },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect((result[0]!.data as HttpNodeType).body).toEqual({ type: BodyType.none, data: [] })
|
||||
})
|
||||
|
||||
it('should default a missing body for HttpRequest nodes', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
data: {
|
||||
type: BlockEnum.HttpRequest,
|
||||
title: '',
|
||||
desc: '',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect((result[0]!.data as HttpNodeType).body).toEqual({ type: BodyType.none, data: [] })
|
||||
})
|
||||
|
||||
it('should keep existing body data for HttpRequest nodes', () => {
|
||||
const listBody = {
|
||||
type: BodyType.json,
|
||||
data: [{ id: 'd-1', type: BodyPayloadValueType.text, value: '{"a":1}' }],
|
||||
}
|
||||
const legacyBody = { type: BodyType.rawText, data: 'raw {{#start.q#}}' }
|
||||
const nodes = [
|
||||
createNode({
|
||||
id: 'http-1',
|
||||
data: { type: BlockEnum.HttpRequest, title: '', desc: '', body: listBody },
|
||||
}),
|
||||
createNode({
|
||||
id: 'http-2',
|
||||
data: { type: BlockEnum.HttpRequest, title: '', desc: '', body: legacyBody },
|
||||
}),
|
||||
]
|
||||
|
||||
const result = initialNodes(nodes, [])
|
||||
expect((result[0]!.data as HttpNodeType).body).toEqual(listBody)
|
||||
expect((result[1]!.data as HttpNodeType).body).toEqual(legacyBody)
|
||||
})
|
||||
|
||||
it('should migrate legacy Tool node configurations', () => {
|
||||
const nodes = [
|
||||
createNode({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HttpNodeType } from '../nodes/http/types'
|
||||
import type { IfElseNodeType } from '../nodes/if-else/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
NODE_WIDTH_X_OFFSET,
|
||||
START_INITIAL_POSITION,
|
||||
} from '../constants'
|
||||
import { BodyType } from '../nodes/http/types'
|
||||
import { branchNameCorrect } from '../nodes/if-else/utils'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
@@ -306,19 +304,12 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
if (node.data.type === BlockEnum.ParameterExtractor)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.HttpRequest) {
|
||||
if (!node.data.retry_config) {
|
||||
node.data.retry_config = {
|
||||
retry_enabled: true,
|
||||
max_retries: DEFAULT_RETRY_MAX,
|
||||
retry_interval: DEFAULT_RETRY_INTERVAL,
|
||||
}
|
||||
if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) {
|
||||
node.data.retry_config = {
|
||||
retry_enabled: true,
|
||||
max_retries: DEFAULT_RETRY_MAX,
|
||||
retry_interval: DEFAULT_RETRY_INTERVAL,
|
||||
}
|
||||
|
||||
// Imported DSL may omit body.data; legacy string data is converted when the panel opens.
|
||||
const httpNodeData = node.data as HttpNodeType
|
||||
if (!httpNodeData.body) httpNodeData.body = { type: BodyType.none, data: [] }
|
||||
else if (!httpNodeData.body.data) httpNodeData.body.data = []
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user