feat: add interactive config wizard

- Add mycli wizard command for guided configuration
- Interactive menus for paths, SSH, API keys, Feishu apps, git proxies
- User-friendly prompts with numbered choices
- Support add/edit/delete operations via wizard
- Hide sensitive input (API keys, secrets)
- Confirm before destructive operations
This commit is contained in:
Zhengshou Lai
2026-04-13 11:53:27 +08:00
parent 469aefb71f
commit 00c608a3bd
10 changed files with 331 additions and 2 deletions
+3
View File
@@ -65,6 +65,9 @@ mycli self uninstall # Uninstall mycli
mycli self info # Show installation info
# Configuration (config.json) - 增删改查
# 方式1: 交互式向导(推荐,无需记忆命令)
mycli wizard # 启动交互式配置向导
# 方式2: 命令行直接操作
mycli config show # Show full config
mycli config list # List top-level sections
mycli config get paths # Get paths section
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -2,7 +2,7 @@
import click
from mycli.commands import pdf, image, latex, video, bib, utils, env_cmd, ssh_cmd, git_cmd, self_cmd, config_cmd
from mycli.commands import pdf, image, latex, video, bib, utils, env_cmd, ssh_cmd, git_cmd, self_cmd, config_cmd, wizard_cmd
@click.group()
@@ -24,6 +24,7 @@ cli.add_command(ssh_cmd)
cli.add_command(git_cmd)
cli.add_command(self_cmd)
cli.add_command(config_cmd)
cli.add_command(wizard_cmd)
def main():
+2 -1
View File
@@ -6,5 +6,6 @@ from .ssh import ssh_cmd
from .git import git_cmd
from .self_mgmt import self_cmd
from .config_cmd import config_cmd
from .config_wizard import wizard_cmd
__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "self_cmd", "config_cmd"]
__all__ = ["pdf", "image", "latex", "video", "bib", "utils", "env_cmd", "ssh_cmd", "git_cmd", "self_cmd", "config_cmd", "wizard_cmd"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+324
View File
@@ -0,0 +1,324 @@
"""Interactive configuration wizard for mycli."""
import json
from pathlib import Path
import click
from mycli.config import config
def _prompt_choice(options: list[str], prompt: str = "Select") -> str | None:
"""Show numbered options and get user choice."""
click.echo(f"\n{prompt}:")
for i, opt in enumerate(options, 1):
click.echo(f" {i}. {opt}")
click.echo(" 0. Cancel")
while True:
choice = click.prompt("Enter number", type=int, default=0)
if choice == 0:
return None
if 1 <= choice <= len(options):
return options[choice - 1]
click.echo("Invalid choice, try again.")
def _prompt_confirm(message: str) -> bool:
"""Prompt for yes/no confirmation."""
return click.confirm(message, default=False)
@click.group(name="wizard", invoke_without_command=True)
@click.pass_context
def wizard_cmd(ctx):
"""Interactive configuration wizard."""
if ctx.invoked_subcommand is None:
# Show main wizard menu
show_main_menu()
def show_main_menu():
"""Show the main wizard menu."""
click.clear()
click.secho("╔══════════════════════════════════════╗", fg="cyan")
click.secho("║ MyCLI Configuration Wizard ║", fg="cyan")
click.secho("╚══════════════════════════════════════╝", fg="cyan")
options = {
"1": ("Manage Paths", manage_paths),
"2": ("Manage SSH Hosts", manage_ssh_hosts),
"3": ("Manage API Keys", manage_api_keys),
"4": ("Manage Feishu Apps", manage_feishu_apps),
"5": ("Manage Git Proxies", manage_git_proxies),
"0": ("Exit", lambda: click.echo("Goodbye!")),
}
while True:
click.echo("\nMain Menu:")
for key, (label, _) in options.items():
click.echo(f" [{key}] {label}")
choice = click.prompt("\nSelect option", type=str, default="0")
if choice in options:
_, func = options[choice]
func()
if choice == "0":
break
else:
click.echo("Invalid choice.")
def manage_paths():
"""Interactive path management."""
click.secho("\n--- Path Management ---", fg="cyan")
paths = config.get("paths", {})
# Show current paths
click.echo("\nCurrent paths:")
for name, path in sorted(paths.items()):
exists = "" if Path(path).expanduser().exists() else ""
click.echo(f" [{exists}] {name}: {path}")
action = _prompt_choice(["Add new path", "Edit path", "Delete path", "Back"])
if action == "Add new path":
name = click.prompt("Path name (e.g., 'mynewproject')")
path = click.prompt("Path value (e.g., '~/Documents/project')")
_config_set(f"paths.{name}", path)
click.secho(f"Added path: {name} = {path}", fg="green")
elif action == "Edit path":
if not paths:
click.echo("No paths to edit.")
return
name = _prompt_choice(list(paths.keys()), "Select path to edit")
if name:
new_path = click.prompt("New path value", default=paths[name])
_config_set(f"paths.{name}", new_path)
click.secho(f"Updated path: {name}", fg="green")
elif action == "Delete path":
if not paths:
click.echo("No paths to delete.")
return
name = _prompt_choice(list(paths.keys()), "Select path to delete")
if name and _prompt_confirm(f"Delete path '{name}'?"):
_config_delete(f"paths.{name}")
click.secho(f"Deleted path: {name}", fg="green")
def manage_ssh_hosts():
"""Interactive SSH host management."""
click.secho("\n--- SSH Host Management ---", fg="cyan")
hosts = config.get("ssh_hosts", {})
click.echo("\nCurrent SSH hosts:")
for name, host_config in sorted(hosts.items()):
click.echo(f" {name}: {host_config['host']}:{host_config['port']}")
action = _prompt_choice(["Add new host", "Edit host", "Delete host", "Back"])
if action == "Add new host":
name = click.prompt("Host name (e.g., 'myserver')")
host = click.prompt("SSH connection (e.g., 'user@host')")
port = click.prompt("Port", type=int, default=22)
has_key = click.confirm("Use SSH key?", default=False)
host_config = {"host": host, "port": port}
if has_key:
key_path = click.prompt("SSH key path (e.g., '~/.ssh/id_rsa')")
host_config["key"] = key_path
_config_set(f"ssh_hosts.{name}", host_config)
click.secho(f"Added SSH host: {name}", fg="green")
elif action == "Edit host":
if not hosts:
click.echo("No hosts to edit.")
return
name = _prompt_choice(list(hosts.keys()), "Select host to edit")
if name:
host_config = hosts[name]
host = click.prompt("Host", default=host_config["host"])
port = click.prompt("Port", type=int, default=host_config["port"])
host_config["host"] = host
host_config["port"] = port
_config_set(f"ssh_hosts.{name}", host_config)
click.secho(f"Updated SSH host: {name}", fg="green")
elif action == "Delete host":
if not hosts:
click.echo("No hosts to delete.")
return
name = _prompt_choice(list(hosts.keys()), "Select host to delete")
if name and _prompt_confirm(f"Delete host '{name}'?"):
_config_delete(f"ssh_hosts.{name}")
click.secho(f"Deleted SSH host: {name}", fg="green")
def manage_api_keys():
"""Interactive API key management."""
click.secho("\n--- API Key Management ---", fg="cyan")
api_keys = config.get("api_keys", {})
click.echo("\nCurrent API keys:")
for name in sorted(api_keys.keys()):
value = api_keys[name]
masked = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
click.echo(f" {name}: {masked}")
action = _prompt_choice(["Add new API key", "Edit API key", "Delete API key", "Back"])
if action == "Add new API key":
name = click.prompt("API name (e.g., 'openai', 'anthropic')")
key = click.prompt("API key", hide_input=True)
_config_set(f"api_keys.{name}", key)
click.secho(f"Added API key: {name}", fg="green")
elif action == "Edit API key":
if not api_keys:
click.echo("No API keys to edit.")
return
name = _prompt_choice(list(api_keys.keys()), "Select API key to edit")
if name:
key = click.prompt("New API key", hide_input=True)
_config_set(f"api_keys.{name}", key)
click.secho(f"Updated API key: {name}", fg="green")
elif action == "Delete API key":
if not api_keys:
click.echo("No API keys to delete.")
return
name = _prompt_choice(list(api_keys.keys()), "Select API key to delete")
if name and _prompt_confirm(f"Delete API key '{name}'?"):
_config_delete(f"api_keys.{name}")
click.secho(f"Deleted API key: {name}", fg="green")
def manage_feishu_apps():
"""Interactive Feishu app management."""
click.secho("\n--- Feishu App Management ---", fg="cyan")
apps = config.get("feishu_apps", {})
click.echo("\nCurrent Feishu apps:")
for name in sorted(apps.keys()):
app = apps[name]
click.echo(f" {name}: {app['app_id']}")
action = _prompt_choice(["Add new app", "Edit app", "Delete app", "Back"])
if action == "Add new app":
name = click.prompt("App name (e.g., 'mybot')")
app_id = click.prompt("App ID")
app_secret = click.prompt("App Secret", hide_input=True)
app_config = {"app_id": app_id, "app_secret": app_secret}
_config_set(f"feishu_apps.{name}", app_config)
click.secho(f"Added Feishu app: {name}", fg="green")
elif action == "Edit app":
if not apps:
click.echo("No apps to edit.")
return
name = _prompt_choice(list(apps.keys()), "Select app to edit")
if name:
app = apps[name]
app_id = click.prompt("App ID", default=app["app_id"])
change_secret = click.confirm("Change app secret?", default=False)
app["app_id"] = app_id
if change_secret:
app["app_secret"] = click.prompt("App Secret", hide_input=True)
_config_set(f"feishu_apps.{name}", app)
click.secho(f"Updated Feishu app: {name}", fg="green")
elif action == "Delete app":
if not apps:
click.echo("No apps to delete.")
return
name = _prompt_choice(list(apps.keys()), "Select app to delete")
if name and _prompt_confirm(f"Delete app '{name}'?"):
_config_delete(f"feishu_apps.{name}")
click.secho(f"Deleted Feishu app: {name}", fg="green")
def manage_git_proxies():
"""Interactive Git proxy management."""
click.secho("\n--- Git Proxy Management ---", fg="cyan")
proxies = config.get("git_proxies", {})
click.echo("\nCurrent Git proxies:")
for name, url in sorted(proxies.items()):
click.echo(f" {name}: {url}")
action = _prompt_choice(["Add new proxy", "Edit proxy", "Delete proxy", "Back"])
if action == "Add new proxy":
name = click.prompt("Proxy name (e.g., 'fastgithub')")
url = click.prompt("Proxy URL (e.g., 'http://127.0.0.1:38457')")
_config_set(f"git_proxies.{name}", url)
click.secho(f"Added Git proxy: {name}", fg="green")
elif action == "Edit proxy":
if not proxies:
click.echo("No proxies to edit.")
return
name = _prompt_choice(list(proxies.keys()), "Select proxy to edit")
if name:
url = click.prompt("Proxy URL", default=proxies[name])
_config_set(f"git_proxies.{name}", url)
click.secho(f"Updated Git proxy: {name}", fg="green")
elif action == "Delete proxy":
if not proxies:
click.echo("No proxies to delete.")
return
name = _prompt_choice(list(proxies.keys()), "Select proxy to delete")
if name and _prompt_confirm(f"Delete proxy '{name}'?"):
_config_delete(f"git_proxies.{name}")
click.secho(f"Deleted Git proxy: {name}", fg="green")
def _config_set(key: str, value):
"""Set a config value directly in the JSON file."""
config_path = config._config_path
with open(config_path) as f:
data = json.load(f)
keys = key.split(".")
target = data
for k in keys[:-1]:
if k not in target:
target[k] = {}
target = target[k]
target[keys[-1]] = value
with open(config_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def _config_delete(key: str):
"""Delete a config key directly from the JSON file."""
config_path = config._config_path
with open(config_path) as f:
data = json.load(f)
keys = key.split(".")
target = data
for k in keys[:-1]:
target = target[k]
del target[keys[-1]]
with open(config_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")