feat: add API keys management and config CRUD operations

- Add api_keys and feishu_apps to config.json
- Add env export/init commands for shell integration
- Add config CRUD: list, set, delete, init
- Add config.json.example template
- Add config.json to .gitignore for security
- Update README with new commands

Usage:
  mycli env export              # Export all env vars
  mycli config set key value    # Set config value
  mycli config delete key       # Delete config key
This commit is contained in:
Zhengshou Lai
2026-04-13 11:41:57 +08:00
parent 5a55b4a60d
commit bd167cfb6d
8 changed files with 354 additions and 10 deletions
+3
View File
@@ -11,3 +11,6 @@ build/
.venv/
.pytest_cache/
.mypy_cache/
# Config contains sensitive data (API keys)
mycli/config.json
+43 -1
View File
@@ -64,13 +64,31 @@ mycli self update # Update mycli
mycli self uninstall # Uninstall mycli
mycli self info # Show installation info
# Configuration (config.json)
# Configuration (config.json) - 增删改查
mycli config show # Show full config
mycli config list # List top-level sections
mycli config get paths # Get paths section
mycli config get paths.apaam # Get specific path
mycli config get api_keys.deepseek # Get API key
mycli config set paths.new ~/Documents/new # Set simple value
mycli config set api_keys.newkey abc123 # Set new API key
mycli config set --json ssh_hosts.new '{"host":"user@host","port":22}'
mycli config delete paths.old # Delete a key
mycli config delete api_keys.old -y # Delete without confirmation
mycli config paths # List all paths with existence check
mycli config where # Show config file location
mycli config edit # Edit config in $EDITOR
mycli config init # Create config from template
# Environment Variables (API Keys)
mycli env status # Show paths, API keys, Feishu status
mycli env export # Print all export statements
mycli env export deepseek # Export specific API key
mycli env export qwen
mycli env export feishu # Export all Feishu apps
mycli env export claude # Export specific Feishu app
eval "$(mycli env export)" # Load all in current shell
mycli env init # Print shell init snippet
```
## Configuration
@@ -80,6 +98,30 @@ All settings are stored in `mycli/config.json`:
- `ssh_hosts`: SSH connection settings
- `git_proxies`: Git proxy URLs
- `openfoam`: OpenFOAM mount settings
- `api_keys`: API keys (deepseek, qwen)
- `feishu_apps`: Feishu app credentials
### Security Note
`config.json` contains sensitive information (API keys) and is **gitignored**.
The repository includes `config.json.example` as a template.
### Setup
1. Copy the example config:
```bash
cp mycli/config.json.example mycli/config.json
```
2. Edit with your actual API keys:
```bash
mycli config edit
```
3. Add to your `~/.zshrc` to auto-load:
```bash
eval "$(mycli env export 2>/dev/null)"
```
## Uninstall
Binary file not shown.
+142 -2
View File
@@ -2,10 +2,12 @@
import json
import os
from pathlib import Path
import click
from mycli.config import config
from mycli.utils import handle_errors
@click.group(name="config")
@@ -23,7 +25,13 @@ def show():
@config_cmd.command("get")
@click.argument("key")
def get_value(key):
"""Get a configuration value by key (dot notation)."""
"""Get a configuration value by key (dot notation).
Examples:
mycli config get paths.research
mycli config get ssh_hosts.tianhe.port
mycli config get api_keys.deepseek
"""
value = config.get(key)
if value is not None:
if isinstance(value, dict):
@@ -35,12 +43,123 @@ def get_value(key):
raise click.Exit(1)
@config_cmd.command("set")
@click.argument("key")
@click.argument("value")
@click.option("--json", "is_json", is_flag=True, help="Parse value as JSON")
def set_value(key, value, is_json):
"""Set a configuration value.
Examples:
mycli config set paths.newpath ~/Documents/new
mycli config set api_keys.newkey abc123
mycli config set --json ssh_hosts.newhost '{"host":"user@host","port":22}'
"""
# Load current config
config_path = config._config_path
with open(config_path) as f:
data = json.load(f)
# Parse value
if is_json:
try:
parsed_value = json.loads(value)
except json.JSONDecodeError as e:
click.echo(f"Invalid JSON: {e}", err=True)
raise click.Exit(1)
else:
parsed_value = value
# Navigate to nested key
keys = key.split(".")
target = data
for k in keys[:-1]:
if k not in target:
target[k] = {}
target = target[k]
# Set value
target[keys[-1]] = parsed_value
# Save
with open(config_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
click.secho(f"Set {key} = {parsed_value}", fg="green")
@config_cmd.command("delete")
@click.argument("key")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def delete_value(key, yes):
"""Delete a configuration key.
Examples:
mycli config delete api_keys.oldkey
mycli config delete paths.oldpath
"""
# Check if exists
if config.get(key) is None:
click.echo(f"Key not found: {key}", err=True)
raise click.Exit(1)
if not yes:
click.confirm(f"Delete '{key}'?", abort=True)
# Load current config
config_path = config._config_path
with open(config_path) as f:
data = json.load(f)
# Navigate and delete
keys = key.split(".")
target = data
for k in keys[:-1]:
if k not in target:
click.echo(f"Key not found: {key}", err=True)
raise click.Exit(1)
target = target[k]
if keys[-1] in target:
del target[keys[-1]]
else:
click.echo(f"Key not found: {key}", err=True)
raise click.Exit(1)
# Save
with open(config_path, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
click.secho(f"Deleted {key}", fg="green")
@config_cmd.command("list")
def list_keys():
"""List all top-level configuration keys."""
config_path = config._config_path
with open(config_path) as f:
data = json.load(f)
click.secho("Configuration sections:", fg="cyan")
for key in sorted(data.keys()):
value = data[key]
type_str = type(value).__name__
if isinstance(value, dict):
count = len(value)
click.echo(f" {key:20} ({type_str}, {count} items)")
elif isinstance(value, list):
click.echo(f" {key:20} ({type_str}, {len(value)} items)")
else:
click.echo(f" {key:20} ({type_str})")
@config_cmd.command("paths")
def list_paths():
"""List all configured paths."""
paths = config.get_all_paths()
max_len = max(len(name) for name in paths.keys())
from pathlib import Path
for name, path in sorted(paths.items()):
exists = "" if path.exists() else ""
click.echo(f"{name:{max_len}} [{exists}] {path}")
@@ -56,3 +175,24 @@ def edit():
def where():
"""Show configuration file location."""
click.echo(config._config_path)
@config_cmd.command("init")
def init_config():
"""Create initial config from example template."""
config_path = config._config_path
example_path = config_path.parent / "config.json.example"
if config_path.exists():
click.echo(f"Config already exists: {config_path}", err=True)
click.echo("Use 'mycli config edit' to modify it")
raise click.Exit(1)
if not example_path.exists():
click.echo(f"Example config not found: {example_path}", err=True)
raise click.Exit(1)
import shutil
shutil.copy(example_path, config_path)
click.secho(f"Created config from template: {config_path}", fg="green")
click.echo("Edit it to add your API keys and customize paths")
+97
View File
@@ -83,3 +83,100 @@ def status():
click.secho(f" Mounted: {openfoam_path}", fg="green")
else:
click.secho(" Not mounted", fg="red")
# API Keys status (without showing actual values)
click.secho("\n=== API Keys ===", fg="cyan")
api_keys = config.get("api_keys", {})
for name in api_keys.keys():
env_var = f"{name.upper()}_API_KEY"
is_set = "" if os.environ.get(env_var) else ""
click.echo(f" {name:12} [{is_set}] {env_var}")
# Feishu Apps status
click.secho("\n=== Feishu Apps ===", fg="cyan")
feishu_apps = config.get("feishu_apps", {})
for app_name in feishu_apps.keys():
app_id_var = f"{app_name.upper()}_APP_ID"
is_set = "" if os.environ.get(app_id_var) else ""
click.echo(f" {app_name:12} [{is_set}] {app_id_var}")
@env_cmd.command("export")
@click.argument("name", required=False)
def export_env(name):
"""Print export statements for environment variables.
Examples:
mycli env export # Export all
mycli env export deepseek # Export specific API key
mycli env export feishu # Export all Feishu apps
eval "$(mycli env export)" # Load in current shell
"""
if name is None:
# Export all
_export_api_keys()
_export_feishu_apps()
elif name == "feishu":
_export_feishu_apps()
elif name in config.get("api_keys", {}):
_export_api_key(name)
elif name in config.get("feishu_apps", {}):
_export_feishu_app(name)
else:
click.echo(f"Unknown export target: {name}", err=True)
click.echo(f"Available: api_keys ({', '.join(config.get('api_keys', {}).keys())}), "
f"feishu_apps ({', '.join(config.get('feishu_apps', {}).keys())})", err=True)
raise click.Exit(1)
def _export_api_key(name: str):
"""Export a single API key."""
api_keys = config.get("api_keys", {})
if name not in api_keys:
return
env_var = f"{name.upper()}_API_KEY"
value = api_keys[name]
click.echo(f"export {env_var}={value}")
def _export_api_keys():
"""Export all API keys."""
api_keys = config.get("api_keys", {})
for name in api_keys.keys():
_export_api_key(name)
def _export_feishu_app(name: str):
"""Export a single Feishu app."""
feishu_apps = config.get("feishu_apps", {})
if name not in feishu_apps:
return
app = feishu_apps[name]
prefix = name.upper()
click.echo(f"export {prefix}_APP_ID={app['app_id']}")
click.echo(f"export {prefix}_APP_SECRET={app['app_secret']}")
def _export_feishu_apps():
"""Export all Feishu apps."""
feishu_apps = config.get("feishu_apps", {})
for name in feishu_apps.keys():
_export_feishu_app(name)
@env_cmd.command("init")
@click.option("--shell", "-s", "shell_type", type=click.Choice(["bash", "zsh", "fish"]), default="zsh")
def init_shell(shell_type):
"""Print shell initialization snippet.
Add this to your ~/.zshrc or ~/.bashrc:
eval "$(mycli env init)"
"""
snippet = f"""# MyCLI environment initialization
# Add this line to your ~/.{shell_type}rc:
# eval "$(mycli env init)"
# Export API keys and app credentials
eval "$(mycli env export 2>/dev/null)"
"""
click.echo(snippet)
+15 -1
View File
@@ -30,5 +30,19 @@
"mount_point": "/Volumes/OpenFOAM",
"build_path": "/Volumes/OpenFOAM/openfoam/build"
},
"cv": "~/Documents/Personal/resume_cv/cv_lai_maintaining_latex/cv_lai_maintaining"
"cv": "~/Documents/Personal/resume_cv/cv_lai_maintaining_latex/cv_lai_maintaining",
"api_keys": {
"deepseek": "sk-5c2f22ad351a485380e29385c710c2b2",
"qwen": "sk-dcbbcce8cce94ecebb4c19417757d32f"
},
"feishu_apps": {
"claude": {
"app_id": "cli_a9452b9024781bd2",
"app_secret": "YIyWNMOUhuPxrs1emA6H7mOvbZajJHBw"
},
"myagent": {
"app_id": "cli_a916d9fef8b89bc7",
"app_secret": "WxhZe8N8MuMuzCjYduDe8btitLeiFUFj"
}
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"paths": {
"study": "~/Documents/myStudy/self_learning",
"research": "~/Documents/myResearch",
"apaam": "~/Documents/myResearch/myProjects/apaam",
"tianhe": "~/Documents/myWork/sysu/0_中山大学/hpc_tianhe",
"academia": "~/Library/Mobile Documents/iCloud~md~obsidian/Documents/myacademia",
"webpage": "~/Documents/myWork/sysu/4_成果归档/mywebpage",
"slides": "~/Documents/myWork/sysu/4_成果归档/myslides",
"myclaude": "~/Documents/myResearch/myProjects/apaam/repo/myclaude",
"myagent": "~/Documents/myResearch/myProjects/apaam/repo/myagent"
},
"ssh_hosts": {
"tianhe": {
"host": "sysu_lchuangxy_1@172.16.31.31",
"port": 6666,
"key": "~/Documents/myWork/sysu/0_中山大学/hpc_tianhe/sysu_lchuangxy_1.id"
},
"starlight": {
"host": "sysu_lchuang_1@proxy.nscc-gz.cn",
"port": 23
}
},
"git_proxies": {
"fastgithub": "http://127.0.0.1:38457",
"pandafan": "http://127.0.0.1:10080"
},
"openfoam": {
"dmg_path": "~/Documents/myResearch/myProjects/apaam/repo/OpenFOAM.dmg",
"mount_point": "/Volumes/OpenFOAM",
"build_path": "/Volumes/OpenFOAM/openfoam/build"
},
"cv": "~/Documents/Personal/resume_cv/cv_lai_maintaining_latex/cv_lai_maintaining",
"api_keys": {
"deepseek": "YOUR_DEEPSEEK_API_KEY_HERE",
"qwen": "YOUR_QWEN_API_KEY_HERE"
},
"feishu_apps": {
"claude": {
"app_id": "YOUR_CLAUDE_APP_ID",
"app_secret": "YOUR_CLAUDE_APP_SECRET"
},
"myagent": {
"app_id": "YOUR_MYAGENT_APP_ID",
"app_secret": "YOUR_MYAGENT_APP_SECRET"
}
}
}