English | 简体中文
XDclassmate-CLI is a Python CLI framework built on a micro-kernel + event bus + plugin architecture. It depends only on the Python standard library and natively supports command spaces, command options, internationalization (i18n), and URL-based plugin integrity verification. A plugin can be either a directory or a .xdplug ZIP archive.
py -3 -m core.main # Enter interactive REPL (prompt: xd>)
py -3 -m core.main <command> [args] # Run a single command, then exit
py -3 -m core.main [<space>...] <command> [options] # Call with command spaces
py -3 -m core.main --version # Show CLI version
py -3 -m core.main --lang en_US help # Show help in English
py -3 -m core.main --log-level DEBUG help # Show help with DEBUG logging
Behavior when launched with no arguments is controlled by the startup_mode config key (see "Configuration" below): repl enters interactive mode, help prints the command view and exits.
The framework ships a pyproject.toml with xd / xdclassmate-cli entry points. After pip install ., you can launch the CLI directly:
pip install . # install core + the official image plugin
pip install ".[image]" # also install Pillow for plugins/image
xd help # equivalent to py -3 -m core.main help
xd image size path/to/file.png
The xd command resolves its configuration from (in order): the XDCLI_CONFIG environment variable → the project-level configs/config.json → the user-level config (%APPDATA%\xdclassmate\config.json on Windows, ~/.config/xdclassmate/config.json elsewhere). If no config file exists, the first run creates one at the user-level location so global installs work out-of-the-box.
Kernel in core/kernel.py is the single assembly point. It does exactly four things:
- Assemble: config → logging → i18n → command registry → plugin manager;
- Boot: load built-in commands → load plugins → broadcast
init_cli/plugin_initin order; - Dispatch: hand user input to the command registry, handle exceptions and exit codes uniformly;
- Interact: enter the REPL or print help according to the startup mode.
The boot order is fixed: load_builtins → load_plugins → emit(init_cli) → emit(plugin_init). Built-in commands register before plugins so that plugin entries can call help; plugin scanning finishes before plugin_init so plugins can subscribe to events.
The kernel itself contains no commands — help / plugins / clear / about are registered in the system space as "built-in plugins" (core/builtins/system_commands.py), going through exactly the same registration channel as third-party plugins. Exit code convention: 0 success / 1 framework exception / 2 command execution exception. Command functions may return an integer as the exit code (normalized to a valid [0,255] value by coerce_exit_code(); None/True are treated as 0) so business commands can signal failure to their caller.
Module responsibilities:
| Module | Responsibility |
|---|---|
core/kernel.py |
Micro-kernel: assembly, boot, dispatch, REPL |
core/command.py |
Command space tree; command/option registration and parsing |
core/plugins.py |
Plugin scanning, dependency topological sort, manifest validation |
core/integrity.py |
URL-based plugin content integrity verification |
core/i18n/ |
i18n (language packs + detection + placeholder translation) |
core/views.py |
Three command views: list / tree / table |
core/logger.py |
Unified logging xdclassmate.cli.* (stderr) |
core/event_bus.py |
Event bus (init_cli / plugin_init) |
core/config.py |
Config read/write (configs/config.json) |
core/args.py |
Global flag parsing at the head of the command line |
core/exceptions.py |
Error-coded exception hierarchy |
core/builtins/ |
Built-in commands (system space) |
Every plugin root directory must contain xdclassmate.cli.setting.json declaring the following fields:
{
"name": "Image Processing",
"entry": "main.py:main",
"version": "1.0.0",
"author": "XDclassmate",
"cli_version": "1.0",
"description": "A tool for processing images.",
"events": ["plugin_init"],
"pre_plugins": {},
"url": "hashes/image.hash256",
"algorithm": "sha256"
}The plugin entry must be a callable in module.py:function format as declared in the manifest. The loader validates manifest fields, versions, pre-plugins, content integrity, and archive paths; a plugin that fails validation is rejected. pre_plugins declarations form a dependency graph loaded in topological order — a single failing plugin is skipped with an ERROR log and does not affect the rest of the CLI.
The default plugin directory is plugins under the current working directory; it can be changed via plugin_dir in configs/config.json. Directory plugins and .xdplug archives can coexist; for same-named plugins, only the first one scanned takes effect (sorted by name).
url points to the file that holds the expected digest. It supports http(s)://, file://, local absolute paths, and paths relative to the project root. A url of null skips verification (local development/testing). The algorithm is set by the manifest algorithm field (default sha256) and can also be inferred from the URL file name suffix (.hash256 / .sha512 / .md5, etc.).
Digest files support two formats:
6d5edd2c1241c5a151cc5d34433e5f5b0cfd13f4e896fb578af7fed7e8421766 # bare digest
6d5edd2c1241c5a151cc5d34433e5f5b0cfd13f4e896fb578af7fed7e8421766 image # sha256sum style
The repository ships an example: plugins/image (image size query with graceful degradation when Pillow is missing) plus hashes/image.hash256.
py -3 tools/plugin_hash.py plugins/image --emit # Generate hashes/<name>.hash256
py -3 tools/plugin_hash.py plugins/image --write # Write digest back per manifest url
py -3 tools/pack_plugin.py plugins/image --update-hash # Pack into build/<name>-<version>.xdplug
--update-hash refreshes the digest before packing, preventing rejection caused by forgetting to sync; __pycache__ and .pyc are excluded automatically. Drop the generated .xdplug into the plugin directory to load it — its manifest validation, integrity checks, and path safety checks are identical to directory plugins.
Digest algorithm: sort by relative POSIX path, then write path length, path, file length, and file content for each file; the manifest itself and __pycache__ are excluded. Directories and .xdplug use the same algorithm.
The built-in system space provides three plugin management commands that, together with the install_url config key, implement the full "fetch from repository → verify → install" pipeline.
Repository layout (install_url points to a directory, supporting http(s)://, file://, or a local path):
index.json plugin catalog (plugin name -> entry)
<name>/<name>-<version>.xdplug plugin archive
hashes/<name>.hash256 digest file (sha256sum style, referencing the archive name)
index.json entry format:
{
"image": {
"version": "1.0.0",
"file": "image-1.0.0.xdplug",
"hash": "hashes/image.hash256",
"algorithm": "sha256"
}
}The hash field can be either a digest-file path relative to install_url or an inline 64-hex-digit digest (saving a network request).
install <name> # Download, verify, and install a plugin into plugin_dir
upgrade [name] # Upgrade installed plugin(s); omit name to upgrade all
uninstall <name> # Remove the plugin directory or archive under plugin_dir
Workflow:
install/upgradereadinstall_url; if unset, a friendly message is shown and the command aborts;index.jsonis fetched and the entry is located by plugin name (version, package path, digest);- The package is downloaded and verified against the digest (algorithm from the URL suffix or the entry's
algorithm); a mismatch is rejected outright (XD-CLI-2004), guaranteeing "what you fetch is usable"; - On success the package is extracted to
plugin_dir/<name>/, with a hint that changes take effect on next start.
All command output (including errors) is translated through core/i18n; errors such as an unset install_url or a plugin missing from the repository are raised as structured exceptions and rendered in the current language by the kernel.
Commands are organized as a tree. default is the root space and the default value of commandspace.
hello # Root-space command: no space prefix needed
space1 command1 [args...] # Single-level space
space1 space2 space3 command1 --opt 1 # Deeply nested space; options pass through
Rules:
- Optional space: registering without
commandspaceputs the command intodefault, callable without a prefix; - Arbitrary nesting: spaces stack level by level; at each level the resolver first matches sub-spaces, then commands in the current space;
- Nesting limit: at most 20 explicit levels (
MAX_COMMAND_SPACE_DEPTH, root not counted); exceeding raisesXD-CLI-3005; - Duplicate names allowed: commands in different spaces may share a name; only duplicates within the same space raise an error (
XD-CLI-3002); - System command fallback: built-in commands (
help/plugins/clear/about) live in thesystemspace and are found via fallback when lookup in the root space fails — sohelpequalssystem help; - Path syntax: the API accepts
"space1/space2","space1 space2", or["space1", "space2"]; a leadingdefaultis normalized away.
Registering commands from a plugin:
from core.command import registry
def main(): # Manifest entry points here; runs on plugin_init
registry.register_command_space("space1/space2/space3") # Create three levels at once
registry.register("command1", handler) # Goes into default, callable bare
registry.register("command1", handler2, commandspace="space1/space2")Declare options with register_option; at execution time they are parsed into keyword arguments passed to the command function:
def main():
entry = registry.register("greet", cmd_greet) # Returns a CommandEntry
registry.register_option(entry, "-n", "--name",
takes_value=True, default="world", help="Who to greet")
registry.register_option(entry, "-l", "--loud", help="Uppercase the output")
def cmd_greet(name="world", loud=False): # option -> same-named kwarg
print(f"Hello, {name}!".upper() if loud else f"Hello, {name}!")greet # Hello, world! (default value)
greet -n XD --loud # HELLO, XD!
greet --name=XD # Inline = value supported
greet -- -not-option # Everything after -- is a positional argument
Rules:
- Option names must start with
-/--; the kwarg name (dest) is derived from the long option, or set explicitly viadest=; - Flag options (without
takes_value) areTruewhen present and default toFalsewhen absent; - Short options, long options, and aliases are supported; short-option bundling (e.g.
-nl) is not, to keep semantics unambiguous; - Commands that declare no options stay permissive — every token is passed through as a positional argument (backward compatible). Once options are declared, unknown options, missing values, and flags with values raise
XD-CLI-3008; - The view theme
--themeis itself implemented with this mechanism (help -t tree).
The help command switches between three rendering modes via --theme/-t (core/views.py):
help # list: flat groups, each headed by the full space path
help --theme tree # tree: hierarchical connectors (ASCII fallback on non-UTF-8 terminals)
help -t table # table: space/command/description columns, auto-truncated
| Theme | Use case |
|---|---|
list |
Default; grouped by space, full paths, uniformly indented commands |
tree |
Inspecting nesting; commands first, sub-spaces after, aligned per level |
table |
Comparing many commands; aligned and truncated by display width (CJK counted as 2) |
Built-in commands and framework messages are all translated through core/i18n. Current language packs: zh_CN, en_US (core/i18n/languages/). Detection priority:
--lang flag > XDCLI_LANG env var > language config > system locale > default zh_CN
Using translation in a plugin:
from core.i18n import t
print(t("cli.title"))
print(t("cli.startup.repl_hint", plugins=2)) # Placeholders supportedTo add a language, just drop a JSON pack into core/i18n/languages/ — no code changes needed.
The config file is configs/config.json at the repository root:
{
"plugin_dir": "./plugins",
"log_level": "INFO",
"log_file": "",
"log_console_output": false,
"startup_mode": "repl",
"language": "zh_CN",
"install_url": "",
"help_theme": "list"
}| Key | Description |
|---|---|
plugin_dir |
Plugin directory (relative to the working directory) |
log_level |
Log level: DEBUG/INFO/WARNING/ERROR/CRITICAL; --log-level on the command line takes precedence |
log_file |
Optional log file path (UTF-8); empty means no file logging |
log_console_output |
Whether to write logs to the console (stderr); default false. When off, the console stays clean and logs are emitted only if log_file is set or this is explicitly enabled |
startup_mode |
No-argument launch behavior: repl interactive mode / help print help and exit |
language |
UI language code, e.g. zh_CN / en_US |
install_url |
Plugin repository URL (install/upgrade fetch packages and digests from here); supports http(s)://, file://, or a local path; install is disabled when empty |
help_theme |
Default help view theme: list / tree / table, default list; override per call with help -t <theme> |
core/logger.py provides unified logging: loggers live in the xdclassmate.cli.* namespace (e.g. xdclassmate.cli.kernel, xdclassmate.cli.plugins), with optional file logging. Console output (stderr) is gated by log_console_output and is off by default, so it never interferes with user-facing output on stdout.
from .logger import get_logger
LOGGER = get_logger("command")
LOGGER.debug("Executing command %s", path)Every key stage — kernel assembly, plugin scanning, integrity verification, the REPL loop — is logged. Use --log-level DEBUG to observe the full boot chain.
Plugins can reuse the CLI's built-in i18n directly: from core.i18n import t, get_language inside the entry module to translate text and read the current language.
from core.i18n import get_language, t
def cmd_size(file: str = ""):
language = get_language() # read the global language (e.g. zh_CN / en_US)
info = size(file)
if not info:
print(t("plugin.image.size.fail", file=file or "(未指定路径)"))
return
print(t("plugin.image.size.ok", file=file, width=info[0], height=info[1]))- Translation keys live in
core/i18n/languages/*.json(use theplugin.*namespace for plugin text); get_language()returns the current UI language so plugins can branch on it;- Language detection priority:
--lang> envXDCLI_LANG> configlanguage> system locale >zh_CN.
All exceptions are defined in core/exceptions.py, uniformly inheriting from XDclassmateCLIException with an error code and structured context, translatable into the current language:
[XD-CLI-3001] Command default/hello not found (command=default/hello)
| Segment | Scope | Exceptions (error code) |
|---|---|---|
| 1xxx | Configuration | ConfigException (1000), ConfigFileError (1001) |
| 2xxx | Plugins | PluginException (2000), PluginNotFoundError (2001), DuplicatePluginNamesError (2002), PluginManifestError (2003), PluginHashMismatchError (2004), PluginEntryError (2005), PluginArchiveError (2006), PluginVersionMismatchError (2007), PluginIntegrityError (2008), PluginDependencyError (2009) |
| 3xxx | Commands | CommandException (3000), CommandNotFoundError (3001), DuplicateCommandNamesError (3002), CommandSpaceNotFoundError (3003), DuplicateCommandSpaceNamesError (3004), CommandSpaceDepthExceededError (3005), InvalidCommandSpaceNameError (3006), CommandExecutionError (3007), CommandArgumentException (3008), DuplicateOptionNamesError (3009) |
| 4xxx | Remote/Install | RemoteException (4000), RemoteNotConfiguredError (4001), PluginNotInRepositoryError (4002), RemoteDownloadError (4003), PluginNotInstalledError (4005) |
A single except XDclassmateCLIException catches every framework exception. Non-framework exceptions raised inside command functions are wrapped as CommandExecutionError; argument mismatches are wrapped as CommandArgumentException; the original exception is always preserved in __cause__.
py -3 tests/smoke_test.py # Smoke test: 54 checks
py -3 tools/check_style.py # PEP 8 style check (standard library only)
The smoke test covers space path normalization, nested resolution, duplicate-name rules, the 20-level limit, command CRUD and migration, option parsing, all three views, the exception hierarchy, the logging system, plus directory and .xdplug plugin loading with integrity verification.
The style check covers common PEP 8 issues — line width (79 columns), tab indentation, trailing whitespace, consecutive blank lines, blank lines before top-level definitions, and spacing after commas — while skipping string literals and comments to avoid false positives. The entire codebase follows PEP 8 without third-party formatters.
For detailed architecture notes, module design decisions, and development guides, see docs/development.md (Chinese).
The project uses only the Python standard library and contains no third-party code or copyright-restricted resources.