rename secrets.py to credential_store.py, enhance /status, add usage tracking
- Rename src/secrets.py → src/credential_store.py (avoid stdlib conflict) - Enhanced /status command: uptime, tokens, cost, context window usage - Session metadata now tracks input/output tokens, cost, duration - _safe_env() changed from allowlist to blocklist approach - Better Claude CLI error logging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
cli.py
2
cli.py
@@ -15,7 +15,7 @@ from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.secrets import set_secret, get_secret, list_secrets, delete_secret, check_secrets
|
||||
from src.credential_store import set_secret, get_secret, list_secrets, delete_secret, check_secrets
|
||||
|
||||
PID_FILE = PROJECT_ROOT / "echo-core.pid"
|
||||
LOG_FILE = PROJECT_ROOT / "logs" / "echo-core.log"
|
||||
|
||||
@@ -483,23 +483,113 @@ def create_bot(config: Config) -> discord.Client:
|
||||
|
||||
@tree.command(name="status", description="Show session status")
|
||||
async def status(interaction: discord.Interaction) -> None:
|
||||
from datetime import datetime, timezone
|
||||
import subprocess
|
||||
|
||||
channel_id = str(interaction.channel_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Version info
|
||||
try:
|
||||
commit = subprocess.run(
|
||||
["git", "log", "--format=%h", "-1"],
|
||||
capture_output=True, text=True, cwd=str(PROJECT_ROOT),
|
||||
).stdout.strip() or "?"
|
||||
except Exception:
|
||||
commit = "?"
|
||||
|
||||
# Latency
|
||||
try:
|
||||
lat = round(client.latency * 1000)
|
||||
except (ValueError, TypeError):
|
||||
lat = 0
|
||||
|
||||
# Uptime
|
||||
uptime = ""
|
||||
if hasattr(client, "_ready_at"):
|
||||
elapsed = now - client._ready_at
|
||||
secs = int(elapsed.total_seconds())
|
||||
if secs < 60:
|
||||
uptime = f"{secs}s"
|
||||
elif secs < 3600:
|
||||
uptime = f"{secs // 60}m"
|
||||
else:
|
||||
uptime = f"{secs // 3600}h {(secs % 3600) // 60}m"
|
||||
|
||||
# Channel count
|
||||
channels_count = len(config.get("channels", {}))
|
||||
|
||||
# Session info
|
||||
session = get_active_session(channel_id)
|
||||
if session is None:
|
||||
await interaction.response.send_message(
|
||||
"No active session.", ephemeral=True
|
||||
)
|
||||
return
|
||||
sid = session.get("session_id", "?")
|
||||
truncated_sid = sid[:8] + "..." if len(sid) > 8 else sid
|
||||
model = session.get("model", "?")
|
||||
count = session.get("message_count", 0)
|
||||
await interaction.response.send_message(
|
||||
f"**Model:** {model}\n"
|
||||
f"**Session:** `{truncated_sid}`\n"
|
||||
f"**Messages:** {count}",
|
||||
ephemeral=True,
|
||||
)
|
||||
if session:
|
||||
sid = session.get("session_id", "?")[:8]
|
||||
model = session.get("model", "?")
|
||||
count = session.get("message_count", 0)
|
||||
created = session.get("created_at", "")
|
||||
last_msg = session.get("last_message_at", "")
|
||||
|
||||
age = ""
|
||||
if created:
|
||||
try:
|
||||
el = now - datetime.fromisoformat(created)
|
||||
m = int(el.total_seconds() // 60)
|
||||
age = f"{m}m" if m < 60 else f"{m // 60}h {m % 60}m"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
updated = ""
|
||||
if last_msg:
|
||||
try:
|
||||
el = now - datetime.fromisoformat(last_msg)
|
||||
s = int(el.total_seconds())
|
||||
if s < 60:
|
||||
updated = "just now"
|
||||
elif s < 3600:
|
||||
updated = f"{s // 60}m ago"
|
||||
else:
|
||||
updated = f"{s // 3600}h ago"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Token usage
|
||||
in_tok = session.get("total_input_tokens", 0)
|
||||
out_tok = session.get("total_output_tokens", 0)
|
||||
cost = session.get("total_cost_usd", 0)
|
||||
|
||||
def _fmt_tokens(n):
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1_000_000:.1f}M"
|
||||
if n >= 1_000:
|
||||
return f"{n / 1_000:.1f}k"
|
||||
return str(n)
|
||||
|
||||
tokens_line = f"Tokens: {_fmt_tokens(in_tok)} in / {_fmt_tokens(out_tok)} out"
|
||||
if cost > 0:
|
||||
tokens_line += f" | ${cost:.4f}"
|
||||
|
||||
# Context window usage
|
||||
ctx = session.get("context_tokens", 0)
|
||||
max_ctx = 200_000
|
||||
pct = round(ctx / max_ctx * 100) if ctx else 0
|
||||
context_line = f"Context: {_fmt_tokens(ctx)}/{_fmt_tokens(max_ctx)} ({pct}%)"
|
||||
|
||||
session_line = f"Session: `{sid}` | {count} msgs | {age}" + (f" | updated {updated}" if updated else "")
|
||||
else:
|
||||
model = config.get("bot", {}).get("default_model", "?")
|
||||
session_line = "No active session"
|
||||
tokens_line = ""
|
||||
context_line = ""
|
||||
|
||||
lines = [
|
||||
f"Echo Core ({commit})",
|
||||
f"Model: {model} | Latency: {lat}ms",
|
||||
f"Channels: {channels_count} | Uptime: {uptime}",
|
||||
tokens_line,
|
||||
context_line,
|
||||
session_line,
|
||||
]
|
||||
text = "\n".join(l for l in lines if l)
|
||||
await interaction.response.send_message(text, ephemeral=True)
|
||||
|
||||
@tree.command(name="model", description="View or change the AI model")
|
||||
@app_commands.describe(choice="Model to switch to")
|
||||
@@ -610,6 +700,8 @@ def create_bot(config: Config) -> discord.Client:
|
||||
scheduler = getattr(client, "scheduler", None)
|
||||
if scheduler is not None:
|
||||
await scheduler.start()
|
||||
from datetime import datetime, timezone
|
||||
client._ready_at = datetime.now(timezone.utc)
|
||||
logger.info("Echo Core online as %s", client.user)
|
||||
|
||||
async def _handle_chat(message: discord.Message) -> None:
|
||||
|
||||
@@ -41,11 +41,12 @@ PERSONALITY_FILES = [
|
||||
"HEARTBEAT.md",
|
||||
]
|
||||
|
||||
# Environment variables allowed through to the Claude subprocess
|
||||
_ENV_PASSTHROUGH = {
|
||||
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM",
|
||||
"SHELL", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
# Environment variables to REMOVE from Claude subprocess
|
||||
# (secrets, tokens, and vars that cause nested-session errors)
|
||||
_ENV_STRIP = {
|
||||
"CLAUDECODE", "CLAUDE_CODE_SSE_PORT", "CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
|
||||
"DISCORD_TOKEN", "BOT_TOKEN", "API_KEY", "SECRET",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,8 +66,8 @@ if not shutil.which(CLAUDE_BIN):
|
||||
|
||||
|
||||
def _safe_env() -> dict[str, str]:
|
||||
"""Return a filtered copy of os.environ for subprocess calls."""
|
||||
return {k: v for k, v in os.environ.items() if k in _ENV_PASSTHROUGH}
|
||||
"""Return os.environ minus sensitive/problematic variables."""
|
||||
return {k: v for k, v in os.environ.items() if k not in _ENV_STRIP}
|
||||
|
||||
|
||||
def _load_sessions() -> dict:
|
||||
@@ -121,9 +122,11 @@ def _run_claude(cmd: list[str], timeout: int) -> dict:
|
||||
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
|
||||
|
||||
if proc.returncode != 0:
|
||||
detail = proc.stderr[:500] or proc.stdout[:500]
|
||||
logger.error("Claude CLI stdout: %s", proc.stdout[:1000])
|
||||
logger.error("Claude CLI stderr: %s", proc.stderr[:1000])
|
||||
raise RuntimeError(
|
||||
f"Claude CLI error (exit {proc.returncode}): "
|
||||
f"{proc.stderr[:500]}"
|
||||
f"Claude CLI error (exit {proc.returncode}): {detail}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -190,6 +193,9 @@ def start_session(
|
||||
response_text = data["result"]
|
||||
session_id = data["session_id"]
|
||||
|
||||
# Extract usage stats
|
||||
usage = data.get("usage", {})
|
||||
|
||||
# Save session metadata
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
sessions = _load_sessions()
|
||||
@@ -199,6 +205,11 @@ def start_session(
|
||||
"created_at": now,
|
||||
"last_message_at": now,
|
||||
"message_count": 1,
|
||||
"total_input_tokens": usage.get("input_tokens", 0),
|
||||
"total_output_tokens": usage.get("output_tokens", 0),
|
||||
"total_cost_usd": data.get("total_cost_usd", 0),
|
||||
"duration_ms": data.get("duration_ms", 0),
|
||||
"context_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
|
||||
}
|
||||
_save_sessions(sessions)
|
||||
|
||||
@@ -226,6 +237,9 @@ def resume_session(
|
||||
|
||||
response_text = data["result"]
|
||||
|
||||
# Extract usage stats
|
||||
usage = data.get("usage", {})
|
||||
|
||||
# Update session metadata
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
sessions = _load_sessions()
|
||||
@@ -233,6 +247,11 @@ def resume_session(
|
||||
if session.get("session_id") == session_id:
|
||||
session["last_message_at"] = now
|
||||
session["message_count"] = session.get("message_count", 0) + 1
|
||||
session["total_input_tokens"] = session.get("total_input_tokens", 0) + usage.get("input_tokens", 0)
|
||||
session["total_output_tokens"] = session.get("total_output_tokens", 0) + usage.get("output_tokens", 0)
|
||||
session["total_cost_usd"] = session.get("total_cost_usd", 0) + data.get("total_cost_usd", 0)
|
||||
session["duration_ms"] = session.get("duration_ms", 0) + data.get("duration_ms", 0)
|
||||
session["context_tokens"] = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
|
||||
break
|
||||
_save_sessions(sessions)
|
||||
|
||||
|
||||
@@ -7,12 +7,16 @@ import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project root is on sys.path so `src.*` imports work
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config import load_config
|
||||
from src.secrets import get_secret
|
||||
from src.credential_store import get_secret
|
||||
from src.adapters.discord_bot import create_bot, split_message
|
||||
from src.scheduler import Scheduler
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
PID_FILE = PROJECT_ROOT / "echo-core.pid"
|
||||
LOG_DIR = PROJECT_ROOT / "logs"
|
||||
|
||||
|
||||
@@ -131,17 +131,18 @@ class TestSafeEnv:
|
||||
assert "CLAUDECODE" not in env
|
||||
|
||||
def test_excludes_api_keys(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-xxx")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-xxx")
|
||||
monkeypatch.setenv("API_KEY", "sk-xxx")
|
||||
monkeypatch.setenv("SECRET", "sk-ant-xxx")
|
||||
env = _safe_env()
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert "API_KEY" not in env
|
||||
assert "SECRET" not in env
|
||||
|
||||
def test_only_passthrough_keys(self, monkeypatch):
|
||||
monkeypatch.setenv("RANDOM_SECRET", "bad")
|
||||
def test_strips_all_blocked_keys(self, monkeypatch):
|
||||
for key in claude_session._ENV_STRIP:
|
||||
monkeypatch.setenv(key, "bad")
|
||||
env = _safe_env()
|
||||
for key in env:
|
||||
assert key in claude_session._ENV_PASSTHROUGH
|
||||
for key in claude_session._ENV_STRIP:
|
||||
assert key not in env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from src.secrets import (
|
||||
from src.credential_store import (
|
||||
SERVICE,
|
||||
REQUIRED_SECRETS,
|
||||
set_secret,
|
||||
@@ -48,9 +48,9 @@ def mock_keyring():
|
||||
"""Patch keyring globally for every test so the real keyring is never touched."""
|
||||
fake = FakeKeyring()
|
||||
with (
|
||||
patch("src.secrets.keyring.get_password", side_effect=fake.get_password),
|
||||
patch("src.secrets.keyring.set_password", side_effect=fake.set_password),
|
||||
patch("src.secrets.keyring.delete_password", side_effect=fake.delete_password),
|
||||
patch("src.credential_store.keyring.get_password", side_effect=fake.get_password),
|
||||
patch("src.credential_store.keyring.set_password", side_effect=fake.set_password),
|
||||
patch("src.credential_store.keyring.delete_password", side_effect=fake.delete_password),
|
||||
):
|
||||
yield fake
|
||||
|
||||
|
||||
Reference in New Issue
Block a user