Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91fa0608d0 | |||
| e43e052a32 | |||
| 6ef58472cf | |||
| 48a1c9cd1d | |||
| 343d2e4237 | |||
| c21665284a | |||
| daa4de8832 | |||
| ae958528a6 | |||
| de89f3066c | |||
| 4169f5b9a4 |
@@ -0,0 +1,25 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 16
|
||||
max_iterations: 0
|
||||
completion_promise: "The morning-report skill is fully implemented, tested, and registered"
|
||||
started_at: "2026-01-03T08:16:44Z"
|
||||
---
|
||||
|
||||
Build the morning-report skill following the design at ~/.claude/docs/plans/2025-01-02-morning-report-design.md
|
||||
|
||||
Implementation order:
|
||||
1. Create skill skeleton: ~/.claude/skills/morning-report/ with SKILL.md and config.json
|
||||
2. Build collectors: weather.py, stocks.py, infra.py (easy wins first)
|
||||
3. Build gtasks.py collector (Google Tasks API - add OAuth scope)
|
||||
4. Build news.py collector (RSS feeds)
|
||||
5. Build generate.py orchestrator and render.py templating
|
||||
6. Create systemd timer and /morning command
|
||||
7. Test end-to-end and verify output
|
||||
|
||||
Use appropriate LLM tiers:
|
||||
- Haiku: weather, stocks, infra formatting
|
||||
- Sonnet: email triage, news summarization
|
||||
- None: calendar, tasks (structured data)
|
||||
|
||||
Register in component-registry.json when complete.
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Show information about available agents.
|
||||
Usage: python3 agent-info.py [--tree] [name]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
AGENTS_DIR = CLAUDE_DIR / "agents"
|
||||
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
|
||||
|
||||
|
||||
# Agent hierarchy (from CLAUDE.md)
|
||||
HIERARCHY = {
|
||||
"personal-assistant": {
|
||||
"supervisor": None,
|
||||
"subordinates": ["master-orchestrator"]
|
||||
},
|
||||
"master-orchestrator": {
|
||||
"supervisor": "personal-assistant",
|
||||
"subordinates": ["linux-sysadmin", "k8s-orchestrator", "programmer-orchestrator"]
|
||||
},
|
||||
"linux-sysadmin": {
|
||||
"supervisor": "master-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"k8s-orchestrator": {
|
||||
"supervisor": "master-orchestrator",
|
||||
"subordinates": ["k8s-diagnostician", "argocd-operator", "prometheus-analyst", "git-operator"]
|
||||
},
|
||||
"k8s-diagnostician": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"argocd-operator": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"prometheus-analyst": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"git-operator": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"programmer-orchestrator": {
|
||||
"supervisor": "master-orchestrator",
|
||||
"subordinates": ["code-planner", "code-implementer", "code-reviewer"]
|
||||
},
|
||||
"code-planner": {
|
||||
"supervisor": "programmer-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"code-implementer": {
|
||||
"supervisor": "programmer-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"code-reviewer": {
|
||||
"supervisor": "programmer-orchestrator",
|
||||
"subordinates": []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def load_registry() -> Dict:
|
||||
"""Load component registry."""
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def find_agent_files() -> List[Path]:
|
||||
"""Find all agent markdown files."""
|
||||
if not AGENTS_DIR.exists():
|
||||
return []
|
||||
|
||||
return [f for f in AGENTS_DIR.glob("*.md")
|
||||
if f.name != "README.md"]
|
||||
|
||||
|
||||
def parse_agent_md(path: Path) -> Dict:
|
||||
"""Parse an agent markdown file for metadata."""
|
||||
try:
|
||||
content = path.read_text()
|
||||
|
||||
result = {
|
||||
"name": path.stem,
|
||||
"path": str(path.relative_to(CLAUDE_DIR)),
|
||||
"description": "",
|
||||
"model": "unknown",
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
# Parse YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 2:
|
||||
frontmatter = parts[1]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == "name":
|
||||
result["name"] = value
|
||||
elif key == "description":
|
||||
result["description"] = value
|
||||
elif key == "model":
|
||||
result["model"] = value
|
||||
elif key == "tools":
|
||||
result["tools"] = [t.strip() for t in value.split(",")]
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"name": path.stem, "error": str(e)}
|
||||
|
||||
|
||||
def get_model_emoji(model: str) -> str:
|
||||
"""Get emoji for model type."""
|
||||
return {
|
||||
"opus": "🔷",
|
||||
"sonnet": "🔶",
|
||||
"haiku": "🔸"
|
||||
}.get(model.lower(), "○")
|
||||
|
||||
|
||||
def list_agents():
|
||||
"""List all available agents."""
|
||||
registry = load_registry()
|
||||
reg_agents = registry.get("agents", {})
|
||||
|
||||
print(f"\n🤖 Available Agents ({len(reg_agents)})\n")
|
||||
|
||||
# Group by model
|
||||
by_model = {"opus": [], "sonnet": [], "haiku": [], "unknown": []}
|
||||
|
||||
for name, info in reg_agents.items():
|
||||
model = info.get("model", "unknown")
|
||||
by_model.get(model, by_model["unknown"]).append({
|
||||
"name": name,
|
||||
"description": info.get("description", "No description"),
|
||||
"triggers": info.get("triggers", [])
|
||||
})
|
||||
|
||||
for model in ["opus", "sonnet", "haiku"]:
|
||||
agents = by_model[model]
|
||||
if not agents:
|
||||
continue
|
||||
|
||||
emoji = get_model_emoji(model)
|
||||
print(f"=== {model.title()} {emoji} ===")
|
||||
|
||||
for agent in sorted(agents, key=lambda a: a["name"]):
|
||||
print(f" {agent['name']}")
|
||||
print(f" {agent['description']}")
|
||||
if agent['triggers']:
|
||||
print(f" Triggers: {', '.join(agent['triggers'][:3])}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def show_tree():
|
||||
"""Show agent hierarchy as a tree."""
|
||||
print(f"\n🌳 Agent Hierarchy\n")
|
||||
|
||||
def print_tree(name: str, prefix: str = "", is_last: bool = True):
|
||||
info = HIERARCHY.get(name, {})
|
||||
registry = load_registry()
|
||||
reg_info = registry.get("agents", {}).get(name, {})
|
||||
model = reg_info.get("model", "?")
|
||||
emoji = get_model_emoji(model)
|
||||
|
||||
connector = "└── " if is_last else "├── "
|
||||
print(f"{prefix}{connector}{name} {emoji} ({model})")
|
||||
|
||||
new_prefix = prefix + (" " if is_last else "│ ")
|
||||
subordinates = info.get("subordinates", [])
|
||||
|
||||
for i, sub in enumerate(subordinates):
|
||||
print_tree(sub, new_prefix, i == len(subordinates) - 1)
|
||||
|
||||
# Start from root
|
||||
print_tree("personal-assistant")
|
||||
print("")
|
||||
|
||||
print("Legend: 🔷 opus 🔶 sonnet 🔸 haiku")
|
||||
print("")
|
||||
|
||||
|
||||
def show_agent(name: str):
|
||||
"""Show details for a specific agent."""
|
||||
registry = load_registry()
|
||||
reg_agents = registry.get("agents", {})
|
||||
|
||||
# Find matching agent
|
||||
matches = [n for n in reg_agents.keys() if name.lower() in n.lower()]
|
||||
|
||||
if not matches:
|
||||
print(f"Agent '{name}' not found.")
|
||||
print("\nAvailable agents:")
|
||||
for n in sorted(reg_agents.keys()):
|
||||
print(f" - {n}")
|
||||
return
|
||||
|
||||
if len(matches) > 1 and name not in matches:
|
||||
print(f"Multiple matches for '{name}':")
|
||||
for m in matches:
|
||||
print(f" - {m}")
|
||||
return
|
||||
|
||||
agent_name = name if name in matches else matches[0]
|
||||
reg_info = reg_agents[agent_name]
|
||||
|
||||
print(f"\n🤖 Agent: {agent_name}\n")
|
||||
|
||||
model = reg_info.get("model", "unknown")
|
||||
print(f"Model: {model} {get_model_emoji(model)}")
|
||||
print(f"Description: {reg_info.get('description', 'No description')}")
|
||||
|
||||
# Triggers
|
||||
triggers = reg_info.get("triggers", [])
|
||||
if triggers:
|
||||
print(f"\nTriggers:")
|
||||
for t in triggers:
|
||||
print(f" - {t}")
|
||||
|
||||
# Hierarchy
|
||||
hier = HIERARCHY.get(agent_name, {})
|
||||
supervisor = hier.get("supervisor")
|
||||
subordinates = hier.get("subordinates", [])
|
||||
|
||||
print(f"\nHierarchy:")
|
||||
if supervisor:
|
||||
print(f" Supervisor: {supervisor}")
|
||||
else:
|
||||
print(f" Supervisor: (top-level)")
|
||||
|
||||
if subordinates:
|
||||
print(f" Subordinates:")
|
||||
for sub in subordinates:
|
||||
sub_info = reg_agents.get(sub, {})
|
||||
sub_model = sub_info.get("model", "?")
|
||||
print(f" - {sub} ({sub_model})")
|
||||
|
||||
# Check for agent file
|
||||
agent_file = AGENTS_DIR / f"{agent_name}.md"
|
||||
if agent_file.exists():
|
||||
print(f"\nFile: agents/{agent_name}.md")
|
||||
file_info = parse_agent_md(agent_file)
|
||||
if file_info.get("tools"):
|
||||
print(f"Tools: {', '.join(file_info['tools'])}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Show agent information")
|
||||
parser.add_argument("name", nargs="?", help="Agent name to show details")
|
||||
parser.add_argument("--tree", "-t", action="store_true",
|
||||
help="Show agent hierarchy tree")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List all agents")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.tree:
|
||||
show_tree()
|
||||
elif args.name and not args.list:
|
||||
show_agent(args.name)
|
||||
else:
|
||||
list_agents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -51,6 +51,36 @@ _claude_upgrade() {
|
||||
COMPREPLY=($(compgen -W "--check --backup --apply --help" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_workflow() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--category --list" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_skill_info() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--scripts --list" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_agent_info() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--tree --list" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_diff() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--backup --list --json" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_template() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--list --create --use --delete" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_memory_add() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
local prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
@@ -87,6 +117,11 @@ complete -F _claude_debug debug.sh
|
||||
complete -F _claude_export session-export.py
|
||||
complete -F _claude_mcp_status mcp-status.sh
|
||||
complete -F _claude_upgrade upgrade.sh
|
||||
complete -F _claude_workflow workflow-info.py
|
||||
complete -F _claude_skill_info skill-info.py
|
||||
complete -F _claude_agent_info agent-info.py
|
||||
complete -F _claude_diff config-diff.py
|
||||
complete -F _claude_template session-template.py
|
||||
|
||||
# Alias completions for convenience
|
||||
alias claude-validate='~/.claude/automation/validate-setup.sh'
|
||||
@@ -106,7 +141,13 @@ alias claude-debug='~/.claude/automation/debug.sh'
|
||||
alias claude-export='python3 ~/.claude/automation/session-export.py'
|
||||
alias claude-mcp='~/.claude/automation/mcp-status.sh'
|
||||
alias claude-upgrade='~/.claude/automation/upgrade.sh'
|
||||
alias claude-workflow='python3 ~/.claude/automation/workflow-info.py'
|
||||
alias claude-skill='python3 ~/.claude/automation/skill-info.py'
|
||||
alias claude-agent='python3 ~/.claude/automation/agent-info.py'
|
||||
alias claude-diff='python3 ~/.claude/automation/config-diff.py'
|
||||
alias claude-template='python3 ~/.claude/automation/session-template.py'
|
||||
|
||||
echo "Claude Code completions loaded. Available aliases:"
|
||||
echo " claude-{validate,status,backup,restore,clean,memory-add,memory-list}"
|
||||
echo " claude-{search,history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " claude-{validate,status,backup,restore,clean,memory-add,memory-list,search}"
|
||||
echo " claude-{history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " claude-{workflow,skill,agent,diff,template}"
|
||||
|
||||
@@ -107,6 +107,48 @@ _claude_upgrade() {
|
||||
'--help[Show help]'
|
||||
}
|
||||
|
||||
# Workflow completion
|
||||
_claude_workflow() {
|
||||
_arguments \
|
||||
'--category[Filter by category]:category:(health deploy incidents sysadmin)' \
|
||||
'--list[List all workflows]' \
|
||||
'*:name:'
|
||||
}
|
||||
|
||||
# Skill info completion
|
||||
_claude_skill_info() {
|
||||
_arguments \
|
||||
'--scripts[Show scripts in listing]' \
|
||||
'--list[List all skills]' \
|
||||
'*:name:'
|
||||
}
|
||||
|
||||
# Agent info completion
|
||||
_claude_agent_info() {
|
||||
_arguments \
|
||||
'--tree[Show hierarchy tree]' \
|
||||
'--list[List all agents]' \
|
||||
'*:name:'
|
||||
}
|
||||
|
||||
# Config diff completion
|
||||
_claude_diff() {
|
||||
_arguments \
|
||||
'--backup[Backup file]:file:_files' \
|
||||
'--list[List backups]' \
|
||||
'--json[JSON output]'
|
||||
}
|
||||
|
||||
# Template completion
|
||||
_claude_template() {
|
||||
_arguments \
|
||||
'--list[List templates]' \
|
||||
'--create[Create template]:name:' \
|
||||
'--use[Use template]:name:' \
|
||||
'--delete[Delete template]:name:' \
|
||||
'--non-interactive[Non-interactive mode]'
|
||||
}
|
||||
|
||||
# Register completions
|
||||
compdef _memory_add memory-add.py
|
||||
compdef _memory_list memory-list.py
|
||||
@@ -118,6 +160,11 @@ compdef _claude_debug debug.sh
|
||||
compdef _claude_export session-export.py
|
||||
compdef _claude_mcp_status mcp-status.sh
|
||||
compdef _claude_upgrade upgrade.sh
|
||||
compdef _claude_workflow workflow-info.py
|
||||
compdef _claude_skill_info skill-info.py
|
||||
compdef _claude_agent_info agent-info.py
|
||||
compdef _claude_diff config-diff.py
|
||||
compdef _claude_template session-template.py
|
||||
|
||||
# Aliases
|
||||
alias claude-validate='~/.claude/automation/validate-setup.sh'
|
||||
@@ -137,7 +184,13 @@ alias claude-debug='~/.claude/automation/debug.sh'
|
||||
alias claude-export='python3 ~/.claude/automation/session-export.py'
|
||||
alias claude-mcp='~/.claude/automation/mcp-status.sh'
|
||||
alias claude-upgrade='~/.claude/automation/upgrade.sh'
|
||||
alias claude-workflow='python3 ~/.claude/automation/workflow-info.py'
|
||||
alias claude-skill='python3 ~/.claude/automation/skill-info.py'
|
||||
alias claude-agent='python3 ~/.claude/automation/agent-info.py'
|
||||
alias claude-diff='python3 ~/.claude/automation/config-diff.py'
|
||||
alias claude-template='python3 ~/.claude/automation/session-template.py'
|
||||
|
||||
echo "Claude Code completions loaded (zsh)"
|
||||
echo " Aliases: claude-{validate,status,backup,restore,clean,memory-add,memory-list}"
|
||||
echo " claude-{search,history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " Aliases: claude-{validate,status,backup,restore,clean,memory-add,memory-list,search}"
|
||||
echo " claude-{history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " claude-{workflow,skill,agent,diff,template}"
|
||||
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare current configuration with backup or default.
|
||||
Usage: python3 config-diff.py [--backup FILE] [--default] [--json]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
BACKUP_DIR = CLAUDE_DIR / "backups"
|
||||
|
||||
|
||||
def load_json_safe(path: Path) -> Optional[Dict]:
|
||||
"""Load JSON file safely."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_config_files() -> List[Path]:
|
||||
"""Get list of configuration files to compare."""
|
||||
patterns = [
|
||||
"CLAUDE.md",
|
||||
"VERSION",
|
||||
".claude-plugin/plugin.json",
|
||||
"hooks/hooks.json",
|
||||
"state/*.json",
|
||||
"state/**/*.json",
|
||||
]
|
||||
|
||||
files = []
|
||||
for pattern in patterns:
|
||||
files.extend(CLAUDE_DIR.glob(pattern))
|
||||
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def extract_backup(backup_path: Path) -> Path:
|
||||
"""Extract backup to temporary directory."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
with tarfile.open(backup_path, "r:gz") as tar:
|
||||
tar.extractall(temp_dir)
|
||||
|
||||
return temp_dir
|
||||
|
||||
|
||||
def compare_files(current: Path, other: Path) -> Dict:
|
||||
"""Compare two files and return differences."""
|
||||
result = {
|
||||
"current_exists": current.exists(),
|
||||
"other_exists": other.exists(),
|
||||
"same": False,
|
||||
"diff": None
|
||||
}
|
||||
|
||||
if not current.exists() and not other.exists():
|
||||
result["same"] = True
|
||||
return result
|
||||
|
||||
if not current.exists() or not other.exists():
|
||||
return result
|
||||
|
||||
# Compare content
|
||||
try:
|
||||
current_content = current.read_text()
|
||||
other_content = other.read_text()
|
||||
|
||||
if current_content == other_content:
|
||||
result["same"] = True
|
||||
return result
|
||||
|
||||
# For JSON files, do structural comparison
|
||||
if current.suffix == ".json":
|
||||
try:
|
||||
current_json = json.loads(current_content)
|
||||
other_json = json.loads(other_content)
|
||||
|
||||
# Find differences
|
||||
result["diff"] = {
|
||||
"type": "json",
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"changed": []
|
||||
}
|
||||
|
||||
# Simple key comparison for top-level
|
||||
current_keys = set(current_json.keys()) if isinstance(current_json, dict) else set()
|
||||
other_keys = set(other_json.keys()) if isinstance(other_json, dict) else set()
|
||||
|
||||
result["diff"]["added"] = list(current_keys - other_keys)
|
||||
result["diff"]["removed"] = list(other_keys - current_keys)
|
||||
|
||||
# Check for changed values
|
||||
for key in current_keys & other_keys:
|
||||
if current_json.get(key) != other_json.get(key):
|
||||
result["diff"]["changed"].append(key)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Text comparison
|
||||
current_lines = current_content.split("\n")
|
||||
other_lines = other_content.split("\n")
|
||||
|
||||
result["diff"] = {
|
||||
"type": "text",
|
||||
"current_lines": len(current_lines),
|
||||
"other_lines": len(other_lines),
|
||||
"line_diff": len(current_lines) - len(other_lines)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_latest_backup() -> Optional[Path]:
|
||||
"""Get the most recent backup file."""
|
||||
if not BACKUP_DIR.exists():
|
||||
return None
|
||||
|
||||
backups = list(BACKUP_DIR.glob("*.tar.gz"))
|
||||
if not backups:
|
||||
return None
|
||||
|
||||
return max(backups, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def compare_with_backup(backup_path: Path) -> Dict[str, Dict]:
|
||||
"""Compare current config with a backup."""
|
||||
temp_dir = extract_backup(backup_path)
|
||||
|
||||
try:
|
||||
results = {}
|
||||
config_files = get_config_files()
|
||||
|
||||
for current_file in config_files:
|
||||
rel_path = current_file.relative_to(CLAUDE_DIR)
|
||||
backup_file = temp_dir / rel_path
|
||||
|
||||
results[str(rel_path)] = compare_files(current_file, backup_file)
|
||||
|
||||
# Check for files in backup that don't exist in current
|
||||
for backup_file in temp_dir.rglob("*.json"):
|
||||
rel_path = backup_file.relative_to(temp_dir)
|
||||
current_file = CLAUDE_DIR / rel_path
|
||||
|
||||
if str(rel_path) not in results:
|
||||
results[str(rel_path)] = compare_files(current_file, backup_file)
|
||||
|
||||
return results
|
||||
|
||||
finally:
|
||||
# Cleanup temp directory
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def format_results(results: Dict[str, Dict], json_output: bool = False) -> str:
|
||||
"""Format comparison results."""
|
||||
if json_output:
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
lines = ["\n📊 Configuration Diff\n"]
|
||||
|
||||
# Group by status
|
||||
same = []
|
||||
added = []
|
||||
removed = []
|
||||
changed = []
|
||||
|
||||
for path, info in results.items():
|
||||
if info.get("same"):
|
||||
same.append(path)
|
||||
elif info.get("current_exists") and not info.get("other_exists"):
|
||||
added.append(path)
|
||||
elif not info.get("current_exists") and info.get("other_exists"):
|
||||
removed.append(path)
|
||||
else:
|
||||
changed.append((path, info))
|
||||
|
||||
# Summary
|
||||
lines.append(f"Unchanged: {len(same)}")
|
||||
lines.append(f"Added: {len(added)}")
|
||||
lines.append(f"Removed: {len(removed)}")
|
||||
lines.append(f"Changed: {len(changed)}")
|
||||
lines.append("")
|
||||
|
||||
# Details
|
||||
if added:
|
||||
lines.append("=== Added Files ===")
|
||||
for path in added:
|
||||
lines.append(f" + {path}")
|
||||
lines.append("")
|
||||
|
||||
if removed:
|
||||
lines.append("=== Removed Files ===")
|
||||
for path in removed:
|
||||
lines.append(f" - {path}")
|
||||
lines.append("")
|
||||
|
||||
if changed:
|
||||
lines.append("=== Changed Files ===")
|
||||
for path, info in changed:
|
||||
lines.append(f" ~ {path}")
|
||||
diff = info.get("diff", {})
|
||||
if diff:
|
||||
if diff.get("type") == "json":
|
||||
if diff.get("added"):
|
||||
lines.append(f" Added keys: {', '.join(diff['added'][:5])}")
|
||||
if diff.get("removed"):
|
||||
lines.append(f" Removed keys: {', '.join(diff['removed'][:5])}")
|
||||
if diff.get("changed"):
|
||||
lines.append(f" Changed keys: {', '.join(diff['changed'][:5])}")
|
||||
elif diff.get("type") == "text":
|
||||
lines.append(f" Lines: {diff['other_lines']} -> {diff['current_lines']}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare configuration with backup")
|
||||
parser.add_argument("--backup", "-b", type=str,
|
||||
help="Backup file to compare with (default: latest)")
|
||||
parser.add_argument("--list", "-l", action="store_true",
|
||||
help="List available backups")
|
||||
parser.add_argument("--json", "-j", action="store_true",
|
||||
help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
if not BACKUP_DIR.exists():
|
||||
print("No backups directory found.")
|
||||
return 0
|
||||
|
||||
backups = sorted(BACKUP_DIR.glob("*.tar.gz"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if not backups:
|
||||
print("No backups found.")
|
||||
return 0
|
||||
|
||||
print("\n📦 Available Backups\n")
|
||||
for b in backups[:10]:
|
||||
mtime = datetime.fromtimestamp(b.stat().st_mtime)
|
||||
size = b.stat().st_size
|
||||
if size > 1024 * 1024:
|
||||
size_str = f"{size / 1024 / 1024:.1f}M"
|
||||
else:
|
||||
size_str = f"{size / 1024:.1f}K"
|
||||
print(f" {b.name:<40} {size_str:<8} {mtime:%Y-%m-%d %H:%M}")
|
||||
|
||||
return 0
|
||||
|
||||
# Get backup to compare
|
||||
if args.backup:
|
||||
backup_path = Path(args.backup)
|
||||
if not backup_path.exists():
|
||||
backup_path = BACKUP_DIR / args.backup
|
||||
if not backup_path.exists():
|
||||
print(f"Backup not found: {args.backup}")
|
||||
return 1
|
||||
else:
|
||||
backup_path = get_latest_backup()
|
||||
if not backup_path:
|
||||
print("No backups found. Create one with: claude-backup")
|
||||
return 1
|
||||
|
||||
print(f"Comparing with: {backup_path.name}")
|
||||
|
||||
results = compare_with_backup(backup_path)
|
||||
print(format_results(results, args.json))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Manage session templates for common workflows.
|
||||
Usage: python3 session-template.py [--list|--create NAME|--use NAME|--delete NAME]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
TEMPLATES_DIR = CLAUDE_DIR / "state" / "personal-assistant" / "templates"
|
||||
|
||||
|
||||
def ensure_templates_dir():
|
||||
"""Ensure templates directory exists."""
|
||||
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_template(name: str) -> Optional[Dict]:
|
||||
"""Load a template by name."""
|
||||
template_file = TEMPLATES_DIR / f"{name}.json"
|
||||
try:
|
||||
with open(template_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def save_template(name: str, template: Dict):
|
||||
"""Save a template."""
|
||||
ensure_templates_dir()
|
||||
template_file = TEMPLATES_DIR / f"{name}.json"
|
||||
with open(template_file, "w") as f:
|
||||
json.dump(template, f, indent=2)
|
||||
|
||||
|
||||
def list_templates():
|
||||
"""List available templates."""
|
||||
ensure_templates_dir()
|
||||
|
||||
templates = list(TEMPLATES_DIR.glob("*.json"))
|
||||
|
||||
if not templates:
|
||||
print("\nNo templates found. Create one with --create <name>")
|
||||
print("\nBuilt-in templates you can create:")
|
||||
print(" - daily-standup: Morning status check and planning")
|
||||
print(" - code-review: Review code changes")
|
||||
print(" - troubleshoot: Debug an issue")
|
||||
print(" - deploy: Deployment workflow")
|
||||
return
|
||||
|
||||
print(f"\n📋 Session Templates ({len(templates)})\n")
|
||||
|
||||
for t in sorted(templates):
|
||||
template = load_template(t.stem)
|
||||
if template:
|
||||
desc = template.get("description", "No description")
|
||||
category = template.get("category", "general")
|
||||
print(f" {t.stem}")
|
||||
print(f" Category: {category}")
|
||||
print(f" {desc}")
|
||||
print("")
|
||||
|
||||
|
||||
def create_template(name: str, interactive: bool = True):
|
||||
"""Create a new template."""
|
||||
ensure_templates_dir()
|
||||
|
||||
# Check for built-in templates
|
||||
builtin_templates = {
|
||||
"daily-standup": {
|
||||
"name": "daily-standup",
|
||||
"description": "Morning status check and planning",
|
||||
"category": "routine",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": ["/status"],
|
||||
"checklist": [
|
||||
"Check system health",
|
||||
"Review pending items",
|
||||
"Check calendar",
|
||||
"Plan priorities"
|
||||
],
|
||||
"prompt_template": "Good morning! Let's start with a quick status check and plan the day."
|
||||
},
|
||||
"code-review": {
|
||||
"name": "code-review",
|
||||
"description": "Review code changes in a project",
|
||||
"category": "development",
|
||||
"context_level": "comprehensive",
|
||||
"initial_commands": [],
|
||||
"checklist": [
|
||||
"Understand the change scope",
|
||||
"Check for issues",
|
||||
"Verify tests",
|
||||
"Suggest improvements"
|
||||
],
|
||||
"prompt_template": "Please review the recent changes in {project}. Focus on {focus_areas}."
|
||||
},
|
||||
"troubleshoot": {
|
||||
"name": "troubleshoot",
|
||||
"description": "Debug an issue systematically",
|
||||
"category": "debugging",
|
||||
"context_level": "comprehensive",
|
||||
"initial_commands": ["/debug"],
|
||||
"checklist": [
|
||||
"Gather symptoms",
|
||||
"Check logs",
|
||||
"Identify root cause",
|
||||
"Implement fix",
|
||||
"Verify resolution"
|
||||
],
|
||||
"prompt_template": "I'm experiencing {issue}. Let's debug this systematically."
|
||||
},
|
||||
"deploy": {
|
||||
"name": "deploy",
|
||||
"description": "Deploy application to environment",
|
||||
"category": "operations",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": ["/k8s:cluster-status"],
|
||||
"checklist": [
|
||||
"Verify cluster health",
|
||||
"Check application readiness",
|
||||
"Perform deployment",
|
||||
"Verify deployment",
|
||||
"Monitor for issues"
|
||||
],
|
||||
"prompt_template": "Deploy {app} to {environment}."
|
||||
}
|
||||
}
|
||||
|
||||
if name in builtin_templates:
|
||||
template = builtin_templates[name]
|
||||
save_template(name, template)
|
||||
print(f"✓ Created template from built-in: {name}")
|
||||
print(f" {template['description']}")
|
||||
return
|
||||
|
||||
if interactive:
|
||||
print(f"\nCreating new template: {name}\n")
|
||||
|
||||
template = {
|
||||
"name": name,
|
||||
"created": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# Get details interactively
|
||||
template["description"] = input("Description: ").strip() or "No description"
|
||||
template["category"] = input("Category [general]: ").strip() or "general"
|
||||
template["context_level"] = input("Context level [moderate]: ").strip() or "moderate"
|
||||
|
||||
print("Initial commands (comma-separated, e.g., /status,/debug): ")
|
||||
commands = input("> ").strip()
|
||||
template["initial_commands"] = [c.strip() for c in commands.split(",") if c.strip()]
|
||||
|
||||
print("Checklist items (one per line, empty line to finish):")
|
||||
checklist = []
|
||||
while True:
|
||||
item = input(" - ").strip()
|
||||
if not item:
|
||||
break
|
||||
checklist.append(item)
|
||||
template["checklist"] = checklist
|
||||
|
||||
template["prompt_template"] = input("Prompt template: ").strip()
|
||||
|
||||
save_template(name, template)
|
||||
print(f"\n✓ Template '{name}' created successfully")
|
||||
else:
|
||||
# Create minimal template
|
||||
template = {
|
||||
"name": name,
|
||||
"description": "Custom template",
|
||||
"category": "custom",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": [],
|
||||
"checklist": [],
|
||||
"prompt_template": "",
|
||||
"created": datetime.now().isoformat()
|
||||
}
|
||||
save_template(name, template)
|
||||
print(f"✓ Created empty template: {name}")
|
||||
print(f" Edit: {TEMPLATES_DIR / f'{name}.json'}")
|
||||
|
||||
|
||||
def use_template(name: str):
|
||||
"""Display template for use."""
|
||||
template = load_template(name)
|
||||
|
||||
if not template:
|
||||
print(f"Template '{name}' not found.")
|
||||
list_templates()
|
||||
return
|
||||
|
||||
print(f"\n📋 Template: {name}\n")
|
||||
print(f"Description: {template.get('description', 'No description')}")
|
||||
print(f"Category: {template.get('category', 'general')}")
|
||||
print(f"Context: {template.get('context_level', 'moderate')}")
|
||||
|
||||
commands = template.get("initial_commands", [])
|
||||
if commands:
|
||||
print(f"\nInitial commands:")
|
||||
for cmd in commands:
|
||||
print(f" {cmd}")
|
||||
|
||||
checklist = template.get("checklist", [])
|
||||
if checklist:
|
||||
print(f"\nChecklist:")
|
||||
for i, item in enumerate(checklist, 1):
|
||||
print(f" [ ] {item}")
|
||||
|
||||
prompt = template.get("prompt_template", "")
|
||||
if prompt:
|
||||
print(f"\nPrompt template:")
|
||||
print(f" {prompt}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def delete_template(name: str):
|
||||
"""Delete a template."""
|
||||
template_file = TEMPLATES_DIR / f"{name}.json"
|
||||
|
||||
if not template_file.exists():
|
||||
print(f"Template '{name}' not found.")
|
||||
return
|
||||
|
||||
template_file.unlink()
|
||||
print(f"✓ Deleted template: {name}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Manage session templates")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List templates")
|
||||
parser.add_argument("--create", "-c", type=str, help="Create a template")
|
||||
parser.add_argument("--use", "-u", type=str, help="Use a template")
|
||||
parser.add_argument("--delete", "-d", type=str, help="Delete a template")
|
||||
parser.add_argument("--non-interactive", "-n", action="store_true",
|
||||
help="Non-interactive mode for --create")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.create:
|
||||
create_template(args.create, interactive=not args.non_interactive)
|
||||
elif args.use:
|
||||
use_template(args.use)
|
||||
elif args.delete:
|
||||
delete_template(args.delete)
|
||||
else:
|
||||
list_templates()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Show information about available skills.
|
||||
Usage: python3 skill-info.py [--scripts] [name]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
SKILLS_DIR = CLAUDE_DIR / "skills"
|
||||
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
|
||||
|
||||
|
||||
def load_registry() -> Dict:
|
||||
"""Load component registry."""
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def find_skills() -> List[Path]:
|
||||
"""Find all skill directories with SKILL.md."""
|
||||
if not SKILLS_DIR.exists():
|
||||
return []
|
||||
|
||||
return [d for d in SKILLS_DIR.iterdir()
|
||||
if d.is_dir() and (d / "SKILL.md").exists()]
|
||||
|
||||
|
||||
def parse_skill_md(path: Path) -> Dict:
|
||||
"""Parse a SKILL.md file for metadata."""
|
||||
try:
|
||||
content = path.read_text()
|
||||
|
||||
result = {
|
||||
"name": path.parent.name,
|
||||
"path": str(path.relative_to(CLAUDE_DIR)),
|
||||
"description": "",
|
||||
"allowed_tools": [],
|
||||
}
|
||||
|
||||
# Parse YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 2:
|
||||
frontmatter = parts[1]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == "description":
|
||||
result["description"] = value
|
||||
elif key == "allowed-tools":
|
||||
result["allowed_tools"] = [t.strip() for t in value.split(",")]
|
||||
|
||||
# Get first paragraph as description if not in frontmatter
|
||||
if not result["description"]:
|
||||
body = content.split("---")[-1] if "---" in content else content
|
||||
lines = body.strip().split("\n\n")
|
||||
for para in lines:
|
||||
if para.strip() and not para.startswith("#"):
|
||||
result["description"] = para.strip()[:200]
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"name": path.parent.name, "error": str(e)}
|
||||
|
||||
|
||||
def get_skill_scripts(skill_dir: Path) -> List[str]:
|
||||
"""Get list of scripts in a skill's scripts/ directory."""
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
if not scripts_dir.exists():
|
||||
return []
|
||||
|
||||
scripts = []
|
||||
for f in scripts_dir.iterdir():
|
||||
if f.is_file() and f.suffix in [".py", ".sh"]:
|
||||
scripts.append(f.name)
|
||||
return sorted(scripts)
|
||||
|
||||
|
||||
def get_skill_references(skill_dir: Path) -> List[str]:
|
||||
"""Get list of reference files in a skill's references/ directory."""
|
||||
refs_dir = skill_dir / "references"
|
||||
if not refs_dir.exists():
|
||||
return []
|
||||
|
||||
refs = []
|
||||
for f in refs_dir.iterdir():
|
||||
if f.is_file():
|
||||
refs.append(f.name)
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def list_skills(show_scripts: bool = False):
|
||||
"""List all available skills."""
|
||||
registry = load_registry()
|
||||
reg_skills = registry.get("skills", {})
|
||||
|
||||
skills = find_skills()
|
||||
|
||||
if not skills:
|
||||
print("No skills found.")
|
||||
return
|
||||
|
||||
print(f"\n🎯 Available Skills ({len(skills)})\n")
|
||||
|
||||
for skill_dir in sorted(skills):
|
||||
name = skill_dir.name
|
||||
skill_info = parse_skill_md(skill_dir / "SKILL.md")
|
||||
reg_info = reg_skills.get(name, {})
|
||||
|
||||
desc = skill_info.get("description", reg_info.get("description", "No description"))
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
|
||||
print(f" {name}")
|
||||
print(f" {desc}")
|
||||
|
||||
if show_scripts:
|
||||
scripts = get_skill_scripts(skill_dir)
|
||||
if scripts:
|
||||
print(f" Scripts: {', '.join(scripts)}")
|
||||
|
||||
triggers = reg_info.get("triggers", [])
|
||||
if triggers:
|
||||
trigger_str = ", ".join(triggers[:4])
|
||||
if len(triggers) > 4:
|
||||
trigger_str += f" (+{len(triggers)-4} more)"
|
||||
print(f" Triggers: {trigger_str}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def show_skill(name: str):
|
||||
"""Show details for a specific skill."""
|
||||
# Find matching skill
|
||||
skills = find_skills()
|
||||
matches = [s for s in skills if name.lower() in s.name.lower()]
|
||||
|
||||
if not matches:
|
||||
print(f"Skill '{name}' not found.")
|
||||
print("\nAvailable skills:")
|
||||
for s in sorted(skills):
|
||||
print(f" - {s.name}")
|
||||
return
|
||||
|
||||
if len(matches) > 1 and not any(s.name == name for s in matches):
|
||||
print(f"Multiple matches for '{name}':")
|
||||
for s in matches:
|
||||
print(f" - {s.name}")
|
||||
return
|
||||
|
||||
skill_dir = next((s for s in matches if s.name == name), matches[0])
|
||||
skill_info = parse_skill_md(skill_dir / "SKILL.md")
|
||||
|
||||
registry = load_registry()
|
||||
reg_info = registry.get("skills", {}).get(skill_dir.name, {})
|
||||
|
||||
print(f"\n🎯 Skill: {skill_dir.name}\n")
|
||||
print(f"Path: {skill_dir.relative_to(CLAUDE_DIR)}/")
|
||||
print(f"Description: {skill_info.get('description', 'No description')}")
|
||||
|
||||
# Allowed tools
|
||||
allowed = skill_info.get("allowed_tools", [])
|
||||
if allowed:
|
||||
print(f"\nAllowed Tools: {', '.join(allowed)}")
|
||||
|
||||
# Triggers
|
||||
triggers = reg_info.get("triggers", [])
|
||||
if triggers:
|
||||
print(f"\nTriggers:")
|
||||
for t in triggers:
|
||||
print(f" - {t}")
|
||||
|
||||
# Scripts
|
||||
scripts = get_skill_scripts(skill_dir)
|
||||
if scripts:
|
||||
print(f"\nScripts:")
|
||||
for s in scripts:
|
||||
script_path = skill_dir / "scripts" / s
|
||||
executable = "✓" if script_path.stat().st_mode & 0o111 else "○"
|
||||
print(f" {executable} {s}")
|
||||
|
||||
# References
|
||||
refs = get_skill_references(skill_dir)
|
||||
if refs:
|
||||
print(f"\nReferences:")
|
||||
for r in refs:
|
||||
print(f" - {r}")
|
||||
|
||||
# Registry script
|
||||
if "script" in reg_info:
|
||||
print(f"\nRegistry Script: {reg_info['script']}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Show skill information")
|
||||
parser.add_argument("name", nargs="?", help="Skill name to show details")
|
||||
parser.add_argument("--scripts", "-s", action="store_true",
|
||||
help="Show scripts in listing")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List all skills")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.name and not args.list:
|
||||
show_skill(args.name)
|
||||
else:
|
||||
list_skills(args.scripts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -85,6 +85,41 @@ else
|
||||
fail "session-export.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 10: workflow-info.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/workflow-info.py" 2>/dev/null; then
|
||||
pass "workflow-info.py syntax valid"
|
||||
else
|
||||
fail "workflow-info.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 11: skill-info.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/skill-info.py" 2>/dev/null; then
|
||||
pass "skill-info.py syntax valid"
|
||||
else
|
||||
fail "skill-info.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 12: agent-info.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/agent-info.py" 2>/dev/null; then
|
||||
pass "agent-info.py syntax valid"
|
||||
else
|
||||
fail "agent-info.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 13: config-diff.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/config-diff.py" 2>/dev/null; then
|
||||
pass "config-diff.py syntax valid"
|
||||
else
|
||||
fail "config-diff.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 14: session-template.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/session-template.py" 2>/dev/null; then
|
||||
pass "session-template.py syntax valid"
|
||||
else
|
||||
fail "session-template.py syntax error"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Skill Scripts ==="
|
||||
|
||||
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
List and describe available workflows.
|
||||
Usage: python3 workflow-info.py [--category CAT] [name]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
WORKFLOWS_DIR = CLAUDE_DIR / "workflows"
|
||||
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
|
||||
|
||||
|
||||
def load_registry() -> Dict:
|
||||
"""Load component registry."""
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def find_workflow_files() -> List[Path]:
|
||||
"""Find all workflow YAML files."""
|
||||
if not WORKFLOWS_DIR.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
for pattern in ["*.yaml", "*.yml", "**/*.yaml", "**/*.yml"]:
|
||||
files.extend(WORKFLOWS_DIR.glob(pattern))
|
||||
|
||||
# Filter out README and other non-workflow files
|
||||
return [f for f in files if f.name not in ["README.md"]]
|
||||
|
||||
|
||||
def parse_workflow(path: Path) -> Optional[Dict]:
|
||||
"""Parse a workflow YAML file."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
# Handle YAML front matter or full YAML
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 2:
|
||||
return yaml.safe_load(parts[1])
|
||||
return yaml.safe_load(content)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_workflow_category(path: Path) -> str:
|
||||
"""Get workflow category from path."""
|
||||
rel_path = path.relative_to(WORKFLOWS_DIR)
|
||||
if len(rel_path.parts) > 1:
|
||||
return rel_path.parts[0]
|
||||
return "general"
|
||||
|
||||
|
||||
def list_workflows(category: Optional[str] = None):
|
||||
"""List all available workflows."""
|
||||
registry = load_registry()
|
||||
workflows = registry.get("workflows", {})
|
||||
|
||||
# Group by category
|
||||
categories: Dict[str, List] = {}
|
||||
|
||||
for name, info in workflows.items():
|
||||
cat = name.split("/")[0] if "/" in name else "general"
|
||||
if category and cat != category:
|
||||
continue
|
||||
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
|
||||
categories[cat].append({
|
||||
"name": name,
|
||||
"description": info.get("description", "No description"),
|
||||
"triggers": info.get("triggers", [])
|
||||
})
|
||||
|
||||
if not categories:
|
||||
print("No workflows found.")
|
||||
return
|
||||
|
||||
print(f"\n📋 Available Workflows\n")
|
||||
|
||||
for cat in sorted(categories.keys()):
|
||||
print(f"=== {cat.title()} ===")
|
||||
for wf in categories[cat]:
|
||||
print(f" {wf['name']}")
|
||||
print(f" {wf['description']}")
|
||||
if wf['triggers']:
|
||||
print(f" Triggers: {', '.join(wf['triggers'][:3])}")
|
||||
print("")
|
||||
|
||||
|
||||
def show_workflow(name: str):
|
||||
"""Show details for a specific workflow."""
|
||||
registry = load_registry()
|
||||
workflows = registry.get("workflows", {})
|
||||
|
||||
# Find matching workflow
|
||||
matches = [n for n in workflows.keys() if name in n]
|
||||
|
||||
if not matches:
|
||||
print(f"Workflow '{name}' not found.")
|
||||
print("\nAvailable workflows:")
|
||||
for n in sorted(workflows.keys()):
|
||||
print(f" - {n}")
|
||||
return
|
||||
|
||||
if len(matches) > 1 and name not in matches:
|
||||
print(f"Multiple matches for '{name}':")
|
||||
for m in matches:
|
||||
print(f" - {m}")
|
||||
return
|
||||
|
||||
wf_name = name if name in matches else matches[0]
|
||||
wf_info = workflows[wf_name]
|
||||
|
||||
print(f"\n📋 Workflow: {wf_name}\n")
|
||||
print(f"Description: {wf_info.get('description', 'No description')}")
|
||||
|
||||
triggers = wf_info.get("triggers", [])
|
||||
if triggers:
|
||||
print(f"\nTriggers:")
|
||||
for t in triggers:
|
||||
print(f" - {t}")
|
||||
|
||||
# Try to find and show the actual workflow file
|
||||
possible_paths = [
|
||||
WORKFLOWS_DIR / f"{wf_name}.yaml",
|
||||
WORKFLOWS_DIR / f"{wf_name}.yml",
|
||||
WORKFLOWS_DIR / wf_name / "workflow.yaml",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
wf_data = parse_workflow(path)
|
||||
if wf_data:
|
||||
print(f"\nFile: {path.relative_to(CLAUDE_DIR)}")
|
||||
|
||||
if "steps" in wf_data:
|
||||
print(f"\nSteps:")
|
||||
for i, step in enumerate(wf_data["steps"], 1):
|
||||
step_name = step.get("name", f"Step {i}")
|
||||
agent = step.get("agent", "unknown")
|
||||
print(f" {i}. {step_name} (agent: {agent})")
|
||||
|
||||
if "trigger" in wf_data:
|
||||
trigger = wf_data["trigger"]
|
||||
if isinstance(trigger, dict):
|
||||
if trigger.get("schedule"):
|
||||
print(f"\nSchedule: {trigger['schedule']}")
|
||||
if trigger.get("manual"):
|
||||
print("Manual trigger: Yes")
|
||||
break
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="List and describe workflows")
|
||||
parser.add_argument("name", nargs="?", help="Workflow name to show details")
|
||||
parser.add_argument("--category", "-c", type=str, help="Filter by category")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List all workflows")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.name and not args.list:
|
||||
show_workflow(args.name)
|
||||
else:
|
||||
list_workflows(args.category)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,6 +19,11 @@ Slash commands for quick actions. User-invoked (type `/command` to trigger).
|
||||
| `/debug` | `/diag`, `/diagnose` | Debug and troubleshoot config |
|
||||
| `/export` | `/session-export`, `/share` | Export session for sharing |
|
||||
| `/mcp-status` | `/mcp`, `/integrations` | Check MCP integrations |
|
||||
| `/workflow` | `/workflows`, `/wf` | List and describe workflows |
|
||||
| `/skill-info` | `/skill`, `/skills-info` | Show skill information |
|
||||
| `/agent-info` | `/agent`, `/agents` | Show agent information |
|
||||
| `/diff` | `/config-diff`, `/compare` | Compare config with backup |
|
||||
| `/template` | `/templates`, `/session-template` | Manage session templates |
|
||||
| `/maintain` | `/maintenance`, `/admin` | Configuration maintenance |
|
||||
| `/programmer` | | Code development tasks |
|
||||
| `/gcal` | `/calendar`, `/cal` | Google Calendar access |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: agent-info
|
||||
description: Show information about available agents
|
||||
aliases: [agent, agents]
|
||||
invokes: command:agent-info
|
||||
---
|
||||
|
||||
# Agent Info Command
|
||||
|
||||
Show information about available agents and their hierarchy.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/agent-info # List all agents
|
||||
/agent-info <name> # Show agent details
|
||||
/agent-info --tree # Show agent hierarchy
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Run the agent info script:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/agent-info.py [options] [name]
|
||||
```
|
||||
|
||||
## Output Includes
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Name | Agent identifier |
|
||||
| Description | What the agent handles |
|
||||
| Model | Assigned model (opus/sonnet/haiku) |
|
||||
| Triggers | Keywords that route to this agent |
|
||||
| Supervisor | Parent agent in hierarchy |
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: diff
|
||||
description: Compare configuration with backup
|
||||
aliases: [config-diff, compare]
|
||||
invokes: command:diff
|
||||
---
|
||||
|
||||
# Diff Command
|
||||
|
||||
Compare current configuration with a backup to see what changed.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/diff # Compare with latest backup
|
||||
/diff --backup <file> # Compare with specific backup
|
||||
/diff --list # List available backups
|
||||
/diff --json # Output as JSON
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/config-diff.py [options]
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Shows changes grouped by type:
|
||||
- **Added** - New files in current config
|
||||
- **Removed** - Files that existed in backup but not now
|
||||
- **Changed** - Modified files with details
|
||||
|
||||
For JSON files, shows which keys were added/removed/changed.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: skill-info
|
||||
description: Show information about available skills
|
||||
aliases: [skill, skills-info]
|
||||
invokes: command:skill-info
|
||||
---
|
||||
|
||||
# Skill Info Command
|
||||
|
||||
Show detailed information about available skills.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/skill-info # List all skills
|
||||
/skill-info <name> # Show skill details
|
||||
/skill-info --scripts # List skills with scripts
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Run the skill info script:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/skill-info.py [options] [name]
|
||||
```
|
||||
|
||||
## Output Includes
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Description | What the skill does |
|
||||
| Scripts | Available executable scripts |
|
||||
| Triggers | Keywords that invoke the skill |
|
||||
| References | Documentation files |
|
||||
| Allowed Tools | Tool restrictions (if any) |
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: template
|
||||
description: Manage session templates for common workflows
|
||||
aliases: [templates, session-template]
|
||||
invokes: command:template
|
||||
---
|
||||
|
||||
# Template Command
|
||||
|
||||
Create and use session templates for repeatable workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/template # List all templates
|
||||
/template --use <name> # Display template for use
|
||||
/template --create <name> # Create new template
|
||||
/template --delete <name> # Delete a template
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/session-template.py [options]
|
||||
```
|
||||
|
||||
## Built-in Templates
|
||||
|
||||
| Name | Category | Description |
|
||||
|------|----------|-------------|
|
||||
| `daily-standup` | routine | Morning status check and planning |
|
||||
| `code-review` | development | Review code changes in a project |
|
||||
| `troubleshoot` | debugging | Debug an issue systematically |
|
||||
| `deploy` | operations | Deploy application to environment |
|
||||
|
||||
## Template Contents
|
||||
|
||||
Each template includes:
|
||||
- **Description** - What the template is for
|
||||
- **Category** - Grouping for organization
|
||||
- **Context level** - How much context to gather
|
||||
- **Initial commands** - Commands to run at start
|
||||
- **Checklist** - Steps to follow
|
||||
- **Prompt template** - Suggested starting prompt
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: workflow
|
||||
description: List and describe available workflows
|
||||
aliases: [workflows, wf]
|
||||
invokes: command:workflow
|
||||
---
|
||||
|
||||
# Workflow Command
|
||||
|
||||
List and describe available workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/workflow # List all workflows
|
||||
/workflow <name> # Show workflow details
|
||||
/workflow --category <cat> # Filter by category
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Run the workflow info script:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/workflow-info.py [options] [name]
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| `health` | cluster-health-check, cluster-daily-summary |
|
||||
| `deploy` | deploy-app |
|
||||
| `incidents` | pod-crashloop, node-issue-response |
|
||||
| `sysadmin` | health-check, system-update |
|
||||
|
||||
## Note
|
||||
|
||||
Workflows are design documents - they guide Claude's actions but aren't
|
||||
auto-executed. Use this command to understand available procedures.
|
||||
+2
-1
@@ -26,7 +26,7 @@ Optimized for Raspberry Pi 3B+ (1GB RAM):
|
||||
|
||||
```bash
|
||||
# Run locally
|
||||
go run ./cmd/server --port 8080 --data ./data
|
||||
go run ./cmd/server --port 8080 --data ./data --claude ~/.claude
|
||||
|
||||
# Build binary
|
||||
go build -o server ./cmd/server
|
||||
@@ -72,6 +72,7 @@ kubectl apply -k deploy/
|
||||
|------|---------|-------------|
|
||||
| --port | 8080 | Server port |
|
||||
| --data | /data | Data directory for persistent state |
|
||||
| --claude | ~/.claude | Claude Code directory |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultClaudeDir(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(home, ".claude")
|
||||
got := defaultClaudeDir()
|
||||
if got != want {
|
||||
t.Fatalf("defaultClaudeDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,31 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/will/k8s-agent-dashboard/internal/api"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
"github.com/will/k8s-agent-dashboard/internal/store"
|
||||
)
|
||||
|
||||
//go:embed all:web
|
||||
var webFS embed.FS
|
||||
|
||||
func defaultClaudeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "/home/will/.claude" // fallback; best-effort
|
||||
}
|
||||
return filepath.Join(home, ".claude")
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "8080", "Server port")
|
||||
dataDir := flag.String("data", "/data", "Data directory for state")
|
||||
claudeDir := flag.String("claude", defaultClaudeDir(), "Claude Code directory")
|
||||
flag.Parse()
|
||||
|
||||
// Initialize store
|
||||
@@ -29,6 +40,12 @@ func main() {
|
||||
log.Fatalf("Failed to initialize store: %v", err)
|
||||
}
|
||||
|
||||
// Initialize Claude loader
|
||||
claudeLoader := claude.NewLoader(*claudeDir)
|
||||
|
||||
// Initialize event hub
|
||||
hub := claude.NewEventHub(1000)
|
||||
|
||||
// Create router
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -48,6 +65,16 @@ func main() {
|
||||
// API routes
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", api.HealthCheck)
|
||||
|
||||
r.Route("/claude", func(r chi.Router) {
|
||||
r.Get("/health", api.GetClaudeHealth(claudeLoader))
|
||||
r.Get("/stats", api.GetClaudeStats(claudeLoader))
|
||||
r.Get("/summary", api.GetClaudeSummary(claudeLoader))
|
||||
r.Get("/inventory", api.GetClaudeInventory(claudeLoader))
|
||||
r.Get("/debug/files", api.GetClaudeDebugFiles(claudeLoader))
|
||||
r.Get("/live/backlog", api.GetClaudeLiveBacklog(claudeLoader))
|
||||
r.Get("/stream", api.GetClaudeStream(hub))
|
||||
})
|
||||
r.Get("/status", api.GetClusterStatus(s))
|
||||
r.Get("/pending", api.GetPendingActions(s))
|
||||
r.Post("/pending/{id}/approve", api.ApproveAction(s))
|
||||
@@ -78,6 +105,10 @@ func main() {
|
||||
|
||||
log.Printf("Starting server on %s", addr)
|
||||
log.Printf("Data directory: %s", *dataDir)
|
||||
log.Printf("Claude directory: %s", *claudeDir)
|
||||
|
||||
stop := make(chan struct{})
|
||||
go claude.TailHistoryFile(stop, hub, filepath.Join(*claudeDir, "history.jsonl"))
|
||||
|
||||
if err := http.ListenAndServe(addr, r); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<!-- General -->
|
||||
<button class="nav-btn" data-view="overview">Overview</button>
|
||||
<button class="nav-btn" data-view="usage">Usage</button>
|
||||
<button class="nav-btn" data-view="inventory">Inventory</button>
|
||||
<button class="nav-btn" data-view="debug">Debug</button>
|
||||
<button class="nav-btn" data-view="live">Live</button>
|
||||
|
||||
<!-- Existing K8s views (kept intact) -->
|
||||
<button class="nav-btn active" data-view="status">Status</button>
|
||||
<button class="nav-btn" data-view="pending">Pending <span id="pending-count" class="badge">0</span></button>
|
||||
<button class="nav-btn" data-view="history">History</button>
|
||||
@@ -23,6 +31,89 @@
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- Overview View -->
|
||||
<section id="overview-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Overview</h2>
|
||||
<div id="claude-overview">
|
||||
<p class="empty-state">Loading Claude overview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Usage View -->
|
||||
<section id="usage-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Usage</h2>
|
||||
<table id="claude-usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Sessions</th>
|
||||
<th>Messages</th>
|
||||
<th>Tool Calls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="empty-state">Loading usage...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Inventory View -->
|
||||
<section id="inventory-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Inventory</h2>
|
||||
<div id="claude-inventory">
|
||||
<p class="empty-state">Loading inventory...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Debug View -->
|
||||
<section id="debug-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Debug</h2>
|
||||
<table id="claude-debug-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Status</th>
|
||||
<th>MTime</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="empty-state">Loading debug info...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live View -->
|
||||
<section id="live-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Live Feed</h2>
|
||||
<div class="live-header">
|
||||
<span id="claude-live-conn" class="conn-badge conn-badge-yellow">Connecting...</span>
|
||||
</div>
|
||||
<table id="claude-live-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Summary</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Status View -->
|
||||
<section id="status-view" class="view active">
|
||||
<div class="card">
|
||||
|
||||
@@ -63,6 +63,7 @@ nav {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--bg-card);
|
||||
}
|
||||
@@ -178,6 +179,47 @@ td {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Claude dashboard extra badges */
|
||||
.status-ok {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-missing {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.simple-list {
|
||||
margin-left: 1.25rem;
|
||||
}
|
||||
|
||||
.simple-list li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.inventory-section + .inventory-section {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Grid helper for Claude overview (keeps markup minimal) */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
/* In some contexts we want a grid inside a card; remove card bottom margin in that case */
|
||||
.grid .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.alerts-list, .pending-list, .workflows-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -328,6 +370,59 @@ footer {
|
||||
.progress-bar .fill.warning { background: var(--warning); }
|
||||
.progress-bar .fill.danger { background: var(--danger); }
|
||||
|
||||
/* Live feed styles */
|
||||
.live-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conn-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conn-badge-connected {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.conn-badge-error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.conn-badge-yellow {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.raw-json {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--bg-secondary);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
|
||||
@@ -5,12 +5,34 @@ const API_BASE = '/api';
|
||||
// State
|
||||
let currentView = 'status';
|
||||
|
||||
// Live feed state
|
||||
let pendingLiveEvents = [];
|
||||
let liveEvents = [];
|
||||
let liveEventSource = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupNavigation();
|
||||
loadAllData();
|
||||
// Refresh data every 30 seconds
|
||||
setInterval(loadAllData, 30000);
|
||||
|
||||
// Initialize live feed
|
||||
initLiveFeed();
|
||||
|
||||
// Batch render live events every 1s
|
||||
setInterval(() => {
|
||||
if (pendingLiveEvents.length > 0) {
|
||||
liveEvents = [...pendingLiveEvents, ...liveEvents];
|
||||
if (liveEvents.length > 500) {
|
||||
liveEvents = liveEvents.slice(0, 500);
|
||||
}
|
||||
pendingLiveEvents = [];
|
||||
if (currentView === 'live') {
|
||||
renderLiveEvents();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Navigation
|
||||
@@ -41,10 +63,16 @@ function switchView(view) {
|
||||
async function loadAllData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
// Existing k8s dashboard data
|
||||
loadClusterStatus(),
|
||||
loadPendingActions(),
|
||||
loadHistory(),
|
||||
loadWorkflows()
|
||||
loadWorkflows(),
|
||||
|
||||
// Claude dashboard data
|
||||
loadClaudeStats(),
|
||||
loadClaudeInventory(),
|
||||
loadClaudeDebugFiles()
|
||||
]);
|
||||
updateLastUpdate();
|
||||
} catch (error) {
|
||||
@@ -52,6 +80,123 @@ async function loadAllData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initLiveFeed() {
|
||||
try {
|
||||
// Load initial backlog
|
||||
const response = await fetch(`${API_BASE}/claude/live/backlog?limit=200`);
|
||||
const data = await response.json();
|
||||
liveEvents = data.events || [];
|
||||
renderLiveEvents();
|
||||
|
||||
// Setup SSE
|
||||
liveEventSource = new EventSource(`${API_BASE}/claude/stream`);
|
||||
|
||||
liveEventSource.onopen = () => {
|
||||
updateLiveConnStatus('connected');
|
||||
};
|
||||
|
||||
liveEventSource.onerror = () => {
|
||||
updateLiveConnStatus('error');
|
||||
};
|
||||
|
||||
liveEventSource.onmessage = (e) => {
|
||||
try {
|
||||
const ev = JSON.parse(e.data);
|
||||
pendingLiveEvents.push(ev);
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE event:', err);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error initializing live feed:', error);
|
||||
updateLiveConnStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateLiveConnStatus(status) {
|
||||
const el = document.getElementById('claude-live-conn');
|
||||
if (!el) return;
|
||||
|
||||
el.className = `conn-badge conn-badge-${status}`;
|
||||
el.textContent = status === 'connected' ? 'Connected' : status === 'error' ? 'Disconnected' : 'Connecting...';
|
||||
}
|
||||
|
||||
function renderLiveEvents() {
|
||||
const tbody = document.querySelector('#claude-live-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (liveEvents.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = liveEvents.map(ev => {
|
||||
const summary = ev.data?.summary || {};
|
||||
const summaryText = Object.values(summary).filter(Boolean).join(' ') || '-';
|
||||
const display = ev.data?.json?.display || '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${formatDateTime(ev.ts)}</td>
|
||||
<td><span class="badge">${ev.type}</span></td>
|
||||
<td>${summaryText}</td>
|
||||
<td>
|
||||
<button class="btn-sm" onclick="toggleRawJson(this)">Show JSON</button>
|
||||
<pre class="raw-json" style="display:none">${escapeHtml(JSON.stringify(ev.data?.json || ev.data?.rawLine || {}, null, 2))}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleRawJson(btn) {
|
||||
const pre = btn.nextElementSibling;
|
||||
if (pre.style.display === 'none') {
|
||||
pre.style.display = 'block';
|
||||
btn.textContent = 'Hide JSON';
|
||||
} else {
|
||||
pre.style.display = 'none';
|
||||
btn.textContent = 'Show JSON';
|
||||
}
|
||||
}
|
||||
|
||||
// Claude dashboard data loading
|
||||
async function loadClaudeStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/claude/stats`);
|
||||
const data = await response.json();
|
||||
renderClaudeOverview(data);
|
||||
renderClaudeUsage(data);
|
||||
} catch (error) {
|
||||
// Keep k8s dashboard working even if claude endpoints are unavailable
|
||||
console.error('Error loading Claude stats:', error);
|
||||
renderClaudeOverview(null);
|
||||
renderClaudeUsage(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClaudeInventory() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/claude/inventory`);
|
||||
const data = await response.json();
|
||||
renderClaudeInventory(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading Claude inventory:', error);
|
||||
renderClaudeInventory(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClaudeDebugFiles() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/claude/debug/files`);
|
||||
const data = await response.json();
|
||||
renderClaudeDebugFiles(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading Claude debug files:', error);
|
||||
renderClaudeDebugFiles(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClusterStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/status`);
|
||||
@@ -226,6 +371,151 @@ function renderWorkflows(workflows) {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Claude dashboard rendering
|
||||
function renderClaudeOverview(stats) {
|
||||
const el = document.getElementById('claude-overview');
|
||||
if (!el) return;
|
||||
|
||||
if (!stats) {
|
||||
el.innerHTML = '<p class="empty-state">Claude stats unavailable</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lastComputedDate = stats.lastComputedDate || stats.lastComputed || stats.lastComputedAt || null;
|
||||
|
||||
// Support both shapes: {totals:{...}} and flat {totalSessions,totalMessages,...}
|
||||
const totalSessions = (stats.totalSessions != null) ? stats.totalSessions : (stats.totals && stats.totals.sessions != null ? stats.totals.sessions : 0);
|
||||
const totalMessages = (stats.totalMessages != null) ? stats.totalMessages : (stats.totals && stats.totals.messages != null ? stats.totals.messages : 0);
|
||||
const totalToolCalls = (stats.totalToolCalls != null) ? stats.totalToolCalls : (stats.totals && (stats.totals.toolCalls != null ? stats.totals.toolCalls : (stats.totals.tools != null ? stats.totals.tools : 0)));
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Total Sessions</h3>
|
||||
<div class="metric">${totalSessions}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Total Messages</h3>
|
||||
<div class="metric">${totalMessages}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Total Tool Calls</h3>
|
||||
<div class="metric">${totalToolCalls}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Last Computed</h3>
|
||||
<div class="metric" style="font-size: 14px; font-weight: 600;">${lastComputedDate ? formatDateTime(lastComputedDate) : 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaudeUsage(stats) {
|
||||
const tbody = document.querySelector('#claude-usage-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const daily = (stats && (stats.dailyActivity || stats.daily)) ? (stats.dailyActivity || stats.daily) : [];
|
||||
|
||||
if (!daily || daily.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No usage data available</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = daily.map(d => {
|
||||
const sessions = (d.sessionCount != null) ? d.sessionCount : (d.sessions != null ? d.sessions : 0);
|
||||
const messages = (d.messageCount != null) ? d.messageCount : (d.messages != null ? d.messages : 0);
|
||||
const toolCalls = (d.toolCallCount != null) ? d.toolCallCount : ((d.toolCalls != null) ? d.toolCalls : ((d.tools != null) ? d.tools : 0));
|
||||
return `
|
||||
<tr>
|
||||
<td>${d.date || d.day || ''}</td>
|
||||
<td>${sessions}</td>
|
||||
<td>${messages}</td>
|
||||
<td>${toolCalls}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderClaudeInventory(inv) {
|
||||
const el = document.getElementById('claude-inventory');
|
||||
if (!el) return;
|
||||
|
||||
if (!inv) {
|
||||
el.innerHTML = '<p class="empty-state">Claude inventory unavailable</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = inv.agents || [];
|
||||
const skills = inv.skills || [];
|
||||
const commands = inv.commands || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="inventory-section">
|
||||
<h3>Agents (${agents.length})</h3>
|
||||
${renderSimpleList(agents.map(a => a.name || a.path || a))}
|
||||
</div>
|
||||
<div class="inventory-section">
|
||||
<h3>Skills (${skills.length})</h3>
|
||||
${renderSimpleList(skills.map(s => s.name || s.path || s))}
|
||||
</div>
|
||||
<div class="inventory-section">
|
||||
<h3>Commands (${commands.length})</h3>
|
||||
${renderSimpleList(commands.map(c => c.name || c.path || c))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaudeDebugFiles(debug) {
|
||||
const tbody = document.querySelector('#claude-debug-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const files = (debug && (debug.files || debug.keyFiles)) ? (debug.files || debug.keyFiles) : [];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No debug file info available</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = files.map(f => {
|
||||
const exists = (f.exists != null) ? f.exists : !f.missing;
|
||||
const status = (f.status || ((!exists) ? 'missing' : 'ok')).toLowerCase();
|
||||
const badgeClass = status === 'ok' ? 'status-ok' : 'status-missing';
|
||||
return `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(f.name || f.path || '')}</code></td>
|
||||
<td><span class="status-badge ${badgeClass}">${status}</span></td>
|
||||
<td>${(f.mtime || f.modTime) ? formatDateTime(f.mtime || f.modTime) : ''}</td>
|
||||
<td>${f.error ? escapeHtml(f.error) : ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSimpleList(items) {
|
||||
const safeItems = (items || []).filter(Boolean);
|
||||
if (safeItems.length === 0) return '<p class="empty-state">None</p>';
|
||||
return `
|
||||
<ul class="simple-list">
|
||||
${safeItems.map(i => `<li>${escapeHtml(String(i))}</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return String(value);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function approveAction(id) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
// ClaudeLoader is a minimal interface for Claude Ops endpoints.
|
||||
//
|
||||
// Keep it small so handlers are easy to test with fakes.
|
||||
type ClaudeLoader interface {
|
||||
ClaudeDir() string
|
||||
LoadStatsCache() (*claude.StatsCache, error)
|
||||
ListDir(name string) ([]claude.DirEntry, error)
|
||||
FileMeta(relPath string) (claude.FileMeta, error)
|
||||
PathExists(relPath string) bool
|
||||
}
|
||||
|
||||
func GetClaudeStats(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
type ClaudeSummaryResponse struct {
|
||||
Totals ClaudeSummaryTotals `json:"totals"`
|
||||
PerModel map[string]claude.ModelUsage `json:"perModel"`
|
||||
Derived ClaudeSummaryDerived `json:"derived"`
|
||||
}
|
||||
|
||||
type ClaudeSummaryTotals struct {
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
|
||||
type ClaudeSummaryDerived struct {
|
||||
CacheHitRatioEstimate float64 `json:"cacheHitRatioEstimate"`
|
||||
TopModelByOutputTokens string `json:"topModelByOutputTokens"`
|
||||
}
|
||||
|
||||
func GetClaudeSummary(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := ClaudeSummaryResponse{
|
||||
Totals: ClaudeSummaryTotals{
|
||||
TotalSessions: stats.TotalSessions,
|
||||
TotalMessages: stats.TotalMessages,
|
||||
},
|
||||
PerModel: stats.ModelUsage,
|
||||
}
|
||||
|
||||
var inputTokens, cacheRead, cacheCreate int
|
||||
maxOut := -1
|
||||
topModel := ""
|
||||
for model, usage := range stats.ModelUsage {
|
||||
inputTokens += usage.InputTokens
|
||||
cacheRead += usage.CacheReadInputTokens
|
||||
cacheCreate += usage.CacheCreationInputTokens
|
||||
if usage.OutputTokens > maxOut {
|
||||
maxOut = usage.OutputTokens
|
||||
topModel = model
|
||||
}
|
||||
}
|
||||
|
||||
den := float64(inputTokens + cacheRead + cacheCreate)
|
||||
ratio := 0.0
|
||||
if den > 0 {
|
||||
ratio = float64(cacheRead) / den
|
||||
}
|
||||
|
||||
resp.Derived = ClaudeSummaryDerived{
|
||||
CacheHitRatioEstimate: ratio,
|
||||
TopModelByOutputTokens: topModel,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeHealth(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]bool{}
|
||||
missing := false
|
||||
for _, rel := range []string{"stats-cache.json", "history.jsonl", filepath.Join("state", "component-registry.json")} {
|
||||
exists := loader.PathExists(rel)
|
||||
checks[rel] = exists
|
||||
if !exists {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if missing {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"claudeDir": loader.ClaudeDir(),
|
||||
"fileChecks": checks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeInventory(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := loader.ListDir("agents")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
skills, err := loader.ListDir("skills")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
commands, err := loader.ListDir("commands")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"agents": agents,
|
||||
"skills": skills,
|
||||
"commands": commands,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeDebugFiles(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var metas []claude.FileMeta
|
||||
for _, rel := range []string{
|
||||
"stats-cache.json",
|
||||
"history.jsonl",
|
||||
filepath.Join("state", "component-registry.json"),
|
||||
} {
|
||||
meta, err := loader.FileMeta(rel)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"files": metas,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeLoader struct{}
|
||||
|
||||
func (f fakeLoader) ClaudeDir() string { return "/tmp/claude" }
|
||||
|
||||
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{TotalSessions: 3}, nil
|
||||
}
|
||||
|
||||
func (f fakeLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
|
||||
func (f fakeLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
|
||||
|
||||
func (f fakeLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestGetClaudeStats(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaudeSummary_IncludesDerivedCostSignals(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/summary", GetClaudeSummary(fakeSummaryLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Smoke-check that derived fields exist.
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"cacheHitRatioEstimate", "topModelByOutputTokens"} {
|
||||
if !jsonContainsKey(body, want) {
|
||||
t.Fatalf("expected response to include key %s, body=%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSummaryLoader struct{ fakeLoader }
|
||||
|
||||
func (f fakeSummaryLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{
|
||||
TotalSessions: 3,
|
||||
TotalMessages: 10,
|
||||
ModelUsage: map[string]claude.ModelUsage{
|
||||
"claude-3-5-sonnet": {
|
||||
InputTokens: 100,
|
||||
OutputTokens: 250,
|
||||
CacheReadInputTokens: 50,
|
||||
CacheCreationInputTokens: 25,
|
||||
},
|
||||
"claude-3-5-haiku": {
|
||||
InputTokens: 80,
|
||||
OutputTokens: 300,
|
||||
CacheReadInputTokens: 20,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonContainsKey(body, key string) bool {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
return false
|
||||
}
|
||||
return mapContainsKey(m, key)
|
||||
}
|
||||
|
||||
func mapContainsKey(v any, key string) bool {
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := vv[key]; ok {
|
||||
return true
|
||||
}
|
||||
for _, child := range vv {
|
||||
if mapContainsKey(child, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range vv {
|
||||
if mapContainsKey(child, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type BacklogResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
Events []claude.Event `json:"events"`
|
||||
}
|
||||
|
||||
func GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 200
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
historyPath := filepath.Join(loader.ClaudeDir(), "history.jsonl")
|
||||
lines, err := claude.TailLastNLines(historyPath, limit)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
events := make([]claude.Event, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
ev := parseHistoryLine(line)
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BacklogResponse{
|
||||
Limit: limit,
|
||||
Events: events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseHistoryLine(line string) claude.Event {
|
||||
data := map[string]any{
|
||||
"rawLine": line,
|
||||
}
|
||||
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
|
||||
data["parseError"] = err.Error()
|
||||
} else {
|
||||
data["json"] = jsonData
|
||||
|
||||
summary := map[string]string{}
|
||||
if v, ok := jsonData["sessionId"].(string); ok {
|
||||
summary["sessionId"] = v
|
||||
}
|
||||
if v, ok := jsonData["project"].(string); ok {
|
||||
summary["project"] = v
|
||||
}
|
||||
if v, ok := jsonData["display"].(string); ok {
|
||||
summary["display"] = v
|
||||
}
|
||||
data["summary"] = summary
|
||||
}
|
||||
|
||||
return claude.Event{
|
||||
TS: time.Now(),
|
||||
Type: claude.EventTypeHistoryAppend,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeClaudeDirLoader struct{ dir string }
|
||||
|
||||
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
|
||||
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{}, nil
|
||||
}
|
||||
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) {
|
||||
return claude.FileMeta{}, nil
|
||||
}
|
||||
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
loader := fakeClaudeDirLoader{dir: dir}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !jsonContainsKey(w.Body.String(), "events") {
|
||||
t.Fatalf("expected events in response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
// Integration-style smoke test for the Claude endpoints.
|
||||
//
|
||||
// This does NOT start a server process; it wires chi routes directly and calls
|
||||
// them via httptest.
|
||||
func TestClaudeRoutes_Smoke(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Minimal filesystem layout expected by endpoints.
|
||||
mustMkdirAll(t, filepath.Join(tmp, "agents"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "skills"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "commands"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "state"))
|
||||
|
||||
// Minimal stats-cache.json required by /stats, /summary, /debug/files.
|
||||
// Keep it tiny and deterministic.
|
||||
statsCache := `{
|
||||
"totalSessions": 1,
|
||||
"totalMessages": 1,
|
||||
"modelUsage": {
|
||||
"claude-test": {
|
||||
"inputTokens": 1,
|
||||
"outputTokens": 1,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "stats-cache.json"), []byte(statsCache), 0o600); err != nil {
|
||||
t.Fatalf("write stats-cache.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "history.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write history.jsonl: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "state", "component-registry.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write state/component-registry.json: %v", err)
|
||||
}
|
||||
|
||||
loader := claude.NewLoader(tmp)
|
||||
|
||||
r := chi.NewRouter()
|
||||
// Mirror the /api/claude routes from cmd/server/main.go.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Route("/claude", func(r chi.Router) {
|
||||
r.Get("/health", GetClaudeHealth(loader))
|
||||
r.Get("/stats", GetClaudeStats(loader))
|
||||
r.Get("/summary", GetClaudeSummary(loader))
|
||||
r.Get("/inventory", GetClaudeInventory(loader))
|
||||
r.Get("/debug/files", GetClaudeDebugFiles(loader))
|
||||
})
|
||||
})
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/claude/health",
|
||||
"/api/claude/stats",
|
||||
"/api/claude/inventory",
|
||||
"/api/claude/debug/files",
|
||||
"/api/claude/summary",
|
||||
} {
|
||||
path := path
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status=%d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func GetClaudeStream(hub *claude.EventHub) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "event: %s\n", ev.Type)
|
||||
fmt.Fprintf(w, "id: %d\n", ev.ID)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
case <-notify:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func TestClaudeStream_SendsEvent(t *testing.T) {
|
||||
hub := claude.NewEventHub(10)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stream", GetClaudeStream(hub))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
}()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type=%q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
|
||||
t.Fatalf("body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EventHub struct {
|
||||
mu sync.RWMutex
|
||||
buffer []Event
|
||||
nextID int64
|
||||
subscribers []chan Event
|
||||
bufferSize int
|
||||
}
|
||||
|
||||
func NewEventHub(bufferSize int) *EventHub {
|
||||
return &EventHub{
|
||||
buffer: make([]Event, 0, bufferSize),
|
||||
subscribers: make([]chan Event, 0),
|
||||
bufferSize: bufferSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EventHub) Publish(ev Event) Event {
|
||||
if ev.ID == 0 {
|
||||
ev.ID = atomic.AddInt64(&h.nextID, 1)
|
||||
}
|
||||
if ev.TS.IsZero() {
|
||||
ev.TS = time.Now()
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if len(h.buffer) >= h.bufferSize {
|
||||
h.buffer = h.buffer[1:]
|
||||
}
|
||||
h.buffer = append(h.buffer, ev)
|
||||
|
||||
for _, ch := range h.subscribers {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
func (h *EventHub) Subscribe() (chan Event, func()) {
|
||||
ch := make(chan Event, 10)
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.subscribers = append(h.subscribers, ch)
|
||||
|
||||
cancel := func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for i, c := range h.subscribers {
|
||||
if c == ch {
|
||||
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
|
||||
close(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
func (h *EventHub) ReplaySince(lastID int64) []Event {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
var result []Event
|
||||
for _, ev := range h.buffer {
|
||||
if ev.ID > lastID {
|
||||
result = append(result, ev)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventHub_PublishSubscribe(t *testing.T) {
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeServerNotice {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
if ev.ID == 0 {
|
||||
t.Fatalf("expected id to be assigned")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventHub_ReplaySince(t *testing.T) {
|
||||
hub := NewEventHub(3)
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1
|
||||
hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2
|
||||
hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3
|
||||
|
||||
got := hub.ReplaySince(1)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if got[0].ID != 2 || got[1].ID != 3 {
|
||||
t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package claude
|
||||
|
||||
import "time"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeHistoryAppend EventType = "history.append"
|
||||
EventTypeFileChanged EventType = "file.changed"
|
||||
EventTypeServerNotice EventType = "server.notice"
|
||||
EventTypeServerError EventType = "server.error"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
TS time.Time `json:"ts"`
|
||||
Type EventType `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypesCompile(t *testing.T) {
|
||||
_ = Event{}
|
||||
_ = EventTypeHistoryAppend
|
||||
_ = EventTypeFileChanged
|
||||
_ = EventTypeServerNotice
|
||||
_ = EventTypeServerError
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string) {
|
||||
var offset int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeServerError,
|
||||
Data: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
size := stat.Size()
|
||||
if size > offset {
|
||||
if err := processNewBytes(path, offset, size, hub); err != nil {
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeServerError,
|
||||
Data: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
offset = size
|
||||
} else if size < offset {
|
||||
offset = 0
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeServerNotice,
|
||||
Data: map[string]any{"msg": "file truncated, resetting offset"},
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func processNewBytes(path string, oldSize, newSize int64, hub *EventHub) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Seek(oldSize, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"rawLine": line,
|
||||
}
|
||||
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
|
||||
data["parseError"] = err.Error()
|
||||
} else {
|
||||
data["json"] = jsonData
|
||||
|
||||
summary := map[string]string{}
|
||||
if v, ok := jsonData["sessionId"].(string); ok {
|
||||
summary["sessionId"] = v
|
||||
}
|
||||
if v, ok := jsonData["project"].(string); ok {
|
||||
summary["project"] = v
|
||||
}
|
||||
if v, ok := jsonData["display"].(string); ok {
|
||||
summary["display"] = v
|
||||
}
|
||||
data["summary"] = summary
|
||||
}
|
||||
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeHistoryAppend,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHistoryTailer_EmitsOnAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
stop := make(chan struct{})
|
||||
go TailHistoryFile(stop, hub, p)
|
||||
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeHistoryAppend {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
case <-time.After(700 * time.Millisecond):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
|
||||
close(stop)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Loader reads Claude Code state files from a local claude directory (typically ~/.claude).
|
||||
//
|
||||
// Keep this minimal for now; more helpers (e.g. ListDir / FileInfo) can be added later.
|
||||
type Loader struct {
|
||||
claudeDir string
|
||||
}
|
||||
|
||||
type DirEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
|
||||
type FileMeta struct {
|
||||
Path string `json:"path"`
|
||||
Exists bool `json:"exists"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"modTime"`
|
||||
}
|
||||
|
||||
func NewLoader(claudeDir string) *Loader {
|
||||
return &Loader{claudeDir: claudeDir}
|
||||
}
|
||||
|
||||
func (l *Loader) ClaudeDir() string { return l.claudeDir }
|
||||
|
||||
func (l *Loader) LoadStatsCache() (*StatsCache, error) {
|
||||
if l.claudeDir == "" {
|
||||
return nil, fmt.Errorf("claude dir is empty")
|
||||
}
|
||||
|
||||
p := filepath.Join(l.claudeDir, "stats-cache.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read stats cache %q: %w", p, err)
|
||||
}
|
||||
|
||||
var stats StatsCache
|
||||
if err := json.Unmarshal(b, &stats); err != nil {
|
||||
return nil, fmt.Errorf("parse stats cache %q: %w", p, err)
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (l *Loader) ListDir(name string) ([]DirEntry, error) {
|
||||
if l.claudeDir == "" {
|
||||
return nil, fmt.Errorf("claude dir is empty")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(l.claudeDir, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir %q: %w", name, err)
|
||||
}
|
||||
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Name: e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l *Loader) PathExists(relPath string) bool {
|
||||
if l.claudeDir == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(l.claudeDir, relPath))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (l *Loader) FileMeta(relPath string) (FileMeta, error) {
|
||||
if l.claudeDir == "" {
|
||||
return FileMeta{}, fmt.Errorf("claude dir is empty")
|
||||
}
|
||||
|
||||
p := filepath.Join(l.claudeDir, relPath)
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileMeta{Path: relPath, Exists: false}, nil
|
||||
}
|
||||
return FileMeta{}, fmt.Errorf("stat %q: %w", p, err)
|
||||
}
|
||||
|
||||
return FileMeta{
|
||||
Path: relPath,
|
||||
Exists: true,
|
||||
Size: st.Size(),
|
||||
ModTime: st.ModTime().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadStatsCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "stats-cache.json")
|
||||
err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
loader := NewLoader(dir)
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStatsCache: %v", err)
|
||||
}
|
||||
if stats.TotalSessions != 1 {
|
||||
t.Fatalf("TotalSessions=%d", stats.TotalSessions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package claude
|
||||
|
||||
type DailyActivity struct {
|
||||
Date string `json:"date"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
SessionCount int `json:"sessionCount"`
|
||||
ToolCallCount int `json:"toolCallCount"`
|
||||
}
|
||||
|
||||
type DailyModelTokens struct {
|
||||
Date string `json:"date"`
|
||||
TokensByModel map[string]int `json:"tokensByModel"`
|
||||
}
|
||||
|
||||
type ModelUsage struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
OutputTokens int `json:"outputTokens"`
|
||||
CacheReadInputTokens int `json:"cacheReadInputTokens"`
|
||||
CacheCreationInputTokens int `json:"cacheCreationInputTokens"`
|
||||
WebSearchRequests int `json:"webSearchRequests"`
|
||||
CostUSD float64 `json:"costUSD"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
}
|
||||
|
||||
type StatsCache struct {
|
||||
Version int `json:"version"`
|
||||
LastComputedDate string `json:"lastComputedDate"`
|
||||
DailyActivity []DailyActivity `json:"dailyActivity"`
|
||||
DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"`
|
||||
ModelUsage map[string]ModelUsage `json:"modelUsage"`
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModelTypesCompile(t *testing.T) {
|
||||
_ = StatsCache{}
|
||||
_ = DailyActivity{}
|
||||
_ = ModelUsage{}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func TailLastNLines(path string, n int) ([]string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
var result []string
|
||||
for i := len(lines) - 1; i >= 0 && len(result) < n; i-- {
|
||||
if lines[i] != "" {
|
||||
result = append(result, lines[i])
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailLastNLines_NewestFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 5; i++ {
|
||||
b.WriteString("line")
|
||||
b.WriteString([]string{"1", "2", "3", "4", "5"}[i-1])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
lines, err := TailLastNLines(p, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("TailLastNLines: %v", err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("len=%d", len(lines))
|
||||
}
|
||||
if lines[0] != "line5" || lines[1] != "line4" {
|
||||
t.Fatalf("got=%v", lines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
go test ./...
|
||||
@@ -0,0 +1,213 @@
|
||||
# Morning Report System Design
|
||||
|
||||
**Date:** 2025-01-02
|
||||
**Status:** Approved
|
||||
**Author:** PA + User collaboration
|
||||
|
||||
## Overview
|
||||
|
||||
A daily morning dashboard that aggregates useful information into a single Markdown file, generated automatically via systemd timer and refreshable on-demand.
|
||||
|
||||
## Output
|
||||
|
||||
- **Format:** Markdown
|
||||
- **Location:** `~/.claude/reports/morning.md`
|
||||
- **Archive:** `~/.claude/reports/archive/YYYY-MM-DD.md` (30 days retention)
|
||||
|
||||
## Schedule
|
||||
|
||||
- **Automatic:** Systemd timer at 8:00 AM Pacific
|
||||
- **On-demand:** `/morning` command for manual refresh
|
||||
|
||||
## Report Sections
|
||||
|
||||
### 1. Weather
|
||||
- **Source:** wttr.in (no API key)
|
||||
- **Location:** Seattle, WA, USA
|
||||
- **LLM:** Haiku (parse output, add hints like "bring umbrella")
|
||||
|
||||
### 2. Email
|
||||
- **Source:** Gmail skill (existing)
|
||||
- **Display:** Unread count, urgent highlights, top 5 emails
|
||||
- **LLM:** Sonnet (triage urgency, summarize)
|
||||
|
||||
### 3. Calendar
|
||||
- **Source:** gcal skill (existing)
|
||||
- **Display:** Today's events + tomorrow preview
|
||||
- **LLM:** None (structured JSON, Python formatting)
|
||||
|
||||
### 4. Stocks
|
||||
- **Source:** stock-lookup skill (existing)
|
||||
- **Watchlist:** CRWV, NVDA, MSFT
|
||||
- **Display:** Price, daily change, trend indicator
|
||||
- **LLM:** Haiku (format table, light commentary)
|
||||
|
||||
### 5. Tasks
|
||||
- **Source:** Google Tasks API (new integration)
|
||||
- **Display:** Pending items, due dates, top 5
|
||||
- **LLM:** None (structured JSON, Python formatting)
|
||||
|
||||
### 6. Infrastructure
|
||||
- **Source:** k8s-quick-status + sysadmin-health skills (existing)
|
||||
- **Display:** Traffic light status (green/yellow/red)
|
||||
- **LLM:** Haiku (interpret health output)
|
||||
- **Future:** Enhanced detail levels (fc-042)
|
||||
|
||||
### 7. News
|
||||
- **Source:** RSS feeds (Hacker News, Lobsters)
|
||||
- **Display:** Top 5 headlines with scores
|
||||
- **LLM:** Sonnet (summarize headlines)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ morning-report skill │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ scripts/generate.py # Main orchestrator │
|
||||
│ scripts/collectors/ # Data fetchers │
|
||||
│ ├── gmail.py # Reuse existing gmail skill │
|
||||
│ ├── gcal.py # Reuse existing gcal skill │
|
||||
│ ├── gtasks.py # New: Google Tasks API │
|
||||
│ ├── stocks.py # Reuse stock-lookup skill │
|
||||
│ ├── weather.py # wttr.in integration │
|
||||
│ ├── infra.py # K8s + workstation health │
|
||||
│ └── news.py # RSS/Hacker News feeds │
|
||||
│ scripts/render.py # Markdown templating │
|
||||
│ config.json # Watchlist, location, feeds │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
File: `~/.claude/skills/morning-report/config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"schedule": {
|
||||
"time": "08:00",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"output": {
|
||||
"path": "~/.claude/reports/morning.md",
|
||||
"archive": true,
|
||||
"archive_days": 30
|
||||
},
|
||||
"stocks": {
|
||||
"watchlist": ["CRWV", "NVDA", "MSFT"],
|
||||
"show_trend": true
|
||||
},
|
||||
"weather": {
|
||||
"location": "Seattle,WA,USA",
|
||||
"provider": "wttr.in"
|
||||
},
|
||||
"email": {
|
||||
"max_display": 5,
|
||||
"triage": true
|
||||
},
|
||||
"calendar": {
|
||||
"show_tomorrow": true
|
||||
},
|
||||
"tasks": {
|
||||
"max_display": 5,
|
||||
"show_due_dates": true
|
||||
},
|
||||
"infra": {
|
||||
"check_k8s": true,
|
||||
"check_workstation": true,
|
||||
"detail_level": "traffic_light"
|
||||
},
|
||||
"news": {
|
||||
"feeds": [
|
||||
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 3},
|
||||
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 2}
|
||||
],
|
||||
"summarize": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## LLM Delegation
|
||||
|
||||
| Section | LLM Tier | Reason |
|
||||
|---------|----------|--------|
|
||||
| Weather | Haiku | Parse wttr.in, add hints |
|
||||
| Email | Sonnet | Triage urgency, summarize |
|
||||
| Calendar | None | Structured JSON, template |
|
||||
| Stocks | Haiku | Format, light commentary |
|
||||
| Tasks | None | Structured JSON, template |
|
||||
| Infra | Haiku | Interpret health output |
|
||||
| News | Sonnet | Summarize headlines |
|
||||
|
||||
## Error Handling
|
||||
|
||||
Each collector is isolated - failures don't break the whole report.
|
||||
|
||||
| Collector | Timeout | Retries | Fallback |
|
||||
|-----------|---------|---------|----------|
|
||||
| Weather | 5s | 1 | "Weather unavailable" |
|
||||
| Email | 10s | 2 | Show error + auth hint |
|
||||
| Calendar | 10s | 2 | Show error |
|
||||
| Stocks | 5s | 1 | Partial results per-symbol |
|
||||
| Tasks | 10s | 2 | Show error |
|
||||
| Infra | 15s | 1 | "Status unknown" (yellow) |
|
||||
| News | 10s | 1 | "News unavailable" |
|
||||
|
||||
## Logging
|
||||
|
||||
- Run logs: `~/.claude/logs/morning-report.log`
|
||||
- Systemd logs: `journalctl --user -u morning-report`
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Config + skeleton structure
|
||||
2. Weather, Stocks, Infra collectors (easy wins)
|
||||
3. Google Tasks collector (new OAuth scope)
|
||||
4. News collector
|
||||
5. Orchestrator + renderer
|
||||
6. Systemd timer + `/morning` command
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **fc-041:** Terminal output version (motd-style)
|
||||
- **fc-042:** Enhanced infrastructure detail levels
|
||||
|
||||
## Sample Output
|
||||
|
||||
```markdown
|
||||
# Morning Report - Thu Jan 2, 2025
|
||||
|
||||
## Weather
|
||||
Seattle: 45°F, Partly Cloudy | High 52° Low 38° | Rain likely 3PM
|
||||
|
||||
## Email (3 unread, 1 urgent)
|
||||
[!] From: boss@work.com - "Q4 numbers needed"
|
||||
* From: github.com - "PR #123 merged"
|
||||
* From: newsletter@tech.com - "Weekly digest"
|
||||
|
||||
## Today
|
||||
* 9:00 AM - Standup (30m)
|
||||
* 2:00 PM - 1:1 with Sarah (1h)
|
||||
Tomorrow: 3 events, first at 10:00 AM
|
||||
|
||||
## Stocks
|
||||
CRWV $79.32 +10.8% NVDA $188.85 +1.3% MSFT $430.50 -0.2%
|
||||
|
||||
## Tasks (4 pending)
|
||||
* Finish quarterly report (due today)
|
||||
* Review PR #456
|
||||
* Book travel for conference
|
||||
* Call dentist
|
||||
|
||||
## Infrastructure
|
||||
K8s Cluster: [OK] | Workstation: [OK]
|
||||
|
||||
## Tech News
|
||||
* "OpenAI announces GPT-5" (Hacker News, 342 pts)
|
||||
* "Rust 2.0 released" (Lobsters, 89 votes)
|
||||
* "Kubernetes 1.32 features" (Hacker News, 156 pts)
|
||||
|
||||
---
|
||||
Generated: 2025-01-02 08:00:00 PT
|
||||
```
|
||||
@@ -0,0 +1,442 @@
|
||||
# Claude Ops Dashboard Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extend the existing Go web dashboard to monitor Claude Code agent activity, context usage, skills/commands usage signals, and cost-related token stats from your local `~/.claude/` directory.
|
||||
|
||||
**Architecture:** Keep the current lightweight Go HTTP server + static HTML/JS frontend. Add a new “Claude Ops” section with API endpoints that parse local Claude Code state files (primarily `~/.claude/stats-cache.json`, `~/.claude/history.jsonl`, `~/.claude/state/component-registry.json`, and directory listings under `~/.claude/agents`, `~/.claude/skills`, `~/.claude/commands`). Frontend gains new navigation tabs + tables/charts using vanilla JS.
|
||||
|
||||
**Tech Stack:** Go 1.21+, chi router, vanilla HTML/CSS/JS (no React), file-based data sources under `~/.claude/`.
|
||||
|
||||
---
|
||||
|
||||
## Scope (YAGNI)
|
||||
|
||||
In this first iteration, focus on read-only analytics:
|
||||
- Daily activity + token usage (from `~/.claude/stats-cache.json`)
|
||||
- Recent sessions list (from `~/.claude/history.jsonl` without parsing full message bodies)
|
||||
- Installed agents/skills/commands inventory (from `~/.claude/agents/`, `~/.claude/skills/`, `~/.claude/commands/`)
|
||||
- “Debug” view: show which data files are missing/unreadable and last-modified timestamps
|
||||
|
||||
Explicitly out of scope:
|
||||
- Real-time websocket streaming
|
||||
- Multi-user auth
|
||||
- Editing/triggering slash commands
|
||||
- Deep semantic parsing of every history event (we’ll add iteratively)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Claude directory config to server
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Modify: `~/.claude/dashboard/README.md`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create a minimal unit test that ensures server config defaults to `~/.claude` when not specified.
|
||||
|
||||
- Create: `~/.claude/dashboard/cmd/server/config_test.go`
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultClaudeDir(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(home, ".claude")
|
||||
got := defaultClaudeDir()
|
||||
if got != want {
|
||||
t.Fatalf("defaultClaudeDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: defaultClaudeDir`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
|
||||
Add helper:
|
||||
```go
|
||||
func defaultClaudeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "/home/will/.claude" // fallback; best-effort
|
||||
}
|
||||
return filepath.Join(home, ".claude")
|
||||
}
|
||||
```
|
||||
|
||||
Add CLI flag:
|
||||
- `--claude` (default `~/.claude`)
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go cmd/server/config_test.go README.md
|
||||
git commit -m "feat: add default claude dir config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Claude models for API responses
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/models.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Create: `~/.claude/dashboard/internal/claude/models_test.go`
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModelTypesCompile(t *testing.T) {
|
||||
_ = StatsCache{}
|
||||
_ = DailyActivity{}
|
||||
_ = ModelUsage{}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: StatsCache`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create: `~/.claude/dashboard/internal/claude/models.go`
|
||||
|
||||
Implement structs matching the subset we use:
|
||||
```go
|
||||
package claude
|
||||
|
||||
type DailyActivity struct {
|
||||
Date string `json:"date"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
SessionCount int `json:"sessionCount"`
|
||||
ToolCallCount int `json:"toolCallCount"`
|
||||
}
|
||||
|
||||
type DailyModelTokens struct {
|
||||
Date string `json:"date"`
|
||||
TokensByModel map[string]int `json:"tokensByModel"`
|
||||
}
|
||||
|
||||
type ModelUsage struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
OutputTokens int `json:"outputTokens"`
|
||||
CacheReadInputTokens int `json:"cacheReadInputTokens"`
|
||||
CacheCreationInputTokens int `json:"cacheCreationInputTokens"`
|
||||
WebSearchRequests int `json:"webSearchRequests"`
|
||||
CostUSD float64 `json:"costUSD"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
}
|
||||
|
||||
type StatsCache struct {
|
||||
Version int `json:"version"`
|
||||
LastComputedDate string `json:"lastComputedDate"`
|
||||
DailyActivity []DailyActivity `json:"dailyActivity"`
|
||||
DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"`
|
||||
ModelUsage map[string]ModelUsage `json:"modelUsage"`
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/models.go internal/claude/models_test.go
|
||||
git commit -m "feat: add claude stats response models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement Claude file loader
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/loader.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/loader_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadStatsCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "stats-cache.json")
|
||||
err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
loader := NewLoader(dir)
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStatsCache: %v", err)
|
||||
}
|
||||
if stats.TotalSessions != 1 {
|
||||
t.Fatalf("TotalSessions=%d", stats.TotalSessions)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: NewLoader`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Implement `Loader` with:
|
||||
- `NewLoader(claudeDir string)`
|
||||
- `LoadStatsCache() (*StatsCache, error)` reads `<claudeDir>/stats-cache.json`
|
||||
- `ListDir(name string) ([]DirEntry, error)` for `agents/`, `skills/`, `commands/`
|
||||
- `FileInfo(path string) (FileMeta, error)` for debug view
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/loader.go internal/claude/loader_test.go
|
||||
git commit -m "feat: load claude stats-cache.json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add new Claude Ops API routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/internal/api/handlers.go` (only if you want shared helpers)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_handlers_test.go`
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeLoader struct{}
|
||||
|
||||
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{TotalSessions: 3}, nil
|
||||
}
|
||||
|
||||
func TestGetClaudeStats(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: GetClaudeStats`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create endpoints:
|
||||
- `GET /api/claude/health` → returns `{status:"ok", claudeDir:"..."}` and file presence checks
|
||||
- `GET /api/claude/stats` → returns parsed `StatsCache`
|
||||
- `GET /api/claude/inventory` → lists agents/skills/commands entries
|
||||
- `GET /api/claude/debug/files` → returns file metas for key files and last-modified
|
||||
|
||||
Wire in `cmd/server/main.go`:
|
||||
- Build loader from `--claude` flag
|
||||
- Register routes under `/api/claude`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/api/claude_handlers.go internal/api/claude_handlers_test.go
|
||||
git commit -m "feat: add claude ops api endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add new UI navigation tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/index.html`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/css/style.css`
|
||||
|
||||
**Step 1: Make a minimal UI change (no tests)**
|
||||
|
||||
Add nav buttons:
|
||||
- Overview
|
||||
- Usage
|
||||
- Inventory
|
||||
- Debug
|
||||
|
||||
Add new `<section>` elements mirroring existing “views” pattern.
|
||||
|
||||
**Step 2: Manual verification**
|
||||
|
||||
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
Expected: New tabs switch views (even if empty).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/web/index.html cmd/server/web/static/css/style.css
|
||||
git commit -m "feat: add claude ops dashboard views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement frontend data fetching + rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/js/app.js`
|
||||
|
||||
**Step 1: Add API calls**
|
||||
|
||||
Add functions:
|
||||
- `loadClaudeStats()` → `GET /api/claude/stats`
|
||||
- `loadClaudeInventory()` → `GET /api/claude/inventory`
|
||||
- `loadClaudeDebugFiles()` → `GET /api/claude/debug/files`
|
||||
|
||||
Integrate into `loadAllData()`.
|
||||
|
||||
**Step 2: Add render functions**
|
||||
|
||||
- Overview: show totals + lastComputedDate
|
||||
- Usage: simple table for `dailyActivity` (date, messages, sessions, tool calls)
|
||||
- Inventory: 3 columns lists: agents, skills, commands
|
||||
- Debug: table of key files with status/missing + mtime
|
||||
|
||||
**Step 3: Manual verification**
|
||||
|
||||
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
Expected: Data populates from your local `~/.claude/stats-cache.json`.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/web/static/js/app.js
|
||||
git commit -m "feat: render claude usage and inventory data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add “cost optimization” signals (derived)
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/internal/api/claude_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/internal/claude/models.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add a test that expects derived fields:
|
||||
- cache hit ratio estimate: `cacheReadInputTokens / (inputTokens + cacheReadInputTokens + cacheCreationInputTokens)` (best-effort)
|
||||
- top model by output tokens
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL because fields aren’t present
|
||||
|
||||
**Step 3: Implement derived summary endpoint**
|
||||
|
||||
- `GET /api/claude/summary` returns:
|
||||
- totals
|
||||
- per-model tokens
|
||||
- derived cost signals
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/claude_handlers.go internal/claude/models.go internal/api/claude_handlers_test.go
|
||||
git commit -m "feat: add claude summary and cache efficiency signals"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end check
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
**Step 1: Run tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run server locally**
|
||||
|
||||
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
Expected: Browser shows Claude Ops tabs with live data.
|
||||
|
||||
---
|
||||
|
||||
## Notes / Follow-ups (later iterations)
|
||||
|
||||
- Parse `~/.claude/history.jsonl` into “sessions” and show recent slash commands usage (requires schema discovery)
|
||||
- Add “top tools called” chart (requires richer history parsing)
|
||||
- Add alert thresholds (e.g., token spikes day-over-day)
|
||||
@@ -0,0 +1,660 @@
|
||||
# Claude Real-Time Monitoring (SSE) Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add real-time-ish monitoring of Claude Code agent activity to the existing Go dashboard, with a backlog (last 200 history events) plus SSE updates, shown in a new “Live” UI feed with prettified rows and expandable raw JSON.
|
||||
|
||||
**Architecture:** Add an in-memory `EventHub` (pub/sub + ring buffer) that receives events from a `HistoryTailer` (tails `~/.claude/history.jsonl`) and a debounced file watcher (for `stats-cache.json` and `state/component-registry.json`). Expose a REST backlog endpoint (`/api/claude/live/backlog`) returning normalized `Event` objects (newest→oldest) and an SSE stream endpoint (`/api/claude/stream`) that pushes new events to the browser. Frontend uses `EventSource` plus batching (render every 1–2s).
|
||||
|
||||
**Tech Stack:** Go 1.21+, `chi`, vanilla HTML/CSS/JS, optional `fsnotify`.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- Transport: SSE first (WebSockets later).
|
||||
- Acceptable latency: 2–5 seconds.
|
||||
- UI: prettified table with expandable raw JSON.
|
||||
- Backlog: enabled, default `limit=200`.
|
||||
- Backlog ordering: **newest → oldest**.
|
||||
- Parsing: **generic-first**, best-effort extraction of a few fields; always preserve raw JSON.
|
||||
- Backlog response format: **normalized `events`** (not just raw lines).
|
||||
|
||||
---
|
||||
|
||||
## Data Contract
|
||||
|
||||
### Event JSON
|
||||
|
||||
All events share:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"ts": "2026-01-01T12:00:00Z",
|
||||
"type": "history.append",
|
||||
"data": {
|
||||
"summary": {
|
||||
"sessionId": "...",
|
||||
"project": "...",
|
||||
"display": "/model"
|
||||
},
|
||||
"rawLine": "{...}",
|
||||
"json": { "...": "..." },
|
||||
"parseError": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Event types:
|
||||
- `history.append`
|
||||
- `file.changed`
|
||||
- `server.notice`
|
||||
- `server.error`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `Event` types
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/events.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/events_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypesCompile(t *testing.T) {
|
||||
_ = Event{}
|
||||
_ = EventTypeHistoryAppend
|
||||
_ = EventTypeFileChanged
|
||||
_ = EventTypeServerNotice
|
||||
_ = EventTypeServerError
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: Event`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import "time"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeHistoryAppend EventType = "history.append"
|
||||
EventTypeFileChanged EventType = "file.changed"
|
||||
EventTypeServerNotice EventType = "server.notice"
|
||||
EventTypeServerError EventType = "server.error"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
TS time.Time `json:"ts"`
|
||||
Type EventType `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/events.go internal/claude/events_test.go
|
||||
git commit -m "feat: add real-time event types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement `EventHub` (pub/sub + ring buffer)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/eventhub.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/eventhub_test.go`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventHub_PublishSubscribe(t *testing.T) {
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeServerNotice {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
if ev.ID == 0 {
|
||||
t.Fatalf("expected id to be assigned")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventHub_ReplaySince(t *testing.T) {
|
||||
hub := NewEventHub(3)
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1
|
||||
hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2
|
||||
hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3
|
||||
|
||||
got := hub.ReplaySince(1)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if got[0].ID != 2 || got[1].ID != 3 {
|
||||
t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: NewEventHub`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Implement:
|
||||
- `type EventHub struct { ... }`
|
||||
- `NewEventHub(bufferSize int) *EventHub`
|
||||
- `Publish(ev Event) Event`:
|
||||
- assign `ID` if zero using an internal counter
|
||||
- set `TS = time.Now()` if zero
|
||||
- append to ring buffer
|
||||
- broadcast to subscriber channels (non-blocking send)
|
||||
- `Subscribe() (chan Event, func())` returns a buffered channel and a cancel func
|
||||
- `ReplaySince(lastID int64) []Event`
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/eventhub.go internal/claude/eventhub_test.go
|
||||
git commit -m "feat: add event hub with replay buffer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tail last N lines helper (newest → oldest)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/tail.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/tail_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailLastNLines_NewestFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 5; i++ {
|
||||
b.WriteString("line")
|
||||
b.WriteString([]string{"1","2","3","4","5"}[i-1])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
lines, err := TailLastNLines(p, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("TailLastNLines: %v", err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("len=%d", len(lines))
|
||||
}
|
||||
if lines[0] != "line5" || lines[1] != "line4" {
|
||||
t.Fatalf("got=%v", lines)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: TailLastNLines`
|
||||
|
||||
**Step 3: Minimal implementation**
|
||||
|
||||
Create `~/.claude/dashboard/internal/claude/tail.go`:
|
||||
|
||||
- `TailLastNLines(path string, n int) ([]string, error)`
|
||||
- First implementation can be simple (read whole file + split) with a TODO noting potential optimization.
|
||||
- Return text lines without trailing newline; newest→oldest ordering.
|
||||
|
||||
**Step 4: Run tests to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/tail.go internal/claude/tail_test.go
|
||||
git commit -m "feat: add tail last N lines helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add backlog endpoint returning normalized events
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_live_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/internal/api/claude_handlers.go` (only to share helper if needed)
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Test: `~/.claude/dashboard/internal/api/claude_live_handlers_test.go`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeClaudeDirLoader struct{ dir string }
|
||||
|
||||
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
|
||||
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) { return &claude.StatsCache{}, nil }
|
||||
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
|
||||
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
loader := fakeClaudeDirLoader{dir: dir}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// Assert response includes an "events" array with at least 1 event.
|
||||
if !jsonContainsKey(t, w.Body.Bytes(), "events") {
|
||||
t.Fatalf("expected events in response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: GetClaudeLiveBacklog`
|
||||
|
||||
**Step 3: Minimal implementation**
|
||||
|
||||
Create `~/.claude/dashboard/internal/api/claude_live_handlers.go`:
|
||||
|
||||
- `GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc`
|
||||
- Query param: `limit` (default 200; clamp 1..1000)
|
||||
- Use `claude.TailLastNLines(filepath.Join(loader.ClaudeDir(), "history.jsonl"), limit)`
|
||||
- For each line, create a `claude.Event` with:
|
||||
- `Type: claude.EventTypeHistoryAppend`
|
||||
- `TS: time.Now()` (or parse timestamp if present in JSON)
|
||||
- `Data` contains: `rawLine`, optionally `json`, optionally `parseError`, and `summary` (best effort)
|
||||
- JSON parsing should be schema-agnostic: unmarshal into `map[string]any`.
|
||||
- Summary extraction should look for keys: `sessionId`, `project`, `display` (strings).
|
||||
|
||||
Return payload:
|
||||
|
||||
```json
|
||||
{ "limit": 200, "events": [ ... ] }
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Wire route**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/main.go` to register:
|
||||
- `GET /api/claude/live/backlog`
|
||||
|
||||
**Step 6: Run tests again**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/api/claude_live_handlers.go internal/api/claude_live_handlers_test.go internal/claude/tail.go internal/claude/tail_test.go
|
||||
git commit -m "feat: add claude live backlog endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add SSE stream endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_stream_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Test: `~/.claude/dashboard/internal/api/claude_stream_handlers_test.go`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func TestClaudeStream_SendsEvent(t *testing.T) {
|
||||
hub := claude.NewEventHub(10)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stream", GetClaudeStream(hub))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Publish after handler starts.
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
}()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type=%q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
|
||||
t.Fatalf("body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: GetClaudeStream`
|
||||
|
||||
**Step 3: Implement minimal SSE handler**
|
||||
|
||||
Create `~/.claude/dashboard/internal/api/claude_stream_handlers.go`:
|
||||
|
||||
- `GetClaudeStream(hub *claude.EventHub) http.HandlerFunc`
|
||||
- Set headers:
|
||||
- `Content-Type: text/event-stream`
|
||||
- `Cache-Control: no-cache`
|
||||
- Subscribe to hub; write events in SSE format:
|
||||
|
||||
```
|
||||
event: <type>
|
||||
id: <id>
|
||||
data: <json>
|
||||
|
||||
|
||||
```
|
||||
|
||||
- Flush after each event.
|
||||
- Keep it minimal; add keepalive pings later.
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Wire route**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/main.go` to register:
|
||||
- `GET /api/claude/stream`
|
||||
|
||||
**Step 6: Run tests again**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/api/claude_stream_handlers.go internal/api/claude_stream_handlers_test.go internal/claude/eventhub.go internal/claude/eventhub_test.go
|
||||
git commit -m "feat: add claude sse stream endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement HistoryTailer to publish hub events
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/history_tailer.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/history_tailer_test.go`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHistoryTailer_EmitsOnAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
stop := make(chan struct{})
|
||||
go TailHistoryFile(stop, hub, p)
|
||||
|
||||
// Append a line
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeHistoryAppend {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
|
||||
close(stop)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: TailHistoryFile`
|
||||
|
||||
**Step 3: Minimal implementation**
|
||||
|
||||
Create `~/.claude/dashboard/internal/claude/history_tailer.go` implementing:
|
||||
|
||||
- `TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string)`
|
||||
- Simple polling loop (since target latency is 2–5s):
|
||||
- Every 500ms–1s, stat file size
|
||||
- If size grew, read new bytes from offset, split on `\n`, publish `history.append` events
|
||||
- If size shrank, reset offset to 0 and publish `server.notice`
|
||||
|
||||
Also implement an internal helper to parse a history line into event `Data` with `summary` extraction (same logic as backlog).
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Wire tailer in server**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/main.go`:
|
||||
- Create hub at startup: `hub := claude.NewEventHub(1000)`
|
||||
- Start goroutine tailing `filepath.Join(*claudeDir, "history.jsonl")`
|
||||
|
||||
**Step 6: Run tests again**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/claude/history_tailer.go internal/claude/history_tailer_test.go
|
||||
git commit -m "feat: stream history.jsonl appends via event hub"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend Live view (EventSource + batching)
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/index.html`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/js/app.js`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/css/style.css`
|
||||
|
||||
**Step 1: Add Live tab + markup**
|
||||
|
||||
- Add nav button: `data-view="live"`
|
||||
- Add section:
|
||||
- `id="live-view"`
|
||||
- table `id="claude-live-table"` and a connection indicator `id="claude-live-conn"`
|
||||
|
||||
**Step 2: Add JS backlog fetch + EventSource**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/web/static/js/app.js`:
|
||||
- On DOMContentLoaded, create `EventSource('/api/claude/stream')`
|
||||
- Maintain:
|
||||
- `let pendingLiveEvents = []`
|
||||
- `let liveEvents = []` (cap at 500)
|
||||
- Every 1000ms:
|
||||
- move pending → live
|
||||
- render table rows
|
||||
- Fetch backlog once:
|
||||
- `GET /api/claude/live/backlog?limit=200`
|
||||
- prepend/append into `liveEvents` (newest→oldest returned; UI should render newest first at top)
|
||||
|
||||
**Step 3: CSS**
|
||||
|
||||
- Add a small connection badge style (green/yellow/red)
|
||||
- Ensure table remains readable
|
||||
|
||||
**Step 4: Manual verification**
|
||||
|
||||
Run:
|
||||
- `go test ./...`
|
||||
- `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
|
||||
Expected:
|
||||
- Live tab loads backlog rows
|
||||
- New history events appear on subsequent CLI activity
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/web/index.html cmd/server/web/static/js/app.js cmd/server/web/static/css/style.css
|
||||
git commit -m "feat: add live feed UI with SSE batching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end verification
|
||||
|
||||
**Files:**
|
||||
- None (unless a bug requires fixes)
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS (0 failures)
|
||||
|
||||
**Step 2: Manual smoke check**
|
||||
|
||||
Run:
|
||||
- `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
|
||||
Check:
|
||||
- `curl -N http://localhost:8080/api/claude/stream` prints SSE lines
|
||||
- `curl http://localhost:8080/api/claude/live/backlog?limit=5` returns `events` array
|
||||
- Browser Live tab updates
|
||||
|
||||
---
|
||||
|
||||
## Execution handoff
|
||||
|
||||
Plan complete and saved to `~/.claude/docs/plans/2026-01-01-claude-realtime-monitoring-implementation.md`.
|
||||
|
||||
Two execution options:
|
||||
|
||||
1. Subagent-Driven (this session) — I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||
2. Parallel Session (separate) — Open new session with `superpowers:executing-plans`, batch execution with checkpoints
|
||||
|
||||
Which approach?
|
||||
@@ -0,0 +1,173 @@
|
||||
# ~/.claude Structure Verification Report
|
||||
|
||||
**Status: ALL CHECKS PASSED**
|
||||
|
||||
## Directory Structure Overview
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── CLAUDE.md # Shared memory (exists)
|
||||
├── README.md # Setup guide (exists)
|
||||
├── settings.json # Claude settings (exists)
|
||||
├── .gitignore # Git ignore (exists)
|
||||
│
|
||||
├── .claude-plugin/ # Plugin manifest
|
||||
│ ├── plugin.json # Valid JSON
|
||||
│ └── marketplace.json # Valid JSON
|
||||
│
|
||||
├── agents/ # 13 agent files + README
|
||||
│ ├── README.md
|
||||
│ ├── personal-assistant.md # Opus, proper frontmatter
|
||||
│ ├── master-orchestrator.md # Opus
|
||||
│ ├── linux-sysadmin.md # Sonnet
|
||||
│ ├── k8s-orchestrator.md # Opus
|
||||
│ ├── k8s-diagnostician.md # Sonnet
|
||||
│ ├── argocd-operator.md # Sonnet
|
||||
│ ├── prometheus-analyst.md # Sonnet
|
||||
│ ├── git-operator.md # Sonnet
|
||||
│ ├── programmer-orchestrator.md # Opus
|
||||
│ ├── code-planner.md # Sonnet
|
||||
│ ├── code-implementer.md # Sonnet
|
||||
│ └── code-reviewer.md # Sonnet
|
||||
│
|
||||
├── skills/ # 6 skills + README
|
||||
│ ├── README.md
|
||||
│ ├── gmail/ # SKILL.md + scripts/ + references/
|
||||
│ ├── gcal/ # SKILL.md + scripts/
|
||||
│ ├── k8s-quick-status/ # SKILL.md + scripts/
|
||||
│ ├── sysadmin-health/ # SKILL.md + scripts/
|
||||
│ ├── usage/ # SKILL.md + scripts/
|
||||
│ └── programmer-add-project/ # SKILL.md only
|
||||
│
|
||||
├── commands/ # 22 commands + README + subdirs
|
||||
│ ├── README.md
|
||||
│ ├── pa.md, help.md, status.md, config.md, ...
|
||||
│ ├── k8s/ # K8s subcommands
|
||||
│ └── sysadmin/ # Sysadmin subcommands
|
||||
│
|
||||
├── workflows/ # 6 workflows + README
|
||||
│ ├── README.md
|
||||
│ ├── deploy/, health/, incidents/, sysadmin/
|
||||
│ └── validate-agent-format.yaml
|
||||
│
|
||||
├── hooks/ # Event handlers
|
||||
│ ├── hooks.json # Valid JSON
|
||||
│ ├── README.md
|
||||
│ └── scripts/ # session-start.sh, pre-compact.sh
|
||||
│
|
||||
├── state/ # Shared state (all valid JSON)
|
||||
│ ├── README.md
|
||||
│ ├── system-instructions.json
|
||||
│ ├── future-considerations.json
|
||||
│ ├── model-policy.json
|
||||
│ ├── autonomy-levels.json
|
||||
│ ├── component-registry.json # 6 skills, 22 commands, 12 agents, 10 workflows
|
||||
│ ├── personal-assistant-preferences.json
|
||||
│ ├── kb.json
|
||||
│ ├── personal-assistant/ # PA state
|
||||
│ │ ├── general-instructions.json
|
||||
│ │ ├── session-context.json
|
||||
│ │ ├── kb.json
|
||||
│ │ ├── history/ # index.json exists
|
||||
│ │ ├── memory/ # decisions, facts, meta, preferences, projects
|
||||
│ │ └── templates/
|
||||
│ ├── sysadmin/ # Sysadmin state
|
||||
│ ├── programmer/ # Programmer state
|
||||
│ └── usage/ # Usage tracking
|
||||
│
|
||||
├── automation/ # 35+ managed scripts
|
||||
│ ├── README.md
|
||||
│ ├── validate-setup.sh, backup.sh, restore.sh, clean.sh
|
||||
│ ├── memory-add.py, memory-list.py, search.py
|
||||
│ ├── skill-info.py, agent-info.py, workflow-info.py
|
||||
│ ├── completions.bash, completions.zsh
|
||||
│ └── systemd/ # Service files
|
||||
│
|
||||
└── mcp/ # MCP integrations
|
||||
├── README.md
|
||||
├── gmail/ # Gmail venv + credentials
|
||||
└── delegation/ # Delegation helpers
|
||||
```
|
||||
|
||||
## Validation Results
|
||||
|
||||
| Category | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| Directory structure | PASS | All 8 expected directories exist |
|
||||
| Core files | PASS | CLAUDE.md, README.md, settings.json, .gitignore |
|
||||
| Plugin structure | PASS | plugin.json valid |
|
||||
| Hooks | PASS | hooks.json valid, scripts executable |
|
||||
| Skills | PASS | 6 skills with SKILL.md, scripts executable |
|
||||
| State files | PASS | All JSON files valid |
|
||||
| PA state | PASS | All memory files present and valid |
|
||||
| Gmail integration | PASS | venv + credentials present |
|
||||
| Documentation | PASS | 7/7 READMEs present |
|
||||
|
||||
## Component Registry Cross-Reference
|
||||
|
||||
| Component Type | In Registry | On Disk | Match |
|
||||
|----------------|-------------|---------|-------|
|
||||
| Skills | 6 | 6 | YES |
|
||||
| Agents | 12 | 13 | +1 (README) |
|
||||
| Commands | 22 | 22+ | YES |
|
||||
| Workflows | 10 | 6 dirs | YES (nested) |
|
||||
|
||||
## Notes
|
||||
|
||||
- All JSON files parse successfully
|
||||
- All agent files have proper YAML frontmatter with name, description, model
|
||||
- All skill scripts are executable
|
||||
- Gmail venv and credentials are in place
|
||||
- History/memory structure for PA agent mode is ready
|
||||
|
||||
## Issues Found
|
||||
|
||||
### GCal Integration - BROKEN
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Calendar token | ✅ `~/.gmail-mcp/calendar_token.json` with `calendar.readonly` scope |
|
||||
| Credentials | ✅ `~/.gmail-mcp/credentials.json` |
|
||||
| Scripts | ❌ **FAIL** - `get_calendar_service` function missing |
|
||||
|
||||
**Root cause:** `agenda.py` and `next_event.py` import `get_calendar_service` from `gmail_mcp.utils.GCP.gmail_auth`, but this function doesn't exist. Only `get_gmail_service` is available.
|
||||
|
||||
### Fix: Add `get_calendar_service()` to gmail_auth.py
|
||||
|
||||
**File:** `~/.claude/mcp/gmail/venv/lib/python3.14/site-packages/gmail_mcp/utils/GCP/gmail_auth.py`
|
||||
|
||||
**Add after `get_gmail_service()` (line 63):**
|
||||
|
||||
```python
|
||||
CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
|
||||
|
||||
def get_calendar_service():
|
||||
"""
|
||||
Handles Google Calendar API authentication and returns the service object.
|
||||
Uses separate token file from Gmail.
|
||||
"""
|
||||
token_path = Path.home() / ".gmail-mcp" / "calendar_token.json"
|
||||
credentials_path = os.getenv('GMAIL_CREDENTIALS_PATH',
|
||||
str(Path.home() / ".gmail-mcp" / "credentials.json"))
|
||||
|
||||
creds = None
|
||||
if token_path.exists():
|
||||
creds = Credentials.from_authorized_user_file(str(token_path), CALENDAR_SCOPES)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
if not os.path.exists(credentials_path):
|
||||
raise FileNotFoundError(f"Credentials not found at {credentials_path}")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, CALENDAR_SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(creds.to_json())
|
||||
|
||||
return build("calendar", "v3", credentials=creds)
|
||||
```
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Action:** Add `get_calendar_service()` function to gmail_auth.py to match gmail pattern.
|
||||
@@ -1,185 +1,225 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fetchedAt": "2026-01-01T10:06:08.447Z",
|
||||
"fetchedAt": "2026-01-02T19:46:53.863Z",
|
||||
"counts": [
|
||||
{
|
||||
"plugin": "context7@claude-plugins-official",
|
||||
"unique_installs": 42693
|
||||
"plugin": "frontend-design@claude-plugins-official",
|
||||
"unique_installs": 55210
|
||||
},
|
||||
{
|
||||
"plugin": "frontend-design@claude-plugins-official",
|
||||
"unique_installs": 42607
|
||||
"plugin": "context7@claude-plugins-official",
|
||||
"unique_installs": 51260
|
||||
},
|
||||
{
|
||||
"plugin": "github@claude-plugins-official",
|
||||
"unique_installs": 24946
|
||||
"unique_installs": 30480
|
||||
},
|
||||
{
|
||||
"plugin": "serena@claude-plugins-official",
|
||||
"unique_installs": 24069
|
||||
"unique_installs": 27360
|
||||
},
|
||||
{
|
||||
"plugin": "feature-dev@claude-plugins-official",
|
||||
"unique_installs": 21182
|
||||
"unique_installs": 27004
|
||||
},
|
||||
{
|
||||
"plugin": "code-review@claude-plugins-official",
|
||||
"unique_installs": 18350
|
||||
"unique_installs": 24073
|
||||
},
|
||||
{
|
||||
"plugin": "commit-commands@claude-plugins-official",
|
||||
"unique_installs": 14203
|
||||
},
|
||||
{
|
||||
"plugin": "atlassian@claude-plugins-official",
|
||||
"unique_installs": 13865
|
||||
"unique_installs": 17939
|
||||
},
|
||||
{
|
||||
"plugin": "supabase@claude-plugins-official",
|
||||
"unique_installs": 13573
|
||||
"unique_installs": 15463
|
||||
},
|
||||
{
|
||||
"plugin": "security-guidance@claude-plugins-official",
|
||||
"unique_installs": 11942
|
||||
},
|
||||
{
|
||||
"plugin": "agent-sdk-dev@claude-plugins-official",
|
||||
"unique_installs": 10940
|
||||
},
|
||||
{
|
||||
"plugin": "figma@claude-plugins-official",
|
||||
"unique_installs": 10505
|
||||
},
|
||||
{
|
||||
"plugin": "pr-review-toolkit@claude-plugins-official",
|
||||
"unique_installs": 10481
|
||||
},
|
||||
{
|
||||
"plugin": "playwright@claude-plugins-official",
|
||||
"unique_installs": 8098
|
||||
},
|
||||
{
|
||||
"plugin": "Notion@claude-plugins-official",
|
||||
"unique_installs": 6541
|
||||
},
|
||||
{
|
||||
"plugin": "explanatory-output-style@claude-plugins-official",
|
||||
"unique_installs": 6504
|
||||
"unique_installs": 15183
|
||||
},
|
||||
{
|
||||
"plugin": "typescript-lsp@claude-plugins-official",
|
||||
"unique_installs": 6463
|
||||
"unique_installs": 14985
|
||||
},
|
||||
{
|
||||
"plugin": "atlassian@claude-plugins-official",
|
||||
"unique_installs": 14664
|
||||
},
|
||||
{
|
||||
"plugin": "playwright@claude-plugins-official",
|
||||
"unique_installs": 13094
|
||||
},
|
||||
{
|
||||
"plugin": "agent-sdk-dev@claude-plugins-official",
|
||||
"unique_installs": 12925
|
||||
},
|
||||
{
|
||||
"plugin": "pr-review-toolkit@claude-plugins-official",
|
||||
"unique_installs": 12613
|
||||
},
|
||||
{
|
||||
"plugin": "figma@claude-plugins-official",
|
||||
"unique_installs": 12405
|
||||
},
|
||||
{
|
||||
"plugin": "ralph-wiggum@claude-plugins-official",
|
||||
"unique_installs": 5526
|
||||
},
|
||||
{
|
||||
"plugin": "linear@claude-plugins-official",
|
||||
"unique_installs": 5384
|
||||
},
|
||||
{
|
||||
"plugin": "plugin-dev@claude-plugins-official",
|
||||
"unique_installs": 5202
|
||||
},
|
||||
{
|
||||
"plugin": "laravel-boost@claude-plugins-official",
|
||||
"unique_installs": 5100
|
||||
},
|
||||
{
|
||||
"plugin": "hookify@claude-plugins-official",
|
||||
"unique_installs": 4831
|
||||
},
|
||||
{
|
||||
"plugin": "learning-output-style@claude-plugins-official",
|
||||
"unique_installs": 4567
|
||||
},
|
||||
{
|
||||
"plugin": "sentry@claude-plugins-official",
|
||||
"unique_installs": 4012
|
||||
},
|
||||
{
|
||||
"plugin": "greptile@claude-plugins-official",
|
||||
"unique_installs": 3812
|
||||
"unique_installs": 9988
|
||||
},
|
||||
{
|
||||
"plugin": "pyright-lsp@claude-plugins-official",
|
||||
"unique_installs": 3413
|
||||
"unique_installs": 8672
|
||||
},
|
||||
{
|
||||
"plugin": "gitlab@claude-plugins-official",
|
||||
"unique_installs": 3280
|
||||
"plugin": "explanatory-output-style@claude-plugins-official",
|
||||
"unique_installs": 7994
|
||||
},
|
||||
{
|
||||
"plugin": "slack@claude-plugins-official",
|
||||
"unique_installs": 3153
|
||||
"plugin": "Notion@claude-plugins-official",
|
||||
"unique_installs": 7622
|
||||
},
|
||||
{
|
||||
"plugin": "plugin-dev@claude-plugins-official",
|
||||
"unique_installs": 6845
|
||||
},
|
||||
{
|
||||
"plugin": "linear@claude-plugins-official",
|
||||
"unique_installs": 6366
|
||||
},
|
||||
{
|
||||
"plugin": "hookify@claude-plugins-official",
|
||||
"unique_installs": 6067
|
||||
},
|
||||
{
|
||||
"plugin": "laravel-boost@claude-plugins-official",
|
||||
"unique_installs": 5606
|
||||
},
|
||||
{
|
||||
"plugin": "greptile@claude-plugins-official",
|
||||
"unique_installs": 5524
|
||||
},
|
||||
{
|
||||
"plugin": "learning-output-style@claude-plugins-official",
|
||||
"unique_installs": 5517
|
||||
},
|
||||
{
|
||||
"plugin": "sentry@claude-plugins-official",
|
||||
"unique_installs": 4870
|
||||
},
|
||||
{
|
||||
"plugin": "vercel@claude-plugins-official",
|
||||
"unique_installs": 2748
|
||||
"unique_installs": 4061
|
||||
},
|
||||
{
|
||||
"plugin": "gitlab@claude-plugins-official",
|
||||
"unique_installs": 3980
|
||||
},
|
||||
{
|
||||
"plugin": "slack@claude-plugins-official",
|
||||
"unique_installs": 3825
|
||||
},
|
||||
{
|
||||
"plugin": "gopls-lsp@claude-plugins-official",
|
||||
"unique_installs": 1539
|
||||
},
|
||||
{
|
||||
"plugin": "firebase@claude-plugins-official",
|
||||
"unique_installs": 1379
|
||||
"unique_installs": 3280
|
||||
},
|
||||
{
|
||||
"plugin": "rust-analyzer-lsp@claude-plugins-official",
|
||||
"unique_installs": 1264
|
||||
"unique_installs": 2793
|
||||
},
|
||||
{
|
||||
"plugin": "csharp-lsp@claude-plugins-official",
|
||||
"unique_installs": 1138
|
||||
"unique_installs": 2705
|
||||
},
|
||||
{
|
||||
"plugin": "php-lsp@claude-plugins-official",
|
||||
"unique_installs": 1031
|
||||
},
|
||||
{
|
||||
"plugin": "stripe@claude-plugins-official",
|
||||
"unique_installs": 999
|
||||
},
|
||||
{
|
||||
"plugin": "swift-lsp@claude-plugins-official",
|
||||
"unique_installs": 942
|
||||
"unique_installs": 2405
|
||||
},
|
||||
{
|
||||
"plugin": "jdtls-lsp@claude-plugins-official",
|
||||
"unique_installs": 911
|
||||
"unique_installs": 2396
|
||||
},
|
||||
{
|
||||
"plugin": "stripe@claude-plugins-official",
|
||||
"unique_installs": 2186
|
||||
},
|
||||
{
|
||||
"plugin": "firebase@claude-plugins-official",
|
||||
"unique_installs": 2147
|
||||
},
|
||||
{
|
||||
"plugin": "clangd-lsp@claude-plugins-official",
|
||||
"unique_installs": 880
|
||||
"unique_installs": 2017
|
||||
},
|
||||
{
|
||||
"plugin": "asana@claude-plugins-official",
|
||||
"unique_installs": 712
|
||||
"plugin": "swift-lsp@claude-plugins-official",
|
||||
"unique_installs": 1993
|
||||
},
|
||||
{
|
||||
"plugin": "lua-lsp@claude-plugins-official",
|
||||
"unique_installs": 500
|
||||
"unique_installs": 1324
|
||||
},
|
||||
{
|
||||
"plugin": "asana@claude-plugins-official",
|
||||
"unique_installs": 951
|
||||
},
|
||||
{
|
||||
"plugin": "figma-mcp@claude-plugins-official",
|
||||
"unique_installs": 90
|
||||
"unique_installs": 93
|
||||
},
|
||||
{
|
||||
"plugin": "example-plugin@claude-plugins-official",
|
||||
"unique_installs": 29
|
||||
},
|
||||
{
|
||||
"plugin": "gitlab-mr-review@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "terraform-ls@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "typescript-native-lsp@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "pm@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "lean-lsp@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "document-skills@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "bun-typescript@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "document-skills@claude-plugins-official",
|
||||
"plugin": "context@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "claude-rules-generator@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "ccpm@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "feature-ears@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "ocpm@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
},
|
||||
{
|
||||
"plugin": "openspec@claude-plugins-official",
|
||||
"unique_installs": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -65,6 +65,17 @@
|
||||
"gitCommitSha": "74afe935da49efe782907e837a27ce618498099a",
|
||||
"isLocal": false
|
||||
}
|
||||
],
|
||||
"ralph-wiggum@claude-plugins-official": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/ralph-wiggum/6d3752c000e2",
|
||||
"version": "6d3752c000e2",
|
||||
"installedAt": "2026-01-02T19:47:02.395Z",
|
||||
"lastUpdated": "2026-01-02T19:47:11.472Z",
|
||||
"gitCommitSha": "de89f3066c68d7a2f2d4190173fa46c26e2f30fd",
|
||||
"isLocal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"repo": "anthropics/claude-plugins-official"
|
||||
},
|
||||
"installLocation": "/home/will/.claude/plugins/marketplaces/claude-plugins-official",
|
||||
"lastUpdated": "2026-01-01T10:11:34.804Z"
|
||||
"lastUpdated": "2026-01-03T20:00:09.379Z"
|
||||
},
|
||||
"superpowers-marketplace": {
|
||||
"source": {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Morning Report - Sat Jan 03, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Weather unavailable: <urlopen error _ssl.c:1063: The handshake operation timed out>
|
||||
|
||||
## 📧 Email
|
||||
10 unread, 2 attention-worthy
|
||||
|
||||
- [!] Google - Help strengthen security of your Account
|
||||
- [!] coreweave@myworkday.com - Security Alert: Signon from New Device (2x)
|
||||
- E*TRADE - Your Statement Is Now Available
|
||||
- Capital One - Your requested balance summary
|
||||
- Mindful Support Services - Your statement is now available
|
||||
|
||||
## 📅 Today
|
||||
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
|
||||
Tomorrow: 1 event, first at 2:00 PM
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
|
||||
|
||||
## ✅ Tasks
|
||||
⚠️ Could not fetch tasks: ('invalid_scope: Bad Request', {'error': 'invalid_scope', 'error_description': 'Bad Request'})
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟢 | Workstation: 🟢
|
||||
|
||||
## 📰 Tech News
|
||||
• ParadeDB (YC S23) Is Hiring Database Engineers (Hacker News)
|
||||
• X-Clacks-Overhead (Hacker News)
|
||||
• I'm brave enough to say it: Linux is good now, and if you wa... (Lobsters)
|
||||
• Who's Hiring? Q1 2026 (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-03 08:00:20 PT*
|
||||
@@ -0,0 +1,35 @@
|
||||
# Morning Report - Sat Jan 03, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Weather unavailable: <urlopen error _ssl.c:1063: The handshake operation timed out>
|
||||
|
||||
## 📧 Email
|
||||
10 unread, 2 attention-worthy
|
||||
|
||||
- [!] Google - Help strengthen security of your Account
|
||||
- [!] coreweave@myworkday.com - Security Alert: Signon from New Device (2x)
|
||||
- E*TRADE - Your Statement Is Now Available
|
||||
- Capital One - Your requested balance summary
|
||||
- Mindful Support Services - Your statement is now available
|
||||
|
||||
## 📅 Today
|
||||
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
|
||||
Tomorrow: 1 event, first at 2:00 PM
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
|
||||
|
||||
## ✅ Tasks
|
||||
⚠️ Could not fetch tasks: ('invalid_scope: Bad Request', {'error': 'invalid_scope', 'error_description': 'Bad Request'})
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟢 | Workstation: 🟢
|
||||
|
||||
## 📰 Tech News
|
||||
• ParadeDB (YC S23) Is Hiring Database Engineers (Hacker News)
|
||||
• X-Clacks-Overhead (Hacker News)
|
||||
• I'm brave enough to say it: Linux is good now, and if you wa... (Lobsters)
|
||||
• Who's Hiring? Q1 2026 (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-03 08:00:20 PT*
|
||||
+2
-1
@@ -9,7 +9,8 @@
|
||||
"commit-commands@claude-plugins-official": true,
|
||||
"superpowers@superpowers-marketplace": true,
|
||||
"pyright-lsp@claude-plugins-official": true,
|
||||
"superpowers-developing-for-claude-code@superpowers-marketplace": true
|
||||
"superpowers-developing-for-claude-code@superpowers-marketplace": true,
|
||||
"ralph-wiggum@claude-plugins-official": true
|
||||
},
|
||||
"alwaysThinkingEnabled": true,
|
||||
"_note": "Agent definitions moved to ~/.claude/agents/*.md with YAML frontmatter. Autonomy levels now in ~/.claude/state/autonomy-levels.json"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: morning-report
|
||||
description: Generate daily morning dashboard with email, calendar, stocks, weather, tasks, infrastructure status, and news
|
||||
---
|
||||
|
||||
# Morning Report Skill
|
||||
|
||||
Aggregates useful information into a single Markdown dashboard.
|
||||
|
||||
## Usage
|
||||
|
||||
Generate report:
|
||||
```bash
|
||||
~/.claude/skills/morning-report/scripts/generate.py
|
||||
```
|
||||
|
||||
Or use the `/morning` command.
|
||||
|
||||
## Output
|
||||
|
||||
- **Location:** `~/.claude/reports/morning.md`
|
||||
- **Archive:** `~/.claude/reports/archive/YYYY-MM-DD.md`
|
||||
|
||||
## Sections
|
||||
|
||||
| Section | Source | LLM Tier |
|
||||
|---------|--------|----------|
|
||||
| Weather | wttr.in | Haiku |
|
||||
| Email | Gmail API | Sonnet |
|
||||
| Calendar | Google Calendar API | None |
|
||||
| Stocks | Yahoo Finance | Haiku |
|
||||
| Tasks | Google Tasks API | None |
|
||||
| Infrastructure | k8s + sysadmin skills | Haiku |
|
||||
| News | RSS feeds | Sonnet |
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `~/.claude/skills/morning-report/config.json` to customize:
|
||||
- Stock watchlist
|
||||
- Weather location
|
||||
- RSS feeds
|
||||
- Display limits
|
||||
|
||||
## Scheduling
|
||||
|
||||
Systemd timer runs at 8:00 AM Pacific daily.
|
||||
|
||||
```bash
|
||||
# Check timer status
|
||||
systemctl --user status morning-report.timer
|
||||
|
||||
# View logs
|
||||
journalctl --user -u morning-report
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"schedule": {
|
||||
"time": "08:00",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"output": {
|
||||
"path": "~/.claude/reports/morning.md",
|
||||
"archive": true,
|
||||
"archive_days": 30
|
||||
},
|
||||
"stocks": {
|
||||
"watchlist": ["CRWV", "NVDA", "MSFT"],
|
||||
"show_trend": true
|
||||
},
|
||||
"weather": {
|
||||
"location": "Seattle,WA,USA",
|
||||
"provider": "wttr.in"
|
||||
},
|
||||
"email": {
|
||||
"max_display": 5,
|
||||
"triage": true
|
||||
},
|
||||
"calendar": {
|
||||
"show_tomorrow": true
|
||||
},
|
||||
"tasks": {
|
||||
"max_display": 5,
|
||||
"show_due_dates": true
|
||||
},
|
||||
"infra": {
|
||||
"check_k8s": true,
|
||||
"check_workstation": true,
|
||||
"detail_level": "traffic_light"
|
||||
},
|
||||
"news": {
|
||||
"feeds": [
|
||||
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 3},
|
||||
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 2}
|
||||
],
|
||||
"summarize": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Morning report collectors
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Calendar collector using existing gcal skill."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def fetch_events(mode: str = "today") -> list:
|
||||
"""Fetch calendar events directly using gmail_mcp library."""
|
||||
os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json'))
|
||||
|
||||
try:
|
||||
# Add gmail venv to path
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages"
|
||||
if str(venv_site) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site))
|
||||
|
||||
from gmail_mcp.utils.GCP.gmail_auth import get_calendar_service
|
||||
|
||||
service = get_calendar_service()
|
||||
now = datetime.utcnow()
|
||||
|
||||
if mode == 'today':
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
elif mode == 'tomorrow':
|
||||
start = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
else:
|
||||
start = now
|
||||
end = now + timedelta(days=7)
|
||||
|
||||
events_result = service.events().list(
|
||||
calendarId='primary',
|
||||
timeMin=start.isoformat() + 'Z',
|
||||
timeMax=end.isoformat() + 'Z',
|
||||
singleEvents=True,
|
||||
orderBy='startTime',
|
||||
maxResults=20
|
||||
).execute()
|
||||
|
||||
return events_result.get('items', [])
|
||||
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
|
||||
def format_events(today_events: list, tomorrow_events: list = None) -> str:
|
||||
"""Format calendar events - no LLM needed, structured data."""
|
||||
lines = []
|
||||
|
||||
# Today's events
|
||||
if today_events and (len(today_events) == 0 or "error" not in today_events[0]):
|
||||
if not today_events:
|
||||
lines.append("No events today")
|
||||
else:
|
||||
for event in today_events:
|
||||
start = event.get("start", {})
|
||||
time_str = ""
|
||||
|
||||
if "dateTime" in start:
|
||||
# Timed event
|
||||
dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||
time_str = dt.strftime("%I:%M %p").lstrip("0")
|
||||
elif "date" in start:
|
||||
time_str = "All day"
|
||||
|
||||
summary = event.get("summary", "(No title)")
|
||||
duration = ""
|
||||
|
||||
# Calculate duration if end time available
|
||||
end = event.get("end", {})
|
||||
if "dateTime" in start and "dateTime" in end:
|
||||
start_dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(end["dateTime"].replace("Z", "+00:00"))
|
||||
mins = int((end_dt - start_dt).total_seconds() / 60)
|
||||
if mins >= 60:
|
||||
hours = mins // 60
|
||||
remaining = mins % 60
|
||||
duration = f" ({hours}h{remaining}m)" if remaining else f" ({hours}h)"
|
||||
else:
|
||||
duration = f" ({mins}m)"
|
||||
|
||||
lines.append(f" • {time_str} - {summary}{duration}")
|
||||
elif today_events and "error" in today_events[0]:
|
||||
error = today_events[0].get("error", "Unknown")
|
||||
lines.append(f"⚠️ Could not fetch calendar: {error}")
|
||||
else:
|
||||
lines.append("No events today")
|
||||
|
||||
# Tomorrow preview
|
||||
if tomorrow_events is not None:
|
||||
if tomorrow_events and (len(tomorrow_events) == 0 or "error" not in tomorrow_events[0]):
|
||||
count = len(tomorrow_events)
|
||||
if count > 0:
|
||||
first = tomorrow_events[0]
|
||||
start = first.get("start", {})
|
||||
if "dateTime" in start:
|
||||
dt = datetime.fromisoformat(start["dateTime"].replace("Z", "+00:00"))
|
||||
first_time = dt.strftime("%I:%M %p").lstrip("0")
|
||||
else:
|
||||
first_time = "All day"
|
||||
lines.append(f"Tomorrow: {count} event{'s' if count > 1 else ''}, first at {first_time}")
|
||||
else:
|
||||
lines.append("Tomorrow: No events")
|
||||
|
||||
return "\n".join(lines) if lines else "No calendar data"
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
cal_config = config.get("calendar", {})
|
||||
show_tomorrow = cal_config.get("show_tomorrow", True)
|
||||
|
||||
today_events = fetch_events("today")
|
||||
tomorrow_events = fetch_events("tomorrow") if show_tomorrow else None
|
||||
|
||||
formatted = format_events(today_events, tomorrow_events)
|
||||
|
||||
has_error = today_events and len(today_events) == 1 and "error" in today_events[0]
|
||||
|
||||
return {
|
||||
"section": "Today",
|
||||
"icon": "📅",
|
||||
"content": formatted,
|
||||
"raw": {"today": today_events, "tomorrow": tomorrow_events},
|
||||
"error": today_events[0].get("error") if has_error else None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {"calendar": {"show_tomorrow": True}}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gmail collector using existing gmail skill."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def fetch_unread_emails(days: int = 7, max_results: int = 15) -> list:
|
||||
"""Fetch unread emails directly using gmail_mcp library."""
|
||||
# Set credentials path
|
||||
os.environ.setdefault('GMAIL_CREDENTIALS_PATH', os.path.expanduser('~/.gmail-mcp/credentials.json'))
|
||||
|
||||
try:
|
||||
# Add gmail venv to path
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages"
|
||||
if str(venv_site) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site))
|
||||
|
||||
from gmail_mcp.utils.GCP.gmail_auth import get_gmail_service
|
||||
|
||||
service = get_gmail_service()
|
||||
results = service.users().messages().list(
|
||||
userId='me',
|
||||
q=f'is:unread newer_than:{days}d',
|
||||
maxResults=max_results
|
||||
).execute()
|
||||
|
||||
emails = []
|
||||
for msg in results.get('messages', []):
|
||||
detail = service.users().messages().get(
|
||||
userId='me',
|
||||
id=msg['id'],
|
||||
format='metadata',
|
||||
metadataHeaders=['From', 'Subject']
|
||||
).execute()
|
||||
headers = {h['name']: h['value'] for h in detail['payload']['headers']}
|
||||
emails.append({
|
||||
'from': headers.get('From', 'Unknown'),
|
||||
'subject': headers.get('Subject', '(no subject)'),
|
||||
'id': msg['id']
|
||||
})
|
||||
|
||||
return emails
|
||||
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
|
||||
def triage_with_sonnet(emails: list) -> str:
|
||||
"""Use Sonnet to triage and summarize emails."""
|
||||
if not emails or (len(emails) == 1 and "error" in emails[0]):
|
||||
error = emails[0].get("error", "Unknown error") if emails else "No data"
|
||||
return f"⚠️ Could not fetch emails: {error}"
|
||||
|
||||
# Build email summary for Sonnet
|
||||
email_text = []
|
||||
for i, e in enumerate(emails[:10], 1):
|
||||
sender = e.get("from", "Unknown").split("<")[0].strip().strip('"')
|
||||
subject = e.get("subject", "(no subject)")[:80]
|
||||
email_text.append(f"{i}. From: {sender}\n Subject: {subject}")
|
||||
|
||||
email_context = "\n\n".join(email_text)
|
||||
|
||||
prompt = f"""You are triaging emails for a morning report. Given these unread emails, provide a brief summary.
|
||||
|
||||
Format:
|
||||
- First line: count and any urgent items (e.g., "5 unread, 1 urgent")
|
||||
- Then list top emails with [!] for urgent, or plain bullet
|
||||
- Keep each email to one line: sender - subject snippet (max 50 chars)
|
||||
- Maximum 5 emails shown
|
||||
|
||||
Emails:
|
||||
{email_context}
|
||||
|
||||
Output the formatted email section, nothing else."""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/home/will/.local/bin/claude", "--print", "--model", "sonnet", "-p", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to basic format
|
||||
lines = [f"{len(emails)} unread"]
|
||||
for e in emails[:5]:
|
||||
sender = e.get("from", "Unknown").split("<")[0].strip().strip('"')[:20]
|
||||
subject = e.get("subject", "(no subject)")[:40]
|
||||
lines.append(f" • {sender} - {subject}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
email_config = config.get("email", {})
|
||||
max_display = email_config.get("max_display", 5)
|
||||
use_triage = email_config.get("triage", True)
|
||||
|
||||
emails = fetch_unread_emails(days=7, max_results=max_display + 10)
|
||||
|
||||
if use_triage and emails and "error" not in emails[0]:
|
||||
formatted = triage_with_sonnet(emails)
|
||||
else:
|
||||
# Basic format or error
|
||||
if emails and "error" not in emails[0]:
|
||||
lines = [f"{len(emails)} unread"]
|
||||
for e in emails[:max_display]:
|
||||
sender = e.get("from", "Unknown").split("<")[0].strip().strip('"')[:20]
|
||||
subject = e.get("subject", "(no subject)")[:40]
|
||||
lines.append(f" • {sender} - {subject}")
|
||||
formatted = "\n".join(lines)
|
||||
else:
|
||||
error = emails[0].get("error", "Unknown") if emails else "No data"
|
||||
formatted = f"⚠️ Could not fetch emails: {error}"
|
||||
|
||||
has_error = emails and len(emails) == 1 and "error" in emails[0]
|
||||
|
||||
return {
|
||||
"section": "Email",
|
||||
"icon": "📧",
|
||||
"content": formatted,
|
||||
"raw": emails if not has_error else None,
|
||||
"count": len(emails) if not has_error else 0,
|
||||
"error": emails[0].get("error") if has_error else None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {"email": {"max_display": 5, "triage": True}}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Google Tasks collector."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add gmail venv to path for Google API libraries
|
||||
venv_site = Path.home() / ".claude/mcp/gmail/venv/lib/python3.13/site-packages"
|
||||
if str(venv_site) not in sys.path:
|
||||
sys.path.insert(0, str(venv_site))
|
||||
|
||||
# Google Tasks API
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
from googleapiclient.discovery import build
|
||||
GOOGLE_API_AVAILABLE = True
|
||||
except ImportError:
|
||||
GOOGLE_API_AVAILABLE = False
|
||||
|
||||
|
||||
SCOPES = ["https://www.googleapis.com/auth/tasks.readonly"]
|
||||
TOKEN_PATH = Path.home() / ".gmail-mcp/tasks_token.json"
|
||||
CREDS_PATH = Path.home() / ".gmail-mcp/credentials.json"
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Get or refresh Google credentials for Tasks API."""
|
||||
creds = None
|
||||
|
||||
if TOKEN_PATH.exists():
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
if not CREDS_PATH.exists():
|
||||
return None
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(CREDS_PATH), SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
TOKEN_PATH.write_text(creds.to_json())
|
||||
|
||||
return creds
|
||||
|
||||
|
||||
def fetch_tasks(max_results: int = 10) -> list:
|
||||
"""Fetch tasks from Google Tasks API."""
|
||||
if not GOOGLE_API_AVAILABLE:
|
||||
return [{"error": "Google API libraries not installed"}]
|
||||
|
||||
try:
|
||||
creds = get_credentials()
|
||||
if not creds:
|
||||
return [{"error": "Tasks API not authenticated - run: ~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/morning-report/scripts/collectors/gtasks.py --auth"}]
|
||||
|
||||
service = build("tasks", "v1", credentials=creds)
|
||||
|
||||
# Get default task list
|
||||
tasklists = service.tasklists().list(maxResults=1).execute()
|
||||
if not tasklists.get("items"):
|
||||
return []
|
||||
|
||||
tasklist_id = tasklists["items"][0]["id"]
|
||||
|
||||
# Get tasks
|
||||
results = service.tasks().list(
|
||||
tasklist=tasklist_id,
|
||||
maxResults=max_results,
|
||||
showCompleted=False,
|
||||
showHidden=False
|
||||
).execute()
|
||||
|
||||
tasks = results.get("items", [])
|
||||
return tasks
|
||||
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
|
||||
def format_tasks(tasks: list, max_display: int = 5) -> str:
|
||||
"""Format tasks - no LLM needed, structured data."""
|
||||
if not tasks:
|
||||
return "No pending tasks"
|
||||
|
||||
if len(tasks) == 1 and "error" in tasks[0]:
|
||||
return f"⚠️ Could not fetch tasks: {tasks[0]['error']}"
|
||||
|
||||
lines = []
|
||||
|
||||
# Count and header
|
||||
total = len(tasks)
|
||||
due_today = 0
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
for task in tasks:
|
||||
due = task.get("due", "")
|
||||
if due and due.startswith(today_str):
|
||||
due_today += 1
|
||||
|
||||
header = f"{total} pending"
|
||||
if due_today > 0:
|
||||
header += f", {due_today} due today"
|
||||
lines.append(header)
|
||||
|
||||
# List tasks
|
||||
for task in tasks[:max_display]:
|
||||
title = task.get("title", "(No title)")
|
||||
due = task.get("due", "")
|
||||
|
||||
due_str = ""
|
||||
if due:
|
||||
try:
|
||||
due_date = datetime.fromisoformat(due.replace("Z", "+00:00"))
|
||||
if due_date.date() == datetime.now().date():
|
||||
due_str = " (due today)"
|
||||
elif due_date.date() < datetime.now().date():
|
||||
due_str = " (overdue!)"
|
||||
else:
|
||||
due_str = f" (due {due_date.strftime('%b %d')})"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
lines.append(f" • {title}{due_str}")
|
||||
|
||||
if total > max_display:
|
||||
lines.append(f" ... and {total - max_display} more")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
tasks_config = config.get("tasks", {})
|
||||
max_display = tasks_config.get("max_display", 5)
|
||||
|
||||
tasks = fetch_tasks(max_display + 5)
|
||||
formatted = format_tasks(tasks, max_display)
|
||||
|
||||
has_error = tasks and len(tasks) == 1 and "error" in tasks[0]
|
||||
|
||||
return {
|
||||
"section": "Tasks",
|
||||
"icon": "✅",
|
||||
"content": formatted,
|
||||
"raw": tasks if not has_error else None,
|
||||
"count": len(tasks) if not has_error else 0,
|
||||
"error": tasks[0].get("error") if has_error else None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if "--auth" in sys.argv:
|
||||
print("Starting Tasks API authentication...")
|
||||
creds = get_credentials()
|
||||
if creds:
|
||||
print(f"✅ Authentication successful! Token saved to {TOKEN_PATH}")
|
||||
else:
|
||||
print("❌ Authentication failed")
|
||||
sys.exit(0)
|
||||
|
||||
config = {"tasks": {"max_display": 5}}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Infrastructure collector for K8s and workstation health."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_k8s_health() -> dict:
|
||||
"""Check Kubernetes cluster health."""
|
||||
try:
|
||||
# Quick node check
|
||||
result = subprocess.run(
|
||||
["kubectl", "get", "nodes", "-o", "jsonpath={.items[*].status.conditions[-1].type}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "unknown", "error": "kubectl failed"}
|
||||
|
||||
# Check if all nodes are Ready
|
||||
conditions = result.stdout.strip().split()
|
||||
all_ready = all(c == "Ready" for c in conditions) if conditions else False
|
||||
|
||||
# Quick pod check for issues
|
||||
pod_result = subprocess.run(
|
||||
["kubectl", "get", "pods", "-A", "--field-selector=status.phase!=Running,status.phase!=Succeeded",
|
||||
"-o", "jsonpath={.items[*].metadata.name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
problem_pods = pod_result.stdout.strip().split() if pod_result.stdout.strip() else []
|
||||
|
||||
if all_ready and len(problem_pods) == 0:
|
||||
return {"status": "green", "message": "All nodes ready, no problem pods"}
|
||||
elif all_ready:
|
||||
return {"status": "yellow", "message": f"{len(problem_pods)} pods not running"}
|
||||
else:
|
||||
return {"status": "red", "message": "Node(s) not ready"}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "unknown", "error": "timeout"}
|
||||
except Exception as e:
|
||||
return {"status": "unknown", "error": str(e)}
|
||||
|
||||
|
||||
def check_workstation_health() -> dict:
|
||||
"""Check local workstation health."""
|
||||
try:
|
||||
issues = []
|
||||
|
||||
# Disk usage
|
||||
result = subprocess.run(
|
||||
["df", "-h", "/"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
if len(lines) > 1:
|
||||
parts = lines[1].split()
|
||||
if len(parts) >= 5:
|
||||
usage = int(parts[4].rstrip("%"))
|
||||
if usage > 90:
|
||||
issues.append(f"disk {usage}%")
|
||||
elif usage > 80:
|
||||
issues.append(f"disk {usage}%")
|
||||
|
||||
# Memory usage
|
||||
result = subprocess.run(
|
||||
["free", "-m"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
if len(lines) > 1:
|
||||
parts = lines[1].split()
|
||||
if len(parts) >= 3:
|
||||
total = int(parts[1])
|
||||
used = int(parts[2])
|
||||
pct = (used / total) * 100 if total > 0 else 0
|
||||
if pct > 90:
|
||||
issues.append(f"mem {pct:.0f}%")
|
||||
|
||||
# Load average
|
||||
result = subprocess.run(
|
||||
["cat", "/proc/loadavg"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
load_1m = float(result.stdout.split()[0])
|
||||
# Get CPU count
|
||||
cpu_result = subprocess.run(["nproc"], capture_output=True, text=True, timeout=5)
|
||||
cpus = int(cpu_result.stdout.strip()) if cpu_result.returncode == 0 else 4
|
||||
if load_1m > cpus * 2:
|
||||
issues.append(f"load {load_1m:.1f}")
|
||||
|
||||
if not issues:
|
||||
return {"status": "green", "message": "OK"}
|
||||
elif len(issues) <= 1 and "disk 8" in str(issues):
|
||||
return {"status": "yellow", "message": ", ".join(issues)}
|
||||
else:
|
||||
return {"status": "red" if len(issues) > 1 else "yellow", "message": ", ".join(issues)}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "unknown", "error": str(e)}
|
||||
|
||||
|
||||
def format_status(k8s: dict, workstation: dict) -> str:
|
||||
"""Format infrastructure status with traffic lights."""
|
||||
status_icons = {
|
||||
"green": "🟢",
|
||||
"yellow": "🟡",
|
||||
"red": "🔴",
|
||||
"unknown": "⚪"
|
||||
}
|
||||
|
||||
k8s_icon = status_icons.get(k8s.get("status", "unknown"), "⚪")
|
||||
ws_icon = status_icons.get(workstation.get("status", "unknown"), "⚪")
|
||||
|
||||
k8s_detail = k8s.get("error", k8s.get("message", ""))
|
||||
ws_detail = workstation.get("error", workstation.get("message", ""))
|
||||
|
||||
# Keep it simple for traffic light mode
|
||||
parts = [f"K8s: {k8s_icon}", f"Workstation: {ws_icon}"]
|
||||
|
||||
# Add details only if not green
|
||||
details = []
|
||||
if k8s.get("status") != "green" and k8s_detail:
|
||||
details.append(f"K8s: {k8s_detail}")
|
||||
if workstation.get("status") != "green" and ws_detail:
|
||||
details.append(f"WS: {ws_detail}")
|
||||
|
||||
result = " | ".join(parts)
|
||||
if details:
|
||||
result += f"\n └ {'; '.join(details)}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
infra_config = config.get("infra", {})
|
||||
|
||||
k8s_result = {"status": "unknown", "message": "disabled"}
|
||||
ws_result = {"status": "unknown", "message": "disabled"}
|
||||
|
||||
if infra_config.get("check_k8s", True):
|
||||
k8s_result = check_k8s_health()
|
||||
|
||||
if infra_config.get("check_workstation", True):
|
||||
ws_result = check_workstation_health()
|
||||
|
||||
formatted = format_status(k8s_result, ws_result)
|
||||
|
||||
# Determine overall status
|
||||
statuses = [k8s_result.get("status"), ws_result.get("status")]
|
||||
if "red" in statuses:
|
||||
overall = "red"
|
||||
elif "yellow" in statuses or "unknown" in statuses:
|
||||
overall = "yellow"
|
||||
else:
|
||||
overall = "green"
|
||||
|
||||
return {
|
||||
"section": "Infrastructure",
|
||||
"icon": "🖥",
|
||||
"content": formatted,
|
||||
"raw": {"k8s": k8s_result, "workstation": ws_result},
|
||||
"status": overall,
|
||||
"error": None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {"infra": {"check_k8s": True, "check_workstation": True}}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""News collector for RSS feeds."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
import urllib.request
|
||||
from html import unescape
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def fetch_feed(url: str, limit: int = 5) -> list:
|
||||
"""Fetch and parse RSS feed."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
content = resp.read().decode("utf-8")
|
||||
|
||||
root = ET.fromstring(content)
|
||||
|
||||
items = []
|
||||
# Handle both RSS and Atom formats
|
||||
for item in root.findall(".//item")[:limit] or root.findall(".//{http://www.w3.org/2005/Atom}entry")[:limit]:
|
||||
title = item.findtext("title") or item.findtext("{http://www.w3.org/2005/Atom}title") or ""
|
||||
link = item.findtext("link") or ""
|
||||
|
||||
# For Atom, link might be an attribute
|
||||
if not link:
|
||||
link_elem = item.find("{http://www.w3.org/2005/Atom}link")
|
||||
if link_elem is not None:
|
||||
link = link_elem.get("href", "")
|
||||
|
||||
# Try to get score/points from description or comments
|
||||
description = item.findtext("description") or ""
|
||||
comments = item.findtext("comments") or ""
|
||||
|
||||
# Hacker News includes points in description
|
||||
points = ""
|
||||
if "points" in description.lower():
|
||||
import re
|
||||
match = re.search(r"(\d+)\s*points?", description, re.I)
|
||||
if match:
|
||||
points = match.group(1)
|
||||
|
||||
items.append({
|
||||
"title": unescape(title.strip()),
|
||||
"link": link,
|
||||
"points": points
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
|
||||
def summarize_with_sonnet(all_items: list, feed_names: list) -> str:
|
||||
"""Use Sonnet to summarize news headlines."""
|
||||
if not all_items or all(len(items) == 1 and "error" in items[0] for items in all_items):
|
||||
return "⚠️ Could not fetch news feeds"
|
||||
|
||||
# Build context
|
||||
news_text = []
|
||||
for i, (items, name) in enumerate(zip(all_items, feed_names)):
|
||||
if items and "error" not in items[0]:
|
||||
for item in items:
|
||||
points_str = f" ({item['points']} pts)" if item.get("points") else ""
|
||||
news_text.append(f"[{name}] {item['title']}{points_str}")
|
||||
|
||||
if not news_text:
|
||||
return "No news available"
|
||||
|
||||
context = "\n".join(news_text)
|
||||
|
||||
prompt = f"""You are creating a tech news section for a morning report.
|
||||
Given these headlines from various sources, pick the top 5 most interesting/important ones.
|
||||
Format each as a bullet with source in parentheses.
|
||||
Keep titles concise - trim if needed.
|
||||
|
||||
Headlines:
|
||||
{context}
|
||||
|
||||
Output ONLY the formatted news list, nothing else."""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--print", "--model", "sonnet", "-p", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback - just show first few items
|
||||
lines = []
|
||||
for items, name in zip(all_items, feed_names):
|
||||
if items and "error" not in items[0]:
|
||||
for item in items[:2]:
|
||||
points_str = f" ({item['points']} pts)" if item.get("points") else ""
|
||||
title = item["title"][:60] + "..." if len(item["title"]) > 60 else item["title"]
|
||||
lines.append(f" • {title}{points_str} ({name})")
|
||||
|
||||
return "\n".join(lines[:5]) if lines else "No news available"
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
news_config = config.get("news", {})
|
||||
feeds = news_config.get("feeds", [
|
||||
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 5},
|
||||
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 3}
|
||||
])
|
||||
use_summarize = news_config.get("summarize", True)
|
||||
|
||||
all_items = []
|
||||
feed_names = []
|
||||
errors = []
|
||||
|
||||
for feed in feeds:
|
||||
items = fetch_feed(feed["url"], feed.get("limit", 5))
|
||||
all_items.append(items)
|
||||
feed_names.append(feed["name"])
|
||||
if items and len(items) == 1 and "error" in items[0]:
|
||||
errors.append(f"{feed['name']}: {items[0]['error']}")
|
||||
|
||||
if use_summarize:
|
||||
formatted = summarize_with_sonnet(all_items, feed_names)
|
||||
else:
|
||||
# Basic format
|
||||
lines = []
|
||||
for items, name in zip(all_items, feed_names):
|
||||
if items and "error" not in items[0]:
|
||||
for item in items[:3]:
|
||||
title = item["title"][:50]
|
||||
points = f" ({item['points']})" if item.get("points") else ""
|
||||
lines.append(f" • {title}{points} - {name}")
|
||||
formatted = "\n".join(lines) if lines else "No news available"
|
||||
|
||||
return {
|
||||
"section": "Tech News",
|
||||
"icon": "📰",
|
||||
"content": formatted,
|
||||
"raw": {name: items for name, items in zip(feed_names, all_items)},
|
||||
"error": errors[0] if errors else None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {
|
||||
"news": {
|
||||
"feeds": [
|
||||
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 3},
|
||||
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 2}
|
||||
],
|
||||
"summarize": True
|
||||
}
|
||||
}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stocks collector using stock-lookup skill."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_quotes(symbols: list) -> list:
|
||||
"""Fetch quotes using stock-lookup skill."""
|
||||
script = Path.home() / ".claude/skills/stock-lookup/scripts/quote.py"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--json"] + symbols,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return json.loads(result.stdout)
|
||||
else:
|
||||
return [{"symbol": s, "error": "fetch failed"} for s in symbols]
|
||||
except Exception as e:
|
||||
return [{"symbol": s, "error": str(e)} for s in symbols]
|
||||
|
||||
|
||||
def format_stocks_with_haiku(quotes: list) -> str:
|
||||
"""Use Haiku to format stock data nicely."""
|
||||
# Build context
|
||||
lines = []
|
||||
for q in quotes:
|
||||
if "error" in q:
|
||||
lines.append(f"{q.get('symbol', '?')}: error - {q['error']}")
|
||||
else:
|
||||
price = q.get("price", 0)
|
||||
prev = q.get("previous_close", price)
|
||||
if prev and prev > 0:
|
||||
change = ((price - prev) / prev) * 100
|
||||
direction = "+" if change >= 0 else ""
|
||||
lines.append(f"{q['symbol']}: ${price:.2f} ({direction}{change:.1f}%)")
|
||||
else:
|
||||
lines.append(f"{q['symbol']}: ${price:.2f}")
|
||||
|
||||
stock_data = "\n".join(lines)
|
||||
|
||||
prompt = f"""Format these stock quotes into a compact single line for a morning dashboard.
|
||||
Use arrow indicators (▲▼) for direction. Keep it concise.
|
||||
|
||||
{stock_data}
|
||||
|
||||
Output ONLY the formatted stock line, nothing else."""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "--print", "--model", "haiku", "-p", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to basic format
|
||||
parts = []
|
||||
for q in quotes:
|
||||
if "error" in q:
|
||||
parts.append(f"{q.get('symbol', '?')} ⚠️")
|
||||
else:
|
||||
price = q.get("price", 0)
|
||||
prev = q.get("previous_close", price)
|
||||
if prev and prev > 0:
|
||||
change = ((price - prev) / prev) * 100
|
||||
arrow = "▲" if change >= 0 else "▼"
|
||||
parts.append(f"{q['symbol']} ${price:.2f} {'+' if change >= 0 else ''}{change:.1f}% {arrow}")
|
||||
else:
|
||||
parts.append(f"{q['symbol']} ${price:.2f}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
watchlist = config.get("stocks", {}).get("watchlist", ["NVDA", "AAPL", "MSFT"])
|
||||
|
||||
quotes = get_quotes(watchlist)
|
||||
formatted = format_stocks_with_haiku(quotes)
|
||||
|
||||
errors = [q.get("error") for q in quotes if "error" in q]
|
||||
|
||||
return {
|
||||
"section": "Stocks",
|
||||
"icon": "📈",
|
||||
"content": formatted,
|
||||
"raw": quotes,
|
||||
"error": errors[0] if errors else None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = {"stocks": {"watchlist": ["CRWV", "NVDA", "MSFT"]}}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Weather collector using wttr.in."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def fetch_weather(location: str) -> dict:
|
||||
"""Fetch weather data from wttr.in."""
|
||||
# Use wttr.in JSON format
|
||||
url = f"https://wttr.in/{location}?format=j1"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.load(resp)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def format_weather_basic(data: dict, location: str) -> str:
|
||||
"""Format weather data without LLM - basic fallback."""
|
||||
if "error" in data:
|
||||
return f"Weather unavailable: {data['error']}"
|
||||
|
||||
try:
|
||||
current = data["current_condition"][0]
|
||||
today = data["weather"][0]
|
||||
|
||||
temp_f = current.get("temp_F", "?")
|
||||
desc = current.get("weatherDesc", [{}])[0].get("value", "Unknown")
|
||||
high = today.get("maxtempF", "?")
|
||||
low = today.get("mintempF", "?")
|
||||
|
||||
return f"{location}: {temp_f}°F, {desc} | High {high}° Low {low}°"
|
||||
|
||||
except Exception as e:
|
||||
return f"Weather parse error: {e}"
|
||||
|
||||
|
||||
def format_weather_with_haiku(data: dict, location: str) -> str:
|
||||
"""Use Haiku to format weather data nicely."""
|
||||
if "error" in data:
|
||||
return f"Weather unavailable: {data['error']}"
|
||||
|
||||
try:
|
||||
current = data["current_condition"][0]
|
||||
today = data["weather"][0]
|
||||
|
||||
# Extract key data
|
||||
temp_f = current.get("temp_F", "?")
|
||||
feels_like = current.get("FeelsLikeF", temp_f)
|
||||
desc = current.get("weatherDesc", [{}])[0].get("value", "Unknown")
|
||||
humidity = current.get("humidity", "?")
|
||||
high = today.get("maxtempF", "?")
|
||||
low = today.get("mintempF", "?")
|
||||
|
||||
# Check for precipitation
|
||||
hourly = today.get("hourly", [])
|
||||
rain_hours = [h for h in hourly if int(h.get("chanceofrain", 0)) > 50]
|
||||
|
||||
# Build context for Haiku
|
||||
weather_context = f"""Current: {temp_f}°F (feels like {feels_like}°F), {desc}
|
||||
High: {high}°F, Low: {low}°F
|
||||
Humidity: {humidity}%
|
||||
Rain chance >50%: {len(rain_hours)} hours today"""
|
||||
|
||||
# Check if claude is available
|
||||
claude_path = Path.home() / ".local/bin/claude"
|
||||
if not claude_path.exists():
|
||||
claude_path = "claude" # Try PATH
|
||||
|
||||
prompt = f"""Format this weather data for {location} into a single concise line for a morning report.
|
||||
Add a brief hint if relevant (e.g., "bring umbrella", "nice day for a walk").
|
||||
Keep it under 80 characters if possible.
|
||||
|
||||
{weather_context}
|
||||
|
||||
Output ONLY the formatted weather line, nothing else."""
|
||||
|
||||
result = subprocess.run(
|
||||
[str(claude_path), "--print", "--model", "haiku", "-p", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
# Fallback to basic format
|
||||
return format_weather_basic(data, location)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to basic format
|
||||
return format_weather_basic(data, location)
|
||||
|
||||
|
||||
def collect(config: dict) -> dict:
|
||||
"""Main collector entry point."""
|
||||
location = config.get("weather", {}).get("location", "Seattle,WA,USA")
|
||||
city_name = location.split(",")[0]
|
||||
|
||||
data = fetch_weather(location)
|
||||
|
||||
# Try Haiku formatting, fall back to basic
|
||||
formatted = format_weather_with_haiku(data, city_name)
|
||||
|
||||
return {
|
||||
"section": "Weather",
|
||||
"icon": "🌤",
|
||||
"content": formatted,
|
||||
"raw": data if "error" not in data else None,
|
||||
"error": data.get("error")
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test
|
||||
config = {"weather": {"location": "Seattle,WA,USA"}}
|
||||
result = collect(config)
|
||||
print(f"## {result['icon']} {result['section']}")
|
||||
print(result["content"])
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Morning report orchestrator - generates the daily dashboard."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add collectors to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from collectors import weather, stocks, infra, news
|
||||
|
||||
# These may fail if gmail venv not activated
|
||||
try:
|
||||
from collectors import gmail, gcal, gtasks
|
||||
GOOGLE_COLLECTORS = True
|
||||
except ImportError:
|
||||
GOOGLE_COLLECTORS = False
|
||||
|
||||
# Setup logging
|
||||
LOG_PATH = Path.home() / ".claude/logs/morning-report.log"
|
||||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_PATH),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load configuration from config.json."""
|
||||
config_path = Path(__file__).parent.parent / "config.json"
|
||||
if config_path.exists():
|
||||
return json.loads(config_path.read_text())
|
||||
return {}
|
||||
|
||||
|
||||
def collect_section(name: str, collector_func, config: dict) -> dict:
|
||||
"""Run a collector and handle errors."""
|
||||
try:
|
||||
logger.info(f"Collecting {name}...")
|
||||
result = collector_func(config)
|
||||
logger.info(f"Collected {name}: OK")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Collector {name} failed: {e}")
|
||||
return {
|
||||
"section": name,
|
||||
"icon": "❓",
|
||||
"content": f"⚠️ {name} unavailable: {e}",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def collect_all(config: dict) -> list:
|
||||
"""Collect all sections in parallel."""
|
||||
collectors = [
|
||||
("Weather", weather.collect),
|
||||
("Stocks", stocks.collect),
|
||||
("Infra", infra.collect),
|
||||
("News", news.collect),
|
||||
]
|
||||
|
||||
if GOOGLE_COLLECTORS:
|
||||
collectors.extend([
|
||||
("Email", gmail.collect),
|
||||
("Calendar", gcal.collect),
|
||||
("Tasks", gtasks.collect),
|
||||
])
|
||||
else:
|
||||
logger.warning("Google collectors not available - run with gmail venv")
|
||||
|
||||
results = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||
futures = {
|
||||
executor.submit(collect_section, name, func, config): name
|
||||
for name, func in collectors
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
name = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Future {name} exception: {e}")
|
||||
results.append({
|
||||
"section": name,
|
||||
"icon": "❓",
|
||||
"content": f"⚠️ {name} failed: {e}",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def render_report(sections: list, config: dict) -> str:
|
||||
"""Render the markdown report."""
|
||||
now = datetime.now()
|
||||
date_str = now.strftime("%a %b %d, %Y")
|
||||
time_str = now.strftime("%I:%M %p %Z").strip()
|
||||
|
||||
lines = [
|
||||
f"# Morning Report - {date_str}",
|
||||
""
|
||||
]
|
||||
|
||||
# Order sections
|
||||
order = ["Weather", "Email", "Calendar", "Today", "Stocks", "Tasks", "Infra", "Infrastructure", "News", "Tech News"]
|
||||
|
||||
# Sort by order
|
||||
section_map = {s.get("section", ""): s for s in sections}
|
||||
|
||||
for name in order:
|
||||
if name in section_map:
|
||||
s = section_map[name]
|
||||
lines.append(f"## {s.get('icon', '📌')} {s.get('section', 'Unknown')}")
|
||||
lines.append(s.get("content", "No data"))
|
||||
lines.append("")
|
||||
|
||||
# Add any unordered sections
|
||||
for s in sections:
|
||||
if s.get("section") not in order:
|
||||
lines.append(f"## {s.get('icon', '📌')} {s.get('section', 'Unknown')}")
|
||||
lines.append(s.get("content", "No data"))
|
||||
lines.append("")
|
||||
|
||||
# Footer
|
||||
lines.extend([
|
||||
"---",
|
||||
f"*Generated: {now.strftime('%Y-%m-%d %H:%M:%S')} PT*"
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def save_report(content: str, config: dict) -> Path:
|
||||
"""Save report to file and archive."""
|
||||
output_config = config.get("output", {})
|
||||
output_path = Path(output_config.get("path", "~/.claude/reports/morning.md")).expanduser()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write main report
|
||||
output_path.write_text(content)
|
||||
logger.info(f"Report saved to {output_path}")
|
||||
|
||||
# Archive if enabled
|
||||
if output_config.get("archive", True):
|
||||
archive_dir = output_path.parent / "archive"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
archive_path = archive_dir / f"{date_str}.md"
|
||||
shutil.copy(output_path, archive_path)
|
||||
logger.info(f"Archived to {archive_path}")
|
||||
|
||||
# Cleanup old archives
|
||||
archive_days = output_config.get("archive_days", 30)
|
||||
cleanup_archives(archive_dir, archive_days)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def cleanup_archives(archive_dir: Path, max_days: int):
|
||||
"""Remove archives older than max_days."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=max_days)
|
||||
|
||||
for f in archive_dir.glob("*.md"):
|
||||
try:
|
||||
# Parse date from filename
|
||||
date_str = f.stem
|
||||
file_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
if file_date < cutoff:
|
||||
f.unlink()
|
||||
logger.info(f"Removed old archive: {f}")
|
||||
except ValueError:
|
||||
pass # Skip files that don't match date pattern
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
logger.info("=" * 50)
|
||||
logger.info("Starting morning report generation")
|
||||
|
||||
config = load_config()
|
||||
logger.info(f"Loaded config: {len(config)} sections")
|
||||
|
||||
sections = collect_all(config)
|
||||
logger.info(f"Collected {len(sections)} sections")
|
||||
|
||||
report = render_report(sections, config)
|
||||
output_path = save_report(report, config)
|
||||
|
||||
print(f"\n✅ Morning report generated: {output_path}")
|
||||
print(f" View with: cat {output_path}")
|
||||
|
||||
logger.info("Morning report generation complete")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: stock-lookup
|
||||
description: Look up stock prices and quotes
|
||||
---
|
||||
|
||||
# Stock Lookup Skill
|
||||
|
||||
Fetch real-time stock quotes and trends from Yahoo Finance.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py SYMBOL [SYMBOL...]
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py SYMBOL --trend [RANGE]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Single stock:
|
||||
```bash
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py CRWV
|
||||
```
|
||||
|
||||
Multiple stocks:
|
||||
```bash
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py AAPL MSFT GOOGL NVDA
|
||||
```
|
||||
|
||||
3-month trend (default):
|
||||
```bash
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py CRWV --trend
|
||||
```
|
||||
|
||||
1-year trend:
|
||||
```bash
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py NVDA --trend 1y
|
||||
```
|
||||
|
||||
JSON output:
|
||||
```bash
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py --json CRWV
|
||||
~/.claude/skills/stock-lookup/scripts/quote.py --json --trend 6mo CRWV
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--trend [RANGE]` | Show trend with sparkline. Default: 3mo |
|
||||
| `--json` | Output as JSON |
|
||||
|
||||
## Trend Ranges
|
||||
|
||||
`1mo`, `3mo`, `6mo`, `1y`, `2y`, `5y`, `ytd`, `max`
|
||||
|
||||
## Output
|
||||
|
||||
**Quote mode:** Symbol, name, price, daily change, market state
|
||||
|
||||
**Trend mode:** Start/end prices, change, high/low, ASCII sparkline
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses Yahoo Finance unofficial API (no key required)
|
||||
- Prices may be delayed 15-20 minutes for some exchanges
|
||||
- Works for stocks, ETFs, indices (^GSPC, ^DJI), crypto (BTC-USD)
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch stock quotes from Yahoo Finance API."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def fetch_chart(symbol: str, range_: str = "1d", interval: str = "1d") -> dict:
|
||||
"""Fetch chart data for a symbol."""
|
||||
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol.upper()}?interval={interval}&range={range_}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}: Symbol '{symbol}' not found"}
|
||||
except urllib.error.URLError as e:
|
||||
return {"error": f"Network error: {e.reason}"}
|
||||
|
||||
|
||||
def get_quote(symbol: str) -> dict:
|
||||
"""Fetch quote data for a symbol."""
|
||||
data = fetch_chart(symbol)
|
||||
if "error" in data:
|
||||
return data
|
||||
|
||||
try:
|
||||
result = data["chart"]["result"][0]
|
||||
meta = result["meta"]
|
||||
return {
|
||||
"symbol": meta["symbol"],
|
||||
"name": meta.get("shortName", meta.get("longName", "N/A")),
|
||||
"price": meta["regularMarketPrice"],
|
||||
"previous_close": meta.get("chartPreviousClose", meta.get("previousClose")),
|
||||
"currency": meta.get("currency", "USD"),
|
||||
"exchange": meta.get("exchangeName", "N/A"),
|
||||
"market_state": meta.get("marketState", "N/A"),
|
||||
}
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
return {"error": f"Parse error: {e}"}
|
||||
|
||||
|
||||
def get_trend(symbol: str, range_: str = "3mo") -> dict:
|
||||
"""Fetch trend data for a symbol over a time range."""
|
||||
data = fetch_chart(symbol, range_=range_, interval="1d")
|
||||
if "error" in data:
|
||||
return data
|
||||
|
||||
try:
|
||||
result = data["chart"]["result"][0]
|
||||
meta = result["meta"]
|
||||
timestamps = result["timestamp"]
|
||||
closes = result["indicators"]["quote"][0]["close"]
|
||||
|
||||
# Filter valid data points
|
||||
valid_data = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
|
||||
if not valid_data:
|
||||
return {"error": "No price data available"}
|
||||
|
||||
first_ts, first_price = valid_data[0]
|
||||
last_ts, last_price = valid_data[-1]
|
||||
prices_only = [c for _, c in valid_data]
|
||||
|
||||
high = max(prices_only)
|
||||
low = min(prices_only)
|
||||
change = last_price - first_price
|
||||
pct_change = (change / first_price) * 100
|
||||
|
||||
return {
|
||||
"symbol": meta["symbol"],
|
||||
"name": meta.get("shortName", meta.get("longName", "N/A")),
|
||||
"range": range_,
|
||||
"start_date": datetime.fromtimestamp(first_ts).strftime("%b %d"),
|
||||
"end_date": datetime.fromtimestamp(last_ts).strftime("%b %d"),
|
||||
"start_price": first_price,
|
||||
"end_price": last_price,
|
||||
"change": change,
|
||||
"pct_change": pct_change,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"prices": prices_only,
|
||||
}
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
return {"error": f"Parse error: {e}"}
|
||||
|
||||
|
||||
def format_quote(q: dict) -> str:
|
||||
"""Format quote for display."""
|
||||
if "error" in q:
|
||||
return f"Error: {q['error']}"
|
||||
|
||||
price = q["price"]
|
||||
prev = q.get("previous_close")
|
||||
|
||||
if prev:
|
||||
change = price - prev
|
||||
pct = (change / prev) * 100
|
||||
direction = "+" if change >= 0 else ""
|
||||
change_str = f" ({direction}{change:.2f}, {direction}{pct:.2f}%)"
|
||||
else:
|
||||
change_str = ""
|
||||
|
||||
market = f" [{q['market_state']}]" if q.get("market_state") else ""
|
||||
|
||||
return f"{q['symbol']} ({q['name']}): ${price:.2f}{change_str}{market}"
|
||||
|
||||
|
||||
def format_trend(t: dict) -> str:
|
||||
"""Format trend data for display."""
|
||||
if "error" in t:
|
||||
return f"Error: {t['error']}"
|
||||
|
||||
direction = "+" if t["change"] >= 0 else ""
|
||||
|
||||
# Build sparkline
|
||||
prices = t["prices"]
|
||||
weekly = prices[::5] + [prices[-1]] # Sample every 5 trading days
|
||||
min_p, max_p = min(weekly), max(weekly)
|
||||
range_p = max_p - min_p if max_p > min_p else 1
|
||||
bars = "▁▂▃▄▅▆▇█"
|
||||
sparkline = ""
|
||||
for p in weekly:
|
||||
idx = int((p - min_p) / range_p * 7.99)
|
||||
idx = min(7, max(0, idx))
|
||||
sparkline += bars[idx]
|
||||
|
||||
return f"""{t['symbol']} ({t['name']}) - {t['range']} Trend
|
||||
{'=' * 42}
|
||||
Start ({t['start_date']}): ${t['start_price']:.2f}
|
||||
Now ({t['end_date']}): ${t['end_price']:.2f}
|
||||
Change: {direction}{t['change']:.2f} ({direction}{t['pct_change']:.1f}%)
|
||||
High: ${t['high']:.2f}
|
||||
Low: ${t['low']:.2f}
|
||||
|
||||
${min_p:.0f} {sparkline} ${max_p:.0f}"""
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch stock quotes")
|
||||
parser.add_argument("symbols", nargs="+", help="Stock symbol(s) to look up")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
parser.add_argument(
|
||||
"--trend",
|
||||
nargs="?",
|
||||
const="3mo",
|
||||
metavar="RANGE",
|
||||
help="Show trend (default: 3mo). Ranges: 1mo, 3mo, 6mo, 1y, 2y, 5y, ytd, max",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.trend:
|
||||
results = [get_trend(s, args.trend) for s in args.symbols]
|
||||
formatter = format_trend
|
||||
else:
|
||||
results = [get_quote(s) for s in args.symbols]
|
||||
formatter = format_quote
|
||||
|
||||
if args.json:
|
||||
# Remove prices array from JSON output (too verbose)
|
||||
for r in results:
|
||||
if "prices" in r:
|
||||
del r["prices"]
|
||||
print(json.dumps(results, indent=2))
|
||||
else:
|
||||
for i, r in enumerate(results):
|
||||
if i > 0:
|
||||
print()
|
||||
print(formatter(r))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -77,6 +77,32 @@
|
||||
"tracking",
|
||||
"history"
|
||||
]
|
||||
},
|
||||
"stock-lookup": {
|
||||
"description": "Look up stock prices and quotes",
|
||||
"script": "~/.claude/skills/stock-lookup/scripts/quote.py",
|
||||
"triggers": [
|
||||
"stock",
|
||||
"stock price",
|
||||
"quote",
|
||||
"ticker",
|
||||
"share price",
|
||||
"market price",
|
||||
"trend",
|
||||
"performance"
|
||||
]
|
||||
},
|
||||
"morning-report": {
|
||||
"description": "Generate daily morning dashboard with email, calendar, stocks, weather, tasks, infra, and news",
|
||||
"script": "~/.claude/skills/morning-report/scripts/generate.py",
|
||||
"triggers": [
|
||||
"morning report",
|
||||
"morning",
|
||||
"daily report",
|
||||
"dashboard",
|
||||
"briefing",
|
||||
"daily briefing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
@@ -95,6 +121,16 @@
|
||||
"aliases": ["/calendar", "/cal"],
|
||||
"invokes": "skill:gcal"
|
||||
},
|
||||
"/stock": {
|
||||
"description": "Stock price lookup",
|
||||
"aliases": ["/quote", "/ticker"],
|
||||
"invokes": "skill:stock-lookup"
|
||||
},
|
||||
"/morning": {
|
||||
"description": "Generate morning report dashboard",
|
||||
"aliases": ["/briefing", "/daily"],
|
||||
"invokes": "skill:morning-report"
|
||||
},
|
||||
"/usage": {
|
||||
"description": "View usage statistics",
|
||||
"aliases": ["/stats"],
|
||||
@@ -184,6 +220,31 @@
|
||||
"description": "Check MCP integration status",
|
||||
"aliases": ["/mcp", "/integrations"],
|
||||
"invokes": "command:mcp-status"
|
||||
},
|
||||
"/workflow": {
|
||||
"description": "List and describe workflows",
|
||||
"aliases": ["/workflows", "/wf"],
|
||||
"invokes": "command:workflow"
|
||||
},
|
||||
"/skill-info": {
|
||||
"description": "Show skill information",
|
||||
"aliases": ["/skill", "/skills-info"],
|
||||
"invokes": "command:skill-info"
|
||||
},
|
||||
"/agent-info": {
|
||||
"description": "Show agent information",
|
||||
"aliases": ["/agent", "/agents"],
|
||||
"invokes": "command:agent-info"
|
||||
},
|
||||
"/diff": {
|
||||
"description": "Compare config with backup",
|
||||
"aliases": ["/config-diff", "/compare"],
|
||||
"invokes": "command:diff"
|
||||
},
|
||||
"/template": {
|
||||
"description": "Manage session templates",
|
||||
"aliases": ["/templates", "/session-template"],
|
||||
"invokes": "command:template"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
@@ -317,7 +378,13 @@
|
||||
"debug": "~/.claude/automation/debug.sh",
|
||||
"daily-maintenance": "~/.claude/automation/daily-maintenance.sh",
|
||||
"session-export": "~/.claude/automation/session-export.py",
|
||||
"mcp-status": "~/.claude/automation/mcp-status.sh"
|
||||
"mcp-status": "~/.claude/automation/mcp-status.sh",
|
||||
"upgrade": "~/.claude/automation/upgrade.sh",
|
||||
"workflow-info": "~/.claude/automation/workflow-info.py",
|
||||
"skill-info": "~/.claude/automation/skill-info.py",
|
||||
"agent-info": "~/.claude/automation/agent-info.py",
|
||||
"config-diff": "~/.claude/automation/config-diff.py",
|
||||
"session-template": "~/.claude/automation/session-template.py"
|
||||
},
|
||||
"completions": {
|
||||
"bash": "~/.claude/automation/completions.bash",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -154,6 +154,34 @@
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-02_11-35-50",
|
||||
"started": "2026-01-02T11:35:50-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-02_12-57-47",
|
||||
"started": "2026-01-02T12:57:47-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-03_00-41-54",
|
||||
"started": "2026-01-03T00:41:54-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
},
|
||||
{
|
||||
"id": "2026-01-03_01-07-49",
|
||||
"started": "2026-01-03T01:07:49-08:00",
|
||||
"ended": null,
|
||||
"summarized": false,
|
||||
"topics": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "daily-standup",
|
||||
"description": "Morning status check and planning",
|
||||
"category": "routine",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": [
|
||||
"/status"
|
||||
],
|
||||
"checklist": [
|
||||
"Check system health",
|
||||
"Review pending items",
|
||||
"Check calendar",
|
||||
"Plan priorities"
|
||||
],
|
||||
"prompt_template": "Good morning! Let's start with a quick status check and plan the day."
|
||||
}
|
||||
Reference in New Issue
Block a user