返回

CLI-Anything 教程 4:第三阶段 Implement(Click CLI 与 REPL 实现)

CLI-Anything 第三阶段 Implement 详解:如何使用 Click 框架实现 CLI、构建 REPL 交互环境、实现状态管理。包含代码结构、核心模块实现和最佳实践。

教程概述

系列目录:CLI-Anything 教程系列索引

Implement 是 CLI-Anything 7 阶段流程的第三阶段。在这一阶段,我们将把 Design 阶段的架构设计转化为可运行的代码

你将学到

  • ✅ CLI 项目结构
  • ✅ Click 框架核心用法
  • ✅ REPL 实现技巧
  • ✅ 状态管理机制
  • ✅ 错误处理和日志

项目结构

标准目录布局

<software>/agent-harness/
├── cli_anything/
│   ├── __init__.py           # 包初始化
│   ├── cli.py                # 主 CLI 入口
│   ├── repl.py               # REPL 实现
│   ├── state.py              # 状态管理
│   ├── core/                 # 核心功能模块
│   │   ├── __init__.py
│   │   ├── project.py        # 项目管理
│   │   ├── image.py          # 图像操作
│   │   ├── layer.py          # 图层操作
│   │   └── ...
│   ├── utils/                # 工具函数
│   │   ├── __init__.py
│   │   ├── backend.py        # 后端软件封装
│   │   ├── validators.py     # 参数验证
│   │   └── output.py         # 输出格式化
│   └── tests/                # 测试目录
│       ├── __init__.py
│       ├── unit/             # 单元测试
│       └── e2e/              # E2E 测试
├── setup.py                  # 安装配置
├── README.md                 # 使用说明
└── SKILL.md                  # AI 技能定义

Click 框架基础

什么是 Click?

Click 是一个 Python 的命令行界面创建工具包,用于快速构建漂亮的 CLI。

import click

@click.command()
@click.option('--count', default=1, help='Number of greetings.')
@click.option('--name', prompt='Your name', help='The person to greet.')
def hello(count, name):
    """Simple program that greets NAME for a total of COUNT times."""
    for _ in range(count):
        click.echo(f"Hello {name}!")

if __name__ == '__main__':
    hello()

核心概念

flowchart TD
    A[Click 核心概念] --> B[Command 命令]
    A --> C[Group 命令组]
    A --> D[Option 选项]
    A --> E[Argument 参数]

    B --> F[@click.command]
    C --> G[@click.group]
    D --> H[@click.option]
    E --> I[@click.argument]

CLI 入口实现

主 CLI 文件 (cli.py)

#!/usr/bin/env python3
"""
cli-anything-gimp - GIMP CLI for AI Agents

A command-line interface for GIMP that enables AI agents to
automate image editing workflows.
"""

import click
import json
import sys
from pathlib import Path
from typing import Optional

from .repl import start_repl
from .state import ProjectState, StateManager
from .core.project import project_group
from .core.image import image_group
from .core.layer import layer_group
from .core.filter import filter_group
from .core.export import export_group
from .utils.output import format_output, OutputFormat

# 版本信息
__version__ = "1.0.0"


@click.group()
@click.version_option(version=__version__)
@click.option('--json', 'json_output', is_flag=True,
              help='Output in JSON format for agent consumption')
@click.option('--project', 'project_path', type=click.Path(),
              help='Path to project state file')
@click.option('--verbose', is_flag=True, help='Enable verbose output')
@click.option('--quiet', is_flag=True, help='Suppress non-error output')
@click.pass_context
def cli(ctx: click.Context, json_output: bool, project_path: Optional[str],
        verbose: bool, quiet: bool):
    """GIMP CLI for AI Agents - Automate image editing workflows.

    Examples:
        cli-anything-gimp                    # Start REPL mode
        cli-anything-gimp project new        # Create new project
        cli-anything-gimp --json image info  # Get image info as JSON
    """
    # 确保 ctx.obj 存在
    if ctx.obj is None:
        ctx.obj = {}

    # 存储全局选项
    ctx.obj['json_output'] = json_output
    ctx.obj['verbose'] = verbose
    ctx.obj['quiet'] = quiet

    # 初始化状态管理器
    ctx.obj['state_manager'] = StateManager()

    # 加载项目状态(如果指定)
    if project_path:
        try:
            state = ctx.obj['state_manager'].load(project_path)
            ctx.obj['project_state'] = state
            ctx.obj['project_path'] = project_path
        except Exception as e:
            if not quiet:
                click.echo(f"Error loading project: {e}", err=True)
            sys.exit(1)


# 注册命令组
cli.add_command(project_group)
cli.add_command(image_group)
cli.add_command(layer_group)
cli.add_command(filter_group)
cli.add_command(export_group)


@cli.command()
def repl():
    """Start interactive REPL mode."""
    start_repl()


def main():
    """Entry point for the CLI."""
    cli()


if __name__ == '__main__':
    main()

命令组实现

# cli_anything/core/project.py

import click
from pathlib import Path
from datetime import datetime

from ..state import ProjectState
from ..utils.output import success, error, info


@click.group(name='project')
@click.pass_context
def project_group(ctx):
    """Project management commands."""
    pass


@project_group.command(name='new')
@click.option('--name', '-n', required=True, help='Project name')
@click.option('--template', '-t', default='default',
              type=click.Choice(['default', 'photo', 'web', 'print']),
              help='Project template')
@click.option('--output', '-o', type=click.Path(),
              help='Output path for project state file')
@click.pass_context
def project_new(ctx, name: str, template: str, output: Optional[str]):
    """Create a new project.

    Examples:
        cli-anything-gimp project new --name "MyProject"
        cli-anything-gimp project new -n "PhotoEdit" -t photo
    """
    try:
        # 创建项目状态
        state = ProjectState(name=name, template=template)

        # 确定状态文件路径
        if output:
            state_path = Path(output)
        else:
            state_path = Path(f"{name.lower().replace(' ', '_')}.json")

        # 保存状态
        state.save(state_path)

        # 输出结果
        result = {
            'project_id': state.project_id,
            'project_name': state.name,
            'template': template,
            'state_file': str(state_path)
        }

        if ctx.obj.get('json_output'):
            click.echo(json.dumps({
                'success': True,
                'command': 'project new',
                'data': result
            }))
        else:
            success(f"Created project: {name}")
            info(f"Project ID: {state.project_id}")
            info(f"Template: {template}")
            info(f"State file: {state_path}")

        # 更新上下文
        ctx.obj['project_state'] = state
        ctx.obj['project_path'] = str(state_path)

    except Exception as e:
        if ctx.obj.get('json_output'):
            click.echo(json.dumps({
                'success': False,
                'command': 'project new',
                'error': {
                    'type': type(e).__name__,
                    'message': str(e)
                }
            }))
        else:
            error(f"Failed to create project: {e}")
        raise click.ClickException(str(e))


@project_group.command(name='info')
@click.pass_context
def project_info(ctx):
    """Show project information."""
    state = ctx.obj.get('project_state')

    if not state:
        error("No project loaded. Use --project or run 'project new'")
        raise click.ClickException("No project loaded")

    info_data = {
        'project_id': state.project_id,
        'name': state.name,
        'template': state.template,
        'created_at': state.created_at.isoformat(),
        'modified_at': state.modified_at.isoformat(),
        'image_count': len(state.images),
        'modified': state.modified
    }

    if ctx.obj.get('json_output'):
        click.echo(json.dumps({
            'success': True,
            'command': 'project info',
            'data': info_data
        }))
    else:
        info("Project Information:")
        for key, value in info_data.items():
            click.echo(f"  {key}: {value}")

REPL 实现

REPL 核心代码

# cli_anything/repl.py

import cmd
import shlex
import sys
from pathlib import Path
from typing import Optional

import click
from click.testing import CliRunner

from .cli import cli
from .state import ProjectState, StateManager


class CLIRepl(cmd.Cmd):
    """Interactive REPL for CLI-Anything."""

    intro = '''
╔══════════════════════════════════════════╗
║       cli-anything-gimp v1.0.0          ║
║     GIMP CLI for AI Agents              ║
╚══════════════════════════════════════════╝

Type 'help' for available commands, 'exit' to quit.
'''

    def __init__(self):
        super().__init__()
        self.state_manager = StateManager()
        self.project_state: Optional[ProjectState] = None
        self.project_path: Optional[str] = None
        self.history: list = []
        self._update_prompt()

    def _update_prompt(self):
        """Update the REPL prompt based on current state."""
        base = "gimp"

        if self.project_state:
            base += f"[{self.project_state.name}]"
            if self.project_state.modified:
                base += "*"

        self.prompt = f"{base}> "

    def default(self, line: str):
        """Handle unknown commands by passing to Click CLI."""
        if not line.strip():
            return

        # 解析命令
        args = shlex.split(line)

        # 记录历史
        self.history.append(line)

        # 使用 Click 测试运行器执行命令
        runner = CliRunner()

        # 构建参数
        invoke_args = ['--json']  # REPL 模式下默认 JSON 输出
        if self.project_path:
            invoke_args.extend(['--project', self.project_path])
        invoke_args.extend(args)

        # 执行命令
        result = runner.invoke(cli, invoke_args)

        # 输出结果
        if result.output:
            print(result.output.strip())

        # 处理错误
        if result.exit_code != 0 and result.exception:
            print(f"Error: {result.exception}", file=sys.stderr)

        # 更新状态(如果项目被修改)
        if self.project_path:
            try:
                self.project_state = self.state_manager.load(self.project_path)
                self._update_prompt()
            except:
                pass

    def do_help(self, arg: str):
        """Show help information."""
        if arg:
            # 显示特定命令的帮助
            runner = CliRunner()
            result = runner.invoke(cli, ['--help', arg])
            print(result.output)
        else:
            # 显示所有命令
            print("\nAvailable commands:")
            print("  project     Project management")
            print("  image       Image operations")
            print("  layer       Layer operations")
            print("  filter      Filters and effects")
            print("  export      Export and rendering")
            print("\nBuilt-in commands:")
            print("  help        Show this help")
            print("  history     Show command history")
            print("  save        Save current state")
            print("  status      Show current status")
            print("  undo        Undo last operation")
            print("  redo        Redo last operation")
            print("  exit        Exit REPL")

    def do_history(self, arg: str):
        """Show command history."""
        for i, cmd in enumerate(self.history, 1):
            print(f"{i:3d}: {cmd}")

    def do_save(self, arg: str):
        """Save current project state."""
        if not self.project_state:
            print("No project to save")
            return

        try:
            self.project_state.save(self.project_path)
            self.project_state.modified = False
            self._update_prompt()
            print(f"✓ Saved project state to {self.project_path}")
        except Exception as e:
            print(f"✗ Failed to save: {e}")

    def do_status(self, arg: str):
        """Show current status."""
        if self.project_state:
            print(f"Project: {self.project_state.name}")
            print(f"  ID: {self.project_state.project_id}")
            print(f"  Modified: {self.project_state.modified}")
            print(f"  Images: {len(self.project_state.images)}")
        else:
            print("No project loaded")

    def do_undo(self, arg: str):
        """Undo last operation."""
        if not self.project_state:
            print("No project to undo")
            return

        # 实现撤销逻辑
        if self.project_state.undo():
            self._update_prompt()
            print("✓ Undone last operation")
        else:
            print("Nothing to undo")

    def do_redo(self, arg: str):
        """Redo last undone operation."""
        if not self.project_state:
            print("No project to redo")
            return

        # 实现重做逻辑
        if self.project_state.redo():
            self._update_prompt()
            print("✓ Redone last operation")
        else:
            print("Nothing to redo")

    def do_exit(self, arg: str):
        """Exit REPL."""
        # 检查是否有未保存的更改
        if self.project_state and self.project_state.modified:
            response = input("You have unsaved changes. Save before exit? [Y/n] ")
            if response.lower() not in ('n', 'no'):
                self.do_save("")

        print("Goodbye! 👋")
        return True

    do_quit = do_exit
    do_EOF = do_exit  # Ctrl+D


def start_repl():
    """Start the interactive REPL."""
    try:
        import readline
        # 启用历史记录
        readline.parse_and_bind('tab: complete')
    except ImportError:
        pass

    repl = CLIRepl()
    repl.cmdloop()

状态管理实现

状态管理核心

# cli_anything/state.py

import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field, asdict


@dataclass
class LayerState:
    """Represents a layer state."""
    layer_id: str
    name: str
    visible: bool = True
    opacity: float = 100.0
    position: tuple = field(default_factory=lambda: (0, 0))

    def to_dict(self) -> dict:
        return {
            'layer_id': self.layer_id,
            'name': self.name,
            'visible': self.visible,
            'opacity': self.opacity,
            'position': self.position
        }

    @classmethod
    def from_dict(cls, data: dict) -> 'LayerState':
        return cls(**data)


@dataclass
class ImageState:
    """Represents an image state."""
    image_id: str
    name: str
    width: int
    height: int
    layers: List[LayerState] = field(default_factory=list)
    active_layer_id: Optional[str] = None

    def to_dict(self) -> dict:
        return {
            'image_id': self.image_id,
            'name': self.name,
            'width': self.width,
            'height': self.height,
            'layers': [layer.to_dict() for layer in self.layers],
            'active_layer_id': self.active_layer_id
        }

    @classmethod
    def from_dict(cls, data: dict) -> 'ImageState':
        layers = [LayerState.from_dict(l) for l in data.get('layers', [])]
        return cls(
            image_id=data['image_id'],
            name=data['name'],
            width=data['width'],
            height=data['height'],
            layers=layers,
            active_layer_id=data.get('active_layer_id')
        )


@dataclass
class Command:
    """Represents a command in history."""
    command: str
    args: dict
    timestamp: datetime
    undo_data: Optional[dict] = None

    def to_dict(self) -> dict:
        return {
            'command': self.command,
            'args': self.args,
            'timestamp': self.timestamp.isoformat(),
            'undo_data': self.undo_data
        }


@dataclass
class ProjectState:
    """Represents the complete project state."""
    name: str
    template: str = 'default'
    project_id: str = field(default_factory=lambda: f"proj_{uuid.uuid4().hex[:8]}")
    created_at: datetime = field(default_factory=datetime.utcnow)
    modified_at: datetime = field(default_factory=datetime.utcnow)
    images: Dict[str, ImageState] = field(default_factory=dict)
    active_image_id: Optional[str] = None
    modified: bool = False
    history: List[Command] = field(default_factory=list)
    history_position: int = -1

    def to_dict(self) -> dict:
        return {
            'version': '1.0.0',
            'project_id': self.project_id,
            'name': self.name,
            'template': self.template,
            'created_at': self.created_at.isoformat(),
            'modified_at': self.modified_at.isoformat(),
            'images': {k: v.to_dict() for k, v in self.images.items()},
            'active_image_id': self.active_image_id,
            'modified': self.modified,
            'history': [cmd.to_dict() for cmd in self.history],
            'history_position': self.history_position
        }

    def save(self, path: Path):
        """Save state to file."""
        self.modified_at = datetime.utcnow()
        with open(path, 'w') as f:
            json.dump(self.to_dict(), f, indent=2)
        self.modified = False

    @classmethod
    def load(cls, path: Path) -> 'ProjectState':
        """Load state from file."""
        with open(path, 'r') as f:
            data = json.load(f)

        images = {k: ImageState.from_dict(v) for k, v in data.get('images', {}).items()}
        history = [
            Command(
                cmd=cmd['command'],
                args=cmd['args'],
                timestamp=datetime.fromisoformat(cmd['timestamp']),
                undo_data=cmd.get('undo_data')
            )
            for cmd in data.get('history', [])
        ]

        return cls(
            name=data['name'],
            template=data.get('template', 'default'),
            project_id=data['project_id'],
            created_at=datetime.fromisoformat(data['created_at']),
            modified_at=datetime.fromisoformat(data['modified_at']),
            images=images,
            active_image_id=data.get('active_image_id'),
            modified=data.get('modified', False),
            history=history,
            history_position=data.get('history_position', -1)
        )

    def add_command(self, command: str, args: dict, undo_data: Optional[dict] = None):
        """Add a command to history."""
        # 如果不在历史末尾,截断后续历史
        if self.history_position < len(self.history) - 1:
            self.history = self.history[:self.history_position + 1]

        cmd = Command(
            command=command,
            args=args,
            timestamp=datetime.utcnow(),
            undo_data=undo_data
        )
        self.history.append(cmd)
        self.history_position += 1
        self.modified = True

    def undo(self) -> bool:
        """Undo last operation."""
        if self.history_position < 0:
            return False

        # 获取当前命令
        cmd = self.history[self.history_position]

        # 执行撤销(使用保存的 undo_data)
        if cmd.undo_data:
            self._apply_undo(cmd.undo_data)

        self.history_position -= 1
        self.modified = True
        return True

    def redo(self) -> bool:
        """Redo last undone operation."""
        if self.history_position >= len(self.history) - 1:
            return False

        self.history_position += 1
        cmd = self.history[self.history_position]

        # 重新执行命令
        # 实际实现需要调用相应的处理函数
        self.modified = True
        return True

    def _apply_undo(self, undo_data: dict):
        """Apply undo data to restore state."""
        # 根据 undo_data 恢复状态
        pass


class StateManager:
    """Manages project state persistence."""

    def load(self, path: str) -> ProjectState:
        """Load project state from file."""
        return ProjectState.load(Path(path))

    def save(self, state: ProjectState, path: str):
        """Save project state to file."""
        state.save(Path(path))

小结

Implement 阶段将设计转化为可运行的代码:

  1. 项目结构 - 标准化的目录布局
  2. Click CLI - 命令组和选项的实现
  3. REPL 环境 - 交互式会话支持
  4. 状态管理 - 状态持久化和撤销/重做

关键要点

  • ✅ 使用 Click 的标准模式
  • ✅ 支持 JSON 和人类可读两种输出
  • ✅ REPL 和子命令模式一致
  • ✅ 完善的错误处理和日志
  • ✅ 状态管理支持会话恢复

系列导航