Compare commits

...

10 Commits

Author SHA1 Message Date
OpenCode Test 91fa0608d0 Update known_marketplaces.json lastUpdated timestamp 2026-01-03 13:53:43 -08:00
OpenCode Test e43e052a32 Add design plans for dashboard integration
Add implementation plans for morning report, Claude ops dashboard, and realtime monitoring features.
2026-01-03 10:55:22 -08:00
OpenCode Test 6ef58472cf Add morning reports and local configuration
Add daily morning reports and loop configuration for ralph agent.
2026-01-03 10:55:18 -08:00
OpenCode Test 48a1c9cd1d Update settings configuration
Configure Claude integration settings.
2026-01-03 10:55:13 -08:00
OpenCode Test 343d2e4237 Update component registry and system state
Register new skills and update future considerations for Claude dashboard integration.
2026-01-03 10:55:07 -08:00
OpenCode Test c21665284a Update plugin cache and installation state
Refresh plugin install counts and update installed plugins registry.
2026-01-03 10:55:01 -08:00
OpenCode Test daa4de8832 Add morning-report and stock-lookup skills
Add comprehensive morning report skill with collectors for calendar, email, tasks,
infrastructure status, news, stocks, and weather. Add stock lookup skill for quote queries.
2026-01-03 10:54:54 -08:00
OpenCode Test ae958528a6 Add Claude integration to dashboard
Add comprehensive Claude Code monitoring and realtime streaming to the K8s dashboard.
Includes API endpoints for health, stats, summary, inventory, and live event streaming.
Frontend provides overview, usage, inventory, debug, and live feed views.
2026-01-03 10:54:48 -08:00
OpenCode Test de89f3066c Add /diff and /template commands
- /diff command to compare config with backups
  - Shows added/removed/changed files
  - JSON-aware comparison for config files
  - List available backups
- /template command for session templates
  - Built-in templates: daily-standup, code-review, troubleshoot, deploy
  - Each template includes checklist, initial commands, prompt
  - Create custom templates interactively or non-interactively
- Updated shell completions with 21 aliases total
- Test suite now covers 29 tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:06:14 -08:00
OpenCode Test 4169f5b9a4 Add /workflow, /skill-info, and /agent-info commands
- /workflow command to list and describe available workflows
  - Filter by category (health, deploy, incidents, sysadmin)
  - Show workflow steps and triggers
- /skill-info command for skill introspection
  - List scripts, triggers, and allowed tools
  - Show references and documentation
- /agent-info command with hierarchy visualization
  - Tree view of agent relationships
  - Model assignments (opus/sonnet/haiku) with visual indicators
  - Supervisor and subordinate information
- Updated shell completions with 19 aliases total
- Test suite now covers 27 tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:02:42 -08:00
68 changed files with 6647 additions and 109 deletions
+25
View File
@@ -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.
+284
View File
@@ -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()
+43 -2
View File
@@ -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}"
+55 -2
View File
@@ -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}"
+294
View File
@@ -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())
+257
View File
@@ -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()
+225
View File
@@ -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()
+35
View File
@@ -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 ==="
+182
View File
@@ -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()
+5
View File
@@ -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 |
+36
View File
@@ -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 |
+34
View File
@@ -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.
+36
View File
@@ -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) |
+44
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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)
}
}
+31
View File
@@ -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)
+91
View File
@@ -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 {
+291 -1
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Actions
async function approveAction(id) {
try {
+162
View File
@@ -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())
}
}
+85
View File
@@ -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)
}
}
+19
View File
@@ -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"`
}
+11
View File
@@ -0,0 +1,11 @@
package claude
import "testing"
func TestEventTypesCompile(t *testing.T) {
_ = Event{}
_ = EventTypeHistoryAppend
_ = EventTypeFileChanged
_ = EventTypeServerNotice
_ = EventTypeServerError
}
+105
View File
@@ -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)
}
+100
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+33
View File
@@ -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"`
}
+9
View File
@@ -0,0 +1,9 @@
package claude
import "testing"
func TestModelTypesCompile(t *testing.T) {
_ = StatsCache{}
_ = DailyActivity{}
_ = ModelUsage{}
}
+24
View File
@@ -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
}
+34
View File
@@ -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)
}
}
+4
View File
@@ -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 (well 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 arent 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 12s).
**Tech Stack:** Go 1.21+, `chi`, vanilla HTML/CSS/JS, optional `fsnotify`.
---
## Decisions (locked)
- Transport: SSE first (WebSockets later).
- Acceptable latency: 25 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 25s):
- Every 500ms1s, 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?
+173
View File
@@ -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.
+139 -99
View File
@@ -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
}
]
+11
View File
@@ -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
}
]
}
}
+1 -1
View File
@@ -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": {
+35
View File
@@ -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*
+35
View File
@@ -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
View File
@@ -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"
+54
View File
@@ -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
```
+43
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
# Morning report collectors
+137
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"])
+216
View File
@@ -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())
+66
View File
@@ -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)
+175
View File
@@ -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()
+68 -1
View File
@@ -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."
}