fix: harden agent login, check more of the DSL, and trim the difyctl skill

Server:
- describe, check and fill human-input nodes with Dify's own model, delivery
  methods included; human-input action ids and __timeout are valid branches
- refuse End nodes that share an output name, as the console's publish does
- warn on a branch with no edge
- the release check counts a run as tested when its graph runs the same as
  the draft, so editor-only fields no longer reset it

CLI:
- login needs --server; there is no default server
- not_logged_in names the config folder it checked
- on Linux without a DBus session, keep the token in tokens.yml, since the
  keyring there is lost when the session ends

Skill:
- log in with --no-wait only, then --resume after the user approves
- report problems instead of substituting, and get a yes before building any
  change from the spec or plan
- trim every file under 500 words
This commit is contained in:
GareArc
2026-10-09 22:43:37 -07:00
parent 32142c3e49
commit f22362a892
23 changed files with 332 additions and 456 deletions
+1 -1
View File
@@ -654,7 +654,7 @@ class DslCheckPayload(BaseModel):
class DslIssueRow(BaseModel):
code: str
severity: str = Field(description="error: import refuses it; warning: import allows it, publish does not")
severity: str = Field(description="error: import refuses it; warning: import allows it, the release check does not")
node_id: str | None
loc: list[str | int]
message: str
+2 -2
View File
@@ -26,7 +26,7 @@ from graphon.enums import WorkflowExecutionStatus
from models import AppMode, WorkflowRunTriggeredFrom
from models.workflow import Workflow
from services.workflow.graph_check import check_graph
from services.workflow.graph_diff import WorkflowSnapshot, diff_workflows, graph_hash
from services.workflow.graph_diff import WorkflowSnapshot, diff_workflows, same_graph
from services.workflow_service import WorkflowService
_DRAFT_TEST_OPS: Final = {
@@ -77,7 +77,7 @@ class ReleaseCheckApi(Resource):
tested = (
last is not None
and last.status == WorkflowExecutionStatus.SUCCEEDED
and graph_hash(last.graph_dict) == draft.unique_hash
and same_graph(last.graph_dict, draft.graph_dict)
)
published = service.get_published_workflow(ctx.app, session=ctx.session)
diff = diff_workflows(_snapshot(published) if published else None, _snapshot(draft))
+7 -6
View File
@@ -7,6 +7,7 @@ from http import HTTPStatus
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
@@ -14,16 +15,15 @@ from controllers.openapi._errors import NodeTypeNotFound
from controllers.openapi._models import NodeTypeDetailResponse, NodeTypeListResponse, NodeTypeRow
from controllers.openapi.auth.context import Context
from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping
from graphon.nodes.base.node import Node
from services.workflow.node_defaults import complete_sub_models
from services.workflow.node_defaults import complete_sub_models, node_data_type
from services.workflow_service import WorkflowService
def _complete_default_config(node_class: type[Node], default_config: Mapping[str, Any]) -> dict[str, Any]:
def _complete_default_config(model: type[BaseModel], default_config: Mapping[str, Any]) -> dict[str, Any]:
config = default_config.get("config")
if not isinstance(config, Mapping):
return dict(default_config)
return {**default_config, "config": complete_sub_models(node_class, config)}
return {**default_config, "config": complete_sub_models(model, config)}
@openapi_ns.route("/node-types")
@@ -58,9 +58,10 @@ class NodeTypeDetailApi(Resource):
node_class = get_node_type_classes_mapping().get(node_type, {}).get(LATEST_VERSION)
if node_class is None:
raise NodeTypeNotFound()
model = node_data_type(node_type, node_class)
return NodeTypeDetailResponse(
type=node_type,
version=node_class.version(),
schema=node_class._get_node_data_type().model_json_schema(),
default_config=_complete_default_config(node_class, WorkflowService().get_default_block_config(node_type)),
schema=model.model_json_schema(),
default_config=_complete_default_config(model, WorkflowService().get_default_block_config(node_type)),
)
+56 -3
View File
@@ -13,11 +13,12 @@ from pydantic import ValidationError
from core.trigger.constants import TRIGGER_NODE_TYPES
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.node_factory import resolve_workflow_node_class
from core.workflow.nodes.human_input.constants import TIMEOUT_HANDLE
from graphon.entities.graph_config import NodeConfigDictAdapter
from graphon.enums import BuiltinNodeTypes, ErrorStrategy
from graphon.nodes.base.node import Node
from models import AppMode
from services.workflow.node_defaults import fill_node_data
from services.workflow.node_defaults import fill_node_data, node_data_type
logger = logging.getLogger(__name__)
@@ -43,11 +44,16 @@ class IssueCode(StrEnum):
BRANCH_HANDLE_INVALID = "branch_handle_invalid"
REFERENCE_MISSING = "reference_missing"
CONTAINER_START_MISSING = "container_start_missing"
OUTPUT_NAME_DUPLICATE = "output_name_duplicate"
BRANCH_UNCONNECTED = "branch_unconnected"
RESOURCE_UNAVAILABLE = "resource_unavailable"
@property
def severity(self) -> IssueSeverity:
return IssueSeverity.WARNING if self is IssueCode.RESOURCE_UNAVAILABLE else IssueSeverity.ERROR
return IssueSeverity.WARNING if self in _WARNING_CODES else IssueSeverity.ERROR
_WARNING_CODES: Final = frozenset({IssueCode.BRANCH_UNCONNECTED, IssueCode.RESOURCE_UNAVAILABLE})
@dataclass(frozen=True)
@@ -114,7 +120,8 @@ def _node_issues(graph: _Graph) -> Iterable[GraphIssue]:
)
continue
try:
node_class.validate_node_data(adapt_node_config_for_graph({"id": node_id, "data": data})["data"])
adapted = adapt_node_config_for_graph({"id": node_id, "data": data})["data"]
node_data_type(node_type, node_class).model_validate(adapted)
except ValidationError as error:
for problem in error.errors(include_url=False, include_input=False, include_context=False):
yield GraphIssue(
@@ -155,6 +162,11 @@ def _handles(data: Mapping[str, Any]) -> frozenset[str] | None:
if not isinstance(classes, list):
return None
return frozenset({str(c.get("id")) for c in classes if isinstance(c, Mapping)} | extra)
case BuiltinNodeTypes.HUMAN_INPUT:
actions = data.get("user_actions") or []
if not isinstance(actions, list):
return None
return frozenset({str(a.get("id")) for a in actions if isinstance(a, Mapping)} | {TIMEOUT_HANDLE} | extra)
case _:
return None
@@ -182,6 +194,45 @@ def _edge_issues(graph: _Graph) -> Iterable[GraphIssue]:
)
def _unconnected_branch_issues(graph: _Graph) -> Iterable[GraphIssue]:
for node_id, node in graph.nodes.items():
handles = _handles(_data(node))
if handles is None:
continue
used = {str(e.get("sourceHandle", _SOURCE_HANDLE)) for e in graph.edges if e.get("source") == node_id}
for handle in sorted(handles - used):
yield GraphIssue(
IssueCode.BRANCH_UNCONNECTED,
f"Branch {handle!r} of {node_id!r} has no edge; a run that takes it stops there",
node_id,
("nodes", node_id),
)
def _output_name_issues(graph: _Graph) -> Iterable[GraphIssue]:
"""End nodes share one set of workflow outputs, so the console refuses to publish a name used twice."""
places: dict[str, list[tuple[str, int]]] = {}
for node_id, node in graph.nodes.items():
data = _data(node)
outputs = data.get("outputs")
if data.get("type") != BuiltinNodeTypes.END or not isinstance(outputs, list):
continue
for index, output in enumerate(outputs):
name = str(output.get("variable") or "").strip() if isinstance(output, Mapping) else ""
if name:
places.setdefault(name, []).append((node_id, index))
for name, found in places.items():
if len(found) < 2:
continue
for node_id, index in found:
yield GraphIssue(
IssueCode.OUTPUT_NAME_DUPLICATE,
f"Output {name!r} is used by more than one End node output; output names must be unique",
node_id,
("nodes", node_id, "data", "outputs", index, "variable"),
)
def _reference_issues(graph: _Graph) -> Iterable[GraphIssue]:
for node_id, node in graph.nodes.items():
data = fill_node_data(_data(node))
@@ -207,6 +258,8 @@ _CHECKS: Final[Mapping[str, Callable[[_Graph], Iterable[GraphIssue]]]] = {
"app mode": _mode_issues,
"node data": _node_issues,
"edge": _edge_issues,
"branch": _unconnected_branch_issues,
"output name": _output_name_issues,
"reference": _reference_issues,
}
+6 -7
View File
@@ -2,22 +2,15 @@
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, Final
from libs import helper
from services.workflow.node_defaults import fill_node_data
_DATA_UI_KEYS: Final = frozenset({"selected", "_connectedSourceHandleIds", "_connectedTargetHandleIds"})
def graph_hash(graph: Mapping[str, Any]) -> str:
"""Same hash as Workflow.unique_hash, so a run's stored graph can be compared to the draft."""
return helper.generate_text_hash(json.dumps({"graph": graph}, sort_keys=True))
@dataclass(frozen=True)
class WorkflowSnapshot:
graph: Mapping[str, Any]
@@ -84,6 +77,12 @@ def _edges(graph: Mapping[str, Any]) -> set[str]:
}
def same_graph(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
"""Whether two graphs run the same; the editor's layout and selection state don't count."""
old, new = _nodes(a), _nodes(b)
return old.keys() == new.keys() and all(_data(old[i]) == _data(new[i]) for i in old) and _edges(a) == _edges(b)
def diff_workflows(published: WorkflowSnapshot | None, draft: WorkflowSnapshot) -> WorkflowDiff:
base = published or WorkflowSnapshot(graph={}, features={}, environment_variable_names=frozenset())
old, new = _nodes(base.graph), _nodes(draft.graph)
+22 -4
View File
@@ -8,9 +8,11 @@ from dataclasses import dataclass
from types import UnionType
from typing import Any, Final, Union, get_args, get_origin
from pydantic import BaseModel
from pydantic import BaseModel, Field
from core.workflow.human_input_adapter import DeliveryChannelConfig
from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping
from core.workflow.nodes.human_input.entities import HumanInputNodeData
from graphon.enums import BuiltinNodeTypes
from graphon.nodes.base.node import Node
@@ -29,6 +31,22 @@ EDITOR_EMPTY: Final[Mapping[str, Mapping[str, object]]] = {
}
class HumanInputNodeSpec(HumanInputNodeData):
"""Human-input data as the editor saves it; the run parses delivery methods beside the node data."""
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list)
# Graphon keeps these node data opaque; Dify parses them with its own model when the node runs.
_DIFY_NODE_DATA: Final[Mapping[str, type[BaseModel]]] = {
BuiltinNodeTypes.HUMAN_INPUT: HumanInputNodeSpec,
}
def node_data_type(node_type: str, node_class: type[Node]) -> type[BaseModel]:
return _DIFY_NODE_DATA.get(node_type) or node_class._get_node_data_type()
_LEGACY_IF_CASE_ID: Final = "true"
_DEFAULT_LOGICAL_OPERATOR: Final = "and"
@@ -79,9 +97,9 @@ def _fill(model: type[BaseModel], value: Mapping[str, Any]) -> dict[str, Any]:
return {**filled, **value}
def complete_sub_models(node_class: type[Node], data: Mapping[str, Any]) -> dict[str, Any]:
def complete_sub_models(model: type[BaseModel], data: Mapping[str, Any]) -> dict[str, Any]:
"""Add the keys each sub-object's model defaults; keys already present win."""
fields = node_class._get_node_data_type().model_fields
fields = model.model_fields
completed = dict(data)
for key, value in data.items():
field = fields.get(key)
@@ -105,7 +123,7 @@ def fill_node_data(data: Mapping[str, Any]) -> dict[str, Any]:
data = normalize(data)
filled = {**copy.deepcopy(dict(EDITOR_EMPTY.get(node_type, {}))), **data}
node_class = get_node_type_classes_mapping().get(node_type, {}).get(LATEST_VERSION)
return complete_sub_models(node_class, filled) if node_class else filled
return complete_sub_models(node_data_type(node_type, node_class), filled) if node_class else filled
def fill_graph(graph: Mapping[str, Any]) -> dict[str, Any]:
@@ -1,8 +1,6 @@
import json
from collections.abc import Mapping, Sequence
from libs import helper
from services.workflow.graph_diff import WorkflowSnapshot, diff_workflows, graph_hash
from services.workflow.graph_diff import WorkflowSnapshot, diff_workflows
from services.workflow.node_defaults import fill_graph
@@ -53,11 +51,6 @@ def test_edges_features_and_env() -> None:
assert diff.env_added == ["J"]
def test_graph_hash_matches_workflow_unique_hash() -> None:
graph: dict[str, object] = {"nodes": [A], "edges": []}
assert graph_hash(graph) == helper.generate_text_hash(json.dumps({"graph": graph}, sort_keys=True))
def test_defaults_filled_by_import_are_not_changes() -> None:
start = {"id": "s", "data": {"type": "start", "title": "Start"}}
http = {"id": "h", "data": {"type": "http-request", "title": "H", "method": "get", "url": "https://x"}}
+2 -2
View File
@@ -58,7 +58,7 @@ difyctl has no built-in business commands. Every operation the server publishes
| `delete cache` | Delete the local server catalog cache |
| `install skills <dir>` | Write the difyctl skill into a skills root (`--from`) |
`login` blocks until the browser approval arrives. An agent runs it in the background, relays the `open <url>` and `code <code>` lines from its stderr to the user, and does not cancel the job. In a sandbox it adds `--no-keyring` and sets `DIFY_CONFIG_DIR` to persistent storage on every call, so the login survives the next session.
`login` needs `--server`; there is no default server. It blocks until the browser approval arrives. An agent uses `--no-wait` instead: it gives the user the URL and code, then runs `login --resume` after the user approves. If it sets `DIFY_CONFIG_DIR`, it sets it on every call.
`--verbose` is global: it may appear on any command, and it keeps the raw server
response in the error envelope instead of dropping it.
@@ -85,7 +85,7 @@ The same files install through the Vercel skills installer: `npx skills add lang
`get config [key]` prints the whole local config or one key; `set config <key> <value>` and `unset config <key>` change it. The only config key today is `http.timeout` (request timeout in milliseconds).
Tokens use the OS keychain by default, falling back to `tokens.yml` on hosts without one; `login --no-keyring` chooses the file outright. Config and cache files are written `0600`, their directory `0700`.
Tokens use the OS keychain by default, falling back to `tokens.yml` on hosts without one. On Linux without a DBus session (`DBUS_SESSION_BUS_ADDRESS` unset), the keychain would only hold the token until the session ends, so difyctl uses `tokens.yml` there too; `login --no-keyring` chooses the file outright. Config and cache files are written `0600`, their directory `0700`.
| Env var | Effect |
| ------------------- | ----------------------------------------------------------- |
+28 -1
View File
@@ -114,7 +114,10 @@ it('resume with no pending login reports the saved login', async () => {
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' })
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 () => {
@@ -150,3 +153,27 @@ it('a new login drops a pending --no-wait login', async () => {
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)
},
)
+28 -16
View File
@@ -6,17 +6,20 @@ 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 { BaseError, notLoggedIn } from '@/errors/base'
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { Command } from '@/plugins/commands/command'
import { io } from '@/plugins/io'
import { session } from '@/plugins/session'
import { token } from '@/plugins/token'
import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser'
import { DEFAULT_HOST, resolveHost, validateVerificationURI } from '@/util/host'
import { resolveHost, validateVerificationURI } from '@/util/host'
const INPUT = z.object({
server: z.string().default(DEFAULT_HOST).describe('Dify server base URL'),
server: z
.string()
.optional()
.describe('Dify server base URL. Required to start a login; there is no default'),
no_browser: z
.boolean()
.default(false)
@@ -55,6 +58,8 @@ const FINAL_POLL_ERRORS: readonly string[] = [ErrorCode.AuthExpired, ErrorCode.A
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.
@@ -64,25 +69,26 @@ function meaningfulEmail(value: string | undefined): string | undefined {
export default class Login extends Command<typeof INPUT> {
static override summary =
'Log in via the OAuth device flow. Blocks until approval: run it in the background, never cancel it'
'Log in via the OAuth device flow. Agents: use --no-wait, then --resume after the user approves'
static override effect = 'write' as const
static override input = INPUT
static override examples = [
{
title: 'Agent: run in the background, relay the url and code to the user, do not cancel',
input: { server: 'https://dify.example.com', no_browser: true },
},
{
title: 'Agent in a sandbox: add --no-keyring and point DIFY_CONFIG_DIR at persistent storage',
input: { server: 'https://dify.example.com', no_browser: true, no_keyring: true },
},
{
title: 'Agent that cannot keep a process alive: start without waiting',
title: 'Agent: start without waiting, give the user the url and code, then end the turn',
input: { server: 'https://dify.example.com', no_browser: true, no_wait: true },
},
{
title: 'Agent in a sandbox: add --no-keyring and point DIFY_CONFIG_DIR at persistent storage',
input: {
server: 'https://dify.example.com',
no_browser: true,
no_wait: true,
no_keyring: true,
},
},
{
title:
'Finish a --no-wait login after the user approves (repeat while status is pending); with no pending login, reports the saved login or fails with not_logged_in',
'Finish a --no-wait login once the user says they approved; with no pending login, reports the saved login or fails with not_logged_in',
input: { resume: true },
},
]
@@ -91,6 +97,13 @@ export default class Login extends Command<typeof INPUT> {
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()
@@ -139,8 +152,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)).current()
if (current === null) throw notLoggedIn()
const current = await (await ctx.get(session)).require()
await (await ctx.get(token)).get()
return {
server: current.server,
+3 -2
View File
@@ -121,8 +121,9 @@ export function unknownError(message: string, cause?: unknown): BaseError {
export const LOGIN_HINT = `run ${BINARY} login`
export function notLoggedIn(): BaseError {
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint: LOGIN_HINT })
export function notLoggedIn(reason?: string): BaseError {
const message = reason === undefined ? 'not logged in' : `not logged in: ${reason}`
return new BaseError({ code: ErrorCode.NotLoggedIn, message, hint: LOGIN_HINT })
}
type HttpClientErrorOptions = BaseErrorOptions & {
+3 -2
View File
@@ -4,7 +4,7 @@ import { z } from 'zod'
import { BaseError, notLoggedIn } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { definePlugin } from '@/kernel/plugin'
import { env } from '@/plugins/env'
import { ENV, env } from '@/plugins/env'
import { STORAGE_MODES, YamlStore } from '@/store/store'
export const LOGIN_FILE_NAME = 'login.yml'
@@ -106,7 +106,8 @@ export const session = definePlugin({
current,
require: async () => {
const login = await current()
if (login === null) throw notLoggedIn()
if (login === null)
throw notLoggedIn(`no login saved in ${configDir}; ${ENV.ConfigDir} picks this folder`)
return login
},
save: async (login: Login) => {
+32 -1
View File
@@ -1,6 +1,6 @@
import type { TokenStore } from './token-store'
import { describe, expect, it, vi } from 'vite-plus/test'
import { detectTokenStore, getTokenStore } from './manager'
import { detectTokenStore, getTokenStore, keyringDurable } from './manager'
function memStore(label: string): TokenStore & { _label: string } {
const map = new Map<string, string>()
@@ -38,6 +38,7 @@ describe('detectTokenStore', () => {
const f = memStore('file')
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
@@ -51,6 +52,7 @@ describe('detectTokenStore', () => {
}) as TokenStore['write']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
@@ -62,6 +64,7 @@ describe('detectTokenStore', () => {
k.read = vi.fn(async () => 'something-else') as TokenStore['read']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
@@ -86,6 +89,7 @@ describe('detectTokenStore', () => {
const f = memStore('file')
await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(await k.read('__difyctl_probe__', '__difyctl_probe__')).toBe('')
})
@@ -99,6 +103,7 @@ describe('detectTokenStore', () => {
}) as TokenStore['read']
const result = await detectTokenStore({
factory: { keyring: () => k, file: () => f },
keyringDurable: true,
})
expect(removeSpy).toHaveBeenCalledWith('__difyctl_probe__', '__difyctl_probe__')
expect(result.mode).toBe('file')
@@ -129,3 +134,29 @@ describe('getTokenStore', () => {
expect(keyringFactory).not.toHaveBeenCalled()
})
})
describe('keyringDurable', () => {
const env = (vars: Record<string, string>) => (name: string) => vars[name]
it.each([
['linux', {}, false],
['linux', { DBUS_SESSION_BUS_ADDRESS: '' }, false],
['linux', { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' }, true],
['darwin', {}, true],
['win32', {}, true],
['freebsd', {}, true],
])('%s %o → %s', (platform, vars, durable) => {
expect(keyringDurable(platform, env(vars))).toBe(durable)
})
it('a non-durable keyring skips the probe and uses the file store', async () => {
const k = memStore('keyring')
const f = memStore('file')
const writeSpy = vi.spyOn(k, 'write')
const result = await detectTokenStore({
keyringDurable: false,
factory: { keyring: () => k, file: () => f },
})
expect(writeSpy).not.toHaveBeenCalled()
expect(result.mode).toBe('file')
})
})
+18 -1
View File
@@ -1,6 +1,7 @@
import type { StorageMode } from './store'
import type { TokenStore } from './token-store'
import { join } from 'node:path'
import { getEnv } from '@/sys'
import { resolveConfigDir } from './dir'
import { YamlStore } from './store'
import { FileTokenStore, KeychainTokenStore } from './token-store'
@@ -9,6 +10,8 @@ const TOKENS_FILE = 'tokens.yml'
export const CONFIG_FILE_NAME = 'config.yml'
const KEYRING_SERVICE = 'difyctl'
const LINUX = 'linux'
const DBUS_SESSION = 'DBUS_SESSION_BUS_ADDRESS'
function getStore(filePath: string): YamlStore {
return new YamlStore(filePath)
@@ -31,6 +34,18 @@ export type GetTokenStoreOptions = {
export type DetectTokenStoreOptions = GetTokenStoreOptions & {
readonly skipKeyring?: boolean
readonly keyringDurable?: boolean
}
/**
* Without a DBus session, the Linux keyring falls back to the kernel keyutils
* store: the probe round-trips, but the token is gone when the session ends.
*/
export function keyringDurable(
platform: string,
env: (name: string) => string | undefined,
): boolean {
return platform !== LINUX || Boolean(env(DBUS_SESSION))
}
const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => TokenStore> = {
@@ -47,7 +62,9 @@ const TOKEN_STORE_OPENERS: Record<StorageMode, (opts: GetTokenStoreOptions) => T
export async function detectTokenStore(
opts: DetectTokenStoreOptions = {},
): Promise<{ store: TokenStore; mode: StorageMode }> {
if (opts.skipKeyring === true) return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' }
const durable = opts.keyringDurable ?? keyringDurable(process.platform, getEnv)
if (opts.skipKeyring === true || !durable)
return { store: TOKEN_STORE_OPENERS.file(opts), mode: 'file' }
try {
const k = TOKEN_STORE_OPENERS.keychain(opts)
await k.write(PROBE_HOST, PROBE_EMAIL, PROBE_VALUE)
-3
View File
@@ -1,8 +1,6 @@
import { BaseError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
export const DEFAULT_HOST = 'https://cloud.dify.ai'
export function openAPIBase(host: string): string {
return `${host.replace(/\/+$/, '')}/openapi/v1/`
}
@@ -14,7 +12,6 @@ export type ResolveHostOptions = {
export function resolveHost(opts: ResolveHostOptions): string {
let raw = opts.raw.trim()
if (raw === '') raw = DEFAULT_HOST
if (!raw.includes('://')) raw = `https://${raw}`
let url: URL
try {
+2 -1
View File
@@ -13,7 +13,8 @@ Four phases. Each ends with a gate; never skip one, even for a small 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.
- Superpowers, visual tools and subagents are optional. Use them when you have them; the phases work without them.
- **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
@@ -1,93 +1,63 @@
# Build phase
## Contents
- Before the first slice
- Each slice
- When something fails
- Plan changes during build
- Building with subagents
In the commands below, `<mode>` is `workflow` for a Workflow app and `advanced_chat` for a Chatflow app.
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
Check two things: the human approved the plan, and the human chose how to build.
When you change an existing app, skip creating it and the skeleton slice. Start from the current draft.
When you change an existing app, skip creating it and skip the skeleton slice. Start from the current draft: `app.yml` from the first export.
Create the app once:
Otherwise create the app once, and write its `app_id` into `plan.md`:
```bash
difyctl create console_app workflow --name "<name>" --json
difyctl create console_app advanced_chat --name "<name>" --json
difyctl create console_app <mode> --name "<name>" --json
```
Write the `app_id` into the header of `plan.md`. A new app already has an empty draft, so you can export it straight away.
Work on this one app for the whole build. Change it by importing over its draft. Never make copies.
Build this one app; never make copies.
## Each slice
1. Export the draft. The result is `{"data": "<yaml>", "draft_hash": "<hash>"}`. Keep `draft_hash`. On the first slice, also save `data` as `app.yml`.
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 | jq -r .draft_hash
difyctl export console_app dsl --app-id <app_id> --json | jq -r .data > difyctl/<app-slug>/app.yml
difyctl export console_app dsl --app-id <app_id> --json
```
2. Write the slice's nodes and edges into `app.yml` by converting the node table. Make no new decisions here. Read [dsl.md](dsl.md) for where things go in the YAML.
3. Check the YAML before every import. The check lists every problem at once, so you fix them in one pass:
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
```
- Each issue has a `code`, a `severity` (`error` or `warning`), a `node_id` and a `loc` that points to the field. Fix every `error` by its `loc`, then check again. Read `warning` issues and decide.
- Go on when `valid` is true.
4. Import over the draft with that `draft_hash`:
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
```
- Send the YAML through stdin with `--input @-`, as above. Do not pass it as `--yaml-content "$(cat …)"`: a large file breaks the command line.
- A failed import exits 1 with an error envelope on stderr that carries the server's message. If the code is `dsl_invalid`, the `details` hold the same issues as the check. Fix them by `loc`. For any other failure, stop and show the human the message.
- If the draft changed since your export, the server's message says to export again. Someone else changed the draft. Export again, show the human the difference, and never overwrite it.
- A successful import prints a result. Check its `status`. Go on only when it is `completed`. On `completed-with-warnings`, stop and read `warnings`.
- `pending` means the DSL version differs from the server's, and the draft has not changed. Show the human `imported_dsl_version` and `current_dsl_version`. If they agree, run `difyctl confirm console_app dsl_import --import-id <id> --json` with the result's `id`.
- Each import changes the hash. Export again before the next import.
- Read [dsl.md](dsl.md) for what else an import changes.
- `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 with the plan's inputs. Fix it, import again, test again.
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 workflow --app-id <app_id> --node-id <node_id> --inputs '{"#<node_id>.<var>#": "<value>"}' --json
difyctl test node advanced_chat --app-id <app_id> --node-id <node_id> --query "<message>" --json
difyctl test node <mode> --app-id <app_id> --node-id <node_id> --inputs '{"#<node_id>.<var>#": "<value>"}' --json
```
A node test reuses the values the last full draft run saved. Override any value the node reads with `--inputs`, keyed `#<node_id>.<var>#`. To give a file variable a local file, pass `--files '{"<variable>": "<path>"}'`.
6. Run the whole draft on the slice's acceptance cases. Check each result with the case's check type.
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 workflow --app-id <app_id> --inputs '{"<var>": "<value>"}' --json
difyctl test console_app advanced_chat --app-id <app_id> --query "<message>" --inputs '{"<var>": "<value>"}' --json
```
Pass `--inputs` when the start node declares variables. Leave it out when there are none.
Record the run ids. List the latest draft runs with:
```bash
difyctl get run --app-id <app_id> --triggered-from debugging --limit 5 --json
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` and add its run ids. The ticked list is the progress record. A new session resumes at the first unticked slice.
8. Tick the slice in `plan.md` with its run ids. A new session resumes at the first unticked slice.
## When something fails
@@ -97,23 +67,14 @@ Find the node that failed and what it received:
difyctl get run node --app-id <app_id> --run-id <run_id> --json
```
The plan is the source of truth. If a fix changes a decision (a prompt, a condition, a node type), update the plan first, then the YAML.
A fix that changes the plan or spec follows the next section.
## Plan changes during build
## Changes to the plan or spec
You decide every plan change that does not touch the spec. For each one:
Never build a change first and report it later. For each change:
1. Update the plan.
2. Log it under "Changes during build": what changed, why, and what it costs if wrong.
The human reviews that list at hand over.
Stop and ask the human when a change touches the spec: a requirement, an acceptance case, a resource or the mode.
## Building with subagents
- Give one slice at a time to a fresh subagent. Pass it the slice text, the file paths and the `app_id`.
- The subagent runs steps 1 to 6. A reviewer subagent runs step 7.
- Never run two subagents on one app at the same time. Their imports would collide on the draft hash.
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.
+20 -182
View File
@@ -1,201 +1,39 @@
# App DSL
## Contents
- Where things go
- What import refuses
- Tool node
- Minimal Workflow
- Minimal Chatflow
This is the shape of an exported draft. Values vary. Both examples below are trimmed from real fixtures in the Dify repo.
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
- Top level: `version`, `kind: app`, `app` (name, mode, icon), `dependencies`, `workflow`.
- Keep the `version` your export gave you. The examples below show an older one.
- `app.mode` is `workflow` for a Workflow app and `advanced-chat` for a Chatflow app.
- `workflow.graph.nodes[]`: `{id, type: custom, data: {type, title, ...}, position: {x, y}}`. `data.type` is the node type. The rest of `data` comes from `difyctl describe node_type --node-type <type>`.
- Set each node's `data.version` to the `version` that `describe node_type` returns. The trimmed examples below leave `data.version` out; always add it.
- `workflow.graph.edges[]`: `{id, source, target, sourceHandle: source, targetHandle: target, data: {sourceType, targetType}}`.
- Branch nodes use another `sourceHandle`:
- if-else: each case's `case_id` (the first case is usually `true`), plus `false` for the else branch;
- `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`;
- a node with `error_strategy: fail-branch`: `source` for success, `fail-branch` for failure.
- `workflow.features`, `workflow.environment_variables`, `workflow.conversation_variables`: keep what the export gave you.
- 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 fields. In `value_selector` write `[<node_id>, <var>]`.
- An LLM node's `model.provider` and `model.name` must name a model set up in the workspace. Use the one the plan names. If the plan names none, ask the human.
- 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.
## What import refuses
`difyctl check console_app dsl` and the import run the same check. Each issue has a `severity`. An `error` stops the import. These are the error codes:
- `graph_invalid`: the graph is missing or is not a valid graph.
- `mode_incompatible`: the node does not fit the app mode. No `answer` node in a Workflow app. No `end` node and no trigger nodes in a Chatflow app.
- `unknown_node_type`: `data.type` is not a node type the server knows.
- `node_data_invalid`: a node's `data` does not match its type. The `loc` names the field.
- `edge_endpoint_missing`: an edge's `source` or `target` is not a node id.
- `branch_handle_invalid`: an edge's `sourceHandle` is not one of the node's branches.
- `reference_missing`: a `{{#node_id.var#}}` or `value_selector` points to a node that does not exist.
- `container_start_missing`: a loop or iteration has no start node.
A `warning` does not stop the import. Publish refuses it on Enterprise, and runs fail on it everywhere. Fix it before hand-over:
- `resource_unavailable`: the YAML names a model, tool or plugin that the workspace does not have.
Don't use `datasource` or `knowledge-index` nodes. They belong to knowledge pipelines.
Import fills empty containers for you: Start `variables`, tool parameter maps and similar. It never fills in a model, classes or a prompt. Write those yourself. An old if-else condition becomes a `true` case.
- 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. A constant is `{type: constant, value: …}`. Text with references is `{type: mixed, value: "{{#node_id.var#}}"}`. A single variable is `{type: variable, value: [node_id, var]}`.
- 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.
## Minimal Workflow
## The check
Start wired to End. Source: `api/tests/fixtures/workflow/simple_passthrough_workflow.yml`, a real console export, trimmed.
`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`.
```yaml
version: 0.3.1
kind: app
app:
name: echo
mode: workflow
icon: 🤖
icon_background: '#FFEAD5'
description: ''
use_icon_as_answer_icon: false
dependencies: []
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload: { enabled: false }
opening_statement: ''
retriever_resource: { enabled: true }
sensitive_word_avoidance: { enabled: false }
speech_to_text: { enabled: false }
suggested_questions: []
suggested_questions_after_answer: { enabled: false }
text_to_speech: { enabled: false, language: '', voice: '' }
graph:
nodes:
- id: '1754154032319'
type: custom
position: { x: 30, y: 227 }
data:
type: start
title: Start
desc: ''
variables:
- {
variable: query,
label: query,
type: text-input,
required: true,
max_length: null,
options: [],
}
- id: '1754154034161'
type: custom
position: { x: 334, y: 227 }
data:
type: end
title: End
desc: ''
outputs:
- { variable: query, value_type: string, value_selector: ['1754154032319', query] }
edges:
- id: 1754154032319-source-1754154034161-target
type: custom
source: '1754154032319'
sourceHandle: source
target: '1754154034161'
targetHandle: target
data: { sourceType: start, targetType: end }
```
## Minimal Chatflow
Start, then an LLM, then Answer. Source: `api/tests/fixtures/workflow/basic_chatflow.yml`, trimmed. The fixture leaves the model empty; fill in `provider` and `name`.
```yaml
version: 0.3.1
kind: app
app:
name: basic_chatflow
mode: advanced-chat
icon: 🤖
icon_background: '#FFEAD5'
description: Simple chatflow contains only 1 LLM node.
use_icon_as_answer_icon: false
dependencies: []
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload: {}
opening_statement: ''
retriever_resource: { enabled: true }
sensitive_word_avoidance: { enabled: false }
speech_to_text: { enabled: false }
suggested_questions: []
suggested_questions_after_answer: { enabled: false }
text_to_speech: { enabled: false, language: '', voice: '' }
graph:
nodes:
- id: '1755189262236'
type: custom
position: { x: 80, y: 282 }
data:
type: start
title: Start
desc: ''
variables: []
- id: llm
type: custom
position: { x: 380, y: 282 }
data:
type: llm
title: LLM
desc: ''
context: { enabled: false, variable_selector: [] }
memory:
query_prompt_template: '{{#sys.query#}}'
window: { enabled: false, size: 10 }
model:
provider: ''
name: ''
mode: chat
completion_params: { temperature: 0.7 }
prompt_template:
- { role: system, text: '' }
variables: []
vision: { enabled: false }
- id: answer
type: custom
position: { x: 680, y: 282 }
data:
type: answer
title: Answer
desc: ''
answer: '{{#llm.text#}}'
variables: []
edges:
- id: 1755189262236-llm
source: '1755189262236'
sourceHandle: source
target: llm
targetHandle: target
- id: llm-answer
source: llm
sourceHandle: source
target: answer
targetHandle: target
```
- 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,45 +1,47 @@
# Hand-over phase
Start when every slice in `plan.md` is ticked. In the commands below, `<mode>` is `workflow` for a Workflow app and `advanced_chat` for a Chatflow app.
Start when every slice in `plan.md` is ticked. `<mode>` is `workflow` or `advanced_chat`.
1. Run the final acceptance run: every case in the spec, on the draft. Report pass or fail per requirement and per case. Show your reasoning for each `judged` case.
2. Check the release: `difyctl check console_app release --app-id <app_id> --json`. Run it after the acceptance run, so `tested` covers the current draft. The top-level `ready` is true only when every check passes. Fix every item in `issues`. If `tested` fails, run the draft again, then check again. Don't go on until `ready` is true.
3. Stop at the human gate. Show the human the `changes` against the published version in plain words, the `checks` (`draft_valid` and `tested`), the acceptance results, the "Changes during build" list from `plan.md`, and anything still open. Nothing goes live without a yes.
4. Publish only when `ready` is true and the human says yes. Keep `version_id` from the result. If the result has a `warning`, show it to the human. Then confirm the new version is live:
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
```
The row whose `id` is that `version_id` must show `current: true`.
5. Settle access. A new app starts with the web app on and the Service API on. Ask the human about each item separately.
- Web app: keep it on or turn it off. `--enabled` turns it on; `--enabled=false` turns it off. When it stays on, give the human the `url` from the result.
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): keep it on or turn it off, with `--enabled` or `--enabled=false`. Warn the human first: with the Service API off, difyctl can no longer export, test, run, publish or restore this app, and it no longer appears in `difyctl get console_app` or `describe console_app`. Import still works. Note the app id before turning it off. An admin can turn it back on with `difyctl set service_api --app-id <app_id> --enabled`.
- 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). `--access-mode` is one of `public`, `private_all` or `sso_verified`:
- 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
```
For an agent app, use `set webapp_access agent`, `set service_api agent` and `set webapp agent`. To let only chosen members or groups in (`private`), the human must pick them in the Dify console. The mode also applies to `difyctl run`, so the logged-in account must be allowed by it for the smoke test below.
6. Smoke test the live app with one acceptance case. Pass `--inputs` when the start node declares variables.
6. Smoke test the live app with one acceptance case. Chatflow also takes `--query "<message>"`.
```bash
difyctl run console_app workflow --app-id <app_id> --inputs '{"<var>": "<value>"}' --json
difyctl run console_app advanced_chat --app-id <app_id> --query "<message>" --inputs '{"<var>": "<value>"}' --json
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.
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,56 +1,28 @@
# Plan phase
## Contents
- How to plan
- What `plan.md` holds
- Reviews
- Template
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. Confirm each node type exists with `difyctl get node_type`. Read its schema, defaults and `version` with `difyctl describe node_type --node-type <type>`. Never use a node type the server doesn't list.
2. Settle every config in full:
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.
- Tool: the chosen tool's row from `difyctl get tool --provider <id>`, and a value for every parameter. Never guess a parameter.
- 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#}}`.
Leave nothing for the build phase to decide. Writing the YAML afterwards is only a change of format.
3. Draw the node-level Mermaid graph. Use real node ids. Draw every edge. Label each branch: if-else true and false, classifier classes, error branches.
4. Cut the build into slices. Each slice is small and connected, and it imports and runs on its own.
- Slice 1 is always the skeleton: start → end (Chatflow: start → answer), with the final inputs and outputs.
- Each later slice adds a few nodes.
5. Map the tests.
- Give each new node a `difyctl test node <mode>` call with specific inputs. `<mode>` is `workflow` for a Workflow app and `advanced_chat` for a Chatflow app.
- End each slice with a full draft run (`difyctl test console_app workflow` or `difyctl test console_app advanced_chat`) on the acceptance cases that slice can already pass.
6. Check coverage. Every requirement maps to a node and a test. Every resource is confirmed. An open resource blocks approval. Each chosen plugin is installed and configured. An HTTP Request or Code node that calls an outside service needs that choice recorded in the spec.
7. Ask the human to review the plan. Offer two ways to build:
- you build it alone;
- one subagent per slice, one after another.
## What `plan.md` holds
| Section | Content |
| -------------------- | -------------------------------------------------------------------------------------------- |
| Header | App name, mode, `app_id` (filled in at build), link to the spec |
| Node graph | Node-level Mermaid |
| Node table | Id, type, version, title, full config, inputs used, outputs, covers `R…` |
| Slices | Ordered; each lists nodes added, `R`s covered, `test node` calls, full-run cases, a checkbox |
| Test map | Acceptance case → command, inputs, check type |
| Changes during build | Filled in during build (read [build.md](build.md)) |
## Reviews
After each slice, review the slice against the plan. Use a reviewer subagent if you have one; otherwise check your own work. Ask the human at hand over. Ask earlier only when the spec has to change.
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 `difyctl/<app-slug>/plan.md` and fill it in.
Copy this into `plan.md` and fill it in.
````markdown
# <App name>: plan
@@ -68,22 +40,19 @@ flowchart LR
## Node table
| Id | Type | Version | Title | Config | Inputs used | Outputs | Covers |
| --------- | ---- | ------- | ----------- | --------------------------------------------------------------------------------------------------------------------- | ----------------- | ------- | ------ |
| order_llm | llm | 1 | Order reply | model: <provider>/<model>; system prompt: "Answer in the customer's language. Give the order status in one sentence." | `{{#sys.query#}}` | text | R1 |
| 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` and `order_llm`. Covers R1. Node tests: `test node advanced_chat` on `order_llm` with query "Où est ma commande ?". Full run: A1. Runs: <run ids>
- [ ] 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
| Case | Command | Inputs | Check type |
| ---- | ------------------------------ | ----------------------------- | ---------- |
| A1 | test console_app advanced_chat | query: "Où est ma commande ?" | judged |
- A1: `test console_app advanced_chat`, query "Où est ma commande ?", judged.
## Changes during build
| What changed | Why | Cost if wrong |
| ------------ | --- | ------------- |
- <what changed>: <why>. Cost if wrong: <cost>. Approved: <when>.
````
@@ -1,59 +1,28 @@
# Spec phase
## Contents
- How to run the spec talk
- What `spec.md` holds
- Check types for acceptance cases
- Template
The spec phase ends when the human approves `difyctl/<app-slug>/spec.md`. Don't start the plan before that.
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. Ask what the app is for, who uses it, and what a good result looks like. Ask one question at a time. Offer multiple choice where you can.
2. Choose the mode early and say why.
- Workflow: one run, inputs to outputs.
- Chatflow: a conversation with memory.
3. Build two things side by side as you talk.
- **Requirements list.** Whenever the human states a condition or a feature, at any point, add it as `R<n>` in their own words. Give each one a type:
- **feature**: something the app must do.
- **rule**: a condition or a limit.
- **failure**: what happens when something goes wrong.
- **Process graph.** A Mermaid graph of business steps, not nodes. If you have a way to draw richer visuals, offer that too.
4. Walk scenarios with the human. Take 3 to 6 realistic cases through the graph. Include edge cases: empty input, wrong language, a tool failing, an answer too long. Each walk confirms or changes the graph and the requirements.
5. Collect resources: models, tools and plugins, knowledge bases, agents, credentials.
- For each step that calls an outside service or needs a model, search before you suggest anything: `difyctl get tool --query <service>`, `difyctl get model --model-type llm`, then `difyctl get marketplace plugin --query <service>`.
- Show the human a short list per step: name, brief, official, partner or community, install count, installed and configured or not. List HTTP Request or Code last. Say which you recommend and why: an installed and configured tool first, then an official or partner plugin, then a workflow tool.
- The human picks. Record the choice and the options shown.
- Never invent a name. Write an unknown item as open; don't guess it.
6. Before you ask for approval:
- Read the requirements back and ask "anything missing?"
- Trace both ways. Each requirement names its graph step, or "every step". Each requirement has at least one acceptance case. A requirement missing either is a gap. Raise it with the human.
7. Write the spec. Check it against itself. Ask the human to review it. Don't plan until it is approved.
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.
## What `spec.md` holds
| Section | Content |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Goal | What it does, for whom, what success looks like |
| Mode | Workflow or Chatflow, and why |
| Requirements | `R1…Rn`: type, the requirement in plain words, where it lives in the graph |
| Inputs and outputs | Each variable: name, type, required or optional, example. Chatflow: query and conversation memory too |
| Process | The final Mermaid graph; steps may be tagged with requirement ids |
| Acceptance cases | Id, scenario, inputs, expected result, check type, covers (`R` ids) |
| Resources | Each need, the options shown, the human's choice, confirmed or open, and who provides it |
| Out of scope and open questions | |
## Check types for acceptance cases
- `exact`: the output must match.
- `contains`: the output must contain given text, or match a pattern.
- `judged`: you grade the output against a short rubric written in the case, and show your reasoning.
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 `difyctl/<app-slug>/spec.md` and fill it in.
Copy this into `spec.md` and fill it in.
````markdown
# <App name>: spec
@@ -68,17 +37,11 @@ Copy this into `difyctl/<app-slug>/spec.md` and fill it in.
## Requirements
| Id | Type | Requirement | Graph step |
| --- | ------- | ------------------------------------------- | ---------- |
| R1 | feature | Reply in the language the customer wrote in | Answer |
- R1 (feature): Reply in the language the customer wrote in. Step: Answer.
## Inputs and outputs
| Variable | Type | Required | Example |
| -------- | ------ | ---------------------- | --------- |
| <name> | <type> | <required or optional> | <example> |
<Chatflow: also the query and what the conversation must remember.>
- <name>: <type>, <required or optional>, e.g. <example>.
## Process
@@ -89,15 +52,11 @@ flowchart LR
## Acceptance cases
| Id | Scenario | Inputs | Expected result | Check type | Covers |
| --- | ----------------------- | ----------------------------- | --------------------------------------------- | ------------------------------------------------ | ------ |
| A1 | Customer asks in French | query: "Où est ma commande ?" | Reply is in French and gives the order status | judged: French, names the status, under 80 words | R1 |
- 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 | Options shown | Status | Provided by |
| -------------------------------------------------- | -------- | -------------------- | ------------------- | ----------- |
| <model, tool, knowledge base, agent or credential> | <picked> | <what the human saw> | <confirmed or open> | <who> |
- <need>: <choice>. Shown: <options>. <confirmed or open>, provided by <who>.
## Out of scope and open questions
+2 -7
View File
@@ -1,11 +1,9 @@
# Plugins, models and credentials
Read this in the spec phase to find tools, models and knowledge bases, and whenever a plugin, model, tool or credential is missing, or the user asks to install or remove a plugin.
## 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. 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.
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.
@@ -33,10 +31,7 @@ Read this in the spec phase to find tools, models and knowledge bases, and whene
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.
## Models
- `difyctl get model --provider <id>` lists one provider's models; use one whose `status` is `active`.
- Self-hosted or OpenAI-compatible models: `create model credential` with `--model`, `--model-type` and `--credentials @-`.
Self-hosted or OpenAI-compatible models: `create model credential` with `--model`, `--model-type` and `--credentials @-`.
## Remove
+14 -13
View File
@@ -4,28 +4,29 @@
`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. Run this check again any time you lose track, for example after you stopped waiting or a background job died. The login may already be done.
**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.
Pick the way that fits how you run commands:
**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.
1. **You can keep a background job alive** (most agent shells). 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. Do not kill the job; it exits when the user approves, and its exit code reports the result.
2. **Each command must finish before the next one, or background jobs die between calls.** Start without waiting, then finish later:
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, and ask them to tell you when they have approved.
3. `difyctl login --resume`. It checks once. While it prints `status: pending`, wait a few seconds and run it again. When it prints the account `email`, you are logged in.
4. If the code expires or the user denies it, `--resume` fails. Start again from step 1.
3. **A script with a token already.** Skip login: set `DIFY_SERVER` and `DIFY_TOKEN`.
Log in only this way:
**Waiting.** The user may take several minutes. Keep waiting until `expires_in` runs out (usually 15 minutes). Stop early only if the user says they declined.
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. In a sandbox, a container or a headless VM, the keyring is often missing or lost between sessions. There:
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. With `--no-wait`, pass it on the first call; `--resume` remembers it.
- Set `DIFY_CONFIG_DIR` to storage that survives the session, and set it the same way on every call. The pending login from `--no-wait` lives there too, so `--resume` needs the same value.
- 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