Skip to content

Commit 83e610f

Browse files
solzipclaude
andcommitted
refactor: Nice to Have 3건 — CLI 분리, 오프라인 대시보드, 구조적 로깅
Q1: CLI 893줄 → 6개 모듈로 분리 (search/stats/config/team/maintenance) S3: Dashboard chart.js CDN 의존성 제거 → CSS-only 바 차트 U5: sys.stderr.write → logging 모듈 (core/hook/loader) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fab6c0e commit 83e610f

File tree

12 files changed

+917
-823
lines changed

12 files changed

+917
-823
lines changed

src/claude_diary/cli/__init__.py

Lines changed: 9 additions & 766 deletions
Large diffs are not rendered by default.

src/claude_diary/cli/config.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Config, init, and migrate commands."""
2+
3+
import json
4+
import os
5+
from pathlib import Path
6+
7+
import claude_diary.cli as _cli
8+
9+
10+
def cmd_config(args):
11+
config = _cli.load_config()
12+
13+
if args.add_exporter:
14+
_add_exporter_interactive(config, args.add_exporter)
15+
return
16+
17+
if args.set_value:
18+
key, _, value = args.set_value.partition("=")
19+
key = key.strip()
20+
value = value.strip()
21+
if key == "lang":
22+
if value not in ("ko", "en"):
23+
print("Invalid lang: %s (use 'ko' or 'en')" % value)
24+
return
25+
config[key] = value
26+
elif key == "diary_dir":
27+
config[key] = value
28+
elif key == "timezone_offset":
29+
try:
30+
tz = int(value)
31+
if not (-12 <= tz <= 14):
32+
print("Invalid timezone_offset: %s (range: -12 to 14)" % value)
33+
return
34+
config[key] = tz
35+
except ValueError:
36+
print("Invalid timezone_offset: %s (must be integer)" % value)
37+
return
38+
else:
39+
print("Unknown config key: %s (available: lang, diary_dir, timezone_offset)" % key)
40+
return
41+
_cli.save_config(config)
42+
print("Set %s = %s" % (key, value))
43+
return
44+
45+
# Display current config
46+
print("Config path: %s" % _cli.get_config_path())
47+
print()
48+
for key, value in sorted(config.items()):
49+
if key == "exporters":
50+
print("exporters:")
51+
for name, exp in value.items():
52+
enabled = exp.get("enabled", False)
53+
status = "enabled" if enabled else "disabled"
54+
details = []
55+
for k, v in exp.items():
56+
if k == "enabled":
57+
continue
58+
if k in ("api_token", "token", "webhook_url") and isinstance(v, str) and len(v) > 8:
59+
v = v[:4] + "..." + v[-4:]
60+
details.append("%s=%s" % (k, v))
61+
detail_str = " (%s)" % ", ".join(details) if details else ""
62+
print(" %s: %s%s" % (name, status, detail_str))
63+
elif isinstance(value, dict):
64+
print("%s: %s" % (key, json.dumps(value, ensure_ascii=False)))
65+
else:
66+
print("%s: %s" % (key, value))
67+
68+
69+
def _add_exporter_interactive(config, name):
70+
if "exporters" not in config:
71+
config["exporters"] = {}
72+
73+
if name == "notion":
74+
token = input("Notion API token: ").strip()
75+
db_id = input("Notion Database ID: ").strip()
76+
config["exporters"]["notion"] = {
77+
"enabled": True,
78+
"api_token": token,
79+
"database_id": db_id,
80+
}
81+
elif name in ("slack", "discord"):
82+
url = input("%s Webhook URL: " % name.capitalize()).strip()
83+
config["exporters"][name] = {"enabled": True, "webhook_url": url}
84+
elif name == "obsidian":
85+
path = input("Obsidian vault path: ").strip()
86+
config["exporters"]["obsidian"] = {"enabled": True, "vault_path": path}
87+
elif name == "github":
88+
repo = input("GitHub repo (owner/repo): ").strip()
89+
mode = input("Mode (repo/wiki/issue) [repo]: ").strip() or "repo"
90+
config["exporters"]["github"] = {"enabled": True, "repo": repo, "mode": mode}
91+
else:
92+
print("Unknown exporter: %s" % name)
93+
return
94+
95+
_cli.save_config(config)
96+
print("Exporter '%s' added and enabled." % name)
97+
98+
99+
def cmd_init(args):
100+
config = _cli.load_config()
101+
diary_dir = os.path.expanduser(config["diary_dir"])
102+
103+
# Team mode init
104+
if hasattr(args, 'team_repo') and args.team_repo:
105+
from claude_diary.team import init_team
106+
print("Initializing claude-diary (team mode)...")
107+
print()
108+
init_team(args.team_repo)
109+
return
110+
111+
print("Initializing claude-diary...")
112+
print()
113+
114+
# Create diary directory
115+
_cli.ensure_diary_dir(diary_dir)
116+
print(" [ok] Diary directory: %s" % diary_dir)
117+
118+
# Save config
119+
_cli.save_config(config)
120+
print(" [ok] Config: %s" % _cli.get_config_path())
121+
122+
# Register Stop Hook
123+
claude_settings = os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
124+
if os.path.exists(claude_settings):
125+
try:
126+
with open(claude_settings, "r", encoding="utf-8") as f:
127+
settings = json.load(f)
128+
except (json.JSONDecodeError, IOError, ValueError):
129+
settings = {}
130+
131+
if "hooks" not in settings:
132+
settings["hooks"] = {}
133+
if "Stop" not in settings["hooks"]:
134+
settings["hooks"]["Stop"] = []
135+
136+
# Check if already registered
137+
already = False
138+
for group in settings["hooks"]["Stop"]:
139+
for h in group.get("hooks", []):
140+
if "hook.py" in h.get("command", "") or "claude_diary" in h.get("command", ""):
141+
already = True
142+
break
143+
144+
if not already:
145+
hook_cmd = "python -m claude_diary.hook"
146+
settings["hooks"]["Stop"].append({
147+
"hooks": [{"type": "command", "command": hook_cmd}]
148+
})
149+
with open(claude_settings, "w", encoding="utf-8") as f:
150+
json.dump(settings, f, indent=2, ensure_ascii=False)
151+
print(" [ok] Stop Hook registered: %s" % hook_cmd)
152+
else:
153+
print(" [ok] Stop Hook already registered")
154+
else:
155+
# Create settings.json with hook registration
156+
claude_dir = os.path.join(os.path.expanduser("~"), ".claude")
157+
Path(claude_dir).mkdir(parents=True, exist_ok=True)
158+
hook_cmd = "python -m claude_diary.hook"
159+
settings = {
160+
"hooks": {
161+
"Stop": [{"hooks": [{"type": "command", "command": hook_cmd}]}]
162+
}
163+
}
164+
with open(claude_settings, "w", encoding="utf-8") as f:
165+
json.dump(settings, f, indent=2, ensure_ascii=False)
166+
print(" [ok] Created %s with Stop Hook" % claude_settings)
167+
168+
print()
169+
print("Done! Claude Code sessions will be auto-logged.")
170+
print(" View diary: cat %s/$(date +%%Y-%%m-%%d).md" % diary_dir)
171+
172+
173+
def cmd_migrate(args):
174+
print("Migrating v1.0 environment variables to config.json...")
175+
config = _cli.migrate_from_env()
176+
print(" lang: %s" % config["lang"])
177+
print(" diary_dir: %s" % config["diary_dir"])
178+
print(" timezone_offset: %s" % config["timezone_offset"])
179+
print()
180+
print("Config saved: %s" % _cli.get_config_path())
181+
print("Note: Environment variables still work as fallback.")
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Reindex, audit, delete, and dashboard commands."""
2+
3+
import os
4+
from datetime import datetime, timezone, timedelta
5+
from pathlib import Path
6+
7+
import claude_diary.cli as _cli
8+
9+
10+
def cmd_reindex(args):
11+
from claude_diary.indexer import reindex_all
12+
config = _cli.load_config()
13+
diary_dir = os.path.expanduser(config["diary_dir"])
14+
15+
print("Rebuilding search index...")
16+
count = reindex_all(diary_dir)
17+
index_path = os.path.join(diary_dir, ".diary_index.json")
18+
print("Indexed %d sessions." % count)
19+
print("Index: %s" % index_path)
20+
21+
22+
def cmd_audit(args):
23+
from claude_diary.lib.audit import read_audit_log, verify_checksum
24+
config = _cli.load_config()
25+
diary_dir = os.path.expanduser(config["diary_dir"])
26+
27+
if args.verify:
28+
is_valid, current, last = verify_checksum(diary_dir)
29+
if is_valid:
30+
print("Checksum OK: %s" % current)
31+
else:
32+
print("WARNING: Checksum mismatch!")
33+
print(" Current: %s" % current)
34+
print(" Last log: %s" % last)
35+
print(" Source files may have been modified since last Hook execution.")
36+
return
37+
38+
entries = read_audit_log(diary_dir, days=args.days, limit=args.n)
39+
40+
if not entries:
41+
print("No audit log entries found.")
42+
return
43+
44+
print("Audit log (%d entries):" % len(entries))
45+
print()
46+
for e in entries:
47+
ts = e.get("timestamp", "")[:19]
48+
sid = e.get("session_id", "")[:8]
49+
masked = e.get("secrets_masked", 0)
50+
written = len(e.get("files_written", []))
51+
exporters = e.get("exporters_called", [])
52+
failed = e.get("exporters_failed", [])
53+
54+
line = " %s | session:%s | wrote:%d" % (ts, sid, written)
55+
if masked > 0:
56+
line += " | secrets_masked:%d" % masked
57+
if exporters:
58+
line += " | exporters:%s" % ",".join(exporters)
59+
if failed:
60+
line += " | FAILED:%s" % ",".join(failed)
61+
print(line)
62+
63+
64+
def cmd_delete(args):
65+
config = _cli.load_config()
66+
diary_dir = os.path.expanduser(config["diary_dir"])
67+
tz_offset = config.get("timezone_offset", 9)
68+
local_tz = timezone(timedelta(hours=tz_offset))
69+
70+
if args.last:
71+
today = datetime.now(local_tz).strftime("%Y-%m-%d")
72+
filepath = os.path.join(diary_dir, "%s.md" % today)
73+
if not os.path.exists(filepath):
74+
print("No diary file for today (%s)" % today)
75+
return
76+
77+
# Confirmation prompt
78+
confirm = input("Delete last session entry from %s? [y/N]: " % today).strip().lower()
79+
if confirm not in ("y", "yes"):
80+
print("Cancelled.")
81+
return
82+
83+
with open(filepath, "r", encoding="utf-8") as f:
84+
content = f.read()
85+
86+
# Split by session markers and remove last
87+
parts = content.split("### ⏰")
88+
if len(parts) <= 1:
89+
print("No session entries found in today's diary.")
90+
return
91+
92+
# Remove last entry (everything after last "### ⏰")
93+
new_content = "### ⏰".join(parts[:-1])
94+
# Remove trailing "---\n\n" if present
95+
new_content = new_content.rstrip()
96+
if new_content.endswith("---"):
97+
new_content = new_content[:-3].rstrip()
98+
new_content += "\n\n"
99+
100+
with open(filepath, "w", encoding="utf-8") as f:
101+
f.write(new_content)
102+
103+
print("Last session entry deleted from %s" % filepath)
104+
return
105+
106+
if args.session:
107+
# Search all files for session ID and remove that entry
108+
found = False
109+
for f in sorted(Path(diary_dir).glob("*.md")):
110+
try:
111+
content = f.read_text(encoding="utf-8")
112+
except Exception:
113+
continue
114+
if args.session in content:
115+
parts = content.split("### ⏰")
116+
new_parts = [parts[0]]
117+
for part in parts[1:]:
118+
if args.session not in part:
119+
new_parts.append(part)
120+
else:
121+
found = True
122+
new_content = "### ⏰".join(new_parts).rstrip() + "\n"
123+
f.write_text(new_content, encoding="utf-8")
124+
print("Session %s deleted from %s" % (args.session, f.name))
125+
break
126+
127+
if not found:
128+
print("Session '%s' not found." % args.session)
129+
return
130+
131+
print("Specify --last or --session <id>")
132+
133+
134+
def cmd_dashboard(args):
135+
from claude_diary.dashboard import generate_dashboard, serve_dashboard
136+
config = _cli.load_config()
137+
diary_dir = os.path.expanduser(config["diary_dir"])
138+
139+
path = generate_dashboard(diary_dir, months=args.months)
140+
print("Dashboard generated: %s" % path)
141+
142+
if args.serve:
143+
serve_dashboard(diary_dir, port=args.port)
144+
else:
145+
import webbrowser
146+
webbrowser.open("file://%s" % os.path.abspath(path))

0 commit comments

Comments
 (0)