feat(cli): /export and /import slash commands for profile sharing

/export [profile] [-o output.tar.gz] bundles a profile into the shareable
archive; /import <archive> [--name <name>] adopts one as a new profile
(wrapper alias created when safe). Registry-driven, cli_only, so the CLI
and TUI both pick them up in autocomplete and help.
This commit is contained in:
Brooklyn Nicholson 2026-08-04 12:07:29 -06:00
parent d1196750c0
commit bde8c4e108
3 changed files with 82 additions and 0 deletions

4
cli.py
View File

@ -10249,6 +10249,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._handle_rollback_command(cmd_original)
elif canonical == "snapshot":
self._handle_snapshot_command(cmd_original)
elif canonical == "export":
self._handle_export_command(cmd_original)
elif canonical == "import":
self._handle_import_command(cmd_original)
elif canonical == "stop":
self._handle_stop_command()
elif canonical == "agents":

View File

@ -363,6 +363,80 @@ class CLICommandsMixin:
print(f" Unknown subcommand: {subcmd}")
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
def _handle_export_command(self, command: str):
"""Handle /export — export a profile to a shareable .tar.gz archive.
Syntax:
/export export the active profile
/export <profile> export a named profile
/export [profile] -o <path> choose the output path
"""
from hermes_cli.profiles import export_profile, get_active_profile_name
parts = command.split()[1:]
output = None
if "-o" in parts:
idx = parts.index("-o")
if idx + 1 >= len(parts):
print(" Usage: /export [profile] [-o output.tar.gz]")
return
output = parts[idx + 1]
parts = parts[:idx] + parts[idx + 2:]
name = parts[0] if parts else (get_active_profile_name() or "default")
if not output:
output = f"{name}.tar.gz"
try:
result = export_profile(name, output)
print(f" ✓ Exported '{name}' to {result}")
print(" Share it: the other user runs /import or `hermes profile import <archive>`.")
except (ValueError, FileNotFoundError) as e:
print(f" Error: {e}")
def _handle_import_command(self, command: str):
"""Handle /import — import a shared profile archive as a new profile.
Syntax:
/import <archive.tar.gz> [--name <name>]
"""
from hermes_cli.profiles import (
check_alias_collision, create_wrapper_script, import_profile,
)
parts = command.split()[1:]
name = None
if "--name" in parts:
idx = parts.index("--name")
if idx + 1 >= len(parts):
print(" Usage: /import <archive.tar.gz> [--name <name>]")
return
name = parts[idx + 1]
parts = parts[:idx] + parts[idx + 2:]
if not parts:
print(" Usage: /import <archive.tar.gz> [--name <name>]")
return
archive = " ".join(parts) # paths may contain spaces
try:
profile_dir = import_profile(archive, name=name)
except (ValueError, FileExistsError, FileNotFoundError) as e:
print(f" Error: {e}")
return
imported = profile_dir.name
print(f" ✓ Imported profile '{imported}' at {profile_dir}")
try:
if not check_alias_collision(imported):
wrapper_path = create_wrapper_script(imported)
if wrapper_path:
print(f" Wrapper created: {wrapper_path}")
except Exception:
pass
print(f" Use it: hermes -p {imported}")
def _handle_stop_command(self):
"""Handle /stop — kill all running background processes and
background (async) delegations.

View File

@ -133,6 +133,10 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[number]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]"),
CommandDef("export", "Export a profile (config, skills, theme) to a shareable archive", "Configuration",
cli_only=True, args_hint="[profile] [-o output.tar.gz]"),
CommandDef("import", "Import a shared profile archive as a new profile", "Configuration",
cli_only=True, args_hint="<archive.tar.gz> [--name <name>]"),
CommandDef("stop", "Kill all running background processes", "Session",
busy_policy="interrupt_then_dispatch", busy_handler="stop"),
CommandDef("approve", "Approve a pending dangerous command", "Session",