Files
2026-07-13 12:55:23 +08:00

989 lines
35 KiB
Python

"""CC Switch CLI — command-line interface for CC Switch configuration manager.
Usage:
ccswitch providers list [--app claude] [--json]
ccswitch providers set-current <provider-id> --app claude
ccswitch proxy status [--app claude] [--json]
ccswitch proxy config get [--app claude]
ccswitch mcp list [--json]
ccswitch skills list [--json]
ccswitch usage stats [--days 30] [--json]
ccswitch settings get <key>
"""
import json as _json
import os as _os
import sys
import tempfile as _tempfile
from collections.abc import Mapping
from copy import deepcopy as _deepcopy
import click
import tomlkit
from .utils.db import connect_db, load_config, load_settings, VALID_APP_TYPES
_MANAGED_APPS = VALID_APP_TYPES
# ──────────────────────────────────────────────
# Shared helpers
# ──────────────────────────────────────────────
def _resolve_app(app: str | None) -> str | None:
if app is not None:
app = app.lower()
if app not in VALID_APP_TYPES:
raise click.BadParameter(f"Invalid app: {app}. Valid: {', '.join(VALID_APP_TYPES)}")
return app
def _mask_sensitive(key: str, value) -> str:
"""Mask sensitive values like API tokens and keys."""
if isinstance(value, str) and _is_sensitive_key(key):
if len(value) > 12:
return value[:8] + "..." + value[-4:]
return "***"
if isinstance(value, Mapping | list | tuple):
return _format_masked_value(_mask_value(value, key))
return str(value)
def _is_sensitive_key(key: str) -> bool:
lowered = key.lower()
normalized = "".join(ch if ch.isalnum() else "_" for ch in lowered).strip("_")
parts = {p for p in normalized.split("_") if p}
if not normalized:
return False
if normalized in {"key", "apikey", "api_key"}:
return True
if normalized.endswith("_key") or normalized.startswith("key_"):
return True
sensitive_words = {
"token", "secret", "password", "auth", "credential",
"bearer", "cookie", "private",
}
if parts & sensitive_words:
return True
return any(word in normalized for word in (
"apikey", "api_key", "authtoken", "accesskey", "secretkey",
"secretaccesskey", "authorization",
))
def _mask_value(value, key: str = ""):
"""Return a JSON-safe copy with sensitive nested values masked."""
if _is_sensitive_key(key):
if isinstance(value, str):
return _mask_sensitive(key, value)
return "***"
if isinstance(value, Mapping):
return {k: _mask_value(v, str(k)) for k, v in value.items()}
if isinstance(value, list):
return [_mask_value(item, key) for item in value]
if isinstance(value, tuple):
return [_mask_value(item, key) for item in value]
return value
def _format_masked_value(value) -> str:
if isinstance(value, Mapping):
return "{" + ", ".join(
f"{k}: {_format_masked_value(v)}" for k, v in value.items()
) + "}"
if isinstance(value, list):
return "[" + ", ".join(_format_masked_value(item) for item in value) + "]"
return str(value)
def _table(headers: list[str], rows: list[tuple]) -> str:
"""Format data as a simple aligned table."""
if not rows:
return "(empty)"
all_rows = [headers] + [list(map(str, r)) for r in rows]
col_widths = [max(len(r[i]) for r in all_rows) for i in range(len(headers))]
lines = []
header = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
lines.append(header)
lines.append("-" * len(header))
for row in all_rows[1:]:
lines.append(" ".join(v.ljust(col_widths[i]) for i, v in enumerate(row)))
return "\n".join(lines)
def _enabled_apps_str(row: Mapping) -> str:
apps = [app for app in _MANAGED_APPS if row[f"enabled_{app}"]]
return ",".join(apps) if apps else "-"
def _normalise_usage_rows(rows) -> list[dict]:
normalised = []
for row in rows:
data = dict(row)
data["input_tok"] = data["input_tok"] or 0
data["output_tok"] = data["output_tok"] or 0
data["cost"] = data["cost"] or 0
normalised.append(data)
return normalised
def _enabled_app_columns(db, table: str) -> str:
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})")}
parts = []
for app in _MANAGED_APPS:
column = f"enabled_{app}"
parts.append(column if column in columns else f"0 AS {column}")
return ", ".join(parts)
_CODEX_PROVIDER_CONFIG_KEYS = ("model", "model_provider", "base_url")
def _merge_codex_provider_toml(target, provider_config: str) -> None:
"""Merge provider-owned Codex TOML fields without deleting user settings."""
try:
existing_text = target.read_text(encoding="utf-8") if target.exists() else ""
existing_doc = (
tomlkit.parse(existing_text) if existing_text.strip() else tomlkit.document()
)
provider_doc = (
tomlkit.parse(provider_config) if provider_config.strip() else tomlkit.document()
)
except Exception as exc:
raise click.ClickException(f"Invalid Codex config.toml: {exc}") from exc
for key in _CODEX_PROVIDER_CONFIG_KEYS:
if key in provider_doc:
existing_doc[key] = _deepcopy(provider_doc[key])
if "model_providers" in provider_doc:
provider_table = provider_doc["model_providers"]
if not hasattr(provider_table, "items"):
existing_doc["model_providers"] = _deepcopy(provider_table)
else:
existing_table = existing_doc.get("model_providers")
if not hasattr(existing_table, "items"):
existing_doc["model_providers"] = tomlkit.table()
existing_table = existing_doc["model_providers"]
for provider_id, provider_settings in provider_table.items():
existing_table[provider_id] = _deepcopy(provider_settings)
_write_text_config(target, tomlkit.dumps(existing_doc))
# ──────────────────────────────────────────────
# Main CLI
# ──────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "json_mode", is_flag=True, help="Output in JSON format")
@click.option("--db", "db_path", type=click.Path(), help="Override database path")
@click.pass_context
def cli(ctx: click.Context, json_mode: bool, db_path: str | None) -> None:
"""CC Switch CLI — Manage AI coding tool configurations from the terminal."""
ctx.ensure_object(dict)
ctx.obj["json_mode"] = json_mode
ctx.obj["db_path"] = db_path
if ctx.invoked_subcommand is None:
# Show status overview
_show_status(ctx)
@cli.command("status")
@click.pass_context
def status(ctx: click.Context) -> None:
"""Show a quick database overview."""
_show_status(ctx)
def _show_status(ctx: click.Context) -> None:
"""Show a quick status overview."""
db = connect_db(ctx.obj.get("db_path"))
try:
# Count providers
prov_count = db.execute("SELECT COUNT(*) FROM providers").fetchone()[0]
# Current provider per app
cur = db.execute(
"SELECT app_type, name FROM providers WHERE is_current=1 ORDER BY app_type"
).fetchall()
# Skill count
skill_count = db.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
# MCP count
mcp_count = db.execute("SELECT COUNT(*) FROM mcp_servers").fetchone()[0]
if ctx.obj.get("json_mode"):
_json.dump({
"providers": prov_count,
"current": {r["app_type"]: r["name"] for r in cur},
"skills": skill_count,
"mcp_servers": mcp_count,
}, sys.stdout, indent=2)
return
click.echo("CC Switch Status")
click.echo("-" * 40)
click.echo(f" Providers: {prov_count}")
click.echo(f" Skills: {skill_count}")
click.echo(f" MCP Servers: {mcp_count}")
click.echo()
click.echo(" Current providers:")
for r in cur:
click.echo(f" {r['app_type']:>10s}: {r['name']}")
finally:
db.close()
# ──────────────────────────────────────────────
# Providers
# ──────────────────────────────────────────────
@cli.group()
def providers() -> None:
"""Manage AI provider configurations."""
pass
@providers.command("list")
@click.option("--app", "-a", help="Filter by app type")
@click.pass_context
def providers_list(ctx: click.Context, app: str | None) -> None:
"""List all configured providers."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
if app:
rows = db.execute(
"SELECT id, name, category, is_current, sort_index FROM providers "
"WHERE app_type=? ORDER BY sort_index",
(app,),
).fetchall()
else:
rows = db.execute(
"SELECT app_type, id, name, category, is_current, sort_index "
"FROM providers ORDER BY app_type, sort_index"
).fetchall()
if ctx.obj.get("json_mode"):
_json.dump([dict(r) for r in rows], sys.stdout, indent=2, default=str)
return
if app:
click.echo(_table(["ID", "Name", "Category", "Current", "Sort"], [
(r["id"], r["name"], r["category"] or "", "*" if r["is_current"] else "", r["sort_index"])
for r in rows
]))
else:
click.echo(_table(["App", "ID", "Name", "Category", "Current", "Sort"], [
(r["app_type"], r["id"], r["name"], r["category"] or "", "*" if r["is_current"] else "", r["sort_index"])
for r in rows
]))
finally:
db.close()
@providers.command("get")
@click.argument("provider_id")
@click.option("--app", "-a", required=True, help="App type (claude/codex/gemini/...)")
@click.pass_context
def providers_get(ctx: click.Context, provider_id: str, app: str) -> None:
"""Get detailed configuration for a provider."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
row = db.execute(
"SELECT * FROM providers WHERE id=? AND app_type=?", (provider_id, app)
).fetchone()
if not row:
click.echo(f"Provider '{provider_id}' not found for app '{app}'", err=True)
raise SystemExit(1)
data = dict(row)
# Parse settings_config JSON
data["settings_config"] = _json.loads(data["settings_config"])
if ctx.obj.get("json_mode"):
data["settings_config"] = _mask_value(data["settings_config"])
_json.dump(data, sys.stdout, indent=2, default=str)
return
click.echo(f"Provider: {data['name']}")
click.echo(f" ID: {data['id']}")
click.echo(f" App: {data['app_type']}")
click.echo(f" Category: {data.get('category', 'N/A')}")
click.echo(f" Current: {bool(data['is_current'])}")
click.echo(f" Settings:")
for k, v in sorted(data["settings_config"].items()):
click.echo(f" {k}: {_format_masked_value(_mask_value(v, k))}")
finally:
db.close()
@providers.command("set-current")
@click.argument("provider_id")
@click.option("--app", "-a", required=True, help="App type")
@click.pass_context
def providers_set_current(ctx: click.Context, provider_id: str, app: str) -> None:
"""Set the current/active provider for an app."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
# Verify provider exists
row = db.execute(
"SELECT id, name FROM providers WHERE id=? AND app_type=?", (provider_id, app)
).fetchone()
if not row:
click.echo(f"Provider '{provider_id}' not found for '{app}'", err=True)
raise SystemExit(1)
# Unset all, then set current
db.execute("UPDATE providers SET is_current=0 WHERE app_type=?", (app,))
db.execute(
"UPDATE providers SET is_current=1 WHERE id=? AND app_type=?", (provider_id, app)
)
_write_live_config(app, db)
db.commit()
click.echo(f"Switched {app} to provider: {row['name']}")
finally:
db.close()
def _write_live_config(app: str, db) -> None:
"""Write the current provider config to the live app config file."""
from pathlib import Path
home = Path(_os.path.expanduser("~"))
row = db.execute(
"SELECT * FROM providers WHERE app_type=? AND is_current=1", (app,)
).fetchone()
if not row:
return
config = _json.loads(row["settings_config"])
target_map = {
"claude": home / ".claude" / "settings.json",
"codex": home / ".codex" / "config.toml",
"gemini": home / ".gemini" / ".env",
"opencode": home / ".config" / "opencode" / "opencode.json",
"openclaw": home / ".openclaw" / "openclaw.json",
"hermes": home / ".hermes" / "config.yaml",
}
target = target_map.get(app)
if not target:
return
if app == "claude":
_write_json_env_config(target, _env_config(config))
click.echo(f" Written live config to: {target}")
elif app == "codex":
wrote = False
if "config" in config:
_merge_codex_provider_toml(target, str(config["config"]))
wrote = True
if isinstance(config.get("auth"), dict):
_write_json_config(home / ".codex" / "auth.json", config["auth"])
wrote = True
if wrote:
click.echo(f" Written live config to: {target}")
else:
click.echo(f" (Note: no supported {app} live config payload found)")
elif app == "gemini":
written_targets = []
env = _gemini_env_config(config)
if env:
_write_env_config(target, env)
written_targets.append(target)
if "config" in config:
settings_target = home / ".gemini" / "settings.json"
if isinstance(config["config"], dict):
_write_json_config(settings_target, config["config"])
else:
_write_text_config(settings_target, str(config["config"]))
written_targets.append(settings_target)
if written_targets:
click.echo(
" Written live config to: "
+ ", ".join(str(path) for path in written_targets)
)
else:
click.echo(f" (Note: no supported {app} live config payload found)")
elif app == "opencode":
if "config" in config:
_write_text_config(target, str(config["config"]))
elif "settingsConfig" in config:
_write_opencode_provider_config(target, str(row["id"]), config["settingsConfig"])
else:
click.echo(f" (Note: no supported {app} live config payload found)")
return
click.echo(f" Written live config to: {target}")
elif app == "openclaw":
_write_json_config(target, config)
click.echo(f" Written live config to: {target}")
elif app == "hermes":
if "config" in config:
_write_text_config(target, str(config["config"]))
else:
click.echo(f" (Note: no supported {app} live config payload found)")
return
click.echo(f" Written live config to: {target}")
def _env_config(config: dict) -> dict:
env = config.get("env")
if isinstance(env, dict):
return env
return config
def _gemini_env_config(config: dict) -> dict:
env = config.get("env")
if isinstance(env, dict):
return env
if "config" in config or "auth" in config or "settingsConfig" in config:
return {}
return config
def _write_json_env_config(target, env: dict) -> None:
existing = {}
if target.exists():
with open(target, encoding="utf-8") as f:
existing = _json.load(f)
if not isinstance(existing, dict):
existing = {}
existing_env = existing.get("env")
if not isinstance(existing_env, dict):
existing_env = {}
existing_env.update(env)
existing["env"] = existing_env
_write_json_config(target, existing)
def _write_env_config(target, env: dict) -> None:
existing = {}
if target.exists():
existing = _parse_env_file(target.read_text(encoding="utf-8"))
existing.update(_coerce_env_config(env))
lines = [f"{key}={existing[key]}" for key in sorted(existing)]
_write_text_config(target, "\n".join(lines))
def _parse_env_file(content: str) -> dict[str, str]:
parsed = {}
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
if _is_valid_env_key(key):
parsed[key] = value
return parsed
def _coerce_env_config(env: dict) -> dict[str, str]:
result = {}
for key, value in env.items():
key = str(key)
if not _is_valid_env_key(key):
continue
result[key] = _env_value(value)
return result
def _is_valid_env_key(key: str) -> bool:
return bool(key) and all(
ch == "_" or "A" <= ch <= "Z" or "a" <= ch <= "z" or "0" <= ch <= "9"
for ch in key
)
def _env_value(value) -> str:
text = "" if value is None else str(value)
return text.replace("\r", "\\r").replace("\n", "\\n")
def _write_json_config(target, config: dict) -> None:
_write_text_config(target, _json.dumps(config, indent=2, ensure_ascii=False) + "\n")
def _write_opencode_provider_config(target, provider_id: str, provider_config: dict) -> None:
existing = {
"$schema": "https://opencode.ai/config.json",
}
if target.exists():
with open(target, encoding="utf-8") as f:
existing = _json.load(f)
if not isinstance(existing, dict):
existing = {}
providers = existing.get("provider")
if not isinstance(providers, dict):
providers = {}
providers[provider_id] = provider_config
existing["provider"] = providers
_write_json_config(target, existing)
def _write_text_config(target, content: str) -> None:
if content and not content.endswith("\n"):
content += "\n"
_write_secure_file(target, content)
def _write_secure_file(target, content: str) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = _tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
try:
with _os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
try:
_os.chmod(tmp_name, 0o600)
except OSError:
pass
_os.replace(tmp_name, target)
except Exception:
try:
_os.unlink(tmp_name)
except OSError:
pass
raise
# ──────────────────────────────────────────────
# Proxy
# ──────────────────────────────────────────────
@cli.group()
def proxy() -> None:
"""Manage the local HTTP proxy server."""
pass
@proxy.command("status")
@click.option("--app", "-a", default="claude", help="App type")
@click.pass_context
def proxy_status(ctx: click.Context, app: str) -> None:
"""Show proxy server status."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
row = db.execute(
"SELECT * FROM proxy_config WHERE app_type=?", (app,)
).fetchone()
if not row:
click.echo(f"No proxy config for {app}")
return
data = dict(row)
if ctx.obj.get("json_mode"):
_json.dump(data, sys.stdout, indent=2, default=str)
return
click.echo(f"Proxy Status ({app}):")
click.echo(f" Enabled: {bool(data['enabled'])}")
click.echo(f" Listen: {data['listen_address']}:{data['listen_port']}")
click.echo(f" Proxy Enabled: {bool(data['proxy_enabled'])}")
click.echo(f" Auto Failover: {bool(data['auto_failover_enabled'])}")
click.echo(f" Max Retries: {data['max_retries']}")
click.echo(f" Circuit Breaker: {bool(data.get('live_takeover_active', 0))}")
finally:
db.close()
@proxy.command("config")
@click.option("--app", "-a", default="claude", help="App type")
@click.option("--set-port", type=int, help="Set listen port")
@click.option("--enable/--disable", default=None, help="Enable/disable proxy")
@click.option("--failover/--no-failover", default=None, help="Enable/disable auto failover")
@click.pass_context
def proxy_config(
ctx: click.Context, app: str, set_port: int | None,
enable: bool | None, failover: bool | None
) -> None:
"""Get or set proxy configuration."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
if set_port is not None:
db.execute("UPDATE proxy_config SET listen_port=? WHERE app_type=?", (set_port, app))
if enable is True:
db.execute("UPDATE proxy_config SET proxy_enabled=1 WHERE app_type=?", (app,))
elif enable is False:
db.execute("UPDATE proxy_config SET proxy_enabled=0 WHERE app_type=?", (app,))
if failover is True:
db.execute("UPDATE proxy_config SET auto_failover_enabled=1 WHERE app_type=?", (app,))
elif failover is False:
db.execute("UPDATE proxy_config SET auto_failover_enabled=0 WHERE app_type=?", (app,))
db.commit()
# Show updated config
row = db.execute(
"SELECT * FROM proxy_config WHERE app_type=?", (app,)
).fetchone()
if row:
data = dict(row)
click.echo(_json.dumps({
"app": app,
"listen": f"{data['listen_address']}:{data['listen_port']}",
"enabled": bool(data["enabled"]),
"proxy_enabled": bool(data["proxy_enabled"]),
"auto_failover": bool(data["auto_failover_enabled"]),
"max_retries": data["max_retries"],
}, indent=2))
finally:
db.close()
# ──────────────────────────────────────────────
# MCP
# ──────────────────────────────────────────────
@cli.group()
def mcp() -> None:
"""Manage MCP (Model Context Protocol) servers."""
pass
@mcp.command("list")
@click.pass_context
def mcp_list(ctx: click.Context) -> None:
"""List all MCP servers."""
db = connect_db(ctx.obj.get("db_path"))
try:
enabled_columns = _enabled_app_columns(db, "mcp_servers")
rows = db.execute(
f"SELECT id, name, description, {enabled_columns} "
"FROM mcp_servers ORDER BY name"
).fetchall()
if ctx.obj.get("json_mode"):
_json.dump([dict(r) for r in rows], sys.stdout, indent=2, default=str)
return
click.echo(_table(["ID", "Name", "Apps", "Description"], [
(r["id"][:30], r["name"], _enabled_apps_str(r), (r["description"] or "")[:50])
for r in rows
]))
finally:
db.close()
@mcp.command("enable")
@click.argument("server_id")
@click.option("--app", "-a", required=True, help="App type")
@click.option("--on/--off", default=True, help="Enable or disable")
@click.pass_context
def mcp_enable(ctx: click.Context, server_id: str, app: str, on: bool) -> None:
"""Enable or disable an MCP server for an app."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
col = f"enabled_{app}"
db.execute(f"UPDATE mcp_servers SET {col}=? WHERE id=?", (int(on), server_id))
if db.total_changes == 0:
click.echo(f"MCP server '{server_id}' not found", err=True)
raise SystemExit(1)
db.commit()
click.echo(f"MCP '{server_id}' {'enabled' if on else 'disabled'} for {app}")
finally:
db.close()
# ──────────────────────────────────────────────
# Skills
# ──────────────────────────────────────────────
@cli.group()
def skills() -> None:
"""Manage installed skills."""
pass
@skills.command("list")
@click.pass_context
def skills_list(ctx: click.Context) -> None:
"""List all installed skills."""
db = connect_db(ctx.obj.get("db_path"))
try:
enabled_columns = _enabled_app_columns(db, "skills")
rows = db.execute(
"SELECT id, name, description, repo_owner, repo_name, "
f"{enabled_columns} "
"FROM skills ORDER BY name"
).fetchall()
if ctx.obj.get("json_mode"):
_json.dump([dict(r) for r in rows], sys.stdout, indent=2, default=str)
return
click.echo(_table(["Name", "Source", "Apps", "Description"], [
(r["name"], f"{r['repo_owner']}/{r['repo_name']}" if r["repo_owner"] else "local",
_enabled_apps_str(r), (r["description"] or "")[:50])
for r in rows
]))
finally:
db.close()
@skills.command("repos")
@click.pass_context
def skills_repos(ctx: click.Context) -> None:
"""List registered skill repositories."""
db = connect_db(ctx.obj.get("db_path"))
try:
rows = db.execute(
"SELECT owner, name, branch, enabled FROM skill_repos ORDER BY owner, name"
).fetchall()
if ctx.obj.get("json_mode"):
_json.dump([dict(r) for r in rows], sys.stdout, indent=2, default=str)
return
click.echo(_table(["Owner", "Name", "Branch", "Enabled"], [
(r["owner"], r["name"], r["branch"], "yes" if r["enabled"] else "no")
for r in rows
]))
finally:
db.close()
# ──────────────────────────────────────────────
# Usage
# ──────────────────────────────────────────────
@cli.group()
def usage() -> None:
"""View API usage and cost statistics."""
pass
@usage.command("stats")
@click.option("--days", "-d", default=30, type=int, help="Number of days to show")
@click.option("--app", "-a", help="Filter by app type")
@click.pass_context
def usage_stats(ctx: click.Context, days: int, app: str | None) -> None:
"""Show usage statistics."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
if app:
rows = db.execute(
"SELECT model, COUNT(*) as requests, SUM(input_tokens) as input_tok, "
"SUM(output_tokens) as output_tok, "
"SUM(CAST(total_cost_usd AS REAL)) as cost "
"FROM proxy_request_logs "
"WHERE app_type=? AND created_at > unixepoch('now', ? || ' days') "
"GROUP BY model ORDER BY cost DESC",
(app, f"-{days}"),
).fetchall()
else:
rows = db.execute(
"SELECT app_type, model, COUNT(*) as requests, SUM(input_tokens) as input_tok, "
"SUM(output_tokens) as output_tok, "
"SUM(CAST(total_cost_usd AS REAL)) as cost "
"FROM proxy_request_logs "
"WHERE created_at > unixepoch('now', ? || ' days') "
"GROUP BY app_type, model ORDER BY cost DESC",
(f"-{days}",),
).fetchall()
rows = _normalise_usage_rows(rows)
if ctx.obj.get("json_mode"):
_json.dump(rows, sys.stdout, indent=2, default=str)
return
total_cost = sum(r["cost"] for r in rows)
total_requests = sum(r["requests"] for r in rows)
total_in = sum(r["input_tok"] for r in rows)
total_out = sum(r["output_tok"] for r in rows)
if app:
click.echo(_table(["Model", "Requests", "Input Tokens", "Output Tokens", "Cost (USD)"], [
(r["model"], r["requests"], f'{r["input_tok"]:,}', f'{r["output_tok"]:,}', f'${r["cost"]:.4f}')
for r in rows
]))
else:
click.echo(_table(["App", "Model", "Requests", "Input Tokens", "Output Tokens", "Cost (USD)"], [
(r["app_type"], r["model"], r["requests"],
f'{r["input_tok"]:,}', f'{r["output_tok"]:,}', f'${r["cost"]:.4f}')
for r in rows
]))
click.echo()
click.echo(f" Total ({days} days): {total_requests:,} requests | "
f"{total_in + total_out:,} tokens | ${total_cost:.4f}")
finally:
db.close()
@usage.command("logs")
@click.option("--limit", "-n", default=20, type=int, help="Number of recent logs to show")
@click.option("--app", "-a", help="Filter by app type")
@click.pass_context
def usage_logs(ctx: click.Context, limit: int, app: str | None) -> None:
"""Show recent API request logs."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
if app:
rows = db.execute(
"SELECT app_type, model, status_code, input_tokens, output_tokens, "
"total_cost_usd, latency_ms, datetime(created_at, 'unixepoch') as ts "
"FROM proxy_request_logs WHERE app_type=? "
"ORDER BY created_at DESC LIMIT ?",
(app, limit),
).fetchall()
else:
rows = db.execute(
"SELECT app_type, model, status_code, input_tokens, output_tokens, "
"total_cost_usd, latency_ms, datetime(created_at, 'unixepoch') as ts "
"FROM proxy_request_logs "
"ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
if ctx.obj.get("json_mode"):
_json.dump([dict(r) for r in rows], sys.stdout, indent=2, default=str)
return
click.echo(_table(["App", "Model", "Status", "Tokens (in/out)", "Cost", "Latency", "Time"], [
(r["app_type"], r["model"][:25], r["status_code"],
f'{r["input_tokens"]}/{r["output_tokens"]}',
f'${float(r["total_cost_usd"] or 0):.4f}',
f'{r["latency_ms"]}ms', r["ts"])
for r in rows
]))
finally:
db.close()
# ──────────────────────────────────────────────
# Settings
# ──────────────────────────────────────────────
@cli.group()
def settings() -> None:
"""View and manage CC Switch settings."""
pass
@settings.command("list")
@click.pass_context
def settings_list(ctx: click.Context) -> None:
"""List all settings key-value pairs."""
db = connect_db(ctx.obj.get("db_path"))
try:
rows = db.execute("SELECT key, value FROM settings ORDER BY key").fetchall()
if ctx.obj.get("json_mode"):
_json.dump({
r["key"]: _mask_value(r["value"], r["key"]) for r in rows
}, sys.stdout, indent=2)
return
click.echo(_table(["Key", "Value"], [
(r["key"], _mask_sensitive(r["key"], r["value"])[:80]) for r in rows
]))
finally:
db.close()
@settings.command("get")
@click.argument("key")
@click.pass_context
def settings_get(ctx: click.Context, key: str) -> None:
"""Get a specific setting value."""
db = connect_db(ctx.obj.get("db_path"))
try:
row = db.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if not row:
click.echo(f"Setting '{key}' not found", err=True)
raise SystemExit(1)
if ctx.obj.get("json_mode"):
_json.dump({key: _mask_value(row["value"], key)}, sys.stdout, indent=2)
return
click.echo(_mask_sensitive(key, row["value"]))
finally:
db.close()
@settings.command("set")
@click.argument("key")
@click.argument("value")
@click.pass_context
def settings_set(ctx: click.Context, key: str, value: str) -> None:
"""Set a setting value."""
db = connect_db(ctx.obj.get("db_path"))
try:
db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)
)
db.commit()
click.echo(f"Set '{key}' = '{_mask_sensitive(key, value)}'")
finally:
db.close()
# ──────────────────────────────────────────────
# Sessions
# ──────────────────────────────────────────────
@cli.group()
def sessions() -> None:
"""Browse and search AI conversation sessions."""
pass
@sessions.command("list")
@click.option("--app", "-a", help="Filter by app type")
@click.option("--limit", "-n", default=20, type=int)
@click.pass_context
def sessions_list(ctx: click.Context, app: str | None, limit: int) -> None:
"""List recent conversation sessions."""
app = _resolve_app(app)
db = connect_db(ctx.obj.get("db_path"))
try:
if app:
rows = db.execute(
"SELECT file_path, last_modified, last_synced_at "
"FROM session_log_sync WHERE file_path LIKE ? "
"ORDER BY last_modified DESC LIMIT ?",
(f"%{app}%", limit),
).fetchall()
else:
rows = db.execute(
"SELECT file_path, last_modified, last_synced_at "
"FROM session_log_sync ORDER BY last_modified DESC LIMIT ?",
(limit,),
).fetchall()
if ctx.obj.get("json_mode"):
_json.dump([dict(r) for r in rows], sys.stdout, indent=2, default=str)
return
if not rows:
click.echo("No session logs found. Enable usage tracking in CC Switch first.")
return
click.echo(_table(["Path", "Last Modified", "Last Synced"], [
(r["file_path"][:60],
r["last_modified"],
r["last_synced_at"])
for r in rows
]))
finally:
db.close()
# ──────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────
def main() -> None:
"""Main entry point for CC Switch CLI."""
cli(prog_name="ccswitch")
if __name__ == "__main__":
main()