chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Dify Workflow Harness Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This harness adds `cli-anything-dify-workflow`, a CLI-Anything wrapper around the
|
||||
existing `dify-workflow` CLI from the open-source
|
||||
`dify-ai-workflow-tools` project.
|
||||
|
||||
The upstream CLI already provides the real workflow authoring engine for Dify DSL
|
||||
files. This harness focuses on:
|
||||
|
||||
- CLI-Anything packaging under the shared `cli_anything` namespace
|
||||
- AI-discoverable `SKILL.md`
|
||||
- unified REPL skin
|
||||
- registry integration for CLI-Hub
|
||||
- tests that verify wrapper discovery and forwarding behavior
|
||||
|
||||
## Interaction Model
|
||||
|
||||
```text
|
||||
AI Agent
|
||||
-> cli-anything-dify-workflow
|
||||
-> installed dify-workflow CLI / dify_workflow Python package
|
||||
-> local Dify YAML/JSON DSL files
|
||||
```
|
||||
|
||||
## Why a Wrapper Harness
|
||||
|
||||
The upstream project is already a mature CLI and does not need to be rewritten
|
||||
inside CLI-Anything. Following existing wrapper patterns in this repository,
|
||||
this harness exposes the upstream CLI through a CLI-Anything package so agents
|
||||
can discover it from CLI-Hub and load its skill metadata.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Upstream Dify workflow CLI installed separately:
|
||||
- recommended: install from GitHub
|
||||
- optional: install from PyPI if published later
|
||||
|
||||
## Command Surface
|
||||
|
||||
The wrapper exposes the upstream command groups:
|
||||
|
||||
- `guide`
|
||||
- `list-node-types`
|
||||
- `create`
|
||||
- `inspect`
|
||||
- `validate`
|
||||
- `checklist`
|
||||
- `edit <subcommand>`
|
||||
- `config <subcommand>`
|
||||
- `export`
|
||||
- `import`
|
||||
- `diff`
|
||||
- `layout`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- `test_core.py`: backend discovery, command building, wrapper metadata
|
||||
- `test_full_e2e.py`: wrapper subprocess smoke tests and workflow lifecycle
|
||||
forwarding when the upstream CLI is installed
|
||||
@@ -0,0 +1,2 @@
|
||||
include DIFY_WORKFLOW.md
|
||||
recursive-include cli_anything *.md
|
||||
@@ -0,0 +1,53 @@
|
||||
# cli-anything-dify-workflow
|
||||
|
||||
CLI-Anything wrapper for the open-source Dify workflow DSL editor.
|
||||
|
||||
This package does not reimplement the Dify workflow engine. It wraps the
|
||||
existing `dify-workflow` CLI so agents can discover and use it through the
|
||||
CLI-Anything ecosystem and CLI-Hub.
|
||||
|
||||
## Install
|
||||
|
||||
Install the upstream Dify workflow CLI first:
|
||||
|
||||
```bash
|
||||
python -m pip install "dify-ai-workflow-tools @ git+https://github.com/Akabane71/dify-workflow-cli.git@main"
|
||||
```
|
||||
|
||||
Then install this harness:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=dify-workflow/agent-harness
|
||||
```
|
||||
|
||||
If the upstream project is published to PyPI later, you can replace the first
|
||||
command with a normal PyPI install.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cli-anything-dify-workflow guide
|
||||
cli-anything-dify-workflow list-node-types
|
||||
cli-anything-dify-workflow create -o workflow.yaml --template llm
|
||||
cli-anything-dify-workflow inspect workflow.yaml -j
|
||||
cli-anything-dify-workflow validate workflow.yaml -j
|
||||
cli-anything-dify-workflow edit add-node -f workflow.yaml --type code --title "Process"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Most upstream commands support `-j` / `--json-output`.
|
||||
- The wrapper forwards commands to the installed upstream CLI.
|
||||
- If `dify-workflow` is not on PATH but the `dify_workflow` Python package is
|
||||
installed, the wrapper falls back to `python -m dify_workflow.cli`.
|
||||
|
||||
## Run Tests
|
||||
|
||||
```bash
|
||||
python -m pytest cli_anything/dify_workflow/tests/ -v
|
||||
```
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- This harness only edits local Dify workflow DSL files.
|
||||
- Review generated YAML/JSON before importing into production Dify projects.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""CLI-Anything Dify Workflow wrapper package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from cli_anything.dify_workflow.dify_workflow_cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI-Anything wrapper for the upstream dify-workflow CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from cli_anything.dify_workflow import __version__
|
||||
from cli_anything.dify_workflow.utils.dify_workflow_backend import run_dify_workflow
|
||||
from cli_anything.dify_workflow.utils.repl_skin import ReplSkin
|
||||
|
||||
PASS_ARGS = {
|
||||
"ignore_unknown_options": True,
|
||||
"allow_extra_args": True,
|
||||
}
|
||||
|
||||
|
||||
def _configure_stdio() -> None:
|
||||
"""Prefer UTF-8 stdio so forwarded output stays readable on Windows."""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is None:
|
||||
continue
|
||||
try:
|
||||
reconfigure(encoding="utf-8")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
_configure_stdio()
|
||||
|
||||
|
||||
def _emit(output: str) -> None:
|
||||
if output:
|
||||
click.echo(output)
|
||||
|
||||
|
||||
def _forward(*prefix: str) -> None:
|
||||
try:
|
||||
output = run_dify_workflow([*prefix, *list(click.get_current_context().args)])
|
||||
except RuntimeError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
_emit(output)
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True, context_settings=PASS_ARGS)
|
||||
@click.version_option(version=__version__, prog_name="cli-anything-dify-workflow")
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context) -> None:
|
||||
"""CLI-Anything wrapper for the Dify workflow DSL editor."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
ctx.invoke(repl)
|
||||
|
||||
|
||||
@cli.command()
|
||||
def repl() -> None:
|
||||
"""Start a lightweight forwarding REPL."""
|
||||
skin = ReplSkin("dify_workflow", version=__version__)
|
||||
skin.print_banner()
|
||||
skin.info("Type upstream dify-workflow commands without the binary name.")
|
||||
skin.info("Examples: guide | list-node-types | create -o app.yaml --template llm")
|
||||
skin.hint("Type help for wrapper commands, quit to exit.")
|
||||
|
||||
commands = {
|
||||
"guide": "Show the upstream tutorial",
|
||||
"list-node-types": "List supported Dify node types",
|
||||
"create": "Create a new Dify app",
|
||||
"inspect": "Inspect a workflow file",
|
||||
"validate": "Validate a workflow file",
|
||||
"edit": "Workflow graph mutation commands",
|
||||
"config": "Model config mutation commands",
|
||||
"export": "Export YAML or JSON",
|
||||
"import": "Import and normalize a workflow file",
|
||||
"diff": "Compare two workflow files",
|
||||
"layout": "Auto-layout nodes",
|
||||
}
|
||||
|
||||
pt_session = skin.create_prompt_session()
|
||||
while True:
|
||||
try:
|
||||
line = skin.get_input(pt_session, context="dify")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
skin.print_goodbye()
|
||||
return
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if line in {"quit", "exit"}:
|
||||
skin.print_goodbye()
|
||||
return
|
||||
if line == "help":
|
||||
skin.help(commands)
|
||||
continue
|
||||
|
||||
try:
|
||||
output = run_dify_workflow(shlex.split(line))
|
||||
except RuntimeError as exc:
|
||||
skin.error(str(exc))
|
||||
continue
|
||||
if output:
|
||||
click.echo(output)
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def guide() -> None:
|
||||
"""Forward to dify-workflow guide."""
|
||||
_forward("guide")
|
||||
|
||||
|
||||
@cli.command("list-node-types", context_settings=PASS_ARGS)
|
||||
def list_node_types() -> None:
|
||||
"""Forward to dify-workflow list-node-types."""
|
||||
_forward("list-node-types")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def create() -> None:
|
||||
"""Forward to dify-workflow create."""
|
||||
_forward("create")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def inspect() -> None:
|
||||
"""Forward to dify-workflow inspect."""
|
||||
_forward("inspect")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def validate() -> None:
|
||||
"""Forward to dify-workflow validate."""
|
||||
_forward("validate")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def checklist() -> None:
|
||||
"""Forward to dify-workflow checklist."""
|
||||
_forward("checklist")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def export() -> None:
|
||||
"""Forward to dify-workflow export."""
|
||||
_forward("export")
|
||||
|
||||
|
||||
@cli.command("import", context_settings=PASS_ARGS)
|
||||
def import_cmd() -> None:
|
||||
"""Forward to dify-workflow import."""
|
||||
_forward("import")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def diff() -> None:
|
||||
"""Forward to dify-workflow diff."""
|
||||
_forward("diff")
|
||||
|
||||
|
||||
@cli.command(context_settings=PASS_ARGS)
|
||||
def layout() -> None:
|
||||
"""Forward to dify-workflow layout."""
|
||||
_forward("layout")
|
||||
|
||||
|
||||
@cli.group(context_settings=PASS_ARGS)
|
||||
def edit() -> None:
|
||||
"""Workflow graph editing commands."""
|
||||
|
||||
|
||||
@edit.command("add-node", context_settings=PASS_ARGS)
|
||||
def edit_add_node() -> None:
|
||||
_forward("edit", "add-node")
|
||||
|
||||
|
||||
@edit.command("remove-node", context_settings=PASS_ARGS)
|
||||
def edit_remove_node() -> None:
|
||||
_forward("edit", "remove-node")
|
||||
|
||||
|
||||
@edit.command("update-node", context_settings=PASS_ARGS)
|
||||
def edit_update_node() -> None:
|
||||
_forward("edit", "update-node")
|
||||
|
||||
|
||||
@edit.command("add-edge", context_settings=PASS_ARGS)
|
||||
def edit_add_edge() -> None:
|
||||
_forward("edit", "add-edge")
|
||||
|
||||
|
||||
@edit.command("remove-edge", context_settings=PASS_ARGS)
|
||||
def edit_remove_edge() -> None:
|
||||
_forward("edit", "remove-edge")
|
||||
|
||||
|
||||
@edit.command("set-title", context_settings=PASS_ARGS)
|
||||
def edit_set_title() -> None:
|
||||
_forward("edit", "set-title")
|
||||
|
||||
|
||||
@cli.group(context_settings=PASS_ARGS)
|
||||
def config() -> None:
|
||||
"""Chat/agent/completion config commands."""
|
||||
|
||||
|
||||
@config.command("set-model", context_settings=PASS_ARGS)
|
||||
def config_set_model() -> None:
|
||||
_forward("config", "set-model")
|
||||
|
||||
|
||||
@config.command("set-prompt", context_settings=PASS_ARGS)
|
||||
def config_set_prompt() -> None:
|
||||
_forward("config", "set-prompt")
|
||||
|
||||
|
||||
@config.command("add-variable", context_settings=PASS_ARGS)
|
||||
def config_add_variable() -> None:
|
||||
_forward("config", "add-variable")
|
||||
|
||||
|
||||
@config.command("set-opening", context_settings=PASS_ARGS)
|
||||
def config_set_opening() -> None:
|
||||
_forward("config", "set-opening")
|
||||
|
||||
|
||||
@config.command("add-question", context_settings=PASS_ARGS)
|
||||
def config_add_question() -> None:
|
||||
_forward("config", "add-question")
|
||||
|
||||
|
||||
@config.command("add-tool", context_settings=PASS_ARGS)
|
||||
def config_add_tool() -> None:
|
||||
_forward("config", "add-tool")
|
||||
|
||||
|
||||
@config.command("remove-tool", context_settings=PASS_ARGS)
|
||||
def config_remove_tool() -> None:
|
||||
_forward("config", "remove-tool")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: cli-anything-dify-workflow
|
||||
description: Wrapper for the Dify workflow DSL CLI. Create, inspect, validate, edit, and export Dify workflow files through a CLI-Anything harness.
|
||||
---
|
||||
|
||||
# Dify Workflow CLI Skill
|
||||
|
||||
## Installation
|
||||
|
||||
Install the upstream Dify workflow CLI first:
|
||||
|
||||
```bash
|
||||
python -m pip install "dify-ai-workflow-tools @ git+https://github.com/Akabane71/dify-workflow-cli.git@main"
|
||||
```
|
||||
|
||||
Then install the CLI-Anything harness:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=dify-workflow/agent-harness
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The harness forwards to the upstream `dify-workflow` CLI.
|
||||
|
||||
```bash
|
||||
cli-anything-dify-workflow guide
|
||||
cli-anything-dify-workflow list-node-types
|
||||
cli-anything-dify-workflow create -o workflow.yaml --mode workflow --template llm
|
||||
cli-anything-dify-workflow inspect workflow.yaml -j
|
||||
cli-anything-dify-workflow validate workflow.yaml -j
|
||||
cli-anything-dify-workflow edit add-node -f workflow.yaml --type code --title "Process"
|
||||
cli-anything-dify-workflow config set-model -f app.yaml --provider openai --name gpt-4o
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
- `guide`
|
||||
- `list-node-types`
|
||||
- `create`
|
||||
- `inspect`
|
||||
- `validate`
|
||||
- `checklist`
|
||||
- `edit add-node|remove-node|update-node|add-edge|remove-edge|set-title`
|
||||
- `config set-model|set-prompt|add-variable|set-opening|add-question|add-tool|remove-tool`
|
||||
- `export`
|
||||
- `import`
|
||||
- `diff`
|
||||
- `layout`
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- Prefer `-j` / `--json-output` on upstream commands when available.
|
||||
- The harness itself is a wrapper. Real workflow logic is provided by the upstream project.
|
||||
- If `dify-workflow` is not on PATH but the `dify_workflow` Python package is installed, the wrapper falls back to `python -m dify_workflow.cli`.
|
||||
- All operations are local file edits on Dify YAML/JSON DSL files.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Dify Workflow Harness Test Documentation
|
||||
|
||||
## Test Inventory Plan
|
||||
|
||||
- `test_core.py`: backend discovery, command construction, packaging fixture checks
|
||||
- `test_full_e2e.py`: wrapper subprocess smoke tests and workflow lifecycle forwarding
|
||||
|
||||
## Unit Test Plan
|
||||
|
||||
### utils/dify_workflow_backend.py
|
||||
|
||||
- resolve the upstream `dify-workflow` executable from PATH
|
||||
- fall back to `python -m dify_workflow.cli` when only the Python package exists
|
||||
- raise clear install guidance when neither exists
|
||||
- build the final subprocess command correctly
|
||||
|
||||
### Packaging fixtures
|
||||
|
||||
- README documents two-step installation
|
||||
- SKILL.md documents wrapper semantics and installation
|
||||
- wrapper CLI exposes the expected top-level commands
|
||||
|
||||
## E2E Test Plan
|
||||
|
||||
- verify `--help` on the wrapper command
|
||||
- create a minimal workflow through the wrapper when the upstream CLI is installed
|
||||
- validate the created workflow through the wrapper
|
||||
- inspect the workflow as JSON through the wrapper
|
||||
|
||||
## Notes
|
||||
|
||||
This harness wraps an external open-source CLI rather than shipping the workflow
|
||||
engine itself. Full E2E tests therefore require the upstream Dify workflow CLI
|
||||
to be installed locally before the suite is run. If the upstream CLI is missing
|
||||
or broken, the E2E suite now fails explicitly instead of being skipped.
|
||||
|
||||
## Test Results
|
||||
|
||||
Verified on 2026-04-07 in the local CLI-Anything worktree.
|
||||
|
||||
### Commands Run
|
||||
|
||||
```bash
|
||||
uv run --with pytest --with click --with prompt-toolkit --with-editable "c:\Users\lishun\py_ws\提交pr\dify-workflow-cli" python -m pytest cli_anything/dify_workflow/tests -vv -s
|
||||
```
|
||||
|
||||
### Results
|
||||
|
||||
- total collected: 11
|
||||
- passed: 11
|
||||
|
||||
### Notes
|
||||
|
||||
- Full forwarding tests were rerun with the upstream `dify-workflow` package installed editable from the local `dify-workflow-cli` repository.
|
||||
- The wrapper suite now exercises `help`, `create`, `validate`, and `inspect -j` end-to-end against the real upstream CLI.
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Dify Workflow harness."""
|
||||
@@ -0,0 +1,67 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from cli_anything.dify_workflow.dify_workflow_cli import cli
|
||||
from cli_anything.dify_workflow.utils.dify_workflow_backend import (
|
||||
build_command,
|
||||
has_upstream_cli,
|
||||
require_dify_workflow_command,
|
||||
)
|
||||
|
||||
|
||||
class TestBackendDiscovery:
|
||||
def test_require_command_returns_binary_path(self):
|
||||
with patch("cli_anything.dify_workflow.utils.dify_workflow_backend.shutil.which", return_value="/usr/bin/dify-workflow"):
|
||||
assert require_dify_workflow_command() == ["/usr/bin/dify-workflow"]
|
||||
|
||||
def test_require_command_falls_back_to_python_module(self):
|
||||
with patch("cli_anything.dify_workflow.utils.dify_workflow_backend.shutil.which", return_value=None), patch(
|
||||
"cli_anything.dify_workflow.utils.dify_workflow_backend.importlib.util.find_spec",
|
||||
return_value=object(),
|
||||
), patch("cli_anything.dify_workflow.utils.dify_workflow_backend.sys.executable", "python"):
|
||||
assert require_dify_workflow_command() == ["python", "-m", "dify_workflow.cli"]
|
||||
|
||||
def test_require_command_raises_install_guidance(self):
|
||||
with patch("cli_anything.dify_workflow.utils.dify_workflow_backend.shutil.which", return_value=None), patch(
|
||||
"cli_anything.dify_workflow.utils.dify_workflow_backend.importlib.util.find_spec",
|
||||
return_value=None,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="dify-workflow command not found"):
|
||||
require_dify_workflow_command()
|
||||
|
||||
def test_build_command_appends_args(self):
|
||||
with patch("cli_anything.dify_workflow.utils.dify_workflow_backend.require_dify_workflow_command", return_value=["dify-workflow"]):
|
||||
assert build_command(["guide", "-j"]) == ["dify-workflow", "guide", "-j"]
|
||||
|
||||
def test_has_upstream_cli_false_when_resolution_fails(self):
|
||||
with patch("cli_anything.dify_workflow.utils.dify_workflow_backend.require_dify_workflow_command", side_effect=RuntimeError("missing")):
|
||||
assert has_upstream_cli() is False
|
||||
|
||||
|
||||
class TestWrapperCLI:
|
||||
def test_help_renders(self):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "CLI-Anything wrapper for the Dify workflow DSL editor" in result.output
|
||||
assert "edit" in result.output
|
||||
assert "config" in result.output
|
||||
|
||||
|
||||
class TestPackagingFixtures:
|
||||
def test_readme_documents_two_step_install(self):
|
||||
package_root = Path(__file__).resolve().parents[1]
|
||||
readme = (package_root / "README.md").read_text(encoding="utf-8")
|
||||
assert "Install the upstream Dify workflow CLI first" in readme
|
||||
assert "git+https://github.com/Akabane71/dify-workflow-cli.git@main" in readme
|
||||
|
||||
def test_skill_file_documents_wrapper_behavior(self):
|
||||
package_root = Path(__file__).resolve().parents[1]
|
||||
skill = (package_root / "skills" / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "wrapper" in skill.lower()
|
||||
assert "cli-anything-dify-workflow" in skill
|
||||
assert "dify-workflow" in skill
|
||||
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from cli_anything.dify_workflow.utils.dify_workflow_backend import require_dify_workflow_command
|
||||
|
||||
|
||||
def _decode_output(data: bytes | None) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _require_working_upstream() -> None:
|
||||
command = require_dify_workflow_command()
|
||||
result = subprocess.run(
|
||||
command + ["--help"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = _decode_output(result.stderr)
|
||||
stdout = _decode_output(result.stdout)
|
||||
raise RuntimeError(
|
||||
"upstream dify-workflow CLI is required for wrapper E2E tests and must respond to --help.\n"
|
||||
f"stdout: {stdout}\n"
|
||||
f"stderr: {stderr}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_cli(name: str, module: str) -> list[str]:
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||
return [sys.executable, "-m", module]
|
||||
|
||||
|
||||
class TestWrapperE2E:
|
||||
CLI_BASE = _resolve_cli("cli-anything-dify-workflow", "cli_anything.dify_workflow")
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
_require_working_upstream()
|
||||
|
||||
def _run(self, args, check=True):
|
||||
result = subprocess.run(self.CLI_BASE + args, capture_output=True, check=False)
|
||||
stdout = _decode_output(result.stdout)
|
||||
stderr = _decode_output(result.stderr)
|
||||
if check and result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args, output=stdout, stderr=stderr)
|
||||
result.stdout = stdout
|
||||
result.stderr = stderr
|
||||
return result
|
||||
|
||||
def test_help(self):
|
||||
result = self._run(["--help"])
|
||||
assert result.returncode == 0
|
||||
assert "create" in result.stdout
|
||||
|
||||
def test_create_and_validate_workflow(self, tmp_path):
|
||||
workflow_path = tmp_path / "workflow.yaml"
|
||||
|
||||
result = self._run(["create", "-o", str(workflow_path), "--template", "llm", "-j"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "created"
|
||||
|
||||
result = self._run(["validate", str(workflow_path), "-j"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
report = json.loads(result.stdout)
|
||||
assert report["valid"] is True
|
||||
|
||||
def test_inspect_json(self, tmp_path):
|
||||
workflow_path = tmp_path / "workflow.yaml"
|
||||
self._run(["create", "-o", str(workflow_path)], check=True)
|
||||
|
||||
result = self._run(["inspect", str(workflow_path), "-j"])
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip().startswith("{")
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for the Dify Workflow wrapper."""
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Backend adapter for the external dify-workflow CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _decode_output(data: bytes | None) -> str:
|
||||
"""Decode subprocess bytes predictably across Windows locales."""
|
||||
if not data:
|
||||
return ""
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def require_dify_workflow_command() -> list[str]:
|
||||
"""Resolve the upstream dify-workflow executable or module."""
|
||||
cli_path = shutil.which("dify-workflow")
|
||||
if cli_path:
|
||||
return [cli_path]
|
||||
|
||||
if importlib.util.find_spec("dify_workflow") is not None:
|
||||
return [sys.executable, "-m", "dify_workflow.cli"]
|
||||
|
||||
raise RuntimeError(
|
||||
"dify-workflow command not found. Install the upstream project first with:\n"
|
||||
" python -m pip install \"dify-ai-workflow-tools @ git+https://github.com/Akabane71/dify-workflow-cli.git@main\"\n"
|
||||
"If you later publish to PyPI, a normal pip install is also fine."
|
||||
)
|
||||
|
||||
|
||||
def build_command(args: list[str]) -> list[str]:
|
||||
"""Build the final subprocess command."""
|
||||
return [*require_dify_workflow_command(), *args]
|
||||
|
||||
|
||||
def has_upstream_cli() -> bool:
|
||||
"""Return whether the upstream CLI or module is available."""
|
||||
try:
|
||||
require_dify_workflow_command()
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_dify_workflow(args: list[str]) -> str:
|
||||
"""Run the upstream CLI and return stdout."""
|
||||
command = build_command(args)
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
message = (_decode_output(result.stderr) or _decode_output(result.stdout)).strip()
|
||||
raise RuntimeError(message or "dify-workflow exited with a non-zero status")
|
||||
return _decode_output(result.stdout).rstrip()
|
||||
@@ -0,0 +1,567 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
def _display_home_path(path: str) -> str:
|
||||
"""Display a path relative to the home directory when possible."""
|
||||
expanded = Path(path).expanduser().resolve()
|
||||
home = Path.home().resolve()
|
||||
try:
|
||||
relative = expanded.relative_to(home)
|
||||
return f"~/{relative.as_posix()}"
|
||||
except ValueError:
|
||||
return str(expanded)
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the repo-root skills/ tree when present,
|
||||
otherwise from the package's skills/ directory.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
software_aliases = {"iterm2_ctl": "iterm2"}
|
||||
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
|
||||
self.skill_id = f"cli-anything-{self.skill_slug}"
|
||||
self.skill_install_cmd = (
|
||||
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
|
||||
)
|
||||
global_skill_root = Path(
|
||||
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
|
||||
).expanduser()
|
||||
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
|
||||
|
||||
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
|
||||
# inside the CLI-Anything monorepo. Fall back to the packaged
|
||||
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
|
||||
if skill_path is None:
|
||||
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
repo_skill = None
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "skills" / self.skill_id / "SKILL.md"
|
||||
if candidate.is_file():
|
||||
repo_skill = candidate
|
||||
break
|
||||
if repo_skill and repo_skill.is_file():
|
||||
skill_path = str(repo_skill)
|
||||
elif package_skill.is_file():
|
||||
skill_path = str(package_skill)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
import textwrap
|
||||
|
||||
inner = 72
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
def _meta_lines(label: str, value: str) -> list[str]:
|
||||
"""Wrap a metadata line for the banner box."""
|
||||
icon = self._c(_MAGENTA, "◇")
|
||||
label_text = self._c(_DARK_GRAY, label)
|
||||
prefix = f" {icon} {label_text} "
|
||||
available = max(12, inner - _visible_len(prefix))
|
||||
wrapped = textwrap.wrap(
|
||||
value,
|
||||
width=available,
|
||||
break_long_words=True,
|
||||
break_on_hyphens=False,
|
||||
) or [""]
|
||||
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
|
||||
continuation_prefix = " " * _visible_len(prefix)
|
||||
for chunk in wrapped[1:]:
|
||||
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
|
||||
return lines
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
meta_lines: list[str] = []
|
||||
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
|
||||
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
for line in meta_lines:
|
||||
print(_box_line(line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
setup.py for cli-anything-dify-workflow
|
||||
|
||||
Install with: pip install -e .
|
||||
Or publish to PyPI: python -m build && twine upload dist/*
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
README = ROOT / "cli_anything/dify_workflow/README.md"
|
||||
|
||||
|
||||
def read_readme() -> str:
|
||||
try:
|
||||
return README.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return "CLI-Anything wrapper for the Dify Workflow CLI."
|
||||
|
||||
|
||||
setup(
|
||||
name="cli-anything-dify-workflow",
|
||||
version="0.1.0",
|
||||
author="Akabane71",
|
||||
description="CLI-Anything wrapper for the Dify Workflow CLI and DSL editor",
|
||||
long_description=read_readme(),
|
||||
long_description_content_type="text/markdown",
|
||||
url="https://github.com/HKUDS/CLI-Anything",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
],
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
],
|
||||
extras_require={
|
||||
"dev": [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-dify-workflow=cli_anything.dify_workflow.dify_workflow_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.dify_workflow": ["skills/*.md"],
|
||||
},
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
)
|
||||
Reference in New Issue
Block a user